PDOの真の力を開放する - PHPでデータベースを扱う(3)

Posted by Hiraku on 2012-06-24

ちょっと遅れましたが、シリーズの第3回です。前回までに論じた内容をふまえて、簡単な実装を示します。↓前回までの内容はこちら。

題材

「記事にタグを設定できるブログ」みたいなシステムを考えてみます。ブログ記事を示すEntryテーブル、タグを表すTagテーブルの二つを用意しました。MySQL WorkbenchによるER図(鳥足記法)は以下になります。

pdoex.png

1つのEntryに対して複数のTagがある、1対多の関係です。同じTagが複数のEntryに関連するため、多対多の関係と見なすこともできそうですが、タグ程度だとあまり意味がないので、これ以上のテーブル分割はやめておきます。

Entryテーブルの主キーがentryIdと冗長な名前をしているのは、自然結合が使えるようにしてSQLを短くするための工夫です。

Entryが消えれば関連するTagは必要なくなるので、Tag側のentryIdは外部キー制約付きで、ON DELETE CASCADEだけ設定してあります。SQLite用のSQL文も用意しました。

これ以上は紹介しませんが、データモデリングはかなり奥の深い話です…。適当なデータ構造を作ってしまうと、あとになってプログラムで苦しめられること必至なので、初心者は謙虚にベテランの意見を仰ぐか、体系だった本でちゃんと勉強しましょう(自戒も込めて…)。 とりあえずミック先生の本をステマしておきます。

力の使い方を教えてやる

さて、扱うべきデータベースが決まりました。ここからPHPのコーディングに移りますが、完成品はGitHubに上げてあるので詳しくはソースを落として読んでいただければと思います。

hirak/pdo-datamapper-example ・ GitHub

素のPDOで作りました。PHPでデータベースを扱うなら、素のPDOと生SQLで組み立てるのが全ての基本です。

PDO(PHP Data Objects)について一応おさらいしておくと、PHP5.1から標準でバンドルされるようになったデータベースを操作するためのライブラリです。これ単独でデータベース抽象化レイヤー(DAL)の機能を有しており、SQLiteだろうとMySQLだろうと同じインターフェースでSQLを実行できます。また拡張モジュールの形式なので非常に高速に動作します。

このPDO、デファクトスタンダードのはずなんですが、その力を全然使いこなせていない、レベルの低いコードが蔓延していて、実力を過小評価されている気がしてならないです。。そもそも日本語のPHP入門書籍をながめているとPDO自体紹介されていないことすらありますし。

個人的に思う、PDOを使いこなすためのポイントは以下の3つです。

  • PDOStatementはforeachで直接回せ
  • デフォルトフェッチモードは変更しろ
  • PDO::FETCH_CLASSを使え

■PDOStatementはforeachで直接回せ

PDOでSQLを実行すると、PDOStatementのインスタンスという形で結果が返ってきます。これをfetchAll(PDO::FETCH_ASSOC)して配列に直してから操作する人がいますが、実はPDOStatementはTraversableなのでそのままforeachで回すことができます。

<?php
$stmt = $pdo->query('SELECT * FROM Entry');

foreach ($stmt as $row) {
  echo $row['title'], $row['content'];
}

//↑は↓のコードと等価
while ($row = $stmt->fetch()) {
  echo $row['title'], $row['content'];
}

配列に直すコストがもったいないので、なるべくPDOStatementのまま回すのがいいと思います。

■デフォルトフェッチモードは変更しろ

フェッチモードを指定せず単にfetch()した場合、デフォルトではPDO::FETCH_BOTHが選択されており、あまり使い勝手がよくありません。デフォルトのフェッチモードは変更できるので、もっと使いやすいものに変えておくといいです。フェッチモードを変えることで、foreachが使いやすくなります。

<?php
//PDOオブジェクト自体に指定。レスポンスは常に連想配列形式で取得するようになる
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$stmt = $pdo->query('SELECT * FROM Entry');
//PDOStatement毎に指定することも可能
//レスポンスをstdClassとして取得
$stmt->setFetchMode(PDO::FETCH_CLASS, 'stdClass');

var_dump($stmt->fetchAll());
/*
array(
  stdClass{...},
  stdClass{...},
  ...
)
*/

■PDO::FETCH_CLASSを使え

フェッチモードのうちで個人的におすすめなのはFETCH_CLASSです。PDO::FETCH_CLASSは、結果セットをオブジェクトとして取得し、その際クラスを指定することができます。「行は連想配列で扱うな、常にクラスに定義しろ」と前回ひたすら連呼しましたが、FETCH_CLASSを使えば自然に実装できます。また、PDO自身が直接オブジェクトへ変換するため、非常に高速です。

ただし、FETCH_CLASSは概念的に以下のような手順で値をオブジェクトへセットするため、これを理解してクラスを作っておく必要があります。setterなどは使えず、単純に外からpublicなプロパティとして代入しようとするのです。

$assoc = $stmt->fetch(PDO::FETCH_ASSOC);
$obj = new stdClass;
foreach ($assoc as $key => $val) {
  $obj->$key = $val;
}
$obj; //これがfetch()で返ってくるオブジェクト

行クラスを作る

実装にあたって、まずは各テーブルの一行一行に相当するクラスを作ります。

行クラス(ドメインモデル)の責務とは何でしょうか? 私は以下の3点だと思います。

  1. フィールドのデータを保持すること(set,get両方できなければならない)
  2. データの整合性を確認すること
  3. ドメインロジックを持つこと

1番目は当然ですね。そしてデータには必ず仕様があるはずですから、その仕様の範囲内のデータしか保持してはならない、そういう制約も盛り込んでおくべきです。

「ドメインロジック」に関しては必要ないこともありますが、データのみで行えるロジックであれば、ドメインモデル内に実装しておくとコードがすっきりします。商品のドメインモデルに対して「消費税を掛けて税込み価格を計算する」、三角形のドメインモデルから「面積を計算する」といったものです。

PDOで使うことも考慮して作るとなると、__set()や__get()といったマジックメソッドが必要になります。ちょっと泥臭いコードになってしまうので、基本となるDataModelクラスはgithubを参照してください。

データの整合性に関しては、setterでは最低限、基本型のチェックだけ行うものとし、isValid()メソッドで詳しい検証を行うようにしてみました。setterで厳格にチェックしていると、信頼できるデータにすらチェックがかかってしまい、パフォーマンス上の無駄があるからです。例えばデータベースから復元したデータは信頼できるでしょう。(DBに保存する際にチェックしておくべき)

DataModelクラスを継承してドメインモデルのクラスを作ります。protected static $_schemaにこのクラスの持つべきフィールドとその型を定義します。リレーショナルデータベースなので配列型はありません。基本型の他、DATETIME型はPHP標準のDateTimeクラスにキャストするものとして用意しました。そして、isValid()メソッドを定義します。

<?php
class Entry extends DataModel
{
    protected static $_schema = array(
        'entryId'   => parent::INTEGER
      , 'author'    => parent::STRING
      , 'title'     => parent::STRING
      , 'content'   => parent::STRING
      , 'published' => parent::DATETIME
    );

    function isValid()
    {
      //... 値が仕様の範囲ならばtrueを返す
    }
    
    //必要に応じてメソッドを追加する
}

$_schemaの定義に従い、値をsetする際に型をキャストします。schemaに定義されていないものはsetできません。これで、PHPでもそれなりにかっちりしたクラスが出来上がりました。具体的なisValidも含めたコードはEntryクラスTagクラスを見てください。

ActiveRecordかDataMapperか

では、DBアクセスのロジックはどう書くべきでしょうか。実はこの後の実装方法は2種類あって、どちらの方針で行くか決めなくてはなりません。

■ActiveRecord

ひとつはActiveRecordです。これは行クラスそのものにDBのアクセスロジックを含める書き方です。Ruby on Railsに同名のモジュールがありますが、もともとはPofEAAという本にまとめられたデザインパターンの一つの名前です。

//こういう書き方ができればActiveRecordです
/* insert */
$entry = new Entry;
$entry->author = 'Taro';
$entry->title  = 'Hello';
$entry->content = 'Hello, World';
$entry->save(); //これでDBに保存される(INSERT)

/* select系は主にstaticメソッドで実装される */
$entries = Entry::findByAuthor('Taro'); //Entryオブジェクトの配列が返る

/* update */
$entry = Entry::find(123); //主キーからオブジェクトを復元
$entry->title = 'Goodby';  //データを書き換え
$entry->save(); //DBに保存しなおす(UPDATEがかかる)

大きな特徴は、selectしてきたデータがsave()メソッドを持っており、そのまま編集してUPDATEできることです。まるでデータそれ自身が生きているかのごとく振る舞う。まさにActiveRecordという名前の通りです。

ActiveRecordパターンは、使う側が非常にわかりやすいのが特徴です。クラスも1テーブル1クラスだけですし、事前知識があまり必要ありません。使う側のコードは非常にすっきりします。

その反面、ActiveRecordを綺麗に実装するのは非常に難しいです。「ドメインモデルをDBに密結合させる」方向で作るので、例えばKVSなどのNoSQLでも使いまわせるようにするのは至難です。また、staticメソッドを多用するため、言語にstaticメソッドのサポートがないと実装できません。具体的に言えばPHP5.2ではこの通りの見た目を実現することはほぼ不可能でした。(一切継承せずにべたべた書けば可能ですが…)

このパターンを使いたいならむやみに自前実装せず、既存の充分使われている枯れたライブラリを使うべきです。しかしPHPでまともにActiveRecordを実装できているライブラリってよく知らないので。。

■DataMapper

もうひとつのパターンはDataMapperです。ActiveRecordと違い、DBアクセスはドメインモデルとは別のクラス(DBとのマッピングを行う=DataMapperクラス)で実装を行います。DAOパターンの発展形と見なせるかもしれません。

//こういう書き方ならDataMapperです
/* insert */
$emapper = new EntryMapper;
$entry = new Entry;
$entry->author = 'Taro';
$entry->title  = 'Hello';
$entry->content = 'Hello, World';
$emapper->insert($entry); //保存作業はEntryMapperが担当する

/* select系もemapperが持つ */
$entries = $emapper->findByAuthor('Taro'); //Entryインスタンスの配列が返る

/* update */
$entry = $emapper->find(123); //主キーから復元
$entry->title = 'Goodbye';
$emapper->update($entry); //保存作業はEntryMapperが担当する

この程度であればコード量はあまりActiveRecordと変わりませんが、Entryテーブルを操作するためにEntryMapperとEntryという二つのクラスが必要です(1テーブルあたり2クラス必要)。扱うテーブルが増えるに従い、使う側はたくさんのクラスをインスタンス化する必要があり、使う側のコードは面倒になります。DataMapperのライブラリでは、この書きにくさをDSLなどでうまく吸収してくれるものが多いです。

反面、「DBアクセスはドメインモデルから分離する」という設計思想なので、ActiveRecordでは難しかったNoSQL対応などもあっさりと実装可能です。また、クラスが分かれているため、複数のテーブルにまたがる操作も柔軟にコード化できます。「インターフェースは面倒だが実装上の問題が起きにくい」パターンといえます。


1から自前実装するなら、DataMapperがおすすめです。

PDOを使ったDataMapper

PDOの大雑把な使い方は先ほど書いた通りです。今回はテーブルごとにMapperクラスを作ります。

■EntryMapper

EntryクラスとEntryテーブルのやり取りをするのがEntryMapperクラスです。

  • 更新系はEntryインスタンスを引数に取る。それ以外の形式は認めない。戻り値はnull。
  • 参照系は任意の引数を取り、Entryインスタンスか、もしくはEntryインスタンスにマッピングするようセットしたPDOStatementを返す

分割の方針はこんな感じにしておきましょうか。このさじ加減を割と自由に決められるのがDataMapperのメリットなので、調整してもかまいません。今回の方針だと、「Tagから該当するEntry一覧を取得する」のもEntryMapperクラスの仕事になります。

共通するメソッドをDataMapperクラスとしてまとめました。これを使うとこんな感じで書けます。

 class EntryMapper extends DataMapper
 {
   const MODEL_CLASS = 'Entry'; //モデルクラスを指定しておく
   
   //select系メソッドの例
   function fetchAll() {
     $stmt = $this->_pdo->query('
       SELECT * FROM Entry
     ');
     return $this->_decorate($stmt);
   }
   
   //...
 }
 

SELECT系は大体決まっていて、こんな流れのコードになります。

  • 引数ナシならquery()でPDOStatemtntを取得、_decorate()したものをreturnする。
  • 引数があるならprepare()でプリペアードステートメントを作り、bindParam()で変数をSQL文にバインドする。execute()したのち、_decorate()したものをreturnする。

詳しくはgithubを見ていただければと思います。

hirak/pdo-datamapper-example

終わりに

DBシリーズはこれで終わりです。

私が言いたかったのは、「素のPDOでもO/R Mapper的なことが可能」ということです。各フレームワークは「モデル層」などと謳って妙なインターフェースのDAOクラスを提供していたりしますが、素のPDOより機能が劣っているとがっかりします。

O/R Mapperとして足りないものとしてはSQLの自動生成がありますが、、あれはセキュリティ上推奨されませんし、パフォーマンスチューニングの敵でもあります。個人的にはあまり使わない派です。。

デファクトスタンダードなライブラリがあればまずそれを使うことを検討するべきですし、覚えるにしてもフレームワーク固有のクラスより素のPDOの機能を覚えた方が知識の応用が効きやすいでしょう。

PDOの真の力を使いこなして、メンテしやすいコードが増えてくれれば幸いです。

keyword: PDO DDD デザインパターン データベース

PHPの最新記事