This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
<audio id="audio" title="47 | 微服务API网关搭建三步曲" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/1f/fd1b812f439177bf40911e9fd0c0601f.mp3"></audio>
你好,我是温铭。
今天这节课OpenResty 专栏就要进入实战的章节了。我会用三节课的内容,来为你介绍如何实现一个微服务 API 网关。在这个过程中,我们不仅会涉及到前面学过的 OpenResty 知识,我也会从行业、产品、技术选型等多个维度,为你展示下,如何从头做一个新的产品和开源项目。
## 微服务 API 网关有什么用?
让我们先来看下微服务 API 网关的作用。下面这张图,是一个简要的说明:
<img src="https://static001.geekbang.org/resource/image/de/ee/dea3e12608301d1a6b7fffce8bafafee.png" alt="">
众所周知API 网关并非一个新兴的概念,在十几年前就已经存在了,它的作用主要是作为流量的入口,统一处理和业务相关的请求,让请求更加安全、快速和准确地得到处理。它有以下几个传统功能:
- 反向代理和负载均衡,这和 Nginx 的定位和功能是一致的;
- 动态上游、动态 SSL 证书和动态限流限速等运行时的动态功能,这是开源版本 Nginx 并不具备的功能;
- 上游的主动和被动健康检查,以及服务熔断功能;
- 在 API 网关的基础上进行扩展,成为全生命周期的 API 管理平台。
在最近几年业务相关的流量不再仅仅由PC客户端和浏览器发起更多的来自手机、IoT 设备等,未来随着 5G 的普及这些流量会越来越多。同时随着微服务架构的结构变迁服务之间的流量也开始爆发性地增长。在这种新的业务场景下自然也催生了API 网关更多、更高级的功能:
1. 云原生友好,架构要变得轻巧,便于容器化;
1. 对接 Prometheus、Zipkin、SkyWalking 等统计、监控组件;
1. 支持 gRPC 代理,以及 HTTP 到 gRPC 之间的协议转换,把用户的 HTTP 请求转为内部服务的 gRPC 请求;
1. 承担 OpenID Relying Party 的角色,对接 Auth0、Okta 等身份认证提供商的服务,把流量安全作为头等大事来对待;
1. 通过运行时动态执行用户函数的方式来实现 Serverless让网关的边缘节点更加灵活
1. 不锁定用户,支持混合云的部署架构;
1. 最后,网关节点要状态无关,可以随意地扩容和缩容。
当一个微服务 API 网关具备了上述十几项功能时,就可以让用户的服务只关心业务本身;而和业务实现无关的功能,比如服务发现、服务熔断、身份认证、限流限速、统计、性能分析等,就可以在独立的网关层面来解决。
从这个角度来看API 网关既可以替代 Nginx 的所有功能,处理南北向的流量;也可以完成 Istio 控制面和 Envoy 数据面的角色,处理东西向的流量。
## 为什么要新造轮子?
正因为微服务 API 网关的地位如此重要,所以它一直处于兵家必争之地,传统的 IT 巨头在这个领域很早就都有布局。根据 2018 年 Gartner 发布的 API 全生命周期报告谷歌、CA、IBM、红帽、Salesforce 都是处于领导地位的厂商,开发者更熟悉的 Kong 则处于远见者的区间内。
那么,问题就来了,为什么我们还要新造一个轮子呢?
简单来说,这是因为当前的微服务 API 网关都不足以满足我们的需求。我们首先来看闭源的商业产品,它们的功能都很完善,覆盖了 API 的设计、多语言 SDK、文档、测试和发布等全生命周期管理并且提供 SaaS 服务,有些还与公有云做了集成,使用起来非常方便。但同时,它们也带来了两个痛点。
第一个痛点平台锁定问题。API 网关是业务流量的入口,它不像图片、视频等 CDN 加速的这种非业务流量可以随意迁移API 网关上会绑定不少业务相关的逻辑。你一旦使用了闭源的方案,就很难平滑和低成本地迁移到其他平台。
第二个痛点,无法二次开发的问题。一般的大中型企业都会有自己独特的需求,需要定制开发,但这时候你只能依靠厂商,而不能自己动手去做二次开发。
这也是为什么开源的 API 网关方案开始流行的一个原因。不过,现有的开源产品也不是万能的,自身也有很多不足。
第一,依赖 PostgreSQL、MySQL 等关系型数据库。这样,在配置发生变化的时候,网关节点只能轮询数据库。这不仅造成配置生效慢,也给代码增加了复杂度,让人难以理解;同时,数据库也会成为系统的单点和性能瓶颈,无法保证整体的高可用。如果你把 API 网关用于 Kubernetes 环境下,关系型数据库会显得更加笨重,不利于快速伸缩。
第二,插件不能热加载。当你新增一个插件或者修改现有插件的代码后,必须要重载服务才能生效,这和修改 Nginx 配置后需要重载是一样的,显然会影响用户的请求。
第三,代码结构复杂, 难以掌握。有些开源项目做了多层面向对象的封装,一些简单的逻辑也变得雾里看花。但其实,对于 API 网关这种场景,直来直去的表达会更加清晰和高效,也更有利于二次开发。
所以,我们需要一个更轻巧、对云原生和开发友好的 API 网关。当然,我们也不能闭门造车,需要先深入了解已有 API 网关各自的特点这时候云原生软件基金会CNCF的全景图就是一个很好的参考
<img src="https://static001.geekbang.org/resource/image/19/f7/19328e6e516ed8ed6f723dd32fef58f7.png" alt="">
这张图筛选出了业界常见的 API 网关,以开源的方案为主,可以为我们下面的技术选型提供不少有价值的内容。
## API 网关的核心组件和概念
当然,在具体实现之前,我们还需要了解 API 网关有哪些核心组件。根据我们前面提到的 API 网关具备的功能点,它至少需要下面几个组件才能开始运行。
首先是路由。它通过定义一些规则来匹配客户端的请求,然后根据匹配结果,加载、执行相应的插件,并把请求转发给到指定的上游。这些路由匹配规则可以由 host、uri、请求头等组成我们熟悉的 Nginx 中的 location就是路由的一种实现。
其次是插件。这是 API 网关的灵魂所在身份认证、限流限速、IP 黑白名单、Prometheus、Zipkin 等这些功能,都是通过插件的方式来实现的。既然是插件,那就需要做到即插即用;并且,插件之间不能互相影响,就像我们搭建乐高积木一样,需要用统一规则的、约定好的开发接口,来和底层进行交互。
接着是schema。既然是处理 API 的网关,那么少不了要对 API 的格式做校验,比如数据类型、允许的字段内容、必须上传的字段等,这时候就需要有一层 schema 来做统一、独立的定义和检查。
最后是存储。它用于存放用户的各种配置,并在有变更时负责推送到所有的网关节点。这是底层非常关键的基础组件,它的选型决定了上层的插件如何编写、系统能否保持高可用和可扩展性等,所以需要我们审慎地决定。
另外,在这些核心组件之上,我们还需要抽象出几个 API 网关的常用概念,它们在不同的 API 网关之间都是通用的。
先来说说Route。路由会包含三部分内容即匹配的条件、绑定的插件和上游如下图所示
<img src="https://static001.geekbang.org/resource/image/6a/0d/6a86c854ec54b07347ff517114482c0d.png" alt="">
我们可以直接在 Route 中完成所有的配置,这样最简单。但在 API 和上游很多的情况下,这样做就会有很多重复的配置。这时候,我们就需要 Service 和 Upstream 这两个概念来做一层抽象。
我们接着来看Service。它是某类 API 的抽象,也可以理解为一组 Route 的抽象它通常与上游服务是一一对应的而Route 与 Service 之间通常是 N:1 的关系。我也用了一张图来表示:
<img src="https://static001.geekbang.org/resource/image/09/db/0954bddf3828fa26f26a1ba2003c7edb.png" alt="">
通过 Service 的这层抽象,我们就可以把重复的插件和上游剥离出来。这样,在插件和上游发生变更的时候,我们只需要修改 Service 就可以了,而不用去修改多个 Route 上绑定的数据。
最后说说Upstream。还是继续上面的示例如果两个 Route 中的上游是一样的,但是绑定的插件各自不同,那么我们就可以把上游单独抽象出来,如下图所示:
<img src="https://static001.geekbang.org/resource/image/eb/8e/ebedcfafc5aeafb970097e480f663d8e.png" alt="">
这样在上游节点发生变更时Route 是完全无感知的,它们都在 Upstream 内部进行了处理。
其实,从这三个主要概念的衍生过程中,我们也可以看到,这几个抽象都基于用户的实际场景,而不是生造出来的。自然,它们适用于所有的 API 网关,和具体的技术方案无关。
## 写在最后
今天这节课,我们介绍了微服务 API 网关的作用、功能、核心组件和抽象概念,它们都是 API 网关的基础。
这里留给你一个思考题:关于传统的南北向流量,和微服务之间的东西向流量,你觉得 API 网关是否都可以处理呢?如果你已经在使用 API 网关了,你也可以写下当初技术选型时的思考。欢迎在留言区和我交流探讨,也欢迎你把这篇文章分享给你的同事、朋友,一起学习和进步。

View File

@@ -0,0 +1,138 @@
<audio id="audio" title="48 | 微服务API网关搭建三步曲" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2d/1f/2dec6a2604e3b17d2fc506d67e4f671f.mp3"></audio>
你好,我是温铭。
在明白了微服务 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(&quot;resty.radixtree&quot;)
local rx = radix.new({
{
path = &quot;/aa&quot;,
host = &quot;foo.com&quot;,
method = {&quot;GET&quot;, &quot;POST&quot;},
remote_addr = &quot;127.0.0.1&quot;,
},
{
path = &quot;/bb*&quot;,
host = {&quot;*.bar.com&quot;, &quot;gloo.com&quot;},
method = {&quot;GET&quot;, &quot;POST&quot;, &quot;PUT&quot;},
remote_addr = &quot;fe80:fe80::/64&quot;,
vars = {&quot;arg_name&quot;, &quot;jack&quot;},
}
})
ngx.say(rx:match(&quot;/aa&quot;, {host = &quot;foo.com&quot;,
method = &quot;GET&quot;,
remote_addr = &quot;127.0.0.1&quot;
}))
```
从中你也可以看出, `lua-resty-radixtree` 支持根据 uri、host、http method、http header、Nginx 变量、IP 地址等多个维度,作为路由查找的条件;同时,基数树的时间复杂度为 O(K),性能远比现有 API 网关常用的“遍历+hash 缓存”的方式,来得更为高效。
## schema
schema 的选择其实要容易得多,我们在前面介绍过的 `lua-rapidjson` 就是非常好的一个选择。这部分你完全没有必要自己去写一个json schema 已经足够强大了。下面就是一个简单的示例:
```
local schema = {
type = &quot;object&quot;,
properties = {
count = {type = &quot;integer&quot;, minimum = 0},
time_window = {type = &quot;integer&quot;, minimum = 0},
key = {type = &quot;string&quot;, enum = {&quot;remote_addr&quot;, &quot;server_addr&quot;}},
rejected_code = {type = &quot;integer&quot;, minimum = 200, maximum = 600},
},
additionalProperties = false,
required = {&quot;count&quot;, &quot;time_window&quot;, &quot;key&quot;, &quot;rejected_code&quot;},
}
```
## 插件
有了上面存储、路由和 schema 的基础,上层的插件应该如何实现,其实就清晰多了。插件并没有现成的开源库可以使用,需要我们自己来实现。插件在设计的时候,主要有三个方面需要我们考虑清楚。
首先是如何挂载。我们希望插件可以挂载到 `rewrite``access``header filer``body filter``log`阶段,甚至在 `balancer` 阶段也可以设置自己的负载均衡算法。所以,我们应该在 Nginx 的配置文件中暴露这些阶段,并在对插件的实现中预留好接口。
其次是如何获取配置的变更。由于没有关系型数据库的束缚,插件参数的变更可以通过 etcd 的 watch 来实现,这会让整体框架的代码逻辑变得更加明了易懂。
最后是插件的优先级。具体来说,比如,身份认证和限流限速的插件,应该先执行哪一个呢?绑定在 route 和绑定在 service 上的插件发生冲突时,又应该以哪一个为准呢?这些都是我们需要考虑到位的。
在梳理清楚插件的这三个问题后,我们就可以得到插件内部的一个流程图了:
<img src="https://static001.geekbang.org/resource/image/d1/13/d18243966a4973ff8409dd45bf83dc13.png" alt="">
## 架构
自然,当微服务 API 网关的这些关键组件都确定了之后,用户请求的处理流程,也就随之尘埃落定了。这里我画了一张图来表示这个流程:
<img src="https://static001.geekbang.org/resource/image/7f/89/7f2b50689a86d382a4c9340b4edb9489.png" alt="">
从这个图中我们可以看出,当一个用户请求进入 API 网关时,
- 首先会根据请求的方法、uri、host、请求头等条件去路由规则中进行匹配。如果命中了某条路由规则就会从 etcd 中获取对应的插件列表。
- 然后,和本地开启的插件列表进行交集,得到最终可以运行的插件列表。
- 再接着,根据插件的优先级,逐个运行插件。
- 最后,根据上游的健康检查和负载均衡算法,把这个请求发送给上游。
当架构设计完成后,我们就胸有成竹,可以去编写具体的代码了。这其实就像盖房子一样,只有在你拥有设计的蓝图和坚实的地基之后,才能去做砖瓦堆砌的具体工作。
## 写在最后
其实,通过这两节课的学习,我们已经做好了产品定位和技术选型这两件最重要的事情,它们都比具体的编码实现更为关键,也希望你可以更用心地去考虑和选择。
那么,在你的实际工作中,你是否使用过 API 网关呢?你们公司又是如何做 API 网关的选型的呢?欢迎留言和我分享你的经历和收获,也欢迎你把这篇文章分享出去,和更多的人一起交流、进步。

View File

@@ -0,0 +1,211 @@
<audio id="audio" title="49 | 微服务API网关搭建三步曲" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/c7/d4b1aa87b212a3023fc52be2116562c7.mp3"></audio>
你好,我是温铭。
今天这节课,微服务 API 网关搭建就到了最后的环节了。让我们用一个最小的示例来把之前选型的组件,按照设计的蓝图,拼装运行起来吧!
## Nginx 配置和初始化
我们知道API 网关是用来处理流量入口的,所以我们首先需要在 Nginx.conf 中做简单的配置,让所有的流量都通过网关的 Lua 代码来处理。
```
server {
listen 9080;
init_worker_by_lua_block {
apisix.http_init_worker()
}
location / {
access_by_lua_block {
apisix.http_access_phase()
}
header_filter_by_lua_block {
apisix.http_header_filter_phase()
}
body_filter_by_lua_block {
apisix.http_body_filter_phase()
}
log_by_lua_block {
apisix.http_log_phase()
}
}
}
```
这里我们使用开源 API 网关 [APISIX](https://github.com/apache/apisix) 为例,所以上面的代码示例中带有 `apisix` 的关键字。在这个示例中,我们监听了 9080 端口,并通过 `location /` 的方式,把这个端口的所有请求都拦截下来,并依次通过 `access``rewrite``header filter``body filter``log` 这几个阶段进行处理,在每个阶段中都会去调用对应的插件函数。其中, `rewrite` 阶段便是在 `apisix.http_access_phase` 函数中合并处理的。
而对于系统初始化的工作,我们放在了 `init_worker` 阶段来处理,这其中包含了读取各项配置参数、预制 etcd 中的目录、从 etcd 中获取插件列表、对于插件按照优先级进行排序等。我这里列出了关键部分的代码并进行讲解,当然,你可以在 GitHub 上看到更完整的[初始化函数](https://github.com/apache/apisix/blob/master/lua/apisix.lua#L47)。
```
function _M.http_init_worker()
-- 分别初始化路由、服务和插件这三个最重要的部分
router.init_worker()
require(&quot;apisix.http.service&quot;).init_worker()
require(&quot;apisix.plugin&quot;).init_worker()
end
```
通过阅读这段代码,你可以发现,`router``plugin` 这两部分的初始化相对复杂一些,主要涉及到读取配置参数,并根据参数的不同做一些选择。因为这里会涉及到从 etcd 中读取数据,所以我们使用的是 `ngx.timer` 的方式,来绕过“不能在 `init_worker` 阶段使用 cosocket”的这个限制。如果你对这部分很感兴趣并且学有余力建议一定要去读读源码加深理解。
## 匹配路由
在最开始的 `access` 阶段里面,我们首先需要做的就是匹配路由,根据请求中携带 uri、host、args、cookie 等,来和已经设置好的路由规则进行匹配:
```
router.router_http.match(api_ctx)
```
对外暴露的,其实只有上面一行代码,这里的`api_ctx` 中存放的就是 uri、host、args、cookie 这些请求的信息。而具体的 `match` 函数的[实现](https://github.com/apache/apisix/blob/master/apisix/http/router/radixtree_uri.lua),就用到了我们前面提到过的 `lua-resty-radixtree`。如果没有命中,就说明这个请求并没有设置与之对应的上游,就会直接返回 404。
```
local router = require(&quot;resty.radixtree&quot;)
local match_opts = {}
function _M.match(api_ctx)
-- 从 ctx 中获取请求的参数,作为路由的判断条件
match_opts.method = api_ctx.var.method
match_opts.host = api_ctx.var.host
match_opts.remote_addr = api_ctx.var.remote_addr
match_opts.vars = api_ctx.var
-- 调用路由的判断函数
local ok = uri_router:dispatch(api_ctx.var.uri, match_opts, api_ctx)
-- 没有命中路由就直接返回 404
if not ok then
core.log.info(&quot;not find any matched route&quot;)
return core.response.exit(404)
end
return true
end
```
## 加载插件
当然,如果路由可以命中,就会走到过滤插件和加载插件的步骤,这也是 API 网关的核心所在。我们先来看下面这段代码:
```
local plugins = core.tablepool.fetch(&quot;plugins&quot;, 32, 0)
-- etcd 中的插件列表和本地配置文件中的插件列表进行交集运算
api_ctx.plugins = plugin.filter(route, plugins)
-- 依次运行插件在 rewrite 和 access 阶段挂载的函数
run_plugin(&quot;rewrite&quot;, plugins, api_ctx)
run_plugin(&quot;access&quot;, plugins, api_ctx)
```
在这段代码中,我们首先通过 table pool 的方式,申请了一个长度为 32 的 table这是我们之前介绍过的性能优化技巧。然后便是插件的过滤函数。你可能疑惑为什么需要这一步呢在插件的 `init worker` 阶段,我们不是已经从 etcd 中获取插件列表并完成排序了吗?
事实上,这里的过滤是和本地配置文件来做对比的,主要有下面两个原因。
- 第一,新开发的插件需要灰度来发布,这时候新插件在 etcd 的列表中存在,但只在部分网关节点中处于开启状态。所以,我们需要额外做一次交集的运算。
- 第二,为了支持 debug 模式。终端的请求经过了哪些插件的处理?这些插件的加载顺序是什么?这些信息在调试的时候会很有用,所以在过滤函数中也会判断其是否处于 debug 模式,并在响应头中记录下这些信息。
因此,在 access 阶段的最后,我们会把这些过滤好的插件,按照优先级逐个运行,如下面这段代码所示:
```
local function run_plugin(phase, plugins, api_ctx)
for i = 1, #plugins, 2 do
local phase_fun = plugins[i][phase]
if phase_fun then
-- 最核心的调用代码
phase_fun(plugins[i + 1], api_ctx)
end
end
return api_ctx
end
```
你可以看到,在遍历插件的时候,我们是以 `2` 为间隔进行的,这是因为每个插件都会有两个部分组成:插件对象和插件的配置参数。现在,我们来看上面示例代码中最核心的那一行代码:
```
phase_fun(plugins[i + 1], api_ctx)
```
单独看这行代码会有些抽象,我们用一个具体的 `limit_count` 插件来替换一下,就会清楚很多:
```
limit_count_plugin_rewrite_function(conf_of_plugin, api_ctx)
```
到这里API 网关的整体流程,我们就实现得差不多了。这些代码都在同一个代码[文件](https://github.com/apache/apisix/blob/master/apisix/init.lua)中,它里面有 400 多行代码,但核心的代码就是我们上面所介绍的这短短几十行。
## 编写插件
现在,距离一个完整的 demo 还差一件事情,那就是编写一个插件,让它可以跑起来。我们以 `limit-count` 这个限制请求数的插件为例,它的[完整实现](https://github.com/apache/apisix/blob/master/apisix/plugins/limit-count.lua)只有 60 多行代码,你可以点击链接查看。下面,我来详细讲解下其中的关键代码。
首先,我们要引入 `lua-resty-limit-traffic` ,作为限制请求数的基础库:
```
local limit_count_new = require(&quot;resty.limit.count&quot;).new
```
然后,使用 rapidjson 中的 json schema ,来定义这个插件的参数有哪些:
```
local schema = {
type = &quot;object&quot;,
properties = {
count = {type = &quot;integer&quot;, minimum = 0},
time_window = {type = &quot;integer&quot;, minimum = 0},
key = {type = &quot;string&quot;,
enum = {&quot;remote_addr&quot;, &quot;server_addr&quot;},
},
rejected_code = {type = &quot;integer&quot;, minimum = 200, maximum = 600},
},
additionalProperties = false,
required = {&quot;count&quot;, &quot;time_window&quot;, &quot;key&quot;, &quot;rejected_code&quot;},
}
```
插件的这些参数,和大部分 `resty.limit.count` 的参数是对应的,其中包含了限制的 key、时间窗口的大小、限制的请求数。另外插件中增加了一个参数: `rejected_code`,在请求被限速的时候返回指定的状态码。
最后一步,我们把插件的处理函数挂载到 `rewrite` 阶段:
```
function _M.rewrite(conf, ctx)
-- 从缓存中获取 limit count 的对象,如果没有就使用 `create_limit_obj` 函数新建并缓存
local lim, err = core.lrucache.plugin_ctx(plugin_name, ctx, create_limit_obj, conf)
-- 从 ctx.var 中获取 key 的值,并和配置类型和配置版本号一起组成新的 key
local key = (ctx.var[conf.key] or &quot;&quot;) .. ctx.conf_type .. ctx.conf_version
-- 进入限制的判断函数
local delay, remaining = lim:incoming(key, true)
if not delay then
local err = remaining
-- 如果超过阈值,就返回指定的状态码
if err == &quot;rejected&quot; then
return conf.rejected_code
end
core.log.error(&quot;failed to limit req: &quot;, err)
return 500
end
-- 如果没有超过阈值,就放行,并设置对应响应头
core.response.set_header(&quot;X-RateLimit-Limit&quot;, conf.count,
&quot;X-RateLimit-Remaining&quot;, remaining)
end
```
上面的代码中,进行限制判断的逻辑只有一行,其他的都是来做准备工作和设置响应头的。如果没有超过阈值,就会继续按照优先级运行下一个插件。
## 写在最后
今天这节课,通过整体框架和插件的编写,我们就完成了一个 API 网关的 Demo。更进一步利用本专栏学到的 OpenResty 知识,你可以在上面继续添砖加瓦,搭建更丰富的功能。
最后给你留一个思考题。我们知道API 网关不仅可以处理七层的流量,也可以处理四层的流量,基于此,你能想到它的一些使用场景吗?欢迎留言说说你的看法,也欢迎你把这篇文章分享出去,和更多的人一起学习、交流。

View File

@@ -0,0 +1,104 @@
<audio id="audio" title="50 | 答疑(五):如何在工作中引入 OpenResty" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/94/03cb04deab8b531696b1a6e28408c694.mp3"></audio>
你好,我是温铭。
几个月的时间转瞬即逝到现在OpenResty专栏的最后一个版块微服务 API 网关篇,我们就已经学完了。恭喜你没有掉队,始终在积极学习和实践操作,并且热情地留下了你的思考。
很多留言提出的问题很有价值大部分我都已经在App里回复过一些手机上不方便回复的或者比较典型、有趣的问题我专门摘了出来作为今天的答疑内容集中回复。另一方面也是为了保证所有人都不漏掉任何一个重点。
下面我们来看今天的这 5 个问题。
## 问题一OpenResty 在工作中的使用
Q快结课了我也基本上跟下来了但自己的实践还是偏少工作中目前未用。不过这确实是很强大的一门课。感谢温老师的持续分享后期工作中我也会择机引入。
A感谢这位同学的认可关于这条留言我想聊一聊如何在工作中引入 OpenResty这确实是一个值得一谈的话题。
OpenResty 基于 Nginx并在它的基础之上加了 lua-nginx-module 的 C 模块和众多 lua-resty 库,所以 OpenResty 是可以无痛替换 Nginx 的,这是成本最低的开始使用 OpenResty 的方法。当然,这个替换过程也是有风险的,你需要注意下面这三点。
第一,确认线上 Nginx 的版本。OpenResty 的主版本号与 Nginx 保持一致,比如 OpenResty 1.15.8.1 使用的就是 Nginx 1.15.8 的内核。如果目前线上 Nginx 的版本号比 OpenResty 的最新版高,那么你最好谨慎换用 OpenResty毕竟OpenResty 升级的速度还是比较慢的,离 Nginx 的主线版本要落后半年到一年的时间。如果线上 Nginx 的版本和 OpenResty 的一致或者比 OpenResty 的低,那就具备了升级的前提条件。
第二,测试。测试是最主要的一个环节,使用 OpenResty 替换 Nginx 的风险很低,但肯定也存在一些风险。比如,是否有自定义的 C 模块需要编译OpenResty 依赖的 openssl 版本,以及 OpenResty 给 Nginx 打的 patch 是否对业务会造成影响等。你需要复制一些业务的流量过来做验证。
第三,流量切换。基本的验证通过后,你还需要线上真实流量的灰度来验证,这时候为了能够快速的回滚,我们可以新开几台服务器来部署 OpenResty而不是直接替换原有的 Nginx 服务。如果没有问题,我们可以选择二进制文件热升级的方式,或者是从 LB 中逐步摘掉和替换 Nginx 的方式来升级。
OpenResty 除了可以替代 Nginx 外,另外两个比较容易的切入点是 WAF 和 API 网关,它们都是对性能和动态有比较高要求的场景,也有对应的开源项目可以开箱即用,我在专栏中也有部分涉及到。
再继续把 OpenResty 深入到业务层面的话,就需要考虑比较多技术之外的因素了,比如是否容易招聘到 OpenResty 相关的工程师?是否能够和公司原有的技术系统进行融合等等。
总的来说,从替代 Nginx 的角度来切入然后慢慢扩散来使用OpenResty ,是一个不错的注意。
## 问题二OpenResty 的数据库封装
Q根据你的指点要尽量少用 `..`字符串拼接特别是在代码热区。但是我在处理数据库访问时需要动态构建SQL语句在语句中插入变量这应该是非常常见的使用场景。可是对于这个需求我目前感觉只有字符串拼接是最简单的办法其他真的想不到既简单又高性能的办法。
A你可以先用我们前面课程介绍过的 SystemTap 或者其他工具分析下,看 SQL 语句的拼接是否是系统的瓶颈。如果不是,自然就没有优化的必要性,毕竟,过早的优化是万恶之源。
如果瓶颈确实是 SQL 语句的拼接,那么我们可以利用数据库的 `prepare` 语句来做优化,也可以用数组的方式来做拼接。但 `lua-resrty-mysql``prepare` 的支持一直处于 TODO 状态,所以只剩下数组拼接的方式了。这也是一些 lua-resty 库的通病,实现了大部分的功能,处于能用的状态,但更新得并不够及时。除了数据库的 `prepare` 语句外,`lua-resty-redis``cluster` 也一直没有支持。
字符串拼接,包括 lua-resty 库的这类问题OpenResty 是希望用 DSL 来彻底解决的——使用编译器的技术自动生成数组来拼接字符串,把这些细节隐藏起来,上层的用户不用感知;使用小语言 wirelang 来自动生成各种 lua-resty 网络通信库,不再需要手写。
这听上去很美好吧?但有一个问题必须正视,那就是自动生成的代码对人类是不友好的。如果你要学习或者修改生成的代码,就必须再学习编译器技术以及一门可能不会开源的 DSL这会让参与社区的门槛越来越高。
## 问题三OpenResty 的 Web 框架
Q我现在想用 OpenResty 做一个Web项目但做起来很痛苦主要是没找到成熟的框架需要自己造很多轮子就比如说上面的数据库操作问题没找到可以动态构建SQL语句、连贯操作的类库。所以想问下老师在Web框架上有什么好的可以推荐吗
A`awesome-resty` 这个仓库中,我们可以看到有专门的 [W](https://github.com/bungle/awesome-resty#web-frameworks)[eb 框架分类](https://github.com/bungle/awesome-resty#web-frameworks),有 20 个 开源项目不过大部分项目都处于停滞的状态。其中Lapis、lor 和香草这三个项目你可以尝试下,看看哪一个更适合。
确实,由于没有强大的 Web 框架作为支撑OpenResty 在处理大项目的时候就会力不从心,这也是很少有人用 OpenResty 做业务系统的原因之一。
## 问题四修改了响应体怎么修改响应头中的content-length
Q如果需要修改respones body的内容就只能在body filter里做修改但这样会引起body长度与 content-length 长度不一致,应该如何处理呢?
A在这种情况下我们需要在 body filter 之前的 header filter 阶段中,把 content length 这个响应头置为 nil不再返回改为流式输出。
下面是一段示例代码:
```
server {
listen 8080;
location /test {
proxy_pass http://www.baidu.com;
header_filter_by_lua_block {
ngx.header.content_length = nil
}
body_filter_by_lua_block {
ngx.arg[1] = ngx.arg[1] .. &quot;abc&quot;
}
}
}
```
通过这段代码你可以看到,在 body filter 阶段中,`ngx.arg[1]` 代表的就是响应体。如果我们在它后面增加了字符串 `abc`,响应头 content length 就不准确了,所以,我们在 header filter 阶段直接把它禁用掉就可以了。
另外,从这个示例中,我们还可以看到 OpenResty 的各个阶段之间是如何来配合工作的,这一点也希望你注意并思考。
## 问题五Lua 代码的查找路径
Q`lua_package_path` 似乎配置的是Lua依赖的搜索路径。对于`content_by_lua_file`我试验发现它只在prefix下根据指令提供的文件相对路径去搜索而不会到 `lua_package_path` 下搜索。不知道我的理解对不对?
A这位同学自己动手试验和思考的精神非常值得肯定并且这个理解也是对的。`lua_package_path` 这个指令是用来加载 Lua 模块而使用的,比如我们在调用 `require 'cjson'` 时,就会到`lua_package_path` 中的指定目录中,去查找 cjson 这个模块。而 `content_by_lua_file` 则不同,它后面跟随的是磁盘中的一个文件路径:
```
location /test {
content_by_lua_file /path/test.lua;
}
```
而且,如果这里不是绝对路径而是相对路径:
```
content_by_lua_file path/test.lua;
```
那么就会使用 OpenResty 启动时指定的 `-p` 目录,来做一个拼接,从而得到绝对路径。
今天主要解答这几个问题。最后,欢迎你继续在留言区写下你的疑问,我会持续不断地解答。希望可以通过交流和答疑,帮你把所学转化为所得。也欢迎你把这篇文章转发出去,我们一起交流、一起进步。