2024-01-27

Laravel 10 マイナーバージョンでの機能追加を振り返る

1月29日開催予定 PHPer Tea Night #15 - Laravel11直前回 で発表予定のLT資料、及びその解説記事。

LT資料

紹介した機能

クラスベースの追加バリデーション

追加バリデーションの記述方法が拡張された。

これまでは FormRequestwithValidator($validator) を実装しバリデータに対し $validator->after(function(){ ... }) でクロージャを登録する方法だった。

これはお世辞にも書きやすいと言えない。今回、新たに次のような書き方が可能となった。

  1. FormRequestに新設された after() を実装。
    • バリデーションに必要な依存をメソッドインジェクションで取得可能。
  2. after() からは追加バリデーションを行うオブジェクトを配列で返却。

追加バリデータはinvokableなオブジェクトなら何でも良い。多くの場合、invokable classとして実装を外部に切り出すことになるだろう。

この新たな書き方は、Laravelでのバリデーションの書き方に大きな秩序を与えられる可能性がある。

追加バリデーションの多くは、いわゆる「ドメインバリデーション」と呼ばれる、リクエストの責務を大きく超えた実装になる。withValidator()の中にドメイン知識が入り込んでくるため、この実装はとにかく肥大化しがちだ。

それを、単一責務でinvokableなクラスに切り出すことが可能となったのがこの新機能だ。

バリデーションクラスを簡潔に保ちつつ、コントローラーにはバリデーションを通過しないリクエストは決して渡さない、これらの両立が可能かもしれない。

ミドルウェアパラメータの直感的な構築

単独では新機能というほどの内容ではないが、大変便利な上、Laravel 11への布石となっている機能でもある。

これまで、ミドルウェアにパラメータを渡す場合は次のように文字列結合で書いていた。

1
2
3
4
5
6
7
Route::get(...)->middleware(
'password.confirm:password.confirm,3600'
// ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ ^^^^
// │ │ └── int: パスワード入力の有効期限
// │ └───────────── string: 入力ページのルート名
// └────────────────────────────── string: ミドルウェア名
)

今後は次のように書ける。

1
2
3
4
5
6
Route::get(...)->middleware(
RequirePassword::using(
redirectToRoute: 'password.confirm',
passwordTimeoutSeconds: 3600,
)
)

標準ミドルウェアの全てが、この書き方に対応した。

技術的にはさしたる変更ではない。それぞれのミドルウェアにユースケース毎にstatic methodを実装しただけだ。

シンプルな変更だが非常に嬉しい。boolを期待しているパラメータに 'false' という truthy な値を指定するミスは無くなるだろう。ミドルウェアに応じたパラメータの順序や型を調べる手間からも開放される。

ところで、Laravel 10を新規インストールすると、app/Http/Middleware/ 配下にフレームワークが提供するミドルウェアを継承したカスタマイズ用のミドルウェアが幾つか配置されていた。Laravel 11で、これらは全て廃止される。

従来は継承によりカスタマイズしていた振る舞いを、今後は、ルーティング指定時に対応メソッドを呼び出せすだけの簡潔な書き方が出来るようになる。

カスタマイズ可能なミドルウェアを自作で実装する際なども積極的に真似るべきだろう。

“Sleep” ヘルパ

PHP標準関数 sleep() やそれに類する機能のヘルパに加え、テスト時のモック機能やアサーションまでを担うヘルパが追加された。

リトライのテストを実装したい、だがテスト中に実際にスリープされるのは困る、対応として sleep() と同じ動作をするクラスを実装し、テストではそれをモックし…

幾度と無く書かされて来たこれらの実装が標準で提供され記法が統一されるのは、時短の意味でも、プロジェクト間のコードの統一の意味でも非常に有り難い。

SQLログでのバインドパラメータ展開

これまでのSQLログは、プレースホルダとその値を別々に出力することしか出来なかった。これはLaravelの制約ではなく、プリペアドステートメントを利用している以上フレームワークを問わず発生する制約だ。

今後のLaravelではその制約に悩む必要はない。getRawQueryLog()toRawSql() でプレースホルダが展開された状態でのSQLを取得できる。

ただし、全てのログを toRawSql() に置き換えて良いかは検討が必要かもしれない。

EXPLAIN によるデータベースのインデックスチューニングの際などは、SQL実行時に実際に評価される値を使った検証が有効だ。一方、バグ調査の際などはプレースホルダ展開前のログが必要になるかもしれない。

Laravelでは、プリペアドステートメントに対し StringableDataTimeInterface もバインドできる。例えば「データベースに入力されるべき値が想定と異なる」と言った状況のトラブルシュートでは、SQL展開前の生のオブジェクトを確認する必要が出てくるかもしれない。

大変便利な機能だが、変更は注意深く行う必要がある。

秒単位のスケジューラー

スケジューラーの時刻解像度が「分」から「秒」へ拡張された。

「分」という制約はcronの時刻解像度の限界に由来する。cronでは行えないことがLaravelでは行えるようになったわけだ。

Laravelでの実装だが、分単位で起動される各ジョブを1分間ループしている。それによる制約や注意事項がメンテナンスモードやデプロイなどで発生することがあるので、利用の際はマニュアルを精読すると良い。

config:show コマンド

config() に設定された値をartisanコマンドでコンソールに出力できる。

ある程度人間にも読みやすい形式で出力されるのだが、model:show 等と異なりjson形式には対応していない。また表示の際は appdb などトップレベルのキーを指定する必要がある点に注意。

artisanコマンドでの対話的プロンプト

artisanコマンドでの対話的プロンプトのAPIが大幅に改善され実装された。

既存コマンドのUXは非常に向上している。 make:controllermake:model をオプションなしで実行してみて欲しい。これまでよりも直感的かつ簡潔な入力が可能だ。

新設されたAPIの使い勝手も非常に良い。幾つかのヘルパーメソッドに配列を指定するだけで、ブラウザのフォーム入力に相当するコンソールUIが作れてしまう。詳細はPull Requestのご覧いただければと思う。

Eloquentのレースコンディション対応

レースコンディションの発生しうる状況下で、Eloquentの create()update() を安全に利用出来るようになった。

レースコンディションへの対応は lockForUpdate() 等で行っていたが、RDBのロックはすでに存在しているレコードへのロックであるため、create()firstOrCreate() など、レコード新規作成の際は使えない。

これらの制約によるレースコンディションの発生に対し、Laravel内部で適切なリトライ等を行うことで、Eloquentでも安全にcreate()処理が行えるようになった。

afterCommit 機能の拡張

データベーストランザクションと「それ以外の何らかの処理」とを連携させる機能は、多くの場合、注意深い実装が必要になる。

処理の対象がデータベースに閉じていれば「何らかエラーが発生した場合ロールバック」で全てを無かったことにできる。

だが、同時にメール送信や外部へのHTTPリクエストなども行っていた場合、ロールバックでそれらを取り消すことは当然できない。

これ対応するため、以前より トランザクションがcommitされた場合のみジョブをディスパッチする という機能が存在する。次のように設定する。

  • イベントリスナの場合: $after_commit オプションを設定する。
  • ジョブの場合: config: queue.connections.*.after_commit を設定する。
  • 何れの場合も:非同期キューワーカが設定されている場合のみ動作する。

設定方法に一貫性が無い上に暗黙の動作要件が存在する。非常に間違いやすく、問題となるのも「トランザクションが失敗した場合」に限られるため、設定ミスに気づかないことも多いだろう。

今回新たに、より一貫性のある次のような設定方法が提供された。

1
2
3
interface ShouldDispatchAfterCommit {}
interface ShouldHandleEventsAfterCommit {}
interface ShouldQueueAfterCommit {}

要求メソッドは無いので、単にでラベル付けすれば良い。上から順に、イベント、イベントリスナ、ジョブに対しimplements すれば、従来の$after_commitと同じ動作を得られる。

バッチジョブの動的なディスパッチ

ジョブチェーンにネストされたジョブバッチへジョブを動的にディスパッチ出来るようになった。

用語(機能)の解説

  • ジョブ: queue:work が処理するジョブの単位。
  • ジョブチェーン: Bus::chain([...]) で定義されたジョブの集合。
    • 直列で実行される。
  • ジョブバッチ: Bus::batch([...]) で定義されたジョブの集合。
    • キューワーカーの起動数に応じて並列で実行可能。
    • ディスパッチされたジョブの総数や完了数、進捗状況を取得可能。
    • ジョブを動的に追加可能。

これまで「ジョブチェーンにネストされたジョブバッチへ動的にジョブをディスパッチ」というユースケースに制約があった。ネスト内のジョブバッチが全て終了した際に、終了処理として後続のジョブチェーンを実行、という処理を行えない。

JobChain 1Chain 2Batch 11個目Batch 22個目Batch 33個目Batch N個数が不明Chain 3処理開始・Chain 1を起動処理続行: Batchを起動不可: 前段の処理の長さが不明のため

この図の最後のチェーンが可能となった。