Openresty的开发闭环初探

为什么值得入手?

Nginx作为现在使用最广泛的高性能后端服务器,Openresty为之提供了动态预言的灵活,当性能与灵活走在了一起,无疑对于被之前陷于臃肿架构,苦于提升性能的工程师来说是重大的利好消息,本文就是在这种背景下,将初入这一未知的领域之后的一些经验与大家分享一下,若有失言之处,欢迎指教。

安装

现在除了能在 Download里面下载源码来自己编译安装,现在连预编译好的都有了, 安装也就分分钟的事了。

hello world

/path/to/nginx.conf, conftent_by_lua_file里面的路径请根据luapackagepath调整一下。

```
location / {
    content_by_lua_file ../luablib/hello_world.lua;
}
```

/path/to/openresty/lualib/hello_world.lua

```
ngx.say("Hello World")
```

访问一下, Hello World~.

```
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/octet-stream
Date: Wed, 11 Jan 2017 07:52:15 GMT
Server: openresty/1.11.2.2
Transfer-Encoding: chunked

Hello World
```

基本上早期的Openresty相关的开发的路数也就大抵如此了, 将lua库发布到lualib之下,将对应的nginx的配置文件发布到nginx/conf底下,然后reload已有的Openresty进程(少数需要清空Openresty shared_dict数据的情况需要重启), 如果是测试环境的话,那更是简单了,在http段将luacodecache设为off, Openresty不会缓存lua脚本,每次执行都会去磁盘上读取lua脚本文件的内容,发布之后就可以直接看效果了(当然如果配置文件修改了,reload是免不了了),是不是找到一点当初apache写php的感觉呢:)

开发语言Lua的大致介绍

环境搭建完毕之后,接下来就是各种试错了,关于Lua的介绍,网上的资料比如:Openresty最佳实践(版本比较多,这里就不放了)。 写的都会比较详细,本文就不在这里过多解释了,只展示部分基础的Lua的模样。下面对lua一些个性有趣的地方做一下分享,可能不会涉及到lua语言比较全面或者细节的一些部分,作为补充,读者可以翻阅官方的<>。

```lua
-- 单行注释以两个连字符开头 

--[[ 
     多行注释
--]]

-- 变量赋值

num = 13  -- 所有的数字都是双精度浮点型。

s = '单引号字符串'
t = "也可以用双引号" 
u = [[ 多行的字符串
       ]] 

-- 控制流程,和python最明显的差别可能就是冒号变成了do, 最后还得数end的对应
-- while
while n < 10 do 
  n = n + 1  -- 不支持 ++ 或 += 运算符。 
end 

-- for
for i = 0, 9 do
  print(i)
end

-- if语句:
f n == 0 then
  print("no hits")
elseif n == 1 then
  print("one hit")
else
  print(n .. " hits")
end

--只有nil和false为假; 0和 ''均为真! 
if not aBoolValue then print('false') end 

-- 循环的另一种结构: 
repeat 
  print('the way of the future') 
  num = num - 1 
until num == 0 

-- 函数定义:
function add(x, y)
  return x + y
end

-- table 用作键值对
t = {key1 = 'value1', key2 = false} 

print(t.key1)  -- 打印 'value1'. 

-- 使用任何非nil的值作为key: 
u = {['@!#'] = 'qbert', [{}] = 1729, [6.28] = 'tau'} 
print(u[6.28])  -- 打印 "tau" 

-- table用作列表、数组
v = {'value1', 'value2', 1.21, 'gigawatts'} 
for i = 1, #v do  -- #v 是列表的大小
  print(v[i])
end

-- 元表
f1 = {a = 1, b = 2}  -- 表示一个分数 a/b. 
f2 = {a = 2, b = 3} 

-- 这会失败:
-- s = f1 + f2 

metafraction = {} 
function metafraction.__add(f1, f2) 
  local sum = {} 
  sum.b = f1.b * f2.b 
  sum.a = f1.a * f2.b + f2.a * f1.b 
  return sum
end

setmetatable(f1, metafraction) 
setmetatable(f2, metafraction) 

s = f1 + f2  -- 调用在f1的元表上的__add(f1, f2) 方法 

-- __index、__add等的值,被称为元方法。 
-- 这里是一个table元方法的清单: 

-- __add(a, b)                     for a + b 
-- __sub(a, b)                     for a - b 
-- __mul(a, b)                     for a * b 
-- __div(a, b)                     for a / b 
-- __mod(a, b)                     for a % b 
-- __pow(a, b)                     for a ^ b 
-- __unm(a)                        for -a 
-- __concat(a, b)                  for a .. b 
-- __len(a)                        for #a 
-- __eq(a, b)                      for a == b 
-- __lt(a, b)                      for a < b 
-- __le(a, b)                      for a <= b 
-- __index(a, b)  <fn or a table>  for a.b 
-- __newindex(a, b, c)             for a.b = c 
-- __call(a, ...)                  for a(...) 
```

以上参考了 learn lua in y minute ,做了适当的裁剪来做说明。

Lua语言个性的一面

第一道墙: 打印table

作为lua里面唯一标准的数据结构, 直接打印居然只有一个id状的东西,这里说这一点没有抱怨的意思,只是让读者做好倒腾的心理准备,毕竟倒腾一个简洁语言终归是有代价的,了解决定背后的原因,有时候比现成的一步到位的现成方案这也是倒腾的另一面好处吧,这里给出社区里面的讨论

举个例子: lua里面一般使用#table来获取table的长度,究其原因,lua对于未定义的变量、table的键,总是返回nil,而不像python里面肯定是抛出异常, 所以#来计算table长度的时候只会遍历到第一个值为nil的地方,毕竟他不能一直尝试下去,这时候就需要使用table.maxn的方式来获取了。

Good or Bad? 自动类型转换

如果你在python里面去把一个字符串和数字相加,python必定以异常回应。

```python
>>> "a" + 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot concatenate 'str' and 'int' objects
```

但是Lua觉得他能搞定。

```lua
> = "20" + 10
30
```

如果你觉得Lua选择转换加号操作符的操作数成数字类型去进行求值显得不可思议的,下面这种情况下,这种转换又貌似是可以有点用的了,print("hello" .. 123),这时你不用手动去将所有参数手工转换成字符串类型。尚没有定论说这项特性就是一无是处,但是这种依赖语言本身不明显的特性的代码笔者是不希望在项目里面去踩雷的。

多返回值

Lua开始变得越来越与众不同了:允许函数返回多个结果。

```
function foo0() end --无返回值
function foo1() return 'a' end -- 返回一个结果
function foo2() return 'a','b' end -- 返回两个结果
-- 多重赋值时, 函数调用是最后一个表达式时
-- 保留尽可能多的返回值
x, y = foo2()     -- x='a', y='b'
x = foo2()        -- x='a', 'b'被丢弃
x,y,z = 10,foo2()    -- x=10, y='a', z='b'

-- 如果多重赋值时,函数调用不是最后一个表达式时
-- 只产生一个值
x, y = foo2(),20   -- x='a', y=20   
x,y = foo0(), 20, 30 -- x=nil, y= 20,30被丢弃,这种情况当函数没有返回值时,会用nil来补充。

x,y,z = foo2() -- x='a', y='b', z=nil, 这种情况函数没有足够的返回值时也会用nil来补充。

-- 同样在函数调用、table声明中 函数调用作为最后的表达式,都会竟可能多的填充返回值,如果不是最后,则只返回一个
print(foo2(), 1)    --> a  1
print(1, foo2())    --> 1  a  b
t = {foo2(), 1}     --> {'a', 1}
t = {1, foo2()}     --> {1, 'a', 'b'}

-- 阻止这种参数顺序搞事:
print(1, (foo2())) -- 1 a 加一层括号,强制只返回一个值
```

真个性: 模式匹配

简洁的Lua容不下行数比自己实现语言行数还多的正则表达式实现(无论是POSIX, 还是Perl正则表达式),于是乎有了独树一帜的模式与匹配,下面只用模式匹配来做URL解码、编码功能实现的演示。

```
-- 解码
function unescape(s)
  s = string.gsub(s, "+", " ")
  s = string.gsub(s, "%%(%x%x)", function (h)
        return string.char(tonumber(h, 16))
      end)
  return s  
end

print(unescape("a%2Bb+%3D+c")) ---> a+b =c

cgi = {}
function decode(s)
  for name,value in string.gmatch(s, "([^&=]+)=([^&=]+)") do
    name = unescape(name)
    value = unescape(value)
    cgi[name] = value
  end
end

-- 编码

function escape(s)
  s = string.gsub(s, "[&=+%%%c]", function(c)
      return string.format("%%%02X", string.byte(c))
    end)
  s = string.gsub(s, " ", "+")
  return s
end  

function encode(t)
  local b = {}
  for k,v in pairs(t) do
    b[#b+1] = (escape(k) .. "=" .. escape(v))
  end
  return table.concat(b,'&')
end

```

模式匹配实现的功能是足够强大,但是工程上是否值得投入,还值得商榷,没有通用性,只此lua一家用,虽然正则表达式也是不好调试,但是至少知道了解的人多,可能到最后笔者也不会多深入lua的模式匹配,但是如此单纯为了减少代码而放弃正则表达式现成的库,自己又玩了一套,这也是没谁了。

与c的天然亲密

一言不合,就拿c写一个库给lua用,由此可见两门语言是多么哥两好了,如果举个例子的话就是lua5.1里面的位操作符,luajit就是这样提供的解决方案, Lua Bit Operations Module, 有兴趣的读者可以下载源码看一下,完全就是用lua的c api包装了c里面的位操作符出来用,除了加了些限制的话(ex.位移出来的必然是32位有符)。lua除了数据结构上面的过于简洁外,其他的控制结构、操作符这些语言特性基本该有的都有了,唯独缺了位操作符,5.1为什么当时选择了不实现位操作符呢?有知道出处或者原因的读者欢迎留言告知。(顺带一提,lua5.2里面有官方的bit32库可以用,lua5.3已经补上了位操作符,另外Openresty在lua版本的选择上是选择停留在5.1,这点在github的Issue里面有回答过,且没有升级的打算)

  • 只有nilfalse为布尔假。

  • lua中的索引习惯以1开始。

  • 没有整型,所有数字都是浮点数。

  • 当函数的参数是单引号或者双引号的字符串或者table定义的时候,可以省略外面的(), 所以require "cookie"并不是代表require是个关键字。

  • table[index] 等价于 table [index]

构建公司层面完整的Openresty生态

开发助手:成长中的resty命令

习惯了动态语言的解释器的立即反馈,哪怕是熟悉lua的同学,初入Openresty的时候似乎又想起了编译->执行->修改的无限循环的记忆,因为每次都需要修改配置文件、reload、测试再如此重复个几次才能写对一段函数,resty命令无疑期待,笔者也希望resty命令能够更加完善、易用。

另外提一个小遗憾,现在resty命令不能玩Openresty里面的shared_dict共享内存, 这可能跟目前resty使用的nginx配置的模板是固定有关吧。

环境:可能不再需要重新编译Nginx

有过Nginx维护开发经验的同学可能都熟悉这么一个过程,因为多半会做业务的拆分,除了小公司外,基本都不会把一个Nginx的所有可选模块都编译进去,每次有新的Nginx相关的功能的增减,都免不了重新编译,重新部署上线,Openresty是基于Nginx的,如果是新增Nginx本身的功能,重新编译增加功能没什么好说的,如何优雅的更新Nginx服务进程,Nginx有提供方案、各家也有各家的服务可靠性要求,具体怎么办这里就不赘述了。

发布部署

Openresty本身的发布部署跟Nginx本身没有太大的不同,Openresty本身的发布部署官方也推出了linux平台的预编译好的包,在这样的基础上构建环境就更加便捷,环境之上,首先是lua脚本和nginx配置文件的发布,在版本管理之下,加上自动构建的发布平台,Openresty的应用分分钟就可以上线了:),这个流程本身无关Openresty,但是简而言之一句话,当重复性的东西自动化之后,我们才有精力去解决更有趣的问题,不是么?

第三方库的安装、管理

  • 以前: 自己找个第三方库编译之后扔到Openresty的lualib目录,luajit是否兼容、是否lua5.1兼容都得自己来测试一遍。

  • 之前: 对于解决前一个问题,Openresty是通过给出lua里面Luarocks的安装使用来解决的,但是这种方式不能解决上面所说的第二个问题,所以现在这种方式已经不推荐使用了,下面贴一下官网的说明,只做内容收集、展示用, 最新的具体说明参见using luarocks

wget http://luarocks.org/releases/luarocks-2.0.13.tar.gz
tar -xzvf luarocks-2.0.13.tar.gz
cd luarocks-2.0.13/
./configure --prefix=/usr/local/openresty/luajit \
    --with-lua=/usr/local/openresty/luajit/ \
    --lua-suffix=jit \
    --with-lua-include=/usr/local/openresty/luajit/include/luajit-2.1
make
sudo make install

安装第三方库示例: sudo /usr/local/openresty/luajit/luarocks install md5

  • 现在: Openresty提供了解决这两个问题的完整方案,自己的包管理的规范和仓库opm

详细的标准说明, 请移步: https://github.com/openresty/opm#readme, 这里就不多做介绍了,关于第三方库的质量,Openresty官网上也有了专门的QA页面,至少保证了第三方库一些实现的下限,不像python里面安装某些第三方包,比如aerospike的, 里面安装python客户端,每次要去网上拉个c的客户端下来之类的稀奇古怪的玩法,期待opm未来更加完善。

关于单元测试

关于自动化测试的话,就笔者的试用经验而言,感觉还不是特别的顺手,官方提供的Test:Nginx工具已经提供简洁强大的功能,但是如果作为TDD开发中的测试驱动的工具而言,笔者觉得报错信息的有效性上面可能是唯一让人有点觉得有点捉鸡的地方,尚不清楚是否是笔者用的有误,一般Test:Nginx的报错多半无法帮助调试代码,还是要走调试、修改的老路子。但是Test:Nginx的真正价值笔者觉得是讲实例代码和测试完美的结合,由此养成了看每个Openresty相关的项目代码都必先阅读里面的Test:Nginx的测试,当然最多最丰富的还是Openresty本身的测试。

举个实际的例子,在使用Test:Nginx之前,之前对于Nginx的日志输出,一切的测试依据,对于外面的运行环境跑的测试只能通过http的请求和返回来做测试的判断条件,这时候对于一些情况就束手无策了, 比如处理某种错误情况可能需要在log里面记录一下,这种测试就无法保证,另外也有类似lua-resty-test这样通过提供组件的方式来进行,但是我们一旦接触的了Test:Nginx的测试方法之后,这些就显得相形见绌了,我们举个实际的例子。

```
# vim:set ft= ts=4 sw=4 et fdm=marker:
use Test::Nginx::Socket::Lua;

#worker_connections(1014);
#master_process_enabled(1);
#log_level('warn');

#repeat_each(2);

plan tests => repeat_each() * (blocks() * 3 + 0);

#no_diff();
no_long_string();
#master_on();
#workers(2);

run_tests();

__DATA__

=== TEST 1: lpush & lpop
--- http_config
    lua_shared_dict dogs 1m;
--- config
    location = /test {
        content_by_lua_block {
            local dogs = ngx.shared.dogs

            local len, err = dogs:lpush("foo", "bar")
            if len then
                ngx.say("push success")
            else
                ngx.say("push err: ", err)
            end

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)
        }
    }
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]
```

以上是随便选取的lua-nginx-module的测试文件145-shdict-list.t中的一段做说明,测试文件分为3个部分,__DATA__以上的部分编排测试如何运行, __DATA__作为分隔符, __DATA__以下的是各个测试的说明部分. 测试部分如果具体细分的话,一般由====TEST 1: name开始到下一个测试的声明;然后是配置nginx配置的http_config、config、...的部分;接着是模拟请求的部分,基本就是http请求报文的设定,功能不限于这里的request部分;最后是输出部分,这时候不仅是http报文的body部分之类的http响应、还有nginx的日志的输出这样的测试条件,对于这样清晰可读、还能顺带把使用例子写的清楚的单元测试的框架,pythoner真的难道不羡慕么?

关于调试、性能调优

这一块笔者还没有深入研究过,所以,这里就不多说了,这里就做一下相关知识的链接归纳,方便大家整理资料吧。

lua语言本身提供的调试就比较简洁、加上Openresty是嵌入Nginx内部的,这就更给排查工作带来了困难。

官方的调试页面

官方的性能调优页面

通过systemtap探查在线的Nginx work进程

额外的工具库stap++

工具火焰图Flame Graphs的介绍

Linux Kernel Performance: Flame Graphs

作者 toyld 岂安科技搬运代码负责人 主导各处的挖坑工作,擅长挖坑于悄然不息,负责生命不息,挖坑不止。