LoginSignup
7
6

More than 5 years have passed since last update.

インピーダンスの丘を越えて

Last updated at Posted at 2016-12-07

まえがき

この記事は, マーティンファウラーのエンタープライズアプリケーションアーキテクチャパターン(以下PoEAA本)の
12.7章「シングルテーブル継承」ほかの内容を元に書いております.
内容に間違いあるいは勘違い等がありましたら, ご指摘おねがいします

サンプルコードについての注釈

この記事に含まれているサンプルコードでは, 特にが断りがない限り, 以下のようなモデルクラスを使用している

models.php
<?php
/**
 * 全てのカードの基盤となる抽象クラス
 *
 */
abstract class Card {

    public $id;

    public $name;

}

/**
 * 種別=アイドルのカードを表すモデル
 *
 */
class Idol extends Card {

    public $cost;

}

/**
 * 種別=トレーナーのカードを表すモデル
 *
 */
class Trainer  extends Idol {

    public $exp;

}

/**
 * 種別=アイテムのカードを表すモデル
 *
 */
class Item extends Card {

    public $repairPoint;
}

実際はこれらのクラスに様々なドメインロジックを定義していくことになるのだが、 ここでは省略している.
また, 各モデルは各変数をpublicで定義しているが、これはサンプルコードを書く上で手を抜くためであって
publicなインスタンス変数を推奨しているわけではない.

ORMの実装パターンとしては, 前回の記事の分類で言うところのデータマッパーパターンを使用している.

シチュエーション

突然ですが, キミは, アイドルを育てるカードゲームを設計することになった.
ゲーム内にはアイドルはカードであり, またアイテムやなど他の要素もまたカードである.
アイルドの中には通常のアイドルとは別に, 合成専用のトレーナーカードも存在する.
(モ○マスとかそういうゲームをイメージして下さい)

キミは, しばらく考えた後, 以下のようなクラス図を作ってみました.
スクリーンショット 2016-12-04 15.23.04.png

全要素が持っているIDと名前については最上位の抽象クラスであるCardモデルに持たせて, Idolにはそれに加えてコストを追加.
カード合成時に得られる経験値は, Idolの場合はコストとレベルから計算されるが, Trainerは常に一定なので
その値を設定するためのexpフィールドを追加.
(レベルについて考えだすとこの先の話が更に複雑になるので, 今回は省略します)
アイテムもあるという話だが, 聞いたところまだスタミナ回復アイテムくらいしか考えていないとのことなので
とりあえず, 使ったときの回復量だけ定義されたItemモデルだけ用意しておいて, より細かい設計は
仕様が上がってきてから、考えることにしよう.

これを基盤に勧めていけば. 今のところ聞いている要件を満たす事はできるだろう, とほっと一息.

さて, DBはどうしましょうか?

マーティンファウラーかく語りき

概論

アプリケーション側モデルの階層構造をDBにマッピングするパターンとして, PoEAAでは以下の3つが紹介されている.

  • シングルテーブル継承
  • クラステーブル継承
  • 具象テーブル継承

次項にて, それぞれについて解説する.

シングルテーブル継承

このパターンでは, クラス階層に属するクラスを全てに1つのテーブルにマッピングする.

スクリーンショット 2016-12-04 15.25.43.png

テーブルに含まれるべきカラムは,
[クラス階層に含まれるすべてのフィールド] + [レコードに対応するクラスを表すtypeカラム]
となる.

このパターンのメリットは, テーブルが一つしか無いため, データ取得時のクエリが単純になること.
またレコードの追加/更新時もアプリケーションから行う場合は単純である.
また, クラス階層のリファクタリングを行った場合も, 受ける影響は少ない.

デメリットは, レコードの種別によって必要なカラムが変わり, またどの種別がどのカラムを必要とするかの情報が
DBないに存在しないため, アプリケーションを介さずにDBを操作することが困難になる.
また, テーブルを分けた場合と比較して ,DBの使用容量も必要以上に大きくなってしまう.
全ての操作がこのテーブルを参照することになるので, テーブルロック等によってパフォーマンス上の
ボトルネックにもなりうる.

  • メリット
    アプリケーションから扱うのは他のパターンより単純
    クラス階層間のフィールドの移動が, テーブル設計に波及しない

  • デメリット
    DB設計という観点から見ると, あまり筋が良くない(んじゃあないかなあ?)
    単一テーブルへのアクセスがパフォーマンス上のボトルネックになりうる

findメソッドを実装

IDを元にオブジェクトを一件取得するfindメソッドは以下のようになると思われる.

card_mapper.php
<?php

abstract class CardMapper {

    protected $pdo;

    public function __construct($pdo) {
        $this->pdo = $pdo;
    }


    /**
     * IDをキーにCardsクラスのインスタンスを一件取得する
     *
     * 取得されるインスタンスの実際のクラスはこのマッパーを継承する
     * クラスによって異なる
     *
     */
    public function find($id) {
        $sql = "SELECT * FROM singletable_cards WHERE id = :id AND type = :type";
        $params = ['id' => $id, 'type' => $this->getType()];

        $row = $this->fetchRow($sql, $params);

        if ($row == null) {
            return null;
        }

        $model = $this->createNewInstance();
        $this->fill($row, $model);

        return $model;
    }

    /**
     * SQLを実行し, 結果を一件モデルして返す
     */
    private function fetchRow($sql, $params) {
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);

        $assoc =  $stmt->fetch(PDO::FETCH_ASSOC);

        if ($assoc === false) {
            return null;
        }

        return $assoc;
    }

    /**
     * DBのカラムの値を対応したフィールドにセットする
     *
     */
    protected function fill($row, $model) {
        $model->id = $row['id'];
        $model->name = $row['name'];
    }

    /**
     *  マッパーに対応するクラスの種別を返す
     */
    protected abstract function getType();

    /**
     *  マッパーに対応するクラスのインスタンスを生成する
     */
    protected abstract function createNewInstance();
}

findメソッドの殆どの部分は, データマッパーの基底クラスに定義されます.
具体的には, 指定されたIDと, マッパーが対応している種別をキーにselect文を実行し

card_mapper.php
    public function find($id) {
        $sql = "SELECT * FROM singletable_cards WHERE id = :id AND type = :type";
        $params = ['id' => $id, 'type' => $this->getType()];

        $row = $this->fetchRow($sql, $params);

取得されたレコードの値を, マッパーに対応したインスタンスにセットしていきます

card_mapper.php

        $model = $this->createNewInstance();
        $this->fill($row, $model);

        return $model;
    }

各種別に対応したデータマッパーは, この基底クラスを継承し,

  • getType
  • createNewInstance
  • fill

の3つのメソッドをoverrideします

item_mapper.php

/**
 * アイテム用データマッパー
 */
class ItemMapper extends CardMapper {

    protected function getType() {
        return 'item';
    }

    protected function createNewInstance() {
        return new Item();
    }

    /**
     * DBのカラムの値を対応したフィールドにセットする
     *
     * @Override
     */
    protected function fill($row, $model) {
        $model->repairCost = $row['repair_cost'];

        parent::fill($row, $model);
    }
}

fillメソッドでは, 自分の管轄しているフィールドへの値のセットだけを行い
残りのフィールドへのセットは親のfillメソッドに任せることで, 各マッパーが知るべき情報をへらしている.

updateメソッドの実装

モデルの更新を行うupdateメソッドは, 以下のようになると思われる.

card_mapper.php
<?php
abstract class CardMapper {

    protected $pdo;

    public function __construct($pdo) {
        $this->pdo = $pdo;
    }

    /**
     * レコードの更新を行う
     *
     * マッパー毎に異なる更新可能なフィールド一覧を取得し, それをもとにSQLを生成する
     */
    public function update($model) {
        $columns = [];
        foreach ($this->aggregateEditableAttributes($model) as $column => $value) {
            $columns[] = $column.' = :'.$column;
        }

        $sql = 'UPDATE singletable_cards SET '.implode(', ', $columns).' WHERE id = :id';

        return $this->execute($sql, array_merge($this->aggregateEditableAttributes($model), ['id' => $model->id]));
    }

    /**
     * 編集可能な属性一覧を集める
     *
     */
    protected function aggregateEditableAttributes($model, $acc = []) {
        return array_merge($acc, ['name' => $model->name]);
    }

    private function execute($sql, $params) {
        $stmt = $this->pdo->prepare($sql);

        return $stmt->execute($params);
    }
}

こちらの処理の殆どは基底クラスに定義される.
具体的な処理の流れは, 更新可能なカラムと値のリストを生成し,

card_mapper.php
    protected function aggregateEditableAttributes($model, $acc = []) {
        return array_merge($acc, ['name' => $model->name]);
    }

それをもとに update文を生成し,実行する

card_mapper.php
    public function update($model) {
        $columns = [];
        foreach ($this->aggregateEditableAttributes($model) as $column => $value) {
            $columns[] = $column.' = :'.$column;
        }

        $sql = 'UPDATE singletable_cards SET '.implode(', ', $columns).' WHERE id = :id';

        return $this->execute($sql, array_merge($this->aggregateEditableAttributes($model), ['id' => $model->id]));
    }

各種別に対応したデータマッパーは, aggregateEditableAttributesメソッドをoverrideし
更新可能なフィールドを追加していく

item_mapper.php

/**
 * アイテム用データマッパー
 */
class ItemMapper extends CardMapper {

    /**
     * 編集可能な属性一覧を集める
     *
     * @Override
     */
    protected function aggregateEditableAttributes($model, $acc = []) {
        $acc = array_merge($acc, ['repair_point' => $model->repairPoint]);

        return parent::aggregateEditableAttributes($model, $acc);
    }
}

サンプルコード全文

サンプルコードの全文は長くなるのでGistの方を参照してください.

クラステーブル継承

このパターンでは, クラス階層に属する各クラス毎に, 対応するテーブルへマッピングする.

スクリーンショット 2016-12-04 15.26.56.png

各テーブルに含まれるカラムは, 対応するクラスのフィールド+idとなる.
ここで注意が必要なのは, idolのレコードがどのcardのレコードと関連しているかを表すために, 
cardのidとidolのidが一致するようにレコードを作る必要がある.
あるいは, 親レコードを参照する外部キーカラムを導入する方法もあるが, この場合は一つのインスタンスに
関わるレコード一式を揃える際に, 外部キーの値を辿って順に探していく必要が出てくるなど, 操作が多少面倒になる.

このパターンのメリットは, アプリケーション側の階層構造とDB内のテーブルが一致するため,
アプリケーションの開発者にも理解しやすい点.
シングルテーブル継承と違って, 種別によって使用しないカラムというものが存在しないので
ディスクスペースを効率的に使うことができる点.

一方デメリットは, インスタンス一つを生成するために複数回のクエリもしくは外部結合を含んだクエリが必要になる点.
クラス階層間でフィールドの移動が起きた場合もテーブルに変更が必要な点.
アクセスが集中するcardsテーブルがボトルネックになりうる点(これはシングルテーブル継承と同様)

インスタンス生成については, 種別を限定せずにデータを取得しようとした時は更にひどいことになる.
cardsテーブルからレコードを取得した時点では, まだそのレコードに対応するインスタンスの種別が何者なのかわからないため
取得したレコードにたいして, それがIdolなのかTrainerなのか, じゃたまたItemなのかテストしながら必要なレコードを集めていくような
処理が必要になるためである.
外部結合とunionを使ったviewを用意したり, あるいはCardクラスを抽象クラスではなく具象クラスにするなどの
対応をすれば多少は扱いやすくなるかもしれないが,
いずれにしても, パフォーマンスは悪くなることを覚悟する必要がある.

  • メリット
    クラス階層とDB設計の関係を理解しやすい
    DBMSがストレージを効率的にあつかうことができる

  • デメリット
    レコードからオブジェクトへ効率的に変換するのがむずかしい
    種別をまたいだ検索が非常に面倒
    idの扱いがちょっと面倒

findメソッドを実装する

findメソッドは以下のようになると思われる.

card_mapper.php

/**
 * アイテム用データマッパー
 */
class ItemMapper extends CardMapper {
    /**
     * IDをキーにオブジェクトを一件取得する
     */
    public function find($id, $acc = null) {
        $row = $this->fetchRowFrom('classtable_items', $id);

        if ($row == null) {
            return null;
        }

        $model = ($acc != null) ? $acc : new Item();
        $model->repairPoint = $row['repair_point'];

        return parent::find($id, $model);
    }
}

abstract class CardMapper {

    protected $pdo;

    public function __construct($pdo) {
        $this->pdo = $pdo;
    }

    /**
     * IDをキーにCardsクラスのインスタンスを一件取得する
     *
     * 取得されるインスタンスの実際のクラスはこのマッパーを継承する
     * クラスによって異なる
     *
     */
    public function find($id, $acc = null) {
        $row = $this->fetchRowFrom('classtable_cards', $id);

        $acc->id = $row['id'];
        $acc->name = $row['name'];

        return $acc;
    }

    /**
     * 対象テーブルからIDをキーにレコードを一件, 連想配列として取得する
     *
     */
    protected function fetchRowFrom($table, $id) {
        $sql = "SELECT * FROM ".$table." WHERE id = :id";
        $params = ['id' => $id];

        return $this->fetchRow($sql, $params);
    }

    /**
     * SQLを実行し, 結果を一件だけ連想配列として返す
     */
    protected function fetchRow($sql, $params) {
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);

        $assoc =  $stmt->fetch(PDO::FETCH_ASSOC);

        if ($assoc === false) {
            return null;
        }

        return $assoc;
    }
}

このパターンでの検索メソッドの実装法としては, いろいろなやり方があるとは思うが,
ここでは一番末端のクラスから, 最上位の基底クラスまで順番にデータを取得していくやりかたをしている.

ItemMapperのfindメソッドでは, idをキーに対応するitemsテーブルからレコードを取得し,
その値を新たに生成したモデルオブジェクトにセットし, idとそのモデルオブジェクトを引数に
親クラスのfindメソッドを呼び出す.

item_mapper.php
    public function find($id, $acc = null) {
        $row = $this->fetchRowFrom('classtable_items', $id);

        if ($row == null) {
            return null;
        }

        $model = ($acc != null) ? $acc : new Item();
        $model->repairPoint = $row['repair_point'];

        return parent::find($id, $model);
    }

親クラスであるcard_mapperのfindメソッドでは, idをキーに対応するcardsテーブルからレコードを取得し,
引数として渡されるモデルクラスに値をセットする.

card_mapper.php
    public function find($id, $acc = null) {
        $row = $this->fetchRowFrom('classtable_cards', $id);

        $acc->id = $row['id'];
        $acc->name = $row['name'];

        return $acc;
    }

Item#findメソッド内で,

        $model = ($acc != null) ? $acc : new Item();

とすることで, クラス階層のどの位置のmapperから呼び出されても同様に動作するようにしている.
おそらく, このインスタンス生成部分と, どのカラムをどのフィールドにセットするかのルールを抽象化すれば
このメソッドそのものも抽象化できるように思えるが, ここでは考えないことにする.

updateメソッドを実装する

card_mapper.php

/**
 * アイテム用データマッパー
 */
class ItemMapper extends CardMapper {

    /**
     * モデルの更新を行う
     *
     */
    public function update($model) {
        $sql = "update classtable_items set repair_point = :repair_point where id = :id";

        $params = [];
        $params['id'] = $model->id;
        $params['repair_point'] = $model->repairPoint;

        return $this->execute($sql, $params) && parent::update($model);
    }
}

abstract class CardMapper {
    /**
     * モデルの更新を行う
     *
     */
    public function update($model) {
        $sql = "update classtable_cards set name = :name where id = :id";

        $params = [];
        $params['id'] = $model->id;
        $params['name'] = $model->name;

        return $this->execute($sql, $params);
    }

    protected function execute($sql, $params) {
        $stmt = $this->pdo->prepare($sql);

        return $stmt->execute($params);
    }
}

updateも, findと同様に階層の末端から最上位まで順番に遡りながら, 各テーブルを更新していく.

itemMapperのupdateメソッドは, 更新対象のモデルオブジェクトを受け取り, 対応しているitemsテーブルを更新し,
親のupdateメソッドを呼び出す.

item_mapper.php
    public function update($model) {
        $sql = "update classtable_items set repair_point = :repair_point where id = :id";

        $params = [];
        $params['id'] = $model->id;
        $params['repair_point'] = $model->repairPoint;

        return $this->execute($sql, $params) && parent::update($model);
    }

親のcard_mapperのupdateメソッドも同様に, 更新対象のモデルオブジェクトを受け取り, 対応しているcardsテーブルを更新する.

card_mapper.php
    public function update($model) {
        $sql = "update classtable_cards set name = :name where id = :id";

        $params = [];
        $params['id'] = $model->id;
        $params['name'] = $model->name;

        return $this->execute($sql, $params);
    }

ここでは2階層しかないが, 途中の階層が増えたとしても, それぞれの階層で対応したテーブルを更新し,
親のupdateメソッドを呼び出すという処理は変わらない.

今回のサンプルでは, 一つのモデルオブジェクトに関連する全てのテーブルのレコードは同じIDを持っているという
設計になっているので, どのテーブルに対する更新も全て同じIDをキーに行うことができる.
モデルのIDをcardsテーブルのみ持たせ, 各テーブル間のリレーションは別途外部キーを設定する
設計をしている場合は, もう少し違った実装が必要になってくる.

insertメソッドを考える

前述の通り, このパターンの時は一番の基底となるcardsテーブルのIDをそれに付随するテーブルのレコードでも
PKとして使っている.
その為, insertメソッドも, 最初にcardsテーブルにレコードを追加し, idを発行させたあとで
末端のテーブルに向かって順々にレコードが作成されていくようになるとおもわれる.
実装は省略するので, 是非皆さんに考えてもらいたい.

サンプルコード全文

サンプルコード全文はこちら

具象テーブル継承

このパターンでは, クラス階層に含まれる具象クラス毎に, 対応するテーブルとマッピングする.

スクリーンショット 2016-12-06 12.47.45.png

各テーブルは, 対応するクラスが持つ継承したものを含む全てのフィールドをカラムとして持つ.

このパターンのメリットは, レコードをもとにオブジェクトを生成する時に, 結合を含まない単純なクエリのみで済む点.
DBMSが効率的にストレージを使える点.
クラス階層間のフィールドの移動がDB設計に波及しない点.

デメリットも当然ある.
1点目は, クラステーブル継承と同様に, 種別をまたいだデータ取得が非効率になる点.
こちらのパターンの場合は, 各種別ごとに1つのテーブルに対してのみテストを行えばいいので多少は単純に思えるかもしれないが
各テーブルが共通で持っているupdated_atカラムをキーに降順でソートして21-40件目を取得したい, などといった話がでてくると
実際には, こちらはこちらで面倒な問題があることが想像できると思います.
この要件にたいして, 全テーブルから全レコード取得してメモリ上でソートと選択を行うにしても
あるいは, SQLのunionを使って対処するにしても, パフォーマンス上のボトルネックになる可能性は十分にあるだろう.
2点目は, 各テーブルのIDを単純に自動生成するわけにはいかなくなる点.
これらのテーブルのレコードは, アプリケーション内では全てCardクラスのインスタンスでもあるため,
IDがそれぞれのテーブル内で唯一であるというだけでは十分ではないためである.
(id=1のidolsレコードとid=1のitemsレコードはDB的には問題ないがアプリケーションとしても問題であるということ)
その為, このパターンを使う場合は別途ID生成のための機能がひつようになる.

  • メリット
    種別を特定した上でのオブジェクト生成は単純で効率的
    DBMSがストレージを効率的にあつかうことができる
    クラス階層間のフィールドの移動はDB設計に影響を与えない

  • デメリット
    種別を特定していないときのオブジェクト生成は複雑で非効率的
    IDの扱いが面倒

findメソッドを実装する

このパターンでのfindの実装は以下のようになると思われる.

leaftable_cards.php
abstract class CardMapper {

    protected $pdo;

    public function __construct($pdo) {
        $this->pdo = $pdo;
    }

    /**
     * IDをキーにCardsクラスのインスタンスを一件取得する
     *
     * 取得されるインスタンスの実際のクラスはこのマッパーを継承する
     * クラスによって異なる
     *
     */
    public function find($id) {
        $row = $this->fetchRowFrom($this->getTable(), $id);

        if ($row == null) {
            return null;
        }

        $model = $this->createNewInstance();
        $this->fill($row, $model);

        return $model;
    }

    /**
     * 対象テーブルからIDをキーにレコードを一件, 連想配列として取得する
     *
     */
    protected function fetchRowFrom($table, $id) {
        $sql = "SELECT * FROM ".$table." WHERE id = :id";
        $params = ['id' => $id];

        return $this->fetchOne($sql, $params);
    }

    /**
     * SQLを実行し, 結果を一件だけ連想配列として返す
     */
    protected function fetchOne($sql, $params) {
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);

        $assoc =  $stmt->fetch(PDO::FETCH_ASSOC);

        if ($assoc === false) {
            return null;
        }

        return $assoc;
    }

    /**
     * 各マッパークラスに対応するモデルオブジェクトを生成する
     */
    abstract protected function createNewInstance();

    /**
     * 各マッパークラスが対応するテーブル名を返す
     */
    abstract protected function getTable();

    /**
     * DBレコードから対応する値をモデルにセットする
     *
     */
    protected function fill($row, $model) {
        $model->id = $row['id'];
        $model->name = $row['name'];
    }
}
leaftable_items.php
class ItemMapper extends CardMapper {

    /**
     * 各マッパークラスに対応するモデルオブジェクトを生成する
     */
    protected function createNewInstance() {
        return new Item();
    }

    /**
     * 各マッパークラスが対応するテーブル名を返す
     */
    protected function getTable() {
        return 'leaftable_items';
    }

    /**
     * DBレコードから対応する値をモデルにセットする
     *
     * @Override
     */
    protected function fill($row, $model) {
        $model->repairPoint = $row['repair_point'];

        parent::fill($row, $model);
    }
}

この実装は, シングルテーブル継承のfindと似ている.
実際, テーブルから一件レコードを取得し, 各マッパーで定義されるfillメソッドを順に呼び出して
モデルオブジェクトを完成させるという流れは同じである.
ちがいは, シングルテーブル継承の時はsingletable_cardsテーブルに保存されたレコードの
typeカラムの値で種別を判断していたが, このパターンでは, どのテーブルにレコードが保存されているかで
種別を判断するため, 各種別ごとに実装必要なメソッドがgetTypeからgetTableに変わっている点である

leaftable_items.php
    /**
     * 各マッパークラスが対応するテーブル名を返す
     */
    protected function getTable() {
        return 'leaftable_items';
    }

updateメソッドを実装する

updateメソッドは, 非常に単純である.

leaftable_cards.php
abstract class CardMapper {

    /**
     * モデルの更新を行う
     *
     */
    abstract public function update($model);

    /**
     * レコードを返さないSQLを実行する
     *
     */
    protected function execute($sql, $params) {
        $stmt = $this->pdo->prepare($sql);

        return $stmt->execute($params);
    }
}
leaftable_items.php
class ItemMapper extends CardMapper {

    /**
     * モデルの更新を行う
     *
     */
    public function update($model) {
        $sql = 'UPDATE leaftable_items SET name = :name, repair_point = :repair_point WHERE id = :id';

        $params = [];
        $params['name'] = $model->name;
        $params['repair_point'] = $model->repairPoint;
        $params['id'] = $model->id;

        return $this->execute($model);
    }

}

親クラスのCardMapperではメソッドの定義だけを行い, 実際の処理は全て各マッパーにまかせてしまう.
シングルテーブル継承パターンのときと同様, 更新可能なフィールドリストと対象テーブル名を
メタ情報として扱うことで, このupdateも抽象化し, 親クラスにまとめることはできると思うが, 記述が煩雑になるので
ここではやらない.

insertメソッドを実装する

前述の通り, このパターンではIDの扱いが面倒である.
複数のテーブルをまたいでユニークなIDを生成する手段として, アプリケーション側でUUIDを生成するか
あるいは, ID管理用のテーブルを用意する方法が考えられる.
ID管理テーブルを使う方法は, トランザクションの扱いが面倒だったり(ID生成だけ別トランザクションで行わないといけない)
パフォーマンス上のボトルネックになった時に改善しようがなかったりと
色々問題があるらしいので, アプリケーション側で生成するのが妥当なやり方だとは思う.
(もっと言えば, 全てのテーブルのIDをUUIDにしてしまってもいいような気がする)

このパターンのinsertメソッドも実装しないので, 気になる人は自分で実装してみて欲しい.

サンプルコード全文

こちら

種別をまたいだモデルオブジェクトの取得を行う

ここまでは, すでに種別がわかっているモデルを取得する場合のみを見てきた.
次は, 種別が分かっていないがIDあるいは検索条件がわかっている状態で
モデルを取得する場合, どのような実装になるかを見ていく.

まず考えるべきは, 種別を問わないモデル取得メソッドはどこに定義するべきか?であるが,
今回は単純に, CardMapperに定義することにする.

検索メソッドの追加に先駆けて, CardMapperの定義とコンストラクタを以下のように変更する.

card_mapper.php

class CardMapper {

    protected $pdo;

    protected $idolMapper;

    protected $trainerMapper;

    protected $itemMapper;

    public function __construct($pdo, $idolMapper, $trainMapper, $itemMapper) {
        $this->pdo = $pdo;
        $this->idolMapper = $idolMapper;
        $this->trainerMapper = $trainerMapper;
        $this->itemMapper = $itemMapper;
    }

    /* ... */
}

実際には, 抽象クラスから具象クラスに変更するにあたって他にも修正するべきところがあるだが
ここでは省略する.
(なので, これから記述するサンプルは実装イメージです)

シングルテーブル継承の場合

このパターンの場合は, 単純である

card_mapper.php

class CardMapper {
    public function findAny($id) {
        $sql = "SELECT * FROM singletable_cards WHERE id = :id";
        $params = ['id' => $id];

        $row = $this->fetchRow($sql, $params);

        if ($row == null) {
            return null;
        }

        $mapper = $this->getActualMapper($row['type']);

        $model = $mapper->createNewInstance();
        $mapper->fill($row, $model);

        return $model;
    }

    private function getActualMapper($type) {
        if ($type == 'idol') {
            return $this->idolMapper;
        } else if ($type == 'trainer') {
            return $this->trainerMapper;
        } else if ($type == 'item') {
            return $this->itemMapper;
        }
    }
}

まず, cardsテーブルから該当レコードと取得し, typeの値をもとに対応するmappaerを決定し
レコードをモデルオブジェクト化する.
複数件取得する場合でも, 結局は全てのデータはcardsテーブルにあるので何も変わらない.

具象テーブル継承の場合

これまでの順番だと, 次はクラステーブル継承での説明になるのだが, クラステーブル継承は
説明が長くなるので, 先に比較的シンプルな(ただし非効率的な)具象テーブル継承から説明する.

このパターンの場合も, 実装は単純である

    public function findAny($id) {
        $model = $this->idolMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        $model = $this->trainerMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        $model = $this->itemMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        return null;
    }

見ての通り, 対象モデルが見つかるまでクラス階層に関わっているmapperを総当り的に探していく.
複数件の帰す場合も同様に, やはり総当りで検索して, ヒットしたものをマージして返す.
クラスの種類数だけクエリを実行しないといけないため, パフォーマンスは
シングルテーブル継承の場合より悪くなる.

クラステーブル継承の場合

このパターンの時も, クラステーブル継承の時と同様に, 必要なデータが
どのテーブルに入っているか事前にはわからないため総当りとなる.

card_mapper.php

    public function findAny($id) {

        $model = $this->trainerMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        $model = $this->idolMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        $model = $this->itemMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        return null;
    }

注意が必要なのは, 具象テーブル継承の時とは異なり, trainersテーブルでレコードが見つかる場合
idolsテーブルでも必ずレコードが見つかってしまうため, 試行する順番によっては取得できない
種別が出てしまう可能性があることである.

card_mapper.php

    public function findAny($id) {


        $model = $this->idolMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        // trainersテーブルにあるレコードはidolsテーブルにもあるため
        // Trainerを探したい場合もこのコードは実行されない
        $model = $this->trainerMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        $model = $this->itemMapper->find($id);

        if ($model != nulL) {
            return $model;
        }

        return null;
    }

この問題を避けるためには, 試行するmapperの順番を注意深く, しっかりとテストしながら実装していくか
あるいは, シングルテーブル継承のときのようにtypeカラムを追加し,
その値を元に処理を分岐するかが考えられる.

card_mapper.php
    public function findAny($id) {
        $sql = "SELECT * FROM leaftable_cards WHERE id = :id";
        $params = ['id' => $id];

        $row = $this->fetchRow($sql, $params);

        if ($row == null) {
            return null;
        }

        return $this->getActualMapper($row['type'])->find($id);
    }

複数件のレコードを返すような検索を行う場合は, 具象テーブル継承パターンと異なり
全クラス共通の基底テーブルが存在するので, その基底テーブルに
検索条件が全て含まれている場合は, パフォーマンスは落ちるものの, 実装は複雑ではない.
それ以外の項目で検索したい場合は, クラステーブル継承のときと同じ苦しみを味わうことになる.

結局どのパターンを使えばいいのか?

どのパターンを使えばいいのか?
という質問に対する答えは, 「場合による」といういつもどおりの使えない解答しかないと思う.

また, PoEAA本によると, クラス階層とテーブルのマッピングは, これらのパターンの
どれか一つに限定して使わないといけないというわけではなく
全体としてはクラステーブル継承を採用するが, 一部はシングルテーブル継承を採用するなどといった,
複合的な使い方も想定さているらしい.
今回の例でいうと, 全体としてはクラステーブル継承を採用するが, IdolとTrainerは一つの
idolsテーブルにまとめてマッピングする(シングルテーブル継承)といったこともできる.

スクリーンショット 2016-12-04 15.16.59.png

どのパターンが考えやすいかも人によって変わってくると思うので, 是非色々なパターンを試して頂きたい。

という一般論を踏まえた上で言いますと, 私個人としては,
特にこだわりがないならシングルテーブル継承で良いのではないかとかんがえています.
実際のプロジェクトでシングルテーブル継承と具象テーブル継承を, それぞれ試したことがあるですが,
異なるモデルをまたいだ検索がしたいという要望は当然のようにでてくるの(当初そんな話がなかったとしても!!)
その時になって, クエリ何回も発行してアプリでマージしたり, あるいはビューを作って
それに対してクエリを投げたり…
などと言った手間をかけるよりは, 最初から一つのテーブルで全て済ませてしったほうが
シンプルではないかと考えているからです.
カラムが多い割にNULLばかりのレコードなんてろくでもない, という意見もまったくだとは思うのですが, まあ大目にみてください.

参考

エンタープライズ・アプリケーションアーキテクチャパターン

NULL撲滅委員会

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6