Common LispのWebフレームワークであるCaveman 2で遊んでいる. 今日は,リクエストの前後に処理を挿入し,レスポンスヘッダをいじったりしてみるお話. ある程度Common Lispが分かりますよ,というくらいのレベル感です.僕のレベルは,マクロが書けてうれしいね,とかいうレベルです.
全てのエンドポイントで,リクエストハンドラを処理する前・後ろに一定の共通処理を行いたい,というのは実によくある話だ.例えば認証処理を行いたければリクエストハンドラに到達する前に処理を行う必要があるし,ベンチマークのための特別なヘッダを付与するためにリクエストハンドラを通った後で処理を行いたいこともある.こういった処理は頻繁に使われるが,それらがいかに実現されているかについて運用開始後に知る機会はあまりないのが現状だ.
最近Caveman 2で自分用のアプリケーションを開発していて,そこで1からレスポンスの処理などを見て処理を構築する必要に迫られたので,勉強と探検を兼ねてこのメモをのこすことにした.
登場するファイル
この記事で登場するファイルは以下の通りだ.
web.lisp
- 標準的なCaveman 2アプリケーションでは
src/
以下に作成されるファイル.リクエストハンドラを定義する.
- 標準的なCaveman 2アプリケーションでは
config.lisp
- 標準的なCaveman 2アプリケーションでは
src/
以下に作成されるファイル.アプリケーションの設定を定義する.
- 標準的なCaveman 2アプリケーションでは
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をとりまくコンポーネントを以下に図示する.
defroute
を展開して得られる処理はおおまかには以下の通りである.
caveman2.app:find-package-app
が,*web*
を,defroute
されたパッケージにおいて探す- 厳密には
caveman2.app:<app>
のインスタンスを探している.これはcaveman2.app:<app>
として定義されており,ningle:<app>
のサブクラスである - 探す先は特別なハッシュテーブル.
<app>
に対するinitialize-instance :after
が定義されているため,<app>
をインスタンス化すると,パッケージ名とインスタンスの組を*package-app-map*
に自動記録するようになっている
- 厳密には
ningle:route
とsetf
の組み合わせにより,*web*
にルーティングが書き込まれる- 内部的には
defgeneric (setf route)
されており,実際はmyway:add-route
が呼び出され,<app>
のインスタンスのmapper
スロットにルーティング定義が書き込まれる?
- 内部的には
- 場合によっては同名の関数を定義(
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のplack
がPSGI
という仕様と分離されているのとは対照的だ.))
そして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
メソッドを呼び出すように変換されているからである.
解決していそうで蛇足ですが、Lackでは可搬性を考えてナイーブに関数に変換しています。他の実現方法としてfuncallable-standard-objectをメタクラスに指定すると、オブジェクトなのにfuncallできる謎の物ができます
— fukamachi (@nitro_idiot) 2018年4月14日
ここでClackの仕事は一時中断し,Caveman 2アプリケーション,そしてそのスーパークラスであるningleアプリケーションへと処理が引き渡される.
<app>
クラスはcall
メソッドの中で,ルーティング定義に基いたリクエストハンドラのディスパッチを行う *3.ディスパッチとリクエストハンドラの処理が完了したら,<app>
クラスはレスポンスをClackに返却し,HTTPリクエストに対するレスポンスが完遂される.
リクエストハンドラはコンテキストを受け取る
さて,<app>
クラスはディスパッチの直前にコンテキストの初期化を行う.コンテキストは,リクエスト,レスポンス,セッションの3つで構成される.
defroute
で定義されたリクエストハンドラからは,これらのコンテキストを*request*
,*response*
,*session*
として参照することができる.
リクエストに対応するレスポンスは,最終的にこれらを加工することによって完了すると考えることができる.
そして,リクエストとレスポンスの初期化は,それぞれmake-request
とmake-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
メソッドで書き換えられる?
ここは蛇足なので,ただの備忘録として見てほしい.前述のやり方はこうだ.
<web>
にmake-response
メソッドを生やすmake-response
の中でcall-next-method
を呼び,スーパークラスにレスポンスを作成してもらう- スーパークラスが作成したレスポンスをいじって返す
ここをこうできないかと考えた.
<web>
にmake-response :after
補助メソッドを生やす- レスポンスをいじって返す
: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ライブラリが用いられている