コードリーディング: Lack アプリケーションを理解する

深町氏 のウェブアプリケーション・ツール 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サーバーに渡す.コード 2clackup が受け取った app をビルダに渡す部分である.

(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に展開をお願いし,展開結果を見ながら定義を見る.

コード 4. 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)

なるほど.コード 3loop 内で呼び出している convert-to-middleware-form 関数に,最後の引数 app を除いたミドルウエア部分を渡し,ミドルウェアがキーワードか,シンボルか,それ以外かで,置き換え用コードを選別している.この関数はちと長いので リンク先 を見てもらうとして先へ行く.

コード 4の展開結果を見ると,我らが最初に app として渡したラムダ式は (to-app app) に渡される.to-applack.component パッケージで以下のように定義されている.

(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 に代入されるということだ.

ここで,コード 3builder マクロの展開結果に出てくる 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は以下のコードを実行する.

コード 6. 今回buildされるLackアプリケーション
(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 ページに掲載の以下のコードの意味が分かった.

コード 8. ミドルウェアの内部構造(公式ページより)
(defvar *mw*
  (lambda (app)
    (lambda (env)
      ;; preprocessing
      (let ((res (funcall app env)))
        ;; postprocessing
        res))))

;; getting a wrapped app
(funcall *mw* *app*)

おまけ: サーバー起動部分

最後にサーバー起動部分を見ておこう.

コード 9. 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:runapp とWebサーバーのハンドラを渡しサーバーを起動している.

コード 10. src/handler.lisprun 関数
(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))))))