Laravel 8.83.11で天気予報のWeb APIを作成しました。ソースコードは下記のgithubリポジトリで公開しています。
-
天気予報データは他のサイトから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 を渡しています。
下記のコードは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都市のデータをまとめています。