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を用意した簡単なプログラムになります。

# このブログページにはプログラムを作成した際に実行したコマンドとソースコードの内容について記載しました。プログラムの概要と動作確認方法はこちらのブログページに記載しました。

# 今回は、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) は、下記のような処理をしています。

  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 で指定しているため、

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

返信を残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA