CatalystアプリケーションでApache2::Reloadを使えるようにする方法

はじめに

Catalystでプログラムの修正を反映させるにはサーバーを再起動する必要があります。Catalyst付属のテストサーバーでは、-rオプションによって更新を自動的に検知して再起動することができます。

./script/myapp_server.pl -r

しかしコンポーネントが増えてくるとCatalystの起動に時間がかかるようになり、毎回再起動するのが辛くなってきます。実際、30個ぐらいのSchemaクラスを抱えるModelがある時、Controllerを少しいじっただけでサーバーが再起動され、アクセス可能になるまで数秒〜10数秒待つ必要がありました。Controllerだけ再読み込みしてくれればいいのに、と思いましたがサポートされていない感じ。ModPerlアプリケーションとして動かしてApache2::Reloadを使おうとも思ったのですが、それもサポートされてないとの事。

で、今回はApache2::Reloadでの再読み込みを無理やりやってみた。Controllerを修正した時に、サーバーを再起動することなく即座に修正を反映させることができるようになった。Catalystの起動時間を気にせず編集しまくれてウハウハですよ。

やり方

以下の記述をhttpd.confに追加し、my $class = 'MyApp';という部分を適宜変更してください。

PerlModule Apache2::Reload
PerlInitHandler Apache2::Reload

<Perl>
    use Apache2::Reload ();
    use ModPerl::Util   ();

    # hack for "Can't locate mod_perl.pm" caused by mod_perl2.pm
    delete $INC{'mod_perl.pm'};

    # Catalyst based application
    my $class = 'MyApp';

    # hack for problem that package variable doesn't appear again.
    if ( my $handler = Apache2::Reload->can('handler') ) {
        undef *Apache2::Reload::handler;
        *Apache2::Reload::handler = sub {

            # use unregister instead of unload
            local $ModPerl::Util::DEFAULT_UNLOAD_METHOD = sub {
                my $package = shift;
                delete $INC{ Apache2::Reload::package_to_module($package) };

                # reload Catalyst actions
                if ( $package =~ /^${class}::(?:C|Controller)::/ ) {
                    _setup_component_again($package);
                    $class->setup_actions;
                }
            };

            # call reload process
            $handler->(@_);
        };
    }

    sub _setup_component_again {
        my $component = shift;

        Catalyst::Utils::ensure_class_loaded( $component,
            { ignore_loaded => 1 } );
        my $module  = $class->setup_component($component);
        my %modules = (
            $component => $module,
            map { $_ => $class->setup_component($_) }
              Devel::InnerPackage::list_packages($component)
        );
        for my $key ( keys %modules ) {
            $class->components->{$key} = $modules{$key};
        }
    }
</Perl>

Class::Data::Inheritableへの対応としてApache2::Reloadが失敗する問題に対するパッチ - ヒルズで働く@robarioの技ログを適用し、そこにアクションの再構築を行なうコードを追加してます。

アクションの追加・削除をしても問題は無い模様。
C::M::DBIC::Schema周りでエラーが発生して対応するのが面倒だったため、Controllerのみ対応としています。なのでModelやViewを修正したときは再起動が必要ですが、滅多にいじらないからいいかな、と思って放置。

結果

通常、修正を反映させる場合は再起動をするので、以下のようにコンポーネントの読み込み処理([debug] Loaded components:の部分)を含む初期化が実行されます。

[debug] Debug messages enabled
[debug] Loaded plugins:
.----------------------------------------------------------------------------.
| Catalyst::Plugin::ConfigLoader  0.110                                      |
| Catalyst::Plugin::Static::Simple  0.140                                    |
'----------------------------------------------------------------------------'

[debug] Loaded dispatcher "Catalyst::Dispatcher"
[debug] Loaded engine "Catalyst::Engine::Apache2::MP20"
[debug] Found home "/tmp/MyApp"
[debug] Loaded components:
.---------------------------------------------------------------+----------.
| Class                                                         | Type     |
+---------------------------------------------------------------+----------+
| MyApp::Controller::Root                                       | instance |
| MyApp::Model::DBIC                                            | instance |
| MyApp::Model::DBIC::Queue                                     | class    |
| MyApp::View::TT                                               | instance |
'---------------------------------------------------------------+----------'

[debug] Loaded Private actions:
.----------------------+------------------------------------+--------------.
| Private              | Class                              | Method       |
+----------------------+------------------------------------+--------------+
| /default             | MyApp::Controller::Root            | default      |
| /end                 | MyApp::Controller::Root            | end          |
'----------------------+------------------------------------+--------------'

しかし上記設定を適用した状態では、修正後にブラウザをリロードするだけでいきなりアクションの再読み込みから始まり、初期化にほとんど時間がかかりません。どんなに重いModelがあっても無関係です。

Subroutine default redefined at /tmp/MyApp/lib/MyApp/Controller/Root.pm line 16.
Subroutine end redefined at /tmp/MyApp/lib/MyApp/Controller/Root.pm line 22.
[debug] Loaded Private actions:
.----------------------+------------------------------------+--------------.
| Private              | Class                              | Method       |
+----------------------+------------------------------------+--------------+
| /default             | MyApp::Controller::Root            | default      |
| /end                 | MyApp::Controller::Root            | end          |
'----------------------+------------------------------------+--------------'

補足

Apache2::Reloadを使えばとりあえずControllerのリロードは行なわれるのですが、アプリケーションには反映されません。Catalyst::Actionクラスがsetup時に実行コードをキャッシュして、それを実行しているからのようです。(Catalyst::Actionにcodeというアクセサがあり、そこに収められている)なので、なんとかしてこのcodeを置き換える必要があって、今回はアクションを再構築することによってcodeの置き換えを行ないました。
なお、#_setup_component_againはCatalyst#setup_componentsから抜粋したものです。

追記:
一度リロードが行なわれた後、再読み込みをするたびに何度もリロードされる状態が発生しました。上記Hackによるものではなさそうです。調べてみた所、%Apache2::Reload::Statがプロセス毎に存在するようです。(\%Statの値が同じなのに?)
何やら回避法がある気がするのですが、とりあえず以下のように子プロセスを一つだけに制限することにしました。誰か つД`)タスケレ !!

<IfModule mpm_prefork_module>
    StartServers          1
    MaxSpareServers       1
    MinSpareServers       1
    MaxClients            1
    MaxRequestsPerChild   0
</IfModule>