イベント駆動ウェブフレームワーク
2013 年、私はカードゲームとボードゲームに対応したウェブベースのプロトタイプ作成ツール House を作成した。こういった種類のゲームでは、一人のプレイヤーが手を選ぶまで別のプレイヤーが待機する状況がある。そのプレイヤーが手を決定したときは、その手を他のプレイヤーに瞬時に伝えなければならない。
これは見た目より複雑な問題となった。本章では、この種の対話を HTTP を使って実装するときに発生する問題を解説し、そういった問題を解決できるウェブフレームワークを Common Lisp で作成する。
HTTP サーバーの基礎
最も単純な HTTP 通信は一つのリクエストに一つのレスポンスが続くものである。まずクライアントがリクエストを送信する。リクエストにはリソース識別子と HTTP バージョンタグ、そしていくつかのヘッダーとパラメータが含まれる。サーバーは受け取ったリクエストをパースし、何をすべきかを理解する。その後サーバーはすべき処理を実行してレスポンスを送り返す。レスポンスには同じ HTTP バージョンタグ、レスポンスコード、いくつかのヘッダー、そしてレスポンスボディが含まれる。
この説明の中で、サーバーが特定のクライアントから受け取ったリクエストに対してレスポンスを返している点に注目してほしい。先述した状況では、選択された手を瞬時に全員のプレイヤーへ伝える必要があり、手を選択したプレイヤーに対して通知を返すだけでは十分ではない1。具体的に言えば、リクエストを送信していないクライアントにメッセージをプッシュする機能がサーバーに求められる。
HTTP 越しにサーバーからのプッシュを実装する標準的なアプローチがいくつか知られている。
Long Polling
「long polling」と呼ばれるテクニックを使うとき、クライアントはレスポンスを受け取った瞬間に新しいリクエストを送信する。このリクエストにサーバーはすぐに応答せず、次のイベントが起きるのを待ってからレスポンスを返す。クライアントは自身の状態が更新されたときにもリクエストを送信するので、上述した単純なモデルとは根本的に異なる形態の通信が行われることになる。
サーバー送信イベント (SSE)
サーバー送信イベント (server-sent event, SSE) とは、クライアントが初期化した接続をオープンのまま保持して利用するテクニックを言う。サーバーは接続をクローズせず、定期的に新しいデータを接続に書き込む。クライアントはメッセージを受け取ったらすぐに解釈し、接続の終了を待機しない。SSE では各メッセージで新しい HTTP ヘッダーのオーバーヘッドが生じないので、long polling より多少効率的である。
WebSockets
WebSockets は HTTP の上に構築される通信プロトコルである。サーバーとクライアントの間で HTTP 接続をオープンし、その後ハンドシェイクとプロトコル昇格を実行すると利用可能になる。WebSockets の通信は TCP/IP を利用するものの、HTTP は一切利用しない。SSE と比較したとき WebSockets が持つ利点として、プロトコルをカスタマイズして効率を高められることがある。
長寿命接続
これら三つのアプローチはそれぞれ大きく異なるものの、一つの重要な特徴が共通している: 長時間にわたって保持される接続を利用する。long polling では新しいデータが生成されるまでサーバーがリクエストを保持し、SSE ではクライアントとサーバーを結ぶ接続に定期的にデータが書き込まれ、WebSockets では利用するプロトコルを変更してから接続はオープンのままとなる。
この事実は典型的な HTTP サーバーで問題を発生させる。その理由を理解するために、そういった HTTP サーバーの実装を見ていこう。
古典的な HTTP サーバーのアーキテクチャ
HTTP サーバーのプロセスが一つだったとしても、複数のリクエストを並行に処理することはできる。伝統的に多くの HTTP サーバーは thread-per-request (リクエストごとに一つのスレッド) のアーキテクチャを利用してきた。つまり、リクエストを受け取るたびにサーバーが新しいスレッドを作成し、そのスレッドがリクエストの処理とレスポンスの作成を行う。
そういった HTTP サーバーは寿命の短い接続を処理するために使われたので、並列に実行されるスレッドの個数は少なくて済んだ。また、このモデルでは実装も単純になる: サーバープログラマーは接続が一つしか存在しないかのようなコードを書くことができる。さらに、失敗した接続や「ゾンビ」になった接続を関連付いたリソースと共に掃除する処理も簡単に行える: その接続を担当するスレッドを kill して、後はガベージコレクタに任せればよい。
ここで重要なのは、\(N\) 人のユーザーによって並行に利用される古典的なウェブアプリケーションをホストする HTTP サーバーが並列に処理できなければならないリクエストの個数は \(N\) よりずっと小さい可能性が高い、という事実である。しかし、私たちが作成しているインタラクティブなアプリケーションでは \(N\) 人のユーザーがいるとき少なくとも \(N\) 個の並列接続が一度に必要となる。
このため多くの長寿命接続を保持するには、次のいずれかが必要となる:
- スレッドが安価で、大量のスレッドが同時に存在しても問題が起こらないプラットフォーム
- 大量の接続を単一のスレッドで処理できるサーバーアーキテクチャ
Racket, Erlang, Haskell といったプログラミング環境には一つ目の選択肢が可能になる程度に「軽量」なスレッド風の概念が存在する。ただ、このアプローチでは同期の問題への明示的な対処がプログラマーに求められる。同期の問題は多くの長寿命接続が似たようなリソースを取り合う状況で多く発生する。具体的に言えば、複数のユーザーによって共有される中心的なデータが存在する状況では、そのデータの読み込みと書き込みを何らかの形で調整しなければならない。
軽量スレッドが利用できない場合、あるいは明示的な同期処理を書く気になれない場合は、単一のスレッドに複数の接続を処理させる必要がある2。このモデルでは一つのスレッドが大量のリクエストを「少しずつ」同時に扱う。 もちろん処理するリクエストの切り替えは可能な限り効率良く行わなければならない。このシステムアーキテクチャパターンはイベント駆動 (event-driven) やイベントベース (event-based) と呼ばれる3。
一つのスレッドだけを管理するとき、共有リソースを同時アクセスから保護することにそれほど気を使う必要はない。しかし、このモデル特有の問題が存在する: 単一のスレッドが実行途中の複数のリクエストを同時に扱う以上、リクエストの処理がブロックしないことを保証しなければならない。任意の接続がブロックするとサーバー全体の実行がストップし、他のリクエストの進行が妨げられる。現在のリクエストの処理を進められないとき処理を進められる他のリクエストに実行を切り替える機能が必要になる。また、実行を切り替えるときにこれまで進めた処理の結果が失われてはいけない4。
プログラマーがスレッドに動作の停止を明示的に指示することは稀であるものの、多くの一般的な操作はブロックの危険性がある。スレッドは非常に頻繁に利用され、非同期処理はプログラマーにとって重い認知的負荷なので、多くの言語やフレームワークは I/O でブロックすることを当然の性質とみなしている。そのため誤ってブロックするコードはとても簡単に書けてしまう。幸い、Common Lisp は非同期処理を書くための最小限の非同期 I/O プリミティブを提供する。
アーキテクチャの選択
直面している問題の背景が理解できたと思うので、次は何を作成するかを考えよう。
本プロジェクトを始めようかと考えていたころ、Common Lisp はグリーンスレッドの完全な実装を持っておらず、標準のポータブルスレッディングライブラリは「非常に軽量」とは言えなかった。そのため選択肢は他の言語を使うか、自分用のイベント駆動ウェブサーバーを作るかであり、私は後者を選択した。
サーバーのアーキテクチャに加えて、サーバーからのプッシュの実装を上述した三つの中から選ぶ必要もある。念頭にあるユースケース (インタラクティブな多人数ボードゲーム) は各クライアントへの頻繁な更新が求められる一方で、各クライアントからのリクエストは比較的少ない。ここから SSE を使った更新のプッシュが適していると考え、私は SSE を選択した。
私たちが利用する非同期処理のアーキテクチャと、クライアントとサーバー間の双方向通信をシミュレートする仕組みを選択した理由と共に理解できたと思う。続いてウェブフレームワークの作成に取り掛かろう。バカなサーバーを最初に作り、それをウェブアプリケーションフレームワークに拡張していく。最終的に完成するフレームワークでは、内部で実行される処理の詳細ではなく、私たちが作成しているインタラクティブ性の高いプログラムが必要とする処理に集中できなくてはならない。
イベント駆動ウェブサーバーの作成
並行に進行する複数のタスクを単一の実行の流れで管理するプログラムはイベントループと呼ばれるパターンが使うことが多い。私たちが思い描いているウェブサーバーでイベントループをどのように使えるかを見ていこう。
イベントループ
イベントループは次のタスクを実行する必要がある:
- 内向き接続にリッスンする。
- 新しい接続からのハンドシェイク、既存の接続からのデータ受信を全て処理する。
- 予期しない事態 (処理の中断など) によって動作を停止したソケットを掃除する。
イベントループの実装を次に示す:
(defmethod start ((port integer))
(let ((server (socket-listen
usocket:*wildcard-host* port
:reuse-address t
:element-type 'octet))
(conns (make-hash-table)))
(unwind-protect
(loop (loop for ready
in (wait-for-input
(cons server (alexandria:hash-table-keys conns))
:ready-only t)
do (process-ready ready conns)))
(loop for c being the hash-keys of conns
do (loop while (socket-close c)))
(loop while (socket-close server)))))
Common Lisp のプログラムを見るのが初めてなら、このコードブロックには説明が必要だろう。上に示したのはメソッドの定義である。Lisp は関数型言語として有名であるものの、CLOS5 (The Common Lisp Object System) と呼ばれる独自のオブジェクトシステムを持つ。
CLOS と総称関数
CLOS ではクラスやメソッドではなく総称関数 (generic function) が重要な役割を果たす。総称関数はメソッドの集合体として実装される。このモデルでは、メソッドがクラスに属するのではなく、メソッドが特定の型の組み合わせに特殊化される6。上に示した start
は唯一の引数 port
が integer
型に特殊化されるときのメソッドである。引数 port
の型が異なる start
の実装を与えることも可能であり、ランタイムは start
の呼び出しに渡された port
の型に応じて実装を選択する。
一般的に言えば、メソッドは複数の引数に関して特殊化される。method
が呼び出されたとき、ランタイムは次の処理を実行する:
- 引数の型でディスパッチを行い、どのメソッドを実行するかを決定する。
- 選択されたメソッドを実行する。
ソケットの処理
上に示したコードのイベントループでは process-ready
という総称関数が使われている。これは準備が完了したソケットを処理する総称関数であり、二種類あるソケットに対応して二つのメソッドが存在する。
二種類のソケットとは stream-usocket
と stream-server-usocket
である。stream-usocket
はクライアントのソケットを表し、リクエストの受信とレスポンスの返信を必要とする。stream-server-usocket
はサーバー側の TCP リスナーを表し、新しいクライアント接続の作成を必要とする。
stream-server-socket
が ready
になるとは、新しいクライアントソケットが対話を始めようとしていることを意味する。このときサーバーは socket-accept
を呼び出して接続を受け付け、その結果を接続テーブルに格納してイベントループが他の接続と同様に処理できるようにする:
(defmethod process-ready ((ready stream-server-usocket) (conns hash-table))
(setf (gethash (socket-accept ready :element-type 'octet) conns) nil))
stream-usocket
が ready
になるとは、クライアントが送信したバイト列がサーバーから読み込めるようになったことを意味する。また、相手が接続を終了するケースも process-ready
で処理される:
(defmethod process-ready ((ready stream-usocket) (conns hash-table))
(let ((buf (or (gethash ready conns)
(setf (gethash ready conns)
(make-instance
'buffer
:bi-stream
(flex-stream ready))))))
(if (eq :eof (buffer! buf))
(ignore-errors
(remhash ready conns)
(socket-close ready))
(let ((too-big?
(> (total-buffered buf) +max-request-size+))
(too-old?
(> (- (get-universal-time) (started buf)) +max-request-age+))
(too-needy?
(> (tries buf) +max-buffer-tries+)))
(cond
(too-big?
(error! +413+ ready)
(remhash ready conns))
((or too-old? too-needy?)
(error! +400+ ready)
(remhash ready conns))
((and (request buf) (zerop (expecting buf)))
(remhash ready conns)
(when (contents buf)
(setf (parameters (request buf))
(nconc (parse buf) (parameters (request buf)))))
(handler-case
(handle-request ready (request buf))
(http-assertion-error () (error! +400+ ready))
((and (not warning)
(not simple-error)) (e)
(error! +500+ ready e))))
(t
(setf (contents buf) nil)))))))
これは一つ目のメソッドより複雑である。次の処理が行われる:
- ソケットに関連付いたバッファを取得する。存在しないなら、バッファを作成する。
buffer!
を呼び出して、出力をバッファに読み込む。- 出力の読み込みが
:eof
を返したなら、相手は対話を終了している。そのためソケットとバッファの両方を破棄する。 - そうでないなら、バッファが
too-big?
(大きすぎる)、too-old?
(古すぎる)、too-needy?
(読み込みが多すぎる) かどうかを判定する。いずれかに当てはまるなら、現在の接続を接続テーブルから削除し、適切な HTTP レスポンスを返す。
上のコードに含まれる buffer!
は私たちのイベントループで初めて登場する I/O である。先述したように、イベント駆動のシステムでは I/O を注意深く行わないとシステム全体の実行がブロックされる可能性がある。buffer!
がブロックしないことはどのように保証されるのだろうか? この質問に答えるには、buffer!
の実装を詳しく見て動作を理解する必要がある。
ノンブロッキングな接続処理
接続の処理をノンブロッキングに行う上で鍵となるのはライブラリ関数 read-char-no-hang
である。この関数は引数に渡したストリームが利用可能なデータを持たなければすぐに nil
を返す。buffer!
はストリームからデータを読み込める場合に限って、接続ごとに用意されたバッファにデータを読み出す:
(defmethod buffer! ((buffer buffer))
(handler-case
(let ((stream (bi-stream buffer)))
(incf (tries buffer))
(loop for char = (read-char-no-hang stream) until (null char)
do (push char (contents buffer))
do (incf (total-buffered buffer))
when (request buffer) do (decf (expecting buffer))
when (line-terminated? (contents buffer))
;; 訳注: parse 関数にバグがあり、次の行の expecting は常に nil となる
;; 本文中の parse 関数の解説にある訳注も参照
do (multiple-value-bind (parsed expecting) (parse buffer)
(setf (request buffer) parsed
(expecting buffer) expecting)
(return char))
when (> (total-buffered buffer) +max-request-size+) return char
finally (return char)))
(error () :eof)))
buffer!
に引数 buffer
を渡して呼び出すと、次の処理が行われる:
tries
のカウントを1
だけ増やす。tries
は読み込みが多すぎるバッファをprocess-ready
で検出して削除するために利用される。- 入力ストリームから文字列を読み見込めるだけ読み込む。
- 最後に読んだ文字を返す。
さらに、文字列 \r\n\r\n
を検出して以降の処理でリクエストの切れ目が分かるようにする。また、エラーが発生したときは :eof
を返して process-ready
に接続を切断するよう伝える。
buffer
型は CLOS のクラスである。CLOS において、クラスとはスロット (slot) と呼ばれるフィールドを持つ型を言う。buffer
クラスの定義に振る舞いは関連付かない。なぜなら、先述したように buffer!
などの総称関数を使えばクラスのメソッドと同じことができるからである。
クラスは defclass
を使って次のように定義する。このとき各スロットに対してゲッター (reader
) とセッター (accessor
)、そしてデフォルト値を返す初期化式を設定できる。initarg
は make-instance
の呼び出し側がデフォルト値を指定するときに利用するフックを設定する:
(defclass buffer ()
((tries :accessor tries :initform 0)
(contents :accessor contents :initform nil)
(bi-stream :reader bi-stream :initarg :bi-stream)
(total-buffered :accessor total-buffered :initform 0)
(started :reader started :initform (get-universal-time))
(request :accessor request :initform nil)
(expecting :accessor expecting :initform 0)))
このコードで定義される buffer
クラスは次の 7 個のスロットを持つ:
tries
: このバッファの読み込みを試みた回数contents
: これまでに読み込んだデータbi-stream
: ノンブロッキング I/O で生じる Common Lisp 特有の面倒な処理を避けるためのハックtotal-buffered
: これまでに読み込んだ文字数started
: このバッファが作成された時刻のタイムスタンプrequest
: バッファされたデータをパースして構築されるリクエストexpecting
: リクエストのヘッダーの後に期待される文字数 (0
の可能性もある)
リクエストの解釈
バッファを使って少しずつ読み込まれたデータを完全なリクエストに組み立てる方法がこれで理解できた。では、完全なリクエストはどのように解釈されるのだろうか? この処理は handle-request
メソッドで行われる:
(defmethod handle-request ((socket usocket) (req request))
(aif (lookup (resource req) *handlers*)
(funcall it socket (parameters req))
(error! +404+ socket)))
このメソッドはエラー処理のレイヤーを追加し、リクエストが古すぎる、大きすぎる、データ送信が遅すぎる場合に 4XX
レスポンスを返すことでクライアントにデータ送信が遅いか間違っていることを伝える。これらと異なるエラーが発生したときは、プログラマーが handler
の定義でミスをしたということなので、5XX
エラーとして扱われる。このときクライアントは自身が送信したリクエストは正しいものの、サーバーが正常に動作しなかったと理解する。
もしリクエストの形式が正しいなら、リクエストを処理するハンドラを *handlers*
テーブルから検索するという小さくて自明なタスクを実行する。ハンドラが見つかった場合はそれが it
に束縛され、it
, socket
とパースされたリクエストパラメータを引数として funcall
が呼び出される。*handlers*
テーブルからハンドラが見つからなかった場合は 404
エラーを返す。このハンドラシステムは完全な機能を持ったウェブフレームワークの一部であり、本章の後半で解説される。
バッファに読み込まれたリクエストをパース・解釈する処理はまだ示していなかった。続いてこの部分を見よう:
(defmethod parse ((buf buffer))
(let ((str (coerce (reverse (contents buf)) 'string)))
(if (request buf)
(parse-params str)
(parse str))))
この高レベルメソッド parse
は、プレーン文字列に特殊化された parse
メソッド、またはバッファの内容を HTTP パラメータとして解釈する parse-params
メソッドにタスクを委譲する。どちらを呼び出すかは、これまでにリクエストをどこまで処理したかによって決まる: 最後の parse
は部分的な request
が buffer
に保存されたとき、つまり後はリクエストのボディをパースするだけとなったときに呼び出される7。
(defmethod parse ((str string))
(let ((lines (split "\\r?\\n" str)))
(destructuring-bind (req-type path http-version) (split " " (pop lines))
(declare (ignore req-type))
(assert-http (string= http-version "HTTP/1.1"))
(let* ((path-pieces (split "\\?" path))
(resource (first path-pieces))
(parameters (second path-pieces))
(req (make-instance 'request :resource resource)))
(loop
for header = (pop lines)
for (name value) = (split ": " header)
until (null name)
do (push (cons (->keyword name) value) (headers req)))
(setf (parameters req) (parse-params parameters))
req))))
(defmethod parse-params ((params null)) nil)
(defmethod parse-params ((params string))
(loop for pair in (split "&" params)
for (name val) = (split "=" pair)
collect (cons (->keyword name) (or val ""))))
string
型に特殊化された parse
メソッドはバッファの内容を使いやすい形に分解する。バッファを直接操作するのではなく文字列を操作するのは、インタープリタや REPL などの環境でも parse
メソッドを使えるようにするためである。
この parse
メソッドでは次の処理が行われる:
"\\r?\\n"
で文字列を分割する。- 第一行を
" "
で分割し、リクエストタイプ (POST
またはGET
)、URI パス、HTTP バージョンを取得する。 - 処理しているのが
HTTP/1.1
リクエストであることをアサートする。 - URI パスを
"?"
で分割し、GET
のパラメータを含まないプレーンなリソース識別子を取得する。 - そのリソースを表す
request
インスタンスを新しく作成する。 - ヘッダーの各行を
": "
で分割し、得られた情報をrequest
インスタンスに記録する。 GET
パラメータをパースして、得られた情報をrequest
インスタンスに記録する。
予想できたかもしれないが、request
は CLOS クラスのインスタンスである:
(defclass request ()
((resource :accessor resource :initarg :resource)
(headers :accessor headers :initarg :headers :initform nil)
(parameters :accessor parameters :initarg :parameters :initform nil)))
クライアントがリクエストを送信し、サーバーがリクエストを解釈・処理する方法が以上で説明できた。サーバーの中心的インターフェースの一部として実装が必要な最後の機能として、クライアントに送り返すレスポンスを作成する機能を続いて説明する。
レスポンスのレンダリング
レスポンスのレンダリングに入る前に、クライアントに返すレスポンスには二種類ある事実を考える必要がある。一つ目のレスポンスはヘッダーとボディを持った「通常の」HTTP レスポンスであり、response
クラスのインスタンスによって表現される:
(defclass response ()
((content-type
:accessor content-type :initform "text/html" :initarg :content-type)
(charset
:accessor charset :initform "utf-8")
(response-code
:accessor response-code :initform "200 OK" :initarg :response-code)
(keep-alive?
:accessor keep-alive? :initform nil :initarg :keep-alive?)
(body
:accessor body :initform nil :initarg :body)))
二つ目のレスポンスは SSE メッセージであり、クライアントに段階的な更新を伝えるために利用される:
(defclass sse ()
((id :reader id :initarg :id :initform nil)
(event :reader event :initarg :event :initform nil)
(retry :reader retry :initarg :retry :initform nil)
(data :reader data :initarg :data)))
HTTP レスポンスは完全な HTTP リクエストを受け取ったときに送信される。一方で、SSE メッセージはクライアントが何もしていなくても送信する必要がある。SSE メッセージを送るべきタイミングはどのように判断するべきだろうか?
簡単な解決策は次の通りである。まずチャンネル (channel) のリストをバリューとするハッシュテーブルを用意して、そこに subscribe!
でソケットを登録する8:
(defparameter *channels* (make-hash-table))
(defmethod subscribe! ((channel symbol) (sock usocket))
(push sock (gethash channel *channels*))
nil)
その後、publish!
メソッドを使って任意のタイミングでチャンネルに通知を送信する:
(defmethod publish! ((channel symbol) (message string))
(awhen (gethash channel *channels*)
(setf (gethash channel *channels*)
(loop with msg = (make-instance 'sse :data message)
for sock in it
when (ignore-errors
(write! msg sock)
(force-output (socket-stream sock))
sock)
collect it))))
publish!
メソッドがソケットに SSE メッセージを実際に書き込むのに使っているのが write!
メソッドである。完全な HTTP レスポンスを書き込む response
に対する write!
の特殊化も必要になる。HTTP レスポンスに対する write!
を最初に見よう:
(defmethod write! ((res response) (socket usocket))
(handler-case (with-timeout (.2)
(let ((stream (flex-stream socket)))
(flet ((write-ln (&rest sequences)
(mapc (lambda (seq) (write-sequence seq stream)) sequences)
(crlf stream)))
(write-ln "HTTP/1.1 " (response-code res))
(write-ln
"Content-Type: " (content-type res) "; charset=" (charset res))
(write-ln "Cache-Control: no-cache, no-store, must-revalidate")
(when (keep-alive? res)
(write-ln "Connection: keep-alive")
(write-ln "Expires: Thu, 01 Jan 1970 00:00:01 GMT"))
(awhen (body res)
(write-ln "Content-Length: " (write-to-string (length it)))
(crlf stream)
(write-ln it))
(values))))
(trivial-timeout:timeout-error () (values))))
このバージョンの write!
は response
型の値 res
と usocket
型の値 sock
を受け取り、res
を表す HTTP レスポンスを sock
が持つストリームに書き込む。ローカルに定義される write-ln
は受け取ったシーケンスを crlf
で区切りながら sock
のストリームに書き込む関数である。write-ln
はコードの可読性を向上させるために存在しているだけで、write-sequence
と crlf
を直接呼び出すこともできる。
上記のメソッドで「ブロックしてはいけない」のルールが守られていることに注目してほしい。ストリームに対する書き込みはバッファされる可能性が高いのでブロックが起きる可能性は読み込みより低いものの、何か誤動作が起こればサーバー全体の動作が停止する。そのため 0.2 秒のタイムアウト9が設けられ、それまでに処理が終わらなかった場合は完了を待たずに現在のソケットを破棄する。
SSE メッセージをソケットに書き込む write!
メソッドは HTTP レスポンスを書き込む write!
メソッドと似た構造をしている:
(defmethod write! ((res sse) (socket usocket))
(let ((stream (flex-stream socket)))
(handler-case (with-timeout (.2)
(format
stream "~@[id: ~a~%~]~@[event: ~a~%~]~@[retry: ~a~%~]data: ~a~%~%"
(id res) (event res) (retry res) (data res)))
(trivial-timeout:timeout-error () (values)))))
この write!
メソッドは HTTP レスポンスを扱うものより簡単になる: SSE メッセージでは改行コードに CRLF を使う必要がないので、format
を一度呼び出すだけでメッセージを構築できる。~@[...~]
の部分は条件付きディレクティブ (conditional directive) であり、値が nil
のスロットを簡潔に処理するために使われている。例えばフォーマット文字列の先頭にある ~@[id: ~a~%~]
は (id res)
が nil
でないなら id: <id の値>
となり、nil
なら空文字列となる。段階的更新のペイロード data
は sse
の (唯一の) 必須スロットなので、data
が nil
になることはない。また、ここでも with-timeout
を使ってタイムアウトが設定されるので、書き込みが 0.2 秒以内に完了しなければ処理は次に移る。
エラーレスポンス
ここまでリクエストとレスポンスのサイクルを説明する中で、処理が失敗する状況には触れてこなかった。具体的に言えば、handle-request
と process-ready
で使われる error!
関数の動作を説明しなかった。これを説明するために、まず次のコードを見てほしい:
(define-condition http-assertion-error (error)
((assertion :initarg :assertion :initform nil :reader assertion))
(:report (lambda (condition stream)
(format stream "Failed assertions '~s'"
(assertion condition)))))
define-condition
は Common Lisp のエラークラスを新しく作成する。ここで定義されるのは HTTP 関連の処理中に何らかの条件が満たされなかったことを表す http-assertion-error
であり、判定された条件と、エラーを伝える文字列をストリームに書き込む方法を知っている。他の言語であれば、エラーを報告する処理はメソッドとして実装されるかもしれない。しかし Common Lisp ではスロットに関数を持つクラスが利用される。
クライアントに報告すべきエラーはどのように表現されるのだろうか? よく使われる 4xx
エラーと 5xx
エラーを表す値は次のように定義される:
(defparameter +404+
(make-instance
'response :response-code "404 Not Found"
:content-type "text/plain"
:body "Resource not found..."))
(defparameter +400+
(make-instance
'response :response-code "400 Bad Request"
:content-type "text/plain"
:body "Malformed, or slow HTTP request..."))
(defparameter +413+
(make-instance
'response :response-code "413 Request Entity Too Large"
:content-type "text/plain"
:body "Your request is too long..."))
(defparameter +500+
(make-instance
'response :response-code "500 Internal Server Error"
:content-type "text/plain"
:body "Something went wrong on our end..."))
以上の定義があれば、error!
の処理が理解できる:
(defmethod error! ((err response) (sock usocket) &optional instance)
(declare (ignorable instance))
(ignore-errors
(write! err sock)
(socket-close sock)))
error!
はエラーレスポンスとソケットを受け取り、ソケットにエラーレスポンスを書き込んだ後ソケットをクローズする。クライアントが既に接続を閉じている可能性もあるので、その場合は発生するエラーを無視する。省略可能な引数 instance
はログとデバッグで利用される。
これで、HTTP リクエストと SSE メッセージを扱うことのできる、完全なエラー処理を持ったイベント駆動のウェブサーバーが完成した!
サーバーからウェブフレームワークへの拡張
リクエスト・レスポンス・メッセージをクライアントと交換できる、機能的には問題のないウェブサーバーがこれで完成した。このサーバーがホストするウェブアプリケーションは、ハンドラ関数を設定して実際のタスクを実行することになる。ハンドラ関数は以前に軽く紹介したものの、具体的な説明はしていなかった。
サーバーでホストされるアプリケーションとサーバーの間をつなぐインターフェースは重要である: アプリケーションプログラマーがサーバーを含むインフラストラクチャを簡単に利用できるかどうかは、そのインターフェースから大きな影響を受ける。理想的には、ハンドラのインターフェースがリクエストのパラメータを実際にタスクを実行する関数に対応付けるのが望ましい:
(define-handler (source :close-socket? nil) (room)
(subscribe! (intern room :keyword) sock))
(define-handler (send-message) (room name message)
(publish! (intern room :keyword)
(encode-json-to-string
`((:name . ,name) (:message . ,message)))))
(define-handler (index) ()
(with-html-output-to-string (s nil :prologue t :indent t)
(:html
(:head (:script
:type "text/javascript"
:src "/static/js/interface.js"))
(:body (:div :id "messages")
(:textarea :id "input")
(:button :id "send" "Send")))))
House を書いているとき私が念頭に置いていた懸念事項の一つが、広大なインターネットに接続されるアプリケーションは信頼できないクライアントからのリクエストを必ず受け取るということだった。その対処として、各リクエストが含むべきデータの種類を何らかのスキーマで正確に指定できれば便利である。この仕組みがあるとき、上記のハンドラは次のようになる:
(defun len-between (min thing max)
(>= max (length thing) min))
(define-handler (source :close-socket? nil)
((room :string (len-between 0 room 16)))
(subscribe! (intern room :keyword) sock))
(define-handler (send-message)
((room :string (len-between 0 room 16))
(name :string (len-between 1 name 64))
(message :string (len-between 5 message 256)))
(publish! (intern room :keyword)
(encode-json-to-string
`((:name . ,name) (:message . ,message)))))
(define-handler (index) ()
(with-html-output-to-string (s nil :prologue t :indent t)
(:html
(:head (:script
:type "text/javascript"
:src "/static/js/interface.js"))
(:body (:div :id "messages")
(:textarea :id "input")
(:button :id "send" "Send")))))
これは Lisp コードであるものの、インターフェースは宣言的言語 (declarative language) と非常によく似ている。つまり、コードにはハンドラが何をバリデートすべきかが宣言されているだけで、それをどのようにバリデートすべきかは書かれていない。これはハンドラ関数を書くためのドメイン固有言語 (domain-specific language, DSL) と言える: ハンドラにバリデートさせたい条件を簡潔に表現するための慣習と構文が define-handler
によって提供されている。考えている問題を解くための小さな言語を作成するこのアプローチは Lisp プログラマーがよく利用するテクニックであり、他のプログラミング言語でも有用となる。
ハンドラ用 DSL
ハンドラ用 DSL の見た目と使い方が大まかに分かった。では、どうすれば実装できるだろうか? つまり、define-handler
を呼び出したとき具体的にどのような処理が実行されるべきだろうか? 上述した send-message
の定義をもう一度考えよう:
(define-handler (send-message)
((room :string (len-between 0 room 16))
(name :string (len-between 1 name 64))
(message :string (len-between 5 message 256)))
(publish! (intern room :keyword)
(encode-json-to-string
`((:name . ,name) (:message . ,message)))))
define-handler
にさせたい処理は次の通りである:
- ハンドラテーブルにおいて、
(publish! ...)
が表す処理を/send-message
という URI に関連付ける。 -
/send-message
に対するリクエストを受け取ったとき:- HTTP パラメータ
room
,name
,message
が存在することを確かめる。 room
は長さが 16 以内の文字列であり、name
は長さが 1 以上 64 以内の文字列であり、message
は長さが 5 以上 256 以内の文字列であることを確かめる。
- HTTP パラメータ
- レスポンスを返した後にチャンネルをクローズする。
これらの処理を行う Lisp 関数を書いて手動で組み合わせることもできるものの、Lisp ではマクロと呼ばれる機能を使って Lisp コードを生成するアプローチの方が一般的である。マクロを使うと DSL にさせたい処理を少ない行数で簡潔に表現できる。マクロは実行時に Lisp コードに展開される「実行可能なテンプレート」と考えることができる。
define-handler
マクロの定義10を次に示す:
(defmacro define-handler
((name &key (close-socket? t) (content-type "text/html")) (&rest args)
&body body)
(if close-socket?
`(bind-handler
,name (make-closing-handler
(:content-type ,content-type) ,args ,@body))
`(bind-handler
,name (make-stream-handler ,args ,@body))))
define-handler
は bind-handler
, make-closing-handler
, make-stream-handler
という三つのマクロを呼び出している。make-closing-handler
は HTTP リクエスト/レスポンスの完全なサイクルを扱うハンドラを、make-stream-handler
は SSE メッセージを扱うハンドラをそれぞれ作成する。この二つのケースは述語 close-socket?
によって呼び分けられる。バックティックとコンマはマクロでのみ利用可能な演算子であり、define-handler
の呼び出しに渡された引数を Lisp コードに埋め込むために利用される。
define-handler
に実行させたい処理の記述と実際のマクロのコードが似ている点に注目してほしい。これらの処理を Lisp 関数で書いていたら、コードの意図がずっと分かりにくくなっていただろう。
ハンドラの展開
send-message
ハンドラの展開を一歩ずつ確認して、Lisp
がマクロをどのように「展開」するのかを見ていこう。以降のコードは Emacs の SLIME と呼ばれるパッケージを利用して得られたものである。SLIME が提供する Emacs コマンド macro-expander
をマクロの呼び出しに対して使用すると、マクロの展開を一段階ごとに確認できる。上述した (define-handler (send-message) ...)
に対して macro-expander
を使用すると一段階だけマクロを展開した次のコードが得られる:
(BIND-HANDLER
SEND-MESSAGE
(MAKE-CLOSING-HANDLER
(:CONTENT-TYPE "text/html")
((ROOM :STRING (LEN-BETWEEN 0 ROOM 16))
(NAME :STRING (LEN-BETWEEN 1 NAME 64))
(MESSAGE :STRING (LEN-BETWEEN 5 MESSAGE 256)))
(PUBLISH! (INTERN ROOM :KEYWORD)
(ENCODE-JSON-TO-STRING
`((:NAME ,@NAME) (:MESSAGE ,@MESSAGE))))))
define-handler
マクロが展開され、send-message
特有のコードがハンドラのテンプレートに配置されているのが分かる。bind-handler
は別のマクロであり、引数に受け取った URI とハンドラ関数の関連付けをハンドラテーブルに追加する。このマクロは次のように定義される:
(defmacro bind-handler (name handler)
(assert (symbolp name) nil "`name` must be a symbol")
(let ((uri (if (eq name 'root) "/" (format nil "/~(~a~)" name))))
`(progn
(when (gethash ,uri *handlers*)
(warn ,(format nil "Redefining handler '~a'" uri)))
(setf (gethash ,uri *handlers*) ,handler))))
関連付けの作成は最後の行の (setf (gethash ,uri *handlers*) ,handler)
で行われる。これはハッシュテーブルに対する挿入であり、マクロに特有のコンマを除けば通常の Common Lisp コードと変わらない。なお、クオートされた領域の外側にある assert
はマクロの結果ではなくマクロ自身が呼び出された時点で実行される。
(define-handler (send-message) ...)
の展開をもう一段階進めると、次のコードが得られる:
(PROGN
(WHEN (GETHASH "/send-message" *HANDLERS*)
(WARN "Redefining handler '/send-message'"))
(SETF (GETHASH "/send-message" *HANDLERS*)
(MAKE-CLOSING-HANDLER
(:CONTENT-TYPE "text/html")
((ROOM :STRING (LEN-BETWEEN 0 ROOM 16))
(NAME :STRING (LEN-BETWEEN 1 NAME 64))
(MESSAGE :STRING (LEN-BETWEEN 5 MESSAGE 256)))
(PUBLISH! (INTERN ROOM :KEYWORD)
(ENCODE-JSON-TO-STRING
`((:NAME ,@NAME) (:MESSAGE ,@MESSAGE)))))))
URI に対応するハンドラ関数にリクエストを組み立てる処理を設定するコードに形が似てきた。もちろん今はマクロを使っているので、実際にこのコードを書く必要はない!
続いて make-closing-handler
マクロが展開される。その定義を次に示す:
(defmacro make-closing-handler
((&key (content-type "text/html")) (&rest args) &body body)
`(lambda (sock parameters)
(declare (ignorable parameters))
,(arguments
args
`(let ((res (make-instance 'response
:content-type ,content-type
:body (progn ,@body))))
(write! res sock)
(socket-close sock)))))
ここで使われている lambda
は Common Lisp で匿名関数 (ラムダ式) を作るときに使うキーワードである。このラムダ式の中で引数 body
に含まれる response
を使えるようにする内部スコープの宣言が最初にある。その後リクエストがあったソケットに write!
が実行され、ソケットはクローズされる。最後に残った arguments
は何をするのだろうか?
(defun arguments (args body)
(loop with res = body
for arg in args
do (match arg
((guard arg-sym (symbolp arg-sym))
(setf res `(let ((,arg-sym ,(arg-exp arg-sym))) ,res)))
((list* arg-sym type restrictions)
(setf res
(let ((sym (or (type-expression (arg-exp arg-sym) type restrictions)
(arg-exp arg-sym))))
`(let ((,arg-sym ,sym))
,@(awhen (type-assertion arg-sym type restrictions)
`((assert-http ,it)))
,res)))))
finally (return res)))
難しい部分へようこそ。arguments
はユーザーがハンドラに登録したバリデータをパースとアサーションからなる AST に変換する。type-expression
, arg-exp
, type-assertion
を使ってレスポンスに期待されるデータの種類を表す「型システム」が実装される (後述)。以上で define-handler
で使われるマクロと関数が全て説明できた。まとめると、次のコード:
(define-handler (send-message)
((room :string (>= 16 (length room)))
(name :string (>= 64 (length name) 1))
(message :string (>= 256 (length message) 5)))
(publish! (intern room :keyword)
(encode-json-to-string
`((:name . ,name) (:message . ,message)))))
は、次のコードに展開される:
(LAMBDA (SOCK #:COOKIE?1111 SESSION PARAMETERS)
(DECLARE (IGNORABLE SESSION PARAMETERS))
(LET ((ROOM (AIF (CDR (ASSOC :ROOM PARAMETERS))
(URI-DECODE IT)
(ERROR (MAKE-INSTANCE
'HTTP-ASSERTION-ERROR
:ASSERTION 'ROOM)))))
(ASSERT-HTTP (>= 16 (LENGTH ROOM)))
(LET ((NAME (AIF (CDR (ASSOC :NAME PARAMETERS))
(URI-DECODE IT)
(ERROR (MAKE-INSTANCE
'HTTP-ASSERTION-ERROR
:ASSERTION 'NAME)))))
(ASSERT-HTTP (>= 64 (LENGTH NAME) 1))
(LET ((MESSAGE (AIF (CDR (ASSOC :MESSAGE PARAMETERS))
(URI-DECODE IT)
(ERROR (MAKE-INSTANCE
'HTTP-ASSERTION-ERROR
:ASSERTION 'MESSAGE)))))
(ASSERT-HTTP (>= 256 (LENGTH MESSAGE) 5))
(LET ((RES (MAKE-INSTANCE
'RESPONSE :CONTENT-TYPE "text/html"
:COOKIE (UNLESS #:COOKIE?1111
(TOKEN SESSION))
:BODY (PROGN
(PUBLISH!
(INTERN ROOM :KEYWORD)
(ENCODE-JSON-TO-STRING
`((:NAME ,@NAME)
(:MESSAGE ,@MESSAGE))))))))
(WRITE! RES SOCK)
(SOCKET-CLOSE SOCK))))))
これで HTTP リクエスト/レスポンスの完全なサイクルに必要なバリデート処理を実装する仕組みが完成した。SSE メッセージについてはどうだろうか? make-stream-handler
は make-closing-handler
と同様の基本的な処理を行い、最後に socket-close
ではなく force-output
を呼び出す。SSE メッセージを送信した後でも接続はオープンのままにする必要があるためである:
(defmacro make-stream-handler ((&rest args) &body body)
`(lambda (sock parameters)
(declare (ignorable parameters))
,(arguments args
`(let ((res (progn ,@body)))
(write! (make-instance 'response
:keep-alive? t
:content-type "text/event-stream")
sock)
(write! (make-instance 'sse :data (or res "Listening...")) sock)
(force-output (socket-stream sock))))))
(defmacro assert-http (assertion)
`(unless ,assertion
(error (make-instance 'http-assertion-error
:assertion
',assertion))))
assert-http
はエラーに対処するボイラープレートコードを作成するマクロである。assert-http
を展開したコードは与えられたアサーションをチェックし、失敗した場合はアサーションを詰めた http-assertion-error
を送出する。
(defmacro assert-http (assertion)
`(unless ,assertion
(error (make-instance 'http-assertion-error
:assertion
',assertion))))
HTTP の「型」
前節で、HTTP の「型」をバリデートするシステムの実装で利用する三つの式 arg-exp
, type-expression
, type-assertion
を紹介した。これらを理解できれば、もうフレームワークに魔法は存在しない。最初に簡単なものを説明しよう。
arg-exp
arg-exp
はシンボルを受け取り、パラメータの存在をチェックする aif
式を作成する:
(defun arg-exp (arg-sym)
`(aif (cdr (assoc ,(->keyword arg-sym) parameters))
(uri-decode it)
(error (make-instance 'http-assertion-error
:assertion
',arg-sym))))
arg-exp
の実行例を示す:
HOUSE> (arg-exp 'room)
(AIF (CDR (ASSOC :ROOM PARAMETERS))
(URI-DECODE IT)
(ERROR (MAKE-INSTANCE
'HTTP-ASSERTION-ERROR
:ASSERTION 'ROOM)))
HOUSE>
これまでのコードで aif
と awhen
を使ってきたものの、その動作は説明してこなかった。ここで詳しく説明しておこう。
Lisp では Lisp コードを表す AST (抽象構文木) も Lisp のオブジェクトとして表されることを思い出してほしい。木の葉と枝の関係は括弧を使って表される。前節で見た処理を見返すと、make-closing-handler
は AST を返す関数 arguments
を呼び出し、その返り値を一部とする AST を返す。arguments
関数は返り値の AST を arg-exp
といった関数を呼び出しつつ構築する。
つまり、ここでは Lisp 式を入力に受け取り、それと異なる Lisp 式を出力する小さなシステムが構築されている。「直面している問題専用の Common Lisp から Common Lisp へのコンパイラを作っている」と考えると分かりやすいだろう。
こういった「コンパイラ」を指す広く知られた用語としてアナフォリックマクロ (anaphoric macros) がある。この用語は「以前に使われた単語を指す単語」を意味するアナファー (anaphor) という言語学の概念から来ている。aif
と awhen
はアナフォリックマクロであり、私はこれら以外にアナフォリックマクロを使うことはあまりない。anaphora
パッケージを見ると様々なアナフォリックマクロを確認できる。
私の知る限り、アナフォリックマクロは Paul Graham による書籍 On Lisp の第 14 章で初めて定義された。彼が示した利用例は「何らかの高価な (あるいは少しだけ高価な) 判定処理を実行し、条件が満たされるならその結果を使って処理を行う」という状況だった。上記のコードでは、連想リスト parameters
に対する探索結果を使うために aif
を使っている:
(aif (cdr (assoc :room parameters))
(uri-decode it)
(error (make-instance 'http-assertion-error
:assertion
'room)))
このコードは最初に連想リスト parameters
からシンボル :room
を探索し、その結果の cdr
を取る。この値が nil
でないなら「それ」に uri-decode
を実行し、nil
なら http-assertion-error
型のエラーを送出する。
言い換えれば、上記のコードは次のコードと等価である:
(let ((it (cdr (assoc :room parameters))))
(if it
(uri-decode it)
(error (make-instance
'http-assertion-error
:assertion 'room))))
Haskell などの強い型付けを持つ関数型言語では、こういった状況で Maybe
型が使われることが多い。Common Lisp では、展開後のコードで判定結果を参照するためにシンボル it
を利用する。
これが理解できれば、arg-exp
が最終的な AST に何度も現れる似た形の AST を生成するための関数であることが分かるだろう。つまり、ハンドラの parameters
の中に引数が存在するかどうかを判定し、存在するならデコードする処理の AST を生成している。では、次の関数を見よう。
type-expression
type-expression
に関連するコードの一部を次に示す:
(defgeneric type-expression (parameter type)
(:documentation
"type-expression は文字列を必要とされる特定の型に
変換する方法をサーバーに伝える。"))
...
(defmethod type-expression (parameter type) nil)
type-expression
は AST を返す総称関数であり、単なる関数ではない。上記のコードは type-expression
がデフォルトで nil
を返すことだけを定義する。nil
が返ったときは arg-exp
の出力がそのまま使われるものの、多くの状況では何らかの変換が必要になる。例えば、House には次の define-http-type
の呼び出しが組み込まれている:
(define-http-type (:integer)
:type-expression `(parse-integer ,parameter :junk-allowed t)
:type-assertion `(numberp ,parameter))
この呼び出しは「:integer
は parameter
から parse-integer
で作られる」と宣言している。:junk-allowed t
は parse-integer
関数に与えられるパラメータであり、入力が必ずパース可能とは限らず、パース結果が整数かどうかの確認が毎回必要になることを示す。この定義があるときの type-expression
の使用例を示す:
HOUSE> (type-expression 'blah :integer)
(PARSE-INTEGER BLAH :JUNK-ALLOWED T)
HOUSE>
define-http-handler
は House のウェブフレームワークがエクスポートするシンボルの一つである。define-http-handler
を使うと、プログラマーは組み込みの型 (:string
, :integer
, :keyword
, :json
, :list-of-keyword
, :list-of-integer
) 以外に独自の型を定義してパース処理を単純化できる。define-http-type
の定義11を次に示す:
(defmacro define-http-type ((type) &key type-expression type-assertion)
(with-gensyms (tp)
`(let ((,tp ,type))
,@(when type-expression
`((defmethod type-expression (parameter (type (eql ,tp)))
,type-expression)))
,@(when type-assertion
`((defmethod type-assertion (parameter (type (eql ,tp)))
,type-assertion))))))
define-http-type
は指定された型の type-expression
メソッドと type-assertion
メソッドを定義する。データのパースとバリデートをフレームワークのユーザーに手動で行わせたとしても大きな問題は起こらないだろう。しかし間接参照の階層をこうして追加しておけば、後でフレームワークプログラマーが型の実装を変えたとしてもユーザーは型の定義を変更する必要がない。これはアカデミックな理由だけから追加されたのではない: 最初に House を作成したとき、私はシステムの型に関する部分に大きな変更を加えたのだが、そのとき型を利用するアプリケーションに必要となった変更は嬉しいことに非常に少なかった。
define-http-type
を使った整数の型定義を展開して、その動作を確認しよう:
(LET ((#:TP1288 :INTEGER))
(DEFMETHOD TYPE-EXPRESSION (PARAMETER (TYPE (EQL #:TP1288)))
`(PARSE-INTEGER ,PARAMETER :JUNK-ALLOWED T))
(DEFMETHOD TYPE-ASSERTION (PARAMETER (TYPE (EQL #:TP1288)))
`(NUMBERP ,PARAMETER)))
先述したようにコード量はそれほど変わらないものの、マクロを使えばメソッドのパラメータを管理する必要がなくなり、さらに言えばメソッドの存在さえ気にする必要がない。
type-assertion
これで型が定義できるようになった。続いてパース結果が指定された要件を満たすかどうかを判定する type-assertion
を見よう。type-expression
と同様に type-expression
にも defgeneric
と defmethod
の組が最初にある:
(defgeneric type-assertion (parameter type)
(:documentation
"変換の直後に実行されるアサーションを表す。
特定のパラメータの値域を制限するために利用できる。"))
...
(defmethod type-assertion (parameter type) nil)
type-assertion
の実行例を次に示す:
HOUSE> (type-assertion 'blah :integer)
(NUMBERP BLAH)
HOUSE>
type-assertion
が何もしない場合もある。例えば、HTTP パラメータは文字列として与えられるので、:string
に対する type-assertion
が確認すべき条件は存在しない:
HOUSE> (type-assertion 'blah :string)
NIL
HOUSE>
全てを合わせる
やった! これでイベント駆動ウェブサーバーの実装の上にウェブフレームワークを実装できた。このウェブフレームワーク (そしてハンドラ用の DSL) では、新しいアプリケーションを次の手順で構築する:
- URL をハンドラに対応付ける。
- リクエストに対して型のチェックとバリデーションを行うハンドラを定義する。
- 必要に応じてハンドラ用の新しい型を定義する。
アプリケーションを定義する例を次に示す:
(defun len-between (min thing max)
(>= max (length thing) min))
(define-handler (source :close-socket? nil)
((room :string (len-between 0 room 16)))
(subscribe! (intern room :keyword) sock))
(define-handler (send-message)
((room :string (len-between 0 room 16))
(name :string (len-between 1 name 64))
(message :string (len-between 5 message 256)))
(publish! (intern room :keyword)
(encode-json-to-string
`((:name . ,name) (:message . ,message)))))
(define-handler (index) ()
(with-html-output-to-string (s nil :prologue t :indent t)
(:html
(:head (:script
:type "text/javascript"
:src "/static/js/interface.js"))
(:body (:div :id "messages")
(:textarea :id "input")
(:button :id "send" "Send")))))
(start 4242)
後はクライアントサイドのインターフェースを提供する interface.js
を用意すれば、このコードは HTTP チャットサーバーをポート 4242
で開始し、内向き接続を待機する。
-
この問題に対する一つの解決策として、クライアントにサーバーのポーリング (polling) を義務付ける手法が考えられる。つまり、クライアントは自身に何も変化がなくても定期的にリクエストをサーバーに送信しなければならないと定めればよい。これは単純なアプリケーションなら問題ないものの、本章ではこの手法で対処できない場合に利用できるテクニックに焦点を当てる。 ↩︎
-
さらに一般化した、\(N\) 人のユーザーを \(M\) 個のスレッドで並行に処理するモデルも考えられる。ここで \(M\) は何らかの設定可能な値を表す。このモデルで \(N\) 個の接続は \(M\) 個のスレッドで多重化 (multiplex) されていると言う。本章では \(M = 1\) のプログラムを解説するものの、その中で得られる教訓はさらに一般的なモデルにも適用できるだろう。 ↩︎
-
これらの用語が少し分かりにくいのは、OS の研究で生まれたことが関係している。「イベント駆動」と「イベントベース」は複数の並行プロセスが互いに通信する形態を指す。スレッドベースのシステムでは共有メモリなどの同期されたリソースを使ってスレッド間の通信が行われる。これに対して典型的なイベントベースのシステムでは単一の実行の流れで管理されるキューを使ってプロセス間の通信が行われる。そのキューの要素は処理の要求や完了報告なので、「イベント」と呼ばれる。 ↩︎
-
発音は「クロス」「シーロス」「シーロウス」など人によって異なる。 ↩︎
-
プログラミング言語 Julia もオブジェクト指向プログラミングに対する同様のアプローチを採用する。静的解析の章に解説がある。 ↩︎
-
訳注: ここに示された
string
型の値を受け取るparse
メソッドは、Content-Length
ヘッダーの値を整数に変換してreq
とは別に返すことを忘れている。この点を修正した実装は GitHub で公開されている House のソースコードから確認できる。 ↩︎ -
このコードでは今まで使われていなかった新しい構文が使われている。
(defparameter <name> <value> <optional docstring>)
という構文は改変可能な変数を宣言する。 ↩︎ -
with-timeout
の実装は処理系ごとに異なり、一部の環境では新しく作成したスレッドやプロセスで処理を実行する。そのような実装でスレッドまたはプロセスの作成はwrite!
の中で一度しか実行されないものの、書き込みごとに実行される処理としては時間のかかる処理となる。そのため、そういった環境では別のアプローチが必要になる可能性がある。 ↩︎ -
このコードは Common Lisp として非常に不自然なインデントを使っていることに注意してほしい。通常の書き方だと引数リストは改行されず、マクロや関数の名前と同じ行に書かれる。本書では紙幅の都合で改行が必要になったものの、単にコードを書くだけならコードの内容に沿った自然な改行をしていただろう。 ↩︎
-
このマクロの定義では人間が読みやすい出力となるように
,@
を使ってnil
を可能な限り展開している。そのため読みにくい。 ↩︎