OpenResty 社区王院生:lua-resty-r3 高性能 OpenResty 路由实现

大家下午好!首先做下自我介绍,我于 2014 年加入奇虎 360,后与温铭结识,当时他正在基于 OpenResty 做天擎服务端,用于提供 API 服务。2015 年我们一起写了《 OpenResty 最佳实践 》,原因是当时我们团队想扩充,但是身边的同事都不知道如何学习 OpenResty,OpenResty 相关的学习资料也少。我们完成这本书的写作后,就给身边的同事们使用,而不再需要每次都通过口传和培训的方式来影响。意外的是我们在公司内影响的人并不多,反而在公司外却通过这本书聚集了上万人的社区成员。《 OpenResty 最佳实践 》从无到有,完全是以开源的方式公开的。2015 年 12 月,老罗在锤子科技产品发布会上宣布将门票收入全部捐赠给开源项目 OpenResty,这次也让更多的人知道了 OpenResty。2017 年 3 月,春哥(章亦春,网名:agentzh)准备创业,我就跟他一起作为技术合伙加入了 OpenResty Inc.。

今天跟大家分享我最近在做的基于 OpenResty 的高性能路由实现。我了解到很多人用 OpenResty 做网关,也有做 Web Server,由于 OpenResty 成立到现在周边的库和基础设施并没有很完善,所以我们一直想通过社区的方式来提供一个大家比较认同的开发框架来做 Web Server。对于 Web 框架里面比较经典的是 MVC 结构,其中包括 Model、View 和 Controller 三层,目前 Model 和 View 层已经有了比较好的实现,而路由一直没有特别强大、高效的解决方案。这次与大家介绍 lua-resty-r3 路由实现 ,和大家分享我在与春哥合作之后的一个感悟,作为一个开发什么事情是值得我们吹牛?

首先说一说程序员的“牛皮”,之所以说这些是因为其中大部分是我曾经跟别人吹过的牛皮,比如“一天写了几千行代码”、“收入也不错”、“做过超大项目,几亿 PV 小意思”等,这些都可能是程序员跟别人炫耀的话,但是我认为程序员真正的牛皮逃不出两个点:“都在用我的代码”和“运行在更多计算机上”。金山的口号概括地特别好“希望我们写的代码可以跑在每台计算机上”,我们作为程序员,如果创造的每行代码可以让所有人享受到这个代码的好处,这是一件非常荣耀的事情。

今天我们聚集在一起讨论 OpenResty ,很大一部分原因是春哥创造了 OpenResty,而我们都在使用。春哥创造了我们每天都在用的东西,这就是他最“牛”的地方。

基础组件开发特点

如果我们要写代码让更多的人使用,那么代码就必须要下沉到基础组件,因为基础组件的开发所要求的严谨程度远大于业务应用,基础组件的开发通常需要满足以下的要求:

  • 小而美,任何人都不希望基础组件是一个很庞大的东西;
  • 需求变化小;
  • 稳定性要求高;
  • 小需求,可能大改动;
  • 能够处理异常分支。这里的处理要大于正常的业务逻辑,需要把所有的异常都包括在内;
  • 技术难度大,甚至维护的难度也大。优秀的程序员能够把一个难事做的很简单;
  • 关系咬合比较紧密,偶尔重构。由于基础组件位于业务的底层,所以它需要支持的场景自己是不知道的;
  • 基础组件在迭代的时候,针对不兼容的情况,老鸟通常是改,新鸟喜欢新增 API。

上图是一条比较完整的基础组件开发的流程,这里列举的并非包含了所有要素,而是我认为现在做基础组件时哪些是必备的。最中心的是书写代码,这个环节往往是重中之重,但如果要维持一个良好的基础组件,实际上从最开始的需求提出到最后发布版本全流程都需要注意。今天分享的议题是根据我对春哥以及 OpenResty 体系的研究,总结出最常见的基础组件的维护的完整流程。

首先需求、调研和项目目标,甚至包括最简单的测试用例,这些信息主要是用来确定项目目标,我们首先要知道要做什么?技术目标是什么?以及要暴露哪些 API?当暴露了 API 之后,需要讨论最小的使用迷你 case 是什么样子,从而梳理出前期的基本需求,这个环节通常开发可以自己拍板,主要涉及一些文档的工作。

测试模式

下面介绍三种测试模式,大部分人会接触其中的 1-2 种。我认为其中可以适当轻松一下的是 Service Tests,而 Unit Tests(单元测试)是必须要有的,它能保证所有 API 的细节符合我们的输入输出。End-to-End Test (端到端测试),实际上是为了保证业务本身符合我们一开始的设计,它是直接面对用户的,手机 App 点击菜单,输出各种各样的效果,都是有自动化的工具来实现的。单元测试提供的是开发内部,而端到端测试是对于外部完整的联动起来,这二者是必须要有的。

单元测试

OpenResty 体系内使用了在其他领域很少见的测试框架 Test::Nginx,OpenResty

里面用了大量的 Lua ,而 Lua 的测试用例几乎都是使用 busted 来书写的,这二者之间有很大的区别。

OpenResty 能力很强的人,不一定能写 Test::Nginx 的测试用例,因为它的语言是 Perl,很多人不熟悉。此外 Test::Nginx 是一个通用的测试框架,并不仅仅只服务于 OpenResty,它可以扩充延伸,甚至有其他很多不同测试的用途。但是右边的 busted,明显是只能用于 Lua。

为什么 OpenResty 要选 Test::Nginx 这个测试框架呢?原因是因为使用场景,OpenResty 的测试场景既要能够测 C 模块,也要有能力测 Lua 模块,甚至有时候还要有能力测试一个服务,我们不仅需要测试进行内部,还需要测试进程的外部输出,比如 HTTP 请求查看结果。OpenResty 有运行阶段的概念,同样一串 Lua 代码在不同的阶段行为是不一样的。比如在 init 和 content 阶段,所能够使用的 API 完全不一样,但是这种模式在 Lua 层面是完全做不到的。Test::Nginx 的功能点覆盖比较强,由于它是可以跨阶段,可以把 OpenResty 里面所有特殊的情况排列组合,达到测试目的。

Test::Nginx 有这么多优点,自然也会存在一些问题。首先是抽象的层次比较高,这就导致只看测试用例看不出它是用什么语言支撑的,因为它都是抽象的配置项,比如要测试访问码,它是由单独的配置项来实现的,和语言无关,这就需要专门看文档学习;第二个缺点是学习的难度比较高,尤其是需要做自定义修改时,比如扩展选项,这都是需要做二次开发或集成的。此外,Test::Nginx 是没有代码覆盖率的,因为代码覆盖率必须要在源码内部才有,所以 busted 是有代码覆盖率的。

选择 Test::Nginx 的测试框架,还有一个很重要的原因是它是一个通用的测试框架,这意味着可以用这个测试框架测试现有的大部分的测试平台软件,比如 Java、Go 等。

以下是 Test::Nginx 测试框架的特点:

  • 基于 Test::Base;
  • Perl 语⾔(上⼿难);
  • 语⾔无关的测试框架;
  • 很强的扩展性(虽然难),既是优势,也是劣势;
  • 可搭配 valgrind;
  • 可搭配 ASAN;

目前 Test::Nginx 测试框架已经很好地集成了 valgrind 和 ASAN 这两个内存整合工具,可以相互配合,他们都是用来做内存检测的,检查内存是否被正确释放、使用等情况。

我最近两年在写服务的时候都是用的 Test::Nginx 测试框架,也给大家推荐一下,虽然有它的不足,但是带来的好处也很多,最大的好处就是不需要在不同的测试体系下来回地切换思维。

书写代码

接下来介绍一个技术细节,前面提到做基础组件的开发会分几步走,在书写完测试用例后会进行代码书写,我这次的路由书写代码用的框架是基于 r3 ,它是一个开源的项目。它可以把路由规则编译成一个前缀树,从而使匹配效率更高,可以直接用 Lua 调用 libr3.so 的库,这种代码结构会非常简单。但是缺点是如果通过 FFI 的方式来直接调用动态库,需要知道动态库调入时传入的参数的所有结构,如果它的入参只是一些字符串、数值,这样会很简单,直接包就可以。但是 libr3.so 的库比较复杂,它有非常强的内存结构,它的很多输入参数都是有结构体的,而且它也用了很多宏定义来实现数据结构,然后用这样的存储结构来做传参。当我们用 FFI 的方式来描述所有参数的结构体时,需要在 FFI 的文件描述里完整地写出所用到的所有的头、导出的函数,以及依赖的结构体。

网上可以找到 Lua-resty-r3 的另一个开源实现,关于 C 头文件描述用了 170 行代码,但是那个版本和 r3 最近的变化是冲突的,于是我尝试修改了项目的代码,把现有的结构体的声明、函数导出的声明都改一遍,修改到一半就遇到了问题,因为 r3 的结构体的实现一层套一层,而且里面还有各种宏的替换,导致人工来改的成本很高。

于是我对原本的 libr3 做一层封装,把他内部所有调用的结构体的传参全部藏起来,简单地说就是把所有是结构体的地方都换成了一个指针,如果里面的调用函数可以合并,就可以对外导出一个标准的函数。如上图右侧 ”Two steps“,Lua 调用我们封装的 libr3.so,libr3.so 底层调用的是原本的实现 libr3.a ,中间套了一层之后就看不到原来 r3 的结构体。

上图是一段示例代码,可以看出大多数的封装只是把类型转了一下,并没有特别复杂的封装,只是把结构体都变成 void* 。这样做的缺点是,对于结构体,C/C++ 编译器编译阶段很容易找到参数类型传错的问题,而当我们换成 void* 的传参,由于 void* 可以被任意传参, 编译器只能帮我们检测错误的可能,这个问题是开发者需要注意的。

但是优势也比较明显,FFI 只需要导出图中的函数:

void*
r3_create(int cap)
void
r3_free(void*tree)

它们导出是不依赖任何的结构体,会让库写起来非常方便,所以会让 170 行代码变成 20 行。

持续集成

前面已经有了测试框架,我们需要有一种方式能够做对当前所有的业务请求做一个完整的测试用例的回归。


大家如果在用 Github 应该都会了解 Travis ,Travis 是对开源项目最友好的通用测试平台。服务一旦开启了 Travis ,每一次提交它都可以在平台上做自动的回归测试,当然我们需要书写一个 .yml 文件,告诉它你要干什么事情、需要什么环境、怎么编译、怎么检测等,它会给你一个结果的反馈,利用这个结果,就可以跟进软件持续的开发。


上图是开启 Travis CI 的方法,登陆自己的用户,点击 Settings,在用户的分组里面,找到一个具体的项目,点击勾选就开启了。

曾经很多人问过我,春哥的牛皮到底是什么?其实这个问题,我在不同的阶段也有不同的回答。现阶段我认为春哥的测试体系非常厉害。OpenResty 这个软件如果有哪个人能把春哥所有的东西以及这些子项目之间的关系全部搞清楚,我觉得已经很厉害了,而春哥却以一己之力把这些东西玩的很转,其中很大一部分原因是他把测试体系看的很重要,他在测试体系上的积累能够让他把 OpenResty 这个项目可持续地往前推进,所以大家以后要对测试体系要额外地重视,尤其是做一个比较复杂的组件。


测试工具

下面详细介绍一下 C / C++ 的测试工具,我用这种方式发现了 r3 的两个 bug。其中两个测试都是与内存泄露相关,使用 ASAN mode 和 valgrind mode ,ASAN 是使用 clang 加编译参数完成;valgrind 运行之前需要通过 valgrind 命令行的方式,它会模拟 CPU 完成内存的管理,帮我们检查是否有内存泄露等情况。

wrk 和火焰图是辅助工具,也是辅助我们发现问题。wrk 是一个压测的工具,它和 OpenResty 存在的方式几乎是一模一样的,都是通过 C + Lua 实现,不过这里的 Lua 和 OpenResty 里面的 Lua 是两回事,毕竟 Lua 是一门寄宿语言,是由它的宿主决定它具有什么扩展性。火焰图主要可以确定性能瓶颈,比如 CPU 占时、内存持续泄露等问题。如果是性能问题,可以根据火焰图横坐标的长度,确定问题大概在什么位置,是哪段代码占用过多 CPU 时间,如果时间消耗不是符合预期,就可以着手修他了。

在我的开发的习惯中,除了使用 ASAN 和 valgrind 来检查内存问题,到最后一定会跑性能,跑完性能用后面的辅助工具来检验,观察性能的指标是否符合预期。指标是一部分,还有是观察火焰图中看它表现出来的行为和预期的是否一样,比如现在做的库是 r3 路由,我们期望它所有的 CPU 消耗都在路由的预算上。两个点可以关注:第一,是不是把大部分的时间都确实花在路由预算上,第二,路由预算的方法过程本身,是不是还有可优化的空间,这两个问题都可以在火焰图中找到非常好的答案。

创建里程碑

最后需要创建里程碑,完成了一个阶段如果没有里程碑,就没有办法跟领导申请立项,也拿不到项目的预算资金,所以里程碑非常重要,它可以关注每个阶段的东西。除此之外,当我们做一个开源项目的时候,它的作用就更加明显,它代表的是项目对外的稳定版本。

上图是我对 lua-resty-r3 项目打的一个 tag ,这个版本是一个相对比较重要的稳定性阶段,这个项目相关的所有东西我都会放到 Issues 里面,目前这还是一个私有项目,不过过不了多久就会开源给大家。

今天分享的内容主要侧重在开发流程上,纯粹的技术细节不是很多,谢谢大家!


演讲视频及PPT:

lua-resty-r3 高性能 OpenResty 路由实现www.upyun.com图标

编辑于 2019-05-23