从 API Resource 开始


问题引入

上篇教程学院君给大家介绍了 REST API,REST API 是针对资源型 API 路由风格的约定,并结合 HTTP 请求方法、响应状态码对 API 从语义上有完整的规约,细心的同学可能会发现,这里还有另一个重要的部分需要补充,那就是接口响应的数据格式,REST API 一般使用 JSON 作为响应数据格式,因此我们通常所说的 REST API,从接口规约完整性上说应该是 REST + JSON API。

显然,接口响应数据格式要比接口路由风格要复杂的多,即便是 JSON API,不同开发人员编写的接口返回数据格式可能也是五花八门的,这个多样性主要体现在以下几个方面(从数据结构、命名风格、代码规范几个维护):

  • 资源主体数据结构不同
  • 资源主体关联的嵌套资源引入方式不同:
    • 有些和资源主体属性一起作为平级包含在 attributes 字段中
    • 有些包含在单独的 relations 字段中
    • 有些干脆不包含在资源接口返回结果中,需要通过额外的 endpoint 去访问
  • 一些 API 字段命名风格使用驼峰,另一些则使用蛇形(小写+下划线)
  • 排序和过滤没有设定统一规范,不同开发人员按照个人喜好编写代码

既然存在这么多风格和不确定性,作为一个大型项目工程来说,就势必要做 JSON 数据格式进行统一和标准化,不然调用 API 的前端或者外部开发人员将无所适从。

从 API Resource 开始

作为 Laravel 开发人员,可能你最先想到的就是官方提供的 API Resource 特性,对 HTTP 响应的模型数据进行数据格式统一,最后以 JSON 格式返回。

在更早期的 Laravel 版本中,没有提供该特性,可以通过第三方 API 扩展包如 Dingo,或者自行封装转化器来实现。

模型和数据初始化

为此,我们初始化一个 Laravel 博客项目来演示 JSON API 的数据格式定义:

laravel new blog

完整的项目源码已经提交到 Github:https://github.com/geekr-dev/blogv1 版本。

创建文章、评论模型及关联的迁移文件:

php artisan make:model Post -m
php artisan make:model Comment -m

编写模型数据表Schema定义:

// posts
public function up()
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->string('slug')->unique();
        $table->text('content');
        $table->unsignedBigInteger('user_id');
        $table->boolean('is_draft');
        $table->unsignedInteger('views');
        $table->timestamps();
    });
}

// comments
public function up()
{
    Schema::create('comments', function (Blueprint $table) {
        $table->id();
        $table->string('content');
        $table->unsignedBigInteger('user_id');
        $table->unsignedBigInteger('post_id');
        $table->timestamps();
    });
}

.env 中配置数据库连接,运行迁移:

php artisan migrate

编写数据填充工厂:

// UserFactory
public function definition()
{
    return [
        'name' => fake()->name(),
        'email' => fake()->unique()->safeEmail(),
        'email_verified_at' => now(),
        'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
        'remember_token' => Str::random(10),
    ];
}

// PostFactory
public function definition()
{
    return [
        'title' => fake()->sentence(),
        'slug' => fake()->unique()->slug(),
        'content' => fake()->paragraphs(3, true),
        'user_id' => fake()->numberBetween(1, 100),
        'views' => fake()->numberBetween(0, 10000),
        'is_draft' => fake()->numberBetween(0, 1),
    ];
}

// CommentFactory
 public function definition()
 {
     return [
         'user_id' => fake()->numberBetween(1, 100),
         'post_id' => fake()->numberBetween(1, 1000),
         'content' => fake()->sentences(2, true),
     ];
 }

然后在 DatabaseSeeder 中编排这些模型工厂:

use App\Models\Comment;
use App\Models\Post;
use App\Models\User

public function run()
{
    User::factory(100)->create();
    Post::factory(1000)->create(['is_draft' => Post::STATUS_PUBLISHED]);
    Comment::factory(10000)->create();
}

运行数据填充器

php artisan db:seed

这样一来,我们就完成了演示所需的数据准备工作。接下来,我们通过 API Resource 来定义返回的 JSON 数据格式。

API Resource 基本使用

创建 Post 模型对应的资源类:

php artisan make:resource PostResource

routes/api.php 添加一个测试路由:

use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;

Route::get('posts/{post}', function (Request $request, Post $post) {
    return new PostResource($post);
});

就可以在 Postman 中访问 /api/posts/1 测试接口返回的数据了:

image-20221208112542875

关联嵌套资源

想要返回关联资源的话,需要在获取模型时定义要加载的关联关系:

return new PostResource($post->load(['author', 'comments']));

当然,要让这段代码正常工作,还要在 Post 模型类中定义对应的关联方法:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasFactory;

    const STATUS_DRAFT = 1;
    const STATUS_PUBLISHED = 0;

    public function author()
    {
        return $this->belongsTo(User::class, 'user_id', 'id');
    }

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}

对应返回的 JSON 数据结构如下:

image-20221208114851628

可以看到,API Resource 默认是将关联的嵌套资源以和资源属性平级的方式平铺在返回的 JSON 数据结构里的。

自定义 JSON 数据结构

如果需要的话,你可以在生成的资源类中重写 toArray 方法类定义要返回的数据结构和对应的数据转化:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'content' => $this->content,
            'views' => $this->views,
            'created_at' => $this->created_at->toDateTimeString(),
            'updated_at' => $this->updated_at->toDateTimeString(),
        ];
    }
}

这样在返回结果中就不包含 is_draftuser_id 字段了,日期时间展示也更加美观:

image-20221208113822281

当然,要显示关联的嵌套资源,也需要自行定义,使用重写 toArray 方法的方式可以自行定义嵌套资源所在的数据结构,不受默认规则的限制:

return [
    'id' => $this->id,
    'title' => $this->title,
    'slug' => $this->slug,
    'content' => $this->content,
    'views' => $this->views,
    'created_at' => $this->created_at->toDateTimeString(),
    'updated_at' => $this->updated_at->toDateTimeString(),
    'relations' => [
        'author' => $this->author,
        'comments' => $this->comments
    ]
];

image-20221208115159483

不过也要注意这种自有带来的潜在风险:不同开发人员又可以按照自己的喜好写出五花八门的 JSON 数据结构,所以我们需要有一套机制统一 JSON 响应的数据结构。

除了单个资源外,API Resource 还支持资源集合类,其使用方式可以参考 Laravel 官方文档,这里不详细展示了。

API Resource 的不足

可以看到,API Resource 是 Laravel 官方提供的一套开箱即用的、非常入门级的 JSON API 响应数据结构标准化机制,一旦项目变得复杂,有更多定制化的需求,使用成本就会变高,需要更多编码和规范工作来确保数据标准的统一,比如上面提到的嵌套资源到底放在哪里的问题,它也没有更多的规约来告诉我们嵌套的关联资源如果想独立获取要怎么做,以及过滤器和排序这些更高阶的 JSON API 请求要怎么统一标准化设置。

还有一个更大的局限是 API Resource 只是 Laravel 提供的一个功能特性,如果我们的开发团队使用了 Laravel 框架以外的其他语言或者框架,要怎么统一标准化 JSON API 响应的数据结构呢?

显然,我们需要一个更广义、更开放的 JSON API 规范,来满足更多场景、更多技术栈的响应数据标准化,好在,社区已经有了这样的规范和标准,那就是接下来要介绍的主角 —— JSON API 。

JSON API 规范

有关 JSON API 的细节,可以参考官方文档:http://jsonapi.org.cn/format/,里面包含了规范的所有明细,并且 JSON API 也是和 REST API 规范相呼应的。JSON API 已经在 IANA 机构完成注册,它的 MIME 类型是 application/vnd.api+json

按照 JSON API 规范的要求,一个标准的 JSON 响应数据结构应该是下面这样子的:

{
    "data": [
        {
            "type": "posts",
            "id": 1,
            "attributes": {
                "title": "JSON:API paints my bikeshed!",
                "content": "This is the body of the article",
                "views": 2
            },
            "relationships": {
                "author": {
                    "links": {
                        "self": "http://example.com/posts/1/relationships/author",
                        "related": "http://example.com/posts/1/author"
                    }
                },
                "comments": {
                    "links": {
                        "self": "http://example.com/posts/1/relationships/comments",
                        "related": "http://example.com/posts/1/comments"
                    },
                    "data": [
                        {
                            "type": "comments",
                            "id": "5"
                        },
                        {
                            "type": "comments",
                            "id": "12"
                        }
                    ]
                }
            },
            "links": {
                "self": "http://example.com/posts/1"
            }
        }
    ],
    "included": [
        {
            "type": "users",
            "id": "9",
            "attributes": {
                "name": "Test"
            },
            "links": {
                "self": "http://example.com/users/9"
            }
        },
        {
            "type": "comments",
            "id": "5",
            "attributes": {
                "content": "Go!"
            },
            "relationships": {
                "author": {
                    "data": {
                        "type": "users",
                        "id": "2"
                    }
                }
            },
            "links": {
                "self": "http://example.com/comments/5"
            }
        },
        {
            "type": "comments",
            "id": "12",
            "attributes": {
                "content": "I like PHP better"
            },
            "relationships": {
                "author": {
                    "data": {
                        "type": "users",
                        "id": "9"
                    }
                }
            },
            "links": {
                "self": "http://example.com/comments/12"
            }
        }
    ]
}

主要包含以下几个部分:

Identification

资源主体的标识,包括 idtype,这是必须的基础字段,不能为空(一般由实现规范的框架自行生成,无需开发人员设置):

{
    "id": 1,
    "type": "posts"
}

Attributes

资源主体的属性,这里面的属性字段由开发人员按照业务需要进行设置:

{
    "attributes": {
        "title": "JSON:API paints my bikeshed!",
        "content": "This is the body of the article",
        "views": 2
    }
}

Relationships

资源主体关联的嵌套资源,以文章为例,关联的是用户和评论,这里需要注意的是两者也有不同,对于多对一的归属关联,如 author,提供的是对应资源的获取链接 links,而对于一对多的包含关联,如 comments,返回的除了获取链接外,还有包含关联资源主体标识的 data(多个关联资源以数组形式提供),所有关联资源的细节信息在资源主体数据 data 之外的 included 里面,客户端在请求 API 时可以通过 include 条件按需获取想要加载的关联关系,以提供接口响应速度:

{
    "relationships": {
        "author": {
            "links": {
                "self": "http://example.com/articles/1/relationships/author",
                "related": "http://example.com/articles/1/author"
            }
        },
         "comments": {
             "links": {
                 "self": "http://example.com/posts/1/relationships/comments",
                 "related": "http://example.com/posts/1/comments"
             },
             "data": [
                 {
                     "type": "comments",
                     "id": "5"
                 },
                 ...
             ]
         }
    },
    "included": [
         {
            "type": "users",
            "id": "9",
            "attributes": {
                "name": "Test"
            },
            "links": {
                "self": "http://example.com/users/9"
            }
        },
        {
            "type": "comments",
            "id": "5",
            "attributes": {
                "content": "Go!"
            },
            "relationships": {
                "author": {
                    "data": {
                        "type": "users",
                        "id": "2"
                    }
                }
            },
            "links": {
                "self": "http://example.com/comments/5"
            }
        },
        ...
    ]
}

分页、排序和过滤

除了对关联嵌套资源加载的规范,JSON API 还对分页、排序和字段过滤有一套规范,这些可以放到后面具体演示的时候给大家展示。在 JSON API 规范下,我们可以通过请求参数来定制响应结果包含的数据,这就非常灵活了:

# 通过 views 排序(升序)
GET /posts?sort=views

# 通过 views 排序(降序)
GET /posts?sort=-views

# 筛选 title=laravel 的所有数据
GET /posts?filter[title]=laravel

# 通过作者名字进行筛选
GET /posts?filter[author.name]=taylor

# 包含指定的关联关系(author、comments)
GET /posts?include=author,comments

# posts 资源只返回 title、content 属性
GET /posts?fields[posts]=title,content

# 包含 author 关联并且 posts 资源只返回 title、content 字段,author 只返回 name 字段
GET /posts?include=author&fields[posts]=title,content&fields[author]=name

这些都是 API Resource 无法满足的,当然对后端接口开发也提出了更高的要求,好在这些规范既然是开放的规范标准,就必然有很多第三方的实现框架/组件,接下来的两篇教程,学院君就会给大家介绍 Laravel 框架里面针对 JSON API 规范实现的几个常用扩展包,借助这些扩展包,我们就可以编写统一标准的、更加强大的、又不失灵活的 Laravel API 了。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: REST API

>> 下一篇: 向前一步 —— JSON:API Resourece