You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

139 lines
9.6 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 48 | 微服务API网关搭建三步曲
你好,我是温铭。
在明白了微服务 API 网关的核心组件和抽象概念后我们就要开始技术选型并动手去实现它了。今天我们就分别来看下路由、插件、schema 和存储这四个核心组件的技术选型问题。
## 存储
上节课我提到过,存储是底层非常关键的基础组件,它会影响到配置如何同步、集群如何伸缩、高可用如何保证等核心的问题,所以,我们把它放在最开始的位置来选型。
我们先来看看,已有的 API 网关是把数据存储在哪里的。Kong 是把数据储存在PostgreSQL 或者 Cassandra 中,而同样基于 OpenResty 的 Orange则是存储在 MySQL 中。不过,这种选择还是有很多缺陷的。
第一储存需要单独做高可用方案。PostgreSQL、MySQL 数据库虽然有自己的高可用方案,但你还需要 DBA 和机器资源,在发生故障时也很难做到快速切换。
第二,只能轮询数据库来获取配置变更,无法做到推送。这不仅会增加数据库资源的消耗,同时变更的实时性也会大打折扣。
第三,需要自己维护历史版本,并考虑回退和升级。如果用户发布了一个变更,后续可能会有回滚操作,这时候你就需要在代码层面,自己做两个版本之间的 diff以便配置的回滚。同时在系统自身升级的时候还可能会修改数据库的表结构所以代码层面就需要考虑到新旧版本的兼容和数据升级。
第四,提高了代码的复杂度。在实现网关的功能之外,你还需要为了前面 3 个缺陷,在代码层面去打上补丁,这显然会让代码的可读性降低不少。
第五,增加了部署和运维的难度。部署和维护一个关系型数据库并不是一件简单的事情,如果是一个数据库集群那就更加复杂了,并且我们也无法做到快速扩容和缩容。
针对这样的情况,我们应该如何选择呢?
我们不妨回到 API 网关的原始需求上来这里存储的都是简单的配置信息uri、插件参数、上游地址等并没有涉及到复杂的联表操作也不需要严格的事务保证。显然这种情况下使用关系型数据库可不就是“杀鸡焉用宰牛刀”吗
事实上,本着最小化够用并且更贴近 K8s 的原则etcd 就是一个恰到好处的选型了:
* API 网关的配置数据每秒钟的变化次数不会很多etcd 在性能上是足够的;
* 集群和动态伸缩方面,更是 etcd 天生的优势;
* etcd还具备 watch 的接口,不用轮询去获取变更。
其实还有一点,可以让我们更加放心地选择 etcd——它已经是 K8s 体系中保存配置的默认选型了,显然已经经过了很多比 API 网关更加复杂的场景的验证。
## 路由
路由也是非常重要的技术选型,所有的请求都由路由筛选出需要加载的插件列表,逐个运行后,再转发给指定的上游。不过,考虑到路由规则可能会比较多,所以路由这里的技术选型,我们需要着重从算法的时间复杂度上去考量。
我们先来看下,在 OpenResty 下有哪些现成的路由可以拿来使用。老规矩,让我们在 `awesome-resty` 的项目中逐个查找一遍,这其中就有专门的 `Routing Libraries`
```
• lua-resty-route — A URL routing library for OpenResty supporting multiple route matchers, middleware, and HTTP and WebSockets handlers to mention a few of its features
• router.lua — A barebones router for Lua, it matches URLs and executes Lua functions
• lua-resty-r3 — libr3 OpenResty implementation, libr3 is a high-performance path dispatching library. It compiles your route paths into a prefix tree (trie). By using the constructed prefix trie in the start-up time, you may dispatch your routes with efficiency
• lua-resty-libr3 — High-performance path dispatching library base on libr3 for OpenResty
```
你可以看到,这里面包含了四个路由库的实现。前面两个路由都是纯 Lua 实现,相对比较简单,所以有不少功能的欠缺,还不能达到生成的要求。
后面两个库,其实都是基于 libr3 这个 C 库,并使用 FFI 的方式做了一层封装,而 libr3 自身使用的是前缀树。这种算法和存储了多少条规则的数目 N 无关,只和匹配数据的长度 K 有关,所以时间复杂度为 O(K)。
但是, libr3 也是有缺点的,它的匹配规则和我们熟悉的 Nginx location 的规则不同而且不支持回调。这样我们就没有办法根据请求头、cookie、Nginx 变量来设置路由的条件,对于 API 网关的场景来说显然不够灵活。
不过,虽说我们尝试从 `awesome-resty` 中找到可用路由库的努力没有成功,但 libr3 的实现,还是给我们指引了一个新的方向:用 C 来实现前缀树以及 FFI 封装,这样应该可以接近时间复杂度和代码性能上的最优方案。
正好, Redis 的作者开源了一个基数树,也就是压缩前缀树的 [C 实现](https://github.com/antirez/rax)。顺藤摸瓜,我们还可以找到 rax 在 OpenResty 中可用的 [FFI 封装库](https://github.com/iresty/lua-resty-radixtree),它的示例代码如下:
```
local radix = require("resty.radixtree")
local rx = radix.new({
{
path = "/aa",
host = "foo.com",
method = {"GET", "POST"},
remote_addr = "127.0.0.1",
},
{
path = "/bb*",
host = {"*.bar.com", "gloo.com"},
method = {"GET", "POST", "PUT"},
remote_addr = "fe80:fe80::/64",
vars = {"arg_name", "jack"},
}
})
ngx.say(rx:match("/aa", {host = "foo.com",
method = "GET",
remote_addr = "127.0.0.1"
}))
```
从中你也可以看出, `lua-resty-radixtree` 支持根据 uri、host、http method、http header、Nginx 变量、IP 地址等多个维度,作为路由查找的条件;同时,基数树的时间复杂度为 O(K),性能远比现有 API 网关常用的“遍历+hash 缓存”的方式,来得更为高效。
## schema
schema 的选择其实要容易得多,我们在前面介绍过的 `lua-rapidjson` 就是非常好的一个选择。这部分你完全没有必要自己去写一个json schema 已经足够强大了。下面就是一个简单的示例:
```
local schema = {
type = "object",
properties = {
count = {type = "integer", minimum = 0},
time_window = {type = "integer", minimum = 0},
key = {type = "string", enum = {"remote_addr", "server_addr"}},
rejected_code = {type = "integer", minimum = 200, maximum = 600},
},
additionalProperties = false,
required = {"count", "time_window", "key", "rejected_code"},
}
```
## 插件
有了上面存储、路由和 schema 的基础,上层的插件应该如何实现,其实就清晰多了。插件并没有现成的开源库可以使用,需要我们自己来实现。插件在设计的时候,主要有三个方面需要我们考虑清楚。
首先是如何挂载。我们希望插件可以挂载到 `rewrite`、`access`、`header filer`、`body filter` 和 `log`阶段,甚至在 `balancer` 阶段也可以设置自己的负载均衡算法。所以,我们应该在 Nginx 的配置文件中暴露这些阶段,并在对插件的实现中预留好接口。
其次是如何获取配置的变更。由于没有关系型数据库的束缚,插件参数的变更可以通过 etcd 的 watch 来实现,这会让整体框架的代码逻辑变得更加明了易懂。
最后是插件的优先级。具体来说,比如,身份认证和限流限速的插件,应该先执行哪一个呢?绑定在 route 和绑定在 service 上的插件发生冲突时,又应该以哪一个为准呢?这些都是我们需要考虑到位的。
在梳理清楚插件的这三个问题后,我们就可以得到插件内部的一个流程图了:
![](https://static001.geekbang.org/resource/image/d1/13/d18243966a4973ff8409dd45bf83dc13.png)
## 架构
自然,当微服务 API 网关的这些关键组件都确定了之后,用户请求的处理流程,也就随之尘埃落定了。这里我画了一张图来表示这个流程:
![](https://static001.geekbang.org/resource/image/7f/89/7f2b50689a86d382a4c9340b4edb9489.png)
从这个图中我们可以看出,当一个用户请求进入 API 网关时,
* 首先会根据请求的方法、uri、host、请求头等条件去路由规则中进行匹配。如果命中了某条路由规则就会从 etcd 中获取对应的插件列表。
* 然后,和本地开启的插件列表进行交集,得到最终可以运行的插件列表。
* 再接着,根据插件的优先级,逐个运行插件。
* 最后,根据上游的健康检查和负载均衡算法,把这个请求发送给上游。
当架构设计完成后,我们就胸有成竹,可以去编写具体的代码了。这其实就像盖房子一样,只有在你拥有设计的蓝图和坚实的地基之后,才能去做砖瓦堆砌的具体工作。
## 写在最后
其实,通过这两节课的学习,我们已经做好了产品定位和技术选型这两件最重要的事情,它们都比具体的编码实现更为关键,也希望你可以更用心地去考虑和选择。
那么,在你的实际工作中,你是否使用过 API 网关呢?你们公司又是如何做 API 网关的选型的呢?欢迎留言和我分享你的经历和收获,也欢迎你把这篇文章分享出去,和更多的人一起交流、进步。