概要
Laravelを使って、CSVファイルを出力するサンプルを作成します。
背景
データベースやファイルアクセスをしないテストの方法、インターフェースやジェネレータを使ったコードの書き方等、個別に詳しく書かれた記事はあれど実際使うにはどう書き始めたらいいのかベストプラクティスがわかりませんでした。
日々の業務や副業、勉強会を通じてようやく自分の中で少しずつイメージができてきたので現時点で最高のアウトプットをしていこうと思いました。
目的
この記事ではテストを意識したコードかつ、シンプルに書くことを目標にしてます。
より良いコードにしたいのでアドバイスもらえたらうれしいです。
説明不足なところがあったら補足を追記するので気軽に質問等もいただけたら嬉しいです。
環境
- PHP 7.4.1
- Laravel 6.14.0
- MySQL 8.0.19
サンプルコード
https://github.com/ucan-lab/learn-laravel-export-csv
$git clone git@github.com:ucan-lab/learn-laravel-export-csv.git
$cd learn-laravel-export-csv
$make install$make app
$php artisan migrate:fresh --seed#csv出力コマンド
$php artisan export:user
#テスト実行
$./vendor/bin/phpunit
今回のゴール
users
テーブルに入ってるデータをcsv出力する処理を作るところまでゴールとします。
名前,メールアドレス,作成日,更新日
PROF. RACHELLE KUHIC I,leola.rath@example.com,2020-02-01 23:59:59,2020-02-01 23:59:59
JAYLON WOLF,osinski.fernando@example.net,2020-02-01 23:59:59,2020-02-01 23:59:59
LELAND DECKOW,bokon@example.org,2020-02-01 23:59:59,2020-02-01 23:59:59
名前の列は大文字に変換して出力する仕様です。
ベースのコード
環境はこちらのコードを丸コピしてます。
追加したファイル一覧
https://github.com/ucan-lab/learn-laravel-export-csv/pull/1
src/app/Console/Commands/ExportUserCommand.php
src/app/Domain/UserRow.php
src/app/Domain/UserRowHeader.php
src/app/Http/Controllers/Auth/RegisterController.php
src/app/Infrastructure/Adapter/DbUserRepository.php
src/app/Infrastructure/Adapter/FileUserCsvExport.php
src/app/Infrastructure/Adapter/InMemoryUserCsvExport.php
src/app/Infrastructure/Adapter/InMemoryUserRepository.php
src/app/Infrastructure/Eloquent/User.php
src/app/Infrastructure/Port/Export.php
src/app/Infrastructure/Port/UserRepository.php
src/app/Providers/AppServiceProvider.php
src/app/UseCase/UserCsvExportUseCase.php
src/database/factories/UserFactory.php
src/database/seeds/DatabaseSeeder.php
src/database/seeds/UsersTableSeeder.php
src/tests/Unit/UserCsvExportUseCaseTest.php
マイグレーション(テーブル)の確認
今回はLaravelが元々用意してくれている users テーブルをそのまま使います。
<?phpuseIlluminate\Database\Migrations\Migration;useIlluminate\Database\Schema\Blueprint;useIlluminate\Support\Facades\Schema;classCreateUsersTableextendsMigration{/**
* Run the migrations.
*
* @return void
*/publicfunctionup(){Schema::create('users',function(Blueprint$table){$table->bigIncrements('id');$table->string('name');$table->string('email')->unique();$table->timestamp('email_verified_at')->nullable();$table->string('password');$table->rememberToken();$table->timestamps();});}/**
* Reverse the migrations.
*
* @return void
*/publicfunctiondown(){Schema::dropIfExists('users');}}
MySQLのテーブル定義も確認しておきます。
$make mysql
mysql>desc users;+-------------------+-----------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------------------+-----------------+------+-----+---------+----------------+
| id | bigint unsigned | NO | PRI | NULL | auto_increment |
| name | varchar(255) | NO | | NULL | |
| email | varchar(255) | NO | UNI | NULL | |
| email_verified_at | timestamp | YES | | NULL | |
| password | varchar(255) | NO | | NULL | |
| remember_token | varchar(100) | YES | | NULL | |
| created_at | timestamp | YES | | NULL | |
| updated_at | timestamp | YES | | NULL | |
+-------------------+-----------------+------+-----+---------+----------------+
app/User.php => app/Infrastructure/Eloquent/User.php
LaravelのEloquentモデルはデフォルトだとapp直下に配置されます。app/Infrastructure/Eloquent/User.php
へ移動します。
依存するファイルも合わせて修正します。詳細はコミットログ参照
シーダーの作成
Laravelには、シーディング、モデルファクトリ、Fakerが用意されており、ダミーデータを簡単に作成できます。
$php artisan make:seeder UsersTableSeeder
src/database/seeds/UsersTableSeeder.php
シーダーのひな形クラスを作ってくれるので下記のように追記します。
<?phpdeclare(strict_types=1);useApp\Infrastructure\Eloquent\User;useIlluminate\Database\Seeder;classUsersTableSeederextendsSeeder{/**
* Run the database seeds.
*
* @return void
*/publicfunctionrun(){factory(User::class,3)->create();}}
上記の用にシーダーを追加するだけで、テストデータを3件作成してくれます。
Userモデルクラスの各プロパティにどんなデータが入るかの定義はモデルファクトリで定義されてます。
<?phpdeclare(strict_types=1);/** @var \Illuminate\Database\Eloquent\Factory $factory */useApp\Infrastructure\Eloquent\User;useFaker\GeneratorasFaker;useIlluminate\Support\Str;/*
|--------------------------------------------------------------------------
| Model Factories
|--------------------------------------------------------------------------
|
| This directory should contain each of the model factory definitions for
| your application. Factories provide a convenient way to generate new
| model instances for testing / seeding your application's database.
|
*/$factory->define(User::class,function(Faker$faker){return['name'=>$faker->name,'email'=>$faker->unique()->safeEmail,'email_verified_at'=>now(),'password'=>'$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',// password'remember_token'=>Str::random(10),];});
元々用意されている src/database/seeds/DatabaseSeeder.php
で UsersTableSeeder
を呼び出す記述を追記します。
<?phpdeclare(strict_types=1);useIlluminate\Database\Seeder;classDatabaseSeederextendsSeeder{/**
* Seed the application's database.
*
* @return void
*/publicfunctionrun(){$this->call(UsersTableSeeder::class);}}
UserRow, UserRowHeader ドメインを定義
この辺りから本題です。
App\Domain\UserRowHeader
<?phpdeclare(strict_types=1);namespaceApp\Domain;finalclassUserRowHeader{privateconstEOF="\n";privateconstHEADER=['名前','メールアドレス','作成日','更新日',];publicstaticfunctiontoCsv():string{returnimplode(',',self::HEADER).self::EOF;}}
UserRowHeader ドメインクラスではCSVのヘッダー行となる1行目の定義をしてます。
App\Domain\UserRow
<?phpdeclare(strict_types=1);namespaceApp\Domain;useCarbon\Carbon;finalclassUserRow{privateconstEOF="\n";privateconstDATE_FORMAT='Y-m-d H:i:s';privatestring$name;privatestring$email;privateCarbon$createdAt;privateCarbon$updatedAt;publicfunction__construct(string$name,string$email,Carbon$createdAt,Carbon$updatedAt){$this->name=$name;$this->email=$email;$this->createdAt=$createdAt;$this->updatedAt=$updatedAt;}/**
* @return string
*/publicfunctiontoCsv():string{returnimplode(',',$this->toArray()).self::EOF;}/**
* @return array
*/privatefunctiontoArray():array{return[$this->getName(),$this->email,$this->createdAt->format(self::DATE_FORMAT),$this->updatedAt->format(self::DATE_FORMAT),];}/**
* @return string
*/privatefunctiongetName():string{returnstrtoupper($this->name);}}
UserRow ドメインクラスではCSVの1行の定義をしてます。
ユーザー名は大文字や日付のフォーマット等の業務ロジックはここにまとめます。
UserCsvExportUseCase を定義
<?phpdeclare(strict_types=1);namespaceApp\Infrastructure\Port;interfaceExport{publicfunctionprepare(string$header):void;publicfunctionwrite(string$row):void;publicfunctiondisorganize():void;}
Export インターフェースを継承するクラスは prepare(前処理)、write(書き込み)、disorganize(後処理)のメソッドを契約します。
<?phpdeclare(strict_types=1);namespaceApp\Infrastructure\Port;useGenerator;interfaceUserRepository{publicfunctionfindAll():Generator;}
UserRepository インターフェースを継承するクラスはfindAll(全件取得)のメソッドを契約します。
<?phpdeclare(strict_types=1);namespaceApp\UseCase;useApp\Domain\UserRow;useApp\Domain\UserRowHeader;useApp\Infrastructure\Port\Export;useApp\Infrastructure\Port\UserRepository;finalclassUserCsvExportUseCase{/**
* @var UserRepository
*/privateUserRepository$repository;/**
* @var Export
*/privateExport$export;/**
* @param UserRepository $repository
* @param Export $export
*/publicfunction__construct(UserRepository$repository,Export$export){$this->repository=$repository;$this->export=$export;}/**
* @return void
*/publicfunctionhandle():void{$this->export->prepare(UserRowHeader::toCsv());/** @var UserRow $row */foreach($this->repository->findAll()as$row){$this->export->write($row->toCsv());}$this->export->disorganize();}}
UserCsvExportUseCase ユースケースクラスはUserRepositoryとExportインターフェースに依存します。
前処理して、全件取得して、書き込みして、後処理して終わるシンプルな作りにできました。
各インターフェースを契約するクラスの中身はあとで書きます。
ユースケースのテストを書く
テストを書く際、データベースやファイルに直接読み書きするようなテストを書いてしまうと
最初は問題ないですが、テストが増えるにつれてテストの実行速度がどんどん落ちてしまいます。
そのため、テストを書く際はデータベースやファイルアクセスが発生しないようメモリ内で良い感じのテストを書きます。
InMemoryUserCsvExport
<?phpdeclare(strict_types=1);namespaceApp\Infrastructure\Adapter;useApp\Infrastructure\Port\Export;finalclassInMemoryUserCsvExportimplementsExport{/**
* @var string
*/publicstring$file;/**
* @param string $header
*/publicfunctionprepare(string$header):void{$this->file=$header;}/**
* @param string $row
*/publicfunctionwrite(string$row):void{$this->file.=$row;}/**
* @return void
*/publicfunctiondisorganize():void{}}
Export
インターフェースを契約したInMemoryUserCsvExport
クラスを実装します。
やってることは簡単で、prepare
メソッドで$file
プロパティに文字列を入れてwrite
メソッドが呼ばれたらどんどん追記する形です。
実際にファイルアクセスする場合はdisorganize
でfclose
等の処理を入れますが、ファイルアクセスしないので関数だけ定義してます。
InMemoryUserRepository
<?phpdeclare(strict_types=1);namespaceApp\Infrastructure\Adapter;useApp\Domain\UserRow;useApp\Infrastructure\Eloquent\User;useApp\Infrastructure\Port\UserRepository;useGenerator;finalclassInMemoryUserRepositoryimplementsUserRepository{privatearray$usersAttributes;/**
* @param array $users
*/publicfunction__construct(array$users){$this->usersAttributes=$users;}/**
* @return Generator
*/publicfunctionfindAll():Generator{foreach($this->usersAttributesas$userAttributes){yield$this->makeUserRow(factory(User::class)->make($userAttributes));}}/**
* @param User $user
* @return UserRow
*/privatefunctionmakeUserRow(User$user):UserRow{returnnewUserRow($user->name,$user->email,$user->created_at,$user->updated_at);}}
補足: ジェネレータ
findAll
の戻り値の型としてGenerator
オブジェクトを返すと呼び出した側はforeach
を使って順に呼び出すことができます。return
ではなくyield
を指定します。yield
はUserRow
のインスタンスを返してます。
// UserCsvExportUseCase で findAll を foreach でループ処理できます。foreach($this->repository->findAll()as$row){$this->export->write($row->toCsv());}
ジェネレータのメリットはforeach
でループ処理するために巨大な配列を持つ必要がなく1件処理が終わったらメモリを解放して次の処理を実行してくれるので、バッチ処理等のメモリをたくさん使いそうな場合に効果を発揮します。
UserCsvExportUseCaseTest
先ほど作成したInMemoryUserRepository
とInMemoryUserCsvExport
を使ってテストコードを書きます。
<?phpdeclare(strict_types=1);namespaceTests\Unit;useApp\Infrastructure\Adapter\InMemoryUserCsvExport;useApp\Infrastructure\Adapter\InMemoryUserRepository;useApp\UseCase\UserCsvExportUseCase;useTests\TestCase;finalclassUserCsvExportUseCaseTestextendsTestCase{/**
* @param array $users
* @param string $expectedCsv
* @dataProvider dataResolve
*/publicfunctiontestResolve(array$users,string$expectedCsv):void{$repository=newInMemoryUserRepository($users);$export=newInMemoryUserCsvExport();$useCase=newUserCsvExportUseCase($repository,$export);$useCase->handle();$this->assertEquals($expectedCsv,$export->file);}/**
* @return array
*/publicfunctiondataResolve():array{return['正常3件'=>$this->case正常3件(),'正常0件'=>$this->case正常0件(),];}/**
* @return array
*/publicfunctioncase正常3件():array{$usersAttributes=[['name'=>'yamada','email'=>'yamada@example.com','created_at'=>'2020-01-01 00:00:00','updated_at'=>'2020-01-01 00:00:00'],['name'=>'suzuki','email'=>'suzuki@example.com','created_at'=>'2020-01-01 00:00:00','updated_at'=>'2020-01-01 00:00:00'],['name'=>'tanaka','email'=>'tanaka@example.com','created_at'=>'2020-01-01 00:00:00','updated_at'=>'2020-01-01 00:00:00'],];$expectedCsv=<<<EOT名前,メールアドレス,作成日,更新日YAMADA,yamada@example.com,2020-01-0100:00:00,2020-01-0100:00:00SUZUKI,suzuki@example.com,2020-01-0100:00:00,2020-01-0100:00:00TANAKA,tanaka@example.com,2020-01-0100:00:00,2020-01-0100:00:00EOT;return[$usersAttributes,$expectedCsv,];}/**
* @return array
*/publicfunctioncase正常0件():array{$usersAttributes=[];$expectedCsv=<<<EOT名前,メールアドレス,作成日,更新日EOT;return[$usersAttributes,$expectedCsv,];}}
想定している $expectedCsv
の値とユースケースを実行して作成された値 $export->file
が一致すればokです。
補足: dataProvider
PHPUnitのdataProviderについて補足です。
PHPUnitを実行する際に --debug
オプションを付けると詳細ログが見れます。dataProvider
で作った引数もログに出てくるので分かりやすくなります。
データプロバイダ | phpunit.readthedocs.io
$./vendor/bin/phpunit --debugPHPUnit 8.5.2 by Sebastian Bergmann and contributors.
Test 'Tests\Unit\ExampleTest::testBasicTest' started
Test 'Tests\Unit\ExampleTest::testBasicTest' ended
Test 'Tests\Unit\UserCsvExportUseCaseTest::testResolve with data set "正常3件" (array(array('yamada', 'yamada@example.com', '2020-01-01 00:00:00', '2020-01-01 00:00:00'), array('suzuki', 'suzuki@example.com', '2020-01-01 00:00:00', '2020-01-01 00:00:00'), array('tanaka', 'tanaka@example.com', '2020-01-01 00:00:00', '2020-01-01 00:00:00')), '名前,メールアドレス,作成日,更新日\nYAMADA,ya...0:00\n')' started
Test 'Tests\Unit\UserCsvExportUseCaseTest::testResolve with data set "正常3件" (array(array('yamada', 'yamada@example.com', '2020-01-01 00:00:00', '2020-01-01 00:00:00'), array('suzuki', 'suzuki@example.com', '2020-01-01 00:00:00', '2020-01-01 00:00:00'), array('tanaka', 'tanaka@example.com', '2020-01-01 00:00:00', '2020-01-01 00:00:00')), '名前,メールアドレス,作成日,更新日\nYAMADA,ya...0:00\n')' ended
Test 'Tests\Unit\UserCsvExportUseCaseTest::testResolve with data set "正常0件" (array(), '名前,メールアドレス,作成日,更新日\n')' started
Test 'Tests\Unit\UserCsvExportUseCaseTest::testResolve with data set "正常0件" (array(), '名前,メールアドレス,作成日,更新日\n')' ended
Test 'Tests\Feature\ExampleTest::testBasicTest' started
Test 'Tests\Feature\ExampleTest::testBasicTest' ended
Time: 3.04 seconds, Memory: 20.00 MB
OK (4 tests, 4 assertions)
CSVの出力処理を実装する
テストコードが書けたところで、実際にデータベースから取得してCSVファイルを出力する処理を実装します。
DbUserRepository
<?phpdeclare(strict_types=1);namespaceApp\Infrastructure\Adapter;useApp\Domain\UserRow;useApp\Infrastructure\Eloquent\User;useApp\Infrastructure\Port\UserRepository;useGenerator;finalclassDbUserRepositoryimplementsUserRepository{/**
* @return Generator
*/publicfunctionfindAll():Generator{/** @var User $user */foreach(User::query()->cursor()as$user){yieldnewUserRow($user->name,$user->email,$user->created_at,$user->updated_at);}}}
UserRepositoryを契約したDbUserRepositoryクラスです。
補足: User::query()->cursor()
cursor()
を使うとPDOStatement::fetch
結果セットから1行ずつ取得できます。 cursor()
の返り値もジェネレータオブジェクトになります。
User::query()->cursor()
ではなく User::all()
も動作するかと思いますが、一度に大量のデータを取得するのでデータ件数によってはメモリオーバーになってしまう懸念があります。
FileUserCsvExport
<?phpdeclare(strict_types=1);namespaceApp\Infrastructure\Adapter;useApp\Infrastructure\Port\Export;finalclassFileUserCsvExportimplementsExport{/**
* @var string
*/privatestring$streamFilePath;/**
* @var resource
*/private$handle;/**
* @param string $header
* @return void
*/publicfunctionprepare(string$header):void{$this->streamFilePath=$this->makeStreamFile();$this->handle=fopen($this->streamFilePath,'wb+');$this->write($header);}/**
* @param string $row
* @return void
*/publicfunctionwrite(string$row):void{fwrite($this->handle,$row);}/**
* @return void
*/publicfunctiondisorganize():void{fclose($this->handle);// 後処理 配置したい場所へコピーする等dump(file_get_contents($this->streamFilePath));unlink($this->streamFilePath);}/**
* @return string
*/privatefunctionmakeStreamFile():string{returntempnam(sys_get_temp_dir(),config('app.name'));}}
Exportを契約したFileUserCsvExportクラスです。
ExportUserCommand
<?phpdeclare(strict_types=1);namespaceApp\Console\Commands;useApp\UseCase\UserCsvExportUseCase;useIlluminate\Console\Command;classExportUserCommandextendsCommand{/**
* The name and signature of the console command.
*
* @var string
*/protected$signature='export:user';/**
* The console command description.
*
* @var string
*/protected$description='export user data.';/**
* @var UserCsvExportUseCase
*/privateUserCsvExportUseCase$useCase;/**
* ExportUserCommand constructor.
* @param UserCsvExportUseCase $useCase
*/publicfunction__construct(UserCsvExportUseCase$useCase){parent::__construct();$this->useCase=$useCase;}/**
* @return void
*/publicfunctionhandle():void{$this->useCase->handle();}}
LaravelにはArtisanコンソールというコマンドラインインターフェイスが用意されてます。
コマンドクラスを作るだけで簡単に自作コマンドを追加できます。
ExportUserCommandでやってることは、UserCsvExportUseCaseのインスタンスを受け取って、handleメソッドを呼び出すだけです。
$php artisan export:user
上記のコマンドが追加されます。
依存性の注入(DI)
<?phpdeclare(strict_types=1);namespaceApp\Providers;useApp\Infrastructure\Adapter\DbUserRepository;useApp\Infrastructure\Adapter\FileUserCsvExport;useApp\UseCase\UserCsvExportUseCase;useIlluminate\Support\ServiceProvider;classAppServiceProviderextendsServiceProvider{/**
* Register any application services.
*
* @return void
*/publicfunctionregister(){$this->app->bind(UserCsvExportUseCase::class,function($app){returnnewUserCsvExportUseCase(newDbUserRepository(),newFileUserCsvExport());});}/**
* Bootstrap any application services.
*
* @return void
*/publicfunctionboot(){//}}
UserCsvExportUseCaseクラスをサービスコンテナに登録します。
ここで登録しているので、ExportUserCommandはコンストラクタインジェクションでUserCsvExportUseCaseクラスのインスタンスを受け取れます。