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

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

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

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

# プログラムの概要と動作確認方法はこちらのブログページに記載しました。

# このブログページにはプログラムを作成した際に実行したコマンドとソースコードの内容について記載しました。こちらのブログページに記載した内容の続きになります。

# 日時は全てUTCです。

1. Database

Laravelは現時点で下記のデータベースをサポートしています。

  • MariaDB 10.2+
  • MySQL 5.7+
  • PostgreSQL 10.0+
  • SQLite 3.8.8+
  • SQL Server 2017+

このプログラムの開発ではSQLiteを使用しました。

1.1. SQLite利用環境の準備
下記のコマンドでSQLiteとphpのSQLite接続用ドライバーをインストールし、

$ sudo apt install sqlite3
$ sudo apt-get install php7.4-sqlite3

下記のコマンドで空のデータベースファイルを用意しました。

$ touch database/database.sqlite
補足:
データベースには下記のコマンドでアクセスできます。

$ sqlite3 database/database.sqlite

次に .env ファイルのDBに関する設定を下記のように修正しました。

DB_CONNECTION=sqlite
#DB_HOST=127.0.0.1
#DB_PORT=3306
#DB_DATABASE=laravel
#DB_USERNAME=root
#DB_PASSWORD=

1.2. Database: Migrations

天気予報データ格納用のデータベーステーブル weather_data を作成します。

1.2.1. 下記のコマンドでデータベーステーブル weather_data を作成するクラスのひな型を用意します。

php artisan make:migration weather_data

下記のようなファイル database/migrations/2022_05_06_013400_weather_data.php が出力されます。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class WeatherData extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        //
    }

    ...
}

上記のファイルを下記のように修正します。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class WeatherData extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('weather_data', function (Blueprint $table) {
            $table->id()->autoIncrement();
            $table->integer('dt')->unique();
            $table->string('dt_txt', 19)->unique();
            $table->string('new_york_main', 255)->nullable();
            $table->string('new_york_description', 255)->nullable();
            $table->string('london_main', 255)->nullable();
            $table->string('london_description', 255)->nullable();
            $table->string('paris_main', 255)->nullable();
            $table->string('paris_description', 255)->nullable();
            $table->string('berlin_main', 255)->nullable();
            $table->string('berlin_description', 255)->nullable();
            $table->string('tokyo_main', 255)->nullable();
            $table->string('tokyo_description', 255)->nullable();
            $table->timestamp('updated_at')->useCurrent();
            $table->timestamp('created_at')->useCurrent();
        });

        DB::unprepared('CREATE TRIGGER weather_data_updated_at AFTER UPDATE ON weather_data
            BEGIN
                UPDATE weather_data SET updated_at = CURRENT_TIMESTAMP WHERE rowid == NEW.rowid;
            END;');
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        //
    }
}

作成する weather_data テーブルはコラム名が id, dt, dt_txt, new_york_main, …, tokyo_description, updated_at, created_at のテーブルになります。

こちらに記載されているようなコラム修飾子(Column Modifiers)で登録されるデータを修飾しています。

  • idはレコードが登録されると一つずつ順に割り当てられるID番号です。autoIncrement()メソッドで1ずつ増加する整数を割り当てるようにしています。
  • dtは天気予報データの日時のUnixタイムスタンプを格納する整数型のコラムです。同じ日時のレコードは一つにするため、unique()メソッドで一意なデータとなるよう指定しています。
  • dt_txtはdtに対応する日時を 2022-05-13 10:25:49 のような半角19文字のテキスト文字列として格納するためのコラムです。同じ日時のレコードは一つにするため、こちらも一意なデータとなるよう指定しています。
  • new_york_main, …, tokyo_description には Rain, moderate rain のような天気を表す英語の文字列を格納します。最大255文字の文字列が格納されるように指定しています。
  • updated_atにはデータ更新日時、created_atにはデータ登録日時を格納します。datetime型のデータ型を指定しています。useCurrent()でデータを登録(insert)した時刻を初期値とするようにしています。
  • updated_atにはuseCurrentOnUpdate()を指定してデータ更新(update)時に更新日時で更新するようにしようとしましたが、SQLiteでは使えないようでした。そこで下記のようなコードを追加して対応するようにしました。
        DB::unprepared('CREATE TRIGGER weather_data_updated_at AFTER UPDATE ON weather_data
            BEGIN
                UPDATE weather_data SET updated_at = CURRENT_TIMESTAMP WHERE rowid == NEW.rowid;
            END;');

下記のコマンドを実行してデータベーステーブルを作成します。

$ php artisan migrate

上記のコマンドを実行すると weather_data テーブルがデータベース上に作成されます。

1.2.2. 作成されたデータベーステーブルの内容はSQLiteの場合、下記のコマンドで確認できます。

$ sqlite3 database/database.sqlite
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .schema weather_data

下記のように出力されます。

CREATE TABLE IF NOT EXISTS "weather_data" ("id" integer not null primary key autoincrement, "dt" integer not null, "dt_txt" varchar not null, "new_york_main" varchar, "new_york_description" varchar, "london_main" varchar, "london_description" varchar, "paris_main" varchar, "paris_description" varchar, "berlin_main" varchar, "berlin_description" varchar, "tokyo_main" varchar, "tokyo_description" varchar, "updated_at" datetime default CURRENT_TIMESTAMP not null, "created_at" datetime default CURRENT_TIMESTAMP not null);
CREATE UNIQUE INDEX "weather_data_dt_unique" on "weather_data" ("dt");
CREATE UNIQUE INDEX "weather_data_dt_txt_unique" on "weather_data" ("dt_txt");
CREATE TRIGGER weather_data_updated_at AFTER UPDATE ON weather_data
            BEGIN
                UPDATE weather_data SET updated_at = CURRENT_TIMESTAMP WHERE rowid == NEW.rowid;
            END;

2. Queues

2.1. Jobをキューに入れて実行する処理を作成します。

# キューに入れたJobはこちらのページの5.3.に記載したようにSupervisorを起動して実行します。

こちらに記載されているように下記のコマンドでJobクラスのひな型を作成します。

$ php artisan make:job WeatherForecastInquiryJob

下記のようなファイル app/Jobs/WeatherForecastInquiryJob.php が生成されます。

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class WeatherForecastInquiryJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        //
    }
}

上記のコードのhandle()メソッドにWeatherForecastInquiryEventを発行する下記のようなコードを追加しました。

WeatherForecastInquiryJobがキューから取り出されて処理されると、WeatherForecastInquiryEventが発行され、外部サイトから天気予報データを取得します。

...
use App\Events\WeatherForecastInquiryEvent;

class WeatherForecastInquiryJob implements ShouldQueue
{
    ...

    public function handle()
    {
        WeatherForecastInquiryEvent::dispatch();
    }
}

2.2. データベーステーブルをキューとして使用するようにします。

こちらに記載されている手順で実装しました。

下記のコマンドでキューを格納するデータベーステーブルを用意します。

$ php artisan queue:table

下記の内容のファイル database/migrations/2022_05_05_074622_create_jobs_table.php が生成されます。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateJobsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('jobs', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('queue')->index();
            $table->longText('payload');
            $table->unsignedTinyInteger('attempts');
            $table->unsignedInteger('reserved_at')->nullable();
            $table->unsignedInteger('available_at');
            $table->unsignedInteger('created_at');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('jobs');
    }
}

下記のコマンドでキューを格納するデータベーステーブルjobsを作成します。

$ php artisan migrate

下記のように.envファイルのQUEUE_CONNECTIONでdatabaseを指定します。

QUEUE_CONNECTION=database
補足2:
下記のように.envファイルのQUEUE_CONNECTIONをsyncのままにしておくとJobはキューに格納されることなくすぐに実行されます。

QUEUE_CONNECTION=sync

3. Task Scheduling

6時間間隔でWeatherForecastInquiryJobを起動するため、app/Console/Kernel.phpを下記のように書き換えました。

<?php

namespace App\Console;
use App\Jobs\WeatherForecastInquiryJob;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    /**
     * Define the application's command schedule.
     *
     * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
     * @return void
     */
    protected function schedule(Schedule $schedule)
    {
        $schedule->job(new WeatherForecastInquiryJob)->everySixHours();
    }

    ...
}

実際に指定した時間間隔でTask Schedulerを実行するには、こちらに記載されているように php artisan schedule:run コマンドを1分間隔で実行するようcrontabを設定します。下記のようなcrontabの設定を一つだけ登録しておけば、app/Console/Kernel.phpに記載した内容にしたがって複数の時間間隔で複数のタスクを実行することができます。

* * * * * cd /path-to-weather-forecast-project && php artisan schedule:run >> /dev/null 2>&1

4. Jobのキューへの登録と実行の確認

4.1. Jobのキューへの登録と実行の確認のため、app/Console/Kernel.phpのscheduleメソッドを下記のように修正します。

    protected function schedule(Schedule $schedule)
    {
        // $schedule->job(new WeatherForecastInquiryJob)->everySixHours();
        $schedule->job(new WeatherForecastInquiryJob)->everyMinute();
    }

4.2. Jobのキューへの登録の確認

下記のコマンドを実行し、jobsテーブルの中身を確認します。最初は何も登録されていないため下記のように出力されます。

$ sqlite3 database/database.sqlite
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> select * from jobs;
sqlite>

下記のように php artisan schedule:run を3回実行した後でjobsテーブルの中身を確認します。

$ php artisan schedule:run
[2022-05-22T08:49:44+00:00] Running scheduled command: App\Jobs\WeatherForecastInquiryJob
$ php artisan schedule:run
[2022-05-22T08:49:45+00:00] Running scheduled command: App\Jobs\WeatherForecastInquiryJob
$ php artisan schedule:run
[2022-05-22T08:49:46+00:00] Running scheduled command: App\Jobs\WeatherForecastInquiryJob

下記のコマンドでjobsテーブルの中身を確認すると登録された3つのJobが表示されます。

$ sqlite3 database/database.sqlite
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> select * from jobs;
18|default|{"uuid":"978dbb48-9095-46ec-9865-05e38416197b","displayName":"App\\Jobs\\WeatherForecastInquiryJob","job":"Illuminate\\Queue\\CallQueuedHandler@call","maxTries":null,"maxExceptions":null,"failOnTimeout":false,"backoff":null,"timeout":null,"retryUntil":null,"data":{"commandName":"App\\Jobs\\WeatherForecastInquiryJob","command":"O:34:\"App\\Jobs\\WeatherForecastInquiryJob\":10:{s:3:\"job\";N;s:10:\"connection\";N;s:5:\"queue\";N;s:15:\"chainConnection\";N;s:10:\"chainQueue\";N;s:19:\"chainCatchCallbacks\";N;s:5:\"delay\";N;s:11:\"afterCommit\";N;s:10:\"middleware\";a:0:{}s:7:\"chained\";a:0:{}}"}}|0||1653209384|1653209384
19|default|{"uuid":"17851f0d-9ba3-4d3a-8dca-1045364aff16","displayName":"App\\Jobs\\WeatherForecastInquiryJob","job":"Illuminate\\Queue\\CallQueuedHandler@call","maxTries":null,"maxExceptions":null,"failOnTimeout":false,"backoff":null,"timeout":null,"retryUntil":null,"data":{"commandName":"App\\Jobs\\WeatherForecastInquiryJob","command":"O:34:\"App\\Jobs\\WeatherForecastInquiryJob\":10:{s:3:\"job\";N;s:10:\"connection\";N;s:5:\"queue\";N;s:15:\"chainConnection\";N;s:10:\"chainQueue\";N;s:19:\"chainCatchCallbacks\";N;s:5:\"delay\";N;s:11:\"afterCommit\";N;s:10:\"middleware\";a:0:{}s:7:\"chained\";a:0:{}}"}}|0||1653209385|1653209385
20|default|{"uuid":"85d7c4fc-e5e1-4007-ad20-1be01071a7da","displayName":"App\\Jobs\\WeatherForecastInquiryJob","job":"Illuminate\\Queue\\CallQueuedHandler@call","maxTries":null,"maxExceptions":null,"failOnTimeout":false,"backoff":null,"timeout":null,"retryUntil":null,"data":{"commandName":"App\\Jobs\\WeatherForecastInquiryJob","command":"O:34:\"App\\Jobs\\WeatherForecastInquiryJob\":10:{s:3:\"job\";N;s:10:\"connection\";N;s:5:\"queue\";N;s:15:\"chainConnection\";N;s:10:\"chainQueue\";N;s:19:\"chainCatchCallbacks\";N;s:5:\"delay\";N;s:11:\"afterCommit\";N;s:10:\"middleware\";a:0:{}s:7:\"chained\";a:0:{}}"}}|0||1653209386|1653209386

下記のコマンドでweather_dataテーブルに登録されたデータを表示します。最後に登録されたデータの最終更新日時は 2022-05-22 08:48:54 です。

sqlite> select id, updated_at from weather_data;
...
680|2022-05-22 08:48:54

php artisan queue:work コマンドを実行し、jobsテーブルに登録されたJobを実行します。Jobをキューから取り出して実行する処理が3回実行されます。各Jobは外部サイトから天気予報データを取得し、同じ日時の天気予報データを更新します。

$ php artisan queue:work
[2022-05-22 09:02:50][18] Processing: App\Jobs\WeatherForecastInquiryJob
[2022-05-22 09:02:51][18] Processed:  App\Jobs\WeatherForecastInquiryJob
[2022-05-22 09:02:51][19] Processing: App\Jobs\WeatherForecastInquiryJob
[2022-05-22 09:02:52][19] Processed:  App\Jobs\WeatherForecastInquiryJob
[2022-05-22 09:02:52][20] Processing: App\Jobs\WeatherForecastInquiryJob
[2022-05-22 09:02:53][20] Processed:  App\Jobs\WeatherForecastInquiryJob

下記のコマンドでjobsテーブルの中身を表示すると何も表示されません。先ほどキューに登録したJobが全てキューから取り出されたためです。

次に、weather_dataに最後に登録されたidが680の天気予報データの最終更新日時を確認しました。キューから取り出されて最後に実行が完了したJobの実行完了日時 2022-05-22 09:02:53 に更新されていました。

$ sqlite3 database/database.sqlite
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> select * from jobs;
sqlite> select id, updated_at from weather_data where id = '680';
680|2022-05-22 09:02:53

4.3. Jobをキューに登録しない場合の確認

補足2に記載したように、.envファイルのQUEUE_CONNECTIONでsyncを指定するとJobはキューに格納されることなくすぐに実行されます。

QUEUE_CONNECTION=sync

php artisan schedule:run を実行します。

$ php artisan schedule:run
[2022-05-22T09:21:17+00:00] Running scheduled command: App\Jobs\WeatherForecastInquiryJob

下記のコマンドでデータベースの中身を確認します。先ほどと異なり、php artisan queue:work コマンドを実行する前でもjobsテーブルの中身は空です。

次に、weather_dataに最後に登録されたidが680の天気予報データの最終更新日時を確認しました。php artisan schedule:run コマンドを実行した際にコンソールに出力された日時 2022-05-22 09:21:17 となっています。

$ sqlite3 database/database.sqlite
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> select * from jobs;
sqlite> select id, updated_at from weather_data where id = '680';
680|2022-05-22 09:21:17

5. Testing with PHPUnit

PHPUnitを使用したLaravelプロジェクトのテストを試しました。

5.1. phpunit.xmlを下記のように修正しました。

修正箇所だけ記載します。

修正前

<!-- <server name="DB_CONNECTION" value="sqlite"/> -->
<!-- <server name="DB_DATABASE" value=":memory:"/> -->
<server name="QUEUE_CONNECTION" value="sync"/>

修正後

<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="QUEUE_CONNECTION" value="database"/>

5.2. 下記のコマンドでFeatureテストのクラスのひな型を作成します。

$ php artisan make:test WeatherForecastInquiryTest

下記の内容のファイル tests/Feature/WeatherForecastInquiryTest.php が生成されます。

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class WeatherForecastInquiryTest extends TestCase
{
    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function test_example()
    {
        $response = $this->get('/');

        $response->assertStatus(200);
    }
}

tests/Feature/WeatherForecastInquiryTest.php を下記のように修正しました。
LaravelのドキュメントHTTP TestsConsole Testsを参考にした簡単なFeatureテストの例になっています。

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Schema;

use Tests\TestCase;

use DateTime;
use DateTimeZone;


class WeatherForecastInquiryTest extends TestCase
{
    use RefreshDatabase;

    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function test_example()
    {
        $response = $this->get('/');
        $response->assertStatus(200);
    }


    public function testGetWeatherForecast(): void
    {
        $response = $this->get('/api/get-weather-forecast');
        $response->assertStatus(200);
    }


    public function testGetWeatherForecastEmptyInput(): void
    {
        $response = $this->getJson('/api/get-weather-forecast');
        $response->assertStatus(200)
            ->assertJson([
                'Result' => 'Failed',
                'Error' => 'Format Error',
                'Date' => ''
            ]);
    }


    public function testGetWeatherForecastFormatError(): void
    {
        $date = '1234567890';
        $response = $this->getJson('/api/get-weather-forecast?date=' . $date);
        $response->assertStatus(200)
            ->assertJson([
                'Result' => 'Failed',
                'Error' => 'Format Error',
                'Date' => $date
            ]);
    }


    public function testGetWeatherForecastIncorrectDate(): void
    {
        $date = '2022-99-99 03:00:00';
        $response = $this->getJson('/api/get-weather-forecast?date=' . $date);
        $response->assertStatus(200)
            ->assertJson([
                'Result' => 'Failed',
                'Error' => 'Incorrect Date',
                'Date' => $date
            ]);
    }


    public function testGetWeatherForecastDataWillNotBeFound(): void
    {
        $date = '9999-05-02 03:00:00';
        $response = $this->getJson('/api/get-weather-forecast?date=' . $date);
        $response->assertStatus(200)
            ->assertJson([
                'Result' => 'Failed',
                'Error' => 'No weather data was found for the specified date.',
                'Date' => $date
            ]);
    }


    public function testGetWeatherForecastDataWillBeFound(): void
    {
        $now = new DateTime("now", new DateTimeZone('UTC'));
        $tomorrow = $now->modify("+1 day");
        $date = $tomorrow->format("Y-m-d H:i:s");

        $response = $this->getJson('/api/get-weather-forecast?date=' . $date);
        $response->assertStatus(200)
            ->assertJsonStructure([
                'Result',
                'id',
                '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',
                'created_at',
                'updated_at',
            ]);
    }


    public function testWeatherDataTableColumns()
    {
        $this->assertTrue(
            Schema::hasColumns('weather_data', [
                'id',
                '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',
                'created_at',
                'updated_at',
            ]), 1
        );
    }


    public function testArtisanSheduleRunCommand()
    {
        $this->artisan('schedule:run')->assertExitCode(0);
    }


    public function testArtisanMigrateFreshCommand()
    {
        $this->artisan('migrate:fresh')->assertExitCode(0);
    }
}

5.3. テストコマンドの実行

下記のコマンドを入力するとFeatureテストが実行されます。

$ php artisan test

全てのテストにパスするとコンソールに下記のようなメッセージが出力されます。

   PASS  Tests\Unit\ExampleTest
  ✓ example

   PASS  Tests\Feature\ExampleTest
  ✓ example

   PASS  Tests\Feature\WeatherForecastInquiryTest
  ✓ example
  ✓ get weather forecast
  ✓ get weather forecast empty input
  ✓ get weather forecast format error
  ✓ get weather forecast incorrect date
  ✓ get weather forecast data will not be found
  ✓ get weather forecast data will be found
  ✓ weather data table columns
  ✓ artisan shedule run command
  ✓ artisan migrate fresh command

  Tests:  12 passed
  Time:   2.96s

返信を残す

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

CAPTCHA