珠联璧合 —— 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 查询构建器查询:

image-20221212105707758

这样通过适配器的方式对原有 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 接口返回的结果如下:

image-20221212111218960

指定字段

不过,结果并没有按照 allowedFields 指定的字段返回,这是因为该方法只是定义了通过查询字符串指定字段的允许范围,不是定义去数据库查询的字段,要配合查询字符串 fields 指定字段才能实现完整功能(不指定返回所有字段):

http://localhost:8000/api/posts/1?fields[posts]=id,title,slug,content,views,created_at

image-20221212113248150

珠联璧合

有些同学可能会困惑,这 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:返回结果如下:

image-20221212133327376

非常完美,不是吗?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

image-20221212133957097

不指定任何关联,则不加载关联关系,指定哪种关联,就返回对应的关联资源:

http://127.0.0.1:8000/api/posts/1?include=author

image-20221212134800654

http://127.0.0.1:8000/api/posts/1?include=author,comments

image-20221212134826642

此外,你也可以返回关联资源的指定字段(前提是在 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 指定过滤条件,返回结果如下,表示过滤器发挥了功能:

image-20221212140856684

这里只是使用单个 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

image-20221212142633892

当然,我们还可以组合排序条件,以浏览数作为主排序字段、创建时间次之,当浏览数相同时,按照排序时间降序:

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 接口数据格式有所不同了,linksmeta 里面都包含了分页信息:

image-20221212143420543

由于是 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 模式推动整个项目的迭代。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 向前一步 —— JSON:API Resourece

>> 下一篇: CRM API 整体设计