ドメインを純粋に保つ (レガシープロジェクトの改善活動について) (3) 完結編(続きがあります)

フレームワークの機能を利用する処理に染み込んでいるビジネス概念を抽出して、ドメインに移動して単体テストによって保護された状態にするという「ドメインを純粋に保つ」活動について説明する、の第3回 完結編です。

このシリーズで改善しつづけている仮想レガシープロジェクトです。

今回は、残った処理内容からビジネス概念を抽出してみようと思います。(ビジネス概念という言葉の説明については、上の記事を参照してください)

  • IssueController.php の searchメソッドの検索条件の処理

まずは、IssueController.php のsearchメソッドの処理を見てみます。

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

該当部分の抜粋です。

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

class IssueController extends Controller
{
(略)
    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)]);
    }
}

 

検索ボタンでPostされた検索キーワードに対しての処理が行なわれています。細かく全体の処理内容を細かく見ていきます。

  1.  $q = $request->input('q') ?? ''; nullなら空文字として、クエリパラメータ qを取得します。
  2.  $q = mb_convert_kana($q, 's'); 全角スペースを半角スペースに変換します。
  3.  $keywords = preg_split('/\s+/', $q); 半角スペースで分割し配列に格納します。
  4.  $query = DB::table('issues'); フレームワークが提供するモデルを取得します。
  5. foreach ($keywords as $k) {キーワード毎にループします。
  6.     $query->where('summary', 'like', sprintf('%%%s%%', str_replace(['%', '_'], ['\%', '\_'], $k))); Like検索のエスケープ処理をしながら、検索条件を追加します。
  7. $query->select('id', 'summary', 'deadline', 'description', 'status'); 検索結果として取得するフィールドを指定します。
  8. return view('issue.index', ['issues' => $query->simplePaginate(3)]); paginatorオブジェクトをビューに渡します。

さて、今はビジネス概念を探そうとしているのですが、どの処理がビジネス概念でしょうか?

  • クエリパラメータから検索キーワードの配列を取得する処理
  • Like検索に指定するキーワードのエスケープ処理
  • 検索結果として取得するフィールドを指定する処理
  • 1ページに3件づつ表示することを指定する処理

それぞれ検討してみます。

クエリパラメータから検索キーワードの配列を取得する処理
これは、HTTP関連の入出力の処理と判断できます。しかし、スペース区切りで複数のキーワードを指定できることは、ユーザーが認識できる仕様なのでビジネス概念に含めても良いかもしれません。迷うところですが、Google等でも複数のキーワードをスペース区切りで指定できるので、一般的なWebの仕様と判断することもできます。今回はビジネス概念には含めず、独自のFormRequestクラス(SearchIssue)クラスで吸収することにします。
Like検索に指定するキーワードのエスケープ処理
これは、データベースやSQLの仕様に引き摺られて必要になっている処理です。ビジネス概念には該当しません。MySqlHelperクラスを作って吸収することにします。
検索結果として取得するフィールドを指定する処理
これは、ユーザーが認識できる仕様であるので、ビジネス概念の候補です。
1ページに3件づつ表示することを指定する処理
これも、ユーザーが認識できる仕様であるので、ビジネス概念の候補です。

ビジネス概念

検索結果として取得するフィールド(['id', 'summary', 'deadline', 'description', 'status'])と一覧の表示件数(3)という値だけという、ビジネス概念として抽出する処理がロジックと呼べないくらい小さくなってしまいました。

状況や考え方によって、YAGNI原則に従ってこれらをドメインに追加する必要は無いと判断する場合もあると思います。しかし、ここでは将来発生しそうな機能追加等を予想することで、これらの値を何というビジネス概念として見做せるかを特定したいです。以下のような機能追加や要望が、予想できます。

  • 一覧の表示件数を10件に増やして欲しい
  • ユーザーが一覧の表示件数を選択できるようにして欲しい
  • 一覧に新規項目としてカテゴリーを追加してほしい。
  • ユーザーが一覧に表示する項目をカスタマイズできるようにして欲しい

ここまでの要望が出てくるかどうかはわからないのですが、検索結果として取得するフィールドと一覧の表示件数は、「課題一覧の設定(IssueListSettings)」というビジネス概念として抽出しておくのが良さそうです。

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

課題一覧ページのみに影響するビジネス概念なので、Listというネームスペースに Settings.php を定義することにしました。今回はテストも実装も簡素です。

<?php

namespace Tests\Unit\Issue\List;

use Domain\Issue\List\Settings;
use PHPUnit\Framework\TestCase;

class SettingsTest extends TestCase
{
    public function test_一覧に表示する項目(): void
    {
        $sut = new Settings();
        $this->assertSame(['id', 'summary', 'deadline', 'description', 'status'], $sut->getListFields());
    }

    public function test_一覧に表示する件数(): void
    {
        $sut = new Settings();
        $this->assertSame(3, $sut->getCountParPage());
    }
}

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

<?php

declare(strict_types=1);

namespace Domain\Issue\List;

/**
 * 課題一覧の設定
 */
final class Settings {
    public function __construct()
    {
    }

    /**
     * @return string[] 一覧の項目一覧
     */
    public function getListFields(): array
    {
        return ['id', 'summary', 'deadline', 'description', 'status'];
    }

    public function getCountParPage(): int
    {
        return 3;
    }
}

検索用FormRequest app\Http\Requests\SearchIssue.php

ビジネス概念としてドメインに入れなかった処理も、より適切な場所に移動します。コントローラーで実行していたキーワードを取得する処理を、getKeywordsメソッドで実行するようにしました。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class SearchIssue extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        return [
        ];
    }

    public function getKeywords(): array
    {
        $q = $this->input('q') ?? '';
        $q = mb_convert_kana($q, 's');
        return array_filter(preg_split('/\s+/', $q));
    }
}

Helper追加 app\Helpers\MySqlHelper.php

こちらもビジネス概念としてドメインに入れなかった処理を、より適切な場所に移動します。Likeによる検索条件のエスケープを行なうヘルパーを作成します。

<?php
declare(strict_types=1);

namespace App\Helpers;
 
final class MySqlHelper
{
    public static function escapeLikeParameter(string $str): string
    {
        return str_replace(['%', '_'], ['\%', '\_'], $str);
    }
}

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

作成したビジネス概念(List\Settings)やFormRequestクラス(SearchIssue)、Helperクラス(MySqlHelper)を使うようにコントローラーを修正しました。

一覧表示のindexメソッドと検索のsearchメソッドに変更がありました。雑に記述されていた雑多なロジックが、あるべき場所に移動されて、十分簡潔になったのではないでしょうか?

<?php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Helpers\MySqlHelper;
use App\Http\Requests\SearchIssue;
use App\Http\Requests\StoreIssue;
use App\Http\Requests\UpdateIssue;
use App\Models\Issue;
use Domain\Issue\IssueStatus;
use Domain\Issue\List\Settings;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class IssueController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        $settings = new Settings();
        $issues = DB::table('issues')->select(...$settings->getListFields())->simplePaginate($settings->getCountParPage());
        return view('issue.index', ['issues' => $issues]);
    }

    public function search(SearchIssue $request)
    {
        $query = DB::table('issues');
        foreach ($request->getKeywords() as $k) {
            $query->where('summary', 'like', sprintf('%%%s%%', MySqlHelper::escapeLikeParameter($k)));
        }
        $settings = new Settings();
        $query->select(...$settings->getListFields());
        return view('issue.index', ['issues' => $query->simplePaginate($settings->getCountParPage())]);
    }
}

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

https://github.com/smeghead/legacy-sample/pull/3/files

ドメインのクラス図

php-class-diagram を使って、出力したクラス図です。

まとめ

今回の重要な点としては、抽出したものを全てドメインに入れるのではないという点です。どれがビジネス概念であるかビジネス概念ではないかを判断することは、状況によっても結果は変わると思います。大切なのは、ドメインに含めるに値する程重要なビジネス概念かどうかを、慎重に検討するということです。そのことが「ドメインを純粋に保つ」事に貢献します。

3つのブログ記事に渡って、テスト無しの既存のレガシープロジェクトに対して、純粋なビジネス概念をドメインという特別な領域に隔離しつつ、純粋な単体テストで保護しながらアプリケーションの継続的な改善活動を行なっている日頃の活動で、自分が何を考えてどのような方針で進めているかを説明してみました。この方針であれば、大規模なアーキテクチャ変更無しにプロジェクトの健全化に取り組めると思いますので、是非試してみてください。フィードバック・感想など教えていただけると嬉しいです。

前回までの投稿後に、Twitterで感想を頂けた事に助けられ、書き終えることができました。武田さん、おぎさん、ありがとうございます。

 

最後まで読んでいただきありがとうございます。フィードバック等いただけると嬉しいです。

 

追記(2023-05-20)

完結編のあと、補足したくなってあとがき記事を追加しました。

あとがき

 

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

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

1件のピンバック

コメントする

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


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

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