ドメインを純粋に保つ (レガシープロジェクトの改善活動について) (5) CSVダウンロード機能
ドメインを純粋に保つシリーズは 3回に加えてあとがきを書いた時点で一度終了していたのですが、ビジネス概念の抽出というテーマで書けることを思いついたらそのタイミングで不定期に記事を書いていくつもりです。
レガシーサンプルのプロジェクトにCSVダウンロード機能を追加しましたので、CSVダウンロード機能からビジネス概念を抽出してみたいと思います。
今回の開始時点のソースコードに、タグ(v2.0.0)を打っておきました。
https://github.com/smeghead/legacy-sample/tree/v2.0.0
CSVダウンロード機能のビジネス概念
今回もビジネス概念を探してドメインに隔離してテストで保護することを目的としていますので、まずはCSVダウンロード機能の処理全体を把握することから始めます。
※ビジネス概念という言葉の定義については、『ドメインを純粋に保つ』の初回を参照してください。
CSVダウンロード処理の分析 app/Http/Controllers/IssueController.php
現状のCSVダウンロード機能の実装の確認します。コントローラーのメソッドで完結しています。 テストで保護された純粋なビジネス概念を抽出することを目的に処理内容を把握していきます。
<?php
public function downloadCsv(Request $request)
{
$now = new \DateTimeImmutable('now');
return response()->streamDownload(function () {
$file = new \SplFileObject('php://output', 'w');
$file->fputcsv([
'ID',
'件名',
'説明',
'期限',
'状態',
'作成日時',
'更新日時',
]);
$issues = Issue::orderBy('id', 'desc');
foreach ($issues->cursor() as $issue) {
$file->fputcsv([
$issue->id,
$issue->summary,
$issue->description,
$issue->deadline,
IssueStatus::createFromValue($issue->status)->name(),
$issue->created_at,
$issue->updated_at,
]);
}
}, sprintf('issues-%s.csv', $now->format('YmdHis')), [
'Content-type' => 'text/csv',
'Content-Disposition' => 'attachment;',
]);
}
streamDownload はディスクに書き込まずにダウンロード可能なレスポンスへ変換するためのメソッドです。 引数は、コールバック、ダウンロードファイル名、ヘッダ配列です。
https://readouble.com/laravel/8.x/ja/responses.html#streamed-downloads
SplFileObject クラスは、CSVファイル形式で出力するために使用しています。 コンストラクタで標準出力を指定しているので、fputcsv メソッドはCSV形式で標準出力に出力することになります。
SplFileObject クラスはファイルのためのオブジェクト指向のインターフェイスを提供します。
上記を踏まえてdownloadCsvメソッドを見てみると以下のように見えてきます。
緑に色付けした部分は、ストリームダウンロードやHTTPヘッダ指定など、HTTP関連の処理です。
黄色に色付けした部分は、CSV出力に関する処理です。
赤に色付けした部分は、ORMを使ったデータ取得関連処理です。
青に色付けした部分は、ダウンロードされるCSVファイルの形式に関連した処理です。
ビジネス概念の抽出
青に色付けされたところが、ビジネス概念の候補です。
- CSVファイルのヘッダの定義
- ORMの1つのオブジェクトからCSVに出力する1行の項目の配列に変換する処理
- ダウンロードされるCSVファイルのファイル名
これらを纏めるビジネス概念の名前を決める必要があります。課題のCSVダウンロード時の『CSV形式』としてみます。3つ目のファイル名については、同じビジネス概念に纏めるのが適切かなとちょっと考えましたが、CSVの形式として仕様書に書かれる内容に当然ダウンロードされるファイル名も定義されるだろうという意味では、CSVの形式の仕様として一緒に扱うのが妥当という判断をしました。
ビジネス概念 CSV形式 CsvFormat
クラスのメソッドを考える
CSV形式 CsvFormat
としては、以下のメソッドがあれば、要件を満たせそうです。
- getHeaders(): string[] ヘッダーを返却します。
- convertRow(array $row): array 1行の課題レコードから、CSVに出力する項目配列に変換を行ないます。
- getDownloadFilename(): string ダウンロードファイル名を返却します。
convertRow
の引数の型は、App::Models::Issue
を直接渡すのではなくarray
としました。このビジネス概念は純粋なままドメインに隔離しようとしているクラスなので、外界の関心事(モデルの型)に依存させたくはなかったためです。この選択のデメリットとしては、型が緩い方に寄せてしまっているので、意図しない使われ方をする危険が増えることです。(後程対策について言及します)
テストコードを作成する tests/Unit/Issue/List/CsvFormatTest.php
いつもの流れで、まずテストコードを用意します。課題一覧にあるCSVダウンロード機能に関するビジネス概念なので、List
というネームスペースに CsvFormat.php
を定義することにしました。
<?php
namespace Tests\Unit\Issue\List;
use Domain\Issue\List\CsvFormat;
use PHPUnit\Framework\TestCase;
class CsvFormatTest extends TestCase
{
public function testGetHeaders_ヘッダ取得(): void
{
$sut = new CsvFormat(new \DateTimeImmutable('2023-07-12 01:01:02'));
$this->assertSame(['ID', '件名', '説明', '期限', '状態', '作成日時', '更新日時'], $sut->getHeaders());
}
public function testConvertRow_不正な空配列を入力(): void
{
$this->expectException(\Exception::class);
$sut = new CsvFormat(new \DateTimeImmutable('2023-07-12 01:01:02'));
$issue = [];
$sut->convertRow($issue);
}
public function testConvertRow_不正な配列を入力(): void
{
$this->expectException(\Exception::class);
$sut = new CsvFormat(new \DateTimeImmutable('2023-07-12 01:01:02'));
$issue = [
'id' => '123',
'summary' => 'test',
'description' => 'description.',
];
$sut->convertRow($issue);
}
public function testConvertRow_正常な入力(): void
{
$sut = new CsvFormat(new \DateTimeImmutable('2023-07-12 01:01:02'));
$issue = [
'id' => '123',
'summary' => 'test',
'description' => 'description.',
'deadline' => '',
'status' => 'opened',
'created_at' => '2023-07-01 01:10:10',
'updated_at' => '2023-07-01 01:11:10',
];
$this->assertSame([
'123',
'test',
'description.',
'',
'新規',
'2023-07-01 01:10:10',
'2023-07-01 01:11:10',
], $sut->convertRow($issue));
}
public function testGetDownloadFilename_正常な入力(): void
{
$sut = new CsvFormat(new \DateTimeImmutable('2023-07-12 01:01:02'));
$this->assertSame('issues-20230712010102.csv', $sut->getDownloadFilename());
}
}
先程、convertRow
の引数がarray
になることで、意図しない使われ方をする危険が増えるデメリットがあると言ったのですが、その危険をできるだけ軽減するために、必要なキーが存在しない配列が渡された場合には例外がスローされることを確認するテストを追加しています。こうすることによって間違った使い方をされないように防御をしています。(危険を軽減するための方法は、これ以外にも考えられます。例えば引数用にDTOクラスを受けとるように定義することにより安全性を確保する等)
ビジネス概念の実装 domain/Issue/List/CsvFormat.php
<?php
declare(strict_types=1);
namespace Domain\Issue\List;
use Domain\Issue\IssueStatus;
/**
* 課題のダウンロード機能のCSV形式
*/
final class CsvFormat {
private \DateTimeImmutable $now;
public function __construct(\DateTimeImmutable $now)
{
$this->now = $now;
}
/**
* @return string[] CSVのヘッダー(項目一覧)
*/
public function getHeaders(): array
{
return ['ID', '件名', '説明', '期限', '状態', '作成日時', '更新日時'];
}
/**
* 課題の1レコードの連想配列をCSVの1行のデータに変換します。
* @param array $issue 課題の1レコードの連想配列
* @return string[] CSVの1行のデータ
*/
public function convertRow(array $issue): array
{
$validFields = [
'id',
'summary',
'description',
'deadline',
'status',
'created_at',
'updated_at',
];
foreach ($validFields as $f) {
if ( ! array_key_exists($f, $issue)) {
throw new \Exception(sprintf('invalid issue row. %s', $f));
}
}
return [
$issue['id'],
$issue['summary'],
$issue['description'],
$issue['deadline'],
IssueStatus::createFromValue($issue['status'])->name(),
$issue['created_at'],
$issue['updated_at'],
];
}
/**
* ダウンロードされるCSVファイルのファイル名
* @return string CSVファイルのファイル名
*/
public function getDownloadFilename(): string
{
return sprintf('issues-%s.csv', $this->now->format('YmdHis'));
}
}
コントローラー app\Http\Controllers\IssueController.php
作成したビジネス概念(List\CsvFormat
)を使うようにコントローラーを修正しました。
一覧表示のdownloadCsv
メソッドに変更がありました。CSV形式に関する仕様は、ビジネス概念(CsvFormat
)に抽出されたため、コントローラーの処理はほぼフレームワークが提供する機能の呼び出しとCSVを扱うためのメソッド呼び出しだけとなりました。
<?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\CsvFormat;
use Domain\Issue\List\Settings;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class IssueController extends Controller
{
public function downloadCsv(Request $request)
{
$now = new \DateTimeImmutable('now');
$csvFormat = new CsvFormat($now);
return response()->streamDownload(function () use ($csvFormat) {
$file = new \SplFileObject('php://output', 'w');
$file->fputcsv($csvFormat->getHeaders());
$issues = Issue::orderBy('id', 'desc');
foreach ($issues->cursor() as $issue) {
$file->fputcsv($csvFormat->convertRow($issue->toArray()));
}
}, $csvFormat->getDownloadFilename(), [
'Content-type' => 'text/csv',
'Content-Disposition' => 'attachment;',
]);
}
}
この記事での修正は、プルリクエストを作成しましたので、GitHub上で差分を確認することができます。
https://github.com/smeghead/legacy-sample/pull/4/files
ドメインのクラス図
php-class-diagram を使って、出力したクラス図です。
まとめ
基本的には、前の記事と同じ方針でビジネス概念を抽出したのですが、ファットコントローラーに染み込んだ処理内容を副作用の無いビジネス概念の関数(convertRow
)として抽出するというところは、レガシーコードを整理するための常套手段と考えています。副作用の無い関数として抽出したビジネス概念なので、モックを使わずに単純に単体テストを書けることも大きな利点です。CSV形式の仕様変更が入った際には、テストで保護されているので安心して修正を行なうことができそうです。今後発生する全ての仕様変更に対してビジネス概念の変更で対応できる訳ではないのですが、ビジネス概念の単体テストが次の修正に役に立つと思います。単体テストで保護されたビジネス概念が増えてくると、仕様変更やバグ修正が楽で安全になってきます。
最後まで読んでいただきありがとうございます。フィードバック等いただけると嬉しいです。
ドメインを純粋に保つインデックス
シリーズ化してますので、他記事もご覧ください。
1件のピンバック
ドメインを純粋に保つ (レガシープロジェクトの改善活動について) (4) あとがき | 週記くらい