HTTP サーバ(http-handler)
(TCP ソケットガイドの http-hello.lisp のように)
read-line/write-line で HTTP を手書きする方法も勉強になりますが、
素朴なリクエスト/レスポンス型のサーバであれば
rontolisp:http-handler
がパースを引き受けてくれます。ハンドラは Clack の環境プロパティリスト
(:request-method / :path-info / :query-string / :headers /
:raw-body / ...)を受け取り、Clack のレスポンスリスト
(status headers body) を返します。これは
Clack Web アプリケーションのプロトコルそのものであり、Clack
アプリケーションがリクエストごとの変換なしで serve できる理由です:
(defun handle (env)
(list 200 '(:content-type "text/plain")
(list (format nil "Hello from rontolisp!~%~a ~a~%"
(getf env :request-method) (getf env :path-info)))))
(rontolisp:http-handler 'handle 8080)
これを app.lisp として保存し
(examples/net/http-handler.lisp
としても同梱されています)、以下の 3 つのバックエンドのいずれかで実行します。
ハンドラの契約
ハンドラは Clack の環境プロパティリストを受け取ります。キーは以下のとおりで、 常にすべて存在します:
| env キー | 値 |
|---|---|
:request-method | メソッドの大文字化・intern 済みキーワード(:GET、:POST、...)。(eq m :POST) が動きます |
:script-name | アプリケーションのマウントポイント(パーセントデコード済み)— コンテキストパス配下にデプロイした Servlet war 以外では "" |
:path-info | パーセントデコード済みのリクエストパス(マウントポイントを取り除いたもの) |
:query-string | 最初の ? より後ろの生のテキスト、なければ nil |
:server-name / :server-port | Host ヘッダがあればそこから、なければリスナーの値 |
:server-protocol | キーワード。例: :HTTP/1.1 |
:request-uri | 生のリクエストターゲットそのまま(エンコードされたまま、クエリ込み) |
:url-scheme | "http" または "https" |
:remote-addr / :remote-port | インタープリタと JVM では実際のピア。WASI コンポーネントでは nil(wasi:http@0.3.0 はピアのアクセサを公開しません) |
:headers | 小文字化したヘッダ名をキーとする equal ハッシュテーブル — (gethash "content-type" (getf env :headers)) で引きます。重複したヘッダは ", " で結合され、nil になることはありません |
:content-type / :content-length | 上のテーブルから(なければ nil。:content-length は整数) |
:raw-body | リクエストボディ(下記) |
デフォルトの :raw-body は rontolisp の非同期ストリームです。読む
ハンドラは
(rontolisp:await (rontolisp:read-all (getf env :raw-body))) で読み尽くし、
rontolisp:async-defun
として定義する必要があります。ディレクティブのオプション引数
(rontolisp:http-handler 'handle 8080 :raw-body :buffered) を付けると、
代わりにボディを先に全部読み切り、同期のインメモリな bivalent
ストリーム — read-line/read-char と read-byte/read-sequence
の両方で読め、本物の file-position を持つ — として渡します。これが
Clack アプリケーション(lack-request、http-body)が必要とする形です。
ボディの無いリクエストでは :raw-body は nil になります。
ハンドラは Clack の位置引数のレスポンスリスト (status headers body)
を返します:
status— 必須の整数。car が整数でなければエラーを送出します。headers— キーワード plist('(:content-type "text/plain")、慣用形) またはドット対の alist — 後者を受け付けるのでrontolisp:fetchの結果の:headersをそのまま渡せ ます。同名の繰り返しはそれぞれが独立したヘッダ行になり(:set-cookieの繰り返しは構造上正しくなります)、content-length/transfer-encodingは落とされます(サーバが計算します)。nilでも構いません。body— 文字列のリスト(連結されます)、nilまたは省略(空の ボディ — 2 要素の(status headers)形も有効です)、(unsigned-byte 8)ベクタ(1 バイトずつそのまま書き出されるので、バイナリのレスポンスは バイト単位で正確です)、または rontolisp のストリーム(例: プロキシした fetch のボディ)。 裸の文字列はエラーを送出します — これは意図的で、文字列を拒否する Clack に忠実な挙動です。pathname のボディは「このファイルを serve せよ」を 意味し (lack の静的ファイルミドルウェアが返します)、ここでは独立した値 なので、トランスポートが配信できるようになるまで未対応として拒否されます。関数のレスポンスは Clack の delayed 形のみ対応です —(lambda (responder) ... (funcall responder (list 200 nil (list "later"))))— streaming writer 形は拒否されます。
Clack 以前の契約で書かれたハンドラの移行では、次の落とし穴に注意して
ください: レスポンス側は(上記のエラーで)大きな音を立てて失敗しますが、
リクエスト側は静かに失敗します — 移行しきれていないハンドラの
(getf env :method) は単に nil を返すだけです。
クライアント側は変わっていません: rontolisp:fetch は
これまでどおり (:status <integer> :headers <alist> :body <stream>) の
結果 plist を返します。
インタープリタで実行する
http-handler はポート 8080 でブロッキングの組み込み HTTP サーバを起動し
(リクエストごとに 1 つの仮想スレッド)、プロセスが Ctrl-C で停止されるまで
処理を続けます。
$ rontolisp app.lisp
$ curl http://127.0.0.1:8080/hello
Hello from rontolisp!
GET /hello
リスナーはループバックではなくワイルドカードアドレス (0.0.0.0、
デュアルスタック) にバインドされるため、ホスト側が許可していれば他のマシンから
そのまま到達できます。ディレクティブにアドレス引数はありません。バインド先を
選びたい場合 — ループバックのみ、あるいは特定のインターフェース — は、同じ
アプリケーションを clack:clackup 経由で serve してください。
clackup の :address のデフォルトは 127.0.0.1 です。
JVM クラスにコンパイルする
同じソースは JVM クラス にもコンパイルでき、同じ方式で提供します。この クラスは自己完結しています。提供に使う組み込みサーバが正規の名前のままクラスの 隣に出力されるので、クラスパスに必要なのは出力ディレクトリ自身だけです。
$ rontolisp app.lisp -o App.class
$ ls
App.class am/ app.lisp
$ java -cp . App
$ curl http://127.0.0.1:8080/hello
Hello from rontolisp!
GET /hello
App.class の隣にある am/ik/rontolisp/runtime/ がそのサーバです。JDK の外を
一切 import しない数個のクラスファイルで、JVM ライブラリが
RontoFloatArray ハンドルを引き渡すのと同じ仕組みです。提供を行わない
プログラムは従来どおり 1 ファイルだけにコンパイルされます。
jar にコンパイルすれば同梱されるので、成果物はそれ単体で動きます。
$ rontolisp app.lisp -o app.jar
$ java -jar app.jar
Maven プラグインも同じように
target/classes へ書き出すので、src/main/lisp に置いたサービスはビルドが
作る jar にそのまま入ります。
Servlet war にコンパイルする
同じソースは Servlet war にもコンパイルでき、Servlet 6 対応のコンテナ (Tomcat 10.1/11、Jetty 12、および現行の Jakarta EE サーバ)に無設定・無改変で デプロイできます。
$ rontolisp app.lisp -o app.war
$ cp app.war $CATALINA_HOME/webapps/ROOT.war
war には web.xml もプログラムクラス名を書いたファイルも入っていません。
コンテナがコンパイル済みクラス自体を発見し(JVM クラス出力が既に実装している
ハンドラインタフェースを実装しているため)、/* にサーブレットを登録します。
ポートはコンテナが所有するので、rontolisp:http-handler に書かれたポートは
コンパイル時に 1 行の警告とともに無視されます。--maven-coordinates と
--emit-pom は jar と同じように使えます。
war は非ルートのコンテキストパス配下(ROOT.war ではなく myapp.war
としてのデプロイ)でも正しく動きます。コンテナのマウントポイントは
:script-name としてハンドラに届き、:path-info は残りの部分だけを
持つので、ルーターは war がどこにマウントされても同じルートにマッチします。
:script-name が "" にならないのはこのトランスポートだけです。
サーブレットはデフォルトで非同期です。各リクエストはコンテナのスレッドを
解放し、ハンドラは専用の仮想スレッドで実行されます。これは他のすべての
rontolisp トランスポートが守るリクエストごと 1 仮想スレッドの規則そのものです。
仮想スレッドを設定済みのコンテナでは rontolisp.async コンテキストパラメータ
を false にしてオプトアウトできます。チェーン内のフィルタが非同期対応を
宣言していない場合、war はリクエストを失敗させる代わりに、このパラメータ名を
示す警告を 1 回出して同期パスにフォールバックします。
war には後から独自の web.xml(フィルタ、セキュリティ制約、
<session-config>)を追加できます。metadata-complete="true" や
<absolute-ordering/> の下でもイニシャライザは動作し続けます。
知っておくべき 2 つの失敗の形:
- シグナルするトップレベルフォームは
ExceptionInInitializerErrorとして現れ、 デプロイを失敗させます — コンテナは 500 を返し続ける代わりに webapp の 失敗として報告します。 - ハンドラのスロットはプロセスごとではなく webapp ごとです。webapp は各自の クラスローダを持つので、1 つのコンテナに rontolisp の war を複数並べて デプロイできます。
Clack のアプリケーションも同じように war にコンパイルできます。
出力が .war なら clack:clackup ... :server :rontolisp が Servlet
トランスポートを選ぶので、ソースを変更する必要はありません。
Maven プラグインは
<packaging>war</packaging> プロジェクトの src/main/lisp から同じ war を
ビルドします。プラグインのパラメータ 1 つ (<servlet>true</servlet>) が
-o app.war の代わりになります。
WASI HTTP コンポーネントにコンパイルする
さらに WASI HTTP コンポーネント にもコンパイルでき、wasmtime serve
(wasmtime 47+ — 46 でも動作はしますが、同時アクセスで崩壊します。後述の
スループットの節を参照)で動作します。
$ rontolisp app.lisp -o app.wasm --component
$ wasmtime serve app.wasm
$ curl http://127.0.0.1:8080/hello
Hello from rontolisp!
GET /hello
この場合モジュールは wasi:http/handler@0.3.0(非同期の WASI 0.3 HTTP
world)をエクスポートし、ソケットはホストが所有するため port 引数は無視
されます。コンポーネントは WebAssembly GC プロポーザルと例外処理
プロポーザル(Lisp で書かれた HTTP グルーがボディの終端検出に使用)を
使い、どちらも wasmtime 47+ ではデフォルトで有効です。ハンドラは
コールバック非同期リフトのエクスポートで、
サスペンドしたハンドラ(タイマ・fetch・ボディ読みの await)は制御をホスト
に返し、各完了イベントはコンポーネントのコールバック経由で届けられます —
いずれも wasmtime 46+ でデフォルト有効な基本のコンポーネントモデル非同期
ABI の一部であり、ゲートされた機能フラグは不要です。レスポンスは従来どおり
canon task.return を通じてタスクの途中で届けられ、その後にボディが
ストリームされます。
その他の WASI HTTP ランタイム
このコンポーネントがホストに要求するのは wasi:http 0.3(非同期)と
wasm-GC です。wasmtime 46+ がこれをホストし、wasmCloud もホストします:
wash 2.5.2 が、プロジェクトマニフェストに
dev.wasm_proposals: [gc, exception-handling, component-model-async] を
指定した wash dev で実行します。インストールは
curl -fsSL https://wasmcloud.com/sh | bash で行ってください(wash 2.6.1
で検証済み)。なお別リポジトリ wasmCloud/wash からタグ指定で取得できる
バイナリは別系統の古い版で、2.0.0-rc.x は wasi:http を 0.2 しか提供せず、
インターフェース抽出の段階でコンポーネントを拒否します。
Spin も
canary ビルド
(4.1.0-pre0)以降で実行できます — 組み込みの
wasmtime が 47 になり、WebAssembly GC と例外処理のプロポーザルがデフォルトで
有効なので、フラグは不要です。プログラムの隣に spin.toml を置きます:
spin_manifest_version = 2
[application]
name = "rontolisp-http-handler"
version = "0.1.0"
[[trigger.http]]
route = "/..."
component = "hello"
[component.hello]
source = "app.wasm"
[component.hello.build]
command = "rontolisp app.lisp -o app.wasm --component"
$ spin build && spin up
Serving http://127.0.0.1:3000
$ curl http://127.0.0.1:3000/hello
Hello from rontolisp!
GET /hello
ソケットは Spin が所有し 3000 番で待ち受けるため、ここでも port 引数は
無視されます。ハンドラが rontolisp:fetch を呼ぶ場合は、
接続先ホストをコンポーネントの allowed_outbound_hosts に登録する必要が
あります — Spin はデフォルトで外向き HTTP を拒否します:
[component.dog]
source = "app.wasm"
allowed_outbound_hosts = ["https://dog.ceo"]
リリース版の Spin 4.0.2 では動作しません。組み込みの wasmtime が 44 で、
リリース版の wasi:http@0.3.0 ではなく wasi:http@0.3.0-rc-2026-03-15
スナップショットを話すため、GC を有効にしてもインポートのリンクに失敗します
(そもそもリリース版 4.0.2 のバイナリには GC を有効にする手段がありません:
--experimental-wasm-feature オプションは canary ビルドにのみ組み込まれて
います)。jco もまだ実行できません — 0.3 の非同期 ABI が未実装です。
グレースフルシャットダウン
ディレクティブが起動したサーバは、終了時にドレインします。プロセスに 終了が要求されると — コンテナやオーケストレータからの SIGTERM、Ctrl-C に よる SIGINT — 待ち受けソケットは直ちに閉じられて新しい接続を受け付けなく なり、すでに処理中のリクエストには接続を切る前に最大 30 秒 の猶予が 与えられます。ローリングデプロイの終わりに接続リセットではなく完全な レスポンスが返るのはこのためです:
$ java -jar app.jar &
$ curl http://127.0.0.1:8080/slow & # a handler that takes a while
$ kill %1 # SIGTERM: the /slow response still arrives
30 秒という値は rontolisp.http.shutdown-grace システムプロパティ、または
RONTOLISP_HTTP_SHUTDOWN_GRACE 環境変数(単位は秒)で変更できます。0 に
すると処理中のリクエストも直ちに切断されます。値はプラットフォームが許す
期限より短く設定してください — Kubernetes は
terminationGracePeriodSeconds(デフォルト 30)の経過後に SIGKILL を送るため、
それより長い猶予期間は決して使い切れません。
これはインタープリタとコンパイル済み JVM クラス / jar が使う JDK サーバの 振る舞いです。Servlet war はコンテナの流儀で、 WASI コンポーネントはホストの流儀で ドレインします。どちらもソケットを所有していないため、シャットダウンの 扱いはここには従いません。
プログラム内部からの明示的な停止(clack:stop)はグレースフルでは
ありません。これは意図的な設計です。ハンドラが自分自身のサーバを停止
することがあり、そこで待ってしまうと停止を要求したリクエスト自身を待つ
ことになるためです。
クエリ文字列
:path-info は(パーセントデコード済みの)パスのみを保持するため、
ルーティングの比較は完全一致で書けます。リクエストにクエリ文字列がある
場合は、最初の ? より後ろの生のテキストが :query-string として別途
渡されます(クエリが無ければ nil)。URL ライブラリの
クエリ文字列関数
rontolisp:query-param と
rontolisp:query-params
でパースしてください(どちらもキーと値を URL デコードし、nil も受け付け
ます):
(defun handle (env)
(list 200 '(:content-type "text/plain")
(list (format nil "Hello, ~a!~%"
(or (rontolisp:query-param (getf env :query-string) "name")
"world")))))
(rontolisp:http-handler 'handle 8080)
$ curl 'http://127.0.0.1:8080/greet?name=ronto%20lisp'
Hello, ronto lisp!
$ curl http://127.0.0.1:8080/greet
Hello, world!
ハンドラから他のサービスを呼び出す
rontolisp:fetch は 3 つのバックエンドすべてで、サービング中の
ハンドラ内でも動作します。古典的なプロキシ/アグリゲータの形が書けます。
await するハンドラは非同期関数なので、defun ではなく
rontolisp:async-defun
で定義してください:
(rontolisp:async-defun handle (env)
(let ((res (rontolisp:await
(rontolisp:fetch "http://127.0.0.1:9000/upstream"))))
(list (getf res :status) (getf res :headers) (getf res :body))))
(rontolisp:http-handler 'handle 8080)
fetch の結果の :headers alist はそのままレスポンスの headers スロットに、
:body ストリームは body スロットに入ります — ストリームはサーバが
読み尽くします。ストリームのチャンクは上流のオクテットそのもので途中で何も
デコードされないため、中継はバイト単位で正確です: 入ってきた画像がそのまま
画像として出ていきます。
WASI コンポーネントバックエンドでは、外向きリクエストの機構も同じコンポーネント
に同梱されます — serve と serve+fetch は 1 つのコンポーネント形状で、
インポートする wasi:http/client@0.3.0 は wasmtime serve がデフォルトで
提供します(-S http=y は不要です):
$ rontolisp proxy.lisp -o proxy.wasm --component
$ wasmtime serve proxy.wasm
完全な例は
examples/net/dog-fetcher.lisp
です。wasmCloud の dog-fetcher の例
の再現で、リクエストごとに dog.ceo API からランダムな犬の画像 URL を取得して
JSON で応答します。
状態を保つ: グローバル変数ではなくストアへ
インタープリタと JVM ではサーバは 1 つの長命プロセスなので、グローバルなハッシュ テーブルはリクエストを跨いで生き残ります。serve されるコンポーネントではそう なりません — しかもその壊れ方は「毎回リセットされる」よりも厄介です。 インスタンスがどれだけ生きるかはホストの判断であり、ホストごとに異なります:
| ホスト | インスタンスの寿命 |
|---|---|
wasmtime serve | 128 リクエストで破棄 (--max-instance-reuse-count) |
| Spin | 128 リクエスト (wasmtime の既定値をそのまま継承) |
wasmCloud wash dev | 1 リクエスト — 常に新しいインスタンス |
つまりグローバル変数は、実行中ずっと残るわけでもリクエストごとに戻るわけでも ありません。wasmtime と Spin では 128 リクエストごとにトップレベルが再実行され、 ハンドラがグローバルに溜めたものはそこで消えます。トップレベルの副作用は 冪等に書き、残さなければならない状態はコンポーネントの外に置いてください。
したがって状態を保つ方法は、それをコンポーネントの外 — ハンドラが呼ぶ WIT
インターフェース — に置くことです。束縛には
rontolisp:wit-import
を使います。serve されるコンポーネントは、それを固定の wasi:http
表面と並べてインポートします:
(rontolisp:wit-import "wit/keyvalue.wit"
:interface "wasi:keyvalue/store@0.2.0-draft"
:package kv)
(defun handle (env)
(let* ((page (getf env :path-info))
(bucket (kv:open ""))
(seen (kv:bucket-get bucket page))
(hits (+ 1 (if seen (parse-integer seen) 0))))
(kv:bucket-set bucket page (princ-to-string hits))
(list 200 nil (list (format nil "~a: ~a hits~%" page hits)))))
(rontolisp:http-handler 'handle 8080)
$ rontolisp page-hits-server.lisp -o server.wasm --component
$ wasmtime serve -S keyvalue=y server.wasm
同じソースがインタープリタと JVM でも動きます。そこでは Lisp で書かれた
プロバイダ
がインターフェースに応答します。コンポーネントでカウントが実際に残るかどうかは
ホストの都合です: wasmtime 組み込みのキーバリュープロバイダはリクエストごとに
空から始まるインメモリストアなので (実測: どのリクエストも 1 hit を返します)
残りませんが、プロセス外のプロバイダをリンクするホスト (たとえば wasmCloud の
wash dev) なら残ります。実例は
examples/wit/keyvalue
にあります。
スループットと、コンポーネントが払っているコスト
単純なハンドラであれば 3 つのバックエンドは同程度です。1 台のマシンでの計測
(同時接続 16、10 秒のクローズドループ、"Hello " + :path-info を返すハンドラ、
wasmtime 47.0.2、wasmtime serve は既定設定):
| バックエンド | requests/s | mean | p99 |
|---|---|---|---|
| インタープリタ | 33 900 | 0.47 ms | 0.99 ms |
| JVM クラス | 36 600 | 0.44 ms | 0.88 ms |
| WASI コンポーネント | 24 500 | 0.65 ms | 1.19 ms |
コンポーネントの行には wasmtime 47 以降が必要です。wasmtime 46 では、 実行時の型テストの失敗 — 動的言語の型ディスパッチが普通に起こすミス — がエンジン全体で共有されるロックを取るホスト呼び出しになります。接続が 1 本ならただ払うだけ(約 10%)ですが、同時接続はこのロックを奪い合い、 接続を増やすほどスループットが下がります — 16 接続でおよそ 1/15 です。 wasmtime 47 はこの型チェックをインライン化し(rontolisp が出力する型は すべて final で、これはまさにその高速パスが要求する形です)、崩壊は 消えます。
コンポーネントの差はハンドラではなくインスタンス化です。ホストはインスタンス
ごとにトップレベル全体 (_start) を 1 回実行し、--max-instance-reuse-count
リクエストごとにインスタンスを破棄します。このつまみを下げるとコストが見えます —
--max-instance-reuse-count 1 では同じコンポーネントのスループットは上表の
1/3 程度まで落ちます。リクエストごとにインスタンス化を丸ごと払うからです。
知っておく価値のある帰結が 2 つあります:
- トップレベルに何を置くかが効き、その度合いはホスト次第。トップレベルの
処理はインスタンスごとに 1 回なので、wasmtime と Spin では 128 リクエストに
償却されますが、wasmCloud では毎リクエスト払います(同じハンドラが
wasmtime の 24500 rps に対して 7900 rps)。
ql:quickload "clack"する プログラムと素のrontolisp:http-handlerのプログラムが wasmtime 上では ほぼ同じ速度なのにまさにこの理由で、wasmCloud 上では目に見えて差が出ます。 - ツリーシェイキングはここでは速度ではなくサイズのため。コンパイル済みのコア モジュールをシェイクし (serve コンポーネントで数 %、serve でない コンポーネントなら 90% 減ることもあります)、インスタンス化はわずかに 短くなりますが、定常状態の 1 リクエストあたりのコストは変わりません。
制限
リクエスト/レスポンスのヘッダは WASI コンポーネントを含むすべての
バックエンドで受け渡しされます。ハンドラは環境の :headers
(小文字化したヘッダ名をキーとする equal ハッシュテーブル)を読み取り、
レスポンスの headers 要素は書き戻されます。
serve コンポーネントのハンドラ内でも random、時刻系の組み込み関数、
print(ホストの標準出力への出力)はすべて動作します — コンポーネントが
これらを、すべての wasi:http ホストが提供する wasi:random・wasi:clocks・
wasi:cli インタフェースへブリッジするためです。uiop:getenv もホストの
環境変数を読みます — serve コンポーネントは wasi:cli/environment@0.3.0 を
インポートするので、wasmtime serve --env NAME=value(または
-S inherit-env=y)がハンドラに届きます。ファイルストリームは利用
できません。
詳細は
rontolisp:http-handler
のリファレンスページを参照してください。
HTTP の クライアント 側には rontolisp:fetch を使ってください —
HTTP リクエストガイドを参照してください。(任意の TCP
プロトコルや TLS など)生のソケットレベルで扱う場合は
TCP ソケットガイドを参照してください。