murchaceって何?
MITライセンスで公開されている注文管理のためのWebシステムです。注文を受け付ける人と捌く人の間でコミュニケーションを円滑にすることが解決したい問題です。商品を注文するデバイスと入ってきた注文を確認するデバイスの間をネットワークで接続することによって、リアルタイムに情報共有・更新することを実現しています。
クライアントがHTTPリクエストを送信し、サーバ側で生成されたHTMLをブラウザで表示するという、シンプルでオーソドックスなRESTアプリケーションです。サーバは、PythonのフレームワークであるFastAPIを採用しました。クライアント側は、htmxとTailwind CSSを用い、基本的にはHTMLのみで記述しています。
この記事では、RESTアーキテクチャとステートマシンの関係、テンプレート言語Jinjaのマクロ機能について解説します。
RESTアーキテクチャとステートマシン
RESTを元にしたウェブアプリケーションは、ステートマシンとして見ることができます。ステートマシンとは、下図のように、状態と動作、そして状態間の遷移が定義されているモデルです。
オフの状態のときは、「押す(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="{"product_id": 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="{"product_id": 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
コマンドであっても比較的シンプルにサーバと対話できます。下図は、今までの一連の動作を状態遷移図にしたものです。
赤線の部分がcurl
で行った一連の状態遷移です。この遷移図では「アメリカンコーヒー→ブレンドコーヒー」の順番で商品を追加した場合のノードしか描かれていませんが、順番を入れ替えたり、別の商品を追加したりすると別の分岐を辿ります。また、商品を削除する動作に対応する遷移がありませんが、1つ前の状態へ戻る矢印を描けば状態遷移図に落とし込めます。
重要なのは、サーバで管理している状態(注文セッション)がHTMLに対応していることと、それぞれの状態が受け付ける動作をHTML内に埋め込んでいるところです。クライアント側での状態管理をHTTPヘッダやHTMLにインライン化することで、振舞の局所性を実現しています。
テンプレート言語によるHTMLの動的生成
ここまではRESTアーキテクチャについて説明してきました。状態に対応するHTMLを生成する方法については触れてきませんでした。murchaceでは、Jinjaを用いて動的コンテンツを記述しています。Jinjaを採用したのは、Pythonで広く使われているからというのが主な理由です。そのため、日本語でも既存の解説記事が豊富にありますが、少し特殊な使い方をしている部分にフォーカスします。
macro
による明示的な宣言
Jinjaは macro
という言語機能があります。呼び方が紛らわしいですが、実際にはトークンを解析・展開してコードを生成する「マクロ」というより、引数をインターフェースとして定義し本体に命令のリストを定義する「関数」や「プロシージャ」、という意味合いで捉えたほうが正しいです。
macro
という名前を採用したのは、enddef
やendfunction
というキーワードが気に食わなかったからだそうです。参考: https://lucumr.pocoo.org/2010/12/5/not-so-stupid-template-languages/
macro
は、引数を明示的に記述できます。当初はinclude
やextends
を用いていたため、暗黙的にコンテキストが渡されていました。以下のコードは、注文が発行されたときに返す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.html
がplacement_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ではinclude
やextend
を使わずに、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
の全引数の型情報P
をrender_macro
の全引数に紐づけているため、型ヒントが引き継がれているという仕組みです。
Request
オブジェクトを渡しています。そのため、実際のmacro_template
デコレータはもう少し複雑です。引数名をPython側でも記述しなければならなくなったのは二度手間になりますが、明示的に引数を定義することによってエラーの早期発見がしやすくなりました。
理想を言えば、構造的なHTMLを記述できるJSXのPython版テンプレート言語が世の中にあればよかったのですが、それはまた別の話です。
まとめ&振り返り
この記事では、RESTアーキテクチャをステートマシンとして捉える事と、Jinjaテンプレートのマクロ機能について解説しました。
この記事ではあまり触れませんでしたが、
- HTTPクッキー
- SSEによるページのリアルタイム更新
- Pythonのastモジュール、async/await
- SQLiteデータベース
- REPL形式のスナップショットテスト
- GitHubでのチーム開発
- VSCodeの開発環境コンテナ
- Podmanで本番環境の立ち上げ
なども勉強になりました。
技科大祭では、喫茶部でぶっつけ本番のような形で運用していましたが、障害やトラブルなく無事運用することができました。協力して頂いた開発チームのメンバー、喫茶部の方々、その他関わって下さった方々に感謝します。
改善点や未実装の機能があるので、murchaceの開発はまだ続きます。
最後まで読んでくださりありがとうございました!