トレタ Advent Calendar 2019 の14日目の記事です。
こんにちは。ニュージーランドで1週間息抜きをしてきた2日後に、ボケボケの頭のまま 社内テックトーク で決して得意とは言えない競技プログラミングに関して話すことになってしまい、自分の計画性のなさを恨んでいるモバイルアプリエンジニアの大野です。 帰国した2日後に火山が噴火して驚きました。
本Advent Calendarには実に3年ぶりの登場となります。
私が主に関わっている Toreta now では、先日公開されたver1.2.0 から店舗を検索する機能にページネーションが追加されました。ページネーションが必要なほどサービスが成長してきたんだなーと思うと嬉しい限りです。
が、そのページネーションを実現するためのAPIの設計で一悶着あったので、本記事ではそのことについて紹介します。
登場人物
- 私
- サーバサイドエンジニア (以下 「サ」)
- 私とは別のモバイルアプリエンジニア (以下「モ」)
なお、GETのAPIなので実際にはリクエストパラメータはqueryで渡していますが、可読性のためにここではjson形式で表示することとします。 また、実際のAPIとはパラメータやデータのフォーマットなどが異なります。
最初期のバージョン
私「店舗検索は(中略) な感じだし、offsetベースよりはcursorベースのページネーションのほうが良いですよね。ってなわけで、こんな感じでリクエストパラメータとレスポンスを考えてみたんですがどうでしょ。」
北緯45°, 東経135°の位置にいるユーザーが、付近の店舗を検索する場合
最初のリクエストには、席数と予約の開始時間とユーザーの現在地を入れる。
# 1回目のリクエスト { "seats": 4, "starts_from": "2019-12-14T21:00:00+09:00", "location": { "latitude": 45.0, "longitude": 135.0 }, "next_token": null }
最初のリクエストへのレスポンスには、店舗一覧の情報と次回の検索用の next_token
が入る。
next_token
がnullだったら、ページネーションが完了したと判断する。
next_token
には次回の検索の先頭のindexが入る。
# レスポンス { "restaurants": [ { "name": "レストラン1" }, { "name": "レストラン2" } ], "next_token": "5" }
2回目以降のリクエストでは、next_token
以外には最初のリクエストと同じ値を入れる。
next_token
には前回のリクエストへのレスポンスに入っていた値をそのまま入れる。
# 2回目以降のリクエスト { "seats": 4, "starts_from": "2019-12-14T21:00:00+09:00", "location": { "latitude": 45.0, "longitude": 135.0 }, "next_token": "5" }
next_token
の実装がどうなっているかはクライアント側では意識せず、単に受け取った値をそのまま使うだけです。
こうすると、UI以外のページネーションのロジックを全てサーバ側に押し付けることが出来るので、クライアント側では楽ができます。
サ「頭の中では大丈夫そうという気持ちになっています」 (原文ママ)
私「あざっす」
モさんによる指摘
モ「ちょっと待ってそれだと最初のリクエストと2回目以降のリクエストで location
の値が違ったらどうなんの。ユーザーの位置が変わったら出てくる店舗も変わっちゃうでしょ。」
私「確かに。必ず同じ値のlocationを入れるようにする、って制限を付けるのもAPIとしては微妙っすね。じゃあこんな感じでどうでしょ。」
北緯45°, 東経135°の位置にいるユーザーが、付近の店舗を検索する場合 改
最初のリクエストには、席数と予約の開始時間とユーザーの現在地を入れる。
# 1回目のリクエスト 改 { "seats": 4, "starts_from": "2019-12-14T21:00:00+09:00", "location": { "latitude": 45.0, "longitude": 135.0 }, "next_token": null }
最初のリクエストへのレスポンスには、店舗一覧の情報と次回の検索用の next_token
が入る。
next_token
がnullだったら、ページネーションが完了したと判断する。
next_token
には、次回の検索の先頭のindexと検索のlocationが入る。
# レスポンス 改 { "restaurants": [ { "name": "レストラン1" }, { "name": "レストラン2" } ], "next_token": "5, latitude=45.0, longitude=135.0" }
2回目以降のリクエストでは、seats
, starts_from
には最初のリクエストと同じ値を入れる。
next_token
には前回のリクエストへのレスポンスに入っていた値をそのまま入れる。
サーバ側では next_token
の値をparseして使う。
# 2回目以降のリクエスト 改 { "seats": 4, "starts_from": "2019-12-14T21:00:00+09:00", "next_token": "5, latitude=45.0, longitude=135.0" }
私「というかこれだと2回目以降のリクエストに必要なパラメータがどれかわかりにくいし、いっそのこと全部 next_token
に入れちゃっていいんじゃね? 」
サ, モ「確かに」
北緯45°, 東経135°の位置にいるユーザーが、付近の店舗を検索する場合 改々
最初のリクエストには、席数と予約の開始時間とユーザーの現在地を入れる。
# 1回目のリクエスト 改々 { "seats": 4, "starts_from": "2019-12-14T21:00:00+09:00", "location": { "latitude": 45.0, "longitude": 135.0 }, "next_token": null }
最初のリクエストへのレスポンスには、店舗一覧の情報と次回の検索用の next_token
が入る。
next_token
がnullだったら、ページネーションが完了したと判断する。
next_token
には、次回の検索の先頭のindexと全ての検索条件が入る。
# レスポンス 改々 { "restaurants": [ { "name": "レストラン1" }, { "name": "レストラン2" } ], "next_token": "5, latitude=45.0, longitude=135.0, seats=4, starts_from=2019-12-14T21:00:00+09:00" }
2回目以降のリクエストでは、next_token
に前回のリクエストへのレスポンスに入っていた値をそのまま入れる。
サーバ側では next_token
の値をparseして使う。
# 2回目以降のリクエスト 改々 { "next_token": "5, latitude=45.0, longitude=135.0, seats=4, starts_from=2019-12-14T21:00:00+09:00" }
私, サ, モ「良さそう」
というわけで最終的にはこの内容で落ち着きました。
サーバ側のロジックがちょっとややこしいことになってしまい、実際に実装途中でいろいろバグりましたが、仮にバグがあった場合サーバ側はすぐに直して反映させることが出来るのに対し、モバイルアプリは審査の期間が挟まれたり何よりユーザーが最新版にアップデートしてくれるとは限らなかったりするため、クライアントにモバイルアプリが含まれることがわかっている場合にはややこしいロジックをサーバー側に押し付けるのは正しい設計だと信じています。
加えて、こうすることでサーバ側、クライアント側ともに状態を持たずにやりとりが出来るので何かと都合が良いことに気付きました。
(クライアント側は一旦 next_token
を保存しないといけませんが。)
何となく、関数型プログラミングと近い考え方なのかな、と思ったりしています。
終わりに
ややこしいロジックを正しく実装してくださったサさん、及び的確な指摘でver1.2.0をリリースまで導いてくださったモさんに、この場を借りてお礼申し上げます。 明日はフロントエンドエンジニアの上垣がなにか書いてくれます。