LP作成処理のControllerに突然現れる履歴保存ロジック。しかもS3アップロード待ちでレスポンスが遅い。データ構造変更時には手動で履歴スキーマも修正が必要。実サービスで苦労した履歴機能の設計を振り返ります。
はじめに
前回の記事では、排他的パラメータの制御について振り返りました。
今回は同じLPcatsの「履歴機能」で苦労した話です。LP/ステップ/パラメータの変更履歴を保存し、過去バージョンに戻せるようにする機能を追加したときに直面した設計の問題と、「こうしておけばよかった」を考えます。
実装した履歴機能
まず、実装した履歴機能の仕様を説明します。
要件
- LP/ステップ/パラメータの変更履歴を保存
- 過去のバージョンを編集画面に読み込んで復元(ロールバック)できる
データ構造
histories テーブル
- id
- lp_id
- created_at
- s3_key (JSONファイルのパス)
S3に保存されるJSON
{
"lp": {...},
"steps": [...],
"parameters": [...]
}
履歴メタデータはDBに、実際の内容はJSONでS3に保存する設計にしました。
最初の実装
class LpController extends Controller
{
public function store(Request $request)
{
// LP作成
$lp = new Lp();
$lp->title = $request->title;
$lp->save();
// ステップ作成
foreach ($request->steps as $stepData) {
$step = new Step();
$step->lp_id = $lp->id;
$step->content = $stepData['content'];
$step->save();
// メディア作成
if (isset($stepData['media'])) {
$media = new Media();
$media->step_id = $step->id;
$media->url = $stepData['media']['url'];
$media->save();
}
}
// パラメータ作成
foreach ($request->parameters as $paramData) {
$parameter = new Parameter();
$parameter->lp_id = $lp->id;
$parameter->key = $paramData['key'];
$parameter->value = $paramData['value'];
$parameter->save();
}
// ← ここから履歴保存処理
$snapshot = [
'lp' => [
'title' => $lp->title,
'description' => $lp->description,
// ... 全フィールドを列挙
],
'steps' => $lp->steps->map(function($step) {
return [
'content' => $step->content,
'order' => $step->order,
'media' => $step->media ? [
'url' => $step->media->url,
'type' => $step->media->type,
] : null,
];
}),
'parameters' => $lp->parameters->map(function($param) {
return [
'key' => $param->key,
'value' => $param->value,
];
}),
];
$s3Key = "histories/{$lp->id}/" . now()->timestamp . ".json";
Storage::disk('s3')->put($s3Key, json_encode($snapshot));
$history = new History();
$history->lp_id = $lp->id;
$history->s3_key = $s3Key;
$history->save();
return response()->json($lp);
}
}
動きはしましたが、いくつもの問題を抱えていました。
実際に困ったこと
1. Controllerに履歴保存ロジックが露出
Controllerが「LP作成」と「履歴保存」の2つの責務を持っている状態です。
public function store(Request $request)
{
// LP作成処理... (本来の責務)
// ← 突然現れる履歴保存処理(横断的関心事)
$snapshot = [...];
Storage::disk('s3')->put(...);
$history->save();
}
問題点:
- LP作成とは関係ない「内部的な事情」がControllerに漏れ出ている
-
updateメソッドを追加したときも同じ履歴保存コードをコピペ - テストで毎回履歴保存をモックする必要がある
履歴保存は「横断的関心事」であり、ビジネスロジックそのものではありません。
横断的関心事(Cross-Cutting Concern)とは
システムの複数箇所に共通して現れる処理のことを指します。代表例:
- ログ出力
- 認証・認可
- トランザクション管理
- 監査ログ(今回の履歴保存)
- エラーハンドリング
これらは本来のビジネスロジックとは別の「システム的な関心事」であり、各所に散らばると保守性が悪化します。Event/Listener、Middleware、AOPなどのパターンで分離するのが定石です。
2. 同期処理でレスポンスが遅い
// S3アップロードを待ってからレスポンス
Storage::disk('s3')->put($s3Key, json_encode($snapshot)); // これ遅い
return response()->json($lp);
履歴保存は:
- ユーザーには見えない内部処理
- 数秒かかることもある
- 失敗してもLP作成自体は成功してほしい
なのに同期処理だから:
- レスポンスが遅い(S3アップロード待ち)
- タイムアウトのリスク
- 履歴保存失敗時の扱いが曖昧
3. データ構造変更時の手動メンテナンス
例えば「Stepに duration フィールドを追加」という改修があったとします。
必要な作業:
- マイグレーション作成
- Modelに追加
- Controllerの保存処理に追加
- 履歴スナップショットにも追加 ← 忘れがち
- 復元処理にも追加 ← これも忘れがち
$snapshot = [
'steps' => $lp->steps->map(function($step) {
return [
'content' => $step->content,
'order' => $step->order,
'duration' => $step->duration, // ← 手動で追加
'media' => ...,
];
}),
];
実際に起きた問題:
- 履歴に保存し忘れて、復元したらフィールドが消える
- 履歴のJSONスキーマがどこにも定義されていない
- 「このフィールドって履歴に入ってたっけ?」と毎回確認
フィールドが増えるたびに人間が覚えておく必要があり、ミスの温床でした。
4. 前回のパラメータ問題との関連
前回の記事で扱った「排他的パラメータ」の構造も、履歴として正しく保存・復元する必要があります。
パラメータはkey/value構造なので、履歴JSONでどう表現するかも悩みどころでした。
{
"parameters": [
{"key": "cta_action_type", "value": "url"},
{"key": "cta_url", "value": "https://..."}
]
}
この構造で排他関係が分かるのか?復元時にStrategyパターンと連携できるのか?など、複雑さが増していきました。
こうしておけばよかった
振り返って、こうしておけばよかったと思う設計を示します。
基本方針
- Event + Listenerで履歴保存をビジネスロジックから分離
- **非同期処理(Queue)**でレスポンス速度を改善
- オブジェクトまるごとJSON化でデータ構造変更に自動追従
Event + Listenerで責務を分離
// イベント定義
class LpCreated
{
public function __construct(
public Lp $lp
) {}
}
class LpUpdated
{
public function __construct(
public Lp $lp
) {}
}
// Controller(履歴保存から解放される)
class LpController extends Controller
{
public function store(Request $request)
{
// LP作成処理だけに集中
$lp = $this->lpService->create(...);
// イベント発火
event(new LpCreated($lp));
return response()->json($lp); // 爆速レスポンス
}
public function update(Request $request, Lp $lp)
{
$this->lpService->update($lp, ...);
event(new LpUpdated($lp));
return response()->json($lp);
}
}
メリット:
- Controllerから履歴保存の責務が完全に消える
- 新しい保存処理(delete等)を追加しても自動で履歴が残る
- テストでイベントをモックすれば履歴を気にしなくていい
非同期処理で高速化
// Listener(Queue化)
class SaveLpHistory implements ShouldQueue
{
public function handle(LpCreated|LpUpdated $event)
{
$lp = $event->lp;
// リレーション全ロード
$lp->load('steps.media', 'parameters');
// JSONに変換してS3に保存
$json = json_encode($lp);
$s3Key = "histories/{$lp->id}/" . now()->timestamp . ".json";
Storage::disk('s3')->put($s3Key, $json);
// 履歴レコード作成
History::create([
'lp_id' => $lp->id,
's3_key' => $s3Key,
]);
}
}
ShouldQueue を実装することで:
- レスポンスが爆速(S3待ちなし)
- 履歴保存が失敗してもリトライできる
- LP作成とは独立して処理される
オブジェクトまるごとJSON化で自動追従
// 保存時: そのままJSON化
$lp->load('steps.media', 'parameters');
$json = json_encode($lp);
メリット:
- フィールド追加しても自動で含まれる
- リレーションもまるっと保存
- 実装がシンプル
注意点:
-
created_at/updated_atなどの不要な情報も含まれる -
hidden属性で隠しているフィールドの扱い
ただし、「情報が欠落するよりは不要な情報がある方がマシ」という判断です。履歴データは後から「あのフィールドも保存しておけば...」となりがちなので。
復元時の処理
class LpRestoreService
{
public function restore(string $s3Key): Lp
{
// S3から履歴JSON取得
$json = Storage::disk('s3')->get($s3Key);
$snapshot = json_decode($json, true);
// 不要なキーを除外してLP作成
$lpData = $snapshot;
unset($lpData['id'], $lpData['created_at'], $lpData['updated_at']);
unset($lpData['steps'], $lpData['parameters']); // リレーションは別処理
$lp = Lp::create($lpData);
// ステップを復元
foreach ($snapshot['steps'] as $stepData) {
unset($stepData['id'], $stepData['lp_id'], $stepData['created_at'], $stepData['updated_at']);
unset($stepData['media']); // リレーションは別処理
$step = $lp->steps()->create($stepData);
// メディアを復元
if (isset($stepData['media'])) {
$mediaData = $stepData['media'];
unset($mediaData['id'], $mediaData['step_id'], ...);
$step->media()->create($mediaData);
}
}
// パラメータを復元
foreach ($snapshot['parameters'] as $paramData) {
unset($paramData['id'], $paramData['lp_id'], ...);
$lp->parameters()->create($paramData);
}
return $lp;
}
}
不要なキー(id, created_at等)を除外するだけで復元できます。
他の案も検討の余地あり
今回提案した「オブジェクトまるごとJSON化」はシンプルですが、以下のような別の案も考えられます:
1. 専用のSnapshotService
class LpSnapshotService
{
public function create(Lp $lp): array
{
return [
'lp' => $this->serializeLp($lp),
'steps' => $this->serializeSteps($lp->steps),
'parameters' => $this->serializeParameters($lp->parameters),
];
}
private function serializeLp(Lp $lp): array
{
// 必要なフィールドだけを明示的に抽出
return [
'title' => $lp->title,
'description' => $lp->description,
// ...
];
}
}
メリット:
- 保存するフィールドを厳密に制御できる
- 不要な情報が含まれない
デメリット:
- フィールド追加時に手動でメンテナンスが必要
2. モデルにスナップショットメソッド
class Lp extends Model
{
public function toSnapshot(): array
{
return [
'lp' => $this->only(['title', 'description', ...]),
'steps' => $this->steps->map->toSnapshot(),
'parameters' => $this->parameters->toArray(),
];
}
}
メリット:
- ロジックがモデルに集約される
- 各モデルが自分のスナップショット形式を管理
デメリット:
- モデルが肥大化する可能性
今回は「シンプルさ」と「自動追従」を優先して「まるごとJSON化」を選びましたが、要件によっては他の方法も検討する価値があります。
この設計の利点
1. 関心の分離
- LP作成ロジック → Controller/Service
- 履歴保存ロジック → Listener
それぞれが独立して変更できます。
2. 高速なレスポンス
非同期処理により、ユーザーは履歴保存を待たずに次の操作に進めます。
3. データ構造変更に強い
フィールドを追加しても、履歴保存コードを触る必要がありません。
4. 拡張性
新しいイベント(LpDeleted等)を追加すれば、自動的に履歴が残ります。
まとめ
履歴機能という「横断的関心事」をControllerに直接書いてしまうと:
- 責務の混在
- レスポンスの遅延
- データ構造変更時の手動メンテナンス
といった問題が発生します。
Event + Listener + 非同期処理により、これらの問題を解決できました(正確には「できたはず」ですが...)。
また、「オブジェクトまるごとJSON化」という選択は一つの解であり、要件に応じて専用のSnapshotServiceなど別の方法も検討する価値があります。
前回の「排他的パラメータ」と今回の「履歴機能」、どちらも「後から追加した機能が既存コードに波及する」という共通の問題を抱えていました。設計時に拡張性を考慮することの重要性を改めて感じます。
次回予告
次回は「LPのアクセスデータ分析の記録処理」で直面したパフォーマンス問題を改善するにあたって、最初にこうしておけばもっと楽に改善できたのに・・・という反省からどう実装すべきだったかを書きたいと思います。