Quantcast
Channel: PHP7.4タグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 113

Laravel CSVの出力処理を実装する

$
0
0

概要

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

今回のゴール

スクリーンショット 2020-02-20 12.18.33.png

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 テーブルをそのまま使います。

src/database/migrations/2014_10_12_000000_create_users_table.php
<?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シーダーのひな形クラスを作ってくれるので下記のように追記します。

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モデルクラスの各プロパティにどんなデータが入るかの定義はモデルファクトリで定義されてます。

src/database/factories/UserFactory.php
<?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.phpUsersTableSeederを呼び出す記述を追記します。

src/database/seeds/DatabaseSeeder.php
<?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

app/Domain/UserRowHeader.php
<?phpdeclare(strict_types=1);namespaceApp\Domain;finalclassUserRowHeader{privateconstEOF="\n";privateconstHEADER=['名前','メールアドレス','作成日','更新日',];publicstaticfunctiontoCsv():string{returnimplode(',',self::HEADER).self::EOF;}}

UserRowHeader ドメインクラスではCSVのヘッダー行となる1行目の定義をしてます。

App\Domain\UserRow

app/Domain/UserRow.php
<?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 を定義

app/Infrastructure/Export.php
<?phpdeclare(strict_types=1);namespaceApp\Infrastructure\Port;interfaceExport{publicfunctionprepare(string$header):void;publicfunctionwrite(string$row):void;publicfunctiondisorganize():void;}

Export インターフェースを継承するクラスは prepare(前処理)、write(書き込み)、disorganize(後処理)のメソッドを契約します。

app/Infrastructure/UserRepository.php
<?phpdeclare(strict_types=1);namespaceApp\Infrastructure\Port;useGenerator;interfaceUserRepository{publicfunctionfindAll():Generator;}

UserRepository インターフェースを継承するクラスはfindAll(全件取得)のメソッドを契約します。

app/UseCase/UserCsvExportUseCase.php
<?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

app/Infrastructure/Adapter/InMemoryUserCsvExport.php
<?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メソッドが呼ばれたらどんどん追記する形です。
実際にファイルアクセスする場合はdisorganizefclose等の処理を入れますが、ファイルアクセスしないので関数だけ定義してます。

InMemoryUserRepository

app/Infrastructure/Adapter/InMemoryUserRepository.php
<?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を指定します。
yieldUserRowのインスタンスを返してます。

// UserCsvExportUseCase で findAll を foreach でループ処理できます。foreach($this->repository->findAll()as$row){$this->export->write($row->toCsv());}

ジェネレータのメリットはforeachでループ処理するために巨大な配列を持つ必要がなく1件処理が終わったらメモリを解放して次の処理を実行してくれるので、バッチ処理等のメモリをたくさん使いそうな場合に効果を発揮します。

UserCsvExportUseCaseTest

先ほど作成したInMemoryUserRepositoryInMemoryUserCsvExportを使ってテストコードを書きます。

tests/Unit/UserCsvExportUseCaseTest.php
<?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

app/Infrastructure/Adapter/DbUserRepository.php
<?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

app/Infrastructure/Adapter/FileUserCsvExport.php
<?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

app/Console/Commands/ExportUserCommand.php
<?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)

app/Providers/AppServiceProvider.php
<?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クラスのインスタンスを受け取れます。


Viewing all articles
Browse latest Browse all 113

Trending Articles