SerializeModelトレイトの導入
概要
Laravelでジョブを非同期処理する際、キュー を使用する。
RedisやRDSなどのキューストア中で、データはシリアライズされた状態で保存される。
多くのパラメータを持つEloquentモデルをシリアライズすると、キューストアのデータ容量を圧迫してしまう。
その解決として クラスの構造 にて、SerializesModels
が紹介されている。
この例では、Eloquentモデルをキュー投入するジョブのコンストラクタへ直接渡すことができたことに注意してください。ジョブが使用しているSerializesModelsトレイトにより、Eloquentモデルとそれらのロード済みリレーションは、ジョブの処理時に正常にシリアル化および非シリアル化されます。
キュー投入するジョブがコンストラクタでEloquentモデルを受け入れる場合、モデルの識別子のみがキューにシリアル化されます。ジョブが実際に処理されると、キューシステムは、完全なモデルインスタンスとそのロード済みリレーションをデータベースから自動的に再取得します。モデルのシリアル化に対するこのアプローチにより、はるかに小さなジョブペイロードをキュードライバに送信できます。
SerializesModelsの原理
原理はシンプルで、シリアライズ中にEloquentモデルを ModelIdentifier クラスに差し替えることによって実現している。
これはクラス名、IDなどを持つ小さなクラスである。
class ModelIdentifier
{
/**
* @var class-string<\Illuminate\Database\Eloquent\Model>
*/
public $class;
/**
* @var mixed
*/
public $id;
}
SerializesModels(と、SerializesModelsの中で使われる SerializesAndRestoresModelIdentifiers では)は、serialize
中に使われる __serialize、__unserialize を提供する。
trait SerializesModels
{
/**
* @return array
*/
public function __serialize()
{
$properties = (new ReflectionClass($this))->getProperties();
$values = [];
foreach ($properties as $property) {
$value = $property->getValue($this);
$values[$name] = $this->getSerializedPropertyValue($value);
}
return $values;
}
/**
* @return void
*/
public function __unserialize(array $values)
{
$properties = (new ReflectionClass($this))->getProperties();
foreach ($properties as $property) {
$property->setValue(
$this, $this->getRestoredPropertyValue($values[$name])
);
}
}
protected function getSerializedPropertyValue($value)
{
return new ModelIdentifier(
get_class($value),
$value->getQueueableId(),
);
}
protected function getRestoredPropertyValue($value)
{
return (new $value->class)->query()->firstOrFail($value->id);
}
}
単にモデルをシリアライズする場合と比べ、キューからジョブが取り出され処理されるタイミングで、
- (状況次第) ジョブ投入時ではなく最新のデータを持つインスタンスを取得できる。
- (メリット) ストア容量も圧縮できる。
- (デメリット) DBからのモデル読み込みが発生する。
キュー投入当時のデータが重要な場合はSerializesModels
を使用しない、または別途Eloquent(Model)
以外のクラスやプロパティとして持つ必要がある。
また物理削除するモデルのdeleted
イベントなどを非同期処理したい場合もこの特性を考慮する必要がある。
キューから取りだすまでの間にモデルが削除されていた場合、deleteWhenMissingModels
が有効であった場合はジョブが削除される。
そうでない場合はModelNotFoundException
でジョブが失敗扱いとなる。
SerializesModelsの導入
導入自体はジョブクラスにてuse SerializesModels
するだけだが、キューがオンラインの状態で、既存のクラスへ導入する場合は非同期処理のクラスの場合は既にキューに入ってしまっているデータとの互換性を考える必要がある。
/**
* ジョブ相当クラス
*/
class Foo
{
use SerializesModels; // ★ リリースでこの行を新たに追加
public function __construct(
private User $user,
) {}
}
// シリアライズフロー(ジョブ投入側)
$u = User::factory()->create();
$c = new Foo($u);
\Storage::put("foo.txt", serialize($c));
// アンシリアライズフロー(ジョブ実行側(queue:work))
$serialized = \Storage::get("foo.txt");
$c = unserialize($serialized);
デプロイ時のジョブ投入側とジョブ実行側それぞれのデプロイタイミングから以下の4パターンが発生する。
① SerializesModelsを導入していないパターン
シリアライズフローも、アンシリアライズフローもリリース前コードの★行が無い状態での実行となる。
通常のシリアライズであり、シリアライズデータにはUser
モデルとしてパラメータが含まれる。
アンシリアライズフローでは、シリアライズフロー当時のデータを持ったUser
を取得できる。
② SerializesModelsを導入完了したパターン
シリアライズフローも、アンシリアライズフローもリリース後コードの★行がある状態での実行。
前述のようにシリアライズデータはModelIdentifier
として格納され、クラス名とidなどのみ持つ形式になる。
アンシリアライズフローでは、最新のデータを持ったUser
を取得できる。
③ SerializesModelsを導入中のパターンA
シリアライズフローは、リリース前の★行が無いコードでの実行、
アンシリアライズフローは、リリース後の★行があるコードでの実行を考える。
シリアライズデータにはUser
モデルとして格納される。
SerializesModels
を導入していても、ModelIdentifier
以外は通常のアンシリアライズ処理が行われるため、
アンシリアライズフローでは、シリアライズフロー当時のデータを持ったUser
を取得できる。
④ SerializesModelsを導入中のパターンB
シリアライズフローは、リリース後の★行があるコードでの実行、
アンシリアライズフローは、リリース後の★行が無いコードでの実行を考える。
シリアライズデータにはModelIdentifier
として格納される。
しかし、アンシリアライズフローで、ModelIdentifier
をそのままアンシリアライズしてしまうため、
User
を想定しているFoo::user
にModelIdentifier
が入ってしまう。型指定をしている場合はTypeError
が発生するため、避けなければいけない。
対応
④のパターンを避けるため、アンシリアライズフロー、すなわちジョブ実行サーバーをジョブ投入サーバーより先にデプロイする必要がある。
サーバーのデプロイ順を制御できない場合は、以下のような工夫が必要になるだろう。
-
Foo
の代わりに新しいFooBar
クラスを作り、そちらにSerializesModels
を導入する -
SerializesModels::__unserialize
を再現したトレイトを作ってデプロイして、ジョブ実行サーバーをModelIdentifier
対応させてから、SerializesModels
を導入する。※
※ 通常のシリアライズ動作と、以下のコードが等価である可能性はありますが検証できておりません。
等価であるならば、__unserialize
用トレイトは要らず、Foo
クラスにこの__serialize
を導入しておくだけで済みます。
public function __serialize(): array
{
return get_object_vars($this);
}
まとめ
考慮すべきことが増えるので、なるべくジョブの設計時にSerializesModels
の導入・非導入を決めておきましょう。
Discussion