こんにちは、アプリケーションエンジニアのid:nanto_viです。
この記事はKyoto.pm Tech Talk 02で発表した内容を加筆修正したものであり、はてなエンジニアアドベントカレンダーの5日目です。
はてなは今年で設立13年目を迎え、以前から存在するサービスの保守・運用も重要な業務のひとつとなっています。今日はそんな業務における改修作業の一例を紹介します。
独自のURIクラス
はてなで以前から存在するサービスのアプリケーションコードは、ほとんどの場合Perlで書かれています。PerlでのWebアプリケーション開発に欠かせないモジュールといえばURI
でしょう。
use URI; my $u = URI->new('http://example.org/'); $u->host; # => 'example.org'; $u->path('/foo'); $u->as_string; # => 'http://example.org/foo'
サービスによってはURI
クラスを拡張した独自のクラス(仮にMy::URI
という名前で参照します)を使っていることもあります。
use My::URI; my $u = My::URI->new('http://example.org/~user/foo/bar'); # URI.pm のメソッドをそのまま呼べる $u->host; # => 'example.org' # 独自のメソッドも使える $u->root_uri; # => 'http://example.org/~user/'
従来の実装
ところが、この独自のURIクラスがときどき意図しない挙動をします。例えば、ポート番号を取得する処理、
my $u = My::URI->new('https://example.org/'); $u->port; # => 80
https URIのデフォルトポートである443を期待していたのに、http URIのデフォルトポートである80が返ってきました。いったいなぜこうなってしまったのでしょうか。My::URI
の実装を覗いてみましょう。
package My::URI; use strict; use warnings; use base qw/URI::http/; sub new { my $class = shift; my $uri = $class->SUPER::new(@_); bless $uri, $class; } ...
URI
を継承しているはずなのにuse base qw/URI/;
ではなくuse base qw/URI::http/;
となっていますね。また、$class->SUPER::new(@_)
の返り値はオブジェクトである(すでにbless
されている)はずなのに、new
の最後でbless
し直しています。
実は、URI->new(...)
で返ってくるオブジェクトが属するクラスは、URIの種類(URIスキーム; 絶対URIの:
より前の部分)によって異なります。
ref URI->new('http://example.org/'); # => 'URI::http' ref URI->new('https://example.org/'); # => 'URI::https'
$class->SUPER::new(@_)
で返ってくるオブジェクトはURI::*
クラスに属しているのでMy::URI
にbless
し直す必要があるし、http URI用のメソッドを使うためにはURI::http
を継承する必要があるのです。
スキームごとに独自URIクラスを作る
Web上で扱うURIはhttp URIかhttps URIがほとんどですし、この両者は大体同じ性質を持つので、たいていの場合は従来の実装で問題ありません。しかし、http URI用の実装に決め打ちしている以上、http以外のURIに対してはポート番号の例で見たような意図しない挙動を示すことがあります。
URI
を継承しつつ各種のURIを適切に扱うにはどうすればいいのでしょうか。ここでヒントとなるのが特異メソッドです。Rubyには個別のオブジェクトにメソッドを追加できる特異メソッドという機能がありますが、Perlでも同様のことを実現できます。
いずれも、オブジェクトごとに新たなクラスを動的に生成しています。URI
を継承したいときもこの手法が使えます。ただし、オブジェクトごとに新たなクラスを作らなくとも、URIの種類ごとで十分です。
package My::URI; use strict; use warnings; use URI; my %packages = (); sub new { my $class = shift; my $self = URI->new(@_); my $parent = ref $self; my $package = $packages{$parent} ||= do { my $package = "$class\::$parent"; no strict 'refs'; @{"$package\::ISA"} = ($parent, $class); $package; }; return bless $self, $package; } ...
package main; use strict; use warnings; use Test::More; use My::URI; subtest 'http URI' => sub { my $uri = My::URI->new('http://example.org/'); isa_ok $uri, 'URI::http'; isa_ok $uri, 'My::URI'; }; subtest 'https URI' => sub { my $uri = My::URI->new('https://example.org/'); isa_ok $uri, 'URI::https'; isa_ok $uri, 'My::URI'; }; done_testing;
「URI
を継承した」といっていますが、菱形継承を避けるためuse base qw/URI/;
とはしません。http URIに対するクラスの継承ツリーは次のようになります。
URI URI::_query \ / URI::_generic | URI::_server | URI::http My::URI \ / My::URI::URI::http
スキームの変更への対応
URI
オブジェクトはスキームを変更することもでき、その場合オブジェクトの属するクラスが動的に変わります。独自URIクラスでもこうした状況に対応しないといけません。動的なクラス生成処理はオブジェクト生成時とスキーム変更時で共通なので、rebless
メソッドとして切り出します。
sub new { my $class = shift; my $self = URI->new(@_); return $class->rebless($self); } my %packages = (); sub rebless { my ($class, $uri) = @_; my $parent = ref $uri; my $package = $packages{$parent} ||= do { my $package = "$class\::$parent"; my $scheme_method = sub { my $self = shift; return $self->URI::_scheme unless @_; my $scheme = $self->URI::_scheme(@_); $class->rebless($self) unless $self->isa($class); return $scheme; }; no strict 'refs'; @{"$package\::ISA"} = ($parent, $class); *{"$package\::_scheme"} = $scheme_method; $package; }; return bless $uri, $package; }
subtest 'change scheme' => sub { my $uri = My::URI->new('http://example.org/'); $uri->scheme('https'); isa_ok $uri, 'URI::https'; isa_ok $uri, 'My::URI'; };
URI
クラスにおいて、実際のスキーム変更処理を担当するのはscheme
メソッドではなく_scheme
メソッドなので、_scheme
メソッドを上書きしています。
また、$self->SUPER::_scheme(@_)
ではなく$self->URI::_scheme(@_)
と呼び出していることに注意してください。SUPER::
は実行時にオブジェクトが属するクラスとは関係なく、コードが書かれたクラスの親クラスからメソッドを探します。ここでコードが書かれたクラスであるMy::URI
自体はURI
を継承していないので、SUPER::_scheme
を使うことはできないのです。
サブクラス化への対応
これで大丈夫かと思いきや、実際にアプリケーションを走らせてみるとエラーが発生することがあります。ある独自URIクラスを継承して別の独自URIクラスを定義しているのですが、どうもその部分に問題があるようです。独自URIクラスを継承可能にしてみましょう。
my %packages = (); sub rebless { my ($class, $uri) = @_; my $parent = ref $uri; my $package = "$class\::$parent"; unless ($packages{$package}) { my $scheme_method = ...; ... *{"$package\::_scheme"} = $scheme_method; $packages{$package} = 1; } return bless $uri, $package; }
{ package Our::URI; use parent qw(My::URI); } subtest 'inherited class' => sub { my $my_uri = My::URI->new('http://example.org/'); my $our_uri = Our::URI->new('http://example.org/'); isa_ok $our_uri, 'Our::URI'; };
%packages
のキーとなる値を$parent
から$package
へと変更することで、無事継承可能になりました。
オブジェクトからの新規オブジェクト生成への対応
アプリケーションコードを見渡していると、ある独自URIオブジェクトから別の独自URIオブジェクトを生成している部分がありました。
package My::URI; ... sub some_method { my ($self) = @_; my $new_uri_string = ...; return ref($self)->new($new_uri_string); }
独自URIクラスが継承可能なので、ここでMy::URI->new($new_uri_string)
(あるいは__PACKAGE__->new($new_uri_string)
)と書くことはできません。書いてしまうと、サブクラスのオブジェクトに対してsome_method
を呼び出したときに返ってくるオブジェクトが、サブクラスではなくMy::URI
に属することになってしまいます。
この問題に対処するため、new
メソッドも_scheme
メソッドと同じく、動的に生成したクラス上で定義してみます。
sub rebless { ... unless ($packages{$package}) { ... my $new_method = sub { shift; return $class->new(@_); }; no strict 'refs'; @{"$package\::ISA"} = ($parent, $class); *{"$package\::_scheme"} = $scheme_method; *{"$package\::new"} = $new_method; $packages{$package} = 1; } return bless $uri, $package; }
subtest 'new from object' => sub { my $uri = My::URI->new('http://example.org/'); my $new_uri = ref($uri)->new($uri->as_string); is ref($new_uri), ref($uri); };
これでURIの種類ごとに適切な挙動を示し、継承もできる独自URIクラスが作成できました。
委譲による独自URIクラスの作成
TMTOWTDIを身上とするPerlのこと、もちろん別の手段もあります。そのひとつがURI
オブジェクトを内部に持ち、URI
の各メソッドを委譲することです。
package My::URI; use strict; use warnings; use URI; sub new { my $class = shift; return bless { uri => URI->new(@_) }, $class; } sub host { my $self = shift; return $self->{uri}->host(@_); } ...
ただし、委譲だと独自オブジェクトの内部構造が元のオブジェクトと変わってしまいます。今回の事例では独自URIオブジェクトを使っている個所が多数存在するため、安全を期して内部構造の変わらない継承を選択しました。
結び
URI
のように自身のサブクラスのインスタンスを返すクラスであっても、動的にクラスを生成することでサブクラス化できることを示しました。
はてなでは既存のサービスを次代につなげていくエンジニアも募集しています。