Perl Hackers Hub

第47回Anikiで学ぶ実践的なO/Rマッパの作り方(3)

(1)こちら⁠2)こちらから。

Aniki─⁠─これまでになかった抽象度を実現したO/Rマッパ

これまでのO/Rマッパが持つ問題を解消するために筆者が新しく開発したO/RマッパがAnikiです。⁠3)ではAnikiの設計と実装について、特徴的な部分とその考え方を説明します。

既存のO/Rマッパとの違い

AnikiはこれまでのO/Rマッパと比べて、抽象度の低いデータベース操作ができるO/Rマッパとして設計されています。すべてのデータベース操作を行うメソッドは、抽象的な操作の名前ではなくデータベースにどのようなSQLを発行するかというより低レベルな操作で命名されています。たとえば、Anikiのデータベース操作を行うメソッドの名前はselectinsert_and_fetch_rowなどにしています。これにより、O/Rマッパに明るくない人でも、どこでどのようなSQLが発行されるのかが比較的容易にわかります。

反面として、O/Rマッパを使う以上は高い抽象度でデータを扱いたいケースが多いはずです。Anikiでは行オブジェクトを不変オブジェクトImmutable Objectとして扱うことで、既存のO/Rマッパに近い触り心地を実現しつつも、SQLの発行は明示的に行うインタフェースを使うことを強制できるようにしています。行オブジェクトが状態を持たないことによってデータが変更された可能性を考慮する必要がなくなりデバッグが容易になるなど、行オブジェクトを不変オブジェクトとして扱うメリットはたくさんあります。

また、⁠2)で紹介したようなCPANモジュールに積極的に機能を委譲しており、さらにMouseのメタクラスを利用することで、本質的でないコードをなくして読みやすく、拡張しやすくしています。この点についてはのちほど実装を踏まえて詳しく説明します。

本当に作る必要があるのか

表1に挙げたような既存のO/Rマッパが抱える問題は、根本的な機能設計レベルで起きているものです。既存のO/Rマッパで問題を解決するためには非常に大きな変更を行う必要があります。変更によりインタフェースにも非互換が発生するため、すでに広く使われているO/Rマッパを変更することは難しいでしょう。

もちろん、O/Rマッパを使わないという選択もあります。しかし、筆者が以前行っていたソーシャルゲームのゲームサーバ開発のように、巨大な構造を持ったデータベースを利用するアプリケーションの開発においては、テーブルの数が100を超えることも珍しくありません。それくらいの規模になるとテーブルどうしの関係性の管理を補助しコードの抽象度を高めてくれるO/Rマッパを使わない開発は考えられません。

また、⁠2)のとおり今日では先人たちの不断の努力の結果としてデータベースプログラミングに必要な各機能が別々のCPANモジュールとして提供されています。これらを使って新しいO/Rマッパを構築することは以前と比べると極めて容易になったと言えるでしょう。そこで、これらの問題を解決することを目指して、自分の考える最高のO/Rマッパを新たに構築しようと作り始めたものがAnikiです。

セットアップ

Anikiについて詳しく知るために、どのように使えるのかを見ていきましょう。

まずはcpanmAnikiをインストールします。

$ cpanm Aniki

インストールに成功すると、install-anikiコマンドが利用可能になります。Anikiを利用するネームスペースとモジュールの生成先ディレクトリを指定して実行すればセットアップが完了します。

MyApp::DBネームスペースにセットアップする場合
$ install-aniki --lib=./lib MyApp::DB

セットアップされるモジュールは表2のとおりです。

表2 install-anikiがセットアップするモジュール
名前機能
MyApp::DBデータベースを表現する
MyApp::DB::Schemaスキーマ定義を定義する
MyApp::DB::Filterinflate/deflateなどのフィルタを表現する
MyApp::DB::ResultSQLの実行結果を表現する
MyApp::DB::Rowデータベースの行を表現する

スキーマ定義

install-anikiで生成したMyApp::DB::Schemaでは、DBIx::Schema::DSLを利用してスキーマを定義します。

サンプルとして、DBIx::Schema::DSLを利用して簡単な地理とその間のフライトを管理するスキーマを用意しました図1⁠。

図1 サンプルのER図
図1 サンプルのER図
create_table country => columns {
    integer 'id', primary_key;
    varchar 'name', size => 32;
    varchar 'region', size => 32;
};

create_table city => columns {
    integer 'id', primary_key;
    varchar 'name', size => 32;
    integer 'country_id';

    belongs_to 'country';
};

create_table flight => columns {
    integer 'id', primary_key;
    varchar 'name', size => 32;
    integer 'departure_city_id';
    integer 'arrival_city_id';

    fk 'departure_city_id' => 'city', 'id';
    fk 'arrival_city_id' => 'city', 'id';
};

次項以降では、これらのモジュールとスキーマをベースにAnikiの各機能を見ていきましょう。

接続ハンドリング

デフォルトでは、Aniki::Handlerに委譲して接続ハンドリングを管理します。Aniki::Handlerは基本的にはDBIx::Handlerと同じ機能を提供しています。

特筆するべき点としては、接続ハンドラを差し替え可能にしている点でしょう。MyApp::DBsetupメソッドにhandlerオプションを渡すことで、接続ハンドラとして利用するクラスを変えることができます。

package MyApp::DB;

...

__PACKAGE__->setup(
    ...
    handler => 'MyApp::DB::Handler',
);

具体的なユースケースとして、たとえばレプリケーションなどを利用して読み込み専用のデータベースサーバを複数用意してランダムに選択し、データベースからの読み込みを分散させるアプローチは一般的によく用いられます。Aniki::Handler::WeightedRoundRobinを利用すれば、Data::WeightedRoundRobinDBIx::Handlerを組み合わせて重み付けをしつつ、ランダムにデータベースサーバに接続させることができます。

SQLの発行

Anikiは大きく分けて2種類のSQL発行方法をサポートしています。クエリビルダによる動的なSQL生成を伴うSQLの発行と、静的に記述したSQLの発行です。先述したとおり、前者の方式であってもselectなどのSQLに寄った名前のメソッドを使います。

# クエリビルダによる動的なSQL生成を伴うSQLの発行
my @countries = $db->select(country => {
    region => 'Asia',
})->all;

# 静的に記述したSQLの発行
my @countries = $db->select_by_sql(q{
    SELECT * FROM country WHERE region = ?
}, ['Asia'])->all;

これらのメソッドからクエリを発行した結果はMyApp::DB::Resultのインスタンスです。このクラスはひな型でAniki::Result::Collectionを継承しています。コレクションとして振る舞うためallメソッドを呼び出すことですべての行を得ることができます。デフォルトでは各行はMyApp::DB::Rowのインスタンスとしてマッピングされますが、suppress_row_objectsオプションでマッピングを抑制して純粋なハッシュリファレンスで行を表現することもできます。

RowResultはどちらもそのネームスペースに、テーブル名をキャメルケースCamelCaseにした名前でルートクラスを継承したクラスを定義することで、テーブルごとに別のクラスにマッピングさせることができます。具体的には、countryテーブルに対応するRowクラスを定義したい場合は、MyApp::DB::Rowを継承するMyApp::DB::Row::Countryという名前のクラスを定義すればよいでしょう。

このように行オブジェクトなどはテーブルごとにクラスを分けることができますが、前述のどちらのクエリ発行方式であってもテーブル名から適切なクラスを選択して、行オブジェクトをクエリの結果から生成して利用できます。静的に記述したSQLの発行方式では、SQLのFROM句からテーブル名を抽出して利用することもできます。table_nameオプションで明示的に指定することもできます。

クエリビルダ

AnikiではクエリビルダとしてSQL::Makerを利用しています。Anikiではデフォルトでプリペアドステートメントのためにprepare_cachedを利用します。

キャッシュヒット率を向上させるため、WHERE句のカラムの順序が同じ順番になるように、検索条件となるハッシュをソートして利用する機能があります[3]⁠。これによって、RDBMSのクエリキャッシュとの相性も良くなります。もちろん、邪魔なケースもあり得るのでOFFにすることもできます。

リレーションシップサポート

Anikiでは、スキーマの外部キー制約から自動でリレーションシップ定義を構築する機能を実装しています。実際の制約をもとに生成することで、実態に即したリレーションシップ定義を利用できます。また、Aniki::Schema::Relationship::Declareを利用することで外部キー制約に依存せずにリレーションシップを定義することもできます。外部キー制約を利用したほうが実態に即した状態に維持しやすいため、外部キー制約を利用するほうが基本的にはお勧めです。

ここでは主に外部キー制約の解釈について説明します。

スキーマにはbelongs_toによって外部キー制約が定義されています。これはcity.country_idにはcountryテーブルに存在するidの値しか入らないという制約を簡潔に定義するためのエイリアスです。プライマリキー制約やユニーク制約を見てみると、countrycityは1対多の関係にあることがわかります。つまり、countryは複数のcityを持つ可能性があり、逆にcityは単一のcountryしか持たないということです。Anikiでは外部キー制約から自動的にこの関係を抽出して利用できます。

具体的なコードは次のようになります。

my $country = $db->select(country => {
    id => 1,
}, {
    prefetch => [qw/cities/],
})->first;
$country->cities; # Array[city]

これらのアクセサの生成と命名はスキーマから自動で行われますが、それがどのように実装されているのかを見ていきましょう。

アクセサの命名規則による関係性の表現

この機能は、外部キー制約のソースとなるテーブル名に対して作用します。このスキーマの場合はcitycountry_idからcountryidに対して外部キー制約が定義されているため、ソースとなるテーブル名と同じcountryという名前で関連レコードへのアクセサが作られます。

関連レコードへのアクセサはテーブル間の関係によって得られるべき情報が違います。たとえば、countryのレコードに対して紐付くcityのレコードは複数ある可能性があります。しかし、逆の関係を見るとcityのレコードに対して紐付くcountryのレコードは1つしかあり得ません。つまり、countryのレコードに紐付くのはcityの集合であり、cityのレコードに紐付くのは単一のcountryです。

集合をデータ構造で表現するためには配列などを利用する必要がありますが、もし同じような名前のアクセサであるにもかかわらず配列が返ってきたりスカラ値が返ってきたりしたら混乱します。そのため、Anikiでは複数個以上のレコードに対するアクセサの場合は複数形の名前でアクセサが作られるしくみにしています。

例を出すと、countryの行オブジェクトからはcityテーブルのレコードに対して複数形の名前であるcitiesでアクセサが作られます。逆に、cityの行オブジェクトからはcountryテーブルのレコードに対して単数形の名前であるcountryでアクセサが作られます。このように、複数形と単数形を使い分けて1対1、1対多を表現することで、テーブル間がどのような関係にあるのかをコードで表現するための手助けをしています。

Perlで英語の単語を単数形/複数形を変換するためには、Lingua::EN::Inflectモジュールが利用できます。Anikiでは後述する接頭辞を無視するために、テーブル名を記号で区切った最後にあたる単語を複数形に変換します。たとえばdeparture_cityという名前であれば最後にあたるcityを複数形にすることで、departure_citiesにして自然な名前にできます。

接頭辞の解釈

テーブル名を解釈するだけでは不十分な場合もあるでしょう。具体的には、flightテーブルのdeparture_city_idarrival_city_idカラムのように、複数のカラムから同一テーブルに対して外部キー制約を定義している場合です。このようなケースでは、カラム名に接頭辞を付けて区別することが多いでしょう。そのため、カラム名の接頭辞をアクセサの名前にも付けるしくみにしています。

たとえば、flightテーブルのdeparture_city_idカラムに対する外部キー制約からは、まずカラム名からdepartureという接頭辞が抜き出されます。そして、その接頭辞を対象のテーブル名に付けてdeparture_cityとして、さらに区切られた最後の単語となるcityを複数形にしたdeparture_citiesという名前でアクセサが作られます。これは一見ややこしいですが、直感に即した名前になっていると思います。

プリフェッチ機能

selectメソッドにprefetchオプションを指定することで、N+1クエリ問題を解決できます。これには一般的にはJOINが用いられますが、AnikiではトランザクションとIN句を使ってプリフェッチを実現しています。その結果、生成するSQLがシンプルなものになるため、クエリチューニングが容易になります。

プラグイン

Anikiではプラグインとして、SQLのCOUNT関数を利用したクエリを手軽に発行するためのAniki::Plugin::Countや、JOIN句を利用するためのAniki::Plugin::SelectJoinedページネーションをサポートするAniki::Plugin::Pagerなどを標準で用意しています。AnikiのプラグインはすべてMouse::Roleとして実装されているため、Mousewith句を利用してプラグインを適用できます。

特筆するべきは、同様のしくみで行オブジェクトなどに対するプラグインも実装可能である点です。行オブジェクトのクラスはテーブルごとに分けることができますが、特定パターンのカラムを持つ別テーブルに対して共通したメソッドを定義したいケースも多いはずです。たとえば、緯度と経度の情報を持ったテーブルのレコードから緯度と経度をもとに位置情報を表現するオブジェクトを作るメソッドを用意したり、特定のスコアを数値で保存しているレコードどうしを比較するメソッドを用意したりなどのケースがあると思います。そのようなケースでもMouse::Roleの定義を利用して簡単に問題を解決できます。

さらに、Mouse::Rolerequiresを利用すれば特定のメソッドを持つクラスだけにプラグインを適用させることもできるので、アクセサメソッドをrequiresに指定することで特定のカラムが存在するテーブルのみにプラグインを適用させることもできます。また、Mouse::Roleは別のMouse::Roleを内包できるため、複数の似たプラグインのサブセットを作ることもできます。たとえばコアのプラグインにはAniki::Plugin::PagerAniki::Plugin::SQLPagerがありますが、これらの共通する部分をAniki::Plugin::PagerInjectorとして共通化しています。

まとめ

本稿では、O/Rマッパを使う良し悪しを考察し、CPANモジュールを使って具体的な問題を解決できるO/Rマッパを作ることで、Anikiのような実践的なO/Rマッパを作れることを示しました。

筆者が挙げた問題は、環境によっては問題にならないものもあるかと思います。自分の環境に適したO/Rマッパを選択あるいは作成することが大切です。DBIx::ClassTengも帯に短し襷(たすき)に長しと感じたことがある人は、ぜひAnikiもご検討頂けると幸いです。

さて、次回の執筆者は谷脇真琴さんで、テーマは「Perlでの今風のゲームサーバ開発とテスト」です。お楽しみに。

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧