Laravel 8.83.11で天気予報のWeb APIを作成しました。ソースコードは下記のgithubリポジトリで公開しています。
https://github.com/fukagai-takuya/weather-forecast
# 天気予報データは他のサイトからWeb APIで取得してデータベースに格納するようにしています。Laravelを使ってWeb APIを用意した簡単なプログラムになります。
# このブログページにはプログラムを作成した際に実行したコマンドとソースコードの内容について記載しました。プログラムの概要と動作確認方法はこちらのブログページに記載しました。
# 今回は、Routing、Controllers、Eventsの部分のコードを作成したときに使用したコマンドとコードの内容についてまとめました。残りはまた時間のあるときにまとめる予定です。
# 日時は全てUTCです。
1. Installation
Laravelプロジェクトをインストールする方法は複数ありますが、今回は下記のコマンドでインストールしました。下記のコマンドを実行すると、weather-forecastというディレクトリ名で新しいLaravelプロジェクトを作成することができます。
$ composer create-project laravel/laravel weather-forecast
2. Routing
WebブラウザやPostman等の外部のHTTPクライアントからアドレスを指定してWeb APIが呼ばれたときに実行されるメソッドを指定します。Laravelは routes/api.php または routes/web.php に各アドレスが指定されたときに呼ばれるメソッドを記述します。
今回作成するプログラムは、日時パラメータを指定してGETメソッドでWeb APIが呼ばれたときに、その日時の天気予報データをJSON形式で返すだけのプログラムです。このような用途では routes/api.php でRoutingを指定することができます。
routes/api.php の末尾に下記のようなコードを追加しました。
Route::get('/get-weather-forecast', [WeatherForecastInquiryController::class, 'getWeatherForecast']);
上記のように記述すると、Laravelをlocalhostのポート8000で起動し、下記のようなアドレスが指定されたときにクラス WeatherForecastInquiryController のメソッド getWeatherForecast が呼ばれます。
http://localhost:8000/api/get-weather-forecast
3. Controllers
routes/api.php または routes/web.php から呼ばれるリクエストハンドリングのコードを Controller クラスに記述します。
まず、下記のコマンドで WeatherForecastInquiryController という名前の Controller クラスのひな型を作成します。
$ php artisan make:controller WeatherForecastInquiryController
上記のコマンドを実行すると、下記の内容のファイル app/Http/Controllers/WeatherForecastInquiryController.php が作成されます。
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; class WeatherForecastInquiryController extends Controller { // }
このファイルを編集し、下記のようなコードを記述します。
<?php namespace App\Http\Controllers; use App\Events\WeatherForecastInquiryEvent; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use DateTime; class WeatherForecastInquiryController extends Controller { public function getWeatherForecast(Request $request) { $dt_txt = $request->date; if (!preg_match('/\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\z/', $dt_txt)) { $return_value = ['Result' => 'Failed', 'Error' => 'Format Error', 'Date' => $request->date]; return response()->json($return_value); } $dt = 0; try { $dt_obj = new DateTime($dt_txt); $dt = $dt_obj->getTimestamp(); } catch (\Exception $ex) { $dt = false; } if ($dt === false || $dt === 0) { $return_value = ['Result' => 'Failed', 'Error' => 'Incorrect Date', 'Date' => $request->date]; return response()->json($return_value); } $dt_one_and_a_half_hours_ago = $dt - 5400; $dt_one_and_a_half_hours_later = $dt + 5400; $weather_data = DB::table('weather_data') ->where('dt', '>=', $dt_one_and_a_half_hours_ago) ->where('dt', '<', $dt_one_and_a_half_hours_later)->get(); $weather_data_array = $weather_data->toArray(); if (empty($weather_data_array)) { WeatherForecastInquiryEvent::dispatch(); $weather_data = DB::table('weather_data') ->where('dt', '>=', $dt_one_and_a_half_hours_ago) ->where('dt', '<', $dt_one_and_a_half_hours_later)->get(); $weather_data_array = $weather_data->toArray(); } if (empty($weather_data_array)) { $return_value = ['Result' => 'Failed', 'Error' => 'No weather data was found for the specified date.', 'Date' => $request->date]; return response()->json($return_value); } $weather_response_result = ['Result' => 'Success']; $weather_response = $weather_data_array[0]; $weather_response = json_decode(json_encode($weather_response), true); $weather_response = array_merge($weather_response_result, $weather_response); return response()->json($weather_response); } }
メソッド getWeatherForecast(Request \$request) は、routes/api.php で指定したリクエストハンドリングのメソッドです。
http://localhost:8000/api/get-weather-forecast?date=2022-05-13 10:25:49
上記のようにリクエストパラメータ付きでWeb APIが呼ばれたとき、リクエストパラメータ date の値は下記のコードで参照することができます。上記のアドレスの例では下記のコードで \$dt_txt に 2022-05-13 10:25:49 が格納されます。
$dt_txt = $request->date;
このWeb APIが返すJSONリスポンスは下記のコードの例のように response()->json(\$return_value) の引数 \$return_value として与えています。
$return_value = ['Result' => 'Failed', 'Error' => 'Format Error', 'Date' => $request->date]; return response()->json($return_value);
メソッド getWeatherForecast(Request \$request) は、下記のような処理をしています。
- リクエストパラメータ date にセットされた文字列の内容を \$dt_txt に格納。
- \$dt_txt が所定のフォーマットを満たしているか確認。
- \$dt_txt をその日時を表すUnixタイムスタンプ (1970年1月1日午前0時0分0秒からの経過秒数) に変換し、\$dtに格納。このとき日時を表す数値が正しい範囲の値かを確認。
- \$dt の前後1時間半の範囲の時刻の秒数を計算。
- \$dt の前後1時間半の範囲の天気予報データをデータベーステーブル weather_data から取得。
- データベーステーブル weather_data に \$dt の前後1時間半の範囲の天気予報データがなければ WeatherForecastInquiryEvent::dispatch(); で外部のサイトから最新の天気予報データを取得。それでも該当する日時のデータが見つからなければエラーを返す。
- 該当する日時の天気予報データが見つかればそれをJSONリスポンスとして返す。
下記のような名前空間を使用するよう namespace で指定しているため、
namespace App\Http\Controllers;
グローバルネームスペースの Exception にアクセスする際、下記のコードのようにバックスラッシュを付けて \\Exception と記載してます。
try { $dt_obj = new DateTime($dt_txt); $dt = $dt_obj->getTimestamp(); } catch (\Exception $ex) { $dt = false; }
また、同じくグローバルネームスペースのクラス DateTime にアクセスするため、ファイルの上部に下記のように記述しています。
use DateTime;
上記のコードからは削除しましたが、下記のように記述すると ./storage/logs/laravel.log にデバッグ用のログが出力されます。
Log::debug('An informational message.');
4. Events
天気予報データの外部サイトからの取得はLaravelのEventとListenerで実装しました。
天気予報データ取得Eventは、3.のControllerの処理で該当する天気予報データがデータベースにないため最新のデータを問い合わせる際と、6時間に一回起動されるJobが最新のデータを取得する際に発行されます。
上記のタイミングで発行されるEventのクラス WeatherForecastInquiryEvent とそのイベントの通知を受けるクラス WeatherForecastInquiryNotification を app/Providers/EventServiceProvider.php に登録します。下記のコードのようにlistenプロパティに登録します。
下記のコードの例では WeatherForecastInquiryEvent のListenerは WeatherForecastInquiryNotification だけですが、一つのEventに対し複数のListenerを指定することもできます。また、複数のEventを登録することもできます。
これにより、登録されたEventが発行されたときに対応するListenerが起動されるようになります。
namespace App\Providers; use App\Events\WeatherForecastInquiryEvent; use App\Listeners\WeatherForecastInquiryNotification; ... class EventServiceProvider extends ServiceProvider { /** * The event listener mappings for the application. * * @var array<class-string, array<int, class-string>> */ protected $listen = [ WeatherForecastInquiryEvent::class => [ WeatherForecastInquiryNotification::class, ], ]; ... }
下記のコマンドで WeatherForecastInquiryEvent と WeatherForecastInquiryNotification のひな型のクラスファイルを生成します。
$ php artisan make:event WeatherForecastInquiryEvent $ php artisan make:listener WeatherForecastInquiryNotification --event=WeatherForecastInquiryEvent
下記の2つのファイルが生成されます。
- app/Events/WeatherForecastInquiryEvent.php
- app/Listeners/WeatherForecastInquiryNotification.php
生成された2つのファイルのうち WeatherForecastInquiryNotification.php のみ修正しました。
WeatherForecastInquiryNotification.php の修正後のコードを以下に記します。
<?php namespace App\Listeners; use App\Events\WeatherForecastInquiryEvent; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\Http; use Illuminate\Http\Client\Pool; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class WeatherForecastInquiryNotification { private const openweathermap_url = 'https://api.openweathermap.org/data/2.5/forecast'; private const openweathermap_appid = '199b75177d487aaadd4e634813b3b7ce'; private const city_data = [ ['40.730610', '-73.935242', 'new_york'], ['51.509865', '-0.118092', 'london'], ['48.864716', '2.349014', 'paris'], ['52.520008', '13.404954', 'berlin'], ['35.652832', '139.839478', 'tokyo'] ]; /** * Create the event listener. * * @return void */ public function __construct() { // } /** * Handle the event. * * @param \App\Events\WeatherForecastInquiryEvent $event * @return void */ public function handle(WeatherForecastInquiryEvent $event) { $weather_dt_cities_list = $this->getWeatherFromOpenWeatherMap(); $columns_to_be_updated = ['new_york_main', 'new_york_description', 'london_main', 'london_description', 'paris_main', 'paris_description', 'berlin_main', 'berlin_description', 'tokyo_main', 'tokyo_description']; DB::table('weather_data')->upsert($weather_dt_cities_list, ['dt'], $columns_to_be_updated); } private function getWeatherFromOpenWeatherMap() { $openweathermap_responses = Http::pool(fn (Pool $pool) => [ $pool->get(self::openweathermap_url, ['lat' => self::city_data[0][0], 'lon' => self::city_data[0][1], 'appid' => self::openweathermap_appid]), $pool->get(self::openweathermap_url, ['lat' => self::city_data[1][0], 'lon' => self::city_data[1][1], 'appid' => self::openweathermap_appid]), $pool->get(self::openweathermap_url, ['lat' => self::city_data[2][0], 'lon' => self::city_data[2][1], 'appid' => self::openweathermap_appid]), $pool->get(self::openweathermap_url, ['lat' => self::city_data[3][0], 'lon' => self::city_data[3][1], 'appid' => self::openweathermap_appid]), $pool->get(self::openweathermap_url, ['lat' => self::city_data[4][0], 'lon' => self::city_data[4][1], 'appid' => self::openweathermap_appid]), ]); $dt_array = []; $weather_dt_list = []; $num_city_data = count($openweathermap_responses); for ($i = 0; $i < $num_city_data; $i++) { $openweathermap_json = $openweathermap_responses[$i]->json(); $weather_dt_list[$i] = $this->getWeatherDtList($openweathermap_json, self::city_data[$i][2]); $dt_array = array_merge($dt_array, array_keys($weather_dt_list[$i])); } $dt_array = array_unique($dt_array); $weather_dt_cities_list = []; foreach ($dt_array as $dt) { $weather_dt = []; for ($i = 0; $i < $num_city_data; $i++) { $weather_contensts_map_with_dt = $weather_dt_list[$i][$dt]; if (isset($weather_contensts_map_with_dt)) { $weather_dt += $weather_contensts_map_with_dt; } } $weather_dt_cities_list[] = $weather_dt; } return $weather_dt_cities_list; } private function getWeatherDtList($openweathermap_json, $city) { $weather_list = $openweathermap_json['list']; $weather_dt_list = []; foreach ($weather_list as $weather_item) { $dt = $weather_item['dt']; $weather_dt_item['dt'] = $weather_item['dt']; $weather_dt_item['dt_txt'] = $weather_item['dt_txt']; $weather_contents = $weather_item['weather']; if (!empty($weather_contents) && !empty($weather_contents[0])) { if (isset($weather_contents[0]['main'])) { $weather_dt_item[$city . '_main'] = $weather_contents[0]['main']; } if (isset($weather_contents[0]['description'])) { $weather_dt_item[$city . '_description'] = $weather_contents[0]['description']; } } $weather_dt_list[$dt] = $weather_dt_item; } return $weather_dt_list; } }
WeatherForecastInquiryEvent が発行されると WeatherForecastInquiryNotification の handle メソッドが起動されます。
handle メソッドから getWeatherFromOpenWeatherMap(); を呼んで外部サイトからニューヨーク、ロンドン、パリ、ベルリン、東京の5都市の5日ほど先までの3時間ごとの天気予報データを取得します。
次に、下記のコードでデータベースに気象データを登録 (insert) あるいは更新 (update) します。
Laravelに用意されているupsertメソッドは、対応するデータがまだ登録されていなければ登録し、既に登録されていれば新しいデータで更新します。upsertメソッドの一つ目の引数は登録・更新するデータのリスト、第二引数はデータを識別するユニークなコラムの名前(複数のコラムの組み合わせでも可)、第三引数は第二引数の値が一致するレコードが存在した場合に値を更新するコラム名のリストです。
$columns_to_be_updated = ['new_york_main', 'new_york_description', 'london_main', 'london_description', 'paris_main', 'paris_description', 'berlin_main', 'berlin_description', 'tokyo_main', 'tokyo_description']; DB::table('weather_data')->upsert($weather_dt_cities_list, ['dt'], $columns_to_be_updated);
下記のテキストはupsertメソッドの一つ目の引数 \$weather_dt_cities_list をログに出力した例です。
array ( 0 => array ( 'dt' => 1652616000, 'dt_txt' => '2022-05-15 12:00:00', 'new_york_main' => 'Clouds', 'new_york_description' => 'overcast clouds', 'london_main' => 'Rain', 'london_description' => 'light rain', 'paris_main' => 'Clear', 'paris_description' => 'clear sky', 'berlin_main' => 'Clear', 'berlin_description' => 'clear sky', 'tokyo_main' => 'Rain', 'tokyo_description' => 'light rain', ), ... 39 => array ( 'dt' => 1653037200, 'dt_txt' => '2022-05-20 09:00:00', 'new_york_main' => 'Clouds', 'new_york_description' => 'overcast clouds', 'london_main' => 'Clouds', 'london_description' => 'overcast clouds', 'paris_main' => 'Rain', 'paris_description' => 'light rain', 'berlin_main' => 'Clouds', 'berlin_description' => 'scattered clouds', 'tokyo_main' => 'Clouds', 'tokyo_description' => 'overcast clouds', ), )
上記のログはインデックスが 0 から 39 までの40個の連想配列のリストになっています。一日8回3時間ごとのデータ5日分なため、40個の日時のデータになっています。下記のコラム名の40のレコードの値を格納したリストになります。日時のUnixタイムスタンプ dt はユニークで、dt の値が同じレコードが既に登録されていたら、第三引数 \$columns_to_be_updated で指定したコラム名のレコードデータが更新されます。dt が一致するレコードが登録されていなければ新たなレコードとして登録します。
dt, dt_txt, new_york_main, new_york_description, london_main, london_description, paris_main, paris_description, berlin_main, berlin_description, tokyo_main, tokyo_description
5都市の5日先までの天気予報データは緯度と経度を指定してWeb APIで外部サイトに問い合わせています。この処理には時間を要するため、 こちらに記載さているようにHttp::poolメソッドを使用して並列処理しています。
下記のコードはHttp::poolメソッドを用いて天気予報データを取得する箇所のコードになります。\$pool->get メソッドの引数として、クラス内でprivate constを指定して定義した外部サイトのアドレス、各都市の緯度と経度、この外部サイトのWeb APIで使用する appid を渡しています。
$openweathermap_responses = Http::pool(fn (Pool $pool) => [ $pool->get(self::openweathermap_url, ['lat' => self::city_data[0][0], 'lon' => self::city_data[0][1], 'appid' => self::openweathermap_appid]), $pool->get(self::openweathermap_url, ['lat' => self::city_data[1][0], 'lon' => self::city_data[1][1], 'appid' => self::openweathermap_appid]), $pool->get(self::openweathermap_url, ['lat' => self::city_data[2][0], 'lon' => self::city_data[2][1], 'appid' => self::openweathermap_appid]), $pool->get(self::openweathermap_url, ['lat' => self::city_data[3][0], 'lon' => self::city_data[3][1], 'appid' => self::openweathermap_appid]), $pool->get(self::openweathermap_url, ['lat' => self::city_data[4][0], 'lon' => self::city_data[4][1], 'appid' => self::openweathermap_appid]), ]);
外部サイトから受け取った5都市の天気予報データ(JSONリスポンス)は、upsertメソッドの第一引数に適したデータとなるよう都市名を付加してまとめています。dt の値を参照し、同じ日時の5都市のデータをまとめています。