🔛

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::userModelIdentifierが入ってしまう。型指定をしている場合はTypeErrorが発生するため、避けなければいけない。

対応

④のパターンを避けるため、アンシリアライズフロー、すなわちジョブ実行サーバーをジョブ投入サーバーより先にデプロイする必要がある。

サーバーのデプロイ順を制御できない場合は、以下のような工夫が必要になるだろう。

  • Fooの代わりに新しいFooBarクラスを作り、そちらにSerializesModelsを導入する
  • SerializesModels::__unserializeを再現したトレイトを作ってデプロイして、ジョブ実行サーバーをModelIdentifier対応させてから、SerializesModelsを導入する。※

※ 通常のシリアライズ動作と、以下のコードが等価である可能性はありますが検証できておりません。
等価であるならば、__unserialize用トレイトは要らず、Fooクラスにこの__serializeを導入しておくだけで済みます。

public function __serialize(): array
{
    return get_object_vars($this);
}

まとめ

考慮すべきことが増えるので、なるべくジョブの設計時にSerializesModelsの導入・非導入を決めておきましょう。

ソーシャルデータバンク テックブログ

Discussion