Perl Hackers Hub

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

この記事を読むのに必要な時間:およそ 4.5 分

接続ハンドリング

デフォルトでは,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にすることもできます。

注3)
Perl 5.18以降ではHash Randomizationが導入されており,ハッシュを走査するたびに違う順序となります。

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

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.113

2019年10月24日発売
B5判/160ページ
定価(本体1,480円+税)
ISBN978-4-297-10905-9

  • 特集1
    接続エラー,性能低下,権限エラー,クラウド障害
    AWSトラブル解決
    原因調査・対応・予防のノウハウ
  • 特集2
    Ruby書き方ドリル
    要点解説と例題で身に付く!
  • 特集3
    体験
    ドメイン駆動設計
    モデリングから実装までを一気に制覇
  • 一般記事
    FigmaによるUIデザイン
    デザイナーとエンジニアがオンラインで協業できる!
  • 一般記事
    入門
    SwooleによるPHP非同期処理
    高速化のための並列実行はどのように書くのか

著者プロフィール

佐藤健太(さとうけんた)

1990年,千葉県生まれ。2011年に株式会社モバイルファクトリーに新卒入社し2016年9月より現職であるDeNAのオープンプラットフォーム事業部に勤務。Japan Perl Associationの理事も務める。

2014年5月にGotanda.pmを発足。YAPC::AsiaやYAPC::Japan,YAPC::EUなどでもスピーカーをするなど,Perl関連のコミュニティを中心に活動している。

好きな言語はPerlとGo。日本酒と寿司とロックンロールが好物。バンド活動も行っている。

URL:https://karupas.org/