PerlでAtomPubサーバを作ろう!

第4回OpenSocialのベースになっているGDataサーバに挑戦

前回まででAtomPubの機能をすべて実装しました。今回はAtomPubの拡張仕様であるGoogle Data APIs(GData)を取り上げ、検索クエリとJSONをサポートします。GDataは、昨年秋にGoogleが発表したOpenSocialのベースになっていることで話題になりました。

全文検索にはHyper Estraierというオープンソースの検索エンジンを用います。Hyper Estraierはフレーズ検索や属性検索をサポートしているため、GDataサーバの実装にうってつけです。

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

GDataとは

GDataはGoogle CalendarやBloggerなどのサービスを利用するためのAPIです。AtomPubの拡張仕様として定義されています。GDataは、プロトコルに関する仕様とXMLフォーマットに関する仕様に分けられます。プロトコルでは主に検索クエリが定義されています。XMLフォーマットについては、連絡先やイベントを表現するための拡張要素が定められています。この連載はAtomPubというプロトコルを対象としているので、検索クエリを実装する方法を説明します。

なお、実際のGDataサーバはGData AuthSubなどの独自認証方式を実装しています。また、サービスごとに拡張仕様が追加されています。これらをすべて実装することはこの記事の範囲を超えます。本物のGDataサーバのコピーを作れるわけではないことをご了承ください。もちろん、OpenSocialコンテナを実装するときや、みなさんのWebサービスに検索クエリを追加するときには、この記事が役立つと思います。

こちらにGData仕様書の和訳があります。

検索クエリ

GDataでは以下のパラメータが定義されています。

GData 検索パラメータ(クエリ変数)
パラメータ 説明
q 全文検索文字列、フレーズ検索・OR検索・NOT検索をサポート
author エントリの著者
alt 代替表現(json,json-in-script)
callback コールバック関数名(alt=json-in-scriptのときのみ有効)
updated-min,updated-max エントリ更新日時の範囲
published-min,published-max エントリ発行日時の範囲
start-index 取得した結果のうち、ひとつめの結果の通し番号(1から数える)
max-results 取得する結果の最大数

全文検索(q)では、フレーズ検索やOR検索、NOT検索をサポートします。フレーズ検索は、複数の連続するキーワードを検索するときに用います。q="Atom Publishing Protocol"のようにキーワードを引用符で囲みます。OR検索は、q=Atom OR RSSのように指定し、AtomあるいはRSSのいずれかが存在する文書を検索します。NOT検索はq=dog -catのように指定し、dogは含むがcatを含まない文書を検索することができます。

著者検索(author)では、エントリのauthorタグにあるname要素とemail要素を検索します。

代替表現(alt)を指定すると、Atom以外のフォーマットでレスポンスを返します。今回はalt=jsonとalt=json-in-script(JSONP)を実装します。alt=json-in-scriptのときは、コールバック関数名callbackを必ず指定しなければなりません。

updated-minなどのパラメータを使って、updated要素とpublished要素の範囲を指定することができます。Atomと同様に、RFC3339記載のフォーマットに従います。たとえば、updated-min=2008-01-01T00:00:00+09:00のようにします。

クエリの例を示します。日本時間で2008年1月1日以降のatompubを含むエントリを検索する場合は、次のようなクエリになります。

http://localhost:3000/entrycollection?updated-min=2008-01-01T00:00:00+09:00&q=atompub

カテゴリを検索するときは、クエリ変数ではなくパスを使います。

GData検索パラメータ(パス)
パラメータ 説明
/-/category カテゴリフィルタ

メンバリソースのURIと区別するために、/-/で区切ってからカテゴリを指定します。OR検索は"|"で区切り、AND検索は"/"で区切ります。

たとえば、perlあるいはrubyというカテゴリを指定するときは次のようにします。

http://localhost:3000/entrycollection/-/perl|ruby

JSONレスポンス

代替表現パラメータ(alt)が指定されたときは、XMLをJSONに変換します。GDataでは、フィードをJSONに変換する方法を次のように定めています。

属性は文字列プロパティに変換

XML
<link href="http://example.com/collection" rel="self"/>

JSON
{ "link": { "href": "http://example.com/collection",
            "rel" : "self"                           } }

子要素はオブジェクトプロパティに変換(テキスト要素のキーは"$t")

XML
<author>
  <name>Foo Bar</name>
  <email>foo@example.com</email>
</author>

JSON
{ "author": { "name" : { "$t": "Foo Bar"         },
              "email": { "$t": "foo@example.com" } } }

複数存在する要素は配列プロパティに変換

XML
<link href="http://example.com/"/>
<link href="http://example.com/collection" rel="self"/>

JSON
{ "link": [ { "href": "http://example.com/"                          },
            { "href": "http://example.com/collection", "rel": "self" } ] }

Catalysltアプリケーションの作成

新しいWebアプリケーションとして実装します。GDataというWebアプリケーション(のひな形)を作ります。

% catalyst.pl GData
% cd GData/

Hyper Estraier のセットアップ

まず、こちらなどを参考にしてHyper Estraierをインストールしてください。

Hyper Estraierでは、文書の本文に加えて、属性を指定した検索が可能です。今回はGDataに合わせて以下の属性を用います。なお、xml属性にはエントリ本体を保存しておきます。

Hyper Estraierの属性カラム
属性名 説明
uri リソースのURI
category カテゴリ
author 著者名と電子メールアドレス
updated updated要素のUNIX time
published published要素のUNIX time
xml エントリの文字列

Hyper Estraierのデータベースを作成し、起動します。データベース名はtest.dbとします。

GData % estmaster init test.db
GData % estmaster start -bg test.db  # -bg: バックグラウンドで起動する

Hyper Estraierでは「ノード」と呼ばれる一種のサーバに対して検索を行います。ここで、ノードを作成します。まず、ブラウザでhttp://localhost:1978/master_uiにアクセスし(デフォルトのユーザ名とパスワードはadmin,adminです⁠⁠、Manage Nodesをクリックしてください。テキストボックスが2つあるので、それぞれ"entries","エントリコレクション"と入力し、createボタンをクリックします。作成したノードのURIはhttp://localhost:1978/node/entriesとなります。

次に設定ファイルを修正しますが、その前にHyper Estraierを終了します。

GData % estmaster stop test.db

設定ファイルを修正します。設定ファイルはtest.db/_confです。

attrindex の修正(test.db/_conf)
# attribute indexes (attribute name and data type)
attrindex: @uristr
attrindex: @categorystr
attrindex: @authorstr
attrindex: @updatednum
attrindex: @publishednum

設定ファイルからattrindexという文字列を検索し、上のように修正してください。attrindexは、属性インデックスの指定です。GDataでは、著者やカテゴリ、日時に対するクエリが定義されています。それらの属性に文字列型あるいは数値型のインデックスを張ります。

設定ファイルの編集が終わったら、再度Hyper Estraierを起動してください。

GData % estmaster start -bg test.db

モデルの作成

モデルを実装します。モデルには、Catalyst::Model::Estraierを利用します。ヘルパスクリプトを使ってひな形を作成します。

GData % perl script/gdata_create.pl model Entries Estraier http://localhost:1978/node/entries admin admin

コマンドの引数は次の通りです。

モデル作成スクリプトの引数
引数 説明
model 作成対象
Entries 作成するクラス名(フルパスはGData::Model::Entries)
Estraier スーパークラス名(フルパスはCatalyst::Model::Estraier)
http://localhost:1978/node/entries Hyper EstraierノードのURI
admin Hyper Estraierのユーザ名
admin Hyper Estraierのパスワード

コレクションコントローラの実装

ヘルパスクリプトを使ってコントローラクラスのひな形を作成します。

GData % perl script/gdata_create.pl controller EntryCollection Atompub::Collection

第1回第2回と同様に、リソース操作(List+CRUD)を実装します。

メンバの列挙(List)

まず、エントリの列挙です。検索クエリの解析を行うため、メソッドが少し長いです。分割して説明します。

検索条件オブジェクトの構築(lib/GData/Controller/EntryCollection.pm)
use Search::Estraier;
sub get_feed :Atompub(list) {
    my($self, $c) = @_;

    # 検索条件オブジェクトを構築する
    my $cond = Search::Estraier::Condition->new;

    $cond->set_options(qw(SIMPLE));      # 簡便書式に設定
    $cond->add_attr('@updated NUMGE 0'); # updatedが0以上
    $cond->set_order('@updated NUMD');   # updatedの降順にソート

まず、検索条件オブジェクトを構築します。以降、このオブジェクトに検索条件を追加していきます。

検索クエリに簡便書式を指定します。簡便書式についてはHyperEstraierのドキュメントを参照してください。

デフォルトの検索条件として「updatedが0以上」を指定しておきます。これは、Hyper Estraierでは少なくとも1つの検索条件がひつようだからです。なお、Hyper Estraierで属性フィールドを表すときには、頭に"@"を付けます。

更新日時の新しい順にソートします。厳密には、updated要素ではなくapp:edited要素でソートすべきなのですが、今回は属性数を減らすためにupdatedで代用することにします。

全文検索クエリの解析(lib/GData/Controller/EntryCollection.pm⁠

    # 全文検索クエリの解析
    if (my $q = $c->req->param('q')) {
        $q =~ s/\bOR\b/|/g;    # ORを|に置換する
        $q =~ s/\s+-/ ! /g;    # キーワード頭の-を!に置換する
        $cond->set_phrase($q);
    }

全文検索クエリを解析します。GDataは"OR"という文字列でOR検索を指定しますが、Hyper Estraierではパイプ"|"で区切ります。また、NOT検索については、GDataが単語前にマイナス"-"を付けるのに対し、HyperEstraierはエクスクラメーション"!"を付けます。検索文字列を置換し、Hyper Estraierの書式に合わせておきます。

カテゴリクエリの解析(lib/GData/Controller/EntryCollection.pm⁠

    # カテゴリクエリの解析
    my @args;
    if ((@args = @{ $c->req->args }) > 2 && $args[1] eq '-') {
        $args[2] =~ s/\|/ /g ?
            $cond->add_attr("\@category ISTROR $args[2]")
          : $cond->add_attr('@category ISTRAND '.join(' ', @args[ 2..$#args ]));
    }

カテゴリ検索クエリを解析します。カテゴリはパスで指定されるので、$c->req->argsとしてパス部分を取得します。クエリに"|"が含まれるときはOR検索とし、含まれないときはAND検索とします。ISTRORとISTRANDはHyper Estraierの属性検索式で、それぞれOR検索とAND検索を意味します。頭の"I"は、大文字小文字を区別しないという意味です。

著者クエリの解析(lib/GData/Controller/EntryCollection.pm)
    # 著者クエリの解析
    if (my $author = $c->req->param('author')) {
        $cond->add_attr("\@author ISTRAND $author");
    }

著者クエリを解析します。AND検索(ISTRAND)のみとします。

日時クエリの解析(lib/GData/Controller/EntryCollection.pm)
    # 日時クエリの解析
    my @t = ([qw(updated   min NUMGE)], [qw(updated   max NUMLT)],
             [qw(published min NUMGE)], [qw(published max NUMLT)],);
    for (@t) {
        if (my $t = $c->req->param("$_->[0]-$_->[1]")) {
            $cond->add_attr(join ' ', '@'.$_->[0], $_->[2], $t);
        }
    }

日時クエリを解析します。NUMGEとNUMLTは数値比較を行うHyperEstraierの属性検索式で、それぞれ「以上」⁠より小さい」を表します。たとえば、updated-min=2008-01-01T00:00:00:+09:00というクエリは、"@updated NUMGE 2008-01-01T00:00:00:+09:00"に変換されます。

取得結果の開始位置と最大数の解析(lib/GData/Controller/EntryCollection.pm)
    # 取得結果の開始位置と最大数の解析
    $cond->set_skip($c->req->param('start-index')-1)
        if $c->req->param('start-index');
    $cond->set_max($c->req->param('max-results'))
        if $c->req->param('max-results');

取得結果の開始位置と最大数を解析します。GDataはエントリを1から数えますが、Hyper Estraierは0から数えます。そのため、start-indexの値から1を引いています。

検索の実行(lib/GData/Controller/EntryCollection.pm)
    # 検索の実行
    my $rs = $c->model('Entries')->search($cond, 0);

検索を実行します。searchメソッドの第2引数は、複数のHyper Estraierノードを用いるときの検索深さです。

フィードの作成(lib/GData/Controller/EntryCollection.pm)
    # フィード(XML::Atom::Feed) のひな型
    my $feed = $self->collection_resource->body;

    # フィードのひな形にエントリを追加する
    for my $i (0 .. $rs->doc_num-1) {
        my $doc = $rs->get_doc($i);
        my $entry = XML::Atom::Entry->new(\$doc->attr('@xml'));
        $feed->add_entry($entry);
    }

    return 1;
}

フィードのひな型を取得し、エントリを追加します。エントリはxml属性から取得します。

最後にtrueを返して終了です。

なお、フィードのページングは省略しました。第3回を参考に実装してみてください。

メンバの追加と更新(Create,Update)

エントリの追加(Create)と更新(Update)をひとつのメソッドにまとめて実装します。メソッド属性にcreateとupdateを並べると、両方のタイミングでメソッドが呼ばれるようになります。

メンバの追加と更新(lib/GData/Controller/EntryCollection.pm)
sub do_update_or_create_entry :Atompub(create, update) {
    my($self, $c) = @_;

    # エントリ本体を取得する(XML::Atom::Entry)
    my $entry = $self->entry_resource->body;

    # エントリ本文
    my $xml = $entry->as_xml;
       $xml =~ s/[\r\n]+/ /g; # 改行文字を除去する

    # category 要素
    my $category = join ' ', map { '{'.($_->scheme || '').'}'.$_->term } $entry->category;

    # author の子要素
    my $author = join ' ', grep { defined } map { ($_->name, $_->email) } $entry->author;

    # 文書オブジェクトに属性と本文を設定する
    my $doc = Search::Estraier::Document->new;
       $doc->add_attr('@uri',       $self->entry_resource->uri);
       $doc->add_attr('@category',  $category) if $category;
       $doc->add_attr('@author',    $author) if $author;
       $doc->add_attr('@updated',   $entry->updated);
       $doc->add_attr('@published', $entry->published) if $entry->published;
       $doc->add_attr('@xml',       $xml);
       $doc->add_text($xml);

    # データベースに文書を追加する
    $c->model('Entries')->put_doc($doc);

    return 1;
}

Hyper Estraierは属性に改行文字を格納できないため、除去しておきます。

category要素やauthorの子要素は複数存在しうるので、スペースで区切ってひとつの文字列にしておきます。

文書オブジェクトに属性と本文を追加し、データベースに追加します。Hyper Estraierでは、指定されたURIを持った文書が存在すれば上書きされ、存在しなければ新たに追加されます。URIの重複対策については第3回を参考にしてください。

メンバの取得(Read)

メンバの取得(lib/GData/Controller/EntryCollection.pm)
sub get_entry :Atompub(read) {
    my($self, $c) = @_;

    # 検索条件オブジェクトを構築する
    my $cond = Search::Estraier::Condition->new;
       $cond->add_attr('@uri STREQ '.$c->req->uri); # URI が一致

    # 検索を実行する
    my $rs = $c->model('Search')->search($cond, 0);

    # エントリが見つからなければ404エラーを返す
    return $self->error($c, 404) unless $rs->doc_num;

    # エントリリソースをセットする
    my $doc = $rs->get_doc(0);
    my $entry = XML::Atom::Entry->new(\$doc->attr('@xml'));
    $self->entry_resource->body($entry);

    return 1;
}

列挙と同じように検索オブジェクトを構築し、検索条件を設定します。このメソッドでは、URIが一致するエントリを検索します。

メンバの削除(Delete)

メンバの削除(lib/GData/Controller/EntryCollection.pm)
sub delete_entry :Atompub(delete) {
    my($self, $c) = @_;
    $c->model('Search')->out_doc_by_uri($c->req->uri);
    return 1;
}

URI を指定して文書を削除するメソッドを呼び、文書を削除します。

代替表現への変換

代替表現(alt)クエリが指定されたときには、XMLをJSONに変換します。ビュー(MVCのV)を作成してそこで変換するのがスジかもしれませんが、簡単のためにendメソッドで対応することにします。endメソッドは通常のアクションの後に呼ばれます第3回に登場したautoの逆です⁠⁠。

JSONへの変換にはGoogle::Data::JSONモジュールを使います。

代替表現への変換(lib/GData/Controller/EntryCollection.pm)
use Google::Data::JSON qw(gdata);
sub end :ActionClass('RenderView') {
    my($self, $c) = @_;

    # altが指定されていなければ何もしない
    unless ($c->req->param('alt')) {
    }
    # altがjsonであればJSON に変換する
    elsif ($c->req->param('alt') eq 'json') {
        $c->res->body( gdata($c->res->body)->as_json );
        $c->res->content_type('application/json');
    }
    # altがjson-in-scriptであればコールバック関数をセットする
    elsif ($c->req->param('alt') eq 'json-in-script' && $c->req->param('callback')) {
        $c->res->body( $c->req->param('callback').'('.gdata($c->res->body)->as_json.')' );
        $c->res->content_type('application/json');
    }
}

クエリ変数に合わせて、HTTPレスポンス($c->res->body)を変換し、再設定します。

検索してみる

検索する前に、第2回を参考にいくつかエントリを追加してください。

適当なキーワードで検索してみましょう。たとえば、dogというキーワードで検索するにはhttp://localhost:3000/entrycollection?q=dogというURLです。正しく検索できましたでしょうか?

GDataとの相違点

今回は簡単のために、GData仕様のうちいくつか無視した点があります。

代替表現(alt)では、RSS2.0を指定することができます。しかし、AtomがダメでRSSがOKという状況はほとんどないと思いますので、実装しませんでした。

GDataでは、フィードにOpenSearch拡張を含めることとなっています。OpenSearchは、ヒット数などを示すための拡張要素です。XML::Atom::Ext::OpenSearchモジュールを使うと簡単に対応することができます。

実際のGDataでは形態素解析を行って単語単位で検索していると思われます。一方、Hyper EstraierはN-gramを基本としているため、単語の区切りを意識しません。このため、⁠京都」で検索して「東京都」がヒットするようなことがあります。

GDataでは、AtomPubとは異なるバージョンチェック方式が採用されています。しかし、今後はAtomPubに準拠するとのアナウンスがあったことから、この連載では紹介しませんでした。

まとめ

Catalyst::Controller::AtomPubは、AtomPubサーバの開発をサポートするCatalystコントローラです。AtomPubの複雑な側面を隠蔽し、プログラマがリソースの操作に集中できるようにします。また、AtomPubの接続実験に積極的に参加し、現在では多くのAtomPubクライアントと接続できることが確認されています。

しかし、フィードページングのように、プログラマから隠蔽し切れていない部分もあります。また、現在策定中の拡張仕様には対応できていません。今後はこれらに対応するとともに、さらに品質を高めていきたいと考えています。

本連載に最後までお付き合いいただき、ありがとうござました。Catalyst::Controller::AtomPubについての理解が深まり、皆さんが立ちあげているサービスや手がけているプロジェクトで採用していただけたら幸いです。

おすすめ記事

記事・ニュース一覧