v13.36.0 のコンテナとキューワーカー、およびOctaneの実装を確認し、サービスコンテナの基本的な登録方法をパッケージの長期保守に結び付けます。scoped() を選ぶだけで、オブジェクト間の参照や静的状態がすべてリセットされるわけではありません。
登録方法はサービスの保持期間で選ぶ
これは通常の解決を比較した表です。パラメーターを明示した解決やcontextual bindingなどの特殊な経路は別に確認してください。
bind() でも、取得したサービスを別のsingletonのプロパティに保存すれば、その参照は残ります。登録方法はコンテナの生成・再利用規則であって、利用者が保持するオブジェクトまで強制的に作り直す規則ではありません。
scopedは共有登録と除去対象の記録を組み合わせる
確認したContainer::scoped() は、次の2つを行います。
- 登録キーを
scopedInstancesに記録する。 singleton()を呼び、共有バインディングとして登録する。
instances に保存されます。forgetScopedInstances() は、記録されたキーについて保存済みインスタンスを unset() します。バインディングの生成処理は残るため、次の通常の解決で新しいオブジェクトを作れます。
この処理は、古いオブジェクトのプロパティを初期化したり、別オブジェクトが持つ参照を書き換えたりしません。参照が残っている古いオブジェクトは、引き続き利用できてしまいます。
境界を作るのは実行環境
コンテナ自身がHTTPリクエストやジョブの開始を監視しているわけではありません。- キューワーカー:
QueueServiceProviderがスコープをリセットするコールバックをWorkerに渡します。Worker::daemon()は次のジョブを取得する前のループでこれを呼び、コールバック内でforgetScopedInstances()とFacadeの解決済みインスタンスのクリアなどを行います。 - Octane: 公式の既定設定では
OperationTerminatedにFlushTemporaryContainerInstancesを登録します。このリスナーがforgetScopedInstances()を呼びます。 - 独自の常駐コマンドやループ: 単に
make()を繰り返しても境界にはなりません。実行基盤側でライフサイクルを設計する必要があります。
処理内で共有する状態をscopedにする
配送パッケージが、1つのリクエスト・ジョブ内で配送先の識別子を共有したい例を考えます。HTTPのRequestを直接保持せず、必要な値だけを保存します。handle() で、入力の検証や権限の確認を済ませた識別子を設定します。同じライフサイクルでこの型を解決する他のサービスは、同じcontextを参照できます。
scoped() はデータベースや外部キャッシュへの永続化でもありません。次のジョブに渡す値はジョブのデータとして明示的に渡します。
Octaneではサービスプロバイダーの
register() と boot() はワーカーの初期起動時に実行されます。現在のユーザーや配送先を boot() でcontextに保存する設計にしないでください。処理ごとの値は、処理が始まってから設定します。singletonからscopedへの参照は自動更新されない
次の登録は、contextだけをscopedにしても意図通りにはなりません。DeliveryService がコンストラクターでcontextをプロパティに保存するなら、最初に生成したcontextを持ち続けます。コンテナからcontextの保存を除去しても、すでに生成済みの DeliveryService のプロパティは変わりません。
対策は、サービスの責務に合わせて選びます。
- 処理ごとに必要な値をメソッド引数で渡す。状態を共有する必要がなければ、これが単純です。
- 保持する側もscopedにする。
DeliveryServiceとcontextの両方を処理単位で作り直します。 - 利用時に解決するresolverを渡す。singletonを維持する必要がある場合は、現在のコンテナから必要なサービスをその都度取得します。
DeliveryService は、コンストラクターで Closure を受け取り、操作のたびに呼び出すAPIへ変更する必要があります。コンストラクター内でresolverを一度だけ呼び、その結果を保存しては解決になりません。これは公式Octaneドキュメントのcontainer resolverを、パッケージ固有のcontextに応用した設計例です。
scopedで消えない状態も確認する
scoped() は、次の状態を自動的には除去しません。
- パッケージ独自の静的プロパティに追加した値
- 別のsingletonやグローバル変数に保存したcontextへの参照
- singletonの内部に保持した「現在のユーザー」「現在の配送先」
- 独自のFacade・キャッシュ層が保持する参照
同じコンテナで境界をまたぐテストを書く
HTTPテストが毎回新しいアプリケーションを作るだけでは、常駐プロセスで同じコンテナを使い続ける問題を見逃すことがあります。まずはコンテナ単体で、同じ処理内の共有と境界後の再生成を検証します。 次のテストはPHPUnit形式です。最初のテストは公式のContainerテストと同様にforgetScopedInstances() を使い、Worker全体を起動せず境界の作用を確認します。
コンテナ単体テストは、Octaneのsandboxやキューワーカーの実際の実行経路を検証するものではありません。対応を表明する実行環境で連続処理を行う結合テストも必要です。登録方法やサービス実装を変更したデプロイでは、常駐プロセスを再読み込みして新しいコードを適用します。
関連ページ
パッケージのサービス解決後フック
afterResolvingと共有インスタンスの取得、再解決時の設定の違いを確認します。
Laravel Octane
Requestやコンテナの保持、常駐プロセスの運用上の注意点を確認します。
参照した一次情報
- Laravel公式ドキュメント: Binding scoped singletons
- Laravel公式ドキュメント: Dependency injection and Octane
- Container: scoped()・singleton()・forgetScopedInstances()・resolve()
- ContainerTest: scopedの共有と再生成
- QueueServiceProvider: Workerに渡すresetScope
- Worker: daemon()でのスコープリセット
- Octane: FlushTemporaryContainerInstances
- Octane: OperationTerminatedの既定リスナー