0
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-06-18

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

  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 を渡しています。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0