Perl Hackers Hub

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

前回の(1)こちらから。

O/Rマッパを作る前に

(2)では、O/Rマッパを作る前にどのようなことを検討するべきかを説明します。

既存のO/Rマッパとの違いを明確にする

既存のものとまったく同じ問題を解決したところで、単なる車輪の再発明になってしまいます。既存のものと何が違うのかを明確にする必要があります。

そのためには、前例の調査や検証なども必要になるでしょう。CPANモジュールに含まれるChangesやGitHubの過去のissueなどを見ることで、先人の経験をある程度得ることができます。また、近所のPerl MongersやYAPCYet Another Perl Conferenceなどに参加して、有識者の意見を聞くのもよいでしょう。

本当に作る必要があるのかを検討する

その問題は新しいO/Rマッパを作るに値するほどのものなのかを検討しましょう。既存のO/Rマッパに問題を解決するパッチを送ったり、O/Rマッパを使わないという手段もあります。

CPANモジュールを活用する

CPANのDBIxネームスペースやSQLネームスペースには、O/Rマッパの機能のサブセットと言えるようなものがいくつもあります。O/Rマッパを使うことで解決できている問題が局所的であれば、それらを組み合わせてその問題を解決することも容易なはずです。もちろん、それらを組み合わせてO/Rマッパを実装することもできます。

ここでは、Anikiで実際に利用しているCPANモジュールをいくつか紹介します。ただ、文章だけではわかりにくい点もあるかもしれません。本誌サポートサイトから入手できるサンプルコードや各モジュールのドキュメントも併せて読んでいただくことで、より理解が深まると思います。

DBIx::Handler─⁠─データベースとの接続管理

プロセスがforkしても、ファイルディスクリプタはコピーされません。データベースとの接続でもそれを考慮する必要があります。そうしないと、複数のプロセスから同じセッションでクエリを発行することになってしまいます。

親プロセスが単に子プロセスを待つだけなど、同じコネクションを同時に利用しないことが保証されたコードであれば、DBIAutoInactiveDestory属性を利用すれば問題ありません。しかし、トランザクション中に誤ってforkした場合のエラー処理や、その再接続のハンドリングまで考えると大変です。

そのようなデータベースベースとの接続のハンドリングを担うのがDBIx::Handlerです[1]⁠。後述するDBIx::TransactionManagerとうまく連携し、トランザクションの状態を加味したコネクションの一貫した接続管理を行います。

ちなみに、DBIx::HandlerDBIx::TransactionManagerは、O/RマッパのDBIx::Skinnyやその後継となったTengから切り出されたモジュールです。O/Rマッパの実装に利用しやすい設計になっています。

DBIx::TransactionManager─⁠─トランザクションの管理

構造化されたコードから適切にトランザクションを扱うためには、いくつか課題があります。その問題の一つに、トランザクションのネストがあります。

複雑なアプリケーションでは、同じようなデータベース操作を別々の場面で行うことがしばしばあります。トランザクションの開始とコミットを含めて共通化すると、それと同じ単位でコミットしたい操作が出てきたときに困ります。⁠この処理はトランザクションの中で実行すること」という暗黙の了解をもって共通化した場合も、呼び出し側で正しくトランザクションをかけ忘れると、レースコンディション問題[2]が発生するでしょう。あるいは、リクエスト全体でトランザクションを作るという方法も考えられるかもしれませんが、トランザクションとそのロック範囲が不必要に広くなるためパフォーマンスに難があります。

こういった場面でトランザクションのネストができると、それぞれのコンテキストでトランザクションの範囲を考えればよくなります。ただ、トランザクションのネストの挙動にはRDBMSによって違いがあり、そのまま扱うには少し難があります。

DBIx::TransactionManagerは、ネストしたトランザクションをネストの一番外側の1つのトランザクションにまとめて扱えるようにすることで、トランザクションのネストが扱いにくい問題を解決しています。具体的には、トランザクション内では実際は新たにトランザクションを作らずに仮想的なトランザクションを作り、その結果をもとに実際のトランザクションの操作に作用します。そのため、仮想的なトランザクションでロールバックが発生した場合は、トランザクション全体がその時点でロールバックされる実装になっています。

このロールバックの挙動には、少し気を付ける必要があります。なぜなら、AutoCommitが有効なセッションで後続の処理を実行してしまうと、それらの処理はトランザクションがかかっていないので、レースコンディション問題が発生するためです。Try::Tinyなどと組み合わせて例外を捕捉してロールバックし、dieを用いて大域脱出をして、一番外側のトランザクションの外側に抜けられるような実装が望ましいです。前述のDBIx::Handlertxnメソッドを利用すれば、まさにそのような挙動にできます。

SQL::Maker─⁠─柔軟なSQL生成

従業員の検索機能をイメージしてみましょう。年齢で絞り込みを行いたい場合もあれば、それに加えて性別でも絞り込みたい場合も、名字で絞り込みたい場合もあるでしょう。このように検索条件の組み合わせが多いときに、すべての組み合わせのSQLを別々に定義して利用することは現実的ではありません。

このようなケースでは、プログラム的に文字列結合を行い、SQLを作り上げる方法を思い付くでしょう。しかし、この実装方法では順序ベースで値をバインドする一般的なプレースホルダと組み合わせるのが大変で、かといってSQLに直接値を埋め込むナイーブな実装ではSQLインジェクションの温床になります。何より手続きの連続に冗長なSQLが挟まることで、コードの見通しが悪くなることがよくあります。

今回は、オブジェクト指向の素朴でシンプルなクエリビルダであり、O/Rマッパでも利用されているSQL::Makerを紹介します。SQL::Makerは、$maker->select(employee => ['*'], { age => 17 })などといった比較的SQLに近い表現で、SQLとプレースホルダにバインドする値を返す機能を持ちます。また、その一部であるnew_selectメソッドなどを利用することにより、SELECT句を表現するオブジェクトを操作してSQLを組み立てることができます。たとえば、$ageが真値の場合は検索クエリにそれを含めるSQLを生成するには、new_selectメソッドを使い次のように書けます。

my $select = $maker->new_select;
$select->add_select('*')->add_from('employee');
if ($age) {
    $select->add_where(age => $age);
}

my $sql = $select->as_sql;
my @bind = $select->bind;
$dbh->selectall_arrayref($sql, { Slice => {} }, @bind);

文字列結合で同等の処理を実装したコードを想像して比較してみてください。$sql@bindをうまく取り回すのはなかなか骨が折れると思います。これは例なので年齢だけを検索条件にしていますが、実際の現場ではより複雑な条件を扱うことになるでしょう。

SQL::NamedPlaceholder─⁠─名前付きプレースホルダ

巨大なSQLを書く際に、printf風に順序ベースで値をバインドしていく一般的なプレースホルダを利用すると、値を渡すべき順序を間違えやすく、バグの温床になりやすいです。かといってクエリビルダで表現するのが面倒な場合も多いでしょう。

このようなケースでは、SQL::NamedPlaceholderなどの名前付きプレースホルダを利用するのが賢い選択です。名前付きプレースホルダを利用すると、名前ベースで値をバインドできるため説明的なコードになり、間違いに気付きやすくなります。

また、SQL::NamedPlaceholderをはじめとするPerlの名前付きプレースホルダの実装では、配列リファレンスとして渡した値をカンマ区切りで展開する機能を持つものが多いです。この機能はSQLのIN句を利用するときに極めて便利なので、よく利用されています。

DBIx::Schema::DSL─⁠─DSLによるスキーマ定義

DBIx::Schema::DSLは、名前のとおりDSLDomain Specific Languageドメイン特化言語)でスキーマを定義できるモジュールです。Perlの内部DSLでスキーマを記述できることで、デフォルト値やカラムのサイズをconstantプラグマを用いて一元管理できるなどのメリットがあります。

DBIx::Schema::DSLをO/Rマッパで採用する最大のメリットは、O/Rマッパで使うスキーマ定義と実際のDDLData Definition Languageを同一のファイルで管理できることです。従来のO/RマッパはDDLをもとにO/Rマッパ用のスキーマのメタオブジェクトの定義を生成するスタイルのものが多く、データベースに登録したDDLとスキーマのメタオブジェクトの定義を同期する手間が必要でした。DBIx::Schema::DSLを用いてPerlで記述したスキーマ定義からDDLを生成できるため、その手間が不要になります。

また、DBIx::Schema::DSLは、SQL::Translatorがバックエンドになっています。SQL::Translatorは、RDBMSごとに文法や型の名前に差異があるDDLを別のRDBMS向けに変換できるモジュールです。SQL::Translatorはそのしくみ上、解釈したDDLをオブジェクトとして表現するスキーマのメタオブジェクトが必要になります。そのスキーマのメタオブジェクトを表現するためのクラスがSQL::Translator::Schemaです。DBIx::Schema::DSLではSQL::Translator::Schemaのオブジェクトを直接生成しています。

SQL::Translator::SchemaはDDL文の変換に使えるほど、O/Rマッパで利用するには十分すぎる機能を有しています。Anikiではこれを利用して、スキーマ定義を参照するしくみを実装しています。ただし、SQL::Translator::Schemaは頻繁にアクセスされることを想定していないのか、パフォーマンスが良い実装ではありません。Anikiではラッパクラスを定義してホットポイントで独自にキャッシュを行い高速化しています。

<続きの(3)こちら。>

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

おすすめ記事

記事・ニュース一覧