PerlでAtomPubサーバを作ろう!

第3回AtomPubをより効果的に ─ 認証・キャッシュなど

前回までに写真付きブログサーバを作成しました。今回は、AtomPubによるWebサービスを効果的に提供するために欠かせない付加機能を取り上げます。認証、キャッシュ・バージョンチェック、メンバリソースのURI、フィードのページング、サービス文書のカスタマイズ、エラー処理を説明します。それぞれ独立に書かれていますので、興味のあるところから読み始められます。

サンプルコードはこちらからダウンロードできます。

認証

AtomPubでは認証方式は決められておらず、プログラマが選ぶことができます。ただし、最低限Basic認証+SSLができることが求められています。ここでは、Catalyst::Plugin::Authentication::Credential::HTTPを使ったBasic認証の実装例を紹介します。なお、ここで紹介する方法はAtomPubに限らずCatalystで一般的に使えます。

テーブルの作成

ユーザ情報を格納するテーブルを作成し、ユーザを登録します。

ユーザ名とパスワードをguest,abcとします。パスワードはMD5で変換してから格納しますので、事前にハッシュ値を求めておきます。

MyBlog % perl -MDigest::MD5 -e "print Digest::MD5::md5_hex('abc')"
900150983cd24fb0d6963f7d28e17f72

テーブルを作成します。テーブル名をusersとし、ユーザ名とパスワードのカラムをusername,passwordとします。

MyBlog % sqlite3 test.db
sqlite> CREATE TABLE users (
   ...>   id       INTEGER PRIMARY KEY,
   ...>   username TEXT UNIQUE,
   ...>   password TEXT
   ...> );

ユーザをテーブルに追加します。

sqlite> INSERT INTO users (username, password) VALUES ('guest', '900150983cd24fb0d6963f7d28e17f72');

認証処理の実装

MyBlogに認証プラグインを追加します。認証情報の格納先は、エントリと同じくDBICです。

認証プラグインを追加(lib/MyBlog.pm)
use Catalyst qw(
    -Debug
    ConfigLoader
    Static::Simple

    Authentication
    Authentication::Store::DBIC
    Authentication::Credential::HTTP
);

Catalyst認証モジュールを設定します。詳細は省略します。

Basic認証の設定(myblog.yml)
authentication:
    dbic:
        user_class:         DBIC::User
        user_field:         username
        password_field:     password
        password_type:      hashed
        password_hash_type: MD5
    http:
        type: basic

認証処理を実装します。認証処理は、ルートコントローラ(MyBlog::Controller::Root)のautoメソッドで実装することが多いです。このメソッドは、すべてのアクションが呼ばれる前に実行されるためです。

どのようなルールで認証を行うかはサービスによりますが、今回はGETとHEAD以外のリクエストに対して認証を行うことにしましょう。

認証勝利の実装(lib/MyBlog/Controller/Root.pm)
sub auto :Private {
    my($self, $c) = @_;

    # GETでもHEADでもなければ認証を実行する
    $c->authorization_required(realm => 'My Blog')
        if $c->req->method ne 'GET' && $c->req->method ne 'HEAD';

    return 1;
}

ここでは、HTTPメソッドの種類によって認証処理を切り替えましたが、URIによって切り替えることもできます。たとえば、http://localhost:3000/entrycollection以下のURIのみに対して認証を行いたいときは、lib/MyBlog/Controller/EntryCollection.pmのautoメソッドに認証処理を実装してください。

キャッシュ・バージョンチェック

AtomPubにはキャッシュやバージョンチェックが定義されています。クライアントは、過去に取得したりソースに対して変更の有無を問い合わせ、変更されていなければキャッシュを参照します。また、サーバは、クライアントが古いバージョンのリソースに対して変更を加えたときに、そのことを検出することができます。

キャッシュとバージョンチェックの仕組み

サーバがこれらの機能を実装するかどうかは自由です。実装すると、次の利点が得られます。

  • メンバリソースがGETされるとき、リソースが変更されていなければ304 Not Modifiedを返すことができる。リソース本体を返さないので、帯域幅が節約される。
  • メンバリソースがPUTされるとき(更新されようとしているとき⁠⁠、変更が加えられたバージョンをチェックすることができる。古いバージョンに対して変更が加えられたときには、更新を拒否できる(412 Precondition Failedエラーが返る⁠⁠。

これらを実現するために、ETagあるいはLast-ModifiedというHTTPヘッダが用いられます。まず、ETagについて説明します。

サーバは、メンバリソースを返すときにETagヘッダを付与することができます。ETagはリソースのバージョンを区別するための識別子になります。

クライアントは、メンバリソースをGETするときに、If-None-MatchヘッダにETag値を設定します。サーバは、値が一致しなければリソースが更新されたと判断して、リソース本体を返します。一致すれば、更新されていないとみなし、304 Not Modifiedを返してキャッシュを参照するように促します。

クライアントがメンバリソースをPUTするときには、ETag値をIf-Matchヘッダに設定します。サーバは、値が一致すれば更新を受け入れます。一致しなければ、古いバージョンに対して変更を加えたとみなし、更新を拒否します。

Last-Modifiedはリソースの最終更新日時を表します。それ以外はETagとほぼ同様に使われます。If-None-Match/If-Matchの代わりにIf-Modified-Since/If-Unmodified-Sinceが使われます。

ただし、Last-Modifiedは秒を単位とするため、1秒以内に連続して更新が行われた場合にはバージョンを区別することができません。このため、ETagのほうが安全と言えます。

キャッシュとバージョンチェックを実装

Catalyst::Controller::Atompubでキャッシュやバージョン管理を実現するには、コレクションコントローラのfind_versionメソッドをオーバライドし、ETagあるいはLast-Modifiedを返すように実装します。

第1回で作成したエントリコレクションにfind_versionを実装し、ETagを返すようにしてみます。

まず、テーブルにetagカラムを追加します。

MyBlog % sqlite3 test.db
sqlite> ALTER TABLE entries ADD COLUMN etag TEXT;

リソースを追加あるいは更新するときにETagを計算し、テーブルに格納しておきます。ETagの値はリソースのハッシュ値とします。

メンバ追加時にETagを計算(lib/MyBlog/Controller/EntryCollection.pm)
use Digest::MD5 qw(md5_hex);
sub create_entry :Atompub(create) {

    # 省略...

    # entriesテーブルにエントリとメタデータを格納する
    $c->model('DBIC::Entries')->update_or_create({
        edited => $edited,
        uri    => $uri,
        xml    => $entry->as_xml,
        etag   => md5_hex($entry->as_xml),
    });

    # 成功したらtrueを返す
    return 1;
}

メンバ更新時にETag を計算(lib/MyBlog/Controller/EntryCollection.pm⁠

sub update_entry :Atompub(update) {

    # 省略...

    # entriesテーブルからエントリを検索し、更新する
    $c->model('DBIC::Entries')->search({ uri => $uri })->update({
        edited => $edited,
        xml    => $entry->as_xml,
        etag   => md5_hex($entry->as_xml),
    });

    # 成功したらtrueを返す
    return 1;
}

find_versionメソッドを実装します。

find_version(lib/MyBlog/Controller/EntryCollection.pm)
sub find_version {
     # $uriはリクエストされた URI
     my($self, $c, $uri) = @_;

     # $uriに対応するリソースを取得する(リソースが存在しなければ空配列を返す)
     my $rs = $c->model('DBIC::Entries')->find({ uri => $uri }) || return;

     # ETagをハッシュとして返す
     return (etag => $rs->etag);

     # ETagとLast-Modifiedをハッシュとして返すこともできる
     #return (etag => $rs->etag, last_modified => $rs->last_modified);
}

URI に対応するリソースを検索し、先ほど格納したETag値を返します。あとはスーパークラスが適切に処理します。

メディアリソースについても同様に実装します。

メンバリソースのURIの指定

メンバリソースURI(Edit URI)は、リソースが追加されたときにAtomPubサーバによって決定されます。ここでは、デフォルトURIの変更方法を説明します。

デフォルトURI

デフォルトでは、次のようにしてメンバリソースURIが決定されます。

  1. まず、メンバリソースURIは、コレクションのURIとメンバリソース名、拡張子を連結したものです。
  2. メンバリソース名は次のように決定されます。HTTPリクエストにSlugヘッダがあれば、それをリソース名とします。このとき、空白とドット"."をアンダースコア"_"に変更します。大文字を小文字に変換します。URIに使えない文字はPercent Encodeします。Slugがなければ、"年月日-時分秒-マイクロ秒"をリソース名とします。
  3. 拡張子には、HTTPリクエストのContent-Typeに対応する一般的な拡張子を選びます(MIME::Typesモジュールを使っています⁠⁠。拡張子が不明であれば".bin"とします。

次のHTTPリクエストを例に説明します。

POST /entrycollection HTTP/1.1
Host: localhost:3000
Content-Type: application/atom+xml;type=entry
Slug: Entry%201

<?xml version="1.0" encoding="utf-8"?>
<entry xmlns="http://www.w3.org/2005/Atom">
  ...
</entry>

Slugヘッダのスペース(%20)を"_"に置換し、大文字を小文字に変換すると、リソース名は"entry_1"となります。また、Content-TypeはAtomエントリを表していますので、拡張子は".atom"になります。最終的に、このリソースのURIはhttp://localhost:3000/colleciton/entry_1.atomとなります。

URIの変更

メンバリソース名と拡張子は自由に変更することができます。たとえば、Ruby on RailsのようにデータベースのレコードIDをリソース名にすることもできます。

URIの変更方法を説明する前に、デフォルト実装の問題点を説明します。デフォルトでは、同じSlugを持つリクエストに対して同じURIが生成されるます。このとき、新しいリソースが作られずに既存リソースが上書きされます(あるいは、データベースのUNIQUE制約違反でエラーとなります⁠⁠。

このため、URI重複チェックの観点からもデフォルト実装を変更してください。メンバリソースのURIを変更するには、コレクションコントローラでmake_edit_uriメソッドをオーバライドします。

第1回で作成したエントリコレクションにmake_edit_uriを実装し、重複URIをチェックしてみます。

make_edit_uri(lib/MyBlog/Controller/EntriesCollection.pm)
use Time::HiRes qw(gettimeofday);
sub make_edit_uri {
    my($self, $c, @args) = @_;

    # スーパークラスのmake_edit_uriを呼び出す
    my $uri = $self->SUPER::make_edit_uri($c, @args);

    # URI がすでに存在するときは、拡張子の前にUNIX timeを挿入する
    if ($c->model('DBIC::Entries')->find({ uri => $uri })) {
        my $t = join '', gettimeofday;
        $uri =~ s{(\.[^.]+)$}{-$t$1};
    }

    # 空のリソースを作成し、URIを予約する
    $c->model('DBIC::Entries')->create({ uri => $uri });

    return $uri;
}

URIがすでに存在するときは、リソース名の後ろにUNIX time(マイクロ秒)を追加します。たとえば、foo.atomがすでに存在するときは、foo-1200118461123456.atomのように変更されます。

このように、make_edit_uriでURIを予約し、この後で呼ばれるメンバ追加メソッドでエントリ本体や更新日時を格納します。

メディアリソースを扱うエントリでは、メディアリンクエントリのURIに加えて、メディアリソースのURIを返します。

make_edit_uri(lib/MyBlog/Controller/MediaCollection.pm)
use Time::HiRes qw(gettimeofday);
sub make_edit_uri {
    my($self, $c, @args) = @_;

    # スーパークラスの make_edit_uri を呼び出す
    my($entry_uri, $media_uri)
        = $self->SUPER::make_edit_uri($c, @args);

    # 省略...

    return ($entry_uri, $media_uri);
}

フィードのページング

前回までに実装したAtomPubサーバでは、コレクションのメンバを列挙するとき(Atomフィードを返すとき)に、列挙するメンバ数を制限していませんでした。コレクションに非常に多くのメンバが含まれていると、サーバにはとても大きな負荷がかかります。

ここでは、第1回で作成したエントリコレクションを修正し、フィードを複数のページに分割します。ページは"?page="というクエリ変数で指定します。

フィードページング(lib/MyBlog/Controller/EntryCollection.pm)
# ページあたりのエントリ数
my $ENTRIES_PER_PAGE = 10;
sub get_feed :Atompub(list) {
    my($self, $c) = @_;

    # フィード(XML::Atom::Feed)のひな型を取得する
    my $feed = $self->collection_resource->body;

    # コレクションURI
    my $uri = $self->collection_resource->uri;

    # リクエストされたページ(デフォルトは1ページ目)
    my $page = $c->req->param('page') || 1;

    # 検索時の開始レコード(offset)と取得数(rows)を指定する
    my $attr = {
	offset   => ($page-1) * $ENTRIES_PER_PAGE,
	rows     => $ENTRIES_PER_PAGE,
	order_by => 'edited DESC',
    };

    # entriesテーブルからエントリを取得する
    my $rs = $c->model('DBIC::Entries')->search({}, $attr);

    # フィードのひな形にエントリを追加する
    while (my $entry_resource = $rs->next) {
	my $entry = XML::Atom::Entry->new(\$entry_resource->xml);
	$feed->add_entry($entry);
    }

    # 最初のページへのリンクを追加する。
    $feed->first_link($uri);

    # 前後のページへのリンクを追加する
    $feed->previous_link("$uri?page=".($page-1)) if $page > 1;
    $feed->next_link("$uri?page=".($page+1)) if $rs->count >= $ENTRIES_PER_PAGE;

    return $self;
}

まず、ページ数を表すクエリ変数(page)を取得します。指定されなければ1ページとします。次に、開始レコード(offset)と取得数(rows)を指定して、データベースを検索します。最後に、最初のページ(first_link)と前後のページ(previous_link,next_link)へのリンクを追加します。

サービス文書のカスタマイズ

第2回では、サービス文書のコントローラをそのまま使いました。ここでは、サービス文書をカスタマイズする方法を説明します。

サービス文書に登場するコレクションの順序を変更するだけであれば、Catalyst設定ファイル(myblog.yaml)で変更できます。たとえば、エントリコレクションを先にする場合は次のように設定します。

サービス文書のカスタマイズ(myblog.yaml)
Controller::Service:
    workspace:
      - title: My Blog
        collection:
          - Controller::EntryCollection
          - Controller::MediaCollection

重要なコレクションを先に書いた方がよい、というプラクティスがあったような気がします(ソースを思い出せないので記憶違いかもしれませんが、重要な順にして問題はありません⁠⁠。

さらにカスタマイズするときは、modify_service メソッドをオーバライドしてください。

サービス文書のカスタマイズ(lib/Catalyst/Controller/Service.pm)
sub modify_service {
    my($self, $c, $service) = @_;

    # サービス文書$service(XML::Atom::Service)を修正する...

    return $service;
}

エラー処理

ここまで、説明を簡単にするためにエラー処理を省略してきました。エラーが発生したときには、errorメソッドを呼んでreturnしてください。errorメソッドの引数は、Catalystオブジェクト($c⁠⁠、HTTPステータスコード、エラーメッセージです。

たとえば、リクエストされたエントリが見つからないときは、次のようにして404エラーを返します。

エントリが見つからないときのエラー(lib/MyBlog/Controller/EntryCollection.pm)
sub get_entry :Atompub(read) {

    # 省略...

    # entriesテーブルからエントリを検索する
    my $rs = $c->model('DBIC::Entries')->find({ uri => $uri })
        || return $self->error($c, 404, 'Entry does not exist');

    # 省略...
}

すると、AtomPubサーバは、404エラーコードを返します。レスポンスボディは、エラーメッセージを含むエントリ文書になります。

HTTP/1.1 404 Not Found
Content-Type: application/atom+xml;type=entry

<?xml version="1.0" encoding="UTF・"?>?
<entry xmlns="http://www.w3.org/2005/Atom">
  <updated>2007-01-01T00:00:00Z</updated>
  <link rel="related"
        href="http://localhost:3000/mycollection/entry_1.atom"/>
  <title>404 Entry does not exist</title>
  <content type="xhtml">
    <div xmlns="http://www.w3.org/1999/xhtml">
      404 Entry does not exist
    </div>
  </content>
</entry>

おすすめ記事

記事・ニュース一覧