从 Dingo API 原理来看 Laravel 的 Http 请求处理过程

Dingo Api 是一个使用率比较高的包,通常用在 Web API 的开发中。对我来说这三个功能吸引力比较大:

  • 路由版本管理
  • Http Exception 处理
  • Response Transform

如果异常处理以及把结果转换成符合 JSON 标准的响应,都靠手工去实现,确实很烦琐,而 Dingo API 帮我们把这个功能实现了,来看看它的原理。

区分不同的请求

原生的路由系统在 Laravel 中维护了一个单例的 Router,创建的所有路由「Route」都是由 Router 来管理,并且把请求分发的对应的 Route 中。

我们知道 Dingo Api 的路由是可以和 Laravel 原生的路由共存,如果 Dingo 和 Laravel 使用同一个 Router,那么没办法实现路由的版本控制以及处理 Http Exception(实际上 5.4 版本的 Router 中已经提供了 version 接口,只是还没实现,后续应该会实现这个功能);如果 Dingo 维护一个新的 Router 的话,请求是在 Http 的 Kernel 中 handle 给 Router 的,在 Http Kernel 中 Dingo 根本没有接口把自己的 Router 注入到 Kernel 中并让 Kernel 使用 Dingo 的Router。

Dingo 确实自己维护了一个 Router,这个 Router 中实现了路由的版本管理。

区分不同的请求很简单,通过一个中间件来判断请求的 host 或 uri 就行。在使用 Dingo 的时候需要设置 prefix 或者 domain,这两个值就是 Dingo 用来区分 Dingo 路由和 Laravel 的路由的特征值。

关键是如何把中间件运用到路由中。

中间件注入的时机

中间件有四个级别的:一个是全局中间件,也就是 Kernel 中的 Middleware 数组,对所有的请求都有效;第二个是路由的中间件, 通过注册路由时添加中间件,这个只对当前路由有效;第三个是控制区中间件,可以细化到具体的某一个方法;最后一个是 Terminate 中间件,这个中间件是在响应发送之后作用的。

这四个中间件通过的时间顺序也不一样,最先的是全局中间件,然后路由中间件和控制器中间件,最后是 Terminate 中间件。

因为要接管所有请求并判断是否为 API 请求,所以中间件必须是全局中间件,并且请求(Request)要尽量最先通过这个中间件,因为不能保证其他中间件是否会有一些副作用。

在 Http Kernel 中提供了两个方法,prependMiddleware 以及 pushMiddleware,这两个方法可以向 middleware 数组中添加中间件,一个是添加到数组的开头,一个是追加到末尾。

什么时候添加呢?在 ServiceProvider 的 boot 方法中。

这个时候 Kernel 已经创建了,并且请求也 handle 到 Kernel 中了,接下来就是调用 sendRequestThroughRouter 这个方法交给 Router 来处理。在通过所有的中间件之前会调用所有的 ServiceProvider 的 boot 方法,这个时候可以添加你自定义的中间件,之后 Kernel 会使用管道来通过所有的中间件。

中间件的作用

中间件的作用很简单,通过前面说的 prefix 和 domain 等特征值,判断一个请求是否是 API 请求,如果不是的话,那么调用 $next($request) ,把处理交回给 Laravel。如果判断是 API 请求的话,则交给 Dingo 自定义的 sendRequestThroughRouter 来处理。

看到 Dingo 这样的处理方式,给我很大的启发。之前中间件基本上用来做一些有副作用的操作,根本没想到可以以这种方式来接管后面所有请求的操作,也正是这个方式,也让 Dingo 可以从这个阶段开始接管所有的异常处理。

在判断为 API 请求时,会出发一个事件,这个事件会让容器用 Dingo 的 Router 替换原生的 Router。

异常处理

在 Dingo 实现自己的 sendRequestThroughRouter 的时候,就可以在这个方法里面 catch 所有的异常,并针对不同的异常返回不同的 Json 响应。

在 Dingo 中创建了自定义的 Exception handle,这个 Handle 会返回对应异常的 JSON 响应,接着再把异常交给 Laravel 的 Exception Handle 来处理。

自定义的 Router 实现 Api 版本管理

Dingo 中的版本有两种不同的模式,严格模式以及宽松模式。严格模式就是需要传入指定的 Accept Header:Accept:application/x.SUBTYPE.v1+json,通过这个头来判断请求的版本,如果没有对应的请求头,则会抛出异常;宽松模式就是如果没有指定对应的版本,则使用设置中的默认版本。

现在 web API 的版本管理貌似用的比较多的就是两种,要不直接写在 uri 中,要不是用请求头来实现,之前看 Dingo 里面的「标准树」,完全搞不懂啥玩意,其实就是定义一个 MIME 类型,对整个功能来说应该没有影响,但是对一些标准啥的有规定吧。

通过这个头可以解析出请求的 API 版本以及响应的格式。

Dingo 定义了一个全新的 Router,Version 方法是添加了一个版本名称的 Group,这样在分发到请求对应的路由时会查找对应版本的 Group。

这种方式在我看来有利有弊,有利的一方面是由于它自定义了所有 Router 的工作方式,可以很方便的自定义一些操作,比如版本管理,比如可以介入响应(Response)的生成,通过这种方式可以做到一个很细化的程度;弊端呢,在我看来,Dingo 里面有大量的自定义的东西,包括请求,Router Route 等等,以及大量的 Adapter,这样做的结果是整个包臃肿了,逻辑更复杂了,而且兼容性可能也不是太好,比如可能 Laravel 升级了,然后对应的方法有可能 Dingo 没办法做到很快的同步性等。

我倒是有一个思路,比如可以创建一个 Router,继承自 Laravel 的 Router,版本管理很简单,判断路由注册时版本是否跟请求的版本一致,一致的话注册到 Router 中,不一致的话就用一个实现了 Router 接口的 Mock 类来注册,不会影响到路由的调用。

这样做的方法也有利有弊,有利的部分因为继承自原生的 Router,不用管兼容性问题,而且其他方法都是原生的 Router,不用去管那么复杂的逻辑,另外一方面由于没有加载其他版本的路由,在路由分发的时候应该性能会好一点。弊端呢,一方面自主性没有那么大,比如如何去生成指定的响应等等,另外一方面这种方式不知道在使用路由缓存时是否可以很好的处理。

在 Laravel 的 Router 中有 version 接口,看看后续官方是否会不会出这个功能吧。

再来看 Laravel 的生命周期

现在反过来看 Laravel 这个框架,真的觉得扩展性非常高。比如,通过添加中间件,可以在不同的阶段完成一些副作用的行为,甚至可以直接接管整个请求的处理过程。

再比如,只要找准了那个点,在这个组件起作用之前,通过容器的重新绑定,你可以替换任何默认的组件,而对整个框架无需做任何修改,只要通过 ServiceProvider。

这些看起来有些 Geek 的方式,在对 Laravel 框架足够熟悉的情况下,无疑是我们的另外一种选择。

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 7年前 加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 12

@oustn 你讲的方法不错,不过在做了判断后和 include 差不多,是一个备选方案。fractal 的文档我在看 dingo 的时候也看过,这里面也没讲到这个问题,那个 data 的确实很讨厌很讨厌,但是确实是有用的,比如有时候 meta 里是有一些信息的,另外我对 include 的灵活性问题持不同意见,include 的意义我认为是用来让接口功能更灵活,由客户端决定到底需要什么样的数据,这样可以应对不同的业务需求。

举例:

  • /api/users 返回所有用户
  • /api/users?include=posts 返回用户列表及其文章列表
  • /api/users?include=postCount 返回用户列表与其文章总数
  • /api/users?include=department 返回用户列表与其部门详情
  • /api/user/1?include=posts,orders 返回用户详情与其文章列表和订单列表

需要说明的是:

  1. 如果有的路由会因为 include 的参数很多或者很常用,可以 alias 一个简单的路由
  2. 对于某些基本上每个地方都要用到的 relation 可以设置为 defaultIncludes
  3. 特别复杂的接口就不要这样玩了

个人认为以上的方法可以灵活的应对各种端或者各种场景下需要接口返回不同的结构信息的需求,让接口不需要因为频繁的前端需求而做变更。

如果像你讲的方式来做,这样就把接口返回定死了,可能只适合需求稳定、前端/移动端/PM 都讲道理的情况下,针对各种接口要求并且不想做聚合接口做冗余接口时,我认为这个 include 的设计挺好的。

7年前 评论
Summer

Transformer 是我最喜欢的功能

7年前 评论

手动点赞?

这里呢我其实遇到一个 Transformer 的问题,两位有谁遇到过这个场景嘛?
https://github.com/dingo/api/issues/986

@Summer @oustn

如果 issues 里没有描述清楚,我再复述一下:
预期:通过 dingo 返回的所有字段都是 snake 的

7年前 评论

额 不能编辑嘛,不小心就回车了 ?

实际:dingoTransformer 有一个 include 功能,该功能是用来灵活配置 Transformer 的包涵关系的,在 Transformer 中可以设置 protected $defaultIncludesprotected $availableIncludes 来进行关联处理,这里的 include 是要求传入模型的方法名的,根据 PSR-2,方法名是 CameCase 这个是必然的,所以我以 EmployeeTransformer 举例:

class EmployeeTransformer extends TransformerAbstract
{
    protected $defaultIncludes = ['department', 'employeeCategory', 'employeeStatus'];

    ……
}

这样我的到的结果

{
    "data": {
        "id": 1, 
        "name": "竺珺", 
        "department": {
            "data": {
                "id": 3, 
                "name": "市场部"
            }
        }, 
        "employeeCategory": {
            "data": {
                "id": 2, 
                "name": "实习"
            }
        }, 
        "employeeStatus": {
            "data": {
                "id": 1, 
                "name": "在职"
            }
        }
    }
}

预期:通过 dingo 返回的所有字段都是 snake_case 的, 如下:

{
    "data": {
        "id": 1, 
        "name": "竺珺", 
        "department": {
            "data": {
                "id": 3, 
                "name": "市场部"
            }
        }, 
        "employee_category": {
            "data": {
                "id": 2, 
                "name": "实习"
            }
        }, 
        "employee_status": {
            "data": {
                "id": 1, 
                "name": "在职"
            }
        }
    }
}

如果是 Laravel,我知道在 Model 中有个 snake 的开关,所以使用 toArray 方法即可全部转化,但是在 dingo 中我翻遍了手册和 issues,没有找到答案,源码也看了一部分,在看源码过程中发现了 https://github.com/dingo/api/issues/986 提到的方法,但我看代码,那根本不是作者刻意留的,算是个小漏洞。

迫于进度压力就没有继续跟进代码研究了,我目前没有找到行之有效的方法,我是通过创建 BaseTransformer 覆写 Dingo TransformerAbstract 类的方法才实现的,你们有更好的方法吗?

     /**
     * 覆写原方法,增数组键转换为 snake_case 的函数
     *
     * @param Scope  $scope
     * @param mixed  $data
     * @param array  $includedData
     * @param string $include
     *
     * @return array
     */
    protected function includeResourceIfAvailable(
        Scope $scope,
        $data,
        $includedData,
        $include
    ) {
        if ($resource = $this->callIncludeMethod($scope, $include, $data)) {
            $childScope = $scope->embedChildScope($include, $resource);
            // 此处增加 snake_case 转换
            $includedData[snake_case($include)] = $childScope->toArray();
        }

        return $includedData;
    }
7年前 评论

当然,还有一种方法是在模型中将关联方法改为 snake 的或者拷贝一个 snake 形式的功能相同的关联函数。

7年前 评论

@springjk 都有 transform 了为啥还要自行车,非要用那个 $defaultIncludes,灵活性太低了 ~~ 实际上这个是 league/fractal 这个包的锅,dingo 没有处理 transform 部分,只是写了个 adapter .

想想别的招,别用那个$defaultIncludes, 在transform 方法 return 数组中可以直接写。。记得在查找时 with 预加载。

return [
    ......
    'employee_category' => $model->employeeCategory,
    'employee_status' => $model->employeeStatus
];

而且你没觉得通过$defaultIncludes这种方式获取的数据 那个 data key 很讨厌么?想想你在前端要获取的时候 还要额外写个 data 属性。。。

employee.employeeCategory.data.name

如果要使用 transform 也很简单,手动调用 transform 方法就行

return [
    ......
    'employee_category' => (new EmployCategory())->transform($model->employeeCategory),
    'employee_status' => $model->employeeStatus
];
7年前 评论

@Summer 也挺喜欢的,不过就是发现有个问题,这样我得为每个接口定义一个transformer类,这样看起来好像有点多余,好像没有发现可以共用的方法

7年前 评论

@oustn 你讲的方法不错,不过在做了判断后和 include 差不多,是一个备选方案。fractal 的文档我在看 dingo 的时候也看过,这里面也没讲到这个问题,那个 data 的确实很讨厌很讨厌,但是确实是有用的,比如有时候 meta 里是有一些信息的,另外我对 include 的灵活性问题持不同意见,include 的意义我认为是用来让接口功能更灵活,由客户端决定到底需要什么样的数据,这样可以应对不同的业务需求。

举例:

  • /api/users 返回所有用户
  • /api/users?include=posts 返回用户列表及其文章列表
  • /api/users?include=postCount 返回用户列表与其文章总数
  • /api/users?include=department 返回用户列表与其部门详情
  • /api/user/1?include=posts,orders 返回用户详情与其文章列表和订单列表

需要说明的是:

  1. 如果有的路由会因为 include 的参数很多或者很常用,可以 alias 一个简单的路由
  2. 对于某些基本上每个地方都要用到的 relation 可以设置为 defaultIncludes
  3. 特别复杂的接口就不要这样玩了

个人认为以上的方法可以灵活的应对各种端或者各种场景下需要接口返回不同的结构信息的需求,让接口不需要因为频繁的前端需求而做变更。

如果像你讲的方式来做,这样就把接口返回定死了,可能只适合需求稳定、前端/移动端/PM 都讲道理的情况下,针对各种接口要求并且不想做聚合接口做冗余接口时,我认为这个 include 的设计挺好的。

7年前 评论

@Someant transformer 如果你像我在上面一样用了 include,可能就不需要定义那么多了,另外,很多字典表结构都是一样的,返回数据也只有个name等,我一般都是定义一个字典表来转换通用的表结构。

class DictionaryTransformer extends BaseTransformer
{
    public function transform(Model $model)
    {
        return array_only($model->toArray(), ['id', 'name']);
    }
}
7年前 评论

看完你的文档,搞的我又翻了一遍dingo/api的细节。真是又有收获呢。
dingo为了拿到kernel的protected属性middleware竟然连反射都用上了,真是很Hack。

7年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!