この記事は「【マイスター・ギルド】本物のAdvent Calendar 2022」18日目の記事です。
LaravelでWebアプリケーションを開発しているんですが、これは毎回書きそうだなーというのがいくつかあったので、それをまとめてみました。開発はまだまだ途中なので、これから変化していく可能性もありますが。
グローバルスコープ
グローバルスコ―プを使ってDBのデータ + 何らかの条件付き
を表現します。それを使えば疑似的1に条件付きのモデルが定義できます。
例えばUser
モデルがあり、利用者が閲覧可能かどうかをis_visible
で表したデータがあるとき、
class VisibleUser extends User
{
protected $table = 'users';
protected $hidden = [
'is_visible', // 持つ必要が無い
];
protected static function boot()
{
parent::boot();
static::addGlobalScope(function (Builder $builder) {
// 公開中のもののみという条件を付与
$builder->where('is_visible', true);
});
}
}
とする事で「利用者が閲覧可能なデータのみに絞られたVisibleUser
」を定義できます。
利用者に閲覧可能なユーザーの情報のみが表示できる/public/users/{user}
というルーティングがあった時に、モデルバインディングで
Route::get('/public/users/{user}', function (VisibleUser $user) {
return $user;
});
としてしまえば、is_visible=falase
なUserを表示しようとしても404エラーに出来ます。
カスタムクエリビルダ
ユースケースから呼び出す検索条件は、Illuminate\Database\Eloquent\Builder
を拡張したカスタムクエリビルダに書くようにします。
use Illuminate\Database\Eloquent\Builder;
class UserQueryBuilder extends Builder
{
public function nameIs(string $val): static
{
return $this->where('name', '=', $val);
}
}
というカスタムクエリビルダを定義し、モデルで利用するクエリに差し替えます。
class User extends Model
{
...
public static function query(): UserQueryBuilder
{
return parent::query();
}
public function newEloquentBuilder($query): UserQueryBuilder
{
return new UserQueryBuilder($query);
}
}
こうするとユースケースで
User::query()->nameIs('山田')->orderBy('id');
と呼ぶことが出来ます。
トランザクション
コントローラーでトランザクション制御を行います。
public function create(CreateRequest $request): JsonResponse
{
$input = $request->validated();
DB::transaction(function () use ($input) {
$this->usecase->create($input);
});
return response()->json();
}
DB::transaction
でユースケースの処理を囲っておき、成功時はコミット、失敗(エラー発生)時はロールバックを自動でされるようにしておきます。
要トランザクション | トランザクション不要 |
---|---|
登録、更新、削除 | 取得 |
処理の種類とトランザクションの要・不要は上の通りで、ユースケースに「要」の処理が含まれるならトランザクションで囲うようにします。
エラーログ
Log::error(...);
で Laravel のログ出力先に吐き出します。実際にエラーが起きた際のログは、基本的には何もしなくてもLaravelが勝手に出してくれるんですが、try-catch
してエラーを投げなおさない場合には、
try{
...
} catch (Exception $e) {
Log::error($e);
...
}
として手動でエラーを出力する必要が出てきます。
メッセージ
多言語対応・メッセージ管理
画面へ表示されるメッセージを書く場合、多言語対応を見据えて__
ヘルパを使っておきます。
https://readouble.com/laravel/9.x/ja/localization.html#retrieving-translation-strings
__('登録に成功しました。')
__
ヘルパを使っておくとlang/
ディレクトリ下で
'登録に成功しました。' => 'registration successful',
を定義すれば、configの言語設定に従って別言語化されます。
あるいは__
ヘルパには定数を渡すようにして、lang/ja/
下で定数と表示するメッセージの対応を書くようにすれば、lang
フォルダ内に表示メッセージを集めて管理ができます。
__('message.welcome')
(定数を.
で区切ることで階層化が可能です)
`welcome` => 'ようこそ'
画面へのフラッシュ表示(1度だけ表示)
laracast/flashを利用します。
成功メッセージは
Flash::success(...);
で出力し、
エラーメッセージは
Flash::error(...);
で出力します。表示させる側の blade では
@include('flash::message')
を読ませるだけです。成功メッセージに対してはalert-success
、失敗メッセージにはalert-danger
のクラスが付いた DOM が生成されるので、文字色を変えるなどのスタイルを当てます。
通知
Notification を使います。
イベント発火から通知までの流れは以下のようにします。
イベント発火
ユースケースでイベントを発火します(Registered
がイベントです)。この時リスナー処理で必要になる情報をイベントに渡しておきます(DB 登録したモデルとか)。
event(new Registered($user));
イベントとリスナーの対応付け
Providers/EventServiceProvider
に定義しておいたイベントとリスナーの対応づけから、リスナー(SendRegisteredEmail
)が呼び出されます。
protected $listen = [
Registered::class => [
SendRegisteredEmail::class,
],
];
リスナー処理
リスナーでイベント発火時に渡したモデル(DB 登録したモデル)を参照し、そのモデルに定義されている通知用のメソッド(sendRegisteredNotification
)を呼び出します。リスナ―にはShouldQueue
インターフェースを使うようにし、後々非同期処理されるようにします。
class SendRegisteredEmail implements ShouldQueue
{
public function handle(Registered $event)
{
$event->user->sendRegisteredNotification();
}
}
イベント通知
通知用のメソッド内にてnotify
メソッドを呼び、引数に通知クラス(ここのRegistered
は通知クラス)を渡します。このモデルにはNotifiable
トレイトが使用されている事が前提です。
class User extends Model
{
use Notifiable;
public function sendRegisteredNotification()
{
$this->notify(new Registered());
}
}
通知処理
通知クラスでは、通知処理(送信する情報の構築)を書きます。
class Registered extends Notification
{
...
public function via($notifiable)
{
return ['mail'];
}
public function toMail($notifiable)
{
return (new MailMessage)
->subject(__('登録が完了しました'))
->markdown('mails.registered');
}
}
上記はメール通知処理ですが、SMS通知やSlack通知などもここに実装出来ます。
テスト
テスト用DB
- phpunit の設定で、
testing
という開発とは別の DB を使用する設定にします。 -
RefreshDatabase
トレイトを使い、DB はテストケース毎にリフレッシュされるようにします。 - あらかじめデータが入った DB が必要な場合は、テストコードの前半で必要なデータを作成します。
テストケース
@dataProvider
アノテーションを使い、TDT(テーブル駆動テスト)っぽくなるようにします。
/**
* @dataProvider registDataProvider
*/
public function test_regist($input, $seed, $expected, $expectedException)
{
...
}
public function registDataProvider()
{
return [
'正常系' => [
[
'name' => 'Test User',
'email' => 'test@m-gild.com',
'password' => 'password',
], // 引数の$inputにあたります
RegistTestSeeder::class, // 引数の$seedにあたります
'test@m-gild.com', // 引数の$expectedにあたります
null, // 引数の$expectedExceptionにあたります
],
'異常系' => [
[], // 引数の$inputにあたります
RegistTestSeeder::class, // 引数の$seedにあたります
'', // 引数の$expectedにあたります
Exception::class, // 引数の$expectedExceptionにあたります
]
];
}
上記のようにすると、正常系
と異常系
がテストケースとなり、test_regist が2度回ります。その際に test_regist に渡される引数は、配列として書いた順に渡されます。
モック
Usecase をテストしたい場合、それに依存している Model や Service はモック化し、テストに影響を与えないようにします。
UT のsetUp
関数でテスト対象の初期化処理を書くときにモックを渡し、準備をします。
protected function setUp(): void
{
parent::setUp();
$this->mock = Mockery::mock('App\Models\User'); // モック化したいクラスのパスを渡します
// UserUsecaseがテスト対象です
$this->usecase = new UserUsecase(
$this->mock,
);
}
テストコードの前半でモックのメソッドをダミーなもので定義します。定義していないメソッドが呼び出されるとエラーになります。
User
モデルのcreate
というメソッドが使われる Usecase をテストする場合は、
$receive = $this->mock->shouldReceive('create')
->once() // 一度だけシミュレーションするという宣言
->with($input); // 引数
if (is_null($mockException)) {
// エラーを期待しない場合
$receive->andReturn($mockUser); // 応答をダミーに置き換えます
} else {
// エラーを期待する場合
$receive->andThrow($mockException); // 異常時のエラーの発生をダミーに置き換えます
}
$this->usecase->regist($input); // テスト対象。この中でUser::createが呼ばれています
となります。
-
グローバルスコープは DB から取得時のSQLに
'is_visible' = true
を付与するだけ。DB から取得したデータのis_visible
をtrue
に上書きすることは可能なので、モデルが常にis_visible=true
であることを保証するわけではないことに注意。 ↩