平均滞在時間の計算方法を変更する際、traitで共通化したものの、データとロジックが分離していて変更が大変に。ドメインオブジェクトにカプセル化し、Repositoryと組み合わせればもっと保守性が高くなったはず...という反省を振り返ります。
はじめに
前回の記事では、Repository パターンでデータソースを抽象化する話をしました。
今回は同じアクセス分析機能の中で、「平均滞在時間」の計算ロジックに関する設計の反省です。計算方法の変更や非同期化への対応で、traitでの共通化だけでは不十分だったことに気づきました。
平均滞在時間の計算ロジック
前回の記事で登場した平均滞在時間の計算ロジックを振り返ります。
最初の実装
class AnalyticsController extends Controller
{
public function getLpAnalytics(Lp $lp)
{
// ... 他の指標取得
// 平均滞在時間(ハズレ値除外)
$durations = AccessLog::where('lp_id', $lp->id)
->whereBetween('created_at', [$startDate, $endDate])
->whereNotNull('duration')
->pluck('duration');
$avgDuration = $this->calculateAverageDuration($durations);
return response()->json([
// ...
'avg_duration' => $avgDuration,
]);
}
private function calculateAverageDuration($durations)
{
if ($durations->isEmpty()) return 0;
// IQR法で外れ値除外
$sorted = $durations->sort()->values();
$q1 = $sorted->percentile(25);
$q3 = $sorted->percentile(75);
$iqr = $q3 - $q1;
$filtered = $durations->filter(function($duration) use ($q1, $q3, $iqr) {
$lower = $q1 - 1.5 * $iqr;
$upper = $q3 + 1.5 * $iqr;
return $duration >= $lower && $duration <= $upper;
});
return $filtered->avg();
}
}
特徴:
- IQR法(四分位範囲)で外れ値を除外
- 全セッションの滞在時間を配列で取得して計算
最初はこれで動いていました。
問題発覚:全件配列化のパフォーマンス
サービスが成長し、データ量が増えるにつれて問題が発生しました。
// 数十万〜数百万のセッションデータを全件取得
$durations = AccessLog::where('lp_id', $lp->id)
->whereBetween('created_at', [$startDate, $endDate])
->whereNotNull('duration')
->pluck('duration'); // ← 重い
症状:
-
pluck()で全行をEloquentコレクションとして取得するのが重い - 各行がオブジェクトとして生成されるオーバーヘッド
- メモリも大量に消費
- 平均滞在時間の計算だけで数秒かかる
改善1:GROUP_CONCATでの取得
パフォーマンス改善のため、取得方法を変更しました。
// GROUP_CONCATで1行にまとめて取得
$durationsString = AccessLog::where('lp_id', $lp->id)
->whereBetween('created_at', [$startDate, $endDate])
->whereNotNull('duration')
->selectRaw('GROUP_CONCAT(duration) as durations')
->value('durations'); // "120,150,180,200,..."
// カンマ区切り文字列をexplodeで配列化
$durations = collect(explode(',', $durationsString));
$avgDuration = $this->calculateAverageDuration($durations);
メリット:
- DB→PHP間の転送が1行だけで済む
- Eloquentのオブジェクト生成オーバーヘッドがない
- メモリ効率も大幅に改善
この変更で平均滞在時間の取得が高速化しました。
改善2:非同期処理の導入
さらに、データ量が特に多いお客さん向けに、平均滞在時間の計算だけを非同期化することにしました。
同期版API
public function getLpAnalytics(Lp $lp)
{
$startDate = request('start_date');
$endDate = request('end_date');
// 他の指標は同期的に取得
$sessionCount = ...;
$userCount = ...;
// 平均滞在時間は後で取得
$jobId = Str::uuid();
dispatch(new CalculateAverageDurationJob($lp->id, $startDate, $endDate, $jobId));
return response()->json([
'session_count' => $sessionCount,
'user_count' => $userCount,
// ...
'avg_duration' => null, // まだ計算中
'avg_duration_job_id' => $jobId,
]);
}
非同期ジョブ
class CalculateAverageDurationJob implements ShouldQueue
{
public function __construct(
private int $lpId,
private string $startDate,
private string $endDate,
private string $jobId
) {}
public function handle()
{
// GROUP_CONCATで取得
$durationsString = AccessLog::where('lp_id', $this->lpId)
->whereBetween('created_at', [$this->startDate, $this->endDate])
->whereNotNull('duration')
->selectRaw('GROUP_CONCAT(duration) as durations')
->value('durations');
$durations = collect(explode(',', $durationsString));
// ← 同じ計算ロジックをここにもコピペ?
$avgDuration = $this->calculateAverageDuration($durations);
// 結果を保存
Cache::put("avg_duration:{$this->jobId}", $avgDuration, 3600);
}
private function calculateAverageDuration($durations)
{
// Controllerと同じロジック...
}
}
結果取得API
public function getAverageDuration(string $jobId)
{
$avgDuration = Cache::get("avg_duration:{$jobId}");
if ($avgDuration === null) {
return response()->json(['status' => 'processing']);
}
return response()->json([
'status' => 'completed',
'avg_duration' => $avgDuration,
]);
}
trait で共通化したけど...
計算ロジックの重複を避けるため、traitで共通化しました。
trait CalculatesAverageDuration
{
private function calculateAverageDuration($durations)
{
// 文字列で渡された場合は配列化
if (is_string($durations)) {
$durations = collect(explode(',', $durations));
}
if ($durations->isEmpty()) return 0;
// IQR法で外れ値除外
$sorted = $durations->sort()->values();
$q1 = $sorted->percentile(25);
$q3 = $sorted->percentile(75);
$iqr = $q3 - $q1;
$filtered = $durations->filter(function($duration) use ($q1, $q3, $iqr) {
$lower = $q1 - 1.5 * $iqr;
$upper = $q3 + 1.5 * $iqr;
return $duration >= $lower && $duration <= $upper;
});
return $filtered->avg();
}
}
// Controllerで使う
class AnalyticsController extends Controller
{
use CalculatesAverageDuration;
public function getLpAnalytics(Lp $lp)
{
$durationsString = AccessLog::...->value('durations');
$avgDuration = $this->calculateAverageDuration($durationsString);
// ...
}
}
// Jobでも使う
class CalculateAverageDurationJob implements ShouldQueue
{
use CalculatesAverageDuration;
public function handle()
{
$durationsString = AccessLog::...->value('durations');
$avgDuration = $this->calculateAverageDuration($durationsString);
// ...
}
}
ロジックは共通化できました。しかし、いくつかの問題が残っていました。
trait の問題点
1. データとロジックが分離している
// データの準備(各所で重複)
$durationsString = AccessLog::where('lp_id', $lpId)
->whereBetween('created_at', [$startDate, $endDate])
->whereNotNull('duration')
->selectRaw('GROUP_CONCAT(duration) as durations')
->value('durations');
// ロジックの実行
$avgDuration = $this->calculateAverageDuration($durationsString);
データの準備ロジックが各所に散らばっています。
2. 入力フォーマットが暗黙的
// string? array? Collection? どれでも受け入れる...
private function calculateAverageDuration($durations)
{
if (is_string($durations)) {
$durations = collect(explode(',', $durations));
}
// ...
}
型が不明確で、呼び出し側が何を渡すべきか分かりにくい。
3. バリデーションが各所で必要
// 空チェックをtraitの中でやるか、外でやるか曖昧
if ($durationsString === null) {
$avgDuration = 0;
} else {
$avgDuration = $this->calculateAverageDuration($durationsString);
}
4. テストがしづらい
traitを使っているクラス(ControllerやJob)経由でしかテストできません。
こうしておけばよかった:ドメインオブジェクト
振り返って、ドメインオブジェクトとしてカプセル化しておけばよかったと思います。
AverageDuration ドメインオブジェクト
class AverageDuration
{
private Collection $durations;
private function __construct(Collection $durations)
{
$this->durations = $durations;
}
public static function fromConcatenated(string $durationsString): self
{
if (empty($durationsString)) {
return new self(collect([]));
}
$durations = collect(explode(',', $durationsString))
->map(fn($d) => (float) $d)
->filter(fn($d) => $d > 0); // バリデーション
return new self($durations);
}
public static function fromCollection(Collection $durations): self
{
return new self($durations);
}
public function calculate(): float
{
if ($this->durations->isEmpty()) {
return 0;
}
// IQR法で外れ値除外
$sorted = $this->durations->sort()->values();
$q1 = $this->percentile($sorted, 25);
$q3 = $this->percentile($sorted, 75);
$iqr = $q3 - $q1;
$filtered = $this->durations->filter(function($duration) use ($q1, $q3, $iqr) {
$lower = $q1 - 1.5 * $iqr;
$upper = $q3 + 1.5 * $iqr;
return $duration >= $lower && $duration <= $upper;
});
return $filtered->avg();
}
private function percentile(Collection $sorted, int $percentile): float
{
$index = ($percentile / 100) * ($sorted->count() - 1);
$lower = floor($index);
$upper = ceil($index);
$weight = $index - $lower;
return $sorted[$lower] * (1 - $weight) + $sorted[$upper] * $weight;
}
}
Controller(シンプルに)
class AnalyticsController extends Controller
{
public function getLpAnalytics(Lp $lp)
{
$startDate = request('start_date');
$endDate = request('end_date');
// 他の指標取得...
// 平均滞在時間
$durationsString = AccessLog::where('lp_id', $lp->id)
->whereBetween('created_at', [$startDate, $endDate])
->whereNotNull('duration')
->selectRaw('GROUP_CONCAT(duration) as durations')
->value('durations');
$avgDuration = AverageDuration::fromConcatenated($durationsString)
->calculate();
return response()->json([
// ...
'avg_duration' => $avgDuration,
]);
}
}
Job(同じオブジェクトを使う)
class CalculateAverageDurationJob implements ShouldQueue
{
public function handle()
{
$durationsString = AccessLog::where('lp_id', $this->lpId)
->whereBetween('created_at', [$this->startDate, $this->endDate])
->whereNotNull('duration')
->selectRaw('GROUP_CONCAT(duration) as durations')
->value('durations');
// 同じドメインオブジェクトを使う
$avgDuration = AverageDuration::fromConcatenated($durationsString)
->calculate();
Cache::put("avg_duration:{$this->jobId}", $avgDuration, 3600);
}
}
ドメインオブジェクトの利点
1. データとロジックが一体化
データ(滞在時間の配列)と、それに対する操作(外れ値除外・平均計算)が同じオブジェクトにカプセル化されています。
2. 型安全
public static function fromConcatenated(string $durationsString): self
入力フォーマットが明確です。IDEの補完も効きます。
3. バリデーションがカプセル化
$durations = collect(explode(',', $durationsString))
->map(fn($d) => (float) $d)
->filter(fn($d) => $d > 0); // 負の値を除外
バリデーションロジックがオブジェクト内に隠蔽されています。
4. テストが簡単
public function test_平均滞在時間の計算()
{
$avg = AverageDuration::fromConcatenated('100,200,300,10000')
->calculate();
// 10000が外れ値として除外され、200が返る
$this->assertEquals(200, $avg);
}
public function test_空データの場合()
{
$avg = AverageDuration::fromConcatenated('')
->calculate();
$this->assertEquals(0, $avg);
}
ドメインオブジェクト単体でテストできます。
5. 計算方法の変更が局所化
計算方法を変更したい場合、AverageDuration クラスだけを修正すれば良いです。
Repository + ドメインオブジェクトの組み合わせ
前回紹介したRepositoryパターンと組み合わせると、さらに保守性が高くなります。
Repositoryがドメインオブジェクトを返す
interface LpAnalyticsRepository
{
// ...
public function getAverageDuration(int $lpId, string $startDate, string $endDate): AverageDuration;
}
class AggregatedLpAnalyticsRepository implements LpAnalyticsRepository
{
public function getAverageDuration(int $lpId, string $startDate, string $endDate): AverageDuration
{
$durationsString = AccessLog::where('lp_id', $lpId)
->whereBetween('created_at', [$startDate, $endDate])
->whereNotNull('duration')
->selectRaw('GROUP_CONCAT(duration) as durations')
->value('durations');
return AverageDuration::fromConcatenated($durationsString ?? '');
}
}
Controller(完全に抽象化)
class AnalyticsController extends Controller
{
public function __construct(
private LpAnalyticsRepository $repository
) {}
public function getLpAnalytics(Lp $lp)
{
$startDate = request('start_date');
$endDate = request('end_date');
// データ取得もロジックもControllerは知らない
$avgDuration = $this->repository
->getAverageDuration($lp->id, $startDate, $endDate)
->calculate();
return response()->json([
// ...
'avg_duration' => $avgDuration,
]);
}
}
Job
class CalculateAverageDurationJob implements ShouldQueue
{
public function __construct(
private int $lpId,
private string $startDate,
private string $endDate,
private string $jobId
) {}
public function handle(LpAnalyticsRepository $repository)
{
// Repositoryから取得
$avgDuration = $repository
->getAverageDuration($this->lpId, $this->startDate, $this->endDate)
->calculate();
Cache::put("avg_duration:{$this->jobId}", $avgDuration, 3600);
}
}
この組み合わせの利点
1. 完全な関心の分離
- Repository: データソースの詳細(SQLの書き方)
- ドメインオブジェクト: ビジネスロジック(平均の計算方法)
- Controller/Job: アプリケーションフロー
2. 変更が局所化
- データソース変更 → Repositoryだけ
- 計算方法変更 → ドメインオブジェクトだけ
- 非同期化 → Jobだけ
3. テストが簡単
// Controllerのテスト(Repositoryをモック)
$mockRepository = Mockery::mock(LpAnalyticsRepository::class);
$mockRepository->shouldReceive('getAverageDuration')
->andReturn(AverageDuration::fromConcatenated('100,200,300'));
// ドメインオブジェクトのテスト(単体)
$avg = AverageDuration::fromConcatenated('100,200,300')->calculate();
$this->assertEquals(200, $avg);
4. 再利用性
AverageDuration は他の場所(例:ステップレベルの分析)でも使えます。
この設計の利点
1. 変更に強い
計算方法の変更、データ取得方法の変更、非同期化など、どの変更も局所的に対応できます。
2. 理解しやすい
$avgDuration = AverageDuration::fromConcatenated($durationsString)
->calculate();
コードが自己説明的で、何をしているか一目瞭然です。
3. テストしやすい
各コンポーネントを独立してテストできます。
4. 拡張性
新しい集計方法(中央値、モードなど)を追加する場合も、ドメインオブジェクトにメソッドを追加するだけです。
class AverageDuration
{
public function calculate(): float { ... }
public function median(): float { ... } // 追加
public function mode(): float { ... } // 追加
}
まとめ
平均滞在時間の計算ロジックをtraitで共通化しましたが:
- データとロジックが分離
- 入力フォーマットが暗黙的
- 変更時に複数箇所への影響
という問題がありました。
ドメインオブジェクトとしてカプセル化することで:
- データとロジックが一体化
- 型安全で分かりやすい
- テストが簡単
- 変更が局所化
さらに、Repositoryパターンと組み合わせることで:
- データソースの変更にも強い
- 完全な関心の分離
- 高い保守性
ビジネスロジックをドメインオブジェクトとして表現することの重要性を改めて感じました。