APIを構築した経験があれば、データを直接レスポンスとしてダンプすることには慣れているでしょう。正しくダンプできれば問題ありませんが、この小さな手間を解消する実用的な代替案があります。
その1つがFractalです。Fractalを使うとレスポンスを返す前に新たに変換レイヤーをモデルに加えられます。高い柔軟性を持つのでどのようなアプリケーションやフレームワークにも簡単に導入できます。
インストール
この記事ではLaravel 5.3アプリを使ってデモを作成し、Fractalパッケージと統合します。インストーラーを使うか、composer経由で新しいLaravelアプリを作成してください。
インストーラーを使う場合は、次のようにします。
laravel new demo
composer経由の場合は、次のようにします。
composer create-project laravel/laravel demo
フォルダー内には、Fractalパッケージが必要です。
composer require league/fractal
データベースを作成する
データベースにはusersとrolesのテーブルがあり、すべてのユーザーにはロールが割り当てられ、それぞれのロールにはパーミッションの一覧があります。
// app/User.php
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'password',
'role_id',
];
protected $hidden = [
'password', 'remember_token',
];
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function role()
{
return $this->belongsTo(Role::class);
}
}
// app/Role.php
class Role extends Model
{
protected $fillable = [
'name',
'slug',
'permissions'
];
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function users()
{
return $this->hasMany(User::class);
}
}
Transformerを作成する
それぞれのモデルにtransformerを作成します。作成するUserTransformerクラスは次のようなものです。
// app/Transformers/UserTransformer.php
namespace App\Transformers;
use App\User;
use League\Fractal\TransformerAbstract;
class UserTransformer extends TransformerAbstract
{
public function transform(User $user)
{
return [
'name' => $user->name,
'email' => $user->email
];
}
}
はい、これだけでtransformerは完成です! これで開発者が指定する方法でデータを変換でき、ORMやリポジトリの影響を受けません。
ここではTransformerAbstractクラスを継承し、Userインスタンスを引数に呼び出すtransformメソッドを定義しています。RoleTransformerクラスについても同様です。
namespace App\Transformers;
use App\Role;
use League\Fractal\TransformerAbstract;
class RoleTransformer extends TransformerAbstract
{
public function transform(Role $role)
{
return [
'name' => $role->name,
'slug' => $role->slug,
'permissions' => $role->permissions
];
}
}
Controllerを作成する
controllerはデータをユーザーに返す前にデータを変換します。UsersControllerクラスを作成し、とりあえずはindexとshowアクションだけを定義します。
// app/Http/Controllers/UsersController.php
class UsersController extends Controller
{
/**
* @var Manager
*/
private $fractal;
/**
* @var UserTransformer
*/
private $userTransformer;
function __construct(Manager $fractal, UserTransformer $userTransformer)
{
$this->fractal = $fractal;
$this->userTransformer = $userTransformer;
}
public function index(Request $request)
{
$users = User::all(); // Get users from DB
$users = new Collection($users, $this->userTransformer); // Create a resource collection transformer
$users = $this->fractal->createData($users); // Transform data
return $users->toArray(); // Get transformed array of data
}
}
indexアクションはデータベースから全ユーザーを取得し、ユーザーとtransformerのリストを含むリソースコレクションを作成して、実際の変換プロセスを実行します。
{
"data": [
{
"name": "Nyasia Keeling",
"email": "crooks.maurice@example.net"
},
{
"name": "Laron Olson",
"email": "helen55@example.com"
},
{
"name": "Prof. Fanny Dach III",
"email": "edgardo13@example.net"
},
{
"name": "Athena Olson Sr.",
"email": "halvorson.jules@example.com"
}
// ...
]
}
もちろん全ユーザーを一度に返すのはナンセンスなので、ページ区切り機能を実装します。
ページ区切り
Laravelを使うとシンプルに実装できることが多く、ページ区切りは次のようにします。
$users = User::paginate(10);
ただしFractalを組み込むために、ページ区切りを制御するpaginatorを呼び出す前にデータを変換する短いコードを加えます。
// app/Http/Controllers/UsersController.php
class UsersController extends Controller
{
// ...
public function index(Request $request)
{
$usersPaginator = User::paginate(10);
$users = new Collection($usersPaginator->items(), $this->userTransformer);
$users->setPaginator(new IlluminatePaginatorAdapter($usersPaginator));
$users = $this->fractal->createData($users); // Transform data
return $users->toArray(); // Get transformed array of data
}
}
最初はモデルからのデータをページで区切って、先と同様にリソースコレクションを作成し、paginatorをコレクションにセットしています。
FractalにはLengthAwarePaginatorクラスを書き換えるpaginatorアダプターがLaravel向けに用意されています。またSymfonyやZend向けのものもあります。
{
"data": [
{
"name": "Nyasia Keeling",
"email": "crooks.maurice@example.net"
},
{
"name": "Laron Olson",
"email": "helen55@example.com"
},
// ...
],
"meta": {
"pagination": {
"total": 50,
"count": 10,
"per_page": 10,
"current_page": 1,
"total_pages": 5,
"links": {
"next": "http://demo.vaprobash.dev/users?page=2"
}
}
}
}
ページ区切りの詳細に関する追加のフィールドが必要なことに注意してください。詳しいページ区切りについてはドキュメントを読んでください。
サブリソースを追加する
Fractalの使い方が分かったところで、ユーザーの要求に応じてサブリソース(リレーション)をレスポンスに含める方法を紹介します。
次のようにして、追加のリソースをレスポンスに含められます。http://demo.vaprobash.dev/users?include=role. transformerが自動的にリクエストされたものを検知してincludeパラメーターを読み取ります。
// app/Transformers/UserTransformer.php
class UserTransformer extends TransformerAbstract
{
protected $availableIncludes = [
'role'
];
public function transform(User $user)
{
return [
'name' => $user->name,
'email' => $user->email
];
}
public function includeRole(User $user)
{
return $this->item($user->role, App::make(RoleTransformer::class));
}
}
$availableIncludesプロパティを使ってtransformerに追加データをレスポンスに含める必要があることを伝えます。includeクエリのパラメーターがユーザーのロールを要求していれば、includeRoleメソッドを呼び出します。
// app/Http/Controllers/UsersController.php
class UsersController extends Controller
{
// ...
public function index(Request $request)
{
$usersPaginator = User::paginate(10);
$users = new Collection($usersPaginator->items(), $this->userTransformer);
$users->setPaginator(new IlluminatePaginatorAdapter($usersPaginator));
$this->fractal->parseIncludes($request->get('include', '')); // parse includes
$users = $this->fractal->createData($users); // Transform data
return $users->toArray(); // Get transformed array of data
}
}
$this->fractal->parseIncludesの行はincludeクエリパラメーターを読み取ります。もしユーザーの一覧を要求すれば、次のようなものが返ってきます。
{
"data": [
{
"name": "Nyasia Keeling",
"email": "crooks.maurice@example.net",
"role": {
"data": {
"name": "User",
"slug": "user",
"permissions": [ ]
}
}
},
{
"name": "Laron Olson",
"email": "helen55@example.com",
"role": {
"data": {
"name": "User",
"slug": "user",
"permissions": [ ]
}
}
},
// ...
],
"meta": {
"pagination": {
"total": 50,
"count": 10,
"per_page": 10,
"current_page": 1,
"total_pages": 5,
"links": {
"next": "http://demo.vaprobash.dev/users?page=2"
}
}
}
}
もし、すべてのユーザーにロールのリストがあるなら、transformerを次のように書き換えられます。
// app/Transformers/UserTransformer.php
class UserTransformer extends TransformerAbstract
{
protected $availableIncludes = [
'roles'
];
public function transform(User $user)
{
return [
'name' => $user->name,
'email' => $user->email
];
}
public function includeRoles(User $user)
{
return $this->collection($user->roles, App::make(RoleTransformer::class));
}
}
サブリソースを含めるとき、ドット記号を使ってリレーションをネストできます。すべてのロールに別テーブルに保存されているパーミッションの一覧があり、ユーザーをロールとパーミッションの情報とをあわせて一覧で表示したいならinclude=role.permissionsとします。
addressのような必要なリレーションをデフォルトで含めたいこともあるでしょう。その場合は$defaultIncludesプロパティをtransformer内で使います。
class UserTransformer extends TransformerAbstract
{
// ...
protected $defaultIncludes = [
'address'
];
// ...
}
Fractalパッケージのメリットの1つは、パラメーターを含めるためのパラメーターを渡す機能です。ドキュメントに書かれているorder byが分かりやすく、この記事のデモでは次のように使っています。
// app/Transformers/RoleTransformer.php
use App\Role;
use Illuminate\Support\Facades\App;
use League\Fractal\ParamBag;
use League\Fractal\TransformerAbstract;
class RoleTransformer extends TransformerAbstract
{
protected $availableIncludes = [
'users'
];
public function transform(Role $role)
{
return [
'name' => $role->name,
'slug' => $role->slug,
'permissions' => $role->permissions
];
}
public function includeUsers(Role $role, ParamBag $paramBag)
{
list($orderCol, $orderBy) = $paramBag->get('order') ?: ['created_at', 'desc'];
$users = $role->users()->orderBy($orderCol, $orderBy)->get();
return $this->collection($users, App::make(UserTransformer::class));
}
}
ここで重要なのはlist($orderCol, $orderBy) = $paramBag->get('order') ?: ['created_at', 'desc'];で、orderパラメーターをユーザーから受け取り、クエリビルダーに適用しています。
これでパラメーター(/roles?include=users:order(name|asc))を渡して、インクルードされているユーザーリストを並び替えられます。リソースをインクルードする方法の詳細についてはドキュメントを読んでください。
しかし、このままではnullではなく有効なデータを要求しているため、ユーザーにロールがなにも割り当てられていない場合にエラーで停止してしまいます。そこでnullの場合に、リレーションを表示する代わりにレスポンスから削除するよう修正します。
// app/Transformers/UserTransformer.php
class UserTransformer extends TransformerAbstract
{
protected $availableIncludes = [
'roles'
];
public function transform(User $user)
{
return [
'name' => $user->name,
'email' => $user->email
];
}
public function includeRoles(User $user)
{
if (!$user->role) {
return null;
}
return $this->collection($user->roles, App::make(RoleTransformer::class));
}
}
Eager Loading
Eloquentはモデルにアクセスするときに、遅延読み込み(Lazy Load)するので、n+1の問題を抱えています。そこで、リレーションをまとめてEager Loadしてクエリを最適化することで解決します。
class UsersController extends Controller
{
// ...
public function index(Request $request)
{
$this->fractal->parseIncludes($request->get('include', '')); // parse includes
$usersQueryBuilder = User::query();
$usersQueryBuilder = $this->eagerLoadIncludes($request, $usersQueryBuilder);
$usersPaginator = $usersQueryBuilder->paginate(10);
$users = new Collection($usersPaginator->items(), $this->userTransformer);
$users->setPaginator(new IlluminatePaginatorAdapter($usersPaginator));
$users = $this->fractal->createData($users); // Transform data
return $users->toArray(); // Get transformed array of data
}
protected function eagerLoadIncludes(Request $request, Builder $query)
{
$requestedIncludes = $this->fractal->getRequestedIncludes();
if (in_array('role', $requestedIncludes)) {
$query->with('role');
}
return $query;
}
}
この方法なら、モデルリレーションにアクセスするときに追加のクエリが発生しません。
最後に
私はPhil Sturgeonの嫌いにならないAPIを構築するを読んでFractalに出会いました。役立つ情報がつまったすばらしい本で、心からおすすめします。
※本記事はViraj Khatavkarが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:PHP Fractal – Make Your API’s JSON Pretty, Always!)
[翻訳:内藤夏樹/編集:Livit]