PHP
laravel
DDD
ドメイン駆動設計
アーキテクチャ
OriginalWHITEPLUSDay 18

Laravel製の趣味プロダクトをDDD + ヘキサゴナルアーキテクチャで書き直してみた

この記事はWHITEPLUS Advent Calendar 2017の18日目です。


最近、趣味で開発しているLaravel製のプロダクトをDDD + ヘキサゴナルアーキテクチャで書き直してみたので、この記事ではその構成について超ざっくりと紹介します。
Laravelアプリケーションを開発する上での参考になれば嬉しいです。

プロダクトの紹介

ngmy/webloyer: Webloyer is a Web UI for managing Deployer deployments

  • PHP製のデプロイツールDeployerのWeb UIです
  • Deployerの詳しい紹介については、他の方が書いた記事があるのでここでは省略します
  • こんな感じのWeb UIです
  • ざっくりと下記の機能を持っています
    • プロジェクト管理
      • プロジェクト単位のデプロイ設定
    • プロジェクト単位のデプロイ管理
      • 1クリックでのデプロイ・ロールバック
      • デプロイ履歴の保存
      • デプロイ完了のメール通知
    • レシピファイル管理
    • サーバファイル管理
    • ユーザ管理
      • メールアドレス・パスワードによる認証
      • ロールベースのアクセス制御
    • Web APIによるデプロイ
    • Webhookによるデプロイ
      • 記事執筆時点でサポートしているのはGitHub Webhookのみ
  • 利用しているWebアプリケーションフレームワークはLaravel 5.2です
    • 開発開始時点ではLaravel 4.2でした
  • 書き直す前の最新バージョンは0.43.0です
  • 書き直す前(バージョン0.43.0)のアーキテクチャは下記のような感じでした
    • MVC + サービス層 + リポジトリパターン
    • ビジネスロジックをapp/配下に配置(app/Services/app/Repositories/など)
    • リポジトリではEloquentのインスタンスをそのまま返却
    • Implementing Laravelを参考にしていました

書き直してどうなったか?

https://github.com/ngmy/webloyer/tree/feature/ddd-hexagonal-architecture

ビジネスロジックのディレクトリ構成

  • ビジネスロジックはapp/配下ではなく、packages/というディレクトリを別に作成してその配下に配置しています
packages/
├── common
│   ├── src
│   │   ├── Config
│   │   ├── Domain
│   │   │   ├── Model
│   │   │   └── Service
│   │   ├── Enum
│   │   ├── Filesystem
│   │   ├── Notification
│   │   ├── Port
│   │   │   └── Adapter
│   │   │       ├── Notification
│   │   │       ├── Persistence
│   │   │       └── Validation
│   │   ├── QueryObject
│   │   └── Validation
│   └── tests
├── identity_access
│   ├── src
│   │   ├── Application
│   │   │   ├── Role
│   │   │   └── User
│   │   ├── Domain
│   │   │   ├── Model
│   │   │   │   ├── Role
│   │   │   │   └── User
│   │   │   └── Service
│   │   └── Port
│   │       └── Adapter
│   │           └── Persistence
│   │               └── Eloquent
│   └── tests
└── webloyer
    ├── src
    │   ├── Application
    │   │   ├── Deployer
    │   │   ├── Deployment
    │   │   ├── Project
    │   │   ├── Recipe
    │   │   ├── Server
    │   │   └── Setting
    │   ├── Domain
    │   │   ├── Model
    │   │   │   ├── Deployer
    │   │   │   ├── Deployment
    │   │   │   ├── Project
    │   │   │   ├── Recipe
    │   │   │   ├── Role
    │   │   │   ├── Server
    │   │   │   ├── Setting
    │   │   │   └── User
    │   │   └── Service
    │   │       └── Deployer
    │   └── Port
    │       └── Adapter
    │           ├── Form
    │           │   ├── DeploymentForm
    │           │   ├── ProjectForm
    │           │   ├── RecipeForm
    │           │   ├── ServerForm
    │           │   ├── SettingForm
    │           │   └── UserForm
    │           ├── JsonRpc
    │           │   └── Middleware
    │           ├── Messaging
    │           └── Persistence
    │               └── Eloquent
    └── tests
        └── Port
            └── Adapter
                └── Form
                    └── ProjectForm
  • packages/の直下に、ドメインごとのディレクトリを作成しています(webloyer/identity_access/common/
  • 各ドメインのディレクトリの直下にはsrc/tests/があり、それぞれ実コードとテストコードを配置しています(今のところテストコードはほとんど書けてませんが:sweat_smile:
  • src/tests/は同じ名前空間にしていて、composer.jsonにてPSR-4を使ってオートロードで読み込めるようにしています
composer.json
{
    ...
    "autoload": {
        ...
        "psr-4": {
            "App\\": "app/",
            "Ngmy\\Webloyer\\Webloyer\\": "packages/webloyer/src/",
            "Ngmy\\Webloyer\\IdentityAccess\\": "packages/identity_access/src/",
            "Ngmy\\Webloyer\\Common\\": "packages/common/src/"
        }
    },
    "autoload-dev": {
        ...
        "psr-4": {
            "Ngmy\\Webloyer\\Webloyer\\": "packages/webloyer/tests/",
            "Ngmy\\Webloyer\\IdentityAccess\\": "packages/identity_access/tests/",
            "Ngmy\\Webloyer\\Common\\": "packages/common/tests/"
        }
    },
    ...
}
  • 各ドメインに対して1個ずつサービスプロバイダを作成していて、src/直下に配置しています(WebloyerServiceProvider.phpIdentityAccessServiceProvider.phpCommonServiceProvider.php

Webloyerドメイン

  • Webloyerのコアドメインです。プロジェクト管理、デプロイ管理、レシピファイル管理、サーバファイル管理、ユーザ管理、設定などの機能を持ちます

Application

  • サブディレクトリ
    • 集約単位で作成しています
  • アプリケーションサービスのメソッドの入力パラメータ
    • ドメインモデルのオブジェクトを使わず基本型のみを使うようにしています
    • 『実践ドメイン駆動設計』の14章に書かれているように、コマンドオブジェクトを使うともっとすっきり書けるのかもしれません

Domain

Model

  • サブディレクトリ
    • 集約単位で作成しています
  • レイヤスーパータイプ
    • Domain/Model/直下に配置しています
    • エンティティや値オブジェクトなどの抽象クラス群、楽観的並行制御を行うためのトレイトなどがあります
  • エンティティ
    • Eloquentは使わずに、POPOで実装しています

Service

  • サブディレクトリ
    • 集約単位で作成しています

Port/Adapter

Persistence

  • 永続指向のリポジトリの実装と、Eloquentモデルを配置してします
  • インピーダンスミスマッチ
    • ライブラリなどは利用せず、リポジトリの中で地道にマッピングしています
  • リポジトリのメソッドの入力パラメータ
    • 値オブジェクト、基本型、仕様オブジェクト、クエリオブジェクトなど様々です
    • 取得件数(LIMIT)・開始位置(OFFSET)、ソート順、ページネーションなどの指定はクエリオブジェクトを使って行うようにしています(下記)
packages/webloyer/src/Application/Deployment/DeploymentService.php
public function getDeploymentsOfProjectAndPage($projectId, $page = 1, $perPage = 10)
{
    $criteria = new DeploymentCriteria($projectId);
    $order = new Order('deployments.created_at', Direction::desc());
    $pagination = new Pagination($page, $perPage);
    $queryObject = new QueryObject();
    $queryObject->setCriteria($criteria)
        ->addOrder($order)
        ->setPagination($pagination);
    return $this->deploymentRepository->deployments($queryObject);
}          

Form

  • Webのフォーム処理のためのクラス群を配置しています
  • Formクラスの責務は、バリデーションとアプリケーションサービスの呼び出しだけです
  • バリデータの実装
    • CommonドメインにIlluminate\Validation\FactoryをラップしたAbstractLaravelValidator抽象クラスを作っており、これを継承したバリデーションクラスを各フォームに対して1個ずつ作成しています

Messaging

  • Deployerをディスパッチするためのドメインサービスのキュー実装を配置しています

JsonRpc

  • JSON-RPC 2.0のWeb APIです
  • JSON-RPCサーバの実装はサードパーティ製のライブラリ1に任せているので、ここではドメインオブジェクトをJSONに変換して返す処理だけを書いています
  • レスポンスクラスを作ってドメインオブジェクトからJSONへ変換する責務を持たせています
  • 手抜きして、バリデーションやアプリケーションサービスの呼び出しをFormクラスに任せてしまっていますが、本当は個別に持った方がいいと思います

IdentityAccessドメイン

  • Webloyerの支援サブドメインです。ユーザやロールといった、認証やアクセス制御に関する機能を持ちます

Application

  • Webloyerドメインと同じ方針です

Domain/Model

  • Webloyerドメインと同じ方針です

Port/Adapter

  • Webloyerドメインと同じ方針です

Persistence/EloquentUserProvider.php

  • LaravelのAuth::user()で認証済みユーザを取得した際に、Eloquentではなくドメインオブジェクトで返ってきてほしかったので、Illuminate\Auth\EloquentUserProviderを継承したクラスを定義して、各種メソッドをオーバーライドしています

Commonドメイン

  • Webloyerの汎用サブドメインです
  • src/直下に、サードパーティ製のライブラリに置き換え可能な機能や別リポジトリに切り出し可能な機能を、機能ごとに配置しています
    • クエリオブジェクト、列挙型2、バリデーションなどがあります
  • Port/Adapter/配下には、それらの機能のインタフェースの実装を配置しています

Laravel

  • 基本app/配下のコードにはビジネスロジックを書かないようにしています(一部移行しきれずにビジネスロジックが残ってますが:sweat_smile:
  • コントローラやArtisanコマンドやジョブを、ドメインのクライアントとして利用しています
    • ドメインモデルを直接利用しないようにして、アプリケーションサービスやFormクラスのメソッドを経由するようにしています

Console

  • コマンド系
    • Webloyerのインストールコマンドや、古いデプロイのお掃除バッチがあります

Http

  • routes.php
    • コントローラやJsonRpcクラスへのルート登録を行っています
  • breadcrumbs.php
    • パンくずリストです

Controllers

  • アプリケーションサービスやFormクラスをコンストラクタインジェクションしています
  • 下記は典型的なコントローラの実装です
app/Http/Controllers/ServersController.php
class ServersController extends Controller
{
    private $serverForm;

    private $serverService;

    /**
     * Create a new controller instance.
     *
     * @param \Ngmy\Webloyer\Webloyer\Port\Adapter\Form\ServerForm\ServerForm $serverForm
     * @param \Ngmy\Webloyer\Webloyer\Application\Server\ServerService        $serverService
     * @return void
     */
    public function __construct(ServerForm $serverForm, ServerService $serverService)
    {
        $this->middleware('auth');
        $this->middleware('acl');

        $this->serverForm = $serverForm;
        $this->serverService = $serverService;
    }

    /**
     * Display a listing of the resource.
     *
     * @param \Illuminate\Http\Request $request
     * @return Response
     */
    public function index(Request $request)
    {
        $page = $request->input('page', 1);

        $perPage = 10;

        $servers = $this->serverService->getServersOfPage($page, $perPage);

        return view('servers.index')->with('servers', $servers);
    }

    ...

    /**
     * Store a newly created resource in storage.
     *
     * @param \Illuminate\Http\Request $request
     * @return Response
     */
    public function store(Request $request)
    {
        $input = $request->all();

        if ($this->serverForm->save($input)) {
            return redirect()->route('servers.index');
        } else {
            return redirect()->route('servers.create')
                ->withInput()
                ->withErrors($this->serverForm->errors());
        }
    }

    ...
}

Middleware

  • Laravelの機能を利用した認証やCSRFトークンの検証、GitHub Webhookのシークレットキーの検証などを行っています

Jobs

  • キューに投入するためのジョブ
  • Webloyerではデプロイやロールバックのジョブをキューで処理しています
  • Webフォームからデプロイやロールバックのリクエストを受けると、Formクラス(packages/webloyer/src/Port/Adapter/Form/DeploymentForm/DeploymentForm.php)がアプリケーションサービスのメソッドを呼び出して、ジョブの生成とキューへの投入を行っています
  • ジョブクラスも単なるアプリケーションサービスのクライアントになっているので、アプリケーションサービスのメソッドを呼び出すことでデプロイやロールバックを行います

Providers

  • ドメインごとにサービスプロバイダを作るようにしたのでほとんど使っていませんが、ルートとドメインオブジェクトを結合するために唯一RouteServiceProvider.phpだけ使っています
  • 使っていますが、アプリケーションサービスのメソッドの入力パラメータを基本型だけにしたので、ここでドメインオブジェクトを結合してもあまり旨味がないような気がしています。これは後で消すかもしれません

まとめ

超ざっくりとですが、Laravel + DDD + ヘキサゴナルアーキテクチャで構築したアプリケーションの構成について説明してきました。

正直なところヘキサゴナルアーキテクチャに移行したというだけで、肝心なドメインモデルがまだまだ未熟で改善の余地があるのが実情ですが、それについては今後の開発の中で育てていければいいなと思っています。

参考文献

  1. エリック・エヴァンスのドメイン駆動設計 ソフトウェアの核心にある複雑さに立ち向かう
  2. 実践ドメイン駆動設計
  3. VaughnVernon/IDDD_Samples

  1. JSON-RPCサーバはfguillot/JsonRPCというライブラリを使用しています。 

  2. 列挙型は @Hiraku さんのEnumトレイトをカスタマイズしたものを使用しています。値オブジェクトをすっきり書けて便利です。