新米Webエンジニアのhiroさんです。
ツーリングでもおでかけでも天気の情報は欠かせません。
というわけで自分のアプリに天気予報APIから現在の天気を取得して画面に表示してみます。
要件
天気情報提供サービスの OpenWeatherMap の無料枠を使って実装します。
- 天気は画像で表現
- 画像を画面ヘッダのアプリケーション名の横に表示
- APIから取得した情報は都道府県単位に weather_forecasts テーブルに保持
- 前回取得した日時から10分が過ぎている場合はAPIから天気を再取得して weather_forecasts に保存
- 前回取得した日時から10分以内の場合は weather_forecasts テーブルから天気を取得
- 都道府県はユーザ情報から取得
- API Key はgem(dotenv-rails)を使って環境変数に格納・取得
- APIレスポンスの要素を条件に天気画像を表示(画像は いらすとや さんから)
こちらの情報を参考にレスポンス要素の中から ID, Main, Description を使って画像表示の条件を固める。上から順番に表示条件に合致した画像を表示する。
表示条件 | 画像(9種類) |
---|---|
Description: clear sky or few clouds |
|
Description: scattered clouds |
|
Description: broken clouds or overcast clouds |
|
ID:500~504 | |
ID:511~531 | |
Descriptionに"thunderstorm"と"rain"を含む | |
Descriptionに"thunderstorm"のみ含む | |
Main:Snow | |
Main:Drizzle | |
上記以外 | 表示なし |
ブラウザでAPIレスポンスが返ってくるかテスト
千葉県の情報を取得してみます。
https://api.openweathermap.org/data/2.5/weather?id=2113014&units=metric&appid=XX
データはデフォルトのJSON形式で返ってきました。問題ないようです。 このときの千葉県は曇りがち。
{ "coord": { "lon": 140.12, "lat": 35.61 }, "weather": [ { "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" } ], "base": "stations", "main": { "temp": 8.24, "feels_like": 4.36, "temp_min": 5.56, "temp_max": 10.56, "pressure": 1028, "humidity": 54 }, "visibility": 10000, "wind": { "speed": 2.6, "deg": 70 }, "clouds": { "all": 75 }, "dt": 1579953000, "sys": { "type": 1, "id": 7955, "country": "JP", "sunrise": 1579902283, "sunset": 1579939091 }, "timezone": 32400, "id": 2113014, "name": "Chiba", "cod": 200 }
実装
API Key を環境変数に格納
- OpenWeatherMap にユーザ登録して API Key を取得
gem 'dotenv-rails'
をインストール- applicationディレクトリトップに .env ファイルを作成
OPEN_WEATHER_API_KEY=XXXXXXX...
- rails console にて
ENV['OPEN_WEATHER_API_KEY']
で取得できること確認 - .gitignore に
/.env
を追加し、gitの追跡ファイルから除外
画像ファイルを保存
app/assets/images/weathers に画像ファイルを格納
clear_sky.png
cloud.png
drizzle.png
light_rain.png
rain.png
scattered_cloud.png
snow.png
thunderstorm.png
thunderstorm_rain.png
テーブル周り
- prefectures テーブルを新規作成
$ bundle exec rails g model prefecture
class CreatePrefectures < ActiveRecord::Migration[5.2] def change create_table :prefectures do |t| t.string :name, null: false, comment: "県名" t.integer "geonames_id", null: false, comment: "GeoNamesのid" t.timestamps end end end
- weather_forecasts テーブルを新規作成
$ bundle exec rails g model weather_forecast
class CreateWeatherForecasts < ActiveRecord::Migration[5.2] def change create_table :weather_forecasts do |t| t.references :prefecture, foreign_key: true, null: false t.integer :weather_id, null: false, comment: "天気の状態id" t.string :main, null: false, comment: "天気の状態" t.string :description, null: false, comment: "天気の詳細" t.datetime :acquired_at, null: false, comment: "APIで取得した日時" t.timestamps end end end
- user テーブルに prefecture_id を追加
$ bundle exec rails g migration AddPrefectureIdToUsers
class AddPrefectureIdToUsers < ActiveRecord::Migration[5.2] def up add_reference :users, :prefecture, foreign_key: true end def down remove_reference :users, :prefecture end end
マイグレーションを実行
$ bundle exec rails db:migrate
モデルの関連付けを宣言
class Prefecture < ApplicationRecord has_many :users has_many :weather_forecasts end class User < ApplicationRecord belongs_to :prefecture, optional: true end class WeatherForecast < ApplicationRecord belongs_to :prefecture end
- 初期データ(seed)を作成
とりあえず以下の1都2県を登録
Prefecture.create!( [ { name: '東京都', geonames_id: '1850147', }, { name: '高知県', geonames_id: '1859133', }, { name: '沖縄県', geonames_id: '1854345', }, ] )
user.prefecture_id に各県を設定
User.create!( [ { name: '太郎(管理者)', prefecture_id: '1', }, { name: '山田 太郎', prefecture_id: '2', }, { name: '鈴木 一郎', prefecture_id: '3', }, ] )
bundle exec rails db:reset
でエラー発生
rails aborted! ActiveRecord::InvalidForeignKey: PG::ForeignKeyViolation: ERROR: insert or update on table "users" violates foreign key constraint "fk_rails_15bb13b679" DETAIL: Key (prefecture_id)=(1) is not present in table "prefectures".
User よりあとに Prefecture を持ってきていたのが原因でした。 Userの前に Prefecture を入れて解決。
天気の状態を返すサービスオブジェクト(forecast.rb)を作成
他にもいろいろ実装しましたが、核となるこのクラスだけ記載します。 API通信失敗時のエラー処理は見にくくなるため省略しています。
工夫したポイント
- 表示条件の判定を各メソッドに切り出してわかりやすくした。
- 表示条件の判定メソッド呼び出しを部分を工夫した。#weather_condition
- Time.zone.now から forecast.acquired_at を引いて時間差を算出。
diff = Time.zone.now - forecast.acquired_at
APIドキュメントを参照しながら initialize メソッドでリクエストパラメータを設定。
'id': @prefecture.geonames_id,
はcity ID で県を指定しています。'units': 'metric'
は摂氏指定です。
app/services/weather/forecast.rb
module Weather class Forecast attr_accessor :prefecture, :data, :weather_id, :main, :description OPENWEATHERMAP_URL = 'https://api.openweathermap.org/data/2.5/weather' CONDITIONS = [ 'clear_sky', 'scattered_cloud', 'cloud', 'light_rain', 'rain', 'thunderstorm_rain', 'thunderstorm', 'snow', 'drizzle', ] USE_API_TIME_SEC = 600 def initialize(prefecture_id) @prefecture = Prefecture.find(prefecture_id) @data = { 'id': @prefecture.geonames_id, 'units': 'metric', 'appid': ENV['OPEN_WEATHER_API_KEY'], } end def weather_condition forecast = WeatherForecast.find_by(prefecture_id: @prefecture.id) if forecast.present? diff = Time.zone.now - forecast.acquired_at if diff.to_i >= USE_API_TIME_SEC forecast = weather_forecast_update(api_request()) end else forecast = weather_forecast_update(api_request()) end @weather_id = forecast.weather_id @main = forecast.main @description = forecast.description CONDITIONS.each_with_index do |condition, index| break condition if send("#{condition}?") break nil if CONDITIONS.length == index + 1 end end private def api_request query=@data.to_query uri = URI("#{OPENWEATHERMAP_URL}?" + query) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true response = Net::HTTP.get_response(uri) JSON.parse(response.body, symbolize_names: true) end def weather_forecast_update(res_data) weather = res_data[:weather][0] obj = { prefecture_id: @prefecture.id, weather_id: weather[:id].to_i, main: weather[:main], description: weather[:description], acquired_at: Time.zone.now, } WeatherForecast.find_or_initialize_by(prefecture_id: obj[:prefecture_id]).update_attributes(obj) WeatherForecast.find_by(prefecture_id: obj[:prefecture_id]) end def clear_sky? description == 'clear sky' || description == 'few clouds' end def scattered_cloud? description == 'scattered clouds' end def cloud? description == 'broken clouds' || description == 'overcast clouds' end def light_rain? (500..504).include?(weather_id) end def rain? (511..531).include?(weather_id) end def thunderstorm_rain? /thunderstorm/ === description && /rain/ === description end def thunderstorm? /thunderstorm/ === description && !(/rain/ === description) end def snow? main == 'Snow' end def drizzle? main == 'Drizzle' end end end
検証
画面に画像が表示されました。
- logを見てもAPIリクエストに成功しています。
INFO -- : HTTP_LOG:opening connection to api.openweathermap.org:443... INFO -- : HTTP_LOG: INFO -- : HTTP_LOG:opened INFO -- : HTTP_LOG: INFO -- : HTTP_LOG:starting SSL for api.openweathermap.org:443... INFO -- : HTTP_LOG: INFO -- : HTTP_LOG:SSL established, protocol: TLSv1.2, cipher: ECDHE-RSA-AES256-GCM-SHA384 INFO -- : HTTP_LOG: INFO -- : HTTP_LOG:<- INFO -- : HTTP_LOG:"GET /data/2.5/weather?appid=XXXXXXXXXXXXXX&id=1850147&units=metric HTTP/1.1\r\nAccept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\nAccept: */*\r\nUser-Agent: Ruby\r\nHost: api.openweathermap.org\r\n\r\n" INFO -- : HTTP_LOG: INFO -- : HTTP_LOG:-> "HTTP/1.1 200 OK\r\n" INFO -- : HTTP_LOG:-> "Server: openresty\r\n" INFO -- : HTTP_LOG:-> "Date: Sun, 26 Jan 2020 14:06:43 GMT\r\n" INFO -- : HTTP_LOG:-> "Content-Type: application/json; charset=utf-8\r\n" INFO -- : HTTP_LOG:-> "Content-Length: 446\r\n" INFO -- : HTTP_LOG:-> "Connection: keep-alive\r\n" INFO -- : HTTP_LOG:-> "X-Cache-Key: /data/2.5/weather?id=1850147&units=metric\r\n" INFO -- : HTTP_LOG:-> "Access-Control-Allow-Origin: *\r\n" INFO -- : HTTP_LOG:-> "Access-Control-Allow-Credentials: true\r\n" INFO -- : HTTP_LOG:-> "Access-Control-Allow-Methods: GET, POST\r\n" INFO -- : HTTP_LOG:-> "\r\n" INFO -- : HTTP_LOG:reading 446 bytes... INFO -- : HTTP_LOG:-> "{\"coord\":{\"lon\":139.69,\"lat\":35.69},\"weather\":[{\"id\":804,\"main\":\"Clouds\",\"description\":\"overcast clouds\",\"icon\":\"04n\"}],\"base\":\"stations\",\"main\":{\"temp\":4.15,\"feels_like\":1.13,\"temp_min\":0,\"temp_max\":7.78,\"pressure\":1025,\"humidity\":90},\"wind\":{\"speed\":2.08,\"deg\":354},\"clouds\":{\"all\":100},\"dt\":1580046764,\"sys\":{\"type\":3,\"id\":2001249,\"country\":\"JP\",\"sunrise\":1579988763,\"sunset\":1580025647},\"timezone\":32400,\"id\":1850147,\"name\":\"Tokyo\",\"cod\":200}" INFO -- : HTTP_LOG:read 446 bytes INFO -- : HTTP_LOG:Conn keep-alive INFO -- : HTTP_LOG:
- weather_forecasts テーブルに1件データが登録されています。
pry(main)> WeatherForecast.all => WeatherForecast Load (0.7ms) SELECT "weather_forecasts".* FROM "weather_forecasts" [#<WeatherForecast:0x0000555a4ce00d70 id: 9, prefecture_id: 1, weather_id: 804, main: "Clouds", description: "overcast clouds", acquired_at: Sun, 26 Jan 2020 21:11:46 JST +09:00, created_at: Sun, 26 Jan 2020 21:11:46 JST +09:00, updated_at: Sun, 26 Jan 2020 21:11:46 JST +09:00>]
- 10分待って再度画面を更新してみます。 APIリクエストが走り、WeatherForecast テーブルが更新されました。
pry(main)> WeatherForecast.all => WeatherForecast Load (0.7ms) SELECT "weather_forecasts".* FROM "weather_forecasts" [#<WeatherForecast:0x000055b59a546780 id: 9, prefecture_id: 1, weather_id: 804, main: "Clouds", description: "overcast clouds", acquired_at: Sun, 26 Jan 2020 21:23:04 JST +09:00, created_at: Sun, 26 Jan 2020 21:11:46 JST +09:00, updated_at: Sun, 26 Jan 2020 21:23:04 JST +09:00>]
- 10分以内に再度画面を更新するとAPIリクエストは走らず、WeatherForecast テーブルは更新されていません。問題ないようです。
[3] pry(main)> WeatherForecast.all => WeatherForecast Load (0.8ms) SELECT "weather_forecasts".* FROM "weather_forecasts" [#<WeatherForecast:0x000055b59a59e430 id: 9, prefecture_id: 1, weather_id: 804, main: "Clouds", description: "overcast clouds", acquired_at: Sun, 26 Jan 2020 21:23:04 JST +09:00, created_at: Sun, 26 Jan 2020 21:11:46 JST +09:00, updated_at: Sun, 26 Jan 2020 21:23:04 JST +09:00>
結果
このように天気の画像を表示できました👏
所感
今回はじめてAPIを叩いて処理するコードを実装しました。なかなか楽しいです😄
次はテストを書いていきたいです💪
調べてみると日本の快晴日は全国平均でも年間一ヶ月ほどしかありません。休日となるともっと少なくなります。快晴休日は予定を変更しておでかけしてみてはいかがでしょうか。