モダンPerlの世界へようこそ

第7回Catalyst::DispatchType::Chained:チェーンドアクションはむずかしい?

5.7系列の目玉だったチェーンドアクション

3年前に登場したCatalyst 5.7系列で導入された機能のひとつに、チェーンドアクションと呼ばれるものがあります。これは慣れると非常に便利な機能なのですが、それまでのURLとクラスの対応を根底から覆してしまう大転換だったわりにドキュメントが不足していたため、活用の仕方がわからないという声もありました。

今回はCatalyst 5.8系列で導入された新しいツールを使いながら、このチェーンドアクションの使い方を紹介していきます。スペースの都合でCatalystの基本はある程度理解しているものとして話を進めますので、わからないことがあったらCatalyst本体のドキュメントやCatalyst::Manualなどを適宜参照してください。

まずは要件を整理しよう

どのようなアプリケーションであっても事前にある程度要件を定義しておくのは大事なことですが、チェーンドアクションを使う場合は特にどのようなURLを利用するか、あらかじめよく考えておく必要があります。今回は以下のようなURLを持つ簡単なブログもどきをつくるつもりで話を進めていきます。

トップページ:最新記事一覧
http://example.com/
個別の記事表示(<id>は任意の英数字)
http://example.com/entry/<id>
個別の記事作成(更新・削除)
http://example.com/entry/<id>/(create|update|delete)
記事の表示以外は事前にログインが必要
http://example.com/login

先にテストを書いておくと安心

URLの要件定義が済んだら、これをテストに落とし込みます。コマンドラインから「catalyst MyApp」を実行してひな形をつくったら、まずはt/home.tとして、このようなテストを書いてください。

use strict;
use warnings;
use Catalyst::Test 'MyApp';
use Test::More tests => 2;
use HTTP::Status;

my ($res, $c) = ctx_request('/');
ok $res->code == RC_OK;
ok $c->stash->{template} eq 'home';

このctx_requestというのは、Catalyst 5.8系列で新しく加わった便利な機能のひとつ。従来のrequestではレスポンスしか取れませんでしたが、ctx_requestによってコンテキストオブジェクト($c)もあわせて取れるようになりましたので、実際に出力された内容を見なくても、正しいテンプレートが使われているか、セッション情報がどうなっているかなどが簡単に確認できるようになりました[1]⁠。

t/entry.tの方はセッションの扱いが混じりますのでもう少し複雑になります。

use strict;
use warnings;
use Catalyst::Test 'MyApp';
use Test::More tests => 5;
use HTTP::Status;
use HTTP::Request;

my ($res, $c) = ctx_request('/entry/1');
ok $res->code == RC_OK;
ok $c->stash->{template} eq 'entry';

my $req = HTTP::Request->new(GET => '/entry/1/create');

($res, $c) = ctx_request($req);
ok $res->code == RC_UNAUTHORIZED;

$req->header('Cookie' => 'session=foo');

($res, $c) = ctx_request($req);
ok $res->code == RC_OK;
ok $c->stash->{template} eq 'create';

ここでは省略しますが、余力のある方はぜひ更新や削除、ログイン、あるいはエラーになるURLのテストも追加しておいてください。このテストが充実していればいるほど、あとの苦労が少なくなります。

クラスとURLの関係を断ち切ろう

テストができたらコントローラの実装にかかりましょう。まずはこのようなlib/MyApp/Controller/Home.pmを用意してください[2]⁠。

package MyApp::Controller::Home;

use Moose;
BEGIN { extends 'Catalyst::Controller'; }

sub latest_entries : Chained('/') PathPart('') Args(0) {
    my ($self, $c) = @_;

    $c->stash->{entries}  = $c->model('DB')->latest_entries(5);
    $c->stash->{template} = 'home';
}

1;

このhomeというメソッド(アクション)についている3つのアトリビュートの意味はそれぞれ次の通りです。

  • Chained('/') は、このアクションの起点となる「アクションのパス」です。具体的な例はまたあとで見ますが、ここでは「Chained('/')」はこのlatest_entriesというアクションの前に実行されるアクションはない、という意味だと覚えておいてください。

  • PathPart('') は、それまで実行されてきたアクションの「URLのパス」のあとに続くパスです。この場合は最初のアクションですから、始点はルート(⁠⁠/⁠⁠。引数は「''」ですから、追加するパスはなし。つまりここではルートそのものを指していることになります。

  • Args(0) は、このアクションがチェーンの終点であることを意味しています。引数の「0」は、そのあとに続く(⁠⁠/」で区切られた)パスの構成要素(セグメント)を引数にとらない、という意味。引数をとる例はあとで紹介します。

要するに、これだけの文字数を使って、やっていることはふだんMyApp::Controller::Rootに登録されている「sub index : Path」というアクションの再実装に過ぎないのですが、PathやLocalで指定するアクションではクラスとURLが密接に結びついているのに対して、チェーンドアクションを利用するとURLとは無関係にコントローラやメソッド名を整理できるようになりますので、コントローラの再利用性が高まりますし、スタンドアロンサーバをデバッグモードで立ち上げたときに表示されるアクションの遷移もよりわかりやすいものにできます(この例ではチェーンが短いのであまり恩恵が感じられませんが、indexというパス名ではなく、latest_entriesのように意味をもった名前をつけられるようになるので、具体的な処理の流れを追いやすくなります⁠⁠。

チェーンドアクションの優先度

さて、ここで一度テストを実行しておきましょう。MyApp::Model::DBやビューの実装がないといって怒られますが、この段階ではまだ細かな実装を行う必要はありません。テストを実行したときにエラーにならない程度のひな形のみ用意しておいてください。

ただし、必要なひな形を用意しても、残念ながらチェーンドアクションはPathやLocalで指定されるアクションより優先順位が低いため、いまのままではMyApp::Controller::Rootにデフォルトで用意されているindexアクションに処理を取られてしまいます。MyApp::Controller::Rootをこのように書き換えて、競合するアクションを取り除いてしまいましょう。

package MyApp::Controller::Root;

use Moose;
BEGIN { extends 'Catalyst::Controller'; }

__PACKAGE__->config->{namespace} = '';

sub default : Private {
    my ( $self, $c ) = @_;
    $c->forward('/error/not_found');
}

sub end : ActionClass('RenderView') {}

1;

プライベートアクションも活用しよう

defaultアクションについては、より意味を明確にするためにMyApp::Controller::Errorというコントローラに処理を飛ばすことにしました。こちらもあわせて用意しておきます(このようにプライベートアクションを使っても、URLにしばられないクラス分けができます⁠⁠。

package MyApp::Controller::Error;

use Moose;
BEGIN { extends 'Catalyst::Controller'; }
use HTTP::Status;

sub not_found : Private {
    my ($self, $c) = @_;

    $c->res->body(HTTP::Status::status_message(RC_NOT_FOUND));
    $c->res->status(RC_NOT_FOUND);
    $c->detach;
}

1;

最後の$c->detachは、ここでチェーンを打ち切ってビューまわりの処理に移りますよ、という意味。最近のCatalystでは$c->detachを複数回使うと期待した動作が得られないことがありますので、メソッドの遷移は基本的に$c->forwardのみで行い、ここぞというところでだけ$c->detachを(チェーンの中で一度だけ)使うようにするのが無難です。

これでt/home.tについてはテストが通るようになりました。続いてエントリまわりの実装にかかりましょう。

順を追って実装していく

どのエントリを表示(または作成・更新・削除)するにしても、パスにはかならず「entry/<id>という要素が含まれます。この<id>の部分は可変で、entryの部分は変化しません。このような場合、チェーンドアクションのアトリビュートはこう書けます。

package MyApp::Controller::Entry;

use Moose;
BEGIN { extends 'Catalyst::Controller'; }

sub entry : Chained('/') PathPart CaptureArgs(1) {
    my ($self, $c, $id) = @_;

    $c->stash->{entry} = $c->model('DB')->entry($id);
}

最初のChained('/')についてはすでに説明しました。PathPartに引数がない場合は、アクション名が引数になります(つまり、この段階で「/entry」というURLが確定します⁠⁠。次のCaptureArgs(1)は、⁠かならず)続くパスの構成要素ひとつをアクションの引数にとって、チェーンを続ける」という意味(逆にいうと、⁠/entry」というパスの場合は構成要素をひとつも取れないのでこのアクションは実行されません。また、この段階ではかならずチェーンが続くことが前提になっているので、チェーンを終わらせるためには最低でももうひとつアクションが必要になります⁠⁠。

このように可変のパスに対応できるというのがチェーンドアクションのひとつの長所なのですが、チェーンドアクションにはもうひとつ、最終的なURLが確定していない段階でも共通の作業は先に実行できる、という長所があります。

この例の場合、記事を表示するにせよ、新しい記事を作成するにせよ、一度は現在のidからデータベースのレコードを取ってくる作業が必要になります。だから、その結果をstashなりなんなりに保存しておくと、あとでコードの重複が避けられることになります。

終点の再利用

MyApp::Controller::Entryの続きはこのような感じになります。

sub default_read : Chained('/entry/entry') PathPart('') Args(0) {
    my ($self, $c) = @_;

    $c->forward('/entry/read');
}

sub read : Chained('entry') PathPart Args(0) {
    my ($self, $c) = @_;

    $c->stash->{template} = 'entry';
}

sub create : Chained('entry') PathPart Args(0) {
    my ($self, $c) = @_;

    $c->forward('/error/unauthorized') unless $c->user_exists;

    if (uc $c->req->method eq 'POST') {
        $c->stash->{entry}->create($c->req->params);
        $c->res->redirect($c->uri_for('/'));
        $c->detach;
    }

    $c->stash->{template} = 'create';
}

1;

Chainedの引数は、default_readアクションの例のように絶対パスであらわすこともできますし、read/createアクションのように相対パスであらわすこともできます。また、default_readアクションは、CRUDをそろえるために(当初の仕様にはなかった)readアクションに実際の処理を委譲しています。

単純にチェーンのつなぎ方の問題だけ考えると、このdefault_readアクションはentryアクションから処理を引き継がなくてもこう書けますし、実際こちらの方が処理時間は短縮できます。

sub default_read : Chained('/') PathPart('entry') Args(1) {
    my ($self, $c, $id) = @_;

    $c->stash->{entry}    = $c->model('DB')->entry($id);
    $c->stash->{template} = 'entry';
}

ただし、このread/createアクション、よく見てみると、直接MyApp::Controller::Entryの実装に依存している部分はほとんどないことがわかります。

だから、たとえばこのようなベースクラスを用意して、MyApp::Controller::Entryに継承させれば正しく動作しますし、

package MyApp::Base::Controller::CRUD;

use Moose;
BEGIN { extends 'Catalyst::Controller'; }

sub default_read : Chained('entry') PathPart('') Args(0) { ... }
sub read         : Chained('entry') PathPart Args(0)     { ... }
sub create       : Chained('entry') PathPart Args(0)     { ... }

1;

同様のCRUD処理が必要なコントローラを追加したくなったときは、entryというアクションを実装して、$c->stash->{entry}の中に適切な(モデルが生成した)オブジェクトを入れてやれば期待した動作が得られます[3]⁠。

package MyApp::Controller::Wiki;

use Moose;
BEGIN { extends 'MyApp::Base::Controller::CRUD'; }

sub entry : Chained('/') PathPart CaptureArgs(1) { ... }

# あとのCRUD処理はMyApp::Base::Controller::CRUDにおまかせ

1;

サイト全体に関する処理をしたい場合

このように、チェーンドアクションを使い始めると、従来であればコントローラごとにコピー&ペーストして設定部分だけ書き換えていたようなコードや、Catalyst::Action::RESTがしているように(アクションではない)ただのメソッドにディスパッチしていたような処理を、無理のない形で(ベース)コントローラにまとめていけます。

セッション管理や認証まわり、あるいは静的ファイルの処理のように、いまだに「これならプラグインにしてもいい」といわれているものでさえ、プライベートアクションとチェーンドアクションを併用すればふつうのコントローラ(とモデル)にまとめてしまえます。

たとえば、サイト全体に認証をかけたい場合などは、ルートアクションを用意するのが簡単。いままで「Chained('/')」してきた部分(MyApp::Controller::Homeのlatest_entriesアクションと、MyApp::Controller::Entryのentryアクション)をすべて「Chained('/root')」に変えて、MyApp::Controller::Rootにこのようなアクションをひとつ追加してください。

sub root : Chained('/') PathPart('') CaptureArgs(0) {
    my ($self, $c) = @_;

    # セッション・認証管理のように、サイト全体に関係する処理を行う
    # $c->forward('/auth/basic');
    # $c->forward('/auth/requires_ssl');
    # $c->forward('/session/check'); などなど
}

もちろん場合によってはrootという名前ではなく、明示的にauthenticationというアクション名を使ってもよいでしょうし、必要であればrootの前にさらに別の(初期化などを行う)アクションを加えてもよいでしょう。管理画面だけ認証をかけたい場合などは、チェーンの途中に上記のような認証用のアクションをはさんで、それをルートアクションがわりに使うと便利です。

チェーンドアクションとuri_for_action

チェーンドアクションを使う上でもうひとつ忘れてはならないのは、ビュー(テンプレート)内でURLを扱う方法です。従来のLocalやPathと違って、チェーンドアクションでは引数が入ってきますし、クラスとURLの関係もなかなか一目瞭然とはいかないのですが、Catalyst 5.8系列では$c->uri_for_action()を使うと簡単にアクションからURLを逆算できます。

使い方は特にむずかしいところはありません。今回の例であればこの2通りの書き方さえ知っておけば十分でしょう[4]⁠。

# http://example.com/
my $uri = $c->uri_for_action('/home/latest_entries');

# http://example.com/entry/1/create
my $uri = $c->uri_for_action('/entry/create', [1]);

ちなみに、テンプレート内を含めてすべてのURLを上記のような表記にしておくと、アプリケーションをルート以外の階層にインストールしたくなった場合、ルートアクションのPathPartの引数を変えるだけで対応できるようになります[5]⁠。

なんでもかんでもチェーンドアクションにする必要はありません

チェーンドアクションは便利な反面、自由度が高すぎて、行き当たりばったりに使うとすぐにわけのわからないコードになってしまう危険性も秘めています。業務で使う分には要件定義のひとつもしないでコードを書き始めることはないと思いますが、初心者がホビーユースで使うにはいささか取っつきづらいところがあるかもしれません。

従来のLocalやPathを多用するやり方でも、コントローラのベースクラスやモデルを上手に使えばチェーンドアクションを使って得られる利点の多くをカバーできます。チェーンドアクションはディスパッチの回数が増える分だけ重くなりがちですから、共通して使えるようなコードがないなら、無理をする必要はありません。

次回はまた別の観点からCatalyst 5.8的な書き方を眺めてみたいと思います。

おすすめ記事

記事・ニュース一覧