1. 基本构成

Openresty不仅是nginx-lua最基础的框架,也是nginx-lua事实上的技术标准。

不过openresty并不是从0起步,而是基于开源组件Nginx和LuaJIT搭建而来,进入Openresty开发之前,有必要了解下Lua、LuaJIT、Openresty之间的关系:
在这里插入图片描述
名词解释:

  • JIT:Just In Time Compiler, 是一个运行时编译器,也是LuaJIT性能提升的最核心部分;
  • Lua CFunction: lua自带的与C语言交互的一套接口,需要编写中间数据类型转换层;
  • FFI:Foreign Function Interface, 是LuaJIT提供的跨语言函数调用接口,可以被JIT编译器编译;
  • cosocket:协程套接字 coroutine + socket的缩写, 是所有lua-resty-*非阻塞网络IO库的基础;
  • NYI:Not Yet Implemented, 是指JIT编译器不支持的那些Lua原语,例如:pairs, unpack, Lua CFunction等;

从上面来看,nginx-lua与其它语言是有一些不同的,不是由大厂统一规划,所以API方面各种库鱼龙混杂。

同一功能的API,有基于Lua原生的,有基于luajit的,还有基于Openresty的,使用上有优先级,需要有甄别能力。

2. 安装目录说明

bin:可执行文件目录,有两个关键命令:

  • openresty:软链接到nginx可执行程序
  • resty:一个perl脚本,可以直接执行nginx程序,如:resty -e "ngx.say('hello world');"

luajit: openresty版本的luajit环境,存放luajit的执行文件和依赖,是openresty的基石,子目录:

  • bin
  • include
  • lib
  • share

nginx:openresty内部自带的nginx子项目,目录和nginx很相似:

  • conf:配置文件目录
  • sbin: nginx可执行程序所在目录

lualib:存放lua脚本库

  • ngx:lua-resty-core这个官方项目的ngx.*相关代码, 不需要require就可以使用;
  • resty:lua-resty-* 项目包含的代码,像mysql、redis、resty.core等都位于此目录;

3. 基本开发方法

Nginx处理请求划分成了很多阶段,openresty在各个阶段都提供了相应指令来干预请求和响应的处理,如下:
在这里插入图片描述
openresty的开发基本就围绕nginx的执行阶段来展开,在需要的执行阶段插入相应的lua代码块完成功能。

具体到代码编写有两种方式可供参考:

  1. nginx.conf中直接写lua代码块;
  2. 调用写好的lua文件;
server {
    listen 8001;
    # 示例1:nginx.conf中直接写Lua代码块
    location /testargs {
        # 解析URL和BODY中的参数并返回
        content_by_lua_block {
            local args = ngx.req.get_uri_args()
            for k, v in pairs(args) do
                ngx.say("[get] k=", k, ", v=", v)
            end
 
            ngx.req.read_body()
            local post_args = ngx.req.get_post_args()
            for k, v in pairs(post_args) do
                ngx.say("[post] k=", k, ",v=", v)
            end
        }
    }
 
    # 示例2:调用写好的lua文件
    location ~ ^/api/([a-z0-9A-Z/]+) {
      # 准入阶段完成参数验证
      access_by_lua_file lua/access_check.lua;
      # 根据URL路径参数调用不同的Lua代码生成结果
      content_by_lua_file lua/$1.lua;
    }
}

4. 程序加载路径

我们经常会使用很多require函数来加载lua模块,如:

  • require(“resty.mysql”)
  • require(“base.config”)
  • require(“tinyyaml”)
  • ……

有官方的,有自定义的,nginx是如何知道去哪里加载这些模块的代码呢?

openresty在nginx配置文件中提供了一个lua_package_path指令,用于指令程序加载的路径,如下:

lua_package_path '$prefix/lua/?.lua;$prefix/lua/lib/?.lua;;';

其中:

  • $prefix是内置变量表示nginx工作目录,可以在nginx启动时通过-p选项指定。如:
openresty -p /usr/local/openresty/nginx -c /usr/local/openresty/nginx/conf/nginx.conf`
  • 分号表示多个路径的分隔符;
  • 最后两个分号";;"表示默认路径;
  • ?是占位符,在require函数执行时,会替换为require函数的参数,其中.会替换为目录分隔符/

以resty.mysql为例,简单示例说明查找过程:

===> /usr/local/openresty/nginx/lua/resty/mysql.lua           - - 不存在
===> /usr/local/openresty/nginx/lua/lib/resty/mysql.lua       - - 不存在
===>  /usr/local/openresty/lualib/resty/mysql.lua             - - 存在

5. ngx变量生命周期

1)本地变量:
_by_lua 各个阶段里面定义的变量称之为本地变量,本地变量仅在当前阶段有效。如:

content_by_lua_block {
    local str = "hello"
    ngx.say(str)
}

2)跨阶段变量:请求级别

ngx.var.* : 可以在C模块和lua之间共享数据,缺点是只支持字符串,且需要在nginx.conf中用set指令预创建,如:

location ~* ^/test {
    set $user '';                  -- 定义声明
    content_by_lua_block {
        ngx.var.user='zhangsan'    -- 使用方式    
    }
}

ngx.ctx.* : 放在请求上下文ngx.ctx中,是一个lua table, 请求级别,局限在于无法适用于子请求,如:ngx.location.capture和ngx.exec等方式开启的子请求

location ~* ^/test {
    content_by_lua_block {
        ngx.ctx.user ='zhangsan'    -- 无需声明,直接使用  
    }
}

3)模块变量:进程级别
一个worker内的所有请求共享数据,适合只读,如果涉及到写容易race condition

  • lua里有一个全局表 _G, 保存了Lua语言中几乎所有的全局函数和全局变量
  • require指令加载一个模块时,首先是从全局表中查找,如果有就不会再加载,没找到时则加载模块并放到全局表中;

4)共享字典shared dict: 服务器级别
本质上是table,可以在多个worker间共享数据,需要在nginx.conf中预声明大小,

  • 自身使用了自旋锁来确保所有的API都是原子操作,不用担心并发竞争问题;
  • 缺点在于 只支持字符串类型的数据,不支持table,常用于限流限速、流量统计等功能;

6. 请求和响应的处理

4.1 请求处理

假设收到一个请求:

GET  http://demo.quanshi.com/api/test?userId=1221&conferenceId=3456

ngx.var.* 获取请求信息,只能读不能改:

  • ngx.var.scheme : 获取请求协议 http
  • ngx.var.request_method : 获取请求方法 GET
  • ngx.var.request_uri : 获取带请求参数的uri /api/test?userId=1221&conferenceId=3456
  • ngx.var.uri : 获取不带请求参数的uri /api/test
  • ngx.var.cookie_xxx: 获取键为xxx的cookie信息

ngx.req.专门针对请求行信息的API,可根据API来读或写,例如:

  • ngx.req.get_uri_args() :获取uri上的参数
  • ngx.req.get_post_args() : 获取post 请求上的body参数
  • ngx.req.get_body_data():读取原始的body信息
  • ngx.req.set_uri_args(“a=3”) : 改写请求的参数
  • ngx.req.set_uri(“/foo”) : 改写请求的uri
  • ngx.req.get_headers() : 解析或获取请求头,返回值为table格式
  • ngx.req.set_header(“Content-Type”, “text/css”): 改写请求头

4.2 响应处理

终止请求并返回指定http状态码:

  • ngx.exit(ngx.HTTP_BAD_REQUEST)

通过ngx.header改写响应头:

  • ngx.header.content_type = ‘text/plain’
  • ngx.header[“X-My-Header”] = ‘blah blah’ – 添加自定义响应头
  • ngx.header[“X-My-Header”] = nil – 删除响应头

输出响应体:

  • ngx.say(‘hello, world’) : 不仅支持字符串,也支持数组
  • ngx.print(data) : 支持直接输出table

7. Cache

7.1 lrucache

位于lua-resty-lrucache模块,功能特点:

  • 能提供单个worker进程内的cache,无法实现跨进程共享;
  • 支持所有的数据结构,如table;
  • 相比shared_dict避免了数据的序列化和反序列化,但是带来了数据的重复空间占用;
local lrucache = require "resty.lrucache"
local cache, err = lrucache.new(200)        -- 创建一个大小为200条数据的cache
cache:set("dog", 32, 0.1)                   -- 0.1s为过期时间
local data, stale_data = cache:get("dog")   -- stale_data为过期数据

需要注意内存缓存的前提:lua_code_cache on;

  • 位于nginx.conf,用于控制每次请求是否需要重新加载lua代码,只有开启此选项时,require加载的模块才会被缓存下来;

7.2 shared dict

共享字典 ,可以在多个worker间共享同一份数据,但只能支持字符串一种数据类型,如果涉及到table将不得不用json作序列化和反序列化,会带来一定的性能损耗。

首先需要在nginx.conf中预定义字典大小:

lua_shared_dict ucache 10m;

具体lua代码中使用:

local dict = ngx.shared.ucache
dict:set("389548", "Tom", 10)  -- 字典中缓存了10s
print(dict:get("Tom"))

7.3 mlcache

lrucache和shared dict各有自己的优点和不足,并没有哪一个更好的说法,往往在实际选择中很难决择,只能根据实际场景来配合使用。

  • 如果没有 worker 之间共享数据的需求,那么lru 可以缓存数组、函数等复杂的数据类型,并且性能最高,自然是首选。
  • 但如果想要在 worker 之间共享数据,那就可以在 lru 缓存的基础上,加上 shared dict 的缓存,构成两级缓存的架构。
  • 实际生产环境如果是高并发系统想做完善的话,还要考虑缓存过期时大量请求集中查询DB的问题,一份缓存尽量只让一个请求去更新;

有一个库lua-resty-mlcache就是为解决此问题而生,它使用 shared dict 和 lua-resty-lrucache实现了多级缓存机制,并对缓存过期问题作了保护和封装,结构示意如下:
在这里插入图片描述

使用示例如下:

local mlcache = require "resty.mlcache"
-- 创建cache实例
local cache, err = mlcache.new("cache_name", "cache_dict", {
    lru_size = 500,                                -- size of the L1 (lru) cache
    ttl = 3600,                                    -- 过期时间1小时
    neg_ttl  = 30,                                 -- 查询结果为空时的缓存时间,30s
})
 
 
local function fetch_user(id)                      -- L3数据源,即两级缓存都未命中时查询DB的方法
    return db:query_user(id)
end
 
 
-- 业务查询中使用缓存,
-- cache:get方法参数分别表示:缓存中的key, 查询Options,未命中缓存时的callback方法,callback方法的参数
-- cache:get方法返回值分别表示:查询结果,查询出错时的error, 结果数据来源(1、2、3分别表示3级缓存)
local id = 123                              
local user, err, hit_level = cache:get(id, nil, fetch_user, id)   
if err then
    ngx.log(ngx.ERR , "failed to fetch user: ", err)
    return
end
if user then
    print(user.id)                                  -- 123
end

8. 单元测试

Test::Nginx是一个由Perl编写的nginx测试组件,继承自Test::Base

环境搭建:

  • 首先安装perl包管理器cpan
brew install cpanm
  • 使用cpan安装test::nginx组件:
sudo cpanm --notest Test::Nginx IPC::Run > build.log 2>&1 || (cat build.log && exit 1)
  • 下载Test::Nginx源码:
git clone https://github.com/openresty/test-nginx.git
  • 执行单元测试:
prove -Itest-nginx/lib -r t

测试目录一般都命名为t,位于nginx工作目录。

use t::CoreProxy 'no_plan';       # 指明要调用的测试模块
run_tests();                      # 运行测试用例
__DATA__                          # 将测试文件分割成Perl代码和测试用例两部分
 
=== TEST 1: load and run          # === 表示一个测试用例的开始,一个文件中可以写多个测试用例,通过===来分隔不同的测试用例
--- config                        # === 测试用例中的nginx.conf配置,这里调用要测试的lua代码
    location ~* ^/t{
        content_by_lua_block {
            local m = require("init")
            local plugin = require("plugins.splitter")
 
            local err = plugin.init()
            ngx.say(err)
 
            plugin.access(nil, m.get_api_ctx())
            ngx.say(ngx.var.target_url)
            ngx.say(ngx.req.get_headers()["cmsid"])
        }
    }
--- request                       # 要发送的测试请求
POST /t?user_id=81536511&site_id=107259
--- response_body_like chomp      # 校验响应内容,这里使用正则表达式来校验,相对应的--- response_body表示纯字符串比较
nil
http://uniform\w?\.quanshi\.com/t\?user_id=81536511&site_id=107259
12
--- no_error_log                  # 对error.log文件中输出日志的校验,这里表示不能包含[error]级别的Log
[error]

执行某个测试文件中的测试用例: prove t/[dir]/xxx.t

执行目录中的所有测试: prove t/[dir]

9. 开发中的问题举例

9.1 API适用的执行阶段概念

openresty提供的API并不是所有阶段都能使用,有些执行阶段比较特殊,不能使用像ngx.sleep、ngx.req.* 或 coocket之类的耗时操作API。

开发中遇到的相关问题:误在log_by_lua* 阶段执行了DB连接释放,结果日志里报了大量的Mysql连接close失败:

2023/09/30 09:10:28 [warn] 15721#15721: *6943 [lua] mysql_pool.lua:92: close(): mysql set keepalive failed: closed while logging request, client: 192.168.28.222, server: uniform*.quanshi.com, request: "POST /rest/conference/setting/getRoleSetting?timestamp=1601428221296&token=77b8c74e4fa84d8603eccd67f9d21216&nonce=4c06a41185f49f225198c64ae45da2a5&d=1601428221296 HTTP/1.1", host: "confforwardserver"

除此之外,以下两个阶段也不能执行耗时类操作:

  • set_by_lua*:此阶段ngx并没有实现非阻塞IO,事件处理循环是处于阻塞状态,需要避免耗时操作;
  • balancer_by_lua* : 用在upstream块对上游IP作负载均衡,如果是域名不能在balancer阶段解析,必须在其它阶段预先解析好。

9.2 location配置简化

分流器中location配置简化的主要阻碍在于请求路径中携带的参数,如:

location ~* ^/rest/conference/business/getInviteeInfoMeeting/(\w+)/(\w+) {
    xxx
    access_by_lua_block {
        m.http_access_phase()
    }
    proxy_pass $target_url;
}
  • 方式1:直接用set指令为每个参数创建ngx变量,lua中使用ngx.var.confid来使用
    set $confid $1;
    set $tempconfid $2;
  • 方式2:只通过set指令将参数传到lua里,在lua里用其它更方便管理的方式保存起来(如ngx.ctx),这里的$xxx没有意义只为了完成语法
    set_by_lua $xxx
        "m.http_set_phase('confid', ngx.arg[1], 'tempconfid', ngx.arg[2])"
        $1 $2;

上面两种方式都有一个共同的问题是:每个url都需要写专门的location块,像access_by_lua_block, proxy_pass要在每个Location中拷贝一份,不利于维护。

  • 方式3:直接用rewrite指令将路径参数转换为query参数
 rewrite ^/rest/conference/business/getInviteeInfoMeeting/(\w+)/(\w+) $uri?$query_string&tempconfid=$1&confid=$2 break;

10. 性能优化点

10.1 始终使用本地变量

-- 文件开始
local mysql                     = require("resty.mysql")   -- 示例1
local ipairs                    = ipairs                   -- 示例2
local type                      = type                     -- 示例3
 
 
-- 使用的地方
local vtype = type(t)
 
 
for i, v in ipairs(t) do
    ……
end

函数声明赋给本地变量的意义在于:将全局函数变为本地函数,避免全局查找;

10.2 table

table创建:

local t = {}                        # 方式1
local t = table.new(narray, nhash)  # 方式2,创建时预分配大小。

table取长度:

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 -- local i = 1                -- 方式3语句组成
 for k, v in pairs(t2) do      -- 假设t2是一个key-value形式的map表
    table.insert(t, v)         -- 方式1,
--  t[#t + 1] = v              -- 方式2,
--    i = i + 1                -- 方式3语句组成
--    t[i] = v                 -- 方式3 
 end

方式1其实和方式2一样,内部是先调用table.getn()来取长度,再给指定下标赋值,计算table长度是O(n)复杂度,可以理解为遍历一遍表。

table循环使用:

  • 使用table.clear()来清空重复利用
  • 还可以使用openresty官方提供的tablepool
tablepool.fetch(pool_name, narr, nrec)         -- 从池中获取一个table, 如果没有会自动创建
tablepool.release(pool_name, tb)               -- 将table放回池子,内部会清空table数据供其它地方使用

10.3 cache

见第5章节

10.4 NYI

做Openresty开发时一定要留意调用的API是否被LuaJit编译器优化。

NYI全称:Not Yet Implemented,就是指JIT编译器尚未支持的那些原语。

当JIT编译器在当前 代码路径下遇到自己不支持到的原语后,就会从机器码执行模式退回到解释执行模式,对性能会影响很大。

NYI列表:http://wiki.luajit.org/NYI

NYI的检测:

LuaJIT 自带的 jit.dumpjit.v 模块,它们都可以打印出 JIT 编译器工作的过程。

init_by_lua阶段添加以下代码即可启用:

local v = require "jit.v"
v.on("/tmp/jit.log")
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐