URI.pmを継承する

こんにちは、アプリケーションエンジニアの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::URIblessし直す必要があるし、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のように自身のサブクラスのインスタンスを返すクラスであっても、動的にクラスを生成することでサブクラス化できることを示しました。

はてなでは既存のサービスを次代につなげていくエンジニアも募集しています。