LoginSignup
70
45

More than 5 years have passed since last update.

Active Record Sucks! (あるいはSQLおじさんの憂鬱)

Last updated at Posted at 2016-11-24

まえがき

この記事の内容は、マーティンファウラーのエンタープライズ・アプリケーションアーキテクチャパターン (以下EAAP本)の
第十章「データソースのアーキテクチャに関するパターン」をもとに, 個人的な見解を加えて記述しております.
私個人による誤読, 思い込み, あるいは日本語能力の欠如(翻訳者の技術者としての能力の欠如も原因におおいに含めたい)によって
内容を間違えて把握している可能性もあるので, 気になる方はぜひ参考文献に直接当たっていただきたい.
そうでなくても, とてもいい本ですし.

なぜこの記事を書いたのか

しばらく前, SQLおじさんの通称で知られる方のORMについての記事が派手に炎上して話題になっていました.
彼の主張は, 大いに同意できる観点から全く同意できない結論に至るという, なんとも評価がしづらい内容でした.
そこで, 私が何に同意し, 何に同意できなかったかを, EAAP本の解説をしつつ語っていこうと思い
記事を書き出したわけです.

EAAP本に見るデータ・ソースアーキテクチャに関わる4つのパターン

概論

EAAP本では, DBとアプリケーションが接する層の設計パターンを以下の4つに分類して論じている

  • テーブルゲートウェイパターン
  • レコードゲートウェイパターン
  • アクティブレコードパターン
  • データマッパーパターン

次項にて, それぞれのパターンの概要を説明する.

テーブルゲートウェイパターン

このパターンでは, DBとアプリケーションの境界に位置するオブジェクトを, DBのテーブルに対するゲートウェイとして実装する.

ゲートウェイとは何か?
これについては, EAAP本にも明確な定義は書かれていなかったように思えるのですが, Wikipediaの記述に従って
「異なるプロトコルで動いているレイア間に位置し, お互いのプロトコルを適切に変換して伝えることで, それらのレイアを接続する何か」
という意味で, ここでは使っていきます.
つまり, このパターンでは, アプリケーション内に「(DB)テーブル」と言うクラスを用意し, このオブジェクトに対するメソッド呼び出し(アプリ側のプロトコル)を, RDBMSに対するSQL(DB側のプロトコル)に変換する.

「テーブル」ゲートウェイと名前が付いているが, 実際にDB側に用意する操作対象はテーブルである必要はなく
事前に定義されたビューであったり, 定義されていないビュー(つまり複数のテーブルを結合したようなクエリ)であったり, あるいはストアド・プロシージャであっても構わない.

簡単なサンプルを書いてみると, 以下のようになると思われる

UserTableGateway.php
<?php
/**
 * usersテーブルに対するTableGatewayクラス
 *
 */
class UserTableGateway {
    private $pdo;

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

    public function find($id) {
        $sql = "SELECT * FROM users WHERE id = :id";
        $params = ['id' => $id];

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

    public function findByCompany($company) {
        $sql = "SELECT * FROM users WHERE company like :company ";
        $params = ['company' => '%'.$company.'%'];

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

    private 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;
    }

    public function updateAddress($address, $id) {
        $sql = "UPDATE users SET address = :address WHERE id = :id ";
        $params = ['address' => $address, 'id' => $id];

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

    public function insert($name, $address, $company) {
        $sql = "INSERT INTO users(name, address, company) VALUES (:name, :address, :company)";
        $params = ['name' => $name, 'address' => $address, 'company' => $company];

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

    public function delete($id) {
        $sql = "DELETE FROM users WHERE id = :id";
        $params = ['id' => $id];

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

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

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

この例では, 検索メソッドの返り値の型として連想配列を選択しているが, 別途データ用のクラスを定義して
それを返すようにしても良い.
また, この例では, 1つのゲートウェイオブジェクトが1つのテーブルに対応しているように見えるが,
実際には1つのゲートウェイが複数のテーブルへのアクセスを提供していても問題ない.

行データゲートウェイパターン

このパターンでは, DBとアプリケーションの境界に位置するオブジェクトを, DBのレコードに対するゲートウェイとして実装する.
上の記述に揃えると, このパターンでは, アプリケーション内に「(DB)レコード」と言うクラスを用意し, このオブジェクトに対するメソッド呼び出し(アプリ側のプロトコル)を, RDBMSに対するSQL(DB側のプロトコル)に変換する.
DB操作に関わる全てのメソッドを, テーブルオブジェクトに定義するテーブルゲートウェイパターンとは異なり,
このパターンの場合, 操作対象のレコードオブジェクトを検索するためのメソッドはFinderオブジェクトに,
更新/追加を行うためのメソッドはレコードオブジェクトにそれぞれ実装する.

UserRecordDataGateway.php
<?php
/**
 * usersテーブルに対する検索クラス
 *
 */
class UserRecordDataFinder {
    private $pdo;

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

    public function find($id) {
        $sql = "SELECT * FROM users WHERE id = :id";
        $params = ['id' => $id];

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

    public function findByCompany($company) {
        $sql = "SELECT * FROM users WHERE company like :company ";
        $params = ['company' => '%'.$company.'%'];

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

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

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

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

        return new UserRecordData($this->pdo, $assoc['id'], $assoc['name'], $assoc['address'], $assoc['company']);
    }
}

/**
 * usersテーブルのレコードに対するRecordDataGatewayクラス
 *
 */
class UserRecordData {
    private $pdo;
    private $id;
    private $name;
    private $address;
    private $company;

    /* アクセサは省略... */

    public function __construct($pdo, $id, $name, $address, $company) {
        $this->pdo = $pdo;
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->company = $company;
    }

    public function update() {
        $sql = "UPDATE users SET name = :name, address = :address, company = :company WHERE id = :id ";
        $params = ['name' => $this->name, 'address' => $this->address, 'company' => $this->company, 'id' => $this->id];

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

    public function insert() {
        $sql = "INSERT INTO users(name, address, company) VALUES (:name, :address, :company)";
        $params = ['name' => $this->name, 'address' => $this->address, 'company' => $this->company];

        $result = $this->execute($sql, $params);

        if ($result) {
            $this->id = $this->pdo->lastInsertId();
        }

        return $result;
    }

    public function delete() {
        $sql = "DELETE FROM users WHERE id = :id";
        $params = ['id' => $this->id];

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

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

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

アクティブレコードパターン

このパターンでは, DBのレコードのデータとそれに対する操作をラップしたオブジェクトに,
アプリケーション内のドメインロジックを追加していく.
「レコードのデータと操作をラップするなら行データゲートウェイと同じじゃないのか?」と考えられた人もいると思われる.
実際, 行データゲートウェイとアクティブレコードの間にはハッキリとした境界があるわけではない.
このオブジェクトが関わる何らかの処理を追加する必要が出たときに,
サービスオブジェクト(?)にこのオブジェクト引数として受け取るメソッドを追加するならば行データゲートウェイで,
このオブジェクトに直接メソッドを追加するならばアクティブレコードだと考えられる程度の違いしかない.
(アプリケーションがトランザクションスクリプト志向なのかドメインモデル志向なのかの違いとも言うかもしれません)
なお, 一般に実装されているアクティブレコードパターンを採用したORマッパーライブラリでは,
モデルを検索するメソッドをモデルの静的メソッドとして提供していることが多いと思われるが,
必ずしもモデルに検索メソッドを定義する必要はなく, 行データゲートウェイパターンの時と同様
Finderオブジェクトを別に定義する形に実装しても問題はない.

UserActiveRecord.php
<?php
/**
 * usersテーブルに対するActiveRecord
 *
 */
class UserActiveRecord {
    private static $pdo;

    public static function setPdo($pdo) {
        self::$pdo = $pdo;
    }

    private $data;

    public function __construct($data = []) {
        $this->data = $data;
    }

    public function __get($key) {
        if (!isset($this->data[$key])) {
            return '';
        }

        return $this->data[$key];
    }

    public function __set($key, $value) {
        $this->data[$key] = $value;
    }

    public static function find($id) {
        $sql = "SELECT * FROM users WHERE id = :id";
        $params = ['id' => $id];

        return self::fetchOne($sql, $params);
    }

    public static function findByCompany($company) {
        $sql = "SELECT * FROM users WHERE company like :company ";
        $params = ['company' => '%'.$company.'%'];

        return self::fetchOne($sql, $params);
    }

    private static function fetchOne($sql, $params) {
        $stmt = self::$pdo->prepare($sql);
        $stmt->execute($params);

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

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

        return new UserActiveRecord($assoc);
    }

    public function update() {
        $sql = "UPDATE users SET name = :name, address = :address, company = :company WHERE id = :id ";
        $params = ['name' => $this->data['name'], 'address' => $this->data['address'], 'company' => $this->data['company'], 'id' => $this->data['id']];

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

    public function insert() {
        $sql = "INSERT INTO users(name, address, company) VALUES (:name, :address, :company)";
        $params = ['name' => $this->data['name'], 'address' => $this->data['address'], 'company' => $this->data['company']];

        $result = $this->execute($sql, $params);

        if ($result) {
            $this->id = self::$pdo->lastInsertId();
        }

        return $result;
    }

    public function delete() {
        $sql = "DELETE FROM users WHERE id = :id";
        $params = ['id' => $this->data['id']];

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

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

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

    /* 以下, ドメインロジックが続く  */
}

アクティブレコードでは, 更新系のSQLは内部データかメタデータを元に動的に生成する方法が一般的だとは思うが
記述が複雑になってしまうので, ここでは固定のSQLとしている.

データマッパーパターン

このパターンでは, アプリケーションないのドメインモデル設計と, DB内のテーブル設計をそれぞれ独立して行い, それらの間に交互の変換を行うデータマッパーオブジェクトを用意することで
DBとアプリケーションを接続する.
アプリケーション側のドメインモデルオブジェクトは, ドメインに関わるメソッドは持つが, 自身の検索や永続化の為のメソッドは持たず,
DB上ではどのテーブルのどのレコードに相当するかなどと言った暗黙の取り決めや, メタ情報も直接は持たない.
たとえ, Userモデルがidというメンバ変数を持っていたとしても, それがusersテーブルのidカラムと連携しているという保証は無いのである.
DBとドメインモデルをマッピングするための情報はすべてデータマッパが保持し,
ドメインモデル設計の変更や, テーブル設計の変更の影響もここで吸収する.

UserMapper.php
<?php
/**
 * usersテーブルに対するDataMapper
 *
 */
class UserDataMapper {
    private $pdo;

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

    public function find($id) {
        $sql = "SELECT * FROM users WHERE id = :id";
        $params = ['id' => $id];

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

    public function findByCompany($company) {
        $sql = "SELECT * FROM users WHERE company like :company ";
        $params = ['company' => '%'.$company.'%'];

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

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

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

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

        return new UserModel($assoc['id'], $assoc['name'], $assoc['address'], $assoc['company']);
    }

    public function update($user) {
        $sql = "UPDATE users SET name = :name, address = :address, company = :company WHERE id = :id ";
        $params = ['name' => $user->getName(), 'address' => $user->getAddress(), 'company' => $user->getCompany(), 'id' => $user->getId()];

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

    public function insert($user) {
        $sql = "INSERT INTO users(name, address, company) VALUES (:name, :address, :company)";
        $params = ['name' => $user->getName(), 'address' => $user->getAddress(), 'company' => $user->getCompany()];

        $result = $this->execute($sql, $params);

        if ($result) {
            $user->setId($this->pdo->lastInsertId());
        }

        return $result;
    }

    public function delete($user) {
        $sql = "DELETE FROM users WHERE id = :id";
        $params = ['id' => $user->getId()];

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

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

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

class UserModel {
    private $id;
    private $name;
    private $address;
    private $company;

    public function __construct($id, $name, $address, $company) {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->company = $company;
    }

    /* アクセサは省略 */

    /* 以下, 様々なドメインロジックが続く */
}

こうしてみると, TableGatewayパターンとよく似た構成をしていることがわかる.
違いは, ドメインロジックをモデルに配置するかサービスクラスに配置するかであって,
これもまたRecordDataGatewayパターンとActiveRecordパターンの違いと同様に
トランザクションスクリプトを志向するか, あるいはドメインモデルを志向するかの違いなのかもしれない.

アクティブレコードのここが気に入らない!

アクティブレコードの問題点.
それは, アプリケーションとDBが密結合してしまうことです.
これを聞いて奇妙に思う人もいるかと思われます.
アプリケーションからみて外部の存在であるDBに関わる情報をすべてアクティブレコードオブジェクトの内部にラップし,
オブジェクトのメソッド、あるいはインスタンス変数へのアクセスという形で行えるようになり
また, 十分うまく設計できているならばテスト時にはモックオブジェクトに差し替えることでDBから
完全に分離した形で単体テストもできるアクティブレコードオブジェクトが, DBと密結合してるとか、おまえ頭おかしいんじゃないか?
と思った方もいるかもしれません.

しかし, 例えば開発中に
「現状だとユーザ登録で住所欄に大きなテキストボックス一個しか用意してないけど, これ不便だから4つに分けて, それぞれ都道府県, 市区町村, 番地, 建物を入力するようにして」
と言われたら, どういう変更が必要になるでしょうか?
アクティブレコードを使っている場合の, 一番素直な変更内容は,
1. まずUserモデルにインスタンス変数を3つ追加し,
2. 次にusersテーブルに同じ名前のカラムを3つ追加する
という形になるでしょう.

あるいは
「現状、usersテーブルに会社情報も一緒に保存してたけど, やっぱりcompanysテーブル用意して, 会社情報はそっちに保存するようにしてよ」
と言われたら, どういう変更が必要になるでしょうか?
この場合もやはり,
1. DBにcompanysテーブルを作成し,
2. アプリケーション側にCompanyモデルを作成しUserモデルとのリレーションを定義する

という形になるでしょう.
このようにアクティブレコードの世界ではアプリケーション内のモデルに対する設計変更は, 自動的にDBのテーブル設計に対する変更に波及し,
逆にDBのテーブル設計に対する変更もまた, 自動的にアプリケーションのドメインモデル設計に波及してしまうのです.

これが, テーブルゲートウェイパターンを使っていたならばどうだったでしょうか?
最初の例の場合は
1.画面に追加になった住所要素を追加し
2.4つの住所要素を何らかのルールで結合して, テーブルゲートウェイオブジェクトのメソッドを呼び出す(DBには変更なし)

となり, 2つ目の例では

1.テーブルをusersテーブルとcompanysテーブルに分割し
2.テーブルゲートウェイオブジェクトのメソッドを取得時は結合を, 保存時は分割をするように変更する

と言った形で片方の世界の変更を, もう片方の世界へは影響を及ぼさずに済むでしょう.
(まあ, 上記の例の場合, 変えないことが設計上本当に正しいかどうかはさておいてですが)
またデータマッパーパターンを採用していた場合でも, やはり同様にデータマッパーが変更を吸収するので
影響範囲は片方の世界(とデータマッパー)に限定することができたでしょう.

アクティブレコードを採用してしまった者だけが, DBの設計とアプリケーションの設計の密結合という罪を背負って生きていかなければならなくなるのです.

それでもまだまだ反論は考えられる

上記の例を聞いて
「モデルにDB上のカラムに対応するメンバ変数としないメンバ変数の情報を持たせた上で, 追加メンバ変数をDBと対応しないメンバ変数としてやれば問題ない」
とか
「テーブル分割してもいいように, なにかどのメンバ変数とテーブル上のカラムの関係を定義したファイルなりアノテーションなりを用意してらればなんとかなるような気がしないでもない」
などと、反論する人もいるかもしれません.
これらの言葉もまた, 正しいです.
確かに, テーブルとモデルの対応を定義するファイルなりメンバ変数なりを用意して, まあなんか頑張って実装していけばうまいことやっていくことはできるでしょう.

でもさ, 君たちはほんとにそんな苦労したくてアクティブレコードなんて採用したの?
DBにあるものはドメインモデルにもだいたい同じ名前であるし, テーブルのカラムとして存在するものは、やっぱりモデルの同じ名前のメンバ変数として存在する.
オブジェクトが変わればレコードも変わるし, レコードが変わればオブジェクトも変わる.
そういうシンプルな世界, Railsが出てきたときの「規約最高や!XMLファイルなんて最初から要らなかったんや!」の精神でアクティブレコード選んだんじゃないのかい?
もしドメインモデルのレイアに柔軟性, 独立性がほしいのにアクティブレコード選んでいるんだったら、そんなもの捨ててデータマッパーに鞍替えしましょうよ.
脆弱な基盤の上に堅牢な建築物を建てようという発想自体が間違っているんだからさ.

しかし, それでもアクティブレコードは使われている

ここまでアクティブレコードはク◯だと書いてきましたが, 世の中(少なくともPHP界隈では)見回してみるとORMでよく使われているのは
アクティブレコードパターンであり, データマッパパターンを採用しているものは少数派なのが現実だと思います.(Doctrineくらいか?)
なぜ, より柔軟で独立性が高いデータマッパではなくアクティブレコードがよく使われているのか?
それは世の技術者の大半が箸にも棒にも引っかからないような木偶の坊揃いだからである.
...などということはなく, 単純にアクティブレコードが十分に実用的だからです.
そもそも, ただただ単純にテーブルの中身を画面にダンプして, そこで入力されたものをまた
同じテーブルに戻せばいいだけのアプリケーションを作るのに, そんなに立派なドメインモデルなど必要なのでしょうか?
DB設計もアプリケーション設計もプログラマ1,2人で全部やりながら三ヶ月くらいで新しいサービス開始したいと
考えているような組織で, DBとアプリケーションを分離させることにどれだけの利益があるのでしょうか?

世の中, データマッパ持ち出すのは大仰すぎるような簡単で単純に見えるアプリが多いからこそ
ORMではアクティブレコードが中心的な立場にあるのでしょうし, それは今後も変わらないんじゃないかと思われます.

SQLおじさんに同意できなかったこと

ストアド・プロシージャを使おう

API挟んで、両サイド好きに実装しようというのは大いに同意できますし, 良い方法論だとおもうのですが
開発言語になんでまたSQLとかストアド・プロシージャとかを持ち出すのかコレガワカラナイ
私もSQLは嫌いじゃありませんし, アプリ内でちょっとしたレポートが必要になった場合に,
(一般的なWEBアプリプログラマ基準では)複雑な集計用SQLを書いて嫌な顔されたりもするのですが
それはあくまで機能のほんの一部で使う程度の話であって, (おそらく大規模開発の)1レイヤをまるまるを
メンテ性も移植性も悪そうなストアド・プロシージャを使って書くことが, 筋の良い話とはとても思えません.

ORMはクソだし、それを作っているやつも使ってるやつもみんなクソ

アプリケーションがオブジェクト指向を採用しており, DBとしてリレーショナル・データベースを採用しているのでしたら
よほどプリミティブな書き方(巨大なmainメソッドの中でクエリの発行を含めたすべての処理を書いているような書き方)を指定ない限り,
両方の世界の間の差異を埋めるためのモジュールは必要ですし, それを作り込んだり利用したりすることは
全く当然の話だと思われます.

最後に

ここまで色々と書いてきたのですが, もとにしているEAAP本そのものが日本語版の出版が2005年, 原著は更に遡ること2002年と結構古い.
無論, この本の価値が10年やそこらで完全に0になってしまうとは思いませんが, それでも今では上記のような問題を
解決したパターンなどが考案されているかもしれません.
残念ながら, ここ数年はこの方面の勉強をサボっていて, 最新の話には疎いので, もっと良いサイトや本,
あるいは実装されたライブラリなどがありましたら教えていただけるとうれしいです.

参考

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

ゲートウェイ

70
45
2

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
70
45