珠联璧合 —— Laravel Query Builder
引入 Query Builder
在上篇教程中,学院君给大家演示了如何在 Laravel 项目中通过 JSON:API Resource 扩展包实现满足 JSON API 规范的接口响应数据,并介绍了这个扩展包的优点和不足,并且提出如果想要实现完全满足 JSON API 规范的接口,需要继续寻找其他的解决方案。
Laravel 作为一个发展了近十年的流行框架,生态已经非常成熟,除了框架自身提供的丰富功能外,你几乎可以找到任意想要实现基础功能所需的扩展包,在 JSON API 这个领域,除了我们上篇介绍的 JSON:API Resource,比较知名的还有 Laravel Query Builder 以及 Laravel JSON:API。
前者是声名在外的 Spatie 作品,它不仅仅满足构建 JSON API 的需求,还是一个 Eloquent Query Builder,完全兼容 Laravel 自带的 Eloquent 构建器,同时尽可能遵循 JSON API 规范设计查询范式,功能非常强大,如果说 JSON:API Resource 是针对 Laravel API Resource 进行扩展,那么 Laravel Query Builder 就是针对 Laravel Eloquent Builder 进行扩展,在更底层的维度提供更全面的定制化功能,同时还能保留原有的 Eloquent 查询能力,两者结合起来,就可以实现非常完备的 JSON API。
Laravel JSON:API 扩展包则是自立门户,完全遵循 JSON API 规范实现了一套独立的 API 请求、处理、响应体系,显然,这个扩展包对老项目的迁移很不友好,不是像 JSON:API Resource 和 Laravel Query Builder 那样在 Laravel 自带功能上进行扩展,从工程角度提供的灵活性和友好度也就更低(大多数项目都有历史沉疴),除非你是从头开始新项目,饶是如此,Laravel JSON:API 对原有的 Laravel 代码结构侵入也很大,又给后续框架升级带来困扰,所以不太推荐。
接下来,我们来看看如何通过 Laravel Query Builder + JSON:API Resource 构建符合 JSON API 规范的接口。
本篇教程代码已经提交到 Github:https://github.com/geekr-dev/blog
v3
版本。之所以要采用两个扩展包,而不是一个,还是因为实际项目大多数一开始并没有想那么多,为了快速开始都是轻装上线,只有极少部分才能发展成为大型项目,需要对 API 接口就进行治理和维护,而 Laravel Query Builder 和 JSON:API Resource 对现有代码侵入低就成了巨大的优势,会极大降低开发和维护成本。另外,从 Unix 设计哲学来看,也没什么问题,每个功能模块完成最小职责,然后通过管道组合来实现复杂功能,这对面向未来的扩展性和设计来说都是非常有利的。
使用入门
开始使用之前,还是老规矩,先安装配置 Laravel Query Builder 扩展包:
composer require spatie/laravel-query-builder
如果你想要通过配置文件对这个扩展包进行自定义配置,可以发布配置文件到 config 目录下:
php artisan vendor:publish --provider="Spatie\QueryBuilder\QueryBuilderServiceProvider" --tag="query-builder-config"
这里,我们保持默认配置即可。
首先我们还是看最基本数据的返回数据格式。
Laravel Query Builder 作为默认 Eloquent 查询构建器适配 JSON API 的适配器,比起 JSON:API Resource 适配器有着更加简单的调用方式,不用修改 API 资源类,也不用修改模型类,只需要通过 Spatie\QueryBuilder\QueryBuilder::for
方法把指定模型类注入Laravel Query Builder,然后后续的查询都会基于这个新的扩展查询构建器,而不是默认的,如果扩展查询构建器没有对应的方法,则通过魔术函数 __call
回到 Laravel 默认的 Eloquent 查询构建器查询:
这样通过适配器的方式对原有 Laravel 项目代码没有任何侵入,设计非常巧妙,我们来看示例代码:
Route::get('posts/{id}', function (Request $request, int $id) {
return QueryBuilder::for(Post::where('id', $id))
->allowedFields(['id', 'title', 'slug', 'content', 'views', 'created_at', 'updated_at'])
->get();
});
可以看到,Laravel Query Builder 把结果包含字段都统一通过查询方法 allowedFields
提供,更加简单高效,只是这个时候,如果想要对属性做格式转化,可以在模型类中完成(考虑到日期属性会在多处被使用,而不仅仅是 HTTP 响应,在模型类中完成格式转化代码复用性会更好):
protected $casts = [
'created_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
我们看下,这个 API 接口返回的结果如下:
指定字段
不过,结果并没有按照 allowedFields
指定的字段返回,这是因为该方法只是定义了通过查询字符串指定字段的允许范围,不是定义去数据库查询的字段,要配合查询字符串 fields
指定字段才能实现完整功能(不指定返回所有字段):
http://localhost:8000/api/posts/1?fields[posts]=id,title,slug,content,views,created_at
珠联璧合
有些同学可能会困惑,这 Laravel Query Builder 返回的也并不是 JSON API 规范的响应数据啊,这是因为 Laravel Query Builder 更多还是从请求处理参数的角度兼容 JSON API 规范,更专注于查询构建器的逻辑,而不是响应数据格式,这一点从名称上也可以看出来,如果想要让想要响应结果和 JSON API 规范一致,需要将 JSON:API Resource 和 Laravel Query Builder 结合起来使用,这其实是一种管道模式的思维 —— 每个功能模块实现单一职责,然后通过管道方式将不同模块组合起来实现更复杂的功能。
更精妙的是因为 Laravel Query Builder 完全兼容 JSON API 请求参数,所以两者结合起来真可谓是相得益彰,珠联璧合。由于 Laravel Query Builder 返回字段通过 fields
查询字符串指定,就不需要在 API 资源类中定义复杂的 toAttributes
转化代码了,直接调用模型实例的 toArray
方法即可(注意排除掉 id
):
<?php
namespace App\Http\Resources;
use Illuminate\Support\Arr;
use TiMacDonald\JsonApi\JsonApiResource;
use TiMacDonald\JsonApi\Link;
class PostResource extends JsonApiResource
{
public function toAttributes($request): array
{
return Arr::except($this->resource->toArray(), 'id');
}
public function toRelationships($request): array
{
return [
'author' => fn () => new UserResource($this->author),
'comments' => fn () => CommentResource::collection($this->comments),
];
}
public function toLinks($request): array
{
return [
Link::self(route('posts.show', $this->resource)),
];
}
}
调整路由返回响应代码:
Route::get('posts/{id}', function (Request $request, int $id) {
$post = QueryBuilder::for(Post::where('id', $id))
->allowedFields(['id', 'title', 'slug', 'content', 'views', 'created_at', 'updated_at'])
->first();
return new PostResource($post);
});
访问 http://localhost:8000/api/posts/1?fields[posts]=id,title,slug,content,views,created_at,updated_at
:返回结果如下:
非常完美,不是吗?Laravel Query Builder 负责对 JSON API 请求参数进行处理,组合 Laravel 查询构建器对数据库进行查询,最后将查询结果通过 JSON:API Resource 返回,从而完成 JSON API 规范的完整功能。
关联查询
要返回关联关系,可以通过 Laravel Query Builder 提供的 allowedIncludes
方法来实现按需加载:
return QueryBuilder::for(Post::where('id', $id))
->allowedFields(['id', 'title', 'content', 'views', 'created_at', 'authors.id', 'authors.name'])
->allowedIncludes(['author', 'comments'])
->get();
同理,需要在资源类的
toAttributes
方法中清理掉关联资源属性。
包含了哪种关联才会加载哪个关联资源,从而使查询性能更高效:
http://127.0.0.1:8000/api/posts/1
不指定任何关联,则不加载关联关系,指定哪种关联,就返回对应的关联资源:
http://127.0.0.1:8000/api/posts/1?include=author
http://127.0.0.1:8000/api/posts/1?include=author,comments
此外,你也可以返回关联资源的指定字段(前提是在 allowFields
中定义了):
http://127.0.0.1:8000/api/posts/1?include=author&fields[authors]=id,name
过滤
到目前为止,还没有看出 Laravel Query Builder 与 JSON:API Resource 的明显不同,接下来,进入深水区,看 Laravel Query Builder 大展身手。
其实底层已经有不同了,Laravel Query Builder 是真正的按需加载 ,因为它是直接和数据库打交道的,而 JSON:API Resource 只是表现层的封装,是通过障眼法让你以为只加载了指定关联,实际上都加载了,只是在表现层做了过滤而已。同理,返回指定字段也是这样。
因为 Laravel Query Builder 能直接和数据库交互,所以我们可以通过它来实现 JSON API 规范要求的过滤、排序、分页功能。首先来看过滤,假设我们想要在文章列表页只展示标题包含 test
字符的文章,可以这么做:
Route::get('posts', function (Request $request) {
$posts = QueryBuilder::for(Post::class)
->allowedFields(['id', 'title', 'content', 'views', 'created_at', 'authors.id', 'authors.name'])
->allowedFilters(['title'])
->allowedIncludes('author')
->get();
return PostResource::collection($posts);
});
在服务端,我们通过 allowFilters
方法指定了支持过滤筛选的字段,然后通过请求 http://127.0.0.1:8000/api/posts?fields[posts]=id,title,views,created_at&filter[title]=test
指定过滤条件,返回结果如下,表示过滤器发挥了功能:
这里只是使用单个 filter
请求参数实现了一个最基本的过滤器演示,Laravel Query Builder 还支持更复杂的过滤器功能实现,包括多个过滤条件的组合、基于关联资源进行过滤、基于日期范围进行过滤、以及更复杂功能的自定义过滤器实现等,你可以参考官方文档进行设置。
排序
除了过滤功能之外,Laravel Query Builder 还支持通过请求参数 sort
对 JSON API 返回结果进行排序,比如我们这里以浏览数为例进行降序排序,先定义服务端代码,通过 defaultSort
方法表示默认通过 id
降序排序(默认升序, -
前缀表示降序),然后通过 allowSorts
方法允许通过请求参数 views
或者 created_at
自定义排序逻辑:
$posts = QueryBuilder::for(Post::class)
->allowedFields(['id', 'title', 'content', 'views', 'created_at', 'authors.id', 'authors.name'])
->allowedFilters(['title'])
->defaultSort('-id')
->allowedSorts(['views', 'created_at'])
->allowedIncludes('author')
->get();
要加上继续浏览数 views
降序排序的条件,可以这样发起 API 请求:
http://127.0.0.1:8000/api/posts?fields[posts]=id,title,views,created_at&sort=-views
当然,我们还可以组合排序条件,以浏览数作为主排序字段、创建时间次之,当浏览数相同时,按照排序时间降序:
http://127.0.0.1:8000/api/posts?fields[posts]=id,title,views,created_at&sort=-views,-created_at
分页
完成了过滤和排序之后,最后我们来看看如何通过 Laravel Query Builder 实现分页器功能。
到目前为止,我们的文章列表接口返回的都是所有文章,这在生产环境是不允许的,会对数据库造成巨大的压力,因此,我们要为 API 接口加个分页器功能。当然,Laravel Query Buillder 本身完全兼容 Laravel Eloquent,所以使用原生功能就可以实现分页器:
$posts = QueryBuilder::for(Post::class)
->allowedFields(['id', 'title', 'content', 'views', 'created_at', 'authors.id', 'authors.name'])
->allowedFilters(['title'])
->defaultSort('-id')
->allowedSorts(['views', 'created_at'])
->allowedIncludes('author')
->paginate(10);
return PostResource::collection($posts);
还是访问 http://127.0.0.1:8000/api/posts?fields[posts]=id,title,views,created_at&sort=-views,-created_at
,就可以看到分页后返回的 API 接口数据格式有所不同了,links
和 meta
里面都包含了分页信息:
由于是 JSON:API Resource 扩展包帮我们转化后生成的,所以已经完全符合 JSON API 规范的要求,这又是一个两者珠联璧合,合作无间的典型案例。
API 版本
JSON API 并没有对版本有特别规范说明,我们使用 Laravel 自带的路由前缀来区分不同版本 API 即可:
public function boot()
{
$this->configureRateLimiting();
$this->routes(function () {
Route::prefix('api/v1')
->middleware(['api', 'auth:sanctum'])
->group(base_path('routes/v1.php'));
});
}
阶段小结
好了,至此,学院君已经花了四篇教程的篇幅从 REST API 到 JSON API 给大家详细介绍了有关 REST + JSON API 的所有细节,以及在 Laravel 项目中的落地实现。归纳起来,就是我们可以使用 Laravel 自带的资源路由轻松实现符合 RESTful 风格的 API 接口,然后借助 Laravel 第三方扩展包 Laravel Query Builder + JSON:API Resource 组合实现符合 JSON API 规范的 API 请求参数处理和响应数据封装,以及日常常用的嵌套资源加载、过滤筛选、排序、分页功能的实现范式,这样一来,不管是 API 路由定义、请求处理还是数据封装,都有了统一标准来约束和指导代码编写和维护。也就解决了我们一开始提到的 API 接口维护和腐化问题,再结合 TDD 利器,我们就可以开始高质量、可维护、可持续的项目开发迭代之旅。
在正式开始测试驱动 API 开发项目代码编写之前,我们来总结下 API 开发的一些最佳实践:
- 编写可读的 RESTful API
- 无论何时,在控制器中使用嵌套的方法嵌套关联资源(比如
posts/{post}/comments
) - 使用 HTTP 状态码让 API 语义性更强(比如异步接口不返回 200 而是 202 以及一个回调 URL,表示请求已收到,但在处理中,客户端可以通过返回的 URL 查询处理状态)
- 永远不要对外暴露自增 ID!以免产生安全隐患,如何解决这个问题:
- 数据库中保留自增 ID 字段,以利于 SQL 优化
- 同时每个模型有 UUID 字段,对外只暴露 UUID
- 版本化 API,尤其是对外开放的 API,并且尽可能做到向前兼容
- 让客户端决定想要什么资源:
- 加载关联关系
- 过滤筛选
- 排序逻辑
- 指定返回的字段子集
- 标准化以上事项,这样在每个项目里就可以统一标准,我们可以使用 REST + JSON API 规范以底层组件方式完成这个标准化
- 响应数据也要具备可读性,让客户端易于理解:
- 和 JavaScript 客户端约定好命名规范
- 通过嵌套来凝聚统一领域的数据
从下面教程开始,我们将会把这些最佳实践应用到实际项目中,并通过 TDD 模式推动整个项目的迭代。
无评论