開発メモ#3 : レガシーなCGIアプリケーションのリファクタリング

開発メモその3です。今回は Perl のおはなし。

何年も前に作ったウェブアプリケーションのコードを開いてみたら黒歴史なコードが出てきて憂鬱な気分になる、そんな経験ありませんか。私はあります。ずっとそんな現実から目を背けて生きてきました。

さて、先日 Perl + CGI で書いて Apache::Registry で高速化している、実行環境が Apache に癒着した CGIアプリケーションを発見しました。おえ〜っ。一から作り直したい気持ちをぐっと堪えて、これを Plack 化したりとリフォームしていくとしましょう。その過程を以下記します。劇的ビフォア・アフター! ・・・とかは期待せず、地道な変更を積み重ねていくのがコツです。

方針

いきなりコードをがりがり書き換えていくというよりは、試行錯誤のしやすい環境に移行させていきながらリフォームを進めます。遠回りですが、結果的にその後の運用が楽になるでしょう。

  • Apache + CGI で動いている環境を、ポータブルになるよう Apache から切り離して独立させる = Plack で動かす
    • fork で構わないので CGI スクリプトをそのまま plackup できるようにするのが第一段階
    • fork なしの plackup にするのが第二段階
  • Plack で動かしながら必要な CPAN モジュールを判別していく
  • モジュールは Carton でアプリケーションにバンドルさせる形で管理する
  • スクリプトにハードコードされている設定値、特にセキュリティ的に隔離すべきものは Config::Pit で外部化
  • テストを可能な範囲で少しずつ追加
  • 開発メモ#1 で紹介した Cinnamon でデプロイ可能にする

perlbrew, cpanm で perl 環境を箱入りに

perlbrewcpanmperl の実行環境ははホームディレクトリに作るというのが大前提です。最近はどの言語でも、システムグローバルのそれは使わず環境構築する感じですよね。

Plack::App::WrapCGI で CGI スクリプトを plackup

アプリケーションが実行環境から切り離されてないとデバッグ、テストそのほかが面倒で仕方がないのでまずは cgi スクリプトを、Apache なしで動かせるようにします。当然 Plack の出番です。

% cpanm install Plack

CGI で書かれたアプリをいきなり PSGI ready な形式にするのは大変ですが、Plack::App::WrapCGI を使うと PlackCGI インタフェースをエミュレートして、CGI スクリプトをそのまま動かすことができます。

# app.psgi
use Plack::Builder;
use Plack::App::WrapCGI;
use Plack::App::File;

builder {
    mount "/amazlet/amazlet.cgi" => Plack::App::WrapCGI->new(script => './htdocs/amazlet.cgi', execute => 1)->to_app;
    mount "/amazlet/"            => Plack::App::File->new(root => './htdocs/')->to_app;
};

これだけ。amazlet.cgi というのが件の CGI スクリプトでございます。Plack::App::WrapCGI は実行ファイルを CGI そのまま、fork & exec で実行する (その分、エミュレーションの正確度が高い) のでオーバーヘッドが大きいのですが、まずはここから。

% plackup -r

で立ち上げ。色々モジュールが足りないといってすぐ落ちちゃいますが、この時点では気にしない。

Carton でモジュール管理

次に、アプリケーションの実行に必要な CPAN モジュールを判別しながら追加していく作業を行います。モジュールの管理には Carton を使います。Carton は RubyBundler のようなものです。

% cpanm Carton

でインストール。アプリケーションのルートディレクトリに Makefile.PL を作り

use inc::Module::Install;
name 'Amazlet';
version '1.0';

requires 'Net::Amazon', 0.61;
requires 'Data::Page', 2.02;
requires 'Data::Page::Navigation', 0.06;
requires 'Plack';
…

WriteAll;

と必要なモジュールを列挙していって

% carton install

で、依存関係のあるもの含みすべてインストールされる。このときモジュールが通常のパスではなく、アプリケーションディレクトリ内の local ディレクトリに入るのがポイント。

% carton exec -Ilib -- plackup -r

と carton exec すると、その local ディレクトリからモジュールを読み取って plackup でアプリケーションを実行します。OSX の初期インストールで入っている perl や cpanm で指定したディレクトリにあるものは使われない。つまり、Carton ではそのアプリケーションに必要なモジュールはすべてそのアプリケーション専用なものとして扱われて、外の世界のモジュールは使われない。

レガシーな perl アプリケーションに手を付けるときにはこの機能が本当に(本当に!) ありがたい。

本番環境にデプロイしたらモジュールが足りなかったとか、ローカルの開発環境のグローバルにあるモジュールと、リモートのそれでバージョンが食い違っていた・・・みたいなミスを防ぐことができる。Bundler や Carton や npm がただのモジュールインストールを一括でやってくれるベンリツールと思っていた人は考えを改めましょう。はい、私のことです。

これで carton exec で件の CGI スクリプトを plackup していくと足りないモジュールが確実に分かるので、エラーを見ながら都度 Makefile.PL に追加していくとそのうち完全なリストができあがる。一度それができあがってしまえばもう以降はモジュール忘れに悩まされることはないですね。

Proclet

carton exec でコマンドラインオプションが増えてきたことだし、他に memcached や redis やなにがしかの worker などのサーバーが必要になってくると開発再開のたびにそれを立ち上げなおしたりとか面倒です。

Perl の foreman こと Proclet でまとめて起動できるようにします。

% cpanm Proclet

でインストール。Procfile に

memcached: memcached -vv
plack: carton exec -Ilib -- plackup -r

と書いて

% proclet start --color

とすると memcached と carton exec な plack が同時に立ち上がる。

の右上に見るようにログをカラフルに仕分けしてくれるステキ機能もついてくる。ベンリ!

Plack::App::WrapCGI をやめる

ここまでですでに、fork & exec な環境で CGI アプリケーションが動いているはず。とはいえ fork & exec なオーバーヘッドを抱えたままプロダクションで動かすのはさすがにナンセンスなので、Plack::App::WrapCGI をやめて PSGI ready な形にアプリケーションを書き換える。

自分の場合今回は、CGI::Application というフレームワークを使っていたのですが CGI::Application が PSGI に対応してたので CGI::Application::PSGI を使えば ok でした。utf-8 flag がらみで文字化けしたのでその辺を修正する必要が少々ありましたけど。

ちなみに CGI::Application はクエリパラメータベースでアクションへのディスパッチを行う簡素なフレームワーク。名前こそ CGI という prefix がついてますが、十分枯れているし素朴なものならこれで十分です。ここを入れ替える必要は特に感じない。

というわけで以下修正した app.psgi

# app.psgi
use Project::Libs;

use Plack::Builder;
use Plack::App::File;

use CGI::Application::PSGI;
use CGI::PSGI;
use Amazlet::WebApp;

builder {
    enable_if { $ENV{PLACK_ENV} eq 'development' } 'InteractiveDebugger';
    my $amazlet = sub {
        my $app = Amazlet::WebApp->new({
            QUERY     => CGI::PSGI->new($_[0]),
        });
        CGI::Application::PSGI->run($app);
    };
    mount "/amazlet/"            => $amazlet;
    mount "/amazlet/amazlet.cgi" => $amazlet; # 念のための old URL

    mount "/static/css"          => builder {
        enable_if { $ENV{PLACK_ENV} eq 'development' } "File::Less";
        Plack::App::File->new(root => './htdocs/css');
    };
    mount "/static/"             => builder {
        Plack::App::File->new(root => './htdocs');
    }
};

ついでに CSSLESS で処理するようにした。LESS は素の CSS後方互換性があるので、CSS を書き換えることなく LESS パーサーを通しても問題ない。今回のように古いものを少しずつ新しくしていきたい、というのに都合がよい。

開発環境では Plack::Middleware::File::Less を使って Rails の asset pipeline 的に透過的に処理する。.less をいちいちコンパイルしなくても途中でコンパイルして配信。Plack::Middleware::File::Less の LESS コンパイラに関する不都合の解決については Plack::Middleware::File::Less を lessc でコンパイルするように - naoyaのはてなダイアリー に書きました。

Config::Pit, Config::ENV で設定の外部化

古いアプリケーションだと、アプリケーション本体に結構クリティカルな設定項目がべたっと定数化されていたりしますよね。はい、もちろん自分の仕業なんですけど。

例えばWeb APIのシークレットキーなど。github のプライベートレポジトリを使っているとはいえ、できることならこういうのはレポジトリにつっこみたくないですね。Config::ENV で設定項目をモジュール化しつつ、外部化にしたい箇所は Config::Pit で。これで該当箇所は ~/.pit/ 内のファイルに保持されます。

Config::ENV で Plack環境変数 PLACK_ENV 毎に設定を切り替えることもできて一石二鳥です。

package Amazlet::Config;
use Config::ENV 'PLACK_ENV', export => 'config';
use Config::Pit;

my $pit = pit_get("www.amazlet.com");

common +{
    aws_access_key        => $pit->{aws_access_key},
    aws_secret_access_key => $pit->{aws_secret_access_key},
    …
};

config development => +{
    static_root => '/static/', # localhost
    …
};

config production => +{
    static_root => 'http://static.amazlet.com/',
    ...
};

1;

としておいて

% ppit set www.amazlet.com

と ppit コマンドを発行するとエディタが開くので

---
"aws_access_key": '…'
"aws_secret_access_key": '…'
...

と書いて保存すれば ok。

テストを書いて、リファクタリング

さてさて、ここまでくればあとは地道にリファクタリングです。少しずつテストを書きながら進めます。テストの実行も carton exec が必要です。

carton exec -Ilib prove t/*.t

とする必要があるのをお忘れなく。誰が? 私が。

Plack::Test でアプリケーション全体をテスト

ちなみにレガシーなアプリケーションは処理の結合度が高かったりでテスタビリティがいまいちなことが多い。ので、いきなりがっつりテストしようとするのではなく、テストしやすそうなところからテストを書いて、動作を保証しながら、処理を本体から切り離し結合度を下げていく・・・というのを地道に積み上げていくのが個人的には好きです。

このとき Plack::Test を使うとアプリケーション全体のテスト、すなわち粒度の大きなテストが書けるので、まずはここから初めてみるというのも手かなと思います。以下は昨日国際化した amazlet のロケール判定周りのテスト。

use Test::More qw/no_plan/;

use Plack::Test;

use CGI::Application::PSGI;
use CGI::PSGI;
use HTTP::Request::Common;

BEGIN {
    use_ok('Amazlet::WebApp');
}

sub build_app {
    my $addr = shift;
    return sub {
        if ($addr) {
            $_[0]->{REMOTE_ADDR} = $addr;
        }
        my $app = Amazlet::WebApp->new({
            QUERY => CGI::PSGI->new($_[0]),
        });
        CGI::Application::PSGI->run($app);
    };
}

test_psgi build_app(), sub {
    my $cb = shift;
    my $res = $cb->(GET "/amazlet/");
    like $res->content, qr/amazlet/;
};

# 1. デフォルト: IPアドレスから判定
test_psgi build_app(              ), sub { like shift->(GET "/amazlet/")->content, qr/<!-- locale: jp -->/ };
test_psgi build_app('192.168.11.1'), sub { like shift->(GET "/amazlet/")->content, qr/<!-- locale: jp -->/ };
test_psgi build_app('18.181.0.24' ), sub { like shift->(GET "/amazlet/")->content, qr/<!-- locale: us -->/ };
test_psgi build_app('64.4.64.1'   ), sub { like shift->(GET "/amazlet/")->content, qr/<!-- locale: ca -->/ };
test_psgi build_app('25.0.0.0'    ), sub { like shift->(GET "/amazlet/")->content, qr/<!-- locale: uk -->/ };

# 2. Query String が明示的についている -> それに従う
test_psgi build_app('192.168.11.1'), sub {
    my $cb = shift;
    like $cb->(GET "/amazlet/?locale=jp")->content, qr/<!-- locale: jp -->/;
    like $cb->(GET "/amazlet/?locale=us")->content, qr/<!-- locale: us -->/;
    like $cb->(GET "/amazlet/?locale=ca")->content, qr/<!-- locale: ca -->/;
};

...

アプリケーションの出力に HTML のコメントで <!-- locale: jp --> とかデバッグ用のタグを含めておいて、それを like で判定するという簡素なものです。

Cinnamon でデプロイ、Elastic IP で切り替え

こんな感じでアプリケーションを書き換えていってポータビリティを上げていくとデプロイツールでデプロイするのにも苦労ない形になるはず。あとは Cinnamon でステージング環境にデプロイ して、問題なければ Elastic IP をプロダクションから切り替えて 終わりです。

リフォーム完了でメンタル的にもだいぶすっきり! こういう技術的負債は折を見てなんとか返却していきたいですね。

Plack Handbook
Plack Handbook
posted with amazlet at 13.01.29
(2012-10-29)
売り上げランキング: 7,932