MVCのModelをWAFから切り離しつつ、Modelも(DBとの仲立ちをさせる)ORM用スキーマと(ビジネスロジックを書く)ドメインモデルとで分けたいと思い、ここ数日調査や試行をしています。私はPoEAAを読んで、かっとなって「どうせ趣味で書くならドメインモデルしかない!」と思い込んでいます。これがまず出発点です。今回の記事は、そのモデルの守備範囲をどうしようか、というお話です。
まず、CatalystなどのWAFからMVCのMを切り離すことについては、牧さん(lestrratさん)の『モダンPerl入門』(pp.116-121)などで明快に解説されています。
次に、サービスクラスとモデルクラスの使い分けについても、dannさんのCatalystCon #1でのCatalystからModelを切り離せ - MVCのMのあるべき姿 -の発表でも言及があります。
それでは、ドメインモデルはDAO/DTOやORMの方面まで面倒を見るべきなのでしょうか。
今回細々と開発しているFF14ユーザー向けウェブサービスであるAmikecoでは、ORMとしてDBIx::Classの使用を想定しています。また、サービスクラスやモデルクラスは、Mooseクラスを想定しています。
というような状態での具体的な実装方法について、調べてみたところいくつかの類型があるので、試行してみての感想を添えて備忘録として書いておきます。
- MooseクラスでDBIx::Classを継承して、ドメインモデルにスキーマを兼務させる
- DBICx::Modelerを使って、スキーマクラスとモデルのMooseクラスを分離する
- MooseX::DBICを使う
- 永続化非対象の情報をどう持つか
- 番外編: Moosified ORMを使う
- まとめ
1. MooseクラスでDBIx::Classを継承して、ドメインモデルにスキーマを兼務させる
1.1. 概要 : モデルクラスにスキーマクラスを継承させて兼務させる
一番定番なのがこれだと思います。モデルクラスにスキーマを継承させて兼務させる方法です。
DBIx::Class::Manual::FAQでも、How do I store my own (non-db) data in my DBIx::Class objects?
として触れられています。
注意点としては、Moose 0.62_02以降では、(警告を回避するために)__PACKAGE__->meta->make_immutable(inline_constructor => 0)することでしょうか。また、非Mooseクラスの継承にはMooseX::NonMooseが定番で、牧さんのPixisもこの方法を使っています。
1.2. 定番の手法
基本的にはこれが良いと思います。PerlMonksでもDBIx::Class and Mooseなどのいくつかの議論で話題に上りましたし。
dannさんの発表の通り、単純なCRUD処理であれば生モデルを扱いたいところなので、ドメインモデルとDxOやORMが一緒になっていても違和感はないのかもしれません。巨大なアプリケーションでなく、比較的小さめのアプリケーションを作るには、お手軽さはやはり見逃せません。そもそも、「スキーマと一緒で何か気持ち悪い」という私の主観を無視すれば、巨大なアプリケーションでも有用な手法と言えます。
1.3. ORM依存性を減らしたい
しかしやはり私はこれらを分けたいという思いがあります。
まず、特定のDxOやORMに依存した書き方になることは避けたいのです。よしORMを変えようといった場合に、モデル&スキーマクラスから、スキーマ部分を置換するようなことが必要となります。
勿論、ORMを変えようだなんて、そんなことが実際問題として開発中に起こり得るかというと、あまり考えられません。
さらに、これはDBIx::Class::Schema::Loaderの工夫次第で無視出来ると思います。ZIGOROuさんのはてダにあるDBIx::Class::Schema::Loaderの手動スキーマ生成、初心者向けチュートリアルの「別のINCにテンプレ」の手法を使えば、最終的に生成されるクラスはともかく、少なくとも開発者が書くファイルでは素のスキーマクラスへ追加する部分(ビジネスロジック等)のみが現れるからです。これは好みの問題だと思います。
1.4. DBのスキーマの成長を漏らさず取り込みたい、しかも楽に
私にはもう一つの懸念があって、それはDBIx::Class::Schema::Loaderの実行に制約が起きる文脈があるという点です。例えばDBIx::Class::Rowに生やすカラムの名前が付いたアクセッサーメソッドをモディファイ出来ないという問題です。
これは、DBIx::Class::Schema::Loaderが、生成したスキーマクラスに別のテンプレートを突っ込む際に、そのテンプレートの中身が(perlに)評価されるためです。もう少し噛み砕くと、そのテンプレートの記述だけで一つのPerlクラスとして成立していなければならないという制約があります。
DBIX::Class::Schema::LoaderがDBから引っこ抜いて自動生成したスキーマクラスは、例えば以下の/lib/MyApp/Schema/Foo.pmのようになります。
package MyApp::Schema::Foo;
use strict;
use warnings;
use base 'DBIx::Class';
__PACKAGE__->load_components("UTF8Columns", "Core");
__PACKAGE__->table("foo");
__PACKAGE__->add_columns(
"id",
{
data_type => "INTEGER",
default_value => undef,
is_nullable => 1,
size => undef,
},
"password",
{
data_type => "VARCHAR",
default_value => "''",
is_nullable => 0,
size => 255,
},
);
__PACKAGE__->set_primary_key("id");
# Created by DBIx::Class::Schema::Loader v0.04006 @ 2009-10-25 23:57:56
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:uy8PqmDawAHTTN08+aH+Jw
# You can replace this text with custom content, and it will be preserved on regeneration
1;
そして@INCに追加する、別途用意するテンプレートが以下の/schema/lib/MyApp/Schema/Foo.pmのように書かれていたとします。
package MyApp::Schema::Foo;
use Moose;
use MooseX::NonMoose;
extends qw(
DBIx::Class
);
use Digest::SHA qw(sha256_hex);
use namespace::clean -except => [qw(meta)];
has 'hashed_password' => (
is => 'ro',
lazy_build => 1,
);
sub _build_hashed_password {
sha256_hex($_[0]->password);
}
after password => sub {
return
if @_ > 1;
$_[0]->clear_hashed_password;
};
__PACKAGE__->meta->make_immutable(inline_constructor => 0);
1;
ここで、__PACKAGE__->add_columns('password')した段階で、行オブジェクトにはpasswordアクセッサーメソッドが生えるのですが、そんなことを知らないテンプレート側では、ベタにafter password => sub { ... }などと書いても、「passwordなんてメソッドは継承ツリーのどのクラスにもいないよ」(The method 'password' was not found in the inheritance hierarchy for MyApp::Schema::Foo)と怒られる訳なんですね。
追記 : どうも私が使い方を根本的に勘違いしていたようで、まるっとテンプレートをコピペしてもpasswordメソッドが見つからないのは変わりませんでした。after idでは問題ありません。別のスキーマで、PRIMARY KEY指定したcolumn名にafterした時には動いたので、それと混同していました。いずれにせよ、クラス内でuse base 'DBIx::Class'した後で再度extends 'DBIx::Class'している箇所などが危険なので、素のDBIx::Class::Schema::Loaderでは難しいところがあります。ZIGOROuさんの「差分を直に書く方法」
であれば問題ないという訳です。
そう何度も頻繁にDBの定義を変えることは考えにくいので、単なる私の杞憂と言ってしまえばそれまでです。しかし私は基本的におっちょこちょいなので、出来ればドメインモデルより奥側の世界は自動生成だけで済ませたいところです。(以前のER図描画ツール探しの記事で言及したり、YAPC::Asia 2009の特別研修の特別質疑応答コーナーで牧さんとMillsさんに紹介していただいた)MySQL Workbenchで吐いたCREATE文があれば、スキーマを自動生成しつつ独自実装部もテンプレートからはめ込んむということを、スクリプト一発でやってくれる仕組み(その例はGist #217006に上げました)は、是非大事にしたいと思います。
テンプレートの内容を文字列展開するようにDBIx::Class::Schema::Loaderの自作版を作る手もあるかと思い付きましたが、実際に手を動かすのが億劫で、断念しました。
2. DBICx::Modelerを使って、スキーマクラスとモデルのMooseクラスを分離する
2.1. 概要 : スキーマクラスへ被せる皮をモデルクラスに提供する
なんともわがままというか自己中な考えを書きましたが、それではモデルクラスとスキーマクラスを分けるならば、どのようにすべきでしょうか。
15分くらい、ロールとしてスキーマクラスとの架け橋を造ろうとしていましたが、色々考えなければいけないことが多すぎると思い至りました。
そもそもこんな私の思い自体、何とも自己中でありますけれども、しかしそれほど突飛なことを考えているつもりはなかったので、きっと世界のどこかで誰かがいつか何かを作っているんじゃないか、と思って見つけたのがRobert KrimenさんのDBICx::Modelerです。
使い方はテストスクリプトの通りですが、MyApp::Model::Fooモデルクラスでuse DBICx::Modeler::Modelしておけば、相対するMyApp::Schema::Fooスキーマクラスの行オブジェクト用アクセッサーも使いつつ、自分でアトリビュートだろうがメソッドモディファイヤーだろうが好きに書けるのがいい点です。
2.2. 分けると精神的には楽
これにしても結局はDBICx::Modelerに依存してはいるのですが、他のORM用にもこうしたラッパーがあれば、ビジネスロジックとスキーマを峻別したまま、ラッパーの何を使うかだけを切り替えれば対応が出来るという点は見逃せません(そのラッパーをDIする手もあるでしょうし)。
DBIx::Class::Schema::LoaderでMyApp::SchemaとMyApp::Schema::*はばんばん自動生成しつつ、MyApp::Model::*はそれに影響を受けずに悠々と書けるという案配です。
2.3. Moose 0.90以降の警告を回避する対応
ところで、Moose 0.90以降だと、DBICx::Modeler 0.004がクローニングを$self->newで行っている箇所でMooseが警告を出します。将来的には警告ではなくエラーになるので、些細なものですが対応をしてみました。
要は(blessed $self)->newに変えただけですが、ともあれpull requestもしてみました。
Hello Robert, I updated my branch with the fixing issue where Moose warned against using deprecated cloning. If you like this trivial patch, could you please merge it into your branch?
ありがたいことにマージをしていただき、DBICx::Modeler 0.005としてCPANに上げていただいています。実はCPANモジュールにパッチをお送りするのはこれが初めてで、些細な差分ですが採用いただいてとても嬉しかったです。
後になって思えば、本件対応用のトピックブランチを切った方が良かったかと、少々後悔しています。また、2行だけ差し替えてテストケースを2個追加したという今回の場合、
- RTチケットを切ってパッチを送ることで、問題の所在やその対応を(衆目に晒して)是非を問うのが良いのか
- 開発に直結している方法として、githubでforkしてcommitしてpushしてpull requestした方が良いのか
- 両方した方が良いのか
といった悩みもあります。取り敢えず2番目の手法を選びましたが、今後もどうすべきかを考えたいところです。
3. MooseX::DBICを使う
Stevan Littleさん(stevanさん)のMooseX::DBICというモジュールもあります。2009年10月23日現在、まだCPANには上がっていませんが、これもgithub上でリポジトリが公開されています。
これは1.と同じ類型で、スキーマクラスとMooseモデルクラスを兼務させるためのものです。
もし1.を考えている場合、このMooseX::DBICが成長すれば、有力な選択肢となるかも知れません。こちらも大変期待の持てる作品です。
4. 永続化非対象の情報をどう持つか
例えば、forename(名)とsurname(姓)がDBに格納される場合で、fullname(氏名)をどう持つか、という備考です。
4.1. 手動triggerをafterで使う
これと同じような例をtriggerについての備忘録で記述しましたが、今回はtriggerは使えません。そもそもforenameやsurnameはDBIx::Class::Rowに生えたアクセッサーメソッドであって、モデルであるMooseクラスのアトリビュートとして存在している訳ではないからです。
ということで、afterを使って、自前でアクセッサーがゲッターとして使われたのかセッターとして使われたのかを(引数の個数を見て)判断する必要があるわけです。
4.2. DBIx::Class::VirtualColumnsを使う
無理にアトリビュートで持とうとしなくても、ORM側で「他の列と同じように読み書き出来るけど、永続化はしない」という、融通を利かせることが出来ます。それがDBIx::Class::VirtualColumnsという素敵なモジュールです。
例によってPixisを読んで知ったモジュールですが、これを活用することでも、素直にDB外の情報を持ち回ることが出来て、いい感じに実装出来そうです。
4.3. 個別のメソッドとして実装する
例えば名と姓を合体させて氏名を作る処理は、毎回導出しても悪いわけではありません。勿論性能面での問題等は考慮が必要ですが、単にsub fullname { $_[0]->forename . q{ } . $_[0]->surname }してしまえば事足りる場合だって少なくないと思います。
氏名を求めることを振る舞いと見るか、氏名という導出情報を行が持つというように見るか次第で、私は後者の説に立つので4.1.か4.2.を採用したいと考えています。
5. 番外編: Moosified ORMを使う
これまでDBIC前提で書いてきましたが、Mooseで書かれたORMを活用する手もあります。これなら、一緒にしようが分けようが、諸々のことを考えなくて済みます。
KiokuDBやFey::ORMなど、面白そうなORMがあります。まだ邦語情報が少ないのですが、すぐにアプリで使わないにしても、少しずつこれらの中身も知っておきたいと思っています。
6. まとめ
結局、まずは分離しようということで、DBICx::Modelerにお世話になってプロトタイピングしてみるつもりです。
現在はまだ実現可能性調査の段階なので、この記事は大きなアプリケーションの開発を通じて得られるであろう知見が全く反映されていません。従って、素直にDBIC(やその拡張)の機能を活用した方法が一番だということになるかも知れません。
そもそも金融ユー子なのにエンタープライズアプリケーションの何たるかをろくに理解していないので、もっとちゃんとPoEAAを読もうと思います。
コメントする