問題
CGI::Sessionモジュールで、セッションを生ファイルに格納している。指定したディレクトリ配下に全てのファイルを格納するのではなく、セッションIDの頭文字を付けた子ディレクトリの下にばらして保存したい。つまり、
/foo/bar/session
a07cc22875335fd43b139d7a560687f7.obj
8b82046493d32b808c6ff3b731414fc4.obj
ではなく、
/foo/bar/session/a
a07cc22875335fd43b139d7a560687f7.obj
/foo/bar/session/8
8b82046493d32b808c6ff3b731414fc4.obj
のように。
解法
CGI::Session::Driver::fileモジュールのinitおよび_fileメソッドにパッチを当てます。詳細は以下の通りです。
/usr/local/lib/perl5/site_perl/5.10.0/Ermitejo/Session/Patch.pm
package Ermitejo::Session::Patch;
use strict;
use warnings;
our $VERSION = 0.01;
sub _patch_up {
return
if CGI->VERSION > 4.38;
package CGI::Session::Driver::file;
no warnings 'redefine';
*init = sub {
my $self = shift;
$self->{Directory} ||= File::Spec->tmpdir();
unless ( -d $self->{Directory} ) {
require File::Path;
foreach my $child (0..9, 'a'..'f') {
unless (File::Path::mkpath($self->{Directory} . '/' . $child)) {
return $self->set_error
( "init(): couldn't create directory path: $!" );
}
}
}
$self->{NoFlock} = $NoFlock unless exists $self->{NoFlock};
$self->{UMask} = $UMask unless exists $self->{UMask};
return 1;
};
*_file = sub {
my ($self, $sid) = @_;
my $id = $sid;
$id =~ s||/|g;
if ($id =~ m{/}) {
return $self->set_error
( "_file(): Session ids cannot contain or / chars: $sid" );
}
return File::Spec->catfile
($self->{Directory},
sprintf( $FileName, substr($id, 0, 1) . '/' . $id ));
};
return 1;
}
*import = &_patch_up();
"patched!";
foobar.cgi
use strict;
use warnings;
use CGI::Session::Driver::file; # This line is mandatory!!
use Ermitejo::Controller::Session::Patch;
$CGI::Session::Driver::file::FileName = "%s.obj";
my $SESSION_DIRECTORY = '/foo/bar/session';
my $claimed_session_id = shift;
my $session = CGI::Session->new(
"driver:File; serializer:Storable; id:MD5",
$claimed_session_id,
{ Directory => $SESSION_DIRECTORY }
);
解説
問題の検証
フラットに置くと性能が劣化する
CGI::Sessionで、セッション情報を生ファイルとして保存することにした場合、当然ではありますが、セッションの数だけファイルが指定したディレクトリ上(もしくはテンポラリディレクトリ上)にぶちまけられます。
期限切れのセッションであればそれを消して新たなセッションIDを付与したり、バッチ処理として定期的に洗い替えを行ったりするわけですが、それにしてもユニークユーザ数の多いWebアプリケーションでは、ファイルの数が多くなることは想像に難くありません。
Ermitejoのように辺境サイトを細々と運営している限りでは対した問題にならないかも知れませんが、洗い替えcron設定を忘れたりすると、ゴミ屋敷のようにセッションファイルがうずたかく積まれてしまいます。
ゴミが溜まると何がいけないか。それは、或るディレクトリ上にファイルをベタに(フラットに)ぶちまけた場合、ディスクの走査に時間が掛かる点にあります。
早期からの過剰な最適化は身を滅ぼしますが、さりとてスケーラビリティを無視して設計するのは、過剰な最適化というよりは適切な設計と言えるでしょう。
ファイル容量だけでなくファイル数へも制限がある
共用サーバでは特に、漸増するファイルがある場合には容量の見積もりを或る程度やっておかなければ破綻してしまいます。
さらに、Ermitejoをホストしているcoreserver.jp(XREAも同様?)では、ディレクトリ内のファイル数に制限が掛かっています。
RDBやDBファイルに格納するのが一番だが……
それではどうするか。綺麗な解決策は、セッションをRDBやBerkeley DBファイルへ格納することです。しかし、世の中簡単なことばかりではありません。諸々の事情で、ファイルに保存する場合もあることでしょう。
- RDBが使えない。
CGI::Sessionの場合にはCPANに諸々の実装が公開されています。標準添付のMySQL, PostgreSQL, SQLiteなどのほか、Oracleのプラグインもあります。しかし、万策が尽きる場合もあるかも知れません。 - 計ってみたらRDBに突っ込む方が時間が掛かった。
などの理由です。
この記事では、そうした場合の回避策を紹介しています。
期待するゴール
今回の例では、CGI::Sessionに手を加え、「問題」のようにセッションファイルを或る程度階層的に振り分けて持つことにしています。例示したコードの場合、1ディレクトリあたりのファイル数はN分の1になります。今回はデフォルトのMD5のhex表示の場合で決め打ちしてしまっていますので、N=16(0..9, ‘a’..’f')です。
まず、自分仕様のCGI::Session::Driver::fileモジュールがどのような状態であれば良いのか、考えます。
_file : ファイルアクセス時にディレクトリをなめる
_fileメソッドは、ファイルの入出力時のパス名を得る機能があります。この機能を上書きします。単純に頭文字をsubstrで得て、$FileNameのルールでsprintfする処理のみを上書きしましょう。
init : 子ディレクトリも生成する
initメソッドは初期化の際の機能がまとまっており、その機能の一つにセッション保存ディレクトリの存否確認と、存在しない場合の生成処理(mkpath)があります。この機能を上書きし、子ディレクトリも合わせて生成するようにしましょう。
パッチ当ての方法
さて、これで完成後のCGI::Session::Driver::fileモジュールの出来姿が見えました。しかし、これをどう反映させましょうか。
CGI::Sessionのような規模になると、オレオレモジュールを作るのは大変です。大変ということはエンバグする余地があることと等価です。では継承しようかというと、CGI::SessionではなくCGI::Session::Driver::fileという奥底のモジュールとして実装されている内容であるため、芋蔓式に親モジュールも修正していかなければなりません。
そこで、高名な小飼 弾さんの「perl - パッチなしでパッチする」という記事の完全な受け売りですが、動的にパッチを当ててしまいましょう。
イメージしたゴールはそのままベタで書きますが、パッチャモジュールをuseしたとき(つまりrequireされてimportサブルーチンが呼ばれたとき)に元のメソッドを上書きしてしまうという動き方です。
ここでは、小飼さんの記事の、モジュールを使う方の解法を使っています。色々とサンプルコードは長いですが、元の実装と変わった点はわずかであることに留意してください。
一点だけ補足ですが、最後の行に
*import = cgi_fixup();
とあるのは、恐らく
*import = &cgi_fixup();
のことだと思われます。そうでないと「Can't modify single ref constructor in scalar assignment at ...」という警告が出るはずです。
その他で注意すべき点としては、モジュールをロードする側(foobar.cgi)では、明示的にCGI::Session::Driver::fileモジュールをuseする必要があるということです。どのみち今回の例では$CGI::Session::Driver::file::FileName(sprintfのフォーマット)を変えていますので呼ばざるを得ませんでしたが。
なお、その他の場所(CGI::Sessionのnewメソッドなど)でパスをでっち上げようとしてもあまり意味がない(まずnewだけでは無理なのが、$claimed_session_idがundefでない場合で分かると思います)ので、素直に_fileメソッドに面倒を見て貰うのが良いかと思います。
備考
_file(およびinit)にさらに手を加えて、階層を増やすことも出来ます。
なお、coreserver.jpやXREAでは、ディレクトリ内のファイル数だけでなく、アカウント全体でのファイル数の上限も決められています。漸増ファイルがある場合には、上限を気にしておく必要があります。ただし、或る程度の余裕を持っておけば、毎回ファイル数をなめる処理を入れなくても良いはずです。ファイルやDBに書く度に容量チェックを(明示的には)していないのと同様です。勿論、書けない場合にただ死ぬのでなく、投げたその例外をきちんと捕捉してあげる必要はありますが。
なお、例示したコードはErmitejoのコードとは大分(いや、かなり)違いますが、あくまで例なので敢えて簡略化しています。そもそも現在のErmitejoはセッションを使っていませんが、開発中のネタとして備忘録的に残しておこうと思った次第です。これまでの備忘録は「つかみ」(「解説」の「問題の検証」部分)が冗長なような気もしてきたので、せっかくなので今後はPerlクックブック式にまとめようと思います。