blog.tut-cc.org

注文管理システムmurchaceを支える技術

💡
キャンパスコンパスさんのサイトでインタビュー記事が公開されています。murchaceの開発を始めた経緯も記載されています。興味があったら覗いてみてください!

murchaceって何?

MITライセンスで公開されている注文管理のためのWebシステムです。注文を受け付ける人と捌く人の間でコミュニケーションを円滑にすることが解決したい問題です。商品を注文するデバイスと入ってきた注文を確認するデバイスの間をネットワークで接続することによって、リアルタイムに情報共有・更新することを実現しています。

Image in a image block
クライアント・サーバアーキテクチャ クライアントがHTTPリクエストを送るとサーバからHTMLが返ってくる。サーバはデータベースに問い合わせてデータを取得・更新する。

クライアントがHTTPリクエストを送信し、サーバ側で生成されたHTMLをブラウザで表示するという、シンプルでオーソドックスなRESTアプリケーションです。サーバは、PythonのフレームワークであるFastAPIを採用しました。クライアント側は、htmxTailwind CSSを用い、基本的にはHTMLのみで記述しています。

この記事では、RESTアーキテクチャとステートマシンの関係、テンプレート言語Jinjaのマクロ機能について解説します。

RESTアーキテクチャとステートマシン

RESTを元にしたウェブアプリケーションは、ステートマシンとして見ることができます。ステートマシンとは、下図のように、状態と動作、そして状態間の遷移が定義されているモデルです。

Image in a image block
押して引くスイッチ

オフの状態のときは、「押す(Push)」という動作が定義されており、オンの状態のときは「引っ張る(Pull)」という動作が定義されています。オフ状態のときに「押す」オン状態に遷移し、オン状態のときに「引っ張る」とオフ状態に遷移します。

このように、ステートマシンは状態の遷移を視覚的に記述できます。RESTの場合は、上図のオン・オフ状態がハイパーメディア(HTML)に、「押す」、「引っ張る」といった動作がHTTPのPOST・PUT・DELETEリクエストに対応します。

CLIでRESTアプリの状態遷移デモ

RESTアプリがステートマシンであることを直感的に理解するために、 murchaceサーバとcurlコマンドでやり取りします。商品を選択して注文を確定するところまでをやってみます。まずは、ルートパス(/)にGETリクエストを送ります。

$ curl -s -D /dev/stderr -X GET localhost:8000 | htmlq body -p
HTTP/1.1 200 OK
date: Thu, 07 Nov 2024 12:16:07 GMT
server: uvicorn
content-length: 1641
content-type: text/html; charset=utf-8

<body><div>
  <a href="/order"><p>新しい注文</p></a>
  <a href="/placements/incoming"><p>確定注文一覧</p></a>
  <a href="/wait-estimates"><p>予測待ち時間</p></a>
  <a href="/stat"><p>統計情報</p></a>
  <a href="/products"><p>商品編集(実装中)</p></a>
  <a><p>設定(未実装)</p></a>
</div></body>

-D /dev/stderrでリスポンスヘッダーを標準エラー出力に表示し、-X GETでHTTPのGETメソッドを指定しています。HTTPのステータスコードが200、メッセージ本体がHTML形式、といったヘッダー情報が表示されています。HTMLのレスポンスは、htmlqというツールでbody要素を抽出して表示しています。(ただし、煩雑になるためクラス属性は削除し、フォーマットを少し変更しています。)「新しい注文」というリンクがあるので、href 属性に指定されている/orderにGETリクエストを送信してみます。

$ curl -s -D /dev/stderr -X GET localhost:8000/order | htmlq body -p
HTTP/1.1 405 Method Not Allowed
date: Thu, 07 Nov 2024 12:17:13 GMT
server: uvicorn
allow: POST
content-length: 571
content-type: text/html; charset=utf-8

<body><div hx-post="/order" hx-trigger="load"></div></body>

405のHTTPエラーコードが返ってきました。allowヘッダーを見ると、POSTリクエストしか受け付けないようです。ところで、<div>要素にhx-post="/order"hx-trigger="load"という属性値があります。これは htmx の拡張属性値で、要素を読み込んだときにAJAXで/orderというパスにPOSTリクエストを送ります。ここではhtmxになりきって、POST /orderリクエストを送ってみます。

$ curl -s -D /dev/stderr -X POST localhost:8000/order
HTTP/1.1 201 Created
date: Thu, 07 Nov 2024 12:17:33 GMT
server: uvicorn
location: /order
hx-location: /order
content-length: 6
set-cookie: session_key=528625c2-296e-4a26-99d6-611565bfdfdc; Path=/; SameSite=lax

クッキーが現れました。内部的にはサーバー側で注文セッションが作成され、対応するUUIDのキーがクッキーとして返されています。-Hフラグを用いてヘッダーに指定して、リダイレクト先の/orderに再度GETリクエストを送ってみます。

$ curl -s -X GET localhost:8000/order -H 'cookie: session_key=528625c2-296e-4a26-99d6-611565bfdfdc; Path=/; SameSite=lax' \
> | htmlq '#order-session' -p
<div id="order-session">
  <div><ul></ul></div>
  <div>
    <div>0</div>
    <div>合計: ¥0</div>
    <button disabled="" hx-get="/order/confirm-modal" hx-swap="innerHTML settle:150ms" hx-target="#order-modal-container">確定</button>
    <div id="order-modal-container"></div>
  </div>
</div>
$ curl -s -X GET localhost:8000/order -H 'cookie: session_key=528625c2-296e-4a26-99d6-611565bfdfdc; Path=/; SameSite=lax' \
> | htmlq 'main figure:nth-child(-n+2)' -p
<figure hx-post="/order/items" hx-target="#order-session" hx-vals="{&quot;product_id&quot;: 1}">
  <img alt="ブレンドコーヒー" src="http://localhost:8000/static/coffee01_blend.png">
  <figcaption>ブレンドコーヒー</figcaption>
  <div>¥150</div>
</figure>
<figure hx-post="/order/items" hx-target="#order-session" hx-vals="{&quot;product_id&quot;: 2}">
  <img alt="アメリカンコーヒー" src="http://localhost:8000/static/coffee02_american.png">
  <figcaption>アメリカンコーヒー</figcaption>
  <div>¥150</div>
</figure>

同じGET /orderリクエストを送ってはいるものの、キーを指定することで新しく作成された注文セッションを表示するようになりました。後者の出力では、<main>要素の中にたくさんある商品のうち、最初2つの<figure>要素を抽出しています。hx-post属性を見ると、/order/itemsにPOSTリクエストを送れそうです。hx-valsに記述されている商品IDは curl では -d フラグに対応します。今回は、「アメリカンコーヒー→ブレンドコーヒー」の順に商品を追加するよう、2回POSTリクエストを送信してみます。

$ curl -s -X POST localhost:8000/order/items -H 'cookie: session_key=528625c2-296e-4a26-99d6-611565bfdfdc; Path=/; SameSite=lax' -d 'product_id=2' >/dev/null
$ curl -s -X POST localhost:8000/order/items -H 'cookie: session_key=528625c2-296e-4a26-99d6-611565bfdfdc; Path=/; SameSite=lax' -d 'product_id=1' | htmlq body -p
<body>
  <div>
    <ul>
      <li id="item-d226ce00-7f0a-4a1e-ad25-e50cb6f214da">
        <div><p>アメリカンコーヒー</p><div>¥150</div></div>
        <div><button hx-delete="/order/items/d226ce00-7f0a-4a1e-ad25-e50cb6f214da" hx-target="#order-session">X</button></div>
      </li>
      <li id="item-6ca8844a-78f0-4f30-9357-f345fa0eea1e">
        <div><p>ブレンドコーヒー</p><div>¥150</div></div>
        <div><button hx-delete="/order/items/6ca8844a-78f0-4f30-9357-f345fa0eea1e" hx-target="#order-session">X</button></div>
      </li>
    </ul>
  </div>
  <div>
    <div>2</div>
    <div>合計: ¥300</div>
    <button hx-get="/order/confirm-modal" hx-swap="innerHTML settle:150ms" hx-target="#order-modal-container">確定</button>
    <div id="order-modal-container"></div>
  </div>
</body>

注文セッションに商品が追加されました。今度は、「確定」ボタンに定義されている GET /order/confirm-modal リクエストを送ってみましょう。

$ curl -s -X GET localhost:8000/order/confirm-modal -H 'cookie: session_key=528625c2-296e-4a26-99d6-611565bfdfdc; Path=/; SameSite=lax' | htmlq '#order-confirm-modal' -p
<div id="order-confirm-modal" onclick="event.stopPropagation()">
  <article>
    <h2>注文の確定</h2>
    <ul>
      <li><span>アメリカンコーヒー</span><span>¥150 x 1</span></li>
      <li><span>ブレンドコーヒー</span><span>¥150 x 1</span></li>
    </ul>
    <div>
      <p><span></span><span>2</span></p>
      <p><span>合計金額</span><span>¥300</span></p>
    </div>
  </article>
  <button hx-post="/order" hx-swap="innerHTML settle:150ms" hx-target="#order-modal-container">確認</button>
  <button onclick="htmx.swap('#order-modal', '', {swapStyle: 'outerHTML'})">閉じる</button>
</div>

先程注文セッションに追加した商品一覧とともに、確認モーダルが返ってきました。「確認」ボタンに定義されている POST /order リクエストを送ってみます。

$ curl -s -X POST localhost:8000/order -H 'cookie: session_key=528625c2-296e-4a26-99d6-611565bfdfdc; Path=/; SameSite=lax' | htmlq '#order-issued-modal' -p
<div id="order-issued-modal">
  <article>
    <h2>注文番号 #1</h2>
    <ul>
      <li><span>アメリカンコーヒー</span><span>¥150 x 1</span></li>
      <li><span>ブレンドコーヒー</span><span>¥150 x 1</span></li>
    </ul>
    <div>
      <p><span></span><span>2</span></p>
      <p><span>合計金額</span><span>¥300</span></p>
    </div>
  </article>
  <button hx-post="/order">新規</button>
  <a href="/">ホームに戻る</a>
</div>

注文番号が発行されました。このように、HTMLに埋め込まれたハイパーリンクを辿っていくことで、curlコマンドであっても比較的シンプルにサーバと対話できます。下図は、今までの一連の動作を状態遷移図にしたものです。

Image in a image block
注文開始から注文完了までの状態遷移図

赤線の部分がcurlで行った一連の状態遷移です。この遷移図では「アメリカンコーヒー→ブレンドコーヒー」の順番で商品を追加した場合のノードしか描かれていませんが、順番を入れ替えたり、別の商品を追加したりすると別の分岐を辿ります。また、商品を削除する動作に対応する遷移がありませんが、1つ前の状態へ戻る矢印を描けば状態遷移図に落とし込めます。

重要なのは、サーバで管理している状態(注文セッション)がHTMLに対応していることと、それぞれの状態が受け付ける動作をHTML内に埋め込んでいるところです。クライアント側での状態管理をHTTPヘッダやHTMLにインライン化することで、振舞の局所性を実現しています。

テンプレート言語によるHTMLの動的生成

ここまではRESTアーキテクチャについて説明してきました。状態に対応するHTMLを生成する方法については触れてきませんでした。murchaceでは、Jinjaを用いて動的コンテンツを記述しています。Jinjaを採用したのは、Pythonで広く使われているからというのが主な理由です。そのため、日本語でも既存の解説記事が豊富にありますが、少し特殊な使い方をしている部分にフォーカスします。

macroによる明示的な宣言

Jinjaは macro という言語機能があります。呼び方が紛らわしいですが、実際にはトークンを解析・展開してコードを生成する「マクロ」というより、引数をインターフェースとして定義し本体に命令のリストを定義する「関数」や「プロシージャ」、という意味合いで捉えたほうが正しいです。

💡
macro という名前を採用したのは、enddefendfunctionというキーワードが気に食わなかったからだそうです。
参考: https://lucumr.pocoo.org/2010/12/5/not-so-stupid-template-languages/

macro は、引数を明示的に記述できます。当初はincludeextendsを用いていたため、暗黙的にコンテキストが渡されていました。以下のコードは、注文が発行されたときに返すHTMLのテンプレートを、includeを用いて記述した例です。

<div id="order-modal">
  <div id="order-issued-modal">
    <article>
      <h2>注文番号 #{{ placement_id }}</h2>
      {% include "_total.html" %}
    </article>
    <button hx-post="/order">新規</button>
    <a href="/">ホームに戻る</a>
  </div>
</div>

{% include "_total.html" %}で、_total.htmlというテンプレートを埋め込んでいます。_total.htmlは、埋め込んでいる側のコンテキストオブジェクトにアクセスできます。そのため、このコードを見ただけでは_total.htmlplacement_idという変数にアクセスしているのかしていないのか分かりません。もしかしたら、別のオブジェクトにアクセスしているのかもしれませんし、コンテキストオブジェクトのどの変数にもアクセスしていないかもしれません。

この例をmacroを使って書き換えると、次のようになります。

{% from "_total.html" import total %}

{% macro issued_modal(placement_id, session) %}
  <div id="order-modal">
    <div id="order-issued-modal">
      <article>
        <h2>注文番号 #{{ placement_id }}</h2>
        {{ _total(session.counted_products.values(), session.total_count, session.total_price_str()) }}
      </article>
      <button hx-post="/order">新規</button>
      <a href="/">ホームに戻る</a>
    </div>
  </div>
{% endmacro %}

_total マクロを呼び出すときに引数を明示的に指定する必要があるため、sessionオブジェクトのフィールド値やゲッターの値を使用していることが分かります。一方で、_total.htmlファイルでは、マクロの引数を記述するために、includeに比べて書くコード量が多くなります。

{% macro _total(counted_products, total_count, total_price) %}
  {# 省略 #}
{% endmacro %}

記述量が増えることは悪い事のように思えるかもしれませんが、引数を明示的に記述すると_totalマクロ側と呼び出す側の両方でコードが読みやすくなります。テンプレートの受け取る引数を明示的に定義することで、テンプレート間の依存関係が疎結合になるためです。murchaceではincludeextendを使わずに、macroを用いてコンポーネント単位でテンプレートを記述しています。

テンプレートのマクロ引数を型付け

murchaceのバックエンドサーバであるFastAPIは、Pythonの標準的な型付けをサポートしています。そのため、バックエンドのコードはなるべく型ヒントを用いるようにしています。そこで、Jinjaテンプレートファイルの型付けをしようと試みました。しかし、前例がないことが分かって諦めました。とはいえ、Python側でマクロの引数を型付けすることはできました。

murchaceサーバは、注文セッションに商品がある状態でPOST /orderリクエストを送ると注文番号が発行されます。このとき、サーバ側ではplace_orderという関数が呼び出されます。この関数は、データベースに注文内容を保存し、注文が完了したことを知らせるHTMLを返します。Jinjaテンプレートファイルからマクロを取得し、HTMLを動的生成しようとすると以下のようなコードになります。

from fastapi import HTMLResponse
import jinja2

jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader("app/templates"))

async def place_order(session: OrderSession) -> HTMLResponse:
    # --- 省略 ---
    # データベースに注文内容を保存して placement_id を取得する
    # --- 省略 ---
    template = jinja_env.get_template("order.html")
    # テンプレートから "issued_modal" マクロのみ取得する
    macro = getattr(template.module, "issued_modal")
    body = macro(placement_id, session)  # <-- 引数が型付けされていない
    return HTMLResponse(body)

macro(placement_id, session)という呼び出しでは引数の型がどこにも定義されていないため、型ヒントが得られません。そこで、マクロの引数の型をPython側で定義するために@macro_templateというデコレータを用意しました。

@macro_template("order.html", "issued_modal")
def tmp_issued_modal(placement_id: int, session: OrderSession): ...

async def place_order(session: OrderSession) -> HTMLResponse:
    # --- 省略 ---
    body = tmp_issued_modal(placement_id, session)
    return HTMLResponse(body)

@macro_template はテンプレートファイル名とマクロ名を受け取って、マクロの引数名と型を宣言できます。tmp_issued_modalは最初の引数として整数型を、2つめの引数としてOrderSessionクラスを指定しています。デコレートされた関数を呼び出すと、型ヒントを得ると同時にJinjaのEnvironmentからマクロを取得して動的生成までしてくれます。@macro_templateの定義は次のようになっています。

import functools
from typing import Callable

def macro_template[**P](
    template_name: str, macro_name: str
) -> Callable[[Callable[P]], Callable[P, str]]:
    def type_signature(func: Callable[P]) -> Callable[P, str]:
        @functools.wraps(func)
        def render_macro(*args: P.args, **kwargs: P.kwargs) -> str:
            template = jinja_env.get_template(template_name)
            macro = getattr(template.module, macro_name)
            assert isinstance(macro, jinja2.runtime.Macro)
            return macro(*args, **kwargs)

        return render_macro

    return type_signature

Callable でマクロ引数の型を定義しています。[**P]typing.ParamSpec の型ジェネリクスを定義することで全引数の型情報を受け継ぎつつ、マクロを読み込んで動的生成する関数を返しています。デコレートする関数funcの全引数の型情報Prender_macroの全引数に紐づけているため、型ヒントが引き継がれているという仕組みです。

⚠️
実際の型宣言では、最初の引数としてFastAPIの Request オブジェクトを渡しています。そのため、実際のmacro_templateデコレータはもう少し複雑です。

引数名をPython側でも記述しなければならなくなったのは二度手間になりますが、明示的に引数を定義することによってエラーの早期発見がしやすくなりました。

理想を言えば、構造的なHTMLを記述できるJSXのPython版テンプレート言語が世の中にあればよかったのですが、それはまた別の話です。

まとめ&振り返り

この記事では、RESTアーキテクチャをステートマシンとして捉える事と、Jinjaテンプレートのマクロ機能について解説しました。

この記事ではあまり触れませんでしたが、

  • HTTPクッキー
  • SSEによるページのリアルタイム更新
  • Pythonのastモジュール、async/await
  • SQLiteデータベース
  • REPL形式のスナップショットテスト
  • GitHubでのチーム開発
  • VSCodeの開発環境コンテナ
  • Podmanで本番環境の立ち上げ

なども勉強になりました。

技科大祭では、喫茶部でぶっつけ本番のような形で運用していましたが、障害やトラブルなく無事運用することができました。協力して頂いた開発チームのメンバー、喫茶部の方々、その他関わって下さった方々に感謝します。

改善点や未実装の機能があるので、murchaceの開発はまだ続きます。

最後まで読んでくださりありがとうございました!