Laravel 8.xで天気予報のWeb APIを作成 (2)

Laravel 8.83.11で天気予報のWeb APIを作成しました。ソースコードは下記のgithubリポジトリで公開しています。

https://github.com/fukagai-takuya/weather-forecast

- 天気予報データは他のサイトからWeb APIで取得してデータベースに格納するようにしています。Laravelを使ってWeb APIを用意した簡単なプログラムになります。

- この投稿にはプログラムを作成した際に実行したコマンドとソースコードの内容を記載しました。プログラムの概要と動作確認方法は下記の投稿に記載しました。

https://qiita.com/fukagai-takuya/items/9049712734cc1fdb04e9

- 今回は、Routing、Controllers、Eventsの部分のコードを作成したときに使用したコマンドとコードの内容についてまとめました。残りは下記の投稿に記載しました。

https://qiita.com/fukagai-takuya/items/e60b0aeb3a44a44c95ba

- 日時は全てUTCです。

# 1. [Installation](https://laravel.com/docs/9.x/installation)
Laravelプロジェクトをインストールする方法は複数ありますが、今回は下記のコマンドでインストールしました。下記のコマンドを実行すると、weather-forecastというディレクトリ名で新しいLaravelプロジェクトを作成することができます。

```bash
$ composer create-project laravel/laravel weather-forecast
```

# 2. [Routing](https://laravel.com/docs/9.x/routing)
WebブラウザやPostman等の外部のHTTPクライアントからアドレスを指定してWeb APIが呼ばれたときに実行されるメソッドを指定します。Laravelは routes/api.php または routes/web.php に各アドレスが指定されたときに呼ばれるメソッドを記述します。

今回作成するプログラムは、日時パラメータを指定してGETメソッドでWeb APIが呼ばれたときに、その日時の天気予報データをJSON形式で返すだけのプログラムです。このような用途では routes/api.php でRoutingを指定することができます。

routes/api.php の末尾に下記のようなコードを追加しました。

```php
Route::get('/get-weather-forecast', [WeatherForecastInquiryController::class, 'getWeatherForecast']);
```

上記のように記述すると、Laravelをlocalhostのポート8000で起動し、下記のようなアドレスが指定されたときにクラス WeatherForecastInquiryController のメソッド getWeatherForecast が呼ばれます。

```
http://localhost:8000/api/get-weather-forecast
```

# 3. [Controllers](https://laravel.com/docs/9.x/controllers)
routes/api.php または routes/web.php から呼ばれるリクエストハンドリングのコードを Controller クラスに記述します。

まず、下記のコマンドで WeatherForecastInquiryController という名前の Controller クラスのひな型を作成します。

```
$ php artisan make:controller WeatherForecastInquiryController
```

上記のコマンドを実行すると、下記の内容のファイル app/Http/Controllers/WeatherForecastInquiryController.php が作成されます。

```php
<?php
 
namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
 
class WeatherForecastInquiryController extends Controller
{
    //
}
```

このファイルを編集し、下記のようなコードを記述します。

```php
<?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 が格納されます。

```php
$dt_txt = $request->date;
```

このWeb APIが返すJSONリスポンスは下記のコードの例のように `response()->json($return_value)` の引数 \\$return_value として与えています。

```php
$return_value = ['Result' => 'Failed', 'Error' => 'Format Error', 'Date' => $request->date];
return response()->json($return_value);
```

メソッド `getWeatherForecast(Request $request)` は、下記のような処理をしています。

1. リクエストパラメータ date にセットされた文字列の内容を \\$dt_txt に格納。
2. \\$dt_txt が所定のフォーマットを満たしているか確認。
3. \\$dt_txt をその日時を表すUnixタイムスタンプ (1970年1月1日午前0時0分0秒からの経過秒数) に変換し、\\$dt に格納。このとき日時を表す数値が正しい範囲の値かを確認。
4. \\$dt の前後1時間半の範囲の時刻の秒数を計算。
5. \\$dt の前後1時間半の範囲の天気予報データをデータベーステーブル weather_data から取得。
6. データベーステーブル weather_data に \\$dt の前後1時間半の範囲の天気予報データがなければ `WeatherForecastInquiryEvent::dispatch();` で外部のサイトから最新の天気予報データを取得。それでも該当する日時のデータが見つからなければエラーを返す。
7. 該当する日時の天気予報データが見つかればそれをJSONリスポンスとして返す。

下記のような名前空間を使用するよう namespace で指定しているため、

```php
namespace App\Http\Controllers;
```

グローバルネームスペースの Exception にアクセスする際、下記のコードのようにバックスラッシュを付けて \Exception と書いています。

```php
try {
    $dt_obj = new DateTime($dt_txt);
    $dt = $dt_obj->getTimestamp();
} catch (\Exception $ex) {
    $dt = false;
}
```

また、同じくグローバルネームスペースのクラス DateTime にアクセスするため、ファイルの上部に下記のように記述しています。

```php
use DateTime;
```

上記のコードからは削除しましたが、下記のように記述すると ./storage/logs/laravel.log にデバッグ用のログが出力されます。

```php
Log::debug('An informational message.');
```

# 4. [Events](https://laravel.com/docs/9.x/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が起動されるようになります。

```php
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
<?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](https://laravel.com/docs/9.x/queries#upserts)メソッドは、対応するデータがまだ登録されていなければ登録し、既に登録されていれば新しいデータで更新します。upsertメソッドの第一引数は登録・更新するデータのリスト、第二引数はデータを識別するユニークなコラムの名前(複数のコラムの組み合わせでも可)、第三引数は第二引数の値が一致するレコードが存在した場合に値を更新するコラム名のリストです。

```php
$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 をログに出力した例です。

```php
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で外部サイトに問い合わせています。この処理には時間を要するため、 [こちら](https://laravel.com/docs/9.x/http-client#concurrent-requests)に記載さているようにHttp::poolメソッドを使用して並列処理しています。

下記のコードはHttp::poolメソッドを用いて天気予報データを取得する箇所のコードになります。\\$pool->get メソッドの引数として、クラス内でprivate constを指定して定義した外部サイトのアドレス、各都市の緯度と経度、この外部サイトのWeb APIで使用する appid を渡しています。

下記のコードはHttp::poolメソッドを用いて天気予報データを取得する箇所のコードになります。\\$pool->get メソッドの引数として、クラス内でprivate constを指定して定義した外部サイトのアドレス、各都市の緯度と経度、この外部サイトのWeb APIで使用する appid を渡しています。

```php
$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都市のデータをまとめています。