API 资源类:架起模型与 JSON API 之间的桥梁


简介

构建 API 时,在 Eloquent 模型和最终返回给应用用户的 JSON 响应之间可能需要一个转化层。Laravel 的资源类允许你以简单优雅的方式将模型和模型集合转化为 JSON 格式数据。

生成资源类

要生成一个资源类,可以使用 Artisan 命令 make:resource,默认情况下,资源类存放在应用的 app/Http/Resources 目录下,资源类都继承自 Illuminate\Http\Resources\Json\Resource 基类:

php artisan make:resource UserResource

资源集合

除了生成转化独立模型的资源类之外,还可以生成转化模型集合的资源类。这样响应就可以包含链接和其他与整个给定资源集合相关的元信息。

要创建一个资源集合处理类,需要在创建资源类的时候使用 --collection 标记,或者在资源名称中包含单词 Collection 以便告知 Laravel 需要创建一个资源集合类,资源集合类继承自 Illuminate\Http\Resources\Json\ResourceCollection 基类:

php artisan make:resource Users --collection
php artisan make:resource UserCollection

核心概念

注:这是一个关乎资源和资源集合的高屋建瓴的概述,强烈推荐你阅读本文档的其他部分来深入强化理解资源类提供的功能和定制化。

在深入了解资源类提供的所有功能之前,我们先来高屋建瓴的看一下如何在 Laravel 中使用资源类。一个资源类表示一个单独的需要被转化为 JSON 数据结构的模型,例如,下面是一个简单的 UserResource 类:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\Resource;

class UserResource extends Resource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\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\UserResource;

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

资源集合

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

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

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

当然,这种方式不能添加除模型数据之外的其他需要和集合一起返回的元数据,如果你想要自定义资源集合响应,可以创建专用的资源类来表示集合:

php artisan make:resource UserCollection

资源集合类生成之后,可以很轻松地定义需要包含到响应中的元数据:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * Transform the resource collection into an array.
     *
     * @param  \Illuminate\Http\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());
});

编写资源类

注:如果你还没有阅读核心概念,强烈建议你在继续下去之前阅读那部分文档。

其实资源类很简单,它们所做的只是将给定模型转化为数组,因此,每个资源类都包含 toArray 方法,用于将模型属性转化为一个可以返回给用户的、API 友好的数组:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\Resource;

class UserResource extends Resource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\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\UserResource;

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

关联关系

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

/**
 * 将资源转化为数组
 *
 * @param  \Illuminate\Http\Request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'posts' => Post::collection($this->posts),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}
注:如果你想要只有在关联关系被加载之后包含它们,请参考附加条件的关联关系部分文档。

资源集合

资源类用于将单个模型转化为数组,而资源集合类用于将模型集合转化为数组。并不是每种类型的模型都需要定义一个资源集合类,因为所有资源类都提供了一个 collection 方法立马生成一个特定的资源集合:

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

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

不过,如果你需要自定义与集合一起返回的元数据,就需要定义一个资源集合类了:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * Transform the resource collection into an array.
     *
     * @param  \Illuminate\Http\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 或者其他每个请求都会加载的服务提供者中调用这个方法:

<?php

namespace App\Providers;

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

class AppServiceProvider extends ServiceProvider
{
    /**
     * Perform post-registration booting of services.
     *
     * @return void
     */
    public function boot()
    {
        Resource::withoutWrapping();
    }

    /**
     * Register bindings in the container.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}
注:withoutWrapping 只影响最外层资源,并不会移除你手动添加到自己的资源集合类中的 data 键。

包装嵌套资源

你完全可以自己决定如何包装资源的关联关系。如果你想要所有资源集合包裹到 data 键里,而不管它们之间的嵌套,那么就需要为每个资源定义一个资源集合类并通过 data 键返回这个集合。

当然,你可能会担心这样做会不会导致最外层的资源被包装到两个 data 键,如果你这样想的话就是完全多虑了,Laravel 永远不会让资源出现双层包装,所以你大可不必担心转化资源集合的嵌套问题:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class CommentsCollection extends ResourceCollection
{
    /**
     * Transform the resource collection into an array.
     *
     * @param  \Illuminate\Http\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
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'secret' => $this->when($this->isAdmin(), 'secret-value'),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

在这个例子中,secret 键只有在 $this->isAdmin() 方法返回 true 的前提下才会出现在最终资源响应中。如果这个方法返回 falsesecret 键将会在资源响应发送给客户端之前从返回数据中完全移除。when 方法让你在任何时候都可以优雅地定义资源类,而不必在构建数组时重新编写条件语句。

when 方法还可以接受闭包作为第二个参数,从而允许你在给定条件为 true 的时候计算返回值:

'secret' => $this->when($this->isAdmin(), function () {
    return 'secret-value';
}),
注:记住,在资源类上的方法调用实际上代理的是底层模型实例方法,所以,在这个示例中,isAdmin 方法调用的实际上底层 User 模型上的方法。
合并带条件的属性

有时候你可能有多个属性基于同一条件才会包含到资源响应中,在这种情况下,你可以使用 mergeWhen 方法在给定条件为 true 的前提下来包含这些属性到响应中:

/**
 * Transform the resource into an array.
 *
 * @param  \Illuminate\Http\Request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        $this->mergeWhen($this->isAdmin(), [
            'first-secret' => 'value',
            'second-secret' => 'value',
        ]),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

再次说明,如果给定条件为 false,这些属性将会在资源响应发送给客户端之前从响应数据中完全移除。

注:mergeWhen 方法不能在混合字符串和数字键的数组中使用,此外,也不能在没有顺序排序的纯数字键数组中使用。

带条件的关联关系

除了通过条件加载属性之外,还可以基于给定关联关系是否在模型上加载的条件在资源响应中包含关联关系。这样的话在控制器中就可以决定哪些关联关系需要被加载,然后资源类就可以在关联关系确实已经被加载的情况下很轻松地包含它们。

最后,这种机制也让我们在资源类中避免 N+1 查询问题变得简单。whenLoaded 方法可用于带条件的加载关联关系。为了避免不必要的关联关系加载,该方法接收关联关系名称而不是关联关系本身:

/**
 * Transform the resource into an array.
 *
 * @param  \Illuminate\Http\Request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'posts' => Post::collection($this->whenLoaded('posts')),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

在这个例子中,如果关联关系还没有被加载,posts 键将会在资源响应发送到客户端之前从响应数据中移除。

带条件的中间表信息

除了在资源响应中带条件的包含关联关系信息之外,你还可以使用 whenPivotLoaded 方法从多对多关联关系中间表中引入满足条件的数据。whenPivotLoaded 方法接收中间表名称作为第一个参数,第二个参数是一个闭包,该闭包定义了如果模型对应中间表信息有效的情况下返回的数据:

/**
 * Transform the resource into an array.
 *
 * @param  \Illuminate\Http\Request
 * @return array
 */
public function toArray($request)
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'expires_at' => $this->whenPivotLoaded('role_users', function () {
            return $this->pivot->expires_at;
        }),
    ];
}

添加元数据

有些 JSON API 标准要求添加额外的元数据到资源响应或资源集合响应。这些元数据通常包含链接到资源或关联资源的 links,或者关于资源本身的元数据。如果你需要返回关于资源的额外元数据,可以在 toArray 方法中包含它们。例如,你可以在转化资源集合时引入 links 信息:

/**
 * Transform the resource into an array.
 *
 * @param  \Illuminate\Http\Request
 * @return array
 */
public function toArray($request)
{
    return [
        'data' => $this->collection,
        'links' => [
            'self' => 'link-value',
        ],
    ];
}

从资源类返回额外元数据的场景下,在返回分页响应时永远不必担心意外覆盖 Laravel 自动添加的 linksmeta 键,任何自定义的额外 links 键都会被合并到分页器提供的链接中。

顶层元数据

有时候你可能希望只有在资源是最外层被返回数据的情况下包含特定元数据。通常情况下,这将会包含关于整个响应的元数据,要定义这个元数据,需要添加一个 with 方法到你的资源类。该方法只有在资源是最外层被渲染数据的情况下才会返回一个被包含到资源响应中的元数据数组:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * Transform the resource collection into an array.
     *
     * @param  \Illuminate\Http\Request
     * @return array
     */
    public function toArray($request)
    {
        return parent::toArray($request);
    }

    /**
     * Get additional data that should be returned with the resource array.
     *
     * @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\UserResource;

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

不过,有时候你可能需要在响应发送到客户端之前自定义输出 HTTP 响应。有两种方法来实现这个功能,第一种是链接 response 方法到资源类,该方法会返回一个 Illuminate\Http\Response 实例,从而允许你完全控制响应头:

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

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

另一种方法是在资源类中定义一个 withResponse 方法,该方法会在资源在响应中作为最外层数据返回时被调用:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\Resource;

class UserResource extends Resource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
        ];
    }

    /**
     * Customize the outgoing response for the resource.
     *
     * @param  \Illuminate\Http\Request
     * @param  \Illuminate\Http\Response
     * @return void
     */
    public function withResponse($request, $response)
    {
        $response->header('X-Value', 'True');
    }
}

点赞 取消点赞 收藏 取消收藏

<< 上一篇: 使用访问器和修改器格式化模型数据

>> 下一篇: 将模型数据序列化为数组或 JSON