【2021/10/15 追記】現在の自分の思想としてはこのアーキテクチャを推奨しておりません!
Laravel Advent Calendar 2017 5日目の記事になります。
昨日の @mpyw さんの記事に引き続き,本日は @mpyw が担当させていただきます。
前提知識
- Gate および Policy という認可のための仕組みがある
- Route Model Binding という URL から判断して暗黙的に Eloquent モデルのインスタンスをコントローラおよびポリシーのメソッドに DI する仕組みがある
-
Route::apiResource()
メソッドを使うと Resourceful なルーティングを一括で定義できる
リレーション構成例
例として,以下のようなモデルを考えてください。
-
User
… コミュニティ -
Community
… コミュニティ -
Article
… 記事 -
Event
… イベント -
Comment
… コメント
ここで,以下のようなリレーションを考えます。
-
User
belongs to manyCommunity
-
Community
belongs to manyUser
-
-
Article
belongs toCommunity
-
Community
has manyArticle
-
-
Event
belongs toCommunity
-
Community
has manyEvent
-
-
Comment
morph to commentable-
Article
morph manyComment
-
Event
morph manyComment
-
【注意】
- モデル定義のコードは省略します
- 実運用での利便性を考えて完全な正規化は捨てている部分があります
ルーティングパターンを考えてみよう
(A) 常に自身のエンティティのみをルートに含む
※ 「外部パラメータ」はGETリクエストならクエリーストリング,POSTリクエストならリクエストボディでデータを渡すことを意味します。
メソッド | パス | 外部パラメータ | アクション |
---|---|---|---|
GET | /communities |
CommunityController@index |
|
POST | /communities |
CommunityController@store |
|
GET | /communities/{community} |
CommunityController@show |
|
PUT | /communities/{community} |
CommunityController@update |
|
DELETE | /communities/{community} |
CommunityController@destroy |
メソッド | パス | 外部パラメータ | アクション |
---|---|---|---|
GET | /articles |
community_id | ArticleController@index |
POST | /articles |
community_id | ArticleController@store |
GET | /articles/{article} |
ArticleController@show |
|
PUT | /articles/{article} |
ArticleController@update |
|
DELETE | /articles/{article} |
ArticleController@destroy |
メソッド | パス | 外部パラメータ | アクション |
---|---|---|---|
GET | /events |
community_id | EventController@index |
POST | /events |
community_id | EventController@store |
GET | /events/{event} |
EventController@show |
|
PUT | /events/{event} |
EventController@update |
|
DELETE | /events/{event} |
EventController@destroy |
メソッド | パス | 外部パラメータ | アクション |
---|---|---|---|
GET | /comments |
commentable_type commentable_id |
CommentController@index |
POST | /comments |
commentable_type commentable_id |
CommentController@store |
GET | /comments/{comment} |
CommentController@show |
|
PUT | /comments/{comment} |
CommentController@update |
|
DELETE | /comments/{comment} |
CommentController@destroy |
何も考えずに
Route::apiResources([
'communities' => 'CommunityController',
'articles' => 'CommunityController',
'events' => 'EventController',
'comments' => 'CommentController',
]);
とかやるとこうなっちゃいますね。一見ルーティングがシンプルに収まっていていいようにも思いますが,「外部パラメータ」という謎の概念が付きまとってきます。これ,ポリシーを適用したいときにめちゃくちゃ困るんですよね。例えば「記事を見れるのはコミュニティ内のユーザ限定」という実装であると仮定します。
public function show(User $user, Article $article)
{
// ユーザがコミュニティに所属していることを検証する
return $user->communities()->wherePivot('community_id', $article->community_id)->exists();
}
show
のようにルートモデルバインディングでモデルが入ってきている場合はいいんです。ではそれが無いindex
はどうなるのか?
public function index(User $user)
{
// ユーザがコミュニティに所属していることを検証する
return $user->communities()->wherePivot('community_id', Request::input('community_id'))->exists();
}
このように,外部パラメータを使ってしまうとRequest
ファサードを使わない限り,ポリシーの中で判定するのは困難になります。また,ポリシーは可能な限りシンプルで,宣言的であるべきであり,外部入力に依存するファサードを中で使うこと自体がもはやご法度です。使い回しが効かなくなりますからね。
(B) 常に自身のエンティティに加えて祖先のエンティティもすべてルートに含む
メソッド | パス | アクション |
---|---|---|
GET | /communities |
CommunityController@index |
POST | /communities |
CommunityController@store |
GET | /communities/{community} |
CommunityController@show |
PUT | /communities/{community} |
CommunityController@update |
DELETE | /communities/{community} |
CommunityController@destroy |
メソッド | パス | アクション |
---|---|---|
GET | /communities/{community}/articles |
ArticleController@index |
POST | /communities/{community}/articles |
ArticleController@store |
GET | /communities/{community}/articles/{article} |
ArticleController@show |
PUT | /communities/{community}/articles/{article} |
ArticleController@update |
DELETE | /communities/{community}/articles/{article} |
ArticleController@destroy |
メソッド | パス | アクション |
---|---|---|
GET | /communities/{community}/events |
EventController@index |
POST | /communities/{community}/events |
EventController@store |
GET | /communities/{community}/events/{event} |
EventController@show |
PUT | /communities/{community}/events/{event} |
EventController@update |
DELETE | /communities/{community}/events/{event} |
EventController@destroy |
メソッド | パス | アクション |
---|---|---|
GET |
/communities/{community}/articles/{article}/comments /communities/{community}/events/{event}/comments
|
CommentController@index |
POST |
/communities/{community}/articles/{article}/comments /communities/{community}/events/{event}/comments
|
CommentController@store |
GET |
/communities/{community}/articles/{article}/comments/{comment} /communities/{community}/events/{event}/comments/{comment}
|
CommentController@show |
PUT |
/communities/{community}/articles/{article}/comments/{comment} /communities/{community}/events/{event}/comments/{comment}
|
CommentController@update |
DELETE |
/communities/{community}/articles/{article}/comments/{comment} /communities/{community}/events/{event}/comments/{comment}
|
CommentController@destroy |
Route::apiResource('communities', 'CommunityController');
Route::prefix('communities/{community}')->group(function () {
Route::apiResources([
'articles' => 'ArticleController',
'events' => 'EventController',
]);
Route::prefix('articles/{article}')->apiResource('Comment', 'CommentController');
Route::prefix('events/{event}')->apiResource('Comment', 'CommentController');
});
…はい,もう見るからに辛そうですね(笑)
さすがにこれは無いので,次の (C) を検討しましょう。
(C) 常に自身のエンティティに加えて直属の親エンティティをルートに含む
メソッド | パス | アクション |
---|---|---|
GET | /communities |
CommunityController@index |
POST | /communities |
CommunityController@store |
GET | /communities/{community} |
CommunityController@show |
PUT | /communities/{community} |
CommunityController@update |
DELETE | /communities/{community} |
CommunityController@destroy |
メソッド | パス | アクション |
---|---|---|
GET | /communities/{community}/articles |
ArticleController@index |
POST | /communities/{community}/articles |
ArticleController@store |
GET | /communities/{community}/articles/{article} |
ArticleController@show |
PUT | /communities/{community}/articles/{article} |
ArticleController@update |
DELETE | /communities/{community}/articles/{article} |
ArticleController@destroy |
メソッド | パス | アクション |
---|---|---|
GET | /communities/{community}/events |
EventController@index |
POST | /communities/{community}/events |
EventController@store |
GET | /communities/{community}/events/{event} |
EventController@show |
PUT | /communities/{community}/events/{event} |
EventController@update |
DELETE | /communities/{community}/events/{event} |
EventController@destroy |
メソッド | パス | アクション |
---|---|---|
GET |
/articles/{article}/comments /events/{event}/comments
|
CommentController@index |
POST |
/articles/{article}/comments /events/{event}/comments
|
CommentController@store |
GET |
/articles/{article}/comments/{comment} /events/{event}/comments/{comment}
|
CommentController@show |
PUT |
/articles/{article}/comments/{comment} /events/{event}/comments/{comment}
|
CommentController@update |
DELETE |
/articles/{article}/comments/{comment} /events/{event}/comments/{comment}
|
CommentController@destroy |
Route::apiResource('communities', 'CommunityController');
Route::prefix('communities/{community}')->group(function () {
Route::apiResources([
'articles' => 'ArticleController',
'events' => 'EventController',
]);
});
Route::prefix('articles/{article}')->apiResource('Comment', 'CommentController');
Route::prefix('events/{event}')->apiResource('Comment', 'CommentController');
(B) に比べると悪くはないんですが,大きな欠点が1つあります。例えば CommentController@show
を実行したいとき, コメントIDだけで一意にエンティティを特定できるにも関わらず,記事IDまたはイベントIDも必要となっている点です。API設計としてはまだ間違っている感じがしますね。
(D) 自身のエンティティが未作成の場合にのみ直属の親エンティティをルートに含む
で,考え抜いて導き出した答えがこちら。当たり前といえば当たり前なんですが。
メソッド | パス | アクション |
---|---|---|
GET | /communities |
CommunityController@index |
POST | /communities |
CommunityController@store |
GET | /communities/{community} |
CommunityController@show |
PUT | /communities/{community} |
CommunityController@update |
DELETE | /communities/{community} |
CommunityController@destroy |
メソッド | パス | アクション |
---|---|---|
GET | /communities/{community}/articles |
ArticleController@index |
POST | /communities/{community}/articles |
ArticleController@store |
GET | /articles/{article} |
ArticleController@show |
PUT | /articles/{article} |
ArticleController@update |
DELETE | /articles/{article} |
ArticleController@destroy |
メソッド | パス | アクション |
---|---|---|
GET | /communities/{community}/events |
EventController@index |
POST | /communities/{community}/events |
EventController@store |
GET | /events/{event} |
EventController@show |
PUT | /events/{event} |
EventController@update |
DELETE | /events/{event} |
EventController@destroy |
メソッド | パス | アクション |
---|---|---|
GET |
/articles/{article}/comments /events/{event}/comments
|
CommentController@index |
POST |
/articles/{article}/comments /events/{event}/comments
|
CommentController@store |
GET | /comments/{comment} |
CommentController@show |
PUT |
/comments/{comment} |
CommentController@update |
DELETE | /comments/{comment} |
CommentController@destroy |
ただこれ,Laravelの標準機能だと書きづらいので,自分でRoute
ファサードにマクロを生やして使っています。以下に実装例を紹介します。この実装では,ルーティングを定義すると同時に,ポリシーを適用するところまで一気に完結させています。
Route::authorizedApiResource()
マクロ
実装例
<?php
declare(strict_types=1);
namespace App\Providers;
use App;
use Illuminate\Routing\Route;
use Illuminate\Support\Str;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Routing\Router;
class AuthorizedApiResourceMacroServiceProvider extends ServiceProvider
{
/**
* @var array
*/
protected static $abilityMap = [
'independent' => [
'show' => 'GET',
'update' => 'PUT',
'destroy' => 'DELETE',
],
'dependent' => [
'index' => 'GET',
'store' => 'POST',
],
];
/**
* Route ファサードにマクロを定義します。
*/
public function boot(): void
{
RouteFacade::macro('authorizedApiResource', function (string $className, $parentClassNames = null): Router {
return AuthorizedApiResourceMacroServiceProvider::authorizedApiResource($className, $parentClassNames);
});
}
/**
* リソースルーティングを定義します。
*
* @param string $className
* @param null|array|string $parentClassNames
* @return Router
*/
public static function authorizedApiResource(string $className, $parentClassNames = null): Router
{
foreach (static::$abilityMap['independent'] as $ability => $method) {
static::registerRoute($ability, $method, $className, null, true);
}
foreach (static::$abilityMap['dependent'] as $ability => $method) {
if ($parentClassNames === null) {
static::registerRoute($ability, $method, $className, null, false);
} else {
foreach ((array)$parentClassNames as $i => $parentClassName) {
static::registerRoute($ability, $method, $className, $parentClassName, false);
}
}
}
return app('router');
}
/**
* 1つのルーティングを定義します。
*
* @param string $ability
* @param string $method
* @param string $className
* @param null|string $parentClassName
* @param bool $resourceAlreadyExists
* @return Route
*/
protected static function registerRoute(string $ability, string $method, string $className, ?string $parentClassName, bool $resourceAlreadyExists): Route
{
return RouteFacade::match(
$method,
static::compileUri($className, $parentClassName, $resourceAlreadyExists),
static::controllerName($className) . '@' . $ability
)->middleware(
static::compileMiddleware($ability, $className, $parentClassName, $resourceAlreadyExists)
)->name(
static::compileName($ability, $className, $parentClassName)
);
}
/**
* URIを定義します。
*
* 親リソースがある例: communities/{communities}/articles ← ArticleController::index(Request $request, Community $community)
* 親リソースがない例: articles/{article} ← ArticleController::show(Request $request, Article $article)
*
* @param string $className
* @param null|string $parentClassName
* @param bool $resourceAlreadyExists
* @return string
*/
protected static function compileUri(string $className, ?string $parentClassName, bool $resourceAlreadyExists): string
{
$segments = [];
if ($parentClassName !== null) {
$segments[] = static::parameterName($parentClassName);
$segments[] = '{' . static::placeholderName($parentClassName) . '}';
}
$segments[] = static::parameterName($className);
if ($resourceAlreadyExists) {
$segments[] = '{' . static::placeholderName($className) . '}';
}
return implode('/', $segments);
}
/**
* 認可のミドルウェアを定義します。
*
* 親リソースがある例: can:index,\App\Article,community ← ArticlePolicy::index(User $user, Community $community)
* 親リソースがない例: can:show,article ← ArticlePolicy::show(User $user, Article $article)
*
* @param string $ability
* @param string $className
* @param null|string $parentClassName
* @param bool $resourceAlreadyExists
* @return string
*/
protected static function compileMiddleware(string $ability, string $className, ?string $parentClassName, bool $resourceAlreadyExists): string
{
$segments = [
$ability,
$resourceAlreadyExists ? static::placeholderName($className) : $className,
];
if ($parentClassName !== null) {
$segments[] = static::placeholderName($parentClassName);
}
return 'can:' . implode(',', $segments);
}
/**
* ルーティング名称を定義します。
*
* 親リソースがある例: communities.articles.index
* 親リソースがない例: articles.show
*
* @param string $ability
* @param string $className
* @param null|string $parentClassName
* @return string
*/
protected static function compileName(string $ability, string $className, ?string $parentClassName): string
{
if ($parentClassName !== null) {
$segments[] = static::parameterName($parentClassName);
}
$segments[] = static::parameterName($className);
$segments[] = $ability;
return implode('.', $segments);
}
/**
* モデル名からプレースホルダ名に変換します。
*
* @param string $className
* @return string
*/
protected static function placeholderName(string $className): string
{
return Str::snake(class_basename($className));
}
/**
* モデル名からパラメータ名に変換します。
*
* @param string $className
* @return string
*/
protected static function parameterName(string $className): string
{
return Str::snake(Str::plural(class_basename($className)));
}
/**
* モデル名からコントローラ名に変換します。
* (プレフィックスは RouteServiceProvider の $namespace で定義されているため,クラス名部分だけを返せばいい)
*
* @param string $className
* @return string
*/
protected static function controllerName(string $className): string
{
return Str::studly(class_basename($className)) . 'Controller';
}
}
使用例
ルーティング
以下のようにルーティングをとても簡単に記述できます。
Route::authorizedApiResource(Community::class);
Route::authorizedApiResource(Article::class, Community::class);
Route::authorizedApiResource(Event::class, Community::class);
Route::authorizedApiResource(Comment:class, [Article::class, Event::class]);
ポリシー
取りうる親エンティティが複数ある場合は,共通の1つの変数として受け取ります。
public function show(User $user, Article $article)
{
// ユーザがコミュニティに所属していることを検証する
return $user->communities()->wherePivot('community_id', $article->community_id)->exists();
}
public function index(User $user, Community $community)
{
// ユーザがコミュニティに所属していることを検証する
return $user->communities()->wherePivot('community_id', $community->id)->exists();
}
public function index(User $user, $commentable)
{
// ユーザがコミュニティに所属していることを検証する
return $user->communities()->wherePivot('community_id', $commentable->community_id)->exists();
}
コントローラ
取りうる親エンティティが複数ある場合は,型指定した引数をnull
許容して列挙します。
public function show(Article $article)
{
return $article;
}
public function index(Request $request, Community $community)
{
$q = $community->articles();
if ($maxId = $request->input('max_id')) {
$q->where('id', '<=', $maxId);
}
return $q->get();
}
public function index(Request $request, Article $article = null, Event $event = null)
{
$commentable = $article ?: $event;
$q = $commentable->comments();
if ($maxId = $request->input('max_id')) {
$q->where('id', '<=', $maxId);
}
return $q->get();
}
もしポリシーの範疇に収まらない追加のバリデーションが必要な場合,フォームリクエストバリデーションを使うと良いでしょう。コントローラはスカスカで美しいまま維持できます。
class ArticleValidation extends Request
{
/**
* 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 [
'title' => 'required|max:30',
'body' => 'required',
];
}
}
public function store(ArticleValidation $request, Community $community)
{
$article = Article::make($request->only(['title', 'body']))
->community()->associate($community)
->user()->associate(Auth::user());
$article->save();
return $article;
}
public function update(ArticleValidation $request, Article $article)
{
$article->fill($request->only(['title', 'body']))->save();
return $article;
}
これでルーティングのお悩みも解決!
2日連続で占領してすいませんでした(どうしても書きたいネタが2つにしか絞れなかった)
6日目は @shinnoki さんからコントローラまわりのお話についてです。