PSGI/Plackオレオレ入門
ここ数日、Amon2を解読していたのですが、結局PSGI/Plackをちゃんと理解してないと話にならないことが分かったので、PSGI/Plackを一から勉強してみました。ということでその記録です。
PSGI/Plackについて
PythonにおけるWSGI、RubyにおけるRack、それに相当するのがPerlにおけるPSGIです。このあたりは、前にまとめましたし、他のところでも語り尽くされているネタですので、ここでは省略いたします。
PlackはPSGIなWebアプリケーションを開発するための汎用的なツールキットです。PSGIサーバーのリファレンス実装も含まれています。PSGIサーバー部分は、StarmanやStarletなど、多くのサードパーティ実装があります。Plack自体がよく出来たライブラリ群なので、Amon2やKossyなど、Plackが提供する機能をベースとして、薄く機能拡張をしているWAFも数多くあります。もちろん、独自の哲学を持ったWAFでPSGI対応しているものもあります。
この記事は、WAFを理解するためにPlack自体を理解する必要がありそうだ、という動機で書き始めたものなので、ひたすら素のPlackを触っていきます。
最初のPSGI/Plackアプリ
早速ですがサンプルコードです。
# app.psgi use strict; use warnings; use utf8; use Data::Dumper; my $app = sub { my $env = shift; return [ 200, ["Content-Type", "text/plain"], [$env->{REMOTE_ADDR}, "\n", Dumper($env)], ]; };
# cpanfile requires 'Plack';
$ carton install $ carton exec -- plackup app.psgi
エントリーポイントとなる関数を返すモジュールを作って*1、plackupで実行する。これが基本です。
関数は、第一引数($env)に、HTTPのヘッダなどの情報が良い感じにまとめられたハッシュ(PSGIで規定)のリファレンスを受け取ります。
plackupは色々オプションを取りますが、自動リロードする"-r"オプションは非常に便利なので覚えておくと良いです。また、Plackには、plackup以外にも色々な起動方法がありますので、気になる方は以下を参照しましょう。
静的ファイルの取り扱い
Plackでは、静的ファイルを扱うappを作るためのPlack::App::Fileモジュールが提供されています。
use strict; use warnings; use utf8; use Plack::App::File; Plack::App::File->new(root => ".")->to_app;
このコードにより、現在のディレクトリ"."をルートとして、URLに相当するファイルが表示されるようになります。例えば、"http://localhost:5000/foo/bar.txt"にアクセスすると、"./foo/bar.txt"を表示しようとします。ファイル存在しない場合は"not found"と表示されます。
use strict; use warnings; use utf8; use Plack::App::File; Plack::App::File->new(file => "./app.psgi")->to_app;
こうした場合は、どのURLにアクセスしても"./app.psgi"が表示されます。
NOTE: 演算子オーバーロードとPlack::Component#to_app_auto
先ほどのコードですが、実は->to_app
を省略しても動きます。
Plack::App::File->new(file => "./app.psgi");
この理由は、Plack::App::Fileのインスタンスがデリファレンスされる際に、親クラスであるPlack::Componentで定義されているto_app_autoというメソッドが呼ばれるためです。
該当部分のコードを見てみると以下のようになっています。
package Plack::Component; #... use overload '&{}' => \&to_app_auto, fallback => 1; #... sub to_app_auto { my $self = shift; if (($ENV{PLACK_ENV} || '') eq 'development') { my $class = ref($self); warn "WARNING: Automatically converting $class instance to a PSGI code reference. " . "If you see this warning for each request, you probably need to explicitly call " . "to_app() i.e. $class->new(...)->to_app in your PSGI file.\n"; } $self->to_app(@_); } #... sub prepare_app { return } sub to_app { my $self = shift; $self->prepare_app; return sub { $self->call(@_) }; } ...http://cpansearch.perl.org/src/MIYAGAWA/Plack-1.0030/lib/Plack/Component.pm
use overloadで、関数デリファレンス演算子"&"をオーバーロードを宣言しており、Plack::Componentインスタンスが&$component;
のように参照された時に、自動的にto_app_autoが呼ばれる仕組みとなっているわけですね。ただ、WARNINGの記述を見る限り、場合によっては->to_app
を明示的に書かないとならないようです。
通常は、->to_app
しておくのが無難だと思います。
DSLを提供するPlack::Builder
実際にPlackを使って開発する場合、エントリーポイントを一から書くのは面倒なので、Plack::Builderを使うのが一般的です。
Plack::Builderは幾つかの言語内DSLを提供してくれます。
use strict; use warnings; use utf8; use Plack::Builder; use Plack::App::File; my $app = Plack::App::File->new(root => ".")->to_app; builder { mount "/source" => Plack::App::File->new(file => './app.psgi'); mount "/" => $app; };
builder関数に、色々な設定を書いたブロックを渡すとappで返ってくる仕組みなっています(Perlのブロックについては別記事を参照)。
また、builder内でmount関数を使うとURL毎にappをセットすることが出来ます。ただし、mountの速度自体は遅いようなので、大量に使うのは避けたほうが良さそうです。詳しくは以下を参照して下さい。
mountに指定するのはappについても、当然、builderで生成することが出来ます(サンプルコード)。
Plack::Builder with Plack::Middleware
builderのブロック内ではenable関数でPlack::Middlewareのモジュールを利用することが出来ます。
例えば、先ほど静的ファイルについてはPlack::App::Fileを使う方法を書きましたが、実際には、Plack::App::FileをラップしたPlack::Middleware::Staticを使うのが一般的です。
use strict; use warnings; use utf8; use Plack::Builder; use File::Spec; use File::Basename qw/dirname/; use Data::Dumper; my $app = sub { my $env = shift; return [ 200, ["Content-Type", "text/plain"], [$env->{REMOTE_ADDR}, "\n", Dumper($env)], ]; }; builder { enable "Plack::Middleware::Static", path => qr{^/static/}, root => File::Spec->catdir(dirname(__FILE__), '.'); $app; };
このコードの場合、"http://localhost:5000/static/hoge.txt"へアクセスすると、
"(root)/static/hoge.txt"が参照されます。pathに指定したqr{^/static/}
にマッチするルート相対URL*2について、マッチした部分が排除されて"(root)/hoge.txt"を参照するのではなく、URLそのままで"(root)/static/hoge.txt"が参照されます*3。
内部的には、URLそのままでPlack::App::Fileへ処理が委譲されるので、この様な仕様になっています。
$self->{file} ||= Plack::App::File->new({ root => $self->root || '.', encoding => $self->encoding, content_type => $self->content_type }); local $env->{PATH_INFO} = $path; # rewrite PATH return $self->{file}->call($env);http://cpansearch.perl.org/src/MIYAGAWA/Plack-1.0030/lib/Plack/Middleware/Static.pm
NOTE: ハッシュに対するlocal宣言
my, local, ourの各宣言については以下によくまとまっています。
が、上に載せたソースコードでは、ハッシュの参照先にlocal宣言をつけています。
そんなことも出来るのかーと思い、ちょっと実験してみました。
use strict; use warnings; use utf8; use Data::Dumper; my $val = +{ 'foo' => 1, 'bar' => 2, }; sub print_val { print Dumper($val); } print_val; { local $val->{foo} = 100; print_val; } print_val;
実行結果
$ perl t.pl $VAR1 = { 'bar' => 2, 'foo' => 1 }; $VAR1 = { 'bar' => 2, 'foo' => 100 }; $VAR1 = { 'bar' => 2, 'foo' => 1 };
$envの中身をもう少し
もう少し詳しく$envの中身を見てみます。$envの内容は、前述の通り、PSGIによって定められています。
ここでは、Webアプリケーションで使えそうな情報を出力してみます。パラメータが必ず存在するのか等、細かい部分はPSGI仕様書を確認して下さい。
use strict; use warnings; use utf8; use Plack::Builder; use File::Spec; use File::Basename qw/dirname/; use Encode; use Data::Dumper; my $extracting = +{ REQUEST_METHOD => 'HTTPメソッド(GET, POST, ...)', REQUEST_URI => '生のルート相対URL', SCRIPT_NAME => 'アプリケーションが割り当てられているURL(ルート相対)', PATH_INFO => 'REQUEST_URIからSCRIPT_NAMEを削った感じのURL(URIデコード済み)', QUERY_STRING => '?以降の文字列', HTTP_HOST => 'ホスト名+ポート番号(HTTP_*はHTTPリクエストヘッダに相当)。SCRIPT_ROOTなどと関係があるので仕様書要参照。', 'psgi.url_scheme' => 'http/https(psgi.*はPSGI特有のキー。沢山ある。)', }; builder { enable 'Plack::Middleware::Static', path => qr{^/static/}, root => File::Spec->catdir(dirname(__FILE__), '.'); mount '/foobar' => sub { my $env = shift; my $data = ''; while (my ($k, $v) = each $extracting) { $data .= "KEY: $k\n"; $data .= "VALUE: $env->{$k}\n"; $data .= "DESCRIPTION: $v\n\n"; } return [ 200, ['Content-Type', 'text/plain'], [encode('UTF-8', $data)], ] }; mount '/' => sub { my $env = shift; return [ 200, ['Content-Type', 'text/plain'], [Dumper($env)], ] }; };
適当に起動して"http://localhost:5000/foobar/hoge/huga?a=1&b=two"へアクセスすると以下の様な出力が得られます。
KEY: REQUEST_URI VALUE: /foobar/hoge/huga?a=1&b=two DESCRIPTION: 生のルート相対URL KEY: SCRIPT_NAME VALUE: /foobar DESCRIPTION: アプリケーションが割り当てられているURL(ルート相対) KEY: psgi.url_scheme VALUE: http DESCRIPTION: http/https(psgi.*はPSGI特有のキー。沢山ある。) KEY: PATH_INFO VALUE: /hoge/huga DESCRIPTION: REQUEST_URIからSCRIPT_NAMEを削った感じのURL(URIデコード済み) KEY: REQUEST_METHOD VALUE: GET DESCRIPTION: HTTPメソッド(GET, POST, ...) KEY: HTTP_HOST VALUE: localhost:5000 DESCRIPTION: ホスト名+ポート番号(HTTP_*はHTTPリクエストヘッダに相当)。SCRIPT_ROOTなどと関係があるので仕様書要参照。 KEY: QUERY_STRING VALUE: a=1&b=two DESCRIPTION: ?以降の文字列
NOTE: エンコードについて
Perlでの日本語の取り扱いに関しては、以下のルールを覚えておけば良さそうです。
日本語などのマルチバイト文字をPerlで適切に扱うにはEncodeモジュールを使用します。次の3つのことを覚えておけば多くの場合適切に日本語を扱うことができます。
Encode - 日本語などのマルチバイト文字列を適切に処理する / Perlモジュール徹底解説 - サンプルコードによるPerl入門 〜 Perlで楽しくプログラミングを学ぼう 〜
- 外部から入力された文字列はEncodeモジュールのdecode関数でデコードする
- 外部へ出力する文字列はEncodeモジュールのencode関数でエンコードする
- ソースコードはUTF-8で保存しutf8プラグマを有効にする
他の言語と大体同じですね。
Plack::RequestとPlack::Response
$envにはWebアプリケーションを作るのに必要十分な情報が入っているので、それだけでもアプリは作れます。
が、そのままだとちょっと扱いづらいじゃない?ということで、通常は、$envをいい感じにラップしてくれるPlack::RequestとPlack::Responseが用いられます。
use strict; use warnings; use utf8; use Plack::Request; use Plack::Response; sub { my $env = shift; my $req = Plack::Request->new($env); my $a = $req->param('a'); my $b = $req->param('b'); my $r; if (defined($a) and defined($b)) { $r = $a + $b; } else { $r = "set parameter 'a' and 'b' as number"; } my $res = Plack::Response->new(200); $res->headers({'Content-Type' => 'text/plain'}); $res->body($r); $res->finalize; }
Plack::Request#new_responseでPlack::Responseへの依存を無くすことも出来ます。
use strict; use warnings; use utf8; use Plack::Request; sub { my $env = shift; my $req = Plack::Request->new($env); my $a = $req->param('a'); my $b = $req->param('b'); my $r; if (defined($a) and defined($b)) { $r = $a + $b; } else { $r = "set parameter 'a' and 'b' as number"; } my $res = $req->new_response; $res->status(200); $res->headers({'Content-Type' => 'text/plain'}); $res->body($r); $res->finalize; }
これは、依存関係少ないほうが拡張性高くなるよね、という配慮のようです。
Creates a new Plack::Response object. Handy to remove dependency on Plack::Response in your code for easy subclassing and duck typing in web application frameworks, as well as overriding Response generation in middlewares.
http://search.cpan.org/~miyagawa/Plack-1.0030/lib/Plack/Request.pm#new_response
最初のWebアプリケーション
ここまでに掲載したサンプルは、Webアプリケーションと呼ぶには微妙な代物なので、もう少しそれっぽいサンプルを作ってみました。
GETやPOSTを受け付けて動的にHTML文書を生成します。
use strict; use warnings; use utf8; use Plack::Builder; use Plack::Request; use HTML::Entities; use Encode; my $app = sub { my $env = shift; my $req = Plack::Request->new($env); my $body = <<'EOT'; <!DOCTYPE html> <meta charset="utf-8" /> <title>Hello PSGI!</title> <body> EOT my $msg = $req->param("msg"); if (defined($msg)) { $msg = encode_entities(decode('UTF-8', $msg)); $body .= "<p>$msg</p>"; } else { $body .= <<'EOT'; <form method="GET" action="/"> <h2>GET</h2> <input type="text" name="msg" /><input type="submit" /> </form> <form method="POST" action="/"> <h2>POST</h2> <input type="text" name="msg" /><input type="submit" /> </form> EOT } $body .= '</body>'; my $res = $req->new_response; $res->status(200); $res->headers({'Content-Type' => 'text/html; charset=UTF-8'}); $res->body(encode('UTF-8', $body)); $res->finalize; };
途中のdecodeは日本語(マルチバイト)入力に対応するため、encode_entitiesはXSSを避けるために行っています。
しかしテンプレートエンジンがないと見苦しいですね。
Xslate: Perl5用CPAN最速テンプレートエンジン
PSGI/Plackの話から少し脱線しますが、やっぱりテンプレートエンジンが無いと話にならないので、とにかく速いらしいXslateというテンプレートエンジンを使ってみます。
Plackにテンプレートエンジンは含まれていないので、cpanfileに追加してcarton installします。
# cpanfile requires 'Plack'; requires 'Text::Xslate';
Xslateでは、テンプレートの文法を色々選べるようになっていますが、ここではデフォルトのKolonという文法を利用します。ちょっと癖もありますが、基本は<: $var :>
です。
早速先ほどのコードに組み込んでみます。
余談ですが、Perlではクォートによる文字列でも改行することができるので、ヒアドキュメントの必要性は薄れている、というかあまり好ましくないらしいです(ソースは知恵袋)。
use strict; use warnings; use utf8; use Plack::Builder; use Plack::Request; use HTML::Entities; use Encode; use Text::Xslate; my $tx = Text::Xslate->new(); my $html = q{ <!DOCTYPE html> <meta charset="utf-8" /> <title>Hello PSGI!</title> <body> : if $msg == nil { <form method="GET" action="/"> <h2>GET</h2> <input type="text" name="msg" /><input type="submit" /> </form> <form method="POST" action="/"> <h2>POST</h2> <input type="text" name="msg" /><input type="submit" /> </form> : } else { <p><: $msg :></p> : } </body> }; my $app = sub { my $env = shift; my $req = Plack::Request->new($env); my $msg = $req->param("msg"); $msg = decode('UTF-8', $msg) if (defined($msg)); my $body = $tx->render_string($html, {msg => $msg}); my $res = $req->new_response; $res->status(200); $res->headers({'Content-Type' => 'text/html; charset=UTF-8'}); $res->body(encode('UTF-8', $body)); $res->finalize; };
随分スッキリしました。前のコードでは、encode_entitiesしましたが、上のコードではKolon側でエンコードしてくれるので外しています。
Kolonについては以下の記事が参考になります。
Sinatra風にする
テンプレートエンジンでスッキリしたとは言え、単一ファイルだけではまともな開発ができないので、ディレクトリ構成を見直すとともに、Sinatra風のコントローラーが使えるようにしてみます。
まず、ディレクトリ構成ですが、とりあえず以下のようにすることにしました。DBやテストなどは後ほど考えることにします。
MyApp/ +- script/ | +- app.psgi | +- lib/ | +- MySinatra.pm | +- static/ | +- 任意の静的ファイル | +- view/ | +- テンプレートファイル
MySinatra.pmでSinatra風の関数を使えるようにし、アプリの実装はapp.psgiで行います。通常のWAF*4とは異なる設計ですが、とりあえずこれで突き進みます。
app.psgiからlib内のモジュールを参照するので、plackupのコマンドは以下のようになります。
$ carton exec -- plackup script/app.psgi -Ilib -R lib
"-r"だとapp.psgi自体と、app.psgiのあるディレクトリにあるlibディレクトリがwatchされますが、ここではMyApp/libをwatchしたいので、"-R"で明示的に指定しています。
MyApp/lib/MySinatra.pm
こんな感じで作ってみました。まともにテストしていないので、バグがあるかもしれませんが、そこはご愛嬌。
package MySinatra; use strict; use warnings; use utf8; use Plack::Request; use Encode; use Text::Xslate; use File::Spec; use File::Basename qw/dirname/; sub new { my $class = shift; bless { tx => Text::Xslate->new({ path => File::Spec->catfile( dirname(__FILE__), File::Spec->updir, 'view'), }), handlers => {}, }, $class; } sub to_app { my $self = shift; sub { my $env = shift; my $req = Plack::Request->new($env); my $res = $req->new_response; $res->status(200); $res->headers({'Content-Type' => 'text/html; charset=UTF-8'}); my $handler = $self->{handlers}{$req->method}{$req->path_info}; if (defined($handler)) { $handler->($req, $res); } else { $res->body("not found"); } $res->finalize; }; } sub get { my $self = shift; my ($path, $handler) = @_; $self->{handlers}{GET}{$path} = $handler; } sub post { my $self = shift; my ($path, $handler) = @_; $self->{handlers}{POST}{$path} = $handler; } sub param { my $self = shift; my ($req, $name) = @_; decode_utf8($req->param($name)); } sub render { my $self = shift; my ($view_path, $vars) = @_; encode_utf8($self->{tx}->render($view_path, $vars)); } 1;
とりあえずgetとpostのみサポートしています。
paramとrenderはヘルパー的な存在です。UTF-8のencode/decodeをしたかったので強引な実装になってしまいましたが、ちゃんと作るのであれば、Plack::Responseとかを継承してquery_parametersなどをオーバーライドするのが正解だと思います。Amon2::Web::Requestなどが実装の参考になります。
ちなみに、encode/decodeは、これまではencode('UTF-8', $str)
のようにしてきましたが、これと同等のEncode::encode_utf8というのがあったのでそれを使うことにしました。decodeも同様です。
次は使う側です。
MyApp/script/app.psgi, MyApp/view/index.tx
app.psgiはこんな感じです。
use strict; use warnings; use utf8; use MySinatra; my $ms = MySinatra->new(); my $root = sub { my ($req, $res) = @_; my $msg = $ms->param($req, 'msg'); my $body = $ms->render('index.tx', {msg => $msg}); $res->body($body); }; $ms->get('/' => $root); $ms->post('/' => $root); use Plack::Builder; use File::Spec; use File::Basename qw/dirname/; builder { enable 'Plack::Middleware::Static', path => qr{^/static/}, root => File::Spec->catdir(dirname(__FILE__), File::Spec->updir); $ms->to_app; };
index.txはコピペしただけです。
<!DOCTYPE html> <meta charset="utf-8" /> <title>Hello PSGI!</title> <body> : if $msg == nil { <form method="GET" action="/"> <h2>GET</h2> <input type="text" name="msg" /><input type="submit" /> </form> <form method="POST" action="/"> <h2>POST</h2> <input type="text" name="msg" /><input type="submit" /> </form> : } else { <p><: $msg :></p> : } </body>
最後にセッション周りを少々
ここまで来ると、それこそAmon2でも使えばいいんじゃないかなという気がしてくるので*5、最後にセッション周りについて少し書いて終わりにしたいと思います。
PSGIでは、PSGI::Extensionsとして、ほんの少しだけセッションに関する規定があります。とはいえ実装に関する細かい規定は無いので、通常は、Cookieを使ってセッション管理するコードを書くことになります。
1. セッションIDを生成
2. Cookie(HTTP_COOKIE)に生成したIDを適切に格納
3. サーバー側はセッションストアを用意して、セッションIDとそれに結びついた値を適切に管理
Cookieの取り扱いはPlack::Request#cookiesを利用すれば良さそうです。
ところで、PlackにはPlack::Middleware::Sessionというミドルウェアがあったりします。少し古い記事になりますが、PSGI/Plackの作者である宮川氏自身の言葉によると
Session
第1回 PSGI/Plack―フレームワークとサーバをつなぐエンジン (3):Perl Hackers Hub|gihyo.jp … 技術評論社
Sessionミドルウェアはフレームワークにセッション管理機能を追加します。多くのフレームワークが自前でセッション管理を実装している場合がほとんどのため,執筆時点ではあまりメリットがありませんが,フレームワークからこのPlack::Middleware::Sessionを利用するためのアダプタを記述すれば,各種フレームワークでセッションを共有する,といったことが容易にできるようになります。SessionミドルウェアはCookieによりID管理を行い,ストレージには各種ファイルやmemcachedといったキャッシュバックエンドが利用可能です。
とのことです。独自に管理してもよいけれど、これからWAFをつくる場合にはPlack::Middleware::Sessionを使ったほうが良いことがあるかもしれません。
こんなに書くつもりは全くなかったのに、気がついたら無茶苦茶長くなってしまいました…。
終わります。