深町氏 のウェブアプリケーション・ツール Clack のソースコード・リーディング.今回は,Clackが起動時に受け取る Lack アプリケーションを理解するために,その構築プロセスと使われ方をコードで追ってみる.
以下は, Clack ページのサンプルコードである.
(defvar *handler*
(clack:clackup
(lambda (env)
(declare (ignore env))
'(200 (:content-type "text/plain") ("Hello, Clack!")))))
clack:clackup
に渡されているラムダ関数は,env
を引数に取り,ステータス・コード,ヘッダ,レスポンス・ボディの3つを返す.clackup
は,このラムダ関数をLackの app
ビルダに渡しアプリケーションを構築し,それをWebサーバーに渡す.コード 2は clackup
が受け取った app
をビルダに渡す部分である.
src/clack.lisp
のアプリケーション構築部分(flet ((buildapp (app) (1)
(let ((app (typecase app
((or pathname string) (eval-file app)) (2)
(otherwise app)))) (3)
(builder (4)
(if use-default-middlewares :backtrace nil)
app))))
(let ((app (buildapp app))) (5)
;; Ensure the handler to be loaded.
(find-handler server)
1 | 8行目で使われるローカル関数 buildapp を定義. |
2 | appがパスネームか文字列なら eval-file を使ってファイルからアプリケーションを抽出. |
3 | それ以外ならそのままapp局所変数に代入.ラムダ式はこのまま app に代入される. |
4 | ここで lack:builder マクロが呼ばれ Lack アプリケーションが構築される. |
5 | ここでローカル関数 buildapp を呼んで app を構築. |
上の4行目で呼ばれるLackの builder
マクロの定義は以下の通り.
(defmacro builder (&rest app-or-middlewares)
(let ((middlewares (butlast app-or-middlewares)))
`(reduce #'funcall
(remove-if
#'null
(list
,@(loop for mw in middlewares
when mw
collect (convert-to-middleware-form mw))))
:initial-value (to-app ,(car (last app-or-middlewares)))
:from-end t)))
むむ.これはややこしい.こういう時は,無理せずLispに展開をお願いし,展開結果を見ながら定義を見る.
builder
マクロの展開結果CL-USER> (require 'lack)
CL-USER> (require 'clack)
CL-USER> (in-package :lack.builder)
BUILDER> (macroexpand-1 '(builder :backtrace app))
(REDUCE #'FUNCALL (REMOVE-IF #'NULL (LIST (FIND-MIDDLEWARE :BACKTRACE)))
:INITIAL-VALUE (TO-APP APP) :FROM-END T)
なるほど.コード 3の loop
内で呼び出している convert-to-middleware-form
関数に,最後の引数 app
を除いたミドルウエア部分を渡し,ミドルウェアがキーワードか,シンボルか,それ以外かで,置き換え用コードを選別している.この関数はちと長いので リンク先 を見てもらうとして先へ行く.
コード 4の展開結果を見ると,我らが最初に app
として渡したラムダ式は (to-app app)
に渡される.to-app
は lack.component
パッケージで以下のように定義されている.
lack/src/component.lisp
(defclass lack-component () ())
(defgeneric call (component env)
(:method ((component function) env)
(funcall component env)))
(defgeneric to-app (component)
(:method ((component lack-component)) (1)
(lambda (env) (call component env)))
(:method ((component t)) (2)
component))
1 | to-app の引数がLackのコンポーネントなら,「env を引数にしてコンポーネントを呼び出す」ラムダ関数でラップする. |
2 | 我らが app はただのラムダ式なのでこちらが呼び出され,ラムダ式がそのまま返される. |
これでコード 3の6行目の (to-app app)
がラムダ式になることが分かった.なんだかごちゃごちゃしてきたが,要は,コード 3の5-6行目の評価結果がコード 2,8行目の app
に代入されるということだ.
ここで,コード 3の builder
マクロの展開結果に出てくる reduce
のおさらい.関数 f
をリスト '(a b c)
にreduceで適用すると,リストの左から f
を2項演算子として適用する.その際,最初の引数は前の演算結果が使われる.つまり前の適用結果で引数の位置を揃えると下のようになる.
(f a b) ; 1回目の適用
(f (f a b) c) ; 2回目の適用.第1引数は1回目の結果
(f (f (f a b) c)) ; 3回目の適用.第1引数は2回目の結果
例えば list
関数でreduceするとこんな感じ.
(reduce #'list '(a b c d))
; => (((A B) C) D)
さて,本題に戻って今確認したいのは下のコード結果.
(REDUCE #'FUNCALL (REMOVE-IF #'NULL (LIST (FIND-MIDDLEWARE :BACKTRACE)))
:INITIAL-VALUE (TO-APP APP) :FROM-END T)
:initial-value
キーワードがついているので,funcall
が適用される2項のうち最初の1項目は (to-app app)
の結果で,さらに :from-end
キーワードが付いているので後ろから2項づつ適用することになる.:from-end
の場合,:initial-value
で指定された1項目は最後尾に置かれる.nil
ではないミドルウェアが仮に (a b c d)
と3つあったとしたら,
(funcall a (funcall b (funcall c (funcall d app))))
というコードがコード 2の5-7行目の (builder (if …) app)
に置きかわり,この実行結果が8行目の app
に代入される.今回はミドルウェアに :backtrace
しか与えていないので,(find-middleware :backtrace)
は *lack-middleware-backtrace*
に設定されている値を返す( lack/src/util.lisp 参照).つまり,backtraceミドルウェアのラムダ式だ.したがって上のreduceは以下のコードを実行する.
(funcall (lambda (app) backtraceミドルウェア本体) app)
もし,他にもラップしているミドルウェアがあれば,以下のようになる.
(funcall (lambada (app) ミドルウェア1) (1)
(funcall (lambda (app) ミドルウェア2)
(funcall (lambda (app) ミドルウェア3)
app))) (2)
1 | 一番外側をラップしているミドルウェア. |
2 | env を引数に取るユーザ指定のラムダ式. |
コード 6の実行結果を得るために,backtraceミドルウェアの定義を見る.
(defparameter *lack-middleware-backtrace*
(lambda (app &key
(output '*error-output*)
result-on-error)
(check-type output (or symbol stream pathname string))
(check-type result-on-error (or function cons null))
(flet ((error-handler (condition)
(if (functionp result-on-error)
(funcall result-on-error condition)
result-on-error))
(output-backtrace (condition env)
(etypecase output
(symbol (print-error condition env (symbol-value output)))
(stream (print-error condition env output))
((or pathname string)
(with-open-file (out output
:direction :output
:external-format #+clisp charset:utf-8 #-clisp :utf-8
:if-exists :append
:if-does-not-exist :create)
(print-error condition env out))))))
(lambda (env)
(block nil
(handler-bind ((error (lambda (condition)
(output-backtrace condition env)
(when result-on-error
(return (error-handler condition))))))
(funcall app env))))))
"Middleware for outputting backtraces when an error occured")
まず,5行目から21行目はコード 2,8行目の buildapp
時に実行されるので,サーバーが起動する前に1度だけ実行される処理だ.もっとも7行目から21行目はすぐ後のラムダ関数内で使われる局所関数定義だが.
さて,コード 6の実行によって返されのは22行目の env
を引数に取るラムダ関数だ.そのラムダ関数は28行目でユーザが定義したレスポンスを返すラムダ関数をコールする.ユーザ定義のラムダ関数をラップしたbacktraceのラムダ関数が最終的にコード 2の8行目の app
に設定され,Webサーバーに渡される.
したがって,ミドルウェアの役割は,サーバーから様々なパラメータをカプセル化した env
を受け取り,それをユーザ定義のアプリケーションに渡す前に,何らかの事前処理(コード 7,23—27行目)を実行し,そしてユーザ定義のアプリケーションを呼び出すことだ.ちなみにbacktraceは事前処理しかないが,28行目以降に事後処理を追加することもできる.
まとめ
-
ミドルウェアの
app
を引数に取る外側のラムダ関数がサーバー起動前に実行され,サーバーには内側のenv
を引数に取るラムダ関数が渡される.ミドルウェアはサンクのような役割を果たす.すなわち,外側の関数が一皮むかれ内側のアプリケーションがWebレスポンス時まで遅延評価されている. -
数珠つなぎになったミドルウェアは全て起動前にreduce関数によって
funcall
され,一皮むけた内部のアプリケーションだけが返される. -
最終的にサーバーに渡される
app
の正体は,env
を引数に取り,ステータス・コード,ヘッダ,ボディを返す関数である. -
ミドルウェアをチェーンにした場合,外側ミドルウェアの事前処理から 実行され,レスポンスを返した後,内側ミドルウェアのポスト処理から 順に実行される.ようやく Lack ページに掲載の以下のコードの意味が分かった.
(defvar *mw*
(lambda (app)
(lambda (env)
;; preprocessing
(let ((res (funcall app env)))
;; postprocessing
res))))
;; getting a wrapped app
(funcall *mw* *app*)
おまけ: サーバー起動部分
最後にサーバー起動部分を見ておこう.
src/clack.lisp
のサーバー呼び出し部分(prog1
(apply #'clack.handler:run app server
:port port
:debug debug
:use-thread use-thread
(delete-from-plist args :server :port :debug :silent :use-thread))
上の2行目で clack.handler:run
に app
とWebサーバーのハンドラを渡しサーバーを起動している.
src/handler.lisp
の run
関数(defun run (app server &rest args &key use-thread &allow-other-keys)
(let ((handler-package (find-handler server))
(bt:*default-special-bindings* `((*standard-output* . ,*standard-output*)
(*error-output* . ,*error-output*)
,@bt:*default-special-bindings*)))
(flet ((run-server ()
(apply (intern #.(string '#:run) handler-package)
app
:allow-other-keys t
args)))
(make-handler
:server server
:acceptor (if use-thread
(bt:make-thread #'run-server
:name (format nil "clack-handler-~(~A~)" server)
:initial-bindings
`((bt:*default-special-bindings* . ',bt:*default-special-bindings*)
,@bt:*default-special-bindings*))
(run-server))))))