深町氏 のウェブアプリケーション・ツール 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))))))