/ Gateway

设计一个友好的网关

传统应用

一般情况下,你要对外提供 HTTP 接口,首先你得开发一个 Web Server,然后部署到服务器上,直接对外提供 HTTP 接口。
后来,你发现流量高了,你又没时间优化,只能堆机器了,于是乎你横向扩展了一堆 Web Server,这个时候你就需要一个 Proxy Server 做反向代理(Reverse Proxy)来让你的 Web Server 统一对外提供 HTTP 接口,并且尽可能的保证这一堆 Web Server 承载的流量是一样一样的。
reverse-proxy

微服务

随着时间的推移,你的 Web Server 不断壮大,承载的功能也越来越多了,这个时候微服务(Microservices)出现在你眼中,确认过眼神,你选择拆分你的 Web Server 的功能模块。为了更好的性能,你决定不再使用 HTTP 作为内部通信的方式了,RPC 有幸被你选中了。于是你重新设计了整个架构图。

rpc-proxy-1

那么问题来了,你的 RPC Server 提供的可能不是传统的 HTTP 协议了,这样搞,你就得找一个支持 RPC 反代的 Proxy 了,幸运的是 Nginx 支持了 gRPC 的反向代理,但是浏览器不一定能正常解析你的 RPC 的报文,RPC 一般情况下需要特定的 Client 去支持。所以这个时候,你就需要在 Proxy 与 RPC Server 之间加一个网关(Gateway)去做协议转换,将 RPC 的与 HTTP 之间做转换,让 Prxoy 之前的服务不需要做任何改变,就可以支持目前的架构。

gateway-1

Gateway

协议转换

参数重构

Gateway 对外提供传统的 HTTP 接口,对内,可以作为 RPC Client 去调用对应的 RPC Server。并且 Gateway 还要能够自动发现你新加入的 RPC Server,这样在紧急扩容,或者新接入了 RPC Server,可以不间断,自动的让 RPC Server 暴露出去。

首先来说一下协议转换,一个 HTTP 请求进来

POST /posts?namespace=huilu HTTP/1.1
Host: hui.lu
User-Agent: curl/7.54.0
Accept: */*
X-Token: f136803ab9c241079ba0cc1b5d02ee77
Cookie: UserID=1
Content-Type: application/json; charset=utf-8
Content-Length: 14

{"foo": "bar"}

上面报文中的任何信息,都可以是一个参数,并且可能是后端 RPC Server 需要的,但是 RPC Server 接受的参数形式又不是上述 HTTP 报文中的格式,所以这里就需要 Gateway 来做一下转换。

我们这里假设后端是 gRPC,那么它的这个 POST 请求对应的 protobuf 如下

service Project {
  rpc CreateProject (PostsRequest) returns (PostsResponse) {}
}

message PostsRequest {
  string namespace = 1;
  int64 user_id = 2;
  map<string, string> params = 3;
  string token = 3;
}

message PostsResponse {
  int64 project_id = 1;
}

从上面来看,HTTP 请求的参数与 gRPC 参数的对应关系如下

HTTP Params gRPC Params
HTTP.Header.Cookie.UserID gRPC.PostsRequest.user_id
HTTP.Query.namespace gRPC.PostsRequest.namespace
HTTP.Header.X-Token gRPC.PostsRequest.token
HTTP.Body gRPC.PostsRequest.params

上面就能很清晰的看出来 HTTP 请求的参数与 gRPC 调用的参数映射关系,这里就需要设计一个好用的配置语法,来描述参数的映射关系。在这里,我们选择 YAML 来作为配置的语法

url: /posts
method: post
request:
    gRPC.PostsRequest.user_id: HTTP.Header.Cookie.UserID
    gRPC.PostsRequest.namespace: HTTP.Query.namespace
    gRPC.PostsRequest.token: HTTP.Header.X-Token
    gRPC.PostsRequest.params: HTTP.Body

上面的配置,比上面的表格就强多了,人肉眼可见,理解起来也方便多了, 而且给代码来识别解析,也是很方便。但是上面有一些冗余的地方,比如 request 里面的 key,都是以 gRPC.PostsRequest 开始,后面 value 也都是以 HTTP 开始,在这个特定的场景下,完全可以省略掉

url: /posts
method: post
request:
    user_id: Header.Cookie.UserID
    namespace: Query.namespace
    token: Header.X-Token
    params: Body

可是,假设 gRPC 请求参数中多了一个,需要一个常量 type,并且值恒定为 Body,并且是特定值,不想要 HTTP 请求去改变它,那么上述的配置文件就变成了这样的

url: /posts
method: post
request:
    user_id: Header.Cookie.UserID
    namespace: Query.namespace
    token: Header.X-Token
    params: Body
    type: Body

可是,上面的配置文件就多出了两个 Body 的 value 了,这样会有歧义,你并不能明确的说明,Body 到底是 HTTP 请求的 body 还是一个字符串。首先你想到的是,程序在解析 gRPC 的 protobuf 时,分析出来 gRPC.PostsRequest. type 是个 string 类型,所以请求的 type 也必须是个 string 类型,因为 HTTP.Body 在这个场景下,肯定不是 string,所以程序可以自动处理掉这种情景,不会将 HTTP.Body 当做 type 的值去解析,但是当 HTTP.Body 也是个 string 咋整。所以,我们这里将 HTTP 请求的参数加一个特定的,短小精悍的标识符 $

url: /posts
method: post
request:
    user_id: $.Header.Cookie.UserID
    namespace: $.Query.namespace
    token: $.Header.X-Token
    params: $.Body
    type: Body

上面是不是就明确多了,我们假设 $. 开头的表达式都是我们的参数表达式,并且会从对应的 HTTP 报文中找到对应的值,在调用 gRPC 时,传给后端。
请求说完了,再来说一下响应,上述的 gRPC 返回的是一个 PostsResponse 的对象,我们假设原来的 HTTP 接口返回的也是一样的结构,但是参数名不一样,HTTP 响应的数据是

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

那么在 gRPC 的响应与 HTTP 的响应数据之间也要做一次参数的转换,还是遵循上面的配置规则

url: /posts
method: post
request:
    user_id: $.Header.Cookie.UserID
    namespace: $.Query.namespace
    token: $.Header.X-Token
    params: $.Body
    type: Body
response:
    id: gRPC.PostsResponse.project_id
    type: Body

这里,gRPC.PostsResponse.project_id 在这里还是有点多余,我们也可以将其替换成 $.rpc.project_id,我们在这里,规定 $ 的生命周期是从请求进入到返回响应都是存在的,来作为一个 rootScope,里面保存了请求和响应的各种参数,那么就简化成了下面的形式

request:
    user_id: $.Header.Cookie.UserID
    namespace: $.Query.namespace
    token: $.Header.X-Token
    params: $.Body
    type: Body
response:
    id: $.rpc.project_id
    type: Body

Gateway 就可以利用上面的配置,转换 HTTP 接口与 gRPC 调用的参数,我们在这里,将上述的参数转换,叫做「参数重构」。

插件机制

当你做完上面的事情后,你把你的 Gateway 卖给了前端,RPC Server 的业务方,这个时候,他们就来说了,这里的很多请求,都需要鉴定 token 是否有效,如果每次参数都要加上 token 多累啊,后端也只需要一个 user_id 而已,于是你灵机一动,既然大家都有这种重复逻辑的代码,那我就把它抽出来,在 Gateway 这里做这件事,大家只有在配置里加一个选项,就可以了。
于是,你就加了一个 auth 的配置,当 authtrue 的时候,你就调用一个专门用来做认证的 RPC Server,认证后,就会返回给你用户信息,你就搞出了一个 $.auth.user_id 的配置,只要大家在配置里写了这个,就能获取到 user_id

url: /posts
method: post
auth: true
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
    type: Body
response:
    id: $.rpc.project_id
    type: Body

这个时候,大家就都拍手叫好,不停的说「666」,可是这个时候,又有个人跳出来说,他不止需要 user_id,还需要 mobile 等信息,然后你就又在 $.auth 上加了一个 $.auth.mobile,这样会又获取到了 mobile 了。当你正在准备看看监控喝茶的时候,公司推出了认证服务二代,这个时候,就不是验证 $.Header.Cookie.Token 了,你就想,不就是再加个 auth2 吗?

url: /posts
method: post
auth2: true
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
    type: Body
response:
    id: $.rpc.project_id
    type: Body

但是,当你写出这种配置的时候,你发现,这看起来太 XX 丑了,而且后面加了认证三代、四代咋整,总不能直接加载配置里面吧,于是你也开始拆分 Gateway 的逻辑,将参数重构作为核心功能,将认证相关的拆分成一个个插件,于是配置文件就变成了下面这样的了

url: /posts
method: post
plugins:
    - auth
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
    type: Body
response:
    id: $.rpc.project_id
    type: Body

当你正在为自己的设计沾沾自喜的时候,有人来告诉你,他需要用到两种认证方式,这个时候,你就发现了自己埋下的坑了,你将两种认证服务的用户信息都放在了 $.auth 上了,gg,没法区分了,你就想,要不加一个 $.auth2 来保存认证二代的信息?当你发现你的脑袋里出现了这种念头时,你自己都觉得可怕,于是你就想,为啥不能在插件使用的时候,指定用户信息保存的地方呢?

url: /posts
method: post
plugins:
    - auth --output $.auth
    - auth-v2 --output $.auth2
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
    type: Body
response:
    id: $.rpc.project_id
    type: Body

于是,你又一次满足了客户的要求,客户连连称赞你。但是有个人过来告诉你,由于历史原因,认证用的 token 没有放在 HTTP.Header.X-Token,而是放在了 HTTP.Header.Cookie.TOKEN,你当成懵逼,但是你突然想到你刚加了一个 --output,那为啥不能加一个 --input 来指定 token 的地方了?

url: /posts
method: post
plugins:
    - auth --output $.auth --input $.Cookie.TOKEN
    - auth-v2 --output $.auth2
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
    type: Body
response:
    id: $.rpc.project_id
    type: Body

你不止改进了插件,顺带给常用的 Cookie 做了一个别名,直接可以用过 $.Cookie 来取 Cookie 的值。

执行时机

后续你又加了一系列的插件,比如字符串替换 string-replace,可以设置某个值的 set 插件。但是这些插件,你都是作用在请求 request 解析之前的,你突然想到,要是有人要求对 response 的某个字段做字符串替换,那不就 gg 了吗,于是你决定将这种问题扼杀在摇篮中。
你仔细研究了下上面的配置,和协议转换的流程,你发现在这里可以划分为多个阶段

  • 请求参数重构前
  • 请求参数解析后,RPC 调用前
  • RPC 调用后,响应参数重构前
  • 响应参数重构后,返回响应前

上面描述废话有点多,我们这里简化下

  • 请求参数重构前(beforeRequestParamsRefactor
  • RPC 调用前(beforeRPCCall
  • 响应参数重构前(beforeRPCResponseParamsRefactor
  • 返回响应前(beforeRPCResponse

在这里我们划分为四个阶段,我们用 set 作为示例

url: /posts
method: post
plugins:
    - auth --output $.auth --input $.Cookie.TOKEN
    - auth-v2 --output $.auth2
    - set $.request.type Body --timing beforeRPCCall
    - set $.response.type Body --timing beforeRPCResponse
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
response:
    id: $.rpc.project_id

我们规定,所有的插件默认执行时机都是 beforeRequestParamsRefactor,所以上面我们用 --timing 去改变了插件的执行时机。利用 set 插件,将常量 Body 直接强制覆盖掉请求和响应中的 type

内部路由

但是上述的配置,只能调用一个 RPC Server,有一个接口,他需要同时调用两个 RPC Server,并且拆分请求参数和合并响应结果,于是你就想,为什么不能有一个没有 url 的配置

name: get_something
request:
    id: $.request.project_id
response:
    data: $.rpc.data

于是就有了上面的命名路由,这里我们叫它内部路由,因为它没法被外部直接调用,那这个应该怎么与上述的结合在一起呢?我们将内部路由定义成 $.route.get_something

url: /posts
method: post
plugins:
    - auth --output $.auth --input $.Cookie.TOKEN
    - auth-v2 --output $.auth2
    - set $.request.type Body --timing beforeRPCCall
    - set $.response.type Body --timing beforeRPCResponse
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
response:
    id: $.rpc.project_id
    something: $.route.get_something

服务发现

上述编故事,忘了插入 RPC 调用了 :D,其实也是没地方能植入 RPC 调用的地方,于是就将这个地方抽出来,单独讲。
假设一个 RPC Server,你有 100 个实例来提供服务

  • 192.168.1.2:8100
  • 192.168.1.2:8102
  • 192.168.1.3:8104
  • ...

这么多机器,你怎么知道机器在哪里呢,就几台机器,你可以手动写到配置里

url: /posts
method: post
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
response:
    id: $.rpc.project_id
rpc:
    - host: 192.168.1.2
      port: 8100
    - host: 192.168.1.2
      port: 8102
    - host: 192.168.1.3
      port: 8104

可是,现在 Docker 盛行,RPC Server 的 IP 是会不停的改变的,所以怎么能保证实例的 IP 能够每次自动的让 Gateway 知道呢?这个时候,就需要服务发现了,首先每个 RPC Server 在启动的时候,需要将自己的 IP:PORT 告诉到一个地方,注册中心,然后也要告诉注册中心,它是谁,也就是它的名字,这样,Gateway 就可以通过 RPC Server 的名字,到注册中心从,查到它的地址了,并且在地址改变了之后,注册中心也会通过各种方式通知 Gateway,去新的地方调它。

url: /posts
method: post
request:
    user_id: $.auth.user_id
    namespace: $.Query.namespace
    params: $.Body
response:
    id: $.rpc.project_id
rpc:
    name: posts.server
    timeout: 800ms

所以,上面的配置,就简化了很多了,这里就不去讲注册中心的实现了,这个不在一个「友好」的 Gateway 的范围之类,在 Gateway 这里,只要能够根据服务的名字发现服务的地址就行了。

热更新

上面只是描述了配置文件的格式,但是配置文件放哪里呢?
配置文件的储存必须要满足如下要求

  • 支持热更新
  • 能够回滚到特定的版本
  • 一条 route 的配置错误,不能影响到全局的配置

首先,要支持特定的版本回滚,你肯定首先想到了 git,是的,配置文件,完全可以放在 git 作为持久化,我们在这里将一些相同业务的路由作为一个项目,所以一个项目可以对应一个 gitlab 的项目,没错,我们选择 gitlab 来储存配置文件。

.
├── config.yaml
└── routes.yaml

0 directories, 2 files

上面就是一个 Gateway 配置的配置(怎么这么绕口),其中 routes.yaml 用来储存上述的各种路由配置,config.yaml 可以用来存一些全局性的配置,比如全局的 timeout 之类的。
然后可以利用 gitlab 的 webhook,当有 push 事件的时候,可以推送到某个地方通知 Gateway 来 gitlab 拉取配置文件,并且更新它。这样就可以实现运行时的 Gateway 配置热更新,而且还可以利用 git 的版本特性,实现回滚版本。

总结

上述还有很多地方待完善,比如 $ 的各个变量的作用域,在内部路由时 $.request$.rpc 又表示什么呢,这些都值得思考和仔细设计。