🥳

PHP/アクティブレコードってなに?

20 min read

はじめに

この記事は、Martin FowlerPatterns of Enterprise Application Architecture ( 以下 PofEAA ) という 書籍 にある、

というそれぞれのパターンについて、独自の解釈で 解説するものです。

⚠️

  • 表題にはアクティブレコードとありますが、アクティブレコードを使用しない実装パターンも示しています。これらと比較することで、より理解を深めたいという意図があります。
  • 記事中のコードは解説のための便宜的なものであり、プロダクトコードとしては冗長だったり望ましくないことがあります。
  • 記事における解説は私見が入り混じったものになっているので、正しい原義の理解をしたいのならば、原著を参照してください。
  • 一部画像が縮小されて見辛いですが、たとえば Chrome ならば『新しいタブで画像を開く』などすれば大きい画像で閲覧できます。

予備知識

『状態』について

ここでは PHP におけるオブジェクトの状態にフォーカスして解説する。このセクションに限らず、この記事は PHP を前提としているため、他言語では用語のニュアンスが異なる場合がある。


オブジェクト とは、おおむね クラス をインスタンス化したものであると考えられる。
オブジェクトにおける 状態 とは、おおむねクラスの プロパティ であると考えられる。
インスタンス化後に ( 生成後に ) 変更することができる状態を持つオブジェクトを ミュータブル であるという。
そうでない、生成化後に状態を変更できないオブジェクトを イミュータブル であるという。


final class MutableHuman
{
    public string $name = 'Kono';
}

上記の MutableHuman クラスは、下記のように作成後に状態を ( name プロパティを ) 変更できるので、ミュータブル である。

$human = new MutableHuman();
var_dump($human->name); // Kono

$human->name = 'Sato';
var_dump($human->name); // Sato

final class ImmutableHuman
{
    private string $name;

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

    public function getName(): string
    {
        return $this->name;
    }
}

一方で上記の ImmutableHuman クラスは、作成後に状態を ( name プロパティを ) 変更できないので、イミュータブル である。

$human = new ImmutableHuman('Sato');
var_dump($human->getName()); // Sato

final class MutableHuman
{
    public string $name = 'Kono';
}

final class Taronizer
{
    private MutableHuman $human;

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

    public function getUserName(): string
    {
        return "{$this->human->name} Taro";
    }
}

Taronizer クラスには、状態を ( human プロパティを ) 変更するメソッドが存在しない。イミュータブルのように見える。

だが MutableHuman クラスがミュータブルであり状態が変更されうるので、Taronizer クラスもミュータブルになってしまう。

$human = new MutableHuman();
$taronized = new Taronizer($human);
var_dump($taronized->getUserName()); // Kono Taro

$human->name = 'Sato';
var_dump($taronized->getUserName()); // Sato Taro

final class MathUtil
{
    public static function sum(int $a, int $b): int
    {
        return $a + $b;
    }

    public static function mul(int $a, int $b): int
    {
        return $a * $b;
    }
}

上記の MathUtil クラスはプロパティを、すなわち状態を持っていないためイミュータブルである。

『ドメインロジック』を表現する方法について

データベースとのコネクションを確立するといった、アプリケーションを動かすためのロジックではなく、経験値を得てユーザのレベルが上がるといった、アプリケーションによって表現したい何かを動かすためのロジックを、ドメインロジック と呼ぶ。


tbl_user

user_id level exp
1 1 0

mst_level

level exp
1 0
2 10
3 20
4 30

以上のようなテーブルが用意されており、経験値をもとにユーザをレベルアップさせたいとする。

この仮定に沿って、各パターンにおける例を以下にあげる。

手続き型 ( トランザクションスクリプト ) でやる


class SettableUser
{
    final public function setUserId(int $userId): void
    {
        $this->userId = $userId;
    }

    final public function setLevel(int $level): void
    {
        $this->level = $level;
    }

    final public function setExp(int $exp): void
    {
        $this->exp = $exp;
    }

    final public function getUserId(): int
    {
        return $this->userId;
    }

    final public function getLevel(): int
    {
        return $this->level;
    }

    final public function getExp(): int
    {
        return $this->exp;
    }
}
final class LevelUpService
{
    // プロパティ、コンストラクタの宣言は省略 ...

    final public function __invoke(int $userId, int $expGained): void
    {
        $user = $this->userGateway->findByUserId($userId);
        $mstLevel = $this->levelGateway->findByExp($user->getExp() + $expGained);

        $this->reflect($mstLevel, $expGained, $user);
        $this->userGateway->update(UserCollection::fromArray([$user]));
    }

    private function reflect(
        LevelInterface $mstLevel,
        int            $expGained,
        SettableUser   $user
    ): void {
        $this->setLevel($mstLevel->getLevel(), $user);
        $this->addExp($expGained, $user);
    }

    private function setLevel(int $level, SettableUser $user): void
    {
        assert($user->getLevel() <= $level, '現在のレベルは、経験値獲得後のレベルより大きくならない');
        $user->setLevel($level);
    }

    private function addExp(int $exp, SettableUser $user): void
    {
        assert($exp <= 0, '経験値はマイナスすることができない');
        $user->setExp($user->getExp() + $exp);
    }
}
  • SettableUser にドメインロジックが ない
  • LevelUpService にドメインロジックが ある

ドメインロジックに関する状態を持たないが、手続きが書かれているクラスを、トランザクションスクリプト ( Transaction Script ) と呼ぶ。この例では LevelUpService クラスがトランザクションスクリプトである。

ドメインモデルでやる


final class UserDomainModel
{
    final public function reflect(LevelInterface $mstLevel, int $expGained): void
    {
        $this->setLevel($mstLevel->getLevel());
        $this->addExp($expGained);
    }

    private function setLevel(int $level): void
    {
        assert($this->level <= $level, '現在のレベルは、経験値獲得後のレベルより大きくならない');
        $this->level = $level;
    }

    private function addExp(int $exp): void
    {
        assert($exp >= 0, '経験値はマイナスすることができない');
        $this->exp += $exp;
    }

    final public function getUserId(): int
    {
        return $this->userId;
    }

    final public function getLevel(): int
    {
        return $this->level;
    }

    final public function getExp(): int
    {
        return $this->exp;
    }
}
final class LevelUpService
{
    // プロパティ、コンストラクタの宣言は省略 ...

    final public function __invoke(int $userId, int $expGained): void
    {
        $user = $this->userMapper->findByUserId($userId);
        $mstLevel = $this->levelMapper->findByExp($user->getExp() + $expGained);

        $user->reflect($mstLevel, $expGained);
        $this->userMapper->update(UserCollection::fromArray([$user]));
    }
}
  • UserDomainModel にドメインロジックが ある
  • LevelUpService にドメインロジックが ない

ドメインロジックに関する状態と、その振る舞いが書かれているクラスを、ドメインモデル ( Domain Model ) と呼ぶ。

ここまでのおさらい

  • オブジェクトには状態という観点がある。作成後に状態を変更できるか/否かで、ミュータブル/イミュータブルと呼ばれる
  • ドメインロジックを表現する方法としては、状態と切り離して記述するトランザクションスクリプトと、状態と結びつけて記述するドメインモデルがある

本論

データソースにアクセスするデザインパターン

データソース、特に SQL でクエリを投げて制御できる MySQL などの RDBMS へのアクセスを行うときに利用できるパターン ( KVS といった NoSQL でも利用できる場合がある ) 。

テーブルデータゲートウェイ


  • LevelUpService クラスにドメインロジックが ある
  • SettableUser クラスにドメインロジックが ない

final class UserTableDataGateway implements UserGatewayInterface
{
    private string $table = 'tbl_user';

    final public function findAll(): UserCollection
    {
        $resultSet = Capsule::table($this->table)->get();
        $userList = [];
        foreach ($resultSet as $user) {
            $userList[] = new SettableUser($user->user_id, $user->level, $user->exp);
        }
        return UserCollection::fromArray($userList);
    }

    final public function findByUserId(int $userId): SettableUserInterface
    {
        $user = Capsule::table($this->table)->where('user_id', $userId)->get();
        if (count($user) === 0) {
            throw new RuntimeException("user not found: {$userId}");
        }
        return new SettableUser($user->get('userId'), $user->get('level'), $user->get('exp'));
    }

    final public function create(UserCollection $userCollection): bool
    {
        return Capsule::table($this->table)->insert($userCollection->toArray());
    }

    final public function update(UserCollection $userCollection): int
    {
        $result = 0;
        foreach ($userCollection as $user) {
            $result += Capsule::table($this->table)
                ->where('user_id', $user->getUserId())
                ->update($user->toArray());
        }
        return $result;
    }

    final public function delete(array $userIdList): int
    {
        return Capsule::table($this->table)->whereIn('user_id', $userIdList)->delete();
    }
}
  • イミュータブル に近い傾向がある
    • ここでは Connection といったミュータブルなプロパティが考えられる
  • ドメインロジックが ない
  • 取得されるオブジェクトにもドメインロジックが ない
  • サービス、つまり トランザクションスクリプトにドメインロジックがある

データマッパー


  • LevelUpService クラスにドメインロジックが ない
  • UserDomainModel クラスにドメインロジックが ある

final class UserDataMapper implements UserMapperInterface
{
    private string $table = 'tbl_user';

    final public function findAll(): UserCollection
    {
        $resultSet = Capsule::table($this->table)->get();
        $userList = [];
        foreach ($resultSet as $user) {
            $userList[] = new UserDomainModel($user->user_id, $user->level, $user->exp);
        }
        return UserCollection::fromArray($userList);
    }

    final public function findByUserId(int $userId): UserDomainModelInterface
    {
        $user = Capsule::table($this->table)->where('user_id', $userId)->get();
        if (count($user) === 0) {
            throw new RuntimeException("user not found: {$userId}");
        }
        return new UserDomainModel($user->get('userId'), $user->get('level'), $user->get('exp'));
    }

    final public function create(UserCollection $userCollection): bool
    {
        return Capsule::table($this->table)->insert($userCollection->toArray());
    }

    final public function update(UserCollection $userCollection): int
    {
        $result = 0;
        foreach ($userCollection as $user) {
            $result += Capsule::table($this->table)
                ->where('user_id', $user->getUserId())
                ->update($user->toArray());
        }
        return $result;
    }

    final public function delete(array $userIdList): int
    {
        return Capsule::table($this->table)->whereIn('user_id', $userIdList)->delete();
    }
}
  • イミュータブル に近い傾向がある
  • ドメインロジックが ない
  • 取得されるオブジェクト、つまり ドメインモデルにドメインロジックがある
  • サービスにはドメインロジックが ない

行データゲートウェイ


  • LevelUpService クラスにドメインロジックが ある
  • UserRowDataGateway クラスにドメインロジックが ない

final class UserRowDataGateway extends SettableUser implements SettableUserInterface
{
    private static string $table = 'tbl_user';

    final public static function findAll(): UserCollection
    {
        $resultSet = Capsule::table(self::$table)->get();
        $userList = [];
        foreach ($resultSet as $user) {
            $userList[] = new self($user->user_id, $user->level, $user->exp);
        }
        return UserCollection::fromArray($userList);
    }

    final public static function findByUserId(int $userId): SettableUserInterface
    {
        $user = Capsule::table(self::$table)->where('user_id', $userId)->get();
        if (count($user) === 0) {
            throw new RuntimeException("user not found: {$userId}");
        }
        return new self($user->get('userId'), $user->get('level'), $user->get('exp'));
    }

    final public static function create(UserCollection $userCollection): bool
    {
        return Capsule::table(self::$table)->insert($userCollection->toArray());
    }

    final public static function update(UserCollection $userCollection): int
    {
        $result = 0;
        foreach ($userCollection as $user) {
            $result += Capsule::table(self::$table)
                ->where('user_id', $user->getUserId())
                ->update($user->toArray());
        }
        return $result;
    }

    final public static function delete(array $userIdList): int
    {
        return Capsule::table(self::$table)->whereIn('user_id', $userIdList)->delete();
    }
}
  • ミュータブル
  • ドメインロジックが ない
  • 取得されるオブジェクトは自分自身である
  • サービス、つまり トランザクションスクリプトにドメインロジックがある
  • 一部のメソッドが static になることがある

アクティブレコード


  • LevelUpService クラスにドメインロジックが ない
  • UserActiveRecord クラスにドメインロジックが ある

abstract class AbstractUserActiveRecord extends GettableUser implements UserDomainModelInterface
{
    private static string $table = 'tbl_user';

    final public static function findAll(): UserCollection
    {
        $resultSet = Capsule::table(self::$table)->get();
        $userList = [];
        foreach ($resultSet as $user) {
            $userList[] = new static($user->user_id, $user->level, $user->exp);
        }
        return UserCollection::fromArray($userList);
    }

    final public static function findByUserId(int $userId): UserDomainModelInterface
    {
        $user = Capsule::table(self::$table)->where('user_id', $userId)->get();
        if (count($user) === 0) {
            throw new RuntimeException("user not found: {$userId}");
        }
        return new static($user->get('userId'), $user->get('level'), $user->get('exp'));
    }

    final public static function create(UserCollection $userCollection): bool
    {
        return Capsule::table(self::$table)->insert($userCollection->toArray());
    }

    final public static function update(UserCollection $userCollection): int
    {
        $result = 0;
        foreach ($userCollection as $user) {
            $result += Capsule::table(self::$table)
                ->where('user_id', $user->getUserId())
                ->update($user->toArray());
        }
        return $result;
    }

    final public static function delete(array $userIdList): int
    {
        return Capsule::table(self::$table)->whereIn('user_id', $userIdList)->delete();
    }
}
final class UserActiveRecord extends AbstractUserActiveRecord implements UserDomainModelInterface
{
    final public function reflect(LevelInterface $mstLevel, int $expGained): void
    {
        $this->setLevel($mstLevel->getLevel());
        $this->addExp($expGained);
    }

    private function setLevel(int $level): void
    {
        assert($this->level <= $level, '現在のレベルは、経験値獲得後のレベルより大きくならない');
        $this->level = $level;
    }

    private function addExp(int $exp): void
    {
        assert($exp >= 0, '経験値はマイナスすることができない');
        $this->exp += $exp;
    }
}
  • ミュータブル
  • ドメインロジックが ある
  • 取得されるオブジェクトは自分自身である
  • サービスにはドメインロジックが ない
  • 一部のメソッドが static になることがある
  • 自動生成しやすい CRUD 部分を親クラスとして、その子クラスでドメインロジックの記述に注力するという実装もある

ここまでのおさらい

パターン名称 CRUD と ドメインの状態 ドメインロジックのありか
テーブルデータゲートウェイ 別クラス トランザクションスクリプト
データマッパー 別クラス ドメインモデル
行データゲートウェイ 同一クラス トランザクションスクリプト
アクティブレコード 同一クラス ドメインモデル

補遺

今回の内容と密接に関係する PofEAA の他パターン

DTO ( Data Transfer Object )

  • レイヤーなどをまたぐときにデータの受け渡しをするオブジェクト
    • 小さい粒度で頻繁に別レイヤーにアクセスするより、不要なものがあっても一度に全部取得したいときなどに用いる
    • PHP ならばすべてを連想配列に委ねず、オブジェクトにして型付けする選択肢があってもいい
    • Getter / Setter がある
      • Setter は必須というわけではない。イミュータブルにできるならばすべき
    • ドメインロジックを持たない
    • 自身をシリアライズするメソッドを持ち、それを強制するインターフェースを implements していることがある

セパレートインターフェース ( Separated Interface )

  • 実装が存在しないパッケージに定義されたインターフェース
    • パッケージの依存関係を制御して、独立性を高めることができる
      • 例えば DataSource レイヤのインターフェースを作成し、それを Domain レイヤに配置することで、Domain …> DataSource 方向の依存が発生しないように制御する ( つまり Domain レイヤの独立性を高めている )
    • どうやってインスタンス化するか
      • インターフェースと実装、両方を知っているパッケージを利用する
        • 依存性の注入をサポートしているパッケージなど
      • Factory を利用する
        • Factory のインターフェースもセパレートインターフェースとして定義が必要になる

レコードセット ( Record Set )

  • SQL クエリの結果と同じ構造を持つオブジェクト ( の集合 )
    • 暗黙的なインターフェース / 明示的なインターフェース
      • 暗黙的なインターフェースの Record Set とは、型情報のない汎用的なオブジェクトで、データベースから取得した結果を扱うものである
        • たとえば PDO で fetch したときに返る stdClass
        • 静的解析の恩恵を受ける事ができない
          • アノテーションによる静的解析の範囲が広がり続けているため、いまや断言はできないかもしれない
        • 非常に大量のレコードを取得するときは、パフォーマンスの関係上あえて暗黙的なインターフェースを選択することも考えられる
      • 明示的なインターフェースの Record Set とは、型情報を持たせた専用のオブジェクトで、データベースから取得した結果を扱うものである
$user = $this->userDataAccessObject->findByUserId(1); // stdClass 型が返る
$userId = $userId['user_id']; // 静的解析が効かない
$user = $this->userDataAccessObject->findByUserId(1); // User 型が返る
$userId = $user->userId; // 静的解析が効く

$userId = $user->getUserId(); // カプセル化されていたり、ドメインモデルを兼ねていることなどもある

リポジトリ ( Repository )

  • データマッパーのレイヤーとドメインモデルとを仲介する、コレクションのように振る舞うインターフェース
    • ドメインモデルの利用を前提としているパターン
      • データソースが RDBMS ならば、データマッパーの利用を前提としている
    • リポジトリはデータソースに永続化するオブジェクトや、その操作をカプセル化して隠蔽する
      • これらのインターフェースが定義されておらず、ドメインモデルが具象クラスに直接依存しているのならば、それがリポジトリにはなり得ないはず
      • 隠蔽の結果としてリポジトリはコレクションのように定義できるし、使う側もデータソースを意識せずに、リポジトリをコレクションのように使用できる
      • リポジトリの実装として、例えばデータソースが RDBMS や KVS であろうと、JSON や XML のような静的ファイルであろうと、 SOAP や REST API であろうと、同様のインターフェースが提供されるべき
        • 理想は上記の通りだが、現実として抽象化には限界があることが多いため、ある程度の妥協は必要になることも考えられる
    • リポジトリを Domain レイヤのセパレートインターフェースとして定義することで、依存性の逆転を行う方法がよく知られている

『ドメインロジック』を表現する方法の不安定さについて

  • ドメインロジックをどこに記述するかという問題は、基本的に開発者の意識によってのみでしか解決できない
    • たとえばドメインモデルを採用しよう!とチームで決めたとしても、サービスにドメインロジックが漏れ出して、トランザクションスクリプトが部分的に産み出されてしまうかもしれない
      • その方が望ましいと考えて意識的にそうするならともかく、意図せず他と異なるような実装となるのは好ましくない
    • 場合によってはデータマッパーといったデータソース側にも、ドメインロジックが漏れ出してしまうことすらある
      • さすがにこのケースは許容しない方がよさそう
    • 規模の大きいアプリケーションを複数人で開発するときは、チームメンバー全員が、ドメインロジックは原則ドメインモデルに記述する、という意識をしっかり持つことが望ましい
      • ドメインロジックは原則ドメインモデルに記述するが、ある部分ではトランザクションスクリプトに記述した方が自然だろうからそうする、ということももちろん考えられる
    • この不安定さが、デザインパターンの理解をも困難にしている気がする … ( おわり

Discussion

ログインするとコメントできます