とにかくややこしいことをしない ORM がほしくて Coteng とかいうCPANモジュールを作った

名前の通り、Teng::Lite みたいなモジュールを作った。 Teng::Lite でも良かったけど、なんとなくそれっぽい名前が思い浮かんだのでそれにした。 ORM というよりはやや重めのDBIラッパーかもしれない。

早速、会社で作ってるサーバ管理アプリケーションのTengをCotengに置き換えてみた。 サーバ管理アプリケーションの話はこっち。YAPC::Asia 2013ではてなのサーバ管理ツールの話のはなしをしました - ゆううきブログ

背景

普段Perlの ORM として、Teng にお世話になっている。 ただ、最近採用しているフレームワークとの相性が悪くて、そんなに使わない機能があったりする。 もうちょっと薄いやつがほしくて探したけど無かったので自分で書いた。 探した中で スキーマ定義がいらないという点で DBIx::Lite が一番近かったかもしれない。 DBIx::Liteについてはmotemenさんの資料がわかりやすい。

Kyoto.pm #2 で DBIx::Lite の紹介をしました #kyotopm - NaN days - subtech

先に採用しているフレームワークというものをまず簡単に説明しておく。 ちなみに、Amon2みたいな実装としてのフレームワークではなくて、考え方としてのフレームワークのことを言っているつもり。

まず大きな方針として、

コストの高い処理やリスクの高い処理を気軽に書けないようにする

というのがある。

SQLの発行というのは、Webアプリケーションに必要とされる処理の中で、コストの高い処理に分類されると思う。 ActiveRecord的なパターンに従うと、Modelクラスにfindとかdeleteや、リレーションテーブルへのSQLを発行するメソッドが生えていたりするのが普通だと思う。 ただこれだと、例えば、$user->bookmarks とかやって、コストの高い処理を簡単に書けてしまう。 そこで、SQLを発行する処理はService層に書くっていうのをやってる。 '層'というと大げさで、実際はただのクラスメソッドの集まりになってる。

例を挙げると以下の様な感じ。 https://github.com/hatena/Intern-Bookmark-2013/blob/master/lib/Intern/Bookmark/Service/Bookmark.pm

package Intern::Bookmark::Service::Bookmark;

# Service層のメソッド。ただのクラスメソッド。
sub find_bookmarks_by_entry {
    my ($class, $db, $args) = @_;

    my $entry = $args->{entry} // croak 'entry required';

    $db->dbh('intern_bookmark')->select_all_as(q[
        SELECT * FROM bookmark
          WHERE entry_id = :entry_id
    ], {
        entry_id => $entry->entry_id,
    }, 'Intern::Bookmark::Model::Bookmark');
}

# 呼び出し元
package Intern::Bookmark::Service::Bookmark;

my $bookmarks = Intern::Bookmark::Service::Bookmark->find_bookmarks_by_entry(
    $c->db,
    { entry => $entry },
);

もう少し複雑な例。SQL::Makerを使ってクエリを組み立ててる。

package Intern::Bookmark::Service::Bookmark;

sub find_bookmarks_by_user {
    my ($class, $db, $args) = @_;

    my $user = $args->{user} // croak 'user required';

    my $per_page = $args->{per_page};
    my $page = $args->{page};
    my $order_by = $args->{order_by};

    my $where = {
        user_id => $user->user_id,
    };
    my $opts = {};
    $opts->{limit} = $per_page if defined $per_page;
    $opts->{offset} = ($page - 1) * $per_page if defined $page && defined $per_page;
    $opts->{order_by} = $order_by if defined $order_by;

    my ($sql, @binds) = $db->query_builder->select('bookmark', ['*'], $where, $opts);

    $db->dbh('intern_bookmark')->select_all_as($sql, @binds, 'Intern::Bookmark::Model::Bookmark');
}

上記の呼び出し元をみると、ActiveRecord的な$entry->bookmarksと比べて、Intern::Bookmark::Service::Bookmark->find_bookmarks_by_entry とかやってて明らかにめんどくさくなってる。 ただ、これは「コストの高い処理やリスクの高い処理を気軽に書けないようにする」にしたがっているので、こういう感じでよい。 オブジェクト指向的には美しくないかもしれないけど、現実を見てる感じがする。 N+1なクエリを投げてるとすぐに気づく。

ちなみにこのへんは shiba_yu36 先生に教わった。 不揮発性RAMがコモディティ化したり、Infinibandが普及したりして、ディスクI/OとネットワークI/Oが異常にはやくなって、ボトルネックがCPUに移ってくると、気軽にSQLを発行できてもいいのかもしれない。

ただし、今作ってるアプリケーションでは、コントローラとモデルの個数がかなり多くて、同じような処理をたくさん書かないといけない。 本来は丁寧にService層にロジックを書いたらいんだけど、さすがにidとか適当なカラム名で引くだけの簡単なSQLを毎回丁寧にService::Hogeに書いていくのはつらい。 $teng->singleとか$teng->searchとかを直接コントローラに書いたらいいというところで話はとりあえず落ち着く。

しかし、こういうフレームワークだと、Teng::Rowに生えてるdeleteとかupdateとかを使わないし、Teng::Rowオブジェクトはスキーマ情報とかを保持しているからwarn Dumper $rowとかすると不要な情報が結構出てきてしまう。 さらにいうと、Teng::Iteratorを使う局面が今のところないし、スキーマDSLを書かないといけないのも少々めんどくさいし、blessするしないを。

そこで、SQL::Makerでクエリを組み立てて、DBIx::Sunny経由で実行するぐらいのものがあればちょうどよいような気がした。 Tengのsingleとかsearchとかのインタフェースは結構好きなので、ほとんどそのままにしてる。

SYNOPSIS

雰囲気はなんかこういう感じ。

use Coteng;

my $coteng = Coteng->new({
    connect_info => {
        db_master => [
            'dbi:mysql:dbname=server;host=dbmasterhost', 'nobody', 'nobody', {
                PrintError => 0,
            }
        ],
        db_slave => [
            'dbi:mysql:dbname=server;host=dbslavehost', 'nobody', 'nobody',
        ],
    },
});

my $inserted_host = $coteng->db('db_master')->insert(host => {
    name    => 'host001',
    ipv4    => '10.0.0.1',
    status  => 'standby',
}, "Your::Model::Host"); # "Your::Model::Host"でblessされた結果が返る

my $host = $coteng->db('db_slave')->single(host => {
    name => 'host001',
}, "Your::Model::Host");

my $hosts = $coteng->db('db_slave')->search(host => {
    name => 'host001',
}, "Your::Model::Host");

Modelクラスでblessしたくないとき

my $hosts = $coteng->db('db_slave')->single(host => {
    name => 'host001',
});
my $host = $coteng->db('db_slave')->single_named(q[
    SELECT * FROM host where name = :name LIMIT 1
], { name => "host001" }, "Your::Model::Host");

my $host = $coteng->db('db_slave')->single_by_sql(q[
    SELECT * FROM host where name = ? LIMIT 1
], [ "host001" ], "Your::Model::Host");

my $hosts = $coteng->db('db_slave')->search_named(q[
    SELECT * FROM host where id = (:ids)
], { id => [1, 2, 3] }, "Your::Model::Host");

my $hosts = $coteng->db('db_slave')->search_by_ql(q[
    SELECT * FROM host where status = ?
], [ "working" ], "Your::Model::Host");

Modelクラス

ModelクラスはClass::Accessor::Liteとかで作ってることが多い。 Mouse使ってないけど、Mouseでも良さそう。

package Your::Model::Host;

use Class::Accessor::Lite (
    ro => [qw(
      id
      name
      ipv4
      status
      created
    )],
);

1;

雑感

自分がORM的なものに求めている必要十分なものはとりあえずできてる気がする。 フレームワークとかORMをプロジェクト毎に自作する文化に触れていると、最小限のモジュールがとにかくほしくて、絢爛豪華な料理に飽きた食通みたいなおじさんが木の実とかを食べ始めるみたいになる。 けど、そういう文化に興味なかったら普通にTeng便利だと思う。

songmuさんのYAPCの資料とかみてると鎌倉の文化とはだいぶ違うなーという感じがしてる。