年の瀬ですね。クリスマスの足音も近く、ピザなんかを頼んだら景気が良いかなと思ったので、GitHub 上で Issues を生やすとピザが頼める仕組み(workflows)を構築してみました。
折角のアドカレの機会ですから、GitHub 上でピザを頼むまでの過程を、GitHub や Web 技術、ピザ等に明るい方にも、そうでない方にもお楽しみいただけるように説明*1*2を進めていきます*3。少し長くなりますが、どうぞお付き合いください。
ピザ
突然ですが、みなさまはピザと呼ばれる食べ物をご存知でしょうか? 初めてピザをご覧になられた方に向けて説明しておくと、小麦粉等を練って構成した生地を円形に伸ばし、トマトソースやチーズ、様々な具材を載せてかまどで焼いた、イタリア発祥の料理を指します。
我が国におけるピザの提供手法としては宅配ピザが大きな役割を占めています。さて、代表的な宅配ピザチェーンであるドミノ・ピザ、ピザーラ、ピザハットは、Web サイト*4の技術スタック*5を基に大きく区分することができます。
ドミノ・ピザ | ピザーラ | ピザハット | |
---|---|---|---|
アーキテクチャ | SPA | SPA じゃない(MPA) | SPA |
フレームワーク | React | ASP.NET | Vue.js |
ここでキーワードとなるのが SPA(Single Page Application)です。SPA は、MPA*6と称される伝統的な Web サイトとは異なり、表示部分(ビュー)であるフロントエンドと、データ管理やロジックを処理するバックエンドを分離する傾向にあります。従来の MPA をプログラマブルに操作するにはスクレイピング等の操作が要求されますが、SPA ではある意味 API が丸裸の状態になっているため*7、操作しやすいといえば操作しやすいわけです*8。
MAP/SPA の解説については、以下のページに掲載されている図が参考になります。
今回はドミノ・ピザを対象として、GitHub Actions を経由した注文を試みます。海外のドミノピザでは、既に有志による API ラッパが開発される*9など、技術との親和性が高いことでお馴染みです。
ドミノ・ピザを支える技術
ドミノ・ピザの注文サイトを開いて Google Chrome のデベロッパツールを観察すると、何やら https://olo-graph-at.dominos.jp/graphql の URL に対して頻繁にリクエストを送信していることが解ります。
ここには GraphQL と呼称される API 設計手法が用いられており、単一のエンドポイント*10に対してクエリ言語*11を用いて問い合わせを行うことで情報を取得しています。試しに、curl コマンドを用いて、上記のエンドポイントに対して次のクエリ(問い合わせ)を投げてみます。
curl 'https://olo-graph-at.dominos.jp/graphql' \ -H 'content-type: application/json' \ -H 'dpe-application: MobileWeb' \ -H 'dpe-country: JP' \ -H 'dpe-language: ja' \ -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' \ --data-raw $'{"operationName":"offersQuery","variables":{"storeNo":87634,"serviceMethod":"Delivery","tradingTime":"2023-11-25T12:00:00+09:00"},"query":"query offersQuery($storeNo: Int\u0021, $tradingTime: String, $serviceMethod: ServiceMethodEnum\u0021, $orderId: String, $deliveryAddress: DeliveryAddressInput, $layouts: [Layouts]) { offers( storeNo: $storeNo tradingTime: $tradingTime serviceMethod: $serviceMethod orderId: $orderId deliveryAddress: $deliveryAddress layouts: $layouts) { offerId name items {id name price} media {name description}}}"}'
問い合わせの結果として、下記のレスポンスが得られました。query offersQuery
はどうやらクーポン情報を取得するクエリのようです。ピザ 30 % オフとかある。
{ "data": { "offers": [ { "offerId": "99968", "name": "1ハッピーSサイズピザ990円~(2ハッピー+200円 3ハッピー+300円 4ハッピー+400円・Mサイズ+300円 Lサイズ+600円)", "items": [ { "id": "5fe3905f-885d-4228-9fb9-843ef9e735fb", "name": "1ハッピーSサイズピザ990円~(2ハッピー+200円 3ハッピー+300円 4ハッピー+400円・Mサイズ+300円 Lサイズ+600円)", "price": null } ], "media": { "name": "1ハッピーSサイズピザ990円~(2ハッピー+200円 3ハッピー+300円 4ハッピー+400円・Mサイズ+300円 Lサイズ+600円)", "description": "1ハッピーSサイズピザ990円~(2ハッピー+200円 3ハッピー+300円 4ハッピー+400円・Mサイズ+300円 Lサイズ+600円)" } }, ..., { "offerId": "14638", "name": "Upsell Panel #4-1 Flex Web Voucher for Delivery", "items": [ { "id": "27476", "name": "ピザ1枚+サイド2品", "price": "¥2099~" }, { "id": "27477", "name": "ピザ2枚+サイド2品", "price": "¥3599~" }, { "id": "27478", "name": "ピザ3枚+サイド3品", "price": "¥4699~" } ], "media": { "name": "おトクなおすすめセット", "description": "" } } ] } }
調査の結果、ドミノ・ピザの SPA は注文までの全ての通信*12に GraphQL を採用していました。この挙動を読み解くことができれば、公式に提供されるインタフェースを介さずともピザが注文できそうです。
案外複雑なピザドメイン知識
宅配ピザの注文においては、まず注文する商品を選択する工程が発生するため、手始めに提供されるメニュー情報を取得する必要があります*13。調べたところ、メニュー情報は query MenuQuery
を用いて取得されていました。このクエリの実行結果は、大まかに以下のフィールド*14から構成されます。
menuTransitional - pages: カテゴリ(ピザ、マイドミノ、サイドメニュー等) - sections: 商品の種類 - items: 商品 - sizes: サイズ(サイズ概念が存在しない場合は 1 つのみ) - swaps: ユーザが選択可能な候補 - base: 生地 - sauce: ソース - toppings: トッピング - options: オプション
最終的な注文時に要求される情報は、注文する商品の item, size, swaps を指定するコード(ID)です。我々はピザにしか関心がないので、pages に関しては、pages.code
が Menu.Pizza, Menu.MyBox
のいずれかである要素をフィルタリングすれば良さそうです。このうち、今回は処理の簡略化のためにマイドミノ*15(ピザ S サイズ + オプション 2 点のセット)に焦点を絞ります*16。
これらの問い合わせ + 取得したデータの整形を、TypeScript(実行環境は Node.js)を用いてコーディングしていきます。GraphQL クライアントには graphql-request を、API のモック*17には MSW(Mock Service Worker)を使用します。
GitHub
突然ですが、みなさまは GitHub*18 と呼ばれる Web サービスをご存知でしょうか? GitHub はソフトウェア開発プラットフォームにおけるデファクトスタンダードの存在で、git*19 を用いたソースコード管理、コメントやレビュー、静的サイトホスティング等の多彩な機能が提供されています。
今回は GitHub 上にドミノ・ピザ専用のリポジトリとして inaniwaudon/pizza*20 を作成し、リポジトリ内に Issue*21 を立てることで用いてピザを注文します。具体的には以下に示すフローに基づいて、bot との対話を繰り返しながら注文情報を確定させます。
商品の選択には、チェックボックスを表示してタスクを管理するタスクリスト機能を転用します。GFM(GitHub GitHub Flavored)上でチェックボックスは - [ ]
と表記され、ユーザがチェックを付けるとこれが - [x]
へと変化します。
GitHub Actions
GitHub 上で提供される強力な機能の一つに、GitHub Actions があります。これは CI/CD*22の実行基盤として存在しており、ワークフローと称される処理を定義することで、様々な条件を契機に処理を自動実行することが可能となります。要は GitHub が提供する計算資源を用いて *23ガンガン自動化していこうぜ!!みたいな機能です。しかも、private repository では無料枠で 2,000 分/月まで、public repository であれば無制限に利用可能という太っ腹*24っぷり。今回はこの機能を用いて、上記フローのうち bot 部分を実装します。
ワークフローの定義
ワークフローは YAML*25 という言語を用いて、jobs – steps の階層構造を持って記述されます。例として、ある Issue が open された場合にピザのメニューを取得して、その結果を当該 Issue にコメントするワークフローを以下に示します。
name: issue-opened on: issues: types: [opened] jobs: ci: runs-on: ubuntu-latest permissions: issues: write contents: read steps: - name: Checkout uses: actions/checkout@v4 - name: Setup node.js uses: actions/setup-node@v4 with: node-version: 18.17.1 - name: Use cache uses: actions/cache@v3 id: node_cache with: path: "**/node_modules" key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} - name: Install dependencies if: ${{ steps.node_cache.outputs.cache-hit != 'true' }} run: yarn install --frozen-lockfile - name: Get welcome message run: | { echo 'welcome<<EOF' npx ts-node ./src/index.ts welcome echo EOF } >> $GITHUB_ENV - name: Get menu run: | { echo 'menu<<EOF' npx ts-node ./src/index.ts menu echo EOF } >> $GITHUB_ENV - name: Add comment run: | gh issue comment "$NUMBER" --repo "$REPO" --body "$welcome" gh issue comment "$NUMBER" --repo "$REPO" --body "$menu" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NUMBER: ${{ github.event.issue.number }} REPO: ${{ github.repository }}
ワークフロー中では、src/index.ts を呼び出し、実行結果を gh コマンドを用いてコメントとして投稿しています。環境変数はステップを跨いで共有することができないため、ts-node の実行結果は GITHUB_ENV
に格納します*26。
実際に Issue が立てられ、ワークフローが実行されると以下のコメントが投稿されます。
特定のコメントにのみ反応して処理を実行することも可能です。ワークフローに次の記述を追加すると、「次へ」の文字が含まれるコメントに対してのみ処理を遂行するようになります。
- run: | if [[ "${{ github.event.comment.body }}" != *"次へ"* ]]; then exit 1 fi
選択状態を取得する
ワークフローは独立して実行されるため、各環境に跨ってコンテキストを共有する仕組みはありません。従って、今回はコメント上にダンプ/パースを適宜繰り返して、ユーザが入力した情報や以前処理した内容を次のワークフローへと引き継ぐことにしました。Issue の取得には、GitHub が公式に提供するライブラリである Octokit を Node.js 上で実行します。
取得したコメントの本文中からチェックボックスが選択されている行を取得するには、正規表現を用いて - \[x\] (.+?)
を抽出します。下記のソースコードに示す関数では、選択中の商品コードの取得が実現されています。この辺りはバグを生みやすいので入念にテストコード*27を書く必要がありそうです。
export const parseItemSelectionCodes = (comment: string): SelectionCode[] => { const lines = comment.split("\n"); const searchItem = (i: number) => { const sizeResult = /- \[x\] ((.+?): .+)?(¥\d+)/.exec(lines[i]); if (!sizeResult) return; for (let j = i - 1; j >= 0; j--) { const itemResult = /#### (\d+): .+/.exec(lines[j]); if (itemResult) { return { item: itemResult[1], size: sizeResult[1] ? sizeResult[2] : null, }; } } }; return lines.flatMap((_, i) => searchItem(i) ?? []); };
マイドミノを注文する場合はオプションを 2 つ選ぶ必要があるため、まず商品の選択画面を表示し、続いてオプションの選択を促すチェックボックスを提示します。チェックを付けて再度「次へ」を入力すると、配達先住所や連絡先についても入力を求めた後、同様にパースして取得します。また、決済関連は現金支払いに固定します。
注文を確定させる
最後に、選択した商品や配達先住所を送信して注文を確定する必要があるのですが、これが思わぬ難局でした。注文確定周りに関して、API の挙動を注視することで得られた推察を、ひとまず箇条書きで列挙します*28。
- カート内容は、注文確定時までローカルに保存されている。
- minify されたコードをよくよく観察すると、localStorage 内の
persist:dominosApplication
にカート内容や ID、セッション等の情報が保持されている。 - 中身はバイナリ。状態管理に Redux が採用されており、State の維持や圧縮を目的として redux-persist-transform-compress が用いられている。
lz-string.decompressFromUTF16(str)
でデコード可能。 - ページリロード時に、カート内容を復元すべく LocalStorage の内容を参照する。サーバ側からカート内容を取得するような通信は、この段階では特に見受けられない。
- minify されたコードをよくよく観察すると、localStorage 内の
mutation validateBusket
を実行して、カート内容を検証(バリデーション)する。与えた値が正常な場合は、サーバ側で計算された注文総額等が取得される。- variables の
id
,advanceOrderId
として UUID (v4) を送信する。適当に生成した UUID を与えても怒られない。 productCode
等に商品として存在しないコードを与えると、商品が販売休止中である旨がvalidationErrors
に返される。- validateBusket は度々呼ばれている(商品のカート追加時、住所入力時等)。住所等の入力が求められるのは最後の画面であるため、カート追加時には住所の情報は欠損している。
- variables の
mutation validateBusket
の実行後にquery orderQuery
に同一の UUID を渡すと、validateBusket で与えた内容が得られる。- 従って
mutation validateBusket
はカート内容を検証し、その内容を UUID に紐づけてサーバ側に保管する役割を担うと考えられる。
- 従って
- 最後に、
mutation placeOrder
で注文を確定する(ここがよく解っていない)。
問題となるのは mutation placeOrder
の引数に与える入力型(input type)です。minify されたコードを読み解くと、ID や決済情報を与える必要があることは漠然と判明したものの、具体的なスキーマに関しては手掛かりが得られない状態が続いていました*29。こうなると実際の API コールを監視するのが手っ取り早いのですが、このクエリが呼ばれるためには、実際に決済を走らせてピザを注文する必要があります。ドミノ・ピザのピザメニューは最低でも 1,310 円しますので、無闇に注文していては破産まっしぐらです。
――周囲の協力により割り勘に落とし込むことに成功し、その際*31の注文ページの Network タブをダンプすることによりピザ注文への完全な理解を得ることができました。ちなみに LT で用いた資料は以下のスライドになります。
こたえあわせ
注文確定に際しては、下記 3 クエリを順に投げることで処理が遂行されていました。各種 ID がクライアントサイドで生成されていたことには驚きを隠せません。
- カート内容の更新:
mutation validateBusket
を実行する。適当に採番した UUID であるid
,advanceOrderId
を引数に与える。 - 決済処理?:
mutation initialiseOrder
を実行する。適当に採番した UUID であるorderPaymentId
を引数に与える。 - 注文確定:
mutation placeOrder
を実行する。引数に先程と同一のid
,orderPaymentId
を与える。
ピザトラッカー
公式サイトには、ピザの行方がリアルタイムで表示されるピザトラッカーなる機能が実装されています。この仕様を調査したところ、以下に示す事柄が判明しました。
- 進捗状況を 8 つの状態に区分する
Basket
(注文中),Pending
(待機中),SentToStore
(店舗送信中),Making
(調理中),Cooking
(焼成中),Ready
(配達準備中),Leaving
(配達中),Complete
(配達完了)
- Making 以降になると、ETA(到着予定時刻)が 最短–最長 の形式で表示される。
- これらの情報は
query OrderQuery
で取得可能。eta は当初 null である。
到着予定時刻が Issue 上にも表示されると嬉しいわけですが、これを取得するには一定間隔で繰り返し問い合わせを実行*32しなければなりません。以下の手法を用いた定期更新も検討しましたが、様々な事情から今回は workflow_dispatch による手動実行で配達状況を確認することにしました。
- schedule トリガを用いて定期実行(cron)する
- 注文中以外も定時にワークフローが実行され、リソースが無駄に消費される。また遅延が生じる可能性がある。
- 外部サービスに依存する
- Cloudflare Workers の Cron Triggers 等を利用する。KV 等で注文中であるかを上手く管理する必要がある。GitHub 上で完結しなくなってしまうので今回は見送り。
- Issue が close されたらワークフローを削除する
- 一瞬良さそうに思えたが上述の通り遅延問題があるので却下。
かくしてピザ注文に必要な API の解析と、ワークフローの実装*33が終わりました。ここで、ピザの注文に必要な最低限のクエリ呼び出しを改めて図示して整理します。
あとは Issue を立てて、実際に頼んでみるだけです!
実際にやってみた
ピザの選択、配達先住所の入力、全ては順調に事が運んでいた。今度こそ――そうした期待に胸を膨らませながら、CI の動作に目を落とす。
一同はその後も黙々とエラーの分析に勤しんだ。
玄関のチャイムが部屋に轟く。
というわけでピザの注文に成功しました! 事前の検証でバグを取り切ることができず、最後の注文画面で入力型のエラーに見舞われましたが、4 回の試行を経て無事に注文することができました。現実とパソコンが繋がった気がして嬉しかったです。
むすびにかえて
遊びの一環で始めましたが、進めていくと予想以上に興味深い展開となりたのしかったです。ピザに詳しくなりたければ API の解析から始めると、ピザに対する解像度が上がって良いんじゃないかなと思います。
所感として、公式サイトは非常に優れた設計であると感じました。例えば、表示内容・レイアウト・画像・アイコン等はすべて GraphQL 側に情報が寄せられており、フロント側にハードコーディングされた要素はかなり少なかったです。ゆえに柔軟性が高く、今後ピザに加えて寿司も売るよ!などの展開になった際にも、大規模な改修作業を経ずに商品を追加可能なものと思われます*37。本稿では取り上げませんでしたが、公式ではハーフアンドハーフ等の多種多様なメニューの注文も当然受け付けており、複雑なドメイン*38に対応するために GraphQL を上手く駆使しているという印象を受けました。
今後の展望
今後の展望として、実装予定の機能を挙げておきます(多分やらない)。
- 高速化
- メニュー情報等はキャッシュ等に持たせることで高速化が実現されそう
- マイドミノ以外のピザや、複数商品の注文に対応
- トッピングへの対応
- インタフェースの改善
- ピザ柄のルーレットダーツの結果に応じてピザが注文される仕組みなど
- 現金以外の決済手段への対応
あまりコードを書かないがちの情報メディア創成学類ですが、プログラムがちょっと書けると日々の生活をより便利に楽しく、そして豊かにすることができると思います。mast Advent Calendar 2023 はクリスマスまで続いていきますので、今後の記事にもご期待ください*39!
記事中に登場するサービスや運営会社とは一切関係がありません。本稿は技術検証を目的とした記事であり、記事中のプログラム・手順はすべて自己責任で実行してください。また過度な API コールなど、店側に迷惑となるような行為は厳かに慎むようお願いいたします。
*1:初学者向けの完全な解説は流石に難しいので、雰囲気だけでも掴んでいただければという趣旨です
*2:技術的に不正確な記述があるかもしれませんが許してんぽ〜
*3:Zenn に書くのもアレだなあと思ってはてなブログに書いた
*4:ここでは注文用 Web サイトを指す
*5:使用技術のこと
*6:Multiple Page Application. SPA と区別するためのレトロニム
*7:例えば Twitter の API 騒動の際に Twitter 内部で利用されている API を抽出したリポジトリなどが存在した: https://github.com/tsukumijima/tweepy-authlib
*9:当然のことながら、日本国内の注文サイトとは API の仕様が異なるっぽい
*11:データの取得や更新に用いられる言語
*12:リソース(画像等)の読み込みは除く
*13:「1 枚買うと 1 枚無料」に代表されるようなクーポン情報も存在しますが、今回は簡単のため省略
*14:雑に解釈すると「データ」のような意味
*15:ドミノ・ピザ有益情報ですが、マイドミノというのを頼むと一人でも安くピザが食べられるっぽい
*16:公式に提供される SPA では、シュッとした UI を介して何ら迷うことなく商品注文に辿り着けるのですが、内部で扱われているスキーマはかなり複雑であることが伺えます
*17:テストや動作確認の度に API にリクエストを投げると、相手方に負荷が掛かるほか、テストの結果等も API に依存するという現象が生じてしまいます。これらの懸念を解決するために、モックと呼ばれる API のような振る舞いをするプログラムを用意します
*18:設計図共有サイト
*20:個人情報を扱うため private
*21:ソフトウェアに対するアイデアやバグ、要望等を議論する場。よくタスク管理等にも用いられる
*22:テスト、ビルド、デプロイ等を自動で回すこと
*23:厳密にはSelf-hosted Runner も可能。宅配にも持ち帰りにも対応するピザ業界と思わぬ類似点
*24:さらに我々学生は GitHub Education に登録することで無償で 3,000 分/月まで利用可能。今回の CI の構築ではそのうち 600 分程度の枠を使用しました
*25:ヤムルかヤメルと呼ばれている
*26:初めて知った。ニワカですみません
*27:多義な言葉ですが、この文脈で「テスト」と言えばプログラムの動作を機械的に検証することを指す
*28:急に初心者置き去りになってしまい申し訳ない
*29:イントロスペクションも制限されていた
*30:Lightning Talks. 5 分等の短い時間で雑にプレゼンテーションを行う会
*31:ピザの配達を見てこれが真の Continuous Delivery だねみたいな話をしていた
*32:公式でもそのような実装
*33:詳細な実装は inaniwaudon-public-pizza を参照
*34:エラー時に注文は店舗に送信されないことを確認しているため御安心ください
*35:再撮って雰囲気が出ていいよね
*36:高い
*37:実際にできるかは不明
*38:ある領域に限定した知識のことをドメイン知識と呼ぶ。現実世界は大変だ〜
*39:12/23 にもう一度執筆担当します