Practice of Programming

プログラム とか Linuxとかの話題

Amon2を継承して自分のWAFを作る

2ヶ月くらい前からか、id:tokuhiromさん作成のAmon2を会社のFrameworkのベースにしています。
Amon2は拡張しやすい感じだし、Flavorをメンテするのは結構だるいし、会社なら共通させたほうがいいなーということで、継承して使ってます(Flavorも使っていますけど)。ORマッパーはid:nekokakさんのTeng、テンプレートはid:gfxさんのText::Xslate。
当分公開できるような状態にはならない気がするので、こんな感じにしてます的な話(実際のコードと同じだったり、全然違ったりするところもあるけど、手を入れたポイント的な話)。仮に、MyWAFていう名前空間で書きます。

コードが断片的なのでよくわかんないかも…しれません。

最近書かれているAmon2に関する記事

id:tokuhriomさん自身の解説
http://blog.64p.org/entry/20110713/1310510015

id:hirataraさんのAmon2のソースを読む
http://d.hatena.ne.jp/hiratara/20110823/1314085436

Dispatcher

Amon2では、amon2_setup.pl で出来上がる app.psgi で、

MyApp::Web->to_app();

となっていて、to_app 内で、MyApp::Webのdispatchが呼ばれるだけです。
もし、MyAPpp::Webと違う名前を使いたければ、ここの名前を変えれば良いです。
同じライブラリで、複数のサーバ、複数のpsgiを書きたいとか言ったときには、そんな必要があるやもです。


また、パスによってdispatchするクラスを分けたいっていうときは、
BEFORE_DISPATCHというトリガーがあるので、そこの中で、パスを判断して、パス別にdispatch処理を分けることもできます。

__PACKAGE__->add_trigger(
    BEFORE_DISPATCH => sub {
      my ( $c ) = @_;
      $c->message;
      $c->{_dispatcher_class} = 'MyApp::Web::Dispatcher::User';
      if ($c->req->env->{PATH_INFO} =~m{^/(admin)}) {
        $c->{_dispatcher_class} = 'MyApp::Web::Dispatcher::Admin';
      }
      # ...
    });

sub dispatch {
  my $c = shift;
  $c->{_dispatcher_class}->dispatch($c);
}

みたいな。

Amon2::Web::Dispatcher::Liteについて

Amon2::Web::Dispatcher::Lite は好きなんですが、パスごとにフックかけたりするのが、BEFORE_DISPATCHERのトリガーしかないのは(そんなことない?)ちょっとやりにくいと思っています。
例えば、ログイン処理とか書く時に、トリガーに書くのかなぁ?みたいな。

Amon2::Web::Dispatcher::Liteのやってることは、継承元に各関数をexportしてるだけなので、真似して自分で作るのは簡単です。
で、継承元にexportしてるdispatchていうメソッド(ちょうど、上で書いたのコードのdispatchの中で使われているdispatchメソッド)は、Amon2::Web::Dispatcher::Liteでは、以下な感じ。

    *{"$caller\::dispatch"} = sub {
      my ($klass, $c) = @_;
      if (my $p = $router->match($c->request->env)) {
        return $p->{code}->($c, $p);
      } else {
        return $c->res_404();
      }
    };

このコードをちょっと変えて、get/postなどの関数に渡しているパスのパターンをチェックして特定のパスならhookをかけるとかっていうのを追加してます。うちのMyWaf::Web::Dispatcherでは、

hook '/path/to/hoook' => sub {
  my ($c) = @_;
  # ...
};

みたいな感じで書けるようにしています。hookに登録したコードを実行する処理や、フックのコードがresponseオブジェクトを返した場合は、そこで処理を終了といった判断を、上のdispatchメソッドに追加しています。これをするために、get/postなどのメソッドも全部自分で書き直しになりますが。

  tie %{$caller . '::HOOK_RULE'} => 'Tie::IxHash';
  $hook = \%{$caller . '::HOOK_RULE'};
  *{"$caller\::get"} = sub {
    my $pkg = caller(0);
    my ($path, $code) = @_;
    my $router = $pkg->router;
    my $hook_path = _hook_path($hook, $path);
    $router->connect($path, {code => $code, -hook => $hook_path}, {method => ['GET']});
  };

-hook に、hook 関数で登録したコードリファレンスを配列で持たせる感じです。
で、_hook_pathていうのは、以下のようなもの。

sub _hook_path {
  my ($hook, $target_path) = @_;
  my %except;
  foreach my $path (keys %$hook) {
    foreach my $except_path (@{$hook->{$path}->{except} || []}) {
      if (ref $except_path eq 'Regexp') {
        $except{$path} = 1 if $target_path =~ $except_path;
      } elsif ($target_path eq $except_path) {
        $except{$path} = 1;
      }
    }
  }
  [
   map {
     (not $except{$_} and $target_path =~m{^\Q$_\E$}) ? $hook->{$_}->{code} : ()
     } keys %$hook
  ]
}

-hook に登録されたコードをMyWAF::Web::Dispacherがexportするdispatchメソッドで実行するわけです。
※ちなみにこの書き方だと、対象のパスについて、get/post等の関数で定義する前にhook関数を使う必要があります。
後から書いても有効になるようにも作れるけど、普通、先に定義するし、hook処理はなるべく軽い方がいいんじゃないかと思って、そうしました。


後、内容がstaticなページ(ログイン判断はあるけど)とかで、ファイル指定するだけとかめんどくさいので、

get '/path/to/static_page';

だけでいいようにとか(/path/to/static_page.tt を表示する)。これは、別に静的ファイルじゃなくてもそういう感じにしています。ファイルが指定されなければ、パスのパターンから決める。


これをするためには、MyWAF::Web::Dispatcherがexportするdispatchで、$router->routematchが返す$routeを$cに突っ込んでいます。
$c->{route}->{pattern}を使って、MyWAF::Webのrenderのところでファイル名を決定するためです。hookの箇所も近いのでついでに引用。

  *{"$caller\::dispatch"} = sub {
    my ($klass, $c) = @_;
    my ($p, $route) = $router->routematch($c->request->env);
    $c->{route} = $route; # 後で、パスのパターン(get/post..の引数)を取りたいので、入れてる。
    # ...
    if ($p) { # $p がundefの場合は、マッチしなかった場合
      { # この部分、hookの処理
        foreach my $cm (@{delete $p->{'-hook'} || []}) {
          my $res;
          $res = $cm->[0]->($c, $p);
          return $res if ref $res eq 'Amon2::Web::Response';
        }
      }
    #...

Teng

Tengも拡張しやすいですね。以前pullリクエストだしたのですが、取り込まれる様子は無さそうだし、他にも色々したいし、どうせ継承してるんだから、上書きすればいいかぁと言うことで上書きしました。

以下、pullリクエストしたやつだけど、会社のFramework内にもともと書いちゃっておけば毎回書く必要もない。

sub search {
  my ($self, $table_name, $where, $opt) = @_;

  my $table = $self->schema->get_table( $table_name );
  if (! $table) {
      Carp::croak("No such table $table_name");
  }

  my ($sql, @binds) = $self->sql_builder->select(
      $table_name,
      $opt->{columns} || $table->columns, # ここ変えただけ
      $where,
      $opt
  );

  $self->search_by_sql($sql, \@binds, $table_name);
}

こうすると、

$db->search({ ... }, {columns => ["id"]});

だけ取りたいとか、

$db->search({ ... }, {columns => ["date(create_time) as day", "sum('sales') as sales_sum"], group_by => ['date(create_time)']});

とか、書けるようになります。group by があるのに、関数(sum/countなんかを)使おうと思ったらsearch_by_sqlてのはちょっと面倒なんですよね。
後、パフォーマンス出したいとか、リソース減らしたい時に、fetchするカラムを減らすのは、巨大なテーブルなら効果はあるかと思います。
28カラムあるテーブルから30万レコード中、100行持って来たときのベンチマーク

               Rate 00_no_columns    10_columns        20_dbi
00_no_columns 198/s            --          -28%          -65% # 全28カラムselect
10_columns    274/s           38%            --          -52% # 1カラムselect
20_dbi        567/s          186%          107%            -- # fetchrow_hashref で1カラムselect

あと、こんな感じで統計情報とかのデータ出すのに、より手抜きしたいなーと。

sub search_group {
  my ($self, $table_name, $where, $opt) = @_;
  my $table = $self->schema->get_table( $table_name );
  if (! $table) {
    Carp::croak("No such table $table_name");
  }
  my $stmt = $self->sql_builder->new_select;

  $stmt->add_select(\"COUNT($_) as " . ($_ ne '*' ? "_$_" : '')) for @{ $opt->{count} || []};
  $stmt->add_select(\"SUM($_) as sum_$_")                        for @{ $opt->{sum}   || []};
  foreach my $g (@{ $opt->{group_by} }) {
    my ($name, $as) = ($g, $g);
    if ($as =~ m{(\w+)\((.+?)\)}) {
      $stmt->add_select(\"$name as $1_$2");
    } else {
      $stmt->add_select($name);
    }
  }
  if (ref $where eq 'HASH') {
    $stmt->add_where($_ => $where->{$_}) for keys %{ $where || {} };
  } elsif (ref $where eq 'ARRAY') {
    while (my ($col, $val) = splice(@$where, 0, 2)) {
      $stmt->add_where($col, $val);
    }
  }
  $stmt->add_group_by(\$_)             for @{ $opt->{group_by} || []};
  if ($opt->{join}) {
    $stmt->add_join(%{$opt->{join}});
  } else {
    $stmt->add_from($table_name);
  }
  my @bind = $stmt->bind();

  # 以下、SQL::Makerからコード借りてます
  $stmt->prefix($opt->{prefix}) if $opt->{prefix};
  if (my $o = $opt->{order_by}) {
    if (ref $o eq 'ARRAY') {
      for my $order (@$o) {
        if (ref $order eq 'HASH') {
          # Skinny-ish [{foo => 'DESC'}, {bar => 'ASC'}]
          $stmt->add_order_by(%$order);
        } else {
          # just ['foo DESC', 'bar ASC']
          $stmt->add_order_by(\$order);
        }
      }
    } elsif (ref $o eq 'HASH') {
      # Skinny-ish {foo => 'DESC'}
      $stmt->add_order_by(%$o);
    } else {
      # just 'foo DESC, bar ASC'
      $stmt->add_order_by(\$o);
    }
  }

  $stmt->limit( $opt->{limit} )    if $opt->{limit};
  $stmt->offset( $opt->{offset} )  if $opt->{offset};

  if (my $terms = $opt->{having}) {
    while (my ($col, $val) = each %$terms) {
      $stmt->add_having($col => $val);
    }
  }

  $stmt->for_update(1) if $opt->{for_update};
  # 以上、SQL::Makerからコード借りてます

  my $sql = $stmt->as_sql();
  $self->search_by_sql($sql, \@bind, $table_name);
}

みたいなので、

  my $sales = $self->search_group('sales', [\'year(sales_datetime)' => $year],
                                  {sum => ['sales'], group_by => ['account_id', 'month(sales_datetime)']});

  while (my $r = $sales->next) {
    $r->get_column('month_sales_datetime');
    $r->get_column('sum_sales');
    # ...
  }

みたいな。

上のメソッドでも使ってますが、search_by_sqlに渡すSQLSQL::Makerで作りたければ、

my $stmt = $self->sql_builder->new_select;

で取れます。Teng::QueryBuilderは、SQL::Makerを継承して、InertMultiプラグインをロードしているだけなので、SQL::Makerの書き方はそのまま使えます。とはいえ、上のメソッドでもSQL::Makerからコピペしちゃってる部分がありますので、そのへん切り出した方がいいかもしれない。

Viewまわり

Amon2 では、Flavorが、create_view ていうのを作るので、それを参考にして、自分用の create_viewをMyWAF::Webに書きました。Text::Xslateに渡す関数の書き足しとか、そんなもんだけど。また、Amon2::Webのrenderメソッドでレンダリング&レスポンスを返すのですが、こちらも上書きしました。
理由としては、viewに値を埋めるのがrenderメソッドでしかできないのを変えたかった。

staticの話でも書きましたが、テンプレートファイルはパスから自動的に決めるっていうような仕組みを入れた場合、別のやり方でviewに値を埋めれないと困るわけです。変数だけじゃなく、ファイルもレスポンス返すのと別のタイミングで埋めたい時もあったので、そのへん手を入れました。hookの時に埋めたいような場合もあるし。

 $c->view_param('foo' => 'bar');
 $c->view_file('/path/to/template.tt');

みたいにしたかったわけです。こうしておいて、dispatcherを以下のように変えます。

sub dispatch {
  my $c = shift;
  my $res = $c->{_dispatcher_class}->dispatch($c);
  return ref $res eq 'Amon2::Web::Response' ? $res : $c->render;
}

基本renderで返すことが多いわけなので、responseオブジェクトが帰って来なければrenderしてしまうという感じです。
render内で、view_fileが渡されてればそれを、渡されていなければ、パス(Dispatcherでgetなどの関数に渡したパターンなので、"/path/:id/"とかもあります)からテンプレートを特定し、view_paramで渡された値で表示する(Dispatcherのところで書いた話)。

といったようにしています。

また、サイト共通のview用の設定みたいなものをどこで定義したらいいかなーと思っていたのですが、今のところ、設定ファイルにviewに使う用の場所を用意しておいて、関数から使えるようにしています。単純な話で、こんな感じ。

  site_config => sub { $self->config->{site} };

まぁ、$c が渡っているので、 c().config.site でいいやん的な話もありますが。

Text::Xslate

Text::Xslateは、TTerseで使っていますが、TTで書いてた大きめのmacroも移せたし、基本的には調子よく使ってます。ちょっとした不満は以下くらい。

[% WRAPPER .... WITH ... %] 〜 [% END %] を毎回書くのはメンドクサイので、header にWRAPPERのテンプレートファイルを渡したものの、パラメータ渡せないから使えないなー…て、MLで書いたけど、うまく伝わらずでした。すみません。

実際、ドキュメントにこんなふうにTTのWRAPPERは使えるよって書いてるけど、base側にcontent以外の変数がある気がするんだけど、どうなんだろう?
例えば、ページのタイトルとか、METAの指定とかしたくないのかな。デザイナ側からコントロールする術はないんだろうか。
TTerseのドキュメントより

    my %vpath = (
        wrap_begin => '[% WRAPPER "base" %]',
        wrap_end => '[% END %]',

        base => 'Hello, [% content %] world!' . "\n",
        content => 'Xslate',
    );

    my $tx = Text::Xslate->new(
        syntax => 'TTerse',
        path => \%vpath,

        header => ['wrap_begin'],
        footer => ['wrap_end'],
    );

    print $tx->render('content'); # => Hello, Xslate world!;

あと、もう一点、最近言われて気付いたんだけど、TTのUSEはいちいちモジュール作るのがいけてないけど、再利用っていう点ではそっちのが便利ではあるかなぁ。
今回のように自分でWAFを作るのであれば、create_viewで使いそうな関数をなるべく突っ込んでしまえば済むわけですが、全体的な再利用にはならなさそうな気分はする。

Plugin

追記。tokuhiromさんから、コメントがありました。

load_plugin() にかんしては、+ を prefix につけるという DBIC/Catalyst 的なルールがつかえます。
__PACKAGE__->load_plugin("+MyApp::Plugin::Foo");

てわけで、以下は、嘘情報です。
load_plugin(s)は、Amon2名前空間固定なので、MyWAF::Pluginを作っても読み込まれません。mywaf_load_plugin(s)を作っても良いだろうし、プロジェクトのリポジトリ内に、Amon2::Plugin::* 名前空間で作ってもいいし。既存のload_pluginを両方チェックするように上書きしてもいいだろうし。公開していいものなら、Amon2::Plugin::で公開してしまえばいいかは…知らないです。

Flavor

自分で、Flavorを作る場合、Amon2::Flavor::* な、名前空間にします。

% amon2-setup.pl --flavor=YourFlavor

てな感じで使えます。Flavorの中身は、run ていうメソッドを定義するだけ。Amon2:Setup::Flavor::Minimumあたりを参考にすれば簡単に書けます。
うちでは、Amon2::Setup::Flavor::Minimumを継承して書いています。

というわけで

これらの変更を全部Flavorに書いてメンテすることもできますが、Flavorのメンテは結構大変だし、テストも書きにくくなるし、あんまりおすすめできないと思います。ので、継承して自分用のWAF作っちゃえばいいなじゃないかなーと、勝手に思っております。