0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発の中古バイクサービスで「データAPI」を4本公開した ― 既存ロジックを壊さずAPI化する設計と、ハマった3つの罠

0
Posted at

はじめに

個人で運営している中古バイク横断検索サービス「MotoHub」で、中古バイク市場のデータを外部提供するAPIを4本公開しました。

この記事は、**「すでに画面で動いている集計ロジックを、壊さずにAPI化する」**という、地味だけど個人開発でよくある作業の設計と、その過程でハマった罠をまとめたものです。同じことをやる人の参考になれば。

スタック:Laravel 12 / PHP 8.3 / MySQL / Redis / Meilisearch

公開した4本:

エンドポイント 内容
GET /api/v1/rankings/listings 排気量クラス別の流通台数ランキング
GET /api/v1/rankings/price-trends 相場推移(値下がり/高騰)ランキング
GET /api/v1/models/{id}/stats 車種別の市場データ(地域/年式/走行距離/価格帯)
GET /api/v1/market/stats 市場全体のトレンド(地域/年式/走行距離)

ドキュメントは /data に置いています。

設計の方針:「画面のロジックを正にして、APIはそれを包むだけ」

4本のうち3本は、すでにWebページでユーザーにグラフやランキングをUI表示していたものでした。つまり集計ロジックは既に存在している。ここで取れる選択は2つ:

  1. APIのために集計を書き直す
  2. 既存の集計を唯一の正として、APIはそれを呼んで整形するだけにする

迷わず2です。理由は単純で、1にすると「画面の数字」と「APIの数字」が二重管理になり、いつか必ずズレるから。「MotoHub調べ」として外部に出すデータが、サイトを見た数字と違ったら信用を失います。

具体的には、コントローラに private で埋まっていた集計メソッドを Service クラスに抽出し、ページもAPIも同じServiceを呼ぶ形にしました。

// Before: コントローラ内に集計が埋まっている
class RankingController {
    private function getModelStats(int $id): array { /* 重い集計 */ }
}

// After: Service に抽出。ページもAPIも同じものを呼ぶ
class ModelStatsService {
    public function getStats(int $id): array {
        return Cache::remember("model_stats_ranking_v6_{$id}", 604800, fn () => $this->compute($id));
    }
    private function compute(int $id): array { /* 移動しただけ・ロジックは不変 */ }
}

ポイントは、抽出時にロジックを一行も変えないこと。キャッシュキーもTTLもそのまま移植する。こうすると「既存ページの挙動は完全に同一」を保証でき、APIは安全に相乗りできます。リファクタの差分が +342 / −117 で、117行の削除が「コントローラからの移動」だけ、という状態が理想です。

ハマった罠①:PHPは連想配列の「数値文字列キー」を勝手にintにする

排気量クラスを、こう定数で持っていました。

public const RANGES = [
    '125'   => [101, 125],
    '250'   => [226, 250],
    '400'   => [348, 400],
    'large' => [751, null],
];

クラス一覧をループしてランキングを作る処理で、最初こうしていました。

foreach (self::RANGES as $class => $range) {
    $this->classRanking($class, ...); // classRanking(string $class)
}

これが本番で TypeError: Argument #1 ($class) must be of type string, int given で落ちました。

原因は、PHPが連想配列の「整数として解釈できる文字列キー」を自動的に int に正規化する仕様です。'125'125(int) になり、'large' だけ string のまま。つまり large は通り、125/250/400 で型エラー。

教訓:キーに意味を持たせた配列をループするとき、数値っぽいキーは int 化される。 修正は「キーでなく値の配列でループ」。

foreach (['125', '250', '400', 'large'] as $class) { // string のまま保持
    $this->classRanking($class, ...);
}

classRanking(string $class) の型宣言を緩める誘惑に駆られますが、それは間違い。large という非数値キーがある以上、呼び出し側で string を保証するのが正解です。

ハマった罠②:「クラスの境界」は実データを見ないと決められない

「250ccクラス」を素直に 126〜250cc で集計すると、150/155/160ccのスクーターが混ざります。"250ccバイク"として見せたいのは実質 226〜250cc

さらに6区分に拡張したとき、各帯の下限で実車種の分布を確認する必要がありました。たとえば「400ccクラス」の下限を 376 にするか 348 にするかで、GB350(348cc)が入るかどうかが変わる。キリのいい数字(350, 400…)で機械的に切ると、入れたい車種が落ちたり、意図しない車種が混ざったりします。

教訓:ドメイン特有の「区切り」は、定数の見た目の綺麗さではなく、実データの分布で決める。 そして公開後は、各クラスの上位数件を必ず目視して、変な車種が混ざっていないか確認する。

ハマった罠③:「車種ごとなら平気」な集計が、市場全体にすると牙をむく

4本目の「市場全体トレンド」で、年式別ランキングを作ったら、model_year = 5010 という明らかなゴミデータが1位に来ました。

面白いのは、同じ集計ロジックを車種別ページでは何ヶ月も使っていたのに、表面化していなかったこと。車種別は「1車種に絞り込み+上位10件」なので、ゴミ年式が紛れていても上位に出てこなかった。市場全体に広げて初めて、データ品質の穴が見えたわけです。

対策は健全な年式範囲(1900〜当年+1)で絞る。車種別の挙動は変えず、市場全体クエリにだけ制約を足すことで、既存を壊さず修正しました。

教訓:スコープを広げると、今まで隠れていたデータ品質の問題が顕在化する。 「車種ごとで動いてたから市場全体でも大丈夫」は成り立たない。

おまけ:公開APIにした瞬間、パフォーマンスが「自分の問題」でなくなる

市場全体の年式・走行距離集計は、cappedSold(成約判定のためのウィンドウ関数 ROW_NUMBER())が約14万行を舐めるため、コールドで約8秒かかりました。

画面なら1時間キャッシュで隠せます(ウォーム時2ms)。でも外部公開APIは、知らない人がキャッシュの切れた瞬間に叩く。そのリクエストは8秒待たされるし、同時に来ればDBに負荷が乗る。「自分しか踏まないキャッシュミス」と「他人が踏むキャッシュミス」は重みが違う。

当座は1時間キャッシュで凌ぎつつ、スケジューラでの定期ウォーム、あるいは日次の事前集計テーブルを検討中です。whereIn で capped ID を持ち回る案も考えましたが、対象が約10万行で placeholder 上限を超えるため不採用。このあたりは続編で書くかもしれません。

教訓:公開APIにする=パフォーマンスがUXではなくインフラの問題になる。 キャッシュは「隠す」だけで「速くする」わけではないので、重いクエリは結局どこかで根本対処が要る。

まとめ

  • 既存の集計をServiceに抽出して唯一の正にし、ページもAPIも同じものを呼ぶ。これで数字のズレと二重管理を防ぐ
  • 抽出時はロジックを変えない。差分が「移動だけ」になっている状態を目指す
  • PHPの数値文字列キーの int 化、ドメインの境界定義、スコープ拡大によるデータ品質の顕在化 ―― この3つは個人開発でも普通に踏む
  • 公開APIにするとパフォーマンスの意味が変わる。キャッシュは万能ではない

データAPIは /data で公開しています。中古バイクのデータを使ってみたい方はどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?