Eloquent:API 资源

简介

构建 API 时,可能需要一个位于 Eloquent 模型和实际返回给应用用户的 JSON 响应之间的转换层。Laravel 的资源类允许您直观并轻松地将模型和模型集合转换为 JSON。

生成资源

要生成资源类,可以使用 Artisan 命令 make:resource。默认情况下,资源放在应用的 app/Http/Resources 目录中。资源继承了 Illuminate\Http\Resources\Json\JsonResource 类:

php artisan make:resource User

资源集合

除了生成转换单个模型的资源外,还可以生成负责转换模型集合的资源。资源集合允许您在响应中包含相关的链接和其它元信息。

创建资源集合,可以在创建资源时使用 --collection 标识。或者,在资源名称中包含单词 Collection 指示 Laravel 应创建一个资源集合。资源集合继承了 Illuminate\Http\Resources\Json\ResourceCollection 类:

php artisan make:resource Users --collection

php artisan make:resource UserCollection

概念概述

这是对资源和资源集合的高层次概述。我们强烈建议您阅读本文档的其它部分,深入了解如何自定义资源以及资源的作用。

在深入了解编写资源时所有可用选项之前,我们先来看看在 Laravel 中是如何使用资源的。资源类代表需要转换为 JSON 结构的单个模型。例如,这里有一个简单的 User 资源类:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class User extends JsonResource
{
    /**
     * 将资源转换为数组
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

每个资源类都定义了一个 toArray 方法,此方法返回发送响应时要转换为 JSON 的属性数组。需要注意的是,我们可以直接使用 $this 变量访问模型属性。为了便于访问,资源类会自动代理底层模型的属性和方法。定义资源后,可以在路由或控制器中返回资源:

use App\User;
use App\Http\Resources\User as UserResource;

Route::get('/user', function () {
    return new UserResource(User::find(1));
});

资源集合

如果要返回一个资源集合或者分页响应,可以在路由或控制器中创建资源实例时使用 collection 方法:

use App\User;
use App\Http\Resources\User as UserResource;

Route::get('/user', function () {
    return UserResource::collection(User::all());
});

当然,此处不允许添加和集合一起返回的任何元数据。如果要自定义资源集合的响应,可以创建代表集合的专用资源:

php artisan make:resource UserCollection

生成资源集合类后,就可以轻松定义包含在响应中的元数据了:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * 将资源集合转换为数组
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'data' => $this->collection,
            'links' => [
                'self' => 'link-value',
            ],
        ];
    }
}

定义后,可以在路由或控制器中返回资源:

use App\User;
use App\Http\Resources\UserCollection;

Route::get('/users', function () {
    return new UserCollection(User::all());
});

自定义底层资源类

通常情况下,资源集合的 $this->collection 属性会自动赋值为集合中的元素映射到对应资源类后的结果。并假定对应的单个资源类是集合的类名去掉末尾的 Collection 字符串后的部分。

例如,UserCollection 会将给定用户实例映射到 User 资源。要自定义映射的类,可以覆盖资源集合的 $collects 属性:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * 此资源集合的子元素对应的资源
     *
     * @var string
     */
    public $collects = 'App\Http\Resources\Member';
}

编写资源

如果您还没有阅读 概念概述,强烈建议您在阅读本文档之前先阅读。

从本质上讲,资源很简单。它们只是将给定模型转换为数组。因此,每个资源都包含一个 toArray 方法,用于将模型属性转换为在 API 中返回给用户的数组:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class User extends JsonResource
{
    /**
     * 将资源转换为数组
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

定义资源后,可以直接在路由或控制器中返回:

use App\User;
use App\Http\Resources\User as UserResource;

Route::get('/user', function () {
    return new UserResource(User::find(1));
});

关联

如果要在响应中包含关联的资源,可以通过 toArray 方法将其添加到返回的数组中。在此示例中,我们使用 Post 资源的 collection 方法将用户的博客文章添加到资源响应中:

/**
 * 将资源转换为数组
 *
 * @param  \Illuminate\Http\Request  $request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'posts' => PostResource::collection($this->posts),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

如果只希望在已加载关联时包含关联,可以查看 条件关联 文档。

资源集合

资源将单个模型转换为数组,而资源集合将模型集合转换为数组。当然,也不一定非要为每个模型定义一个资源集合类,因为所有资源都提供一个生成资源集合的 collection 方法:

use App\User;
use App\Http\Resources\User as UserResource;

Route::get('/user', function () {
    return UserResource::collection(User::all());
});

但是,如果需要自定义集合中返回的元数据,就得定义一个资源集合了:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * 将资源集合转换为数组
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'data' => $this->collection,
            'links' => [
                'self' => 'link-value',
            ],
        ];
    }
}

与单个资源一样,可以直接在路由或控制器中返回资源集合:

use App\User;
use App\Http\Resources\UserCollection;

Route::get('/users', function () {
    return new UserCollection(User::all());
});

数据包裹

默认情况下,当资源响应被转换为 JSON 时,最外层资源会被包裹在 data 键中。例如,一个典型的资源集合响应看起来像这样:

{
    "data": [
        {
            "id": 1,
            "name": "Eladio Schroeder Sr.",
            "email": "therese28@example.com",
        },
        {
            "id": 2,
            "name": "Liliana Mayert",
            "email": "evandervort@example.com",
        }
    ]
}

如果要禁用对最外层资源的包裹,可以在资源基类上使用 withoutWrapping 方法。通常情况下,应该在 AppServiceProvider 中调用此方法,或者在另一个 服务提供者 中:

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Http\Resources\Json\Resource;

class AppServiceProvider extends ServiceProvider
{
    /**
     * 注册后启动服务
     *
     * @return void
     */
    public function boot()
    {
        Resource::withoutWrapping();
    }

    /**
     * 注册容器绑定
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

withoutWrapping 方法只影响最外层响应,并不会移除手动添加到自己的资源集合的 data 键。

包裹嵌套资源

您可以自由决定如何包裹资源的关联。如果想无视嵌套将所有资源集合都包裹在 data 键中,可以为每个资源定义一个资源集合类并在 data 键中返回集合。

当然,您可能想知道这样做是否会造成最外层资源被包裹到两个 data 键中。别担心,Laravel 不会让资源被意外地二次包裹,因此不用考虑要转换的资源集合的嵌套层级:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class CommentsCollection extends ResourceCollection
{
    /**
     * 将资源集合转换为数组
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return ['data' => $this->collection];
    }
}

数据包裹和分页

当在资源响应中返回分页集合时,Laravel 会将资源数据包裹到 data 键中,即使调用了 withoutWrapping 方法。这是因为分页响应总是包含有关分页器状态信息的 metalinks 键:

{
    "data": [
        {
            "id": 1,
            "name": "Eladio Schroeder Sr.",
            "email": "therese28@example.com",
        },
        {
            "id": 2,
            "name": "Liliana Mayert",
            "email": "evandervort@example.com",
        }
    ],
    "links":{
        "first": "http://example.com/pagination?page=1",
        "last": "http://example.com/pagination?page=1",
        "prev": null,
        "next": null
    },
    "meta":{
        "current_page": 1,
        "from": 1,
        "last_page": 1,
        "path": "http://example.com/pagination",
        "per_page": 15,
        "to": 10,
        "total": 10
    }
}

分页

您可以始终将分页器实例传递给资源的 collection 方法或者自定义资源集合:

use App\User;
use App\Http\Resources\UserCollection;

Route::get('/users', function () {
    return new UserCollection(User::paginate());
});

分页响应总是包含有关分页器状态信息的 metalinks 键:

{
    "data": [
        {
            "id": 1,
            "name": "Eladio Schroeder Sr.",
            "email": "therese28@example.com",
        },
        {
            "id": 2,
            "name": "Liliana Mayert",
            "email": "evandervort@example.com",
        }
    ],
    "links":{
        "first": "http://example.com/pagination?page=1",
        "last": "http://example.com/pagination?page=1",
        "prev": null,
        "next": null
    },
    "meta":{
        "current_page": 1,
        "from": 1,
        "last_page": 1,
        "path": "http://example.com/pagination",
        "per_page": 15,
        "to": 10,
        "total": 10
    }
}

条件属性

有时可能只希望在给定条件下才将属性包含到资源响应中。例如,可能只希望当前用户是「管理员」时才包含一个值。Laravel 提供了各种有用的方法帮助您处理此类情况。when 方法可用于按条件在资源响应中添加属性:

/**
 * 将资源转换为数组
 *
 * @param  \Illuminate\Http\Request  $request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'secret' => $this->when(Auth::user()->isAdmin(), 'secret-value'),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

在此示例中,只有当认证用户的 isAdmin 方法返回 true 时,才会在最终的资源响应中返回 secret 键。如果此方法返回 false,在向客户端发送资源响应前,secret 会从响应中完全移除。when 方法允许您直观地定义资源,而不用在构建数组时根据条件语句重新排序。

when 方法也接收一个闭包作为其第二个参数,允许您在给定条件为 true 时返回结果值:

'secret' => $this->when(Auth::user()->isAdmin(), function () {
    return 'secret-value';
}),

合并条件属性

有时可能在同一条件下有多个属性要被包含到资源响应中。这种情况下,可以使用 mergeWhen 方法仅在给定条件为 true 时将属性包含到响应中:

/**
 * 将资源转换为数组
 *
 * @param  \Illuminate\Http\Request  $request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        $this->mergeWhen(Auth::user()->isAdmin(), [
            'first-secret' => 'value',
            'second-secret' => 'value',
        ]),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

同样,如果给定条件为 false,在向客户端发送资源响应前,这些属性会从响应中完全移除。

mergeWhen 方法不应在含有字符串和数字混合键的数组中使用。此外,不应在含有不连续数字键的数组中使用。

条件关联

除了按条件加载属性外,还可以根据模型是否已加载关联来决定是否将关联包含到资源响应中。这允许控制器决定在模型上要加载哪些关联,并且资源可以轻松包含实际加载的关联。

最重要的是,可以在资源中轻松避免「N+1」查询问题。whenLoaded 可用于按条件加载关联。为了避免加载不必要的关联,此方法接收关联名而不是关联本身:

/**
 * 将资源转换为数组
 *
 * @param  \Illuminate\Http\Request  $request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'posts' => PostResource::collection($this->whenLoaded('posts')),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

在此示例中,如果关联没有被加载,在向客户端发送资源响应前,posts 键会从响应中完全移除。

有条件的中间表信息

除了在资源响应中按条件包含关联信息外,还可以使用 whenPivotLoaded 方法包含多对多关联的中间表数据。whenPivotLoaded 方法接收中间表名作为其第一个参数。第二个参数是定义当模型上的中间表信息可用时返回值的闭包:

/**
 * 将资源转换为数组
 *
 * @param  \Illuminate\Http\Request  $request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'expires_at' => $this->whenPivotLoaded('role_user', function () {
            return $this->pivot->expires_at;
        }),
    ];
}

如果中间表属性名称不是 pivot,可以使用 whenPivotLoadedAs 方法:

/**
 * 将资源转换为数组
 *
 * @param  \Illuminate\Http\Request  $request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'expires_at' => $this->whenPivotLoadedAs('subscription', 'role_user', function () {
            return $this->subscription->expires_at;
        }),
    ];
}

添加元数据

有些 JSON API 标准要求在资源和资源集合响应中添加元数据。元数据通常包含资源或相关资源的 links 这类信息,或者有关资源本身的元数据。如果需要返回有关资源的额外元数据,可以在 toArray 方法中包含它。例如,在转换资源集合时包含 link 信息:

/**
 * 将资源转换为数组
 *
 * @param  \Illuminate\Http\Request  $request
 * @return array
 */
public function toArray($request)
{
    return [
        'data' => $this->collection,
        'links' => [
            'self' => 'link-value',
        ],
    ];
}

当从资源返回额外的元数据时,完全不用担心返回分页响应时会意外覆盖 Laravel 自动添加的 linksmeta 键。定义的任何其它 links 都会与分页器提供的链接合并。

最外层元数据

有时可能只希望返回最外层资源时,才在资源响应中包含某些元数据。通常情况下,这包含关于整个响应的元信息。要定义这些元数据,可以在资源类中添加 with 方法。此方法返回一个仅当渲染最外层资源时要包含到资源响应的元数据数组:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * 将资源集合转换为数组
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return parent::toArray($request);
    }

    /**
     * 获取要被转换为资源数组的额外数据
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function with($request)
    {
        return [
            'meta' => [
                'key' => 'value',
            ],
        ];
    }
}

构造资源时添加元数据

还可以在路由或控制器中构造资源实例时,添加最外层数据。additional 方法可以在所有资源中使用,它接收一个要添加到资源响应的数据数组:

return (new UserCollection(User::all()->load('roles')))
                ->additional(['meta' => [
                    'key' => 'value',
                ]]);

资源响应

如之前介绍的,可以直接在路由和控制器中返回资源:

use App\User;
use App\Http\Resources\User as UserResource;

Route::get('/user', function () {
    return new UserResource(User::find(1));
});

但是,有时需要在将其发送到客户端前自定义 HTTP 响应。有两种方式可以完成此操作。首先,可以在资源上链式调用 response 方法。此方法会返回一个 Illuminate\Http\Response 实例,允许您完全控制响应头:

use App\User;
use App\Http\Resources\User as UserResource;

Route::get('/user', function () {
    return (new UserResource(User::find(1)))
                ->response()
                ->header('X-Value', 'True');
});

或者,可以在资源中定义一个 withResponse 方法。此方法会在响应中返回最外层资源时调用:

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class User extends JsonResource
{
    /**
     * 将资源转换为数组
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
        ];
    }

    /**
     * 自定义资源传出的响应
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Illuminate\Http\Response  $response
     * @return void
     */
    public function withResponse($request, $response)
    {
        $response->header('X-Value', 'True');
    }
}