Openrest核心知识解读
Openresty不仅是nginx-lua最基础的框架,也是nginx-lua事实上的技术标准。不过openresty并不是从0起步,而是基于开源组件Nginx和LuaJIT搭建而来,进入Openresty开发之前,有必要了解下Lua、LuaJIT、Openresty之间的关系
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代码块完成功能。
具体到代码编写有两种方式可供参考:
- nginx.conf中直接写lua代码块;
- 调用写好的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.dump
和 jit.v
模块,它们都可以打印出 JIT 编译器工作的过程。
在init_by_lua
阶段添加以下代码即可启用:
local v = require "jit.v"
v.on("/tmp/jit.log")
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)