Lambdaカクテル

Common Lispと自宅サーバにWebエンジニアリングの香りを載せて

Common LispのWebフレームワーク「Caveman2」のリクエスト処理を眺める

Common LispのWebフレームワークであるCaveman 2で遊んでいる. 今日は,リクエストの前後に処理を挿入し,レスポンスヘッダをいじったりしてみるお話. ある程度Common Lispが分かりますよ,というくらいのレベル感です.僕のレベルは,マクロが書けてうれしいね,とかいうレベルです.

全てのエンドポイントで,リクエストハンドラを処理する前・後ろに一定の共通処理を行いたい,というのは実によくある話だ.例えば認証処理を行いたければリクエストハンドラに到達する前に処理を行う必要があるし,ベンチマークのための特別なヘッダを付与するためにリクエストハンドラを通った後で処理を行いたいこともある.こういった処理は頻繁に使われるが,それらがいかに実現されているかについて運用開始後に知る機会はあまりないのが現状だ.

最近Caveman 2で自分用のアプリケーションを開発していて,そこで1からレスポンスの処理などを見て処理を構築する必要に迫られたので,勉強と探検を兼ねてこのメモをのこすことにした.

登場するファイル

この記事で登場するファイルは以下の通りだ.

  • web.lisp
    • 標準的なCaveman 2アプリケーションではsrc/以下に作成されるファイル.リクエストハンドラを定義する.
  • config.lisp
    • 標準的なCaveman 2アプリケーションではsrc/以下に作成されるファイル.アプリケーションの設定を定義する.
  • app.lisp
    • アプリケーションのルートディレクトリに作成されるファイル.Caveman 2アプリケーションの頂点であり,Webサーバ(Clack)はこれを読み込む.Clackアプリケーションである.
  • その他,Caveman 2,Ningle,Clack,lackといったフレームワーク/ライブラリを構成するファイル

リクエストハンドラの処理後に,Gitのリビジョンをヘッダに挿入する

処理前にフックするのも後にフックするのもやる事はおおかた同じなので,今回はリクエストハンドラの処理にフックしてみる.題材として,Caveman 2アプリケーションがあるgitレポジトリのmasterブランチのリビジョンを,X-Revisionヘッダとしてレスポンスに挿入する,というシナリオを考えていこう.

defrouteは何をしているのか

リクエストハンドラの前後で処理を行いたいのだから,まずはリクエストハンドラを定義するdefrouteから流れを追いつつ,Caveman 2がWebアプリケーションをどのように表現しているか確認していこう. Caveman 2の標準構成では,エンドポイントを定義するためにdefrouteを使うが,これはただのマクロであって,いくつかの処理をまとめているにすぎない.defrouteは,*web*にルーティング定義を書き込むためのマクロだ.

そもそもCaveman 2はWebアプリケーションフレームワークとしての最小限の機能を,より小さなフレームワークであるningleに分割した構成になっている.違う言い方をすれば,ningleにDBアクセスやコンフィギュレーション,JSON処理,テンプレートエンジンなどの付加機能を加え,使いやすくしたものがCaveman 2である.したがって,基本的な構成要素はningleのものがそのまま流用されたり,それを継承したりしている.ここでルーティング定義を書き込む*web*も,ningleの<app>クラスそのもの*1である.

Caveman 2をとりまくコンポーネントを以下に図示する.

f:id:Windymelt:20180416005913p:plain

defrouteを展開して得られる処理はおおまかには以下の通りである.

  1. caveman2.app:find-package-appが,*web*を,defrouteされたパッケージにおいて探す
    • 厳密にはcaveman2.app:<app>のインスタンスを探している.これはcaveman2.app:<app>として定義されておりningle:<app>のサブクラスである
    • 探す先は特別なハッシュテーブル.<app>に対するinitialize-instance :afterが定義されているため,<app>をインスタンス化すると,パッケージ名とインスタンスの組を*package-app-map*に自動記録するようになっている
  2. ningle:routesetfの組み合わせにより,*web*にルーティングが書き込まれる
  3. 場合によっては同名の関数を定義(defun)する.
    • 名前付きでルーティングを定義しようとした場合のみ.

以上のような流れで,defrouteによって*web*にルーティング定義が書き込まれる.

ところで,実際にweb.lispで定義されている*web*caveman2.app:<app>の直接のインスタンスではなく,*web*とともに定義されたcaveman2.app:<app>のサブクラス<web>のインスタンスである.これを応用して独自のフックを後程定義していくので,頭の片隅に置いておいてほしい.

Clackアプリケーションの定義

web.lisp*web*が定義されていることがわかった.しかし実際にこのアプリケーションを動作させるにはclackupコマンドを使うか,start関数を呼び出す必要がある.start関数は内部的にclackup app.lispとまったく同様のことをしているから,clackupしてからどのようにリクエストハンドラに処理が渡っていき,そしてレスポンスが返されるのかを考えてみたい.

ClackはCommon Lispで動作するWebサーバを抽象化する.Webサーバの実装に依存しないWebアプリケーション用の環境を用意してくれる便利なレイヤで,Rubyにおけるrack,perlにおけるplackにあたるフレームワークだ.((Clackは仕様と実装を合わせた呼び方のようで(ちょっと自信がない),PerlのplackPSGIという仕様と分離されているのとは対照的だ.))

そしてlackは,ningleアプリケーションや,それを継承したcaveman 2アプリケーションを,より可搬な形式であるClackアプリケーションに変換するツールだ. app.lispが呼び出しているのはこのlackで,lack:buildによって*web*をもとにしたClackアプリケーションを構築している.この過程で静的ファイル配信などが設定されている. clackupコマンドはこれを読み込み,WebアプリケーションをWebサーバとともに起動することで,実際にHTTP接続を処理できるようにする.

HTTPリクエストを受信したClackは,Webサーバからの情報を正規化し,環境と呼ばれるものを作成する*2. そして環境を引数にClackアプリケーションを呼び出す((より正確には,環境envを引数としてfuncallする.これもWebサーバ別に実装されている.例: https://github.com/fukamachi/clack/blob/master/src/handler/fcgi.lisp#L52)). するとここで*web*インスタンスのcallが呼び出される.なぜならlack:builderの変換作用によって,Clackアプリケーションに対する関数呼び出しは<app>クラスのcallメソッドを呼び出すように変換されているからである.

ここでClackの仕事は一時中断し,Caveman 2アプリケーション,そしてそのスーパークラスであるningleアプリケーションへと処理が引き渡される.

<app>クラスはcallメソッドの中で,ルーティング定義に基いたリクエストハンドラのディスパッチを行う *3.ディスパッチとリクエストハンドラの処理が完了したら,<app>クラスはレスポンスをClackに返却し,HTTPリクエストに対するレスポンスが完遂される.

リクエストハンドラはコンテキストを受け取る

さて,<app>クラスはディスパッチの直前にコンテキストの初期化を行う.コンテキストは,リクエストレスポンスセッションの3つで構成される. defrouteで定義されたリクエストハンドラからは,これらのコンテキストを*request**response**session*として参照することができる. リクエストに対応するレスポンスは,最終的にこれらを加工することによって完了すると考えることができる.

そして,リクエストとレスポンスの初期化は,それぞれmake-requestmake-responseメソッドで行われる.

フックを作成する

ここまで来てようやく,リクエストの前後に処理を挟むための準備が整った.リクエストの前後に処理を挟むには,<app>がクラスであり,継承できることを利用する. <app>クラスを継承したサブクラスにmake-responseメソッドを作成し,一度スーパークラスのmake-responseメソッドを呼び出してからヘッダを追加すればよさそうだ.

<app>クラスのサブクラスといったが,それはもう既に<web>としてweb.lispで定義されている.これにメソッドを生やして次のようにする.

(defclass <web> (<app>) ())       ; 既に<app>を継承した<web>が定義されている
(defmethod make-response ((app <web>) &optional status headers body)
  "<app>よりも<web>に特化したmake-responesを定義する"
  (let ((res (call-next-method))) ; スーパークラスを呼び出してレスポンスresを作成してもらう
    ;; ↓はヘッダ書き換えのイディオム.X-Revisionヘッダとして"hogehoge"を追加する
    (setf (getf (response-headers res) :X-Revision) "hogehoge")
    ;; レスポンスresを返してスーパークラスのインターフェイスに合わせる
    res))

これをweb.lispに定義しておいた状態で,アプリケーションを起動してみる./に対応するハンドラは既に定義されているものとする(デフォルトでは勝手に作成されるはずだ).

$ cd CAVEMAN2_APP_ROOT_DIR/
$ clackup app.lisp
...
Hunchentoot server is going to start.
Listening on localhost:5000.

Clackアプリケーションが起動したら,別のシェルからCurlを使ってヘッダを確認する.

$ curl -I localhost:5000 # -IはHEADするオプション
HTTP/1.1 200 OK
Date: Sun, 15 Apr 2018 04:47:48 GMT
Server: Hunchentoot 1.2.37
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: private
X-Revision: hoehoge
Content-Type: text/html
Set-Cookie: lack.session=...; path=/; expires=Sat, 28 Jul 2136 09:33:50 GMT

実験は成功だ!これでレスポンス前後のタイミングに処理を追加できるようになった. もう各エンドポイントに処理を一々追加して回る必要はない.

余談: :afterメソッドで書き換えられる?

ここは蛇足なので,ただの備忘録として見てほしい.前述のやり方はこうだ.

  1. <web>make-responseメソッドを生やす
  2. make-responseの中でcall-next-methodを呼び,スーパークラスにレスポンスを作成してもらう
  3. スーパークラスが作成したレスポンスをいじって返す

ここをこうできないかと考えた.

  1. <web>make-response :after補助メソッドを生やす
  2. レスポンスをいじって返す

:after補助メソッドは基本メソッドの後から呼び出されるから,この手の変更に向いているのかと思ったが,これを実現するには:after補助メソッドが基本メソッドからresを受け取る必要があり,その方法がよくわからなかったことと,:afterメソッドの返り値が基本メソッドの返り値になるのかがよくわからなかったことから諦めた.

リビジョンを取得する

さて,長い旅路の果てにヘッダを追加することができるようになった. 今度はそのヘッダの中身である,masterブランチのリビジョンを取得して,ヘッダに設定できるようにしたい.

masterブランチのリビジョンは,.git/refs/heads/masterの中身を見ることで気楽に取得できる. この処理をコードに落としてみよう.

(with-open-file (s (merge-pathnames #P".git/refs/heads/master" hoge.config:*application-root*))
  (read-line s nil nil))

上掲のコードでは,まずmasterへのパスを構築する.幸運にも*application-root*が,Caveman2アプリケーションのsystemが定義されている(.asdファイルの場所がある)ディレクトリを示すパスとしてconfig.lispに定義されているので,merge-pathnamesでパスを合体させて絶対パスを生成する.(merge-pathnamesは引数の順序が直感的ではないので気を付けたい).次にそのパスをファイルとしてオープンし,1行読んで閉じる.

これを先程のヘッダー追加処理に埋め込めば,masterブランチのリビジョンを埋め込むことができるようになる.

(defmethod make-response ((app <web>) &optional status headers body)
  "<app>よりも<web>に特化したmake-responesを定義する"
  (let ((res (call-next-method))
        (master-revision ; 一旦変数に受ける
          (with-open-file (s (merge-pathnames #P".git/refs/heads/master" hoge.config:*application-root*))
            (read-line s nil nil)))
    (setf (getf (response-headers res) :X-Revision) master-revision)
    res))

これで先程と同じようにcurl -Iするとgitのmasterブランチのリビジョンが表示される!やった!

$ curl -I localhost:5000
HTTP/1.1 200 OK
Date: Sun, 15 Apr 2018 05:10:55 GMT
Server: Hunchentoot 1.2.37
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: private
X-Revision: 29aca08a9729fec7e20e18352d7aecd0572e9ca6
Content-Type: text/html
Set-Cookie: lack.session=...; path=/; expires=Sat, 28 Jul 2136 10:21:46 GMT

わ〜い!すご〜い!

高速化する

賢明な人間なら気付く話だが,これではアクセスのたびにファイルにアクセスしてしまう.性能の悪化は火を見るより明らかだ.アプリケーションの起動時に一度だけ読み込み,それ以降はメモリの上から読み込めるようにする手段さえあれば・・・そう,configを使おう.

Configは標準的なCaveman 2プロジェクトであればconfig.lispに定義されている. ファイルを眺めてみるといくつかのdefconfigが定義されているのがわかるはずだ. (defconfig :common)としている箇所は,直感通り他のconfigのベースとなる.ここにリビジョン情報を保管すればよさそうだ.

Configに動的に情報を追加するのは簡単だ.なぜならConfigはただの属性リストとして表現されているからだ.これを準クォート(quasiquote)して動的にリビジョンを読み込ませれば,後はその値が使われ続ける.(defconfig :common)のリストを準クォートして,その末尾に先程のリビジョン読み込みコードを埋め込んでみよう.

(defconfig :common
  `(; ...
    :version ,(with-open-file (s (merge-pathnames #P".git/refs/heads/master" *application-root*))
                (read-line s nil nil))))

あとはヘッダを追加する処理で,このConfigを読むように書き換えてみる.現在有効なConfigは,config関数で読み出せるので・・・

(defmethod make-response ((app <web>) &optional status headers body)
  (let ((res (call-next-method)))
    (setf (getf (response-headers res) :X-Revision) (config :version))
    res))

リクエスト処理の速度を殺さずに,Gitのリビジョンを返せるようになった.ClackやCaveman 2の仕組みにも詳しくなれたし,いいことづくめだ!

おわりに

ヘッダを操作する話よりも,Clack/Caveman 2のコラボレーションを解き明かす作業のほうがはるかに分量において勝ってしまったが,勉強なのでよしとしたい. フレームワークの流れを追ううちに,「それほど難しいことはやっていないのだな」ということが分かり,フレームワークは覗こうと思えば覗けるものなのだという意識ができた. 機会があれば,Clackミドルウェアなどを試しに作ってみて,どのように動くかを確認してみたい.

追記(2018.04.16)

  • ルーティングライブラリmywayに関する記述を削減した
    • 冗長でわかりにくくなるため
  • 図を追加

*1:実際はサブクラス

*2:これはWebサーバ別に実装されている.例: https://github.com/fukamachi/clack/blob/master/src/handler/fcgi.lisp#L133

*3:ルーティングにはmywayライブラリが用いられている