2016.03.07

PHP開発プロジェクトでドメイン駆動設計実践


golden-gate-16

はじめに

こんにちは。次世代システム研究室のT.Tです。

PHPでの開発プロジェクトの立ち上げから半年以上経ち、プロジェクトメンバーに開発方針が浸透してきて、安定した開発を進められる段階を迎えています。ドメイン駆動設計を開発方針の基軸として採用しましたが、プロジェクトメンバーもその方針での開発経験がなく、自分も含めドメイン駆動設計を学びつつ、試行錯誤しながら導入に至ることができました。

今回のプロジェクトでは、Yii 2をフレームワークとして採用したため、そのフレームワークの特性を考慮した上でドメイン駆動設計を適用しました。その中でも効果の大きかった集約やリポジトリ、ユビキタス言語等のパターンを、フレームワークの特性とどのように折り合いを付けて設計し実装したかについてご紹介したいと思います。

アーキテクチャ概要

ドメイン駆動設計

アプリケーションのアーキテクチャにはMSDNで紹介されているようにいくつかの標準的なパターンがあり、Webアプリケーションのサーバーサイドはこれらを組み合わせて作られることが多いと思います。

ざっくりとした説明になりますが、Yii 2は主にレイヤーアーキテクチャ等のパターンを扱うためのフレームワークを提供しています。

ドメイン駆動設計は、この中でビジネスレイヤーの中にさらに細かいドメインごとに特徴のあるモデルの境界線をユビキタス言語で定義することで、プロジェクトメンバー間のコミュニケーションを円滑にしたり、アプリケーションの拡張性やテスト効率を高める役割を担います。

レイヤーアーキテクチャ
“https://i-msdn.sec.s-msft.com/dynimg/IC351000.png”

レイヤーごとの役割

今回のプロジェクトでは、レイヤーごとに以下のように役割を分担するようにしました。
レイヤー 役割
コントローラ
(ベース)
トランザクション管理、例外処理、ログハンドリング
コントローラ
(個別)
認証チェック、バリデーション実行、フォームデータ管理
ページング、ビュー切り替え、サービス提供
モデル エンティティリポジトリ(DBクエリ発行)
ドメインモデルの処理
フォーム 入力フォーム、バリデーション定義
サービス ドメインモデルメソッドとヘルパーを組み合わせたサービス
固有の意味を持つ処理
ヘルパー ドメインモデルと関係ない共通処理

今回開発したWebサービスの特性上、1リクエストに対して1トランザクションで済ませられるのでトランザクション処理は共通のコントローラで一括して管理するようにしました。

Yii 2ではActive Recordが使えるので、モデルをActive Recordで実現するようにして、リポジトリとのやり取りを簡略化しました。そのため、エンティティとリポジトリをモデルの役割としてまとめるようにしました。

コントローラの処理

リクエストを受けたコントローラは大まかに以下の流れでリクエストを処理するようにしました。

集約のロード
ビジネスロジック(サービスメソッドの実行)
集約の保存
表示データの構築
レンダリング

集約

概要

集約を導入すると次のような効果が期待できます。
  1. トランザクション整合性が必要な属性をまとめることで管理しやすくなる
  2. 属性を集約にまとめることで属性の管理を集約間のやり取りとして簡略化できる

集約前のエンティティ属性の更新/取得
集約前更新

集約前取得

集約後のエンティティ属性の更新/取得
集約後更新

集約後取得

ディレクトリ構成

集約はmodelsディレクトリに各集約ごとにサブディレクトリを切って、同じ集約にまとめられるエンティティをその集約名の階層に格納するようにしました。以下の例では、MemberとMemberRankが集約になっていて、それぞれに集約される属性がmemberとmember/rankディレクトリに格納されています。
models
 member
  Member.php
  MemberAttribute.php
  MemberSite.php
  MemberSiteTotal.php
  MemberOrder.php
  rank
   MemberRank.php
   MemberRankDetail.php

リポジトリ

リポジトリの分離

コントローラのフローは先述していますが、リポジトリとのやり取りに焦点を当てた形でもう一度見てみたいと思います。

図のようにコントローラの処理でリポジトリと直接やり取りをするのは、集約のロードと集約の保存をしている箇所だけでそれ以外の箇所ではリポジトリから切り離されています。そのためビジネスロジックはリポジトリを意識する必要はなく、サービスのロジックはオンメモリで独立して表現することができるようになっていて、いったんオンメモリで全ての更新操作を終えてから一括してリポジトリに保存できるようになります。このようにリポジトリと分離しておくと、サービスメソッドが実行するべき本来の処理に集中できるようになり、単体テストの実装もDBへの問い合わせが不要になるため、テスト効率を上げる効果も期待できます。

リポジトリ

カスケード保存

Yii 2には標準のカスケード保存の機能はありませんが、集約の保存でトランザクション整合性を保つために、ActiveRecordをカスケード保存できるように拡張しました。これにより、集約内の属性は集約のルートを保存するだけでそれ以外の更新された属性も保存されます。詳しくは以前のエントリでご紹介しています。

サービス

サービスはモデルのメソッドを呼び出し、Webサービスが持つべき機能をユビキタス言語で抽象化したメソッドとして提供します。トランザクション整合性はモデルのメソッドが担保し、リポジトリはコントローラ側で扱うため、サービスは抽象化された機能を実現することだけに集中します。また、集約を通してモデルのメソッドを実行するため、サービスには集約を引数として渡します。

ユビキタス言語

企画から開発まで一貫して同じ目線でWebサービスを記述できるようにするために、企画側で日常的に利用している用語を抽出して、開発プロジェクトで利用しました。このユビキタス言語は、集約の名称やコントローラ名、サービス名等さまざまな箇所で利用しています。

実装

以上の方針に基づいたユーザー情報の郵便番号を更新するためのコントローラとサービス、モデルの実装は次のようになりました。主要箇所のみ抜粋しています。

controllers/mypage/ZipController.php
class ZipController extends BaseController
{
    public function actionComplete()
    {
        // 集約のロード
        $member = Member::findProperMember($member_id);

        // コントローラはサービス経由でオンメモリのエンティティを更新
        Yii::$app->mypage_service->updateMemberZipCode($member, $form->zip_code);

        // オンメモリの更新をリポジトリに反映
        $member->save();

        // 表示データの構築
        $data = [
            'zip_code'   => $member->zip_code,
            'prefecture' => $member->memberAttribute->prefecture,
        ];

        // レンダリング
        return $this->renderPartial('complete.twig', $data);
    }
}
services/MyPageService.php
class MyPageService extends Component
{
    // サービスメソッドの引数には集約を渡す
    public function updateMemberZipCode($member, $zip_code)
    {
        $zip_code_high = ZipUtil::getHighCode($zip_code);
        $zip_code_low = ZipUtil::getLowCode($zip_code);
        // 集約のメソッドを実行してエンティティを更新する
        $member->updateZipCode($zip_code_high, $zip_code_low);
    }
}
models/member/Member.php
class Member extends ActiveRecord
{
    // リポジトリとエンティティはモデルが兼任
    public static function findProperMember($member_id)
    {
        $member_status = MemberStatus::PROPER;

        return parent::find()
            ->where(compact('member_id', 'member_status'))
            ->one();
    }

    public function updateZipCodeAndPrefecture($member, $zip_code_high, $zip_code_low)
    {
        // 集約内に属しているmemberAttributeをトランザクション整合性を保つように更新する(ここでは郵便番号と同時に都道府県も更新する)
        $this->memberAttribute->zip_high = $zip_code_high;
        $this->memberAttribute->zip_low = $zip_code_low;
        $this->memberAttribute->prefecture = ZipUtil::getPrefecture($zip_code_high, $zip_code_low);
    }
}

単体テスト

PHPUnitでのサービスメソッドの単体テストコードは次のようになります。リポジトリから切り離されているため、オンメモリの記述だけで済みます。
class MyPageServiceTest extends \PHPUnit_Framework_TestCase
{
    public function testUpdateMemberZipCode()
    {
        $member = new Member();
        $member->memberAttribute = new MemberAttribute();
        $member->memberAttribute->zip_high = '150';
        $member->memberAttribute->zip_low = '8512';
        $member->memberAttribute->prefecture = '13';

        Yii::$app->mypage_service->updateMemberZipCode($member, '5300011');

        $this->assertEquals($member->memberAttribute->zip_high, '530');
        $this->assertEquals($member->memberAttribute->zip_low, '0011');
        $this->assertEquals($member->memberAttribute->prefecture, '27');
    }
}

まとめ

今回ご紹介したドメイン駆動設計は実際の開発プロジェクトで導入しているもので、その設計から実装までを一つの実践例としてご紹介しました。Yii 2のフレームワークと折り合いを付けるために、コントローラで集約のロードと保存を実行したり、リポジトリとエンティティを合わせてモデルとして表現していたりと、少しくせのある設計になっている箇所もありますが、ドメインモデルをしっかりと表現しつつ、可読性も拡張性も高いアプリケーション基盤を構築できていると思っています。

もしドメイン駆動設計の役割を担うものがなく、フレームワークの提供する機能だけで実装を進めていたら、ファットコントローラを作ってしまったり、何度もソースコードを読んでようやく理解できるような手続きだらけの実装になってしまったり、可読性も拡張性も低いアプリケーションになってしまったかもしれません。

実際、今回のプロジェクトではドメイン駆動設計を学びながらの導入だったので、初期段階ではエンティティが全てmodelsディレクトリの直下に格納されていたり、DBにOracle DBとMySQLの両方を使っていた関係で、次の段階ではmodels/oracle、models/mysqlのディレクトリを切って、それぞれにエンティティを格納したりと紆余曲折があって、今のような形に至った経緯もあります。

また、今回導入したドメイン駆動設計の手法も全体からするとごく一部にすぎないもので、そういった意味では今もまだ完成系ではなく、引き続き設計やドメインモデルを見直し、リファクタリングを進めてより良いあるべき姿を模索している段階です。引き続きドメイン駆動設計を基軸にした開発手法の改善や他の開発プロジェクトへのフィードバックを進めていきたいと思います。

参考リンク


次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

皆さんのご応募をお待ちしています。