PHP

JSONデータを自在に整形!PHPでAPI作るならFractalがめっちゃ便利だ!

2017/04/04

Younes Rafie

0

Articles in this issue reproduced from SitePoint
Copyright © 2017, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2017, KADOKAWA CorporationJapanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

PHPでAPIを作るときに便利な「Fractal」を知っていますか? APIで出力するJSONデータを簡単に整形できます。

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

データベースを作成する

データベースにはusersrolesのテーブルがあり、すべてのユーザーにはロールが割り当てられ、それぞれのロールにはパーミッションの一覧があります。

// 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クラスを作成し、とりあえずはindexshowアクションだけを定義します。

// 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

Copyright © 2017, Younes Rafie All Rights Reserved.

Younes Rafie

Younes Rafie

モロッコ出身のフリーランスのWeb開発者、技術系ライター・ブロガーです。JAVA、J2EE、JavaScriptでの共同作業経験があり、専門はPHP言語です。

Loading...