ドメインを純粋に保つ (レガシープロジェクトの改善活動について) (1)

機能追加や修正を継続的に行なっているPHPの小規模レガシープロジェクトを、独りで運用しながら改善に取り組んでいます。何処を目指して、どうゆう方針で改善を行なっているかを纏めてみようと思いました。継続して運用していくプロジェクトであれば、新規プロジェクトでも応用できる考え方として参考になるのではないかと考えています。主に以下にリストアップした技術書を読んで、学んだことを現場で活かそうとしている活動の記録でもあります。

ドメインを純粋に保つ

結論は「ドメインを純粋に保つ」というのをスローガンにするこで、アプリケーションの健全化しようと試みています。純粋な「ビジネスロジック」を、ドメインという特別な領域に隔離しつつ、純粋な単体テストで保護しながらアプリケーションの継続的な改善活動を楽で安全にしたいと考えています。

ビジネスロジック:ビジネス概念(ドメイン・プリミティブ)

業務というものは常に改善される可能性があり、時には業務が関連する法改正などの影響を受けて変更が必要になる事もあるので、それを実現するためのシステムも業務に応じて臨機応変に変更できることが望ましいです。そのためには「ビジネスロジック」をコントロール可能な状態にしておくことが重要な課題となります。ビジネスロジックというとざっくりと論理的な処理の塊のようないわゆるトランザクションスクリプト的なものをイメージしてしまうかもしれませんが、ここではビジネス上に現れる業務的な概念を最重要視することにしています。(以下ではこれをビジネス概念と呼びます:セキュア・バイ・デザインでドメイン・プリミティブと呼ばれるものと同じですが、概念であることを重視したいため) まずは、ビジネス概念とそれ以外を認識できるようになる必要があります。

アプリケーションには様々なコードがあり、Webフレームワークを使うことで煩雑になりそうな処理は簡潔に記述できるようになっています。しかし、機能が増えたり修正を繰り返していると、多くのレガシープロジェクトでは、コントローラーやモデルが肥大化して、神クラスになりがちです。ビジネス概念を含む様々なコードがコントローラーやモデルに絡まりながら点在しているような状態だと思います。

Webアプリケーションに実装される様々なコードを眺める

まずは、Webアプリケーションを構成するコードを分類していくと、ざっくり以下のような物に分けられるのではないでしょうか?

  • 認証チェックを行なう。
  • リクエストパラメータを取得する。
  • バリデーションを行なう。
  • データベースからデータを取得する。
  • データベースのデータを更新する。
  • レスポンスを返却する。

これらは、ほとんどWebフレームワークで便利に簡潔なコードで処理することができるようになっています。上記のざっくりとした粒度の処理を考えた場合、これらは純粋なビジネス概念とは言えません。(バリデーションについてはビジネスロジック成分が多いかもしれません)

重要なもの=ビジネス概念はどこにあるかというと、もっと細かい粒度でフレームワークのいろんな処理の中に、染み込んでいることが多いです。これは言葉だけで説明するのが難しいので、コードの中からビジネス概念を探す方法をソースコードを見ながら説明してみます。

サンプルプロジェクトからビジネス概念を見付ける

サンプルプロジェクトとして仮想のIssue管理システムを用意しました。Laravelでフレームワークの機能を使って、単純にシンプルに作成されています。

初期状態のファイルツリーは、v1.0.0 から参照できます。

初期状態(v1.0.0)のソースコードからの抜粋です。実際に何年も運用されたプロジェクトでは、もっと複雑になっていると想像できますが、ドメインに持っていくビジネス概念をどのように見付けるかという観点で、ソースコードを見てみます。

コントローラー app/Http/Controllers/IssueController.php

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\StoreIssue;
use App\Http\Requests\UpdateIssue;
use App\Models\Issue;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class IssueController extends Controller {
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        $issues = DB::table('issues')->select('id', 'summary', 'deadline', 'description', 'status')->simplePaginate(3);
        return view('issue.index', ['issues' => $issues]);
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreIssue $request)
    {
        $issue = new Issue();

        $issue->summary = $request->input('summary');
        $issue->description = $request->input('description');
        $issue->deadline = $request->input('deadline');
        $issue->status = 'opened';
        $issue->save();

        return redirect('issue');
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateIssue $request, string $id)
    {
        $issue = Issue::find($id);
        $issue->summary = $request->input('summary');
        $issue->description = $request->input('description');
        $issue->deadline = $request->input('deadline');
        $issue->status = $request->input('status');
        $issue->save();

        return redirect('issue');
    }

    public function search(Request $request)
    {
        $q = $request->input('q') ?? '';
        $q = mb_convert_kana($q, 's');
        $keywords = preg_split('/\s+/', $q);
        $query = DB::table('issues');
        foreach ($keywords as $k) {
            $query->where('summary', 'like', sprintf('%%%s%%', str_replace(['%', '_'], ['\%', '\_'], $k)));
        }
        $query->select('id', 'summary', 'deadline', 'description', 'status');
        return view('issue.index', ['issues' => $query->simplePaginate(3)]);
    }
}

ビュー resources/views/issue/index.blade.php

<?php

declare(strict_types=1);

function get_issue_class(?string $deadline): string
{
    if (empty($deadline)) {
        return '';
    }
    $d = new \DateTimeImmutable($deadline);
    $today = new \DateTimeImmutable('today');
    if ($d == $today) {
        return 'today';
    } else if ($d < $today) {
        return 'dead';
    } else if ($d < $today->add(new \DateInterval('P3D'))) {
        return 'soon';
    }
    return '';
}
?>
@extends('layout.common')
@section('title', '一覧')

@section('content')
<h1>一覧表示</h1>
<a class="btn btn-primary" href="{{ route('issue.create') }}">{{ __('新規作成') }}</a>
<form method="GET" action="{{ route('issue.search') }}">
    @csrf
    <div>
        <input type="search" name="q" id="form-search" value="{{ request()->query('q') }}" class="form-control">
    </div>

    <button type="submit" class="btn btn-secondary">検索</button>
</form>

<table class="table">
    <tr>
        <th>ID</th>
        <th>件名</th>
        <th>状態</th>
        <th>期限</th>
        <th>内容</th>
        <th></th>
    </tr>
    @foreach($issues as $issue)
    <tr class="{{ get_issue_class($issue->deadline) }}">
        <td>{{$issue->id}}</td>
        <td>{{$issue->summary}}</td>
        <td>
            {{ ['opened' => '新規', 'working' => '作業中', 'done' => '完了'][$issue->status] }}
        </td>
        <td>{{$issue->deadline}}</td>
        <td>{{$issue->description}}</td>
        <td><a href="{{ route('issue.show', ['issue' => $issue->id]) }}">詳細</a></td>
    </tr>
    @endforeach
</table>
{{ $issues->links() }}
@endsection

ビュー resources/views/issue/edit.blade.php

    <div>
        状態
        <select name="status" class="form-control">
            @switch ($issue->status)
                @case('opened')
                    @foreach (['opened' => '新規', 'working' => '作業中'] as $key => $value)
                        <option value="{{$key}}" @selected(old('status') == $key)>
                            {{ $value }}
                        </option>
                    @endforeach
                    @break
                @case('working')
                    @foreach (['working' => '作業中', 'done' => '完了'] as $key => $value)
                        <option value="{{$key}}" @selected(old('status') == $key)>
                            {{ $value }}
                        </option>
                    @endforeach
                    @breakd
                @case('done')
                    @foreach (['done' => '完了'] as $key => $value)
                        <option value="{{$key}}" @selected(old('status') == $key)>
                            {{ $value }}
                        </option>
                    @endforeach
                    @break
                @endswitch
        </select>
        @error('status')
            {{ $message }}
        @enderror
    </div>

ビジネス概念の抽出

ざっと見たところ、件名(summary)、内容(description)、期限(deadline)、状態(status)というカラムを持つissuesテーブルに対するCRUD操作が実装されています。

複数のファイルの中に状態に関するコードが散見されます。Issueを新規作成するコントローラのstoreメソッドで  $issue->status = 'opened'; と値が指定されたり、一覧ページのビューで、 {{ ['opened' => '新規', 'working' => '作業中', 'done' => '完了'][$issue->status] }}  と値から表示名への変換を行なっていたり、編集ページのビューで状態(status)によって、選択可能な次の状態を制御しているワークフロー的な意味合いのコードがあります。

Issueの状態というのは、以下のビジネスルールを持つ純粋なビジネス概念として抽出できそうです。

  • Issueの状態(status)の初期値は、新規(opened)である。
  • Issueの状態の種類は、新規(opened)/作業中(working)/完了(done)がある。
  • 新規(opened)の場合は作業中(working)に変更可能であり、作業中(working)の時は完了(done)に変更可能である。

このように、ビジネス概念はデータベースの単一のカラム(時には複数のカラム)に値として保存されるものが、ソースコードの様々なところから参照されていたり、その値を元に判定処理が行なわれていることが多いです。

抽出したビジネス概念を格納するディレクトリを確保する

抽出しようとしている重要なビジネス概念に当たりを付けることができましたので、次は抽出したビジネス概念をどこに配置するのが適切かを考えます。重要なものは他の場所とは隔離された特別な箱を用意したいです。

ソースコードは十分実用的でシンプルな構造で管理したいので、『現場で役立つシステム設計の原則』で紹介されているアーキテクチャ、三層+ドメインモデルを参考にして、ドメインという特別な領域に隔離して格納することにします。三層の部分は、今回で言えばWebフレームワーク Laravel が提供しているMVC機能を呼び出している部分 (app配下)が相当します。

引用元: 私がドメイン駆動設計をやる理由(slideshare)

appディレクトリはWebフレームワークの機能を利用するコードを配置する場所と考えて、純粋なビジネス概念を隔離して格納するためのdomainディレクトリを別に新規に作成します。

composer.json

diff --git a/laravel/composer.json b/laravel/composer.json
index 4ac9c6a..50da745 100644
--- a/laravel/composer.json
+++ b/laravel/composer.json
@@ -24,7 +24,8 @@
         "psr-4": {
             "App\\": "app/",
             "Database\\Factories\\": "database/factories/",
-            "Database\\Seeders\\": "database/seeders/"
+            "Database\\Seeders\\": "database/seeders/",
+            "Domain\\": "domain/"
         }
     },
     "autoload-dev": {

大事なビジネス概念を入れる箱は用意できました。次にビジネス概念をクラスとして定義するのですが、ビジネス概念のテストコードを先に書くことで、ビジネス概念の適切な実装になるように導いていきます。テストコードを先に作成するのは、TDD(テスト駆動開発)という開発方法です。

テストコードを作成する tests/Unit/Issue/IssueStatusTest.php

テストコードを書くということは、抽出したビジネス概念をどう扱いたいかを考えることであり、設計そのものだと思います。後で作成する対象のビジネス概念クラスが、外部からどう見えて欲しいか、どう使ってもらいたいかを考えながら、テストコードを記述していきます。

<?php
namespace Tests\Unit\Issue;
use Domain\Issue\IssueStatus;
use PHPUnit\Framework\TestCase;

class IssueStatusTest extends TestCase
{
    public function test_IssueStatusの初期値は、openedであること(): void
    {
        $sut = IssueStatus::create();
        $this->assertSame('opened', $sut->value());
        $this->assertSame('新規', $sut->name());
    }

    public function test_IssueStatusを値から作る_working(): void
    {
        $sut = IssueStatus::createFromValue('working');
        $this->assertSame('working', $sut->value());
        $this->assertSame('作業中', $sut->name());
    }

    public function test_想定していない値が指定されたら例外が発生すること_other(): void
    {
        $this->expectException(\Exception::class);

        $sut = IssueStatus::createFromValue('other');
    }

    public function test_openedなら、workingに変更可能である(): void
    {
        $sut = IssueStatus::createFromValue('opened');
        $nextStatuses = $sut->getNextStatuses();
        $this->assertSame(2, count($nextStatuses));
        $this->assertSame('opened', $nextStatuses[0]->value());
        $this->assertSame('working', $nextStatuses[1]->value());
    }
    public function test_workingなら、doneに変更可能である(): void
    {
        $sut = IssueStatus::createFromValue('working');
        $nextStatuses = $sut->getNextStatuses();
        $this->assertSame(2, count($nextStatuses));
        $this->assertSame('working', $nextStatuses[0]->value());
        $this->assertSame('done', $nextStatuses[1]->value());
    }
}

ビジネス概念の実装 domain/Issue/IssueStatus.php

上のテストをパスするようビジネス概念を実装します。クラスに隠蔽するべきものは隠蔽して、公開する必要があるものは最小限になるように設計するのが良いです。実装中にインターフェースに関わる設計を変更したくなる場合もありますが、その場合は単体テストも合わせて同時に修正していくことになります。

<?php
declare(strict_types=1);
namespace Domain\Issue;

/**
 * Issueの状態
 */
final class IssueStatus
{
    const NAMES = [
        'opened' => '新規',
        'working' => '作業中',
        'done' => '完了',
    ];

    /**
     * コンストラクタ
     * 外部からは呼び出せないようにします。
     * 初期状態を作成する時は、IssueStatus::create() を使ってください。
     * 値を指定する場合は、IssueStatus::createFromValue($value) を使ってください。
     */
    private function __construct(private string $value)
    {
        if (!in_array($value, array_keys(self::NAMES))) {
            throw new \Exception('invalid value.');
        }
    }

    /**
     * IssueStatusのインスタンスを生成します。
     * @return IssueStatus 状態の初期値を返却します。
     */
    public static function create(): self
    {
        return new self('opened'); // 初期値はopened
    }

    /**
     * 値を指定してIssueStatusのインスタンスを生成します。
     * @return IssueStatus 状態を返却します。
     */
    public static function createFromValue(string $value): self
    {
        return new self($value);
    }

    /**
     * @return string 値
     */
    public function value(): string
    {
        return $this->value;
    }

    /**
     * @return string 名前
     */
    public function name(): string
    {
        return self::NAMES[$this->value];
    }

    /**
     * この状態が次に取り得る状態の一覧を返却します。
     * @return self[] 現在のIssueStatusが、次に取り得るIssueStatusの一覧
     */
    public function getNextStatuses(): array
    {
        switch ($this->value) {
            case 'opened':
                return [$this, new self('working')];
            case 'working':
                return [$this, new self('done')];
            case 'done':
                return [$this];
            default:
                throw new \Exception('invalid value.');
        }
    }
}

呼び出し部分の変更 app/Http/Controllers/IssueController.php

ドメインに追加した IssueStatus クラスを使うように修正します。状態の初期値の知識がドメインに移動できました。変更したメソッドだけを抜粋してます。


<?php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\StoreIssue;
use App\Http\Requests\UpdateIssue;
use App\Models\Issue;
use Domain\Issue\IssueStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class IssueController extends Controller
{

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreIssue $request)
    {
        $issue = new Issue();
        $status = IssueStatus::create();

        $issue->summary = $request->input('summary');
        $issue->description = $request->input('description');
        $issue->deadline = $request->input('deadline');
        $issue->status = $status->value();
        $issue->save();

        return redirect('issue');
    }

    /**
     * Show the form for editing the specified resource.
     */
    public function edit(string $id)
    {
        $issue = Issue::find($id);
        $status = IssueStatus::createFromValue($issue->status);
        return view('issue/edit', ['issue' => $issue, 'status' => $status]);
    }

}

ビュー resources/views/issue/edit.blade.php

次に取り得る状態の一覧(ワークフロー的制御)の知識が、ドメインのビジネス概念で吸収できたので、ビューのコードが単純化できています。

    <div>
        状態
        <select name="status" class="form-control">
            @foreach ($status->getNextStatuses() as $next)
                <option value="{{$next->value()}}" @selected(old('status') == $next->value())>
                    {{ $next->name() }}
                </option>
            @endforeach
        </select>
        @error('status')
            {{ $message }}
        @enderror
    </div>

ビュー resources/views/issue/index.blade.php

Issueの状態の表示名を取得する処理を、ビジネス概念のIssueStatusを使って行なうように修正しました。


<table class="table">
    <tr>
        <th>ID</th>
        <th>件名</th>
        <th>状態</th>
        <th>期限</th>
        <th>内容</th>
        <th></th>
    </tr>
    @foreach($issues as $issue)
    <tr class="{{ get_issue_class($issue->deadline) }}">
        <td>{{$issue->id}}</td>
        <td>{{$issue->summary}}</td>
        <td>
            {{ IssueStatus::createFromValue($issue->status)->name() }}
        </td>
        <td>{{$issue->deadline}}</td>
        <td>{{$issue->description}}</td>
        <td><a href="{{ route('issue.show', ['issue' => $issue->id]) }}">詳細</a></td>
    </tr>
    @endforeach
</table>

この記事での修正は、プルリクエストを作成しましたので、GitHub上で差分を確認することができます。

https://github.com/smeghead/legacy-sample/pull/1/commits/23327d923bd3f1c0e343a81b059b8587b2998860

ドメインのクラス図

php-class-diagram を使って、出力したクラス図です。現時点ではドメインには、1クラスしか存在しないので寂しいですが、複数のビジネス概念が関連を持ってきた場合は、設計上の価値のある図になっていくと思います。php-class-diagram は、PHPのソースコードからPlantUMLのクラス図スクリプトを自動生成するツールであり、ドメインの設計の可視化をするのに役立つと思います。

まとめ

このように、フレームワークの機能を利用する処理に染み込んでいるビジネス概念を抽出して、ドメインに移動して単体テストによって保護された状態を目指して日々作業を進めています。

この方針で抽出したビジネス概念であれば、外部システムやデータベースやファイルシステムにも依存しないPOPO(Plain Old PHP Object)として構成できるため、モックもDIも依存性の逆転も必要としない素のPHPUnitを使った単体テストを記述することが可能です。テストメソッド数が増えて数千件になっても、テストの実施時間は数秒で収まる範囲だと思います。ドメインがWebフレームワーク等に全く依存していない純粋な状態であれば、フレームワークの移行やバージョンアップ時にも、そのまま利用できる価値の高い安定したドメインになると考えています。

サンプルプロジェクトには、まだ抽出できそうなビジネス概念がいくつかありますので、どのようなビジネス概念として抽出できそうかを考えてみるのも良いかもしれません。続編で、続きのビジネス概念の抽出を行ないたいです。

  • IssueController.php の searchメソッドの検索条件の処理
  • index.blade.php の行の背景を決定する処理

普段「ドメインを純粋に保つ」事を重要視してレガシープロジェクトを改善している活動を説明してみましたが、こんなに時間を掛けて長い記事を書いたのは始めてでした。最後まで読んでいただきありがとうございます。フィードバック等いただけると嬉しいです。

 

続く

ドメインを純粋に保つインデックス

シリーズ化してますので、他記事もご覧ください。

4件のピンバック

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です


The reCAPTCHA verification period has expired. Please reload the page.

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください