Diverse Advent Calendar 2019 14日目の記事です。
こんにちは! @gatapon です!
エンジニア歴1年未満ですが無事生きてます。元気にしてます!
業務では使っているわけではないのですが、Laravelについて書いてみようと思います。
概要
少し設計に興味を持った私がRegisterController.php
をLaravelの機能を利用したり、レイヤー化させてみました。
やったこと
-
RegisterController.php
の処理の分離 - Service層の導入
- Repository層の導入
はじめに
「どうしてこんな記事を書くのか」という経緯をポエムっぽく書きます。
4月からエンジニアとして実務を経験してきましたが、入社前の私は個人で小さなポートフォリオを作った程度でした。実際に仕事で触れるプロダクトはポートフォリオと比べ物にならないくらい大きく、ちょっとした機能の修正であっても「どこに記述するべきか」、「どう記述するべきか」という事に悩む事が多かったです。
エンジニアになって3,4ヶ月程経ち、少しずつですが経験を重ね「ここはこう書いたほうが良いよな」みたいな勘が、なんとなくではありますが自分の中で形成されていきました。しかしそれはあくまで勘でしかなく、「なんとなく正しい」と思ってるだけであり、客観的に正しいかどうか自分には判断できませんでした。自分の中で言語化できておらず他人に伝えることも、なかなか大変でした。
そういった事が原因か判りませんが、抽象的なものを読みたいという欲求に駆られていろいろ読み物を漁りました。コードを『どこに記述するべきか』『どう記述するべきか』について読み漁っていくうちに「設計」を勉強することがが近道ではないかなと感じ、少しずつ興味を持つようになりました。
以前、Laravelを使ってポートフォリオを作っていたのもあり、たまたま持っていた本もLaravelのクリーンアーキテクチャについて詳細に記述されていたのでLaravelを使って勉強してみることにしました。
その中でもLaravelのRegisterController.php
の書かれ方が気になったので、いろいろいじってみました。
それが割と面白かったので、簡単ではありますが内容をお伝えしたいと思います。
以下、出てくるソースは実装をフェイズごとに区切っていますが、勉強時にディレクトリ構造やファイル名、テーブル名など命名にも試行錯誤重ね、度々変更していました。記事中のファイル名などに不整合が起こっているかもしれません。統一するよう見直しましたが、漏れがあったらご指摘下さい。
RegisterController.phpを手直し
まずはそのままのRegisterController.php
を見てみます。
app/Http/Controllers/Auth/RegisterController.php
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use App\User;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class RegisterController extends Controller
{
/*
|--------------------------------------------------------------------------
| Register Controller
|--------------------------------------------------------------------------
| This controller handles the registration of new users as well as their
| validation and creation. By default this controller uses a trait to
| provide this functionality without requiring any additional code.
|
*/
use RegistersUsers;
/**
* Where to redirect users after registration.
*
* @var string
*/
protected $redirectTo = RouteServiceProvider::HOME;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Get a validator for an incoming registration request.
*
* @param array $data
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
}
/**
* Create a new user instance after a valid registration.
*
* @param array $data
* @return \App\User
*/
protected function create(array $data)
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
}
ユーザーの新規登録の処理がコントローラー内にまとめられています。これは各プロジェクトごとに拡張しやすいように、良い感じにしてくれているのでしょう。RegisterController.php
にはvalidation, ユーザーの作成する処理はありますが、実体となるregister
メソッドはここにはありません。
Route::post('auth/register', 'Auth\RegisterController@register');
しかし、それでもRouteで呼ぶことができています。
それはregister
メソッドを持つRegistersUsers
トレイトを継承しているからです。
framework/src/illuminate/Foundation/Auth/RegistersUsers.php
これらソースをもとに下準備を行い記述します。
一部修正というか、別のディレクトリにてファイルを作り直しています。
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Profile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Auth\Events\Registered;
use Illuminate\Foundation\Auth\RegistersUsers;
class RegisterController extends Controller
{
use RegistersUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* register
*
* @param Request
* @return JsonResponse
*/
public function register(Request $request)
{
$validate = $this->validator($request->all());
if ($validate->fails()) {
return new JsonResponse($validate->errors());
}
event(new Registered($user = $this->create($request->all())));
if (empty($user)) {
return new JsonResponse('Error');
}
return new JsonResponse($user);
}
/**
* Get a validator for an incoming registration request.
*
* @param array $data
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'string', 'max:255', 'unique:users'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
}
/**
* Transaction for create a new user and profile, after a valid registration.
*
* @param array $data
* @return \App\Models\User
*/
protected function create(array $data)
{
DB::beginTransaction();
try {
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
Profile::create([
'user_id' => $user->id,
'country' => '',
'question' => '',
'answer' => '',
]);
DB::commit();
return $user;
} catch(\PDOException $e) {
DB::rollBack();
Log::Debug('Transaction Error: '. print_r($e, true));
}
}
}
app/Http/Controllers/Api/RegisterController.php
を作成し、以下を実施しました。
-
RegistersUsers
のregister
メソッドをもってくる。 - 手軽に検証できるように
register
メソッドはJsonを返すように修正 -
create
メソッドにユーザー新規作成と同時にProfile
も作成するトランザクションを追加
いい感じに肥えて来ましたね!
その他、Profile
モデルの実装やテーブル等の準備はありますが端折ります。
作成したRegisterController.phpのダイエット
準備ができました。
早速以下2つを行いRegisterController.php
をスッキリさせます。
- LaravelのFormRequest機能を利用
- Service層を作成して処理層を分離
これらを行うことで、コントローラーは「リクエストを受け取り、要求されたデータを返す」だけのシンプルな形になります。
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\UserRegistPost;
use App\Services\UserRegisterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Auth\Events\Registered;
use Illuminate\Foundation\Auth\RegistersUsers;
class RegisterController extends Controller
{
use RegistersUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct(UserRegisterService $service)
{
$this->middleware('guest');
$this->service = $service;
}
/**
* register
* @param UserRegistPost
* @return JsonResponse
*/
public function register(UserRegistPost $request)
{
# userRegisterTransactionについては後述
event(new Registered($user = $this->service->userRegisterTransaction($request->all())));
if (!$user) {
return new JsonResponse('Error');
}
return new JsonResponse($user);
}
}
FormRequest
LaravelにあるFormRequestはとても便利です。バリデーションのロジックをコントローラーから分離できます。
また、コントローラーのメソッドに依存注入することでコントローラーがリクエストを受け取った時点でバリデーションが行われます。
register
メソッド内にバリデーションエラー処理も記述しなくて済み、コントローラーがスッキリしました。
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use App\Traits\ApiFormRequestTrait;
class UserRegistPost extends FormRequest
{
use ApiFormRequestTrait;
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'name' => ['required', 'string', 'max:255', 'unique:users'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}
}
今回のAPIのFormRequestのValidationエラーの実装は、以下リンクを参考にさせてもらいました。これ以外の方法もあるようです。脇道にそれるので、ここでは記述しません。
【Laravel5】FormRequestのバリデーション結果をJSON APIで返す
Service層の導入
MVCの下に作成されたフレームワークにはFatモデル、Fatコントローラー問題があります。
Service層の導入はその問題を緩和することが出来ます。
モデルやコントローラーからビジネスロジックを分離させることができるので、本来の責務だけ任せることが出来ます。
<?php
namespace App\Services;
use App\Models\User;
use App\Models\Profile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Hash;
class UserRegisterService
{
/**
* Transaction for create a new user and profile, after a valid registration.
*
* @param array $data
* @return User
*/
public function userRegisterTransaction(array $data)
{
DB::beginTransaction();
try {
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
Profile::create([
'user_id' => $user->id,
'country' => '',
'question' => '',
'answer' => '',
]);
DB::commit();
return $user;
} catch(\PDOException $e) {
DB::rollBack();
Log::Debug('Transaction Error: '. print_r($e, true));
}
}
}
メソッド名をuserRegisterTransaction
に変更していますが、ただ単にapp/Services/UserRegisterService.php
にお引越ししてきただけです。
これでRegisterController.php
のダイエットは完了です。
UserRegisterService.phpさんにもダイエットしてもらおう
Repository層の導入
Service層はビジネスロジックを分離できるメリットがあるとお話しましたが、移植してきたものの内容を見てみるとデータの操作ぐらいしか行っていません。と言うか、ビジネスロジックに集中したいService層でデータアクセスを直接行っています。この書き方はもう少し変えていきたいと思います。
ビジネスロジックからデータ操作を切り離すことで、テスト容易性や、保守性、拡張性が保証されます。そこでドメイン駆動開発にも紹介されているRepository層を導入してみようと思います。
注意
実際問題、めったにデータソースが変わることがないサーバーにRepository層を装する必要は、あまりないかもしれません。レイヤー化、抽象化を目的とした設計なら他の方法があるかもしれませんね。
参考:“Repositoryによる抽象化の理想と現実/Ideal and reality of abstraction by Repository - Speaker Deck”
抽象クラス(RepositoryInterface)と具象クラス(Repository)の実装
私は今のところ、Repository層の最大の目的はデータの永続性だと理解しています。データソースが変更された時(MySQL→NoSQLなど)、移行の対応を極力抑えることが出来ます。各RepositoryInterfaceを継承した具象クラスを実装するだけなので、ビジネスロジックに手を加える必要がなくなります。
User
, Profile
それぞれの今回使うメソッドのみの実装を行っていきます。
RepositoryInterfaceの実装
<?php
namespace App\Repositories\Interfaces;
interface UserRepositoryInterface
{
public function register(array $data)
}
<?php
namespace App\Repositories\Interfaces;
interface ProfileRepositoryInterface
{
public function create(int $user_id);
}
インターフェイスはあくまで骨組みだけになります。各データソースごとに、このInterfaceを継承した具象クラスを作成することで変更の対応が容易になります。(後に述べるInterfaceとのバインディングを変更する必要があります)
今回のデータ操作はLaravelのORM、Eloquentを利用していきます。
クエリビルダ、SQLを使うことも出来ます。
Repositoryの実装
<?php
namespace App\Repositories;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use App\Repositories\Interfaces\UserRepositoryInterface;
class UserRepository implements UserRepositoryInterface
{
protected $user;
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Create User
*
* @param $data
* @return \App\Models\User
*/
public function register(array $data)
{
return $this->user->create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
}
<?php
namespace App\Repositories;
use App\Models\Profile;
use App\Repositories\Interfaces\ProfileRepositoryInterface;
class ProfileRepository implements ProfileRepositoryInterface
{
protected $profile;
public function __construct(Profile $profile)
{
$this->profile = $profile;
}
/**
* Create Profile
*
* @param int $user_id
* @return void
*/
public function create(int $user_id)
{
$this->profile->create([
'user_id' => $user_id,
'country' => '',
'question' => '',
'answer' => '',
]);
}
}
Repository層を実装したUserRegisterService.php
は以下になります。
userRegisterTransaction
メソッドに依存注入されているのは具象クラスでなく抽象クラスであるInterface
であることに注意して下さい。
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Repositories\Interfaces\UserRepositoryInterface;
use App\Repositories\Interfaces\ProfileRepositoryInterface;
class UserRegisterService
{
protected $user;
protected $profile;
public function __construct(UserRepositoryInterface $user, ProfileRepositoryInterface $profile)
{
$this->user = $user;
$this->profile = $profile;
}
/**
* Transaction for create a new user and profile, after a valid registration.
*
* @param array $data
* @return \App\Models\User
*/
public function userRegisterTransaction(array $data)
{
DB::beginTransaction();
try {
$newUser = $this->user->register($data);
$this->profile->create($newUser->id);
DB::commit();
return $newUser;
} catch(\PDOException $e) {
DB::rollBack();
Log::Debug('Transaction Error: '. print_r($e, true));
}
}
}
RepositoryとInterfaceをバインディング
新しくRepository用のServiceProviderを作成するので、config/app.php
に追加します。
〜〜
'providers' => [
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\RepositoryServiceProvider::class, # 追加
],
RepositoryServiceProvider.php
を作成し、抽象クラスと具象クラスをバインディングすれば実装完了です。データソースが違う具象クラスを利用する場合、こちらでInterfaceとバインディングされた具象クラスを変更します。
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
{
$this->app->bind(
\App\Repositories\Interfaces\ProfileRepositoryInterface::class,
\App\Repositories\ProfileRepository::class
);
$this->app->bind(
\App\Repositories\Interfaces\UserRepositoryInterface::class,
\App\Repositories\UserRepository::class
);
}
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
//
}
}
実装してみて
一年ぐらい前、エンジニアになるためにLaravelを勉強し始めたのですが、その時とコードの見た目が全く違うなあという印象です。コンストラクタにてDIを利用しクラスをセットしているので、依存クラスがひと目で分かりますね。実装時はディレクトリ構成とクラス名を考えるのに苦戦しました。実際にクラスを増やしていって見ていかないと、よりベターなディレクトリ構成やクラス名を付けれそうにないですね...
しかし機能やレイヤーをきっちり分けると、どこに何を書くべきか
が判断しやすくなりました。面白かったので引き続き勉強していきたいと思います。
参考にした記事
参考にした書籍