概要
Laravelを使って、CSVファイルを出力するサンプルを作成します。
背景
データベースやファイルアクセスをしないテストの方法、インターフェースやジェネレータを使ったコードの書き方等、個別に詳しく書かれた記事はあれど実際使うにはどう書き始めたらいいのかベストプラクティスがわかりませんでした。
日々の業務や副業、勉強会を通じてようやく自分の中で少しずつイメージができてきたので現時点で最高のアウトプットをしていこうと思いました。
目的
この記事ではテストを意識したコードかつ、シンプルに書くことを目標にしてます。
より良いコードにしたいのでアドバイスもらえたらうれしいです。
説明不足なところがあったら補足を追記するので気軽に質問等もいただけたら嬉しいです。
環境
- PHP 7.4.1
- Laravel 6.14.0
- MySQL 8.0.19
サンプルコード
$ 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
名前の列は大文字に変換して出力する仕様です。
ベースのコード
環境はこちらのコードを丸コピしてます。
追加したファイル一覧
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 テーブルをそのまま使います。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
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
*/
public function down()
{
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
シーダーのひな形クラスを作ってくれるので下記のように追記します。
<?php declare(strict_types=1);
use App\Infrastructure\Eloquent\User;
use Illuminate\Database\Seeder;
class UsersTableSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
factory(User::class, 3)->create();
}
}
上記の用にシーダーを追加するだけで、テストデータを3件作成してくれます。
Userモデルクラスの各プロパティにどんなデータが入るかの定義はモデルファクトリで定義されてます。
<?php declare(strict_types=1);
/** @var \Illuminate\Database\Eloquent\Factory $factory */
use App\Infrastructure\Eloquent\User;
use Faker\Generator as Faker;
use Illuminate\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
を呼び出す記述を追記します。
<?php declare(strict_types=1);
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
$this->call(UsersTableSeeder::class);
}
}
UserRow, UserRowHeader ドメインを定義
この辺りから本題です。
App\Domain\UserRowHeader
<?php declare(strict_types=1);
namespace App\Domain;
final class UserRowHeader
{
private const EOF = "\n";
private const HEADER = [
'名前',
'メールアドレス',
'作成日',
'更新日',
];
public static function toCsv(): string
{
return implode(',', self::HEADER) . self::EOF;
}
}
UserRowHeader ドメインクラスではCSVのヘッダー行となる1行目の定義をしてます。
App\Domain\UserRow
<?php declare(strict_types=1);
namespace App\Domain;
use Carbon\Carbon;
final class UserRow
{
private const EOF = "\n";
private const DATE_FORMAT = 'Y-m-d H:i:s';
private string $name;
private string $email;
private Carbon $createdAt;
private Carbon $updatedAt;
public function __construct(
string $name,
string $email,
Carbon $createdAt,
Carbon $updatedAt
) {
$this->name = $name;
$this->email = $email;
$this->createdAt = $createdAt;
$this->updatedAt = $updatedAt;
}
/**
* @return string
*/
public function toCsv(): string
{
return implode(',', $this->toArray()) . self::EOF;
}
/**
* @return array
*/
private function toArray(): array
{
return [
$this->getName(),
$this->email,
$this->createdAt->format(self::DATE_FORMAT),
$this->updatedAt->format(self::DATE_FORMAT),
];
}
/**
* @return string
*/
private function getName(): string
{
return strtoupper($this->name);
}
}
UserRow ドメインクラスではCSVの1行の定義をしてます。
ユーザー名は大文字や日付のフォーマット等の業務ロジックはここにまとめます。
UserCsvExportUseCase を定義
<?php declare(strict_types=1);
namespace App\Infrastructure\Port;
interface Export
{
public function prepare(string $header): void;
public function write(string $row): void;
public function disorganize(): void;
}
Export インターフェースを継承するクラスは prepare(前処理)、write(書き込み)、disorganize(後処理)のメソッドを契約します。
<?php declare(strict_types=1);
namespace App\Infrastructure\Port;
use Generator;
interface UserRepository
{
public function findAll(): Generator;
}
UserRepository インターフェースを継承するクラスはfindAll(全件取得)のメソッドを契約します。
<?php declare(strict_types=1);
namespace App\UseCase;
use App\Domain\UserRow;
use App\Domain\UserRowHeader;
use App\Infrastructure\Port\Export;
use App\Infrastructure\Port\UserRepository;
final class UserCsvExportUseCase
{
/**
* @var UserRepository
*/
private UserRepository $repository;
/**
* @var Export
*/
private Export $export;
/**
* @param UserRepository $repository
* @param Export $export
*/
public function __construct(UserRepository $repository, Export $export)
{
$this->repository = $repository;
$this->export = $export;
}
/**
* @return void
*/
public function handle(): 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
<?php declare(strict_types=1);
namespace App\Infrastructure\Adapter;
use App\Infrastructure\Port\Export;
final class InMemoryUserCsvExport implements Export
{
/**
* @var string
*/
public string $file;
/**
* @param string $header
*/
public function prepare(string $header): void
{
$this->file = $header;
}
/**
* @param string $row
*/
public function write(string $row): void
{
$this->file .= $row;
}
/**
* @return void
*/
public function disorganize(): void
{
}
}
Export
インターフェースを契約したInMemoryUserCsvExport
クラスを実装します。
やってることは簡単で、prepare
メソッドで$file
プロパティに文字列を入れてwrite
メソッドが呼ばれたらどんどん追記する形です。
実際にファイルアクセスする場合はdisorganize
でfclose
等の処理を入れますが、ファイルアクセスしないので関数だけ定義してます。
InMemoryUserRepository
<?php declare(strict_types=1);
namespace App\Infrastructure\Adapter;
use App\Domain\UserRow;
use App\Infrastructure\Eloquent\User;
use App\Infrastructure\Port\UserRepository;
use Generator;
final class InMemoryUserRepository implements UserRepository
{
private array $usersAttributes;
/**
* @param array $users
*/
public function __construct(array $users)
{
$this->usersAttributes = $users;
}
/**
* @return Generator
*/
public function findAll(): Generator
{
foreach ($this->usersAttributes as $userAttributes) {
yield $this->makeUserRow(factory(User::class)->make($userAttributes));
}
}
/**
* @param User $user
* @return UserRow
*/
private function makeUserRow(User $user): UserRow
{
return new UserRow(
$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
を使ってテストコードを書きます。
<?php declare(strict_types=1);
namespace Tests\Unit;
use App\Infrastructure\Adapter\InMemoryUserCsvExport;
use App\Infrastructure\Adapter\InMemoryUserRepository;
use App\UseCase\UserCsvExportUseCase;
use Tests\TestCase;
final class UserCsvExportUseCaseTest extends TestCase
{
/**
* @param array $users
* @param string $expectedCsv
* @dataProvider dataResolve
*/
public function testResolve(array $users, string $expectedCsv): void
{
$repository = new InMemoryUserRepository($users);
$export = new InMemoryUserCsvExport();
$useCase = new UserCsvExportUseCase($repository, $export);
$useCase->handle();
$this->assertEquals($expectedCsv, $export->file);
}
/**
* @return array
*/
public function dataResolve(): array
{
return [
'正常3件' => $this->case正常3件(),
'正常0件' => $this->case正常0件(),
];
}
/**
* @return array
*/
public function case正常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-01 00:00:00,2020-01-01 00:00:00
SUZUKI,suzuki@example.com,2020-01-01 00:00:00,2020-01-01 00:00:00
TANAKA,tanaka@example.com,2020-01-01 00:00:00,2020-01-01 00:00:00
EOT;
return [
$usersAttributes,
$expectedCsv,
];
}
/**
* @return array
*/
public function case正常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 --debug
PHPUnit 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
<?php declare(strict_types=1);
namespace App\Infrastructure\Adapter;
use App\Domain\UserRow;
use App\Infrastructure\Eloquent\User;
use App\Infrastructure\Port\UserRepository;
use Generator;
final class DbUserRepository implements UserRepository
{
/**
* @return Generator
*/
public function findAll(): Generator
{
/** @var User $user */
foreach (User::query()->cursor() as $user) {
yield new UserRow(
$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
<?php declare(strict_types=1);
namespace App\Infrastructure\Adapter;
use App\Infrastructure\Port\Export;
final class FileUserCsvExport implements Export
{
/**
* @var string
*/
private string $streamFilePath;
/**
* @var resource
*/
private $handle;
/**
* @param string $header
* @return void
*/
public function prepare(string $header): void
{
$this->streamFilePath = $this->makeStreamFile();
$this->handle = fopen($this->streamFilePath, 'wb+');
$this->write($header);
}
/**
* @param string $row
* @return void
*/
public function write(string $row): void
{
fwrite($this->handle, $row);
}
/**
* @return void
*/
public function disorganize(): void
{
fclose($this->handle);
// 後処理 配置したい場所へコピーする等
dump(file_get_contents($this->streamFilePath));
unlink($this->streamFilePath);
}
/**
* @return string
*/
private function makeStreamFile(): string
{
return tempnam(sys_get_temp_dir(), config('app.name'));
}
}
Exportを契約したFileUserCsvExportクラスです。
ExportUserCommand
<?php declare(strict_types=1);
namespace App\Console\Commands;
use App\UseCase\UserCsvExportUseCase;
use Illuminate\Console\Command;
class ExportUserCommand extends Command
{
/**
* 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
*/
private UserCsvExportUseCase $useCase;
/**
* ExportUserCommand constructor.
* @param UserCsvExportUseCase $useCase
*/
public function __construct(UserCsvExportUseCase $useCase)
{
parent::__construct();
$this->useCase = $useCase;
}
/**
* @return void
*/
public function handle(): void
{
$this->useCase->handle();
}
}
LaravelにはArtisanコンソールというコマンドラインインターフェイスが用意されてます。
コマンドクラスを作るだけで簡単に自作コマンドを追加できます。
ExportUserCommandでやってることは、UserCsvExportUseCaseのインスタンスを受け取って、handleメソッドを呼び出すだけです。
$ php artisan export:user
上記のコマンドが追加されます。
依存性の注入(DI)
<?php declare(strict_types=1);
namespace App\Providers;
use App\Infrastructure\Adapter\DbUserRepository;
use App\Infrastructure\Adapter\FileUserCsvExport;
use App\UseCase\UserCsvExportUseCase;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->bind(UserCsvExportUseCase::class, function ($app) {
return new UserCsvExportUseCase(new DbUserRepository(), new FileUserCsvExport());
});
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
//
}
}
UserCsvExportUseCaseクラスをサービスコンテナに登録します。
ここで登録しているので、ExportUserCommandはコンストラクタインジェクションでUserCsvExportUseCaseクラスのインスタンスを受け取れます。