传统应用
一般情况下,你要对外提供 HTTP 接口,首先你得开发一个 Web Server,然后部署到服务器上,直接对外提供 HTTP 接口。
后来,你发现流量高了,你又没时间优化,只能堆机器了,于是乎你横向扩展了一堆 Web Server,这个时候你就需要一个 Proxy Server 做反向代理(Reverse Proxy)来让你的 Web Server 统一对外提供 HTTP 接口,并且尽可能的保证这一堆 Web Server 承载的流量是一样一样的。
微服务
随着时间的推移,你的 Web Server 不断壮大,承载的功能也越来越多了,这个时候微服务(Microservices)出现在你眼中,确认过眼神,你选择拆分你的 Web Server 的功能模块。为了更好的性能,你决定不再使用 HTTP 作为内部通信的方式了,RPC 有幸被你选中了。于是你重新设计了整个架构图。
那么问题来了,你的 RPC Server 提供的可能不是传统的 HTTP 协议了,这样搞,你就得找一个支持 RPC 反代的 Proxy 了,幸运的是 Nginx 支持了 gRPC 的反向代理,但是浏览器不一定能正常解析你的 RPC 的报文,RPC 一般情况下需要特定的 Client 去支持。所以这个时候,你就需要在 Proxy 与 RPC Server 之间加一个网关(Gateway)去做协议转换,将 RPC 的与 HTTP 之间做转换,让 Prxoy 之前的服务不需要做任何改变,就可以支持目前的架构。
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
的配置,当 auth
为 true
的时候,你就调用一个专门用来做认证的 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
又表示什么呢,这些都值得思考和仔细设计。