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,228 @@
<audio id="audio" title="15 | OpenResty 和别的开发平台有什么不同?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9d/4c/9d7d6d7ab40c4c0794113ddff514804c.mp3"></audio>
你好,我是温铭。
上一模块中, 你已经学习了 OpenResty 的两个基石NGINX 和 LuaJIT相信你已经摩拳擦掌准备开始学习 OpenResty 提供的 API 了吧?
不过,别着急,在这之前,你还需要再花一点儿时间,来熟悉下 OpenResty 的原理和基本概念。
## 原理
在前面的 LuaJIT 内容中,你已经见过下面这个架构图:
<img src="https://static001.geekbang.org/resource/image/14/f0/14ab2f0c81c170234ab739cb700a62f0.png" alt="">
这里我再详细解释一下。
OpenResty 的 master 和 worker 进程中,都包含一个 LuaJIT VM。在同一个进程内的所有协程都会共享这个 VM并在这个 VM 中运行 Lua 代码。
而在同一个时间点上,每个 worker 进程只能处理一个用户的请求也就是只有一个协程在运行。看到这里你可能会有一个疑问NGINX 既然能够支持 C10K (上万并发),不是需要同时处理一万个请求吗?
当然不是NGINX 实际上是通过 epoll 的事件驱动,来减少等待和空转,才尽可能地让 CPU 资源都用于处理用户的请求。毕竟,只有单个的请求被足够快地处理完,整体才能达到高性能的目的。如果采用的是多线程模式,让一个请求对应一个线程,那么在 C10K 的情况下,资源很容易就会被耗尽的。
在 OpenResty 层面Lua 的协程会与 NGINX 的事件机制相互配合。如果 Lua 代码中出现类似查询 MySQL 数据库这样的 I/O 操作,就会先调用 Lua 协程的 yield 把自己挂起,然后在 NGINX 中注册回调;在 I/O 操作完成(也可能是超时或者出错)后,再由 NGINX 回调 resume 来唤醒 Lua 协程。这样就完成了 Lua 协程和 NGINX 事件驱动的配合,避免在 Lua 代码中写回调。
我们可以来看下面这张图,描述了这整个流程。其中,`lua_yield``lua_resume` 都属于 Lua 提供的 `lua_CFunction`
<img src="https://static001.geekbang.org/resource/image/fa/34/fae1008edb43c7476cf2f20da9928234.png" alt="">
另外一个方面,如果 Lua 代码中没有 I/O 或者 sleep 操作,比如全是密集的加解密运算,那么 Lua 协程就会一直占用 LuaJIT VM直到处理完整个请求。
下面我提供了 `ngx.sleep` 的一段源码,可以帮你更清晰理解这一点。 这段代码位于 `ngx_http_lua_sleep.c` 中,你可以在 `lua-nginx-module` 项目的 [src 目录](https://github.com/openresty/lua-nginx-module/tree/master/src)中找到它。
`ngx_http_lua_sleep.c` 中,我们可以看到 sleep 函数的具体实现。你需要先通过 C 函数 `ngx_http_lua_ngx_sleep`,来注册 `ngx.sleep` 这个 Lua API
```
void
ngx_http_lua_inject_sleep_api(lua_State *L)
{
lua_pushcfunction(L, ngx_http_lua_ngx_sleep);
lua_setfield(L, -2, &quot;sleep&quot;);
}
```
下面便是 sleep 的主函数,这里我只摘取了几行主要的代码:
```
static int ngx_http_lua_ngx_sleep(lua_State *L)
{
coctx-&gt;sleep.handler = ngx_http_lua_sleep_handler;
ngx_add_timer(&amp;coctx-&gt;sleep, (ngx_msec_t) delay);
return lua_yield(L, 0);
}
```
你可以看到:
- 这里先增加了 `ngx_http_lua_sleep_handler` 这个回调函数;
- 然后调用 `ngx_add_timer` 这个 NGINX 提供的接口,向 NGINX 的事件循环中增加一个定时器;
- 最后使用 `lua_yield` 把 Lua 协程挂起,把控制权交给 NGINX 的事件循环。
当 sleep 操作完成后, `ngx_http_lua_sleep_handler` 这个回调函数就被触发了。它里面调用了 `ngx_http_lua_sleep_resume`, 并最终使用 `lua_resume` 唤醒了 Lua 协程。更具体的调用过程,你可以自己去代码里面检索,这里我就不展开描述了。
`ngx.sleep` 只是最简单的一个示例,不过通过对它的剖析,你可以看出 `lua-nginx-module` 模块的基本原理。
## 基本概念
分析完原理之后,让我们一起温故而知新,回忆下 OpenResty 中**阶段**和**非阻塞**这两个重要的概念。
OpenResty 和 NGINX 一样,都有阶段的概念,并且每个阶段都有自己不同的作用:
- `set_by_lua`,用于设置变量;
- `rewrite_by_lua`,用于转发、重定向等;
- `access_by_lua`,用于准入、权限等;
- `content_by_lua`,用于生成返回内容;
- `header_filter_by_lua`,用于应答头过滤处理;
- `body_filter_by_lua`,用于应答体过滤处理;
- `log_by_lua`,用于日志记录。
当然,如果你的代码逻辑并不复杂,都放在 rewrite 或者 content 阶段执行,也是可以的。
不过需要注意OpenResty 的 API 是有阶段使用限制的。每一个 API 都有一个与之对应的使用阶段列表,如果你超范围使用就会报错。这与其他的开发语言有很大的不同。
举个例子,这里我还是以 `ngx.sleep` 为例。通过查阅文档,我知道它只能用于下面列出的上下文中,并不包括 log 阶段:
```
context: rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_
```
而如果你不知道这一点,在它不支持的 log 阶段使用 sleep 的话:
```
location / {
log_by_lua_block {
ngx.sleep(1)
}
}
```
在 NGINX 的错误日志中,就会出现 error 级别的提示:
```
[error] 62666#0: *6 failed to run log_by_lua*: log_by_lua(nginx.conf:14):2: API disabled in the context of log_by_lua*
stack traceback:
[C]: in function 'sleep'
```
所以,在你使用 API 之前,一定记得要先查阅文档,确定其能否在代码的上下文中使用。
复习了阶段的概念后,我们再来回顾下非阻塞。首先明确一点,由 OpenResty 提供的所有 API都是非阻塞的。
我继续以 sleep 1 秒这个需求为例来说明。如果你要在 Lua 中实现它,你需要这样做:
```
function sleep(s)
local ntime = os.time() + s
repeat until os.time() &gt; ntime
end
```
因为标准 Lua 没有直接的 sleep 函数,所以这里我用一个循环,来不停地判断是否达到指定的时间。这个实现就是阻塞的,在 sleep 的这一秒钟时间内Lua 正在做无用功,而其他需要处理的请求,只能在一边傻傻地等待。
不过,要是换成 `ngx.sleep(1)` 来实现的话根据上面我们分析过的源码在这一秒钟的时间内OpenResty 依然可以去处理其他请求(比如 B 请求),当前请求(我们叫它 A 请求)的上下文会被保存起来,并由 NGINX 的事件机制来唤醒,再回到 A 请求,这样 CPU 就一直处于真正的工作状态。
## 变量和生命周期
除了这两个重要概念外,**变量的生命周期**,也是 OpenResty 开发中容易出错的地方。
前面说过,在 OpenResty 中,我推荐你把所有变量都声明为局部变量,并用 luacheck 和 lua-releng 这样的工具来检测全局变量。这其实对于模块来说也是一样的,比如下面这样的写法:
```
local ngx_re = require &quot;ngx.re&quot;
```
其实,在 OpenResty 中,除了 `init_by_lua``init_worker_by_lua` 这两个阶段外,其余阶段都会设置一个隔离的全局变量表,以免在处理过程中污染了其他请求。即使在这两个可以定义全局变量的阶段,你也应该尽量避免去定义全局变量。
通常来说,试图用全局变量来解决的问题,其实更应该用模块的变量来解决,而且还会更加清晰。下面是一个模块中变量的示例:
```
local _M = {}
_M.color = {
red = 1,
blue = 2,
green = 3
}
return _M
```
我在一个名为 hello.lua 的文件中定义了一个模块,模块包含了 color 这个 table。然后我又在 nginx.conf 中增加了对应的配置:
```
location / {
content_by_lua_block {
local hello = require &quot;hello&quot;
ngx.say(hello.color.green)
}
}
```
这段配置会在 content 阶段中 require 这个模块,并把 green 的值作为 http 请求返回体打印出来。
你可能会好奇,模块变量为什么这么神奇呢?
实际上,在同一 worker 进程中,模块只会被加载一次;之后这个 worker 处理的所有请求,就可以共享模块中的数据了。我们说“全局”的数据很适合封装在模块内,是因为 OpenResty 的 worker 之间完全隔离,所以每个 worker 都会独立地对模块进行加载,而模块的数据也不能跨越 worker。
至于应该如何处理 worker 之间需要共享的数据,我会留到后面的章节来讲解,这里你先不必深究。
不过,这里也有一个很容易出错的地方,那就是**访问模块变量的时候,你最好保持只读,而不要尝试去修改,不然在高并发的情况下会出现 race**。这种 bug 依靠单元测试是无法发现的,它在线上偶尔会出现,并且很难定位。
举个例子,模块变量 green 当前的值是 3而你在代码中做了加 1 的操作,那么现在 green 的值是 4 吗?不一定,它可能是 4也可能是 5 或者是 6。因为在对模块变量进行写操作的时候OpenResty 并不会加锁,这时就会产生竞争,模块变量的值就会被多个请求同时更新。
说完了全局变量、局部变量和模块变量,最后我们再来讲讲跨阶段的变量。
有些情况下,我们需要的是跨越阶段的、可以读写的变量。而像我们熟悉的 NGINX 中 `$host``$scheme` 等变量,虽然满足跨越阶段的条件,但却无法做到动态创建,你必须先在配置文件中定义才能使用它们。比如下面这样的写法:
```
location /foo {
set $my_var ; # 需要先创建 $my_var 变量
content_by_lua_block {
ngx.var.my_var = 123
}
}
```
OpenResty 提供了 `ngx.ctx`,来解决这类问题。它是一个 Lua table可以用来存储基于请求的 Lua 数据,且生存周期与当前请求相同。我们来看下官方文档中的这个示例:
```
location /test {
rewrite_by_lua_block {
ngx.ctx.foo = 76
}
access_by_lua_block {
ngx.ctx.foo = ngx.ctx.foo + 3
}
content_by_lua_block {
ngx.say(ngx.ctx.foo)
}
}
```
你可以看到,我们定义了一个变量 `foo`,存放在 `ngx.ctx` 中。这个变量跨越了 rewrite、access 和 content 三个阶段,最终在 content 阶段打印出了值,并且是我们预期的 79。
当然,`ngx.ctx` 也有自己的局限性:
- 比如说,使用 `ngx.location.capture` 创建的子请求,会有自己独立的 `ngx.ctx` 数据,和父请求的 `ngx.ctx` 互不影响;
- 再如,使用 `ngx.exec` 创建的内部重定向,会销毁原始请求的 `ngx.ctx`,重新生成空白的 `ngx.ctx`
这两个局限,在官方文档中都有详细的[代码示例](https://github.com/openresty/lua-nginx-module#ngxctx),如果你有兴趣可以自行查阅。
## 写在最后
最后,我再多说几句。这节课,我们学习的是 OpenResty 的原理和几个重要的概念,不过,你并不需要背得滚瓜烂熟,毕竟,这些概念总是在和实际需求以及代码结合在一起时,才会变得有意义并生动起来。
不知道你是如何理解的呢?欢迎留言和我一起探讨,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。

View File

@@ -0,0 +1,250 @@
<audio id="audio" title="16 | 秒杀大多数开发问题的两个利器:文档和测试案例" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/c9/868e7dd2ae529e45df6f8ebea9f081c9.mp3"></audio>
你好,我是温铭。在学习了 OpenResty 的原理和几个重要概念后,我们终于要开始 API 的学习了。
从我个人的经验来看,学习 OpenResty 的 API 是相对容易的所以并没有占用本专栏太多的篇幅。你可以会疑惑API 不是最常用、最重要的部分吗,为什么花的笔墨不多?
其实,这主要是出于两个方面的考虑。
第一OpenResty 提供了非常详尽的文档。和很多其他的开发语言或者平台相比OpenResty 除了会提供 API 的参数、返回值定义还会提供完整的、可运行的代码示例清楚地告诉你API 是如何处理各种边界条件的。
这种在 API 定义下面紧跟着示例代码和注意事项的做法,就是 OpenResty 文档的一贯风格。这样一来,在看完 API 描述后,你就可以立即在自己的环境下运行示例代码,并修改参数来和文档互相印证,加深记忆和理解。
第二在文档之外OpenResty还提供了高覆盖度的测试案例集。刚刚我提到过OpenResty文档中提供了 API 的代码示例,但终究篇幅有限,多个 API 之间如何配合使用、各种异常情况下的报错和处理等,在文档中并没有呈现。
不过,不用担心,这些内容你大都可以在测试案例集里找到。
对于 OpenResty 的开发者来说,最好的 API 学习资料就是官方文档和测试案例,它们足够专业和友好。在这个前提下,如果我单纯地把文档翻译成中文再放在专栏中来讲,就没有太大意义了。
授人以鱼不如授之以渔,我更希望教给你的是通用的方法和经验。让我们用一个真实的例子来体验下,在 OpenResty 的开发中,如何让文档和测试案例集发挥更大的威力。
## shdict get API
shared dict共享字典是基于 NGINX 共享内存区的 Lua 字典对象,它可以跨多个 worker 来存取数据一般用来存放限流、限速、缓存等数据。shared dict 相关的 API 有 20 多个,是 OpenResty 中最常用也是最重要的一组 API。
我们以最简单的 get 操作为例,你可以点开 [文档链接](https://github.com/openresty/lua-nginx-module/#ngxshareddictget) 做为对照。下面的最小化的代码示例,正是由官方文档改编而来:
```
http {
lua_shared_dict dogs 10m;
server {
location /demo {
content_by_lua_block {
local dogs = ngx.shared.dogs
dogs:set(&quot;Jim&quot;, 8)
local v = dogs:get(&quot;Jim&quot;)
ngx.say(v)
}
}
}
}
```
简单说明一下在Lua 代码中使用 shared dict 之前,我们需要在 nginx.conf 中用 `lua_shared_dict` 指令增加一块内存空间,它的名字是 dogs大小为 10M。修改完 nginx.conf后你还需要重启进程用浏览器或者 curl 访问才能看到结果。
这步骤看起来是不是有些繁琐呢?让我们用一种更直接的方式改造一下。你可以看到,使用 resty CLI 的这种方式,和在 nginx.conf 中嵌入代码的效果是一致的。
```
$ resty --shdict 'dogs 10m' -e 'local dogs = ngx.shared.dogs
dogs:set(&quot;Jim&quot;, 8)
local v = dogs:get(&quot;Jim&quot;)
ngx.say(v)
'
```
你现在已经知道 nginx.conf 和 Lua 代码是如何配合的,也成功运行了 shared dict 的 set 和 get 方法。一般来说,大部分开发者也就此止步,不再深究了。
事实上,这里还是有几个值得注意的地方,比如:
1. 哪些阶段不能使用共享内存相关的 API 呢?
1. 我们在示例代码中看到 get 函数只有一个返回值,那什么情况下会有多个返回值呢?
1. get 函数的入参是什么类型?是否有长度限制?
不要小看这几个问题,窥一斑而见全豹,它们可以帮助我们更好的深入 OpenResty。接下来我就带你一一解读。
## 哪些阶段不能使用共享内存相关的 API
先来看第一个问题,答案很直接,文档中专门有一个 `context` (即上下文部分),里面列出了在什么环境下可以使用这个 API
```
context: set_by_lua*, rewrite_by_lua*, access_by_lua*, content_by_lua*, header_filter_by_lua*, body_filter_by_lua*, log_by_lua*, ngx.timer.*, balancer_by_lua*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*, ssl_session_store_by_lua*
```
可以看出, `init``init_worker` 两个阶段不在其中,也就是说,共享内存的 get API 不能在这两个阶段使用。需要注意的是,每个共享内存的 API 可以使用的阶段并不完全相同,比如 set API 就可以在 `init` 阶段使用。
所以千万不要想当然还是那句话使用时多翻阅文档。当然了尽信书不如无书OpenResty 的文档有时候也会出现错漏,这时候你就需要用实际的测试来验证了。
接下来,让我们修改下测试案例集,来确定下 `init` 阶段是否可以运行 shared dict 的 get API。
那该如何找到和共享内存相关的测试案例集呢事实上OpenResty 的测试案例都放在 `/t` 目录下,并且命名也是有规律的,即`自增数字-功能名.t`。搜索`shdict`,你可以找到 `043-shdict.t`,而这就是共享内存的测试案例集了,它里面有接近 100 个测试案例,包含各种正常和异常情况的测试。
我们来试着修改下第一个测试案例。
你可以把 content 阶段改为 init 阶段,并精简掉无关代码,看看 get 接口能否运行。这里我需要提醒一点,在现阶段,你不用非得搞明白测试案例是如何编写、组织和运行的,你只要知道它是在测试 get 接口就可以了:
```
=== TEST 1: string key, int value
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
init_by_lua '
local dogs = ngx.shared.dogs
local val = dogs:get(&quot;foo&quot;)
ngx.say(val)
';
}
--- request
GET /test
--- response_body
32
--- no_error_log
[error]
--- ONLY
```
你应该注意到了,在测试案例的最后,我加了 `--ONLY` 标记,这表示忽略其他所有测试案例,只运行这一个测试案例,以提高运行速度。后面在测试部分中,我会专门讲解各种各样的标记,你先记住这里就可以了。
修改完以后,我们用 prove 命令,就可以运行这个测试案例:
```
$ prove t/043-shdict.t
```
然后,你会得到一个报错,这也就印证了文档中描述的阶段限制。
```
nginx: [emerg] &quot;init_by_lua&quot; directive is not allowed here
```
## get 函数何时会有多个返回值?
我们再来看第二个问题,它可以从官方文档中总结出来。文档最开始就是这个接口的`syntax` 语法描述部分:
```
value, flags = ngx.shared.DICT:get(key)
```
正常情况下,
- 第一个参数`value` 返回的是字典中 key 对应的值;但当 key 不存在或者过期时,`value` 的值为 nil。
- 第二个参数 `flags` 就稍微复杂一些了,如果 set 接口设置了 flags就返回否则不返回。
一旦 API 调用出错,`value` 返回 nil`flags` 返回具体的错误信息。
从文档总结的信息我们可以看出,`local v = dogs:get("Jim")` 这种只有一个接收参数的写法并不完善,因为它只覆盖了普通的使用场景,没有接收第二个参数,也没有做异常处理。我们可以把它修改为下面这样:
```
local data, err = dogs:get(&quot;Jim&quot;)
if data == nil and err then
ngx.say(&quot;get not ok: &quot;, err)
return
end
```
和第一个问题一样,我们可以到测试案例集里搜索一下,印证下我们对文档的理解:
```
=== TEST 65: get nil key
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
content_by_lua '
local dogs = ngx.shared.dogs
local ok, err = dogs:get(nil)
if not ok then
ngx.say(&quot;not ok: &quot;, err)
return
end
ngx.say(&quot;ok&quot;)
';
}
--- request
GET /test
--- response_body
not ok: nil key
--- no_error_log
[error]
```
在这个测试案例中get 接口的入参为 nil返回的 err 信息是 `nil key`。这一方面验证了我们对文档的分析是正确的另一方面也为第三个问题提供了部分答案——起码get 的入参不能是 nil。
## get 函数的入参是什么类型?
至于第三个问题, get 的入参可以是什么类型的呢?我们按照老规矩先查看文档,不过很可惜,你会发现,文档里并没有注明 key 的合法类型有哪些。这时该怎么办呢?
别着急,至少我们知道 key 可以是字符串类型,并且不能为 nil。不知道你还记得 Lua 中的数据类型吗?除了字符串和 nil还有数字、数组、布尔类型和函数。后面两个显然没有作为 key 的必要性,我们只需要验证前两个。不妨先去测试文件中搜索一下,是否有数字作为 key 的案例:
```
=== TEST 4: number keys, string values
```
通过这个测试案例,你可以清楚看到,数字也可以作为 key ,内部会将数字转为字符串。那么数组呢?很遗憾,测试案例并没有覆盖到,我们需要自己动手试一下:
```
$ resty --shdict 'dogs 10m' -e 'local dogs = ngx.shared.dogs
dogs:get({})
'
```
不出意料,果然报错了:
```
ERROR: (command line -e):2: bad argument #1 to 'get' (string expected, got table)
```
综上我们可以得出结论get API 接受的 key 类型为字符串和数字。
那么入参 key 的长度是否有限制呢?这里其实也有一个对应的测试案例,我们一起来看一下:
```
=== TEST 67: get a too-long key
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
content_by_lua '
local dogs = ngx.shared.dogs
local ok, err = dogs:get(string.rep(&quot;a&quot;, 65536))
if not ok then
ngx.say(&quot;not ok: &quot;, err)
return
end
ngx.say(&quot;ok&quot;)
';
}
--- request
GET /test
--- response_body
not ok: key too long
--- no_error_log
[error]
```
很显然,字符串长度为 65536 的时候,就会被提示 key 太长了。你可以试下把长度改为 65535虽然只少了1个字节却不会再报错了。这就说明key 的最大长度正是 65535。
## 写在最后
OpenResty 现在的官方文档只有英文版本,国内工程师在阅读时,难免会因为语言问题,抓不住重点,甚至误解其中的内容。但越是这样,越没有捷径可走,你更应该仔细地把文档从头到尾读完,并在有疑问时,结合测试案例集和自己的尝试,去确定出答案。这才是辅助我们学习 OpenResty 的正确途径。
最后,我想提醒一下,在 OpenResty 的 API 中,凡是返回值中带有错误信息的,都必须有变量来接收并做错误处理,否则前方一定会有坑等你跳进去。比如把出错的连接放入了连接池,或者在 API 调用失败的情况下继续后面的逻辑,总之一定让人叫苦不迭。
那么,你在写 OpenResty 代码的时候如果遇到问题一般是通过什么方式来解决的是文档、邮件列表、QQ 群,还是其他渠道呢?
欢迎留言一起探讨,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。

View File

@@ -0,0 +1,249 @@
<audio id="audio" title="17 | 为什么能成为更好的Web服务器动态处理请求和响应是关键" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/6e/4896687fb4708633494c82751b8f1b6e.mp3"></audio>
你好,我是温铭。经过前面内容的铺垫后, 相信你已经对 OpenResty 的概念和如何学习它有了基本的认识。今天这节课,我们来看一下 OpenResty 如何处理终端请求和响应。
虽然 OpenResty 是基于 NGINX 的 Web 服务器,但它与 NGINX 却有本质的不同NGINX 由静态的配置文件驱动,而 OpenResty 是由 Lua API 驱动的,所以能提供更多的灵活性和可编程性。
下面,就让我来带你领略 Lua API 带来的好处吧。
## API 分类
首先我们要知道OpenResty 的 API 主要分为下面几个大类:
- 处理请求和响应;
- SSL 相关;
- shared dict
- cosocket
- 处理四层流量;
- process 和 worker
- 获取 NGINX 变量和配置;
- 字符串、时间、编解码等通用功能。
这里,我建议你同时打开 OpenResty 的 Lua API 文档,对照着其中的 [API 列表](https://github.com/openresty/lua-nginx-module/#nginx-api-for-lua) ,看看是否能和这个分类联系起来。
OpenResty 的 API 不仅仅存在于 lua-nginx-module 项目中,也存在于 lua-resty-core 项目中,比如 ngx.ssl、ngx.base64、ngx.errlog、ngx.process、ngx.re.split、ngx.resp.add_header、ngx.balancer、ngx.semaphore、ngx.ocsp 这些 API 。
而对于不在 lua-nginx-module 项目中的 API你需要单独 require 才能使用。举个例子,比如你想使用 split 这个字符串分割函数,就需要按照下面的方法来调用:
```
$ resty -e 'local ngx_re = require &quot;ngx.re&quot;
local res, err = ngx_re.split(&quot;a,b,c,d&quot;, &quot;,&quot;, nil, {pos = 5})
print(res)
'
```
当然,这可能会给你带来一个困惑:在 lua-nginx-module 项目中,明明有 ngx.re.sub、ngx.re.find 等好几个 ngx.re 开头的 API为什么单单是 ngx.re.split 这个 API ,需要 require 后才能使用呢?
事实上,在前面 lua-resty-core 章节中我们也提到过OpenResty 新的 API 都是通过 FFI 的方式在 `lua-rety-core` 仓库中实现的所以难免就会存在这种割裂感。自然我也很期待lua-nginx-module 和 lua-resty-core 这两个项目以后可以合并,彻底解决此类问题。
## 请求
接下来我们具体了解下OpenResty 是如何处理终端请求和响应的。先来看下处理请求的 API不过以 ngx.req 开头的 API 有 20 多个,该怎么下手呢?
我们知道HTTP 请求报文由三部分组成:请求行、请求头和请求体,所以下面我就按照这三部分来对 API 做介绍。
### 请求行
首先是请求行HTTP 的请求行中包含请求方法、URI 和 HTTP 协议版本。在 NGINX 中,你可以通过内置变量的方式,来获取其中的值;而在 OpenResty 中对应的则是 `ngx.var.*` 这个 API。我们来看两个例子。
- `$scheme` 这个内置变量,在 NGINX 中代表协议的名字,是 “http” 或者 “https”而在 OpenResty 中,你可以通过 `ngx.var.scheme` 来返回同样的值。
- `$request_method` 代表的是请求的方法“GET”、“POST” 等;而在 OpenResty 中,你可以通过 `ngx.var. request_method` 来返回同样的值。
至于完整的 NGINX 内置变量列表,你可以访问 NGINX 的官方文档来获取:[http://nginx.org/en/docs/http/ngx_http_core_module.html#variables](http://nginx.org/en/docs/http/ngx_http_core_module.html#variables)。
那么问题就来了:既然可以通过`ngx.var.*` 这种返回变量值的方法,来得到请求行中的数据,为什么 OpenResty 还要单独提供针对请求行的 API 呢?
这其实是很多方面因素的综合考虑结果:
- 首先是对性能的考虑。`ngx.var` 的效率不高,不建议反复读取;
- 也有对程序友好的考虑,`ngx.var` 返回的是字符串,而非 Lua 对象,遇到获取 args 这种可能返回多个值的情况,就不好处理了;
- 另外是对灵活性的考虑,绝大部分的 `ngx.var` 是只读的,只有很少数的变量是可写的,比如 `$args``limit_rate`,可很多时候,我们会有修改 method、URI 和 args 的需求。
所以, OpenResty 提供了多个专门操作请求行的 API它们可以对请求行进行改写以便后续的重定向等操作。
我们先来看下,如何通过 API 来获取 HTTP 协议版本号。OpenResty 的 API `ngx.req.http_version` 和 NGINX 的 `$server_protocol` 变量的作用一样,都是返回 HTTP 协议的版本号。不过这个 API 的返回值是数字格式,而非字符串,可能的值是 2.0、1.0、1.1 和 0.9,如果结果不在这几个值的范围内,就会返回 nil。
再来看下获取请求行中的请求方法。刚才我们提到过,`ngx.req.get_method` 和 NGINX 的 `$request_method` 变量的作用、返回值一样,都是字符串格式的方法名。
但是,改写当前 HTTP 请求方法的 API也就是 `ngx.req.set_method`,它接受的参数格式却并非字符串,而是内置的数字常量。比如,下面的代码,把请求方法改写为 POST
```
ngx.req.set_method(ngx.HTTP_POST)
```
为了验证 `ngx.HTTP_POST` 这个内置常量,确实是数字而非字符串,你可以打印出它的值,看输出是否为 8
```
$ resty -e 'print(ngx.HTTP_POST)'
```
这样一来get 方法的返回值为字符串而set 方法的输入值却是数字,就很容易让你在写代码的时候想当然了。如果是 set 时候传值混淆的情况还好API 会崩溃报出 500 的错误;但如果是下面这种判断逻辑的代码:
```
if (ngx.req.get_method() == ngx.HTTP_POST) then
-- do something
end
```
这种代码是可以正常运行的,不会报出任何错误,甚至在 code review 时也很难发现。不幸的是,我就犯过类似的错误,对此记忆犹新:当时已经经过了两轮 code review还有不完整的测试案例尝试覆盖然而最终还是因为线上环境异常才追踪到了这里。
碰到这类情况,除了自己多小心,或者再多一层封装外,并没有什么有效的方法来解决。平常你在设计自己的业务 API 时,也可以多做一些这方面的考虑,尽量保持 get、set 方法的参数格式一致,即使这会牺牲一些性能。
另外,在改写请求行的方法中,还有 `ngx.req.set_uri``ngx.req.set_uri_args` 这两个 API可以用来改写 uri 和 args。我们来看下这个 NGINX 配置:
```
rewrite ^ /foo?a=3? break;
```
那么,如何用等价的 Lua API 来解决呢?答案就是下面这两行代码。
```
ngx.req.set_uri_args(&quot;a=3&quot;)
ngx.req.set_uri(&quot;/foo&quot;)
```
其实,如果你看过官方文档,就会发现 `ngx.req.set_uri` 还有第二个参数jump默认是 false。如果设置为 true就等同于把 rewrite 指令的 flag 设置为 `last`,而非上面示例中的 `break`
不过,我个人并不喜欢 rewrite 指令的 flag 配置,看不懂也记不住,远没有代码来的直观和好维护。
### 请求头
再来看下和请求头有关的 API。我们知道HTTP 的请求头是 `key : value` 格式的,比如:
```
Accept: text/css,*/*;q=0.1
Accept-Encoding: gzip, deflate, br
```
在OpenResty 中,你可以使用 `ngx.req.get_headers` 来解析和获取请求头,返回值的类型则是 table
```
local h, err = ngx.req.get_headers()
if err == &quot;truncated&quot; then
-- one can choose to ignore or reject the current request here
end
for k, v in pairs(h) do
...
end
```
这里默认返回前 100 个 header如果请求头超过了 100 个,就会返回 `truncated` 的错误信息,由开发者自己决定如何处理。你可能会好奇为什么会有这样的处理,这一点先留个悬念,在后面安全漏洞的章节中我会提到。
不过需要注意的是OpenResty 并没有提供获取某一个指定请求头的 API也就是没有 `ngx.req.header['host']` 这种形式。如果你有这样的需求,那就需要借助 NGINX 的变量 `$http_xxx` 来实现了,那么在 OpenResty 中,就是 `ngx.var.http_xxx` 这样的获取方式。
看完了获取请求头,我们再来看看应该如何改写和删除请求头,这两种操作的 API 其实都很直观:
```
ngx.req.set_header(&quot;Content-Type&quot;, &quot;text/css&quot;)
ngx.req.clear_header(&quot;Content-Type&quot;)
```
当然,官方文档中也提到了其他方法来删除请求头,比如把 header 的值设置为 nil等但为了代码更加清晰的考虑我还是推荐统一用 `clear_header` 来操作。
### 请求体
最后来看请求体。出于性能考虑OpenResty 不会主动读取请求体的内容,除非你在 nginx.conf 中强制开启了 `lua_need_request_body` 指令。此外对于比较大的请求体OpenResty 会把内容保存在磁盘的临时文件中,所以读取请求体的完整流程是下面这样的:
```
ngx.req.read_body()
local data = ngx.req.get_body_data()
if not data then
local tmp_file = ngx.req.get_body_file()
-- io.open(tmp_file)
-- ...
end
```
这段代码中有读取磁盘文件的 IO 阻塞操作。你应该根据实际情况来调整 `client_body_buffer_size` 配置的大小64 位系统下默认是 16 KB尽量减少阻塞的操作你也可以把 `client_body_buffer_size``client_max_body_size` 配置成一样的,完全在内存中来处理,当然,这取决于你内存的大小和处理的并发请求数。
另外,请求体也可以被改写,`ngx.req.set_body_data``ngx.req.set_body_file` 这两个API分别接受字符串和本地磁盘文件做为输入参数来完成请求体的改写。不过这类操作并不常见你可以查看文档来获取更详细的内容。
## 响应
处理完请求后我们就需要发送响应返回给客户端了。和请求报文一样响应报文也由几个部分组成即状态行、响应头和响应体。同样的接下来我会按照这三部分来介绍相应的API。
### 状态行
状态行中,我们主要关注的是状态码。在默认情况下,返回的 HTTP 状态码是 200也就是 OpenResty 中内置的常量 `ngx.HTTP_OK`。但在代码的世界中,处理异常情况的代码总是占比最多的。
如果你检测了请求报文,发现这是一个恶意的请求,那么你需要终止请求:
```
ngx.exit(ngx.HTTP_BAD_REQUEST)
```
不过OpenResty 的 HTTP 状态码中,有一个特别的常量:`ngx.OK`。当 `ngx.exit(ngx.OK)` 时,请求会退出当前处理阶段,进入下一个阶段,而不是直接返回给客户端。
当然,你也可以选择不退出,只使用 `ngx.status` 来改写状态码,比如下面这样的写法:
```
ngx.status = ngx.HTTP_FORBIDDEN
```
如果你想了解更多的状态码常量,可以从[文档](https://github.com/openresty/lua-nginx-module/#http-status-constants)中查询到。
### 响应头
说到响应头,其实,你有两种方法来设置它。第一种是最简单的:
```
ngx.header.content_type = 'text/plain'
ngx.header[&quot;X-My-Header&quot;] = 'blah blah'
ngx.header[&quot;X-My-Header&quot;] = nil -- 删除
```
这里的 ngx.header 保存了响应头的信息,可以读取、修改和删除。
第二种设置响应头的方法是 `ngx_resp.add_header` ,来自 lua-resty-core 仓库,它可以增加一个头信息,用下面的方法来调用:
```
local ngx_resp = require &quot;ngx.resp&quot;
ngx_resp.add_header(&quot;Foo&quot;, &quot;bar&quot;)
```
与第一种方法的不同之处在于add header 不会覆盖已经存在的同名字段。
### 响应体
最后看下响应体,在 OpenResty 中,你可以使用 `ngx.say``ngx.print` 来输出响应体:
```
ngx.say('hello, world')
```
这两个 API 的功能是一致的,唯一的不同在于, `ngx.say` 会在最后多一个换行符。
为了避免字符串拼接的低效,`ngx.say / ngx.print` 不仅支持字符串作为参数,也支持数组格式:
```
$ resty -e 'ngx.say({&quot;hello&quot;, &quot;, &quot;, &quot;world&quot;})'
hello, world
```
这样在 Lua 层面就跳过了字符串的拼接,把这个它不擅长的事情丢给了 C 函数去处理。
## 写在最后
到此,让我们回顾下今天的内容。我们按照请求报文和响应报文的内容,依次介绍了与之相关的 OpenResty API。你可以看得出来和 NGINX 的指令相比OpenResty API更加灵活和强大。
那么,在你处理 HTTP 请求时OpenResty 提供的 Lua API 是否足够满足你的需求呢?欢迎留言一起探讨,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。

View File

@@ -0,0 +1,311 @@
<audio id="audio" title="18 | worker间的通信法宝最重要的数据结构之shared dict" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/a8/b7a770648c696dd5b49fac1a887615a8.mp3"></audio>
你好,我是温铭。
前面我们讲过,在 Lua 中, table 是唯一的数据结构。与之对应的一个事实是共享内存字典shared dict是你在 OpenResty 编程中最为重要的数据结构。它不仅支持数据的存放和读取,还支持原子计数和队列操作。
基于 shared dict你可以实现多个 worker 之间的缓存和通信,以及限流限速、流量统计等功能。你可以把 shared dict 当作简单的 Redis 来使用,只不过 shared dict 中的数据不能持久化,所以你存放在其中的数据,一定要考虑到丢失的情况。
## 数据共享的几种方式
在编写 OpenResty Lua 代码的过程中,你不可避免地会遇到,在一个请求的不同阶段、不同 worker 之间共享数据的情况,还可能需要在 Lua 和 C 代码之间共享数据。
所以,在正式介绍 shared dict 的 API 之前先让我们了解一下OpenResty 中常见的几种数据共享的方法;并学会根据实际情况,选择较为合适的数据共享方式。
**第一种是 Nginx 中的变量**。它可以在 Nginx C 模块之间共享数据,自然的,也可以在 C 模块和 OpenResty 提供的 `lua-nginx-module` 之间共享数据,比如下面这段代码:
```
location /foo {
set $my_var ''; # this line is required to create $my_var at config time
content_by_lua_block {
ngx.var.my_var = 123;
...
}
}
```
不过,使用 Nginx 变量这种方式来共享数据是比较慢的,因为它涉及到 hash 查找和内存分配。同时,这种方法有其局限性,只能用来存储字符串,不能支持复杂的 Lua 类型。
**第二种是`ngx.ctx`,可以在同一个请求的不同阶段之间共享数据**。它其实就是一个普通的 Lua 的 table所以速度很快还可以存储各种 Lua 的对象。它的生命周期是请求级别的,当一个请求结束的时候,`ngx.ctx` 也会跟着被销毁掉。
下面是一个典型的使用场景,我们用 `ngx.ctx` 来缓存 `Nginx 变量` 这种昂贵的调用,并在不同阶段都可以使用到它:
```
location /test {
rewrite_by_lua_block {
ngx.ctx.host = ngx.var.host
}
access_by_lua_block {
if (ngx.ctx.host == 'openresty.org') then
ngx.ctx.host = 'test.com'
end
}
content_by_lua_block {
ngx.say(ngx.ctx.host)
}
}
```
这时,如果你使用 curl 访问的话:
```
curl -i 127.0.0.1:8080/test -H 'host:openresty.org'
```
就会打印出 `test.com`,可以表明 `ngx.ctx` 的确是在不同阶段共享了数据。当然,你还可以自己动手修改上面的例子,保存 table 等更复杂的对象,而非简单的字符串,看看它是否满足你的预期。
不过,这里需要特别注意的是,正因为 `ngx.ctx` 的生命周期是请求级别的,所以它并不能在模块级别进行缓存。比如,我在 `foo.lua` 文件中这样使用就是错误的:
```
local ngx_ctx = ngx.ctx
local function bar()
ngx_ctx.host = 'test.com'
end
```
我们应该在函数级别进行调用和缓存:
```
local ngx = ngx
local function bar()
ngx_ctx.host = 'test.com'
end
```
`ngx.ctx` 还有很多的细节,后面的性能优化部分,我们再继续探讨。
接着往下看,**第三种方法是使用`模块级别的变量`,在同一个 worker 内的所有请求之间共享数据**。跟前面的 Nginx 变量和 `ngx.ctx` 不一样,这种方法有些不太好理解。不过别着急,概念抽象,代码先行,让我们先来看个例子,弄明白什么是 `模块级别的变量`
```
-- mydata.lua
local _M = {}
local data = {
dog = 3,
cat = 4,
pig = 5,
}
function _M.get_age(name)
return data[name]
end
return _M
```
在 nginx.conf 的配置如下:
```
location /lua {
content_by_lua_block {
local mydata = require &quot;mydata&quot;
ngx.say(mydata.get_age(&quot;dog&quot;))
}
}
```
在这个示例中,`mydata` 就是一个模块,它只会被 worker 进程加载一次,之后,这个 worker 处理的所有请求,都会共享 `mydata` 模块的代码和数据。
自然,`mydata` 模块中的 `data` 这个变量,就是 `模块级别的变量`,它位于模块的 top level也就是模块最开始的位置所有函数都可以访问到它。
所以,你可以把需要在请求间共享的数据,放在模块的 top level 变量中。不过,需要特别注意的是,一般我们只用这种方式来保存**只读的数据**。如果涉及到写操作,你就要非常小心了,因为可能会有 **race condition**,这是**非常难以定位的 bug**。
我们可以通过下面这个最简化的例子来体会下:
```
-- mydata.lua
local _M = {}
local data = {
dog = 3,
cat = 4,
pig = 5,
}
function _M.incr_age(name)
data[name] = data[name] + 1
return data[name]
end
return _M
```
在模块中,我们增加了 `incr_age` 这个函数,它会对 data 这个表的数据进行修改。
然后,在调用的代码中,我们增加了最关键的一行 `ngx.sleep(5)`,这个 sleep 是一个 yield 操作:
```
location /lua {
content_by_lua_block {
local mydata = require &quot;mydata&quot;
ngx.say(mydata. incr_age(&quot;dog&quot;))
ngx.sleep(5) -- yield API
ngx.say(mydata. incr_age(&quot;dog&quot;))
}
}
```
如果没有这行 sleep 代码(也可以是其他的非阻塞 IO 操作,比如访问 Redis 等),就不会有 yield 操作,也就不会产生竞争,那么,最后输出的数字就是顺序的。
但当我们加了这行代码后,哪怕只是在 sleep 的 5 秒钟内,也很可能就有其他请求调用了`mydata. incr_age` 函数修改了变量的值从而导致最后输出的数字不连续。要知道在实际的代码中逻辑不会这么简单bug 的定位也一定会困难得多。
所以,除非你很确定这中间没有 yield 操作,不会把控制权交给 Nginx 事件循环,否则,我建议你还是保持对模块级别变量的只读。
**第四种,也是最后一种方法,用 shared dict 来共享数据,这些数据可以在多个 worker 之间共享。**
这种方法是基于红黑树实现的,性能很好,但也有自己的局限性——你必须事先在 Nginx 的配置文件中,声明共享内存的大小,并且这不能在运行期更改:
```
lua_shared_dict dogs 10m;
```
shared dict 同样只能缓存字符串类型的数据,不支持复杂的 Lua 数据类型。这也就意味着,当我需要存放 table 等复杂的数据类型时,我将不得不使用 json 或者其他的方法,来序列化和反序列化,这自然会带来不小的性能损耗。
总之,还是那句话,这里并没有银弹,不存在一种完美的数据共享方式,你需要根据需求和场景,来组合多个方法来使用。
## 共享字典
上面数据共享的部分,我们花了很多的篇幅来学,有的人可能纳闷儿:它们看上去和 shared dict 没有直接关系,是不是有些文不对题呢?
事实并非如此,你可以自己想一下,为什么 OpenResty 中要有 shared dict 的存在呢?
回忆一下刚刚讲的几种方法,前面三种数据共享的范围都是在请求级别,或者单个 worker 级别。所以,在当前的 OpenResty 的实现中,只有 shared dict 可以完成 worker 间的数据共享,并借此实现 worker 之间的通信,这也是它存在的价值。
在我看来,明白一个技术为何存在,并弄清楚它和别的类似技术之间的差异和优势,远比你只会熟练调用它提供的 API 更为重要。这种技术视野,会给你带来一定程度的远见和洞察力,这也可以说是工程师和架构师的一个重要区别。
回到共享字典本身,它对外提供了 20多个 Lua API不过所有的这些 API 都是原子操作,你不用担心多个 worker 和高并发的情况下的竞争问题。
这些 API 都有官方详细的[文档](https://github.com/openresty/lua-nginx-module#ngxshareddict),我就不再一一赘述了。这里我想再强调一下,任何技术课程的学习,都不能代替对官方文档的仔细研读。这些耗时的笨功夫,每个人都省不掉的。
继续看shared dict 的 API这些 API可以分为下面三个大类也就是字典读写类、队列操作类和管理类这三种。
### 字典读写类
首先来看字典读写类。在最初的版本中,只有字典读写类的 API它们也是共享字典最常用的功能。下面是一个最简单的示例
```
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set(&quot;Tom&quot;, 56)
print(dict:get(&quot;Tom&quot;))'
```
除了 set 外OpenResty 还提供了 `safe_set``add``safe_add``replace` 这四种写入的方法。这里`safe` 前缀的含义是,在内存占满的情况下,不根据 LRU 淘汰旧的数据,而是写入失败并返回 `no memory` 的错误信息。
除了 get 外OpenResty 还提供了 `get_stale` 的读取数据的方法,相比 `get` 方法,它多了一个过期数据的返回值:
```
value, flags, stale = ngx.shared.DICT:get_stale(key)
```
你还可以调用 `delete` 方法来删除指定的 key它和 `set(key, nil)` 是等价的。
### 队列操作类
再来看队列操作,它是 OpenResty 后续新增的功能,提供了和 Redis 类似的接口。队列中的每一个元素,都用 `ngx_http_lua_shdict_list_node_t` 来描述:
```
typedef struct {
ngx_queue_t queue;
uint32_t value_len;
uint8_t value_type;
u_char data[1];
} ngx_http_lua_shdict_list_node_t;
```
我把这些队列操作 API 的 [PR](https://github.com/openresty/lua-nginx-module/pull/586/files) 贴在了文章中,如果你对此感兴趣,可以跟着文档、测试案例和源码,来分析具体的实现。
不过,下面这 5 个队列 API在文档中并没有对应的代码示例这里我简单介绍一下
- lpush/rpush表示在队列两端增加元素
- lpop/rpop表示在队列两端弹出元素
- llen表示返回队列的元素数量。
别忘了我们上节课讲过的另一个利器——测试案例。如果文档中没有,我们通常可以在测试案例中找到对应的代码。队列相关的测试,正是在 `145-shdict-list.t` 这个文件中:
```
=== TEST 1: lpush &amp; 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(&quot;foo&quot;, &quot;bar&quot;)
if len then
ngx.say(&quot;push success&quot;)
else
ngx.say(&quot;push err: &quot;, err)
end
local val, err = dogs:llen(&quot;foo&quot;)
ngx.say(val, &quot; &quot;, err)
local val, err = dogs:lpop(&quot;foo&quot;)
ngx.say(val, &quot; &quot;, err)
local val, err = dogs:llen(&quot;foo&quot;)
ngx.say(val, &quot; &quot;, err)
local val, err = dogs:lpop(&quot;foo&quot;)
ngx.say(val, &quot; &quot;, err)
}
}
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]
```
### 管理类
最后要说的管理类 API 也是后续新增的,属于社区呼声比较高的需求。其中,共享内存的使用情况就是最典型的例子。比如,用户申请了 100M 的空间作为 shared dict那么这 100M 是否够用呢?里面存放了多少 key具体是哪些 key 呢?这几个都是非常现实的问题。
对于这类问题OpenResty 的官方态度,是希望用户使用火焰图来解决,即非侵入式,保持代码基的高效和整洁,而不是提供侵入式的 API 来直接返回结果。
但站在使用者友好角度来考虑,这些管理类 API 还是非常有必要的。毕竟开源项目是用来解决产品需求的,并不是展示技术本身的。所以,下面我们就来了解一下,这几个后续增加的管理类 API。
首先是 `get_keys(max_count?)`,它默认也只返回前 1024 个 key如果你把 `max_count` 设置为 0那就返回所有 key。
然后是 `capacity``free_space`,这两个 API 都属于 lua-resty-core 仓库,所以需要你 require 后才能使用:
```
require &quot;resty.core.shdict&quot;
local cats = ngx.shared.cats
local capacity_bytes = cats:capacity()
local free_page_bytes = cats:free_space()
```
它们分别返回的,是共享内存的大小(也就是 `lua_shared_dict` 中配置的大小)和空闲页的字节数。因为 shared dict 是按照页来分配的,即使 `free_space` 返回为 0在已经分配的页面中也可能存在空间所以它的返回值并不能代表共享内存实际被占用的情况。
## 写在最后
在实际的开发中我们经常会用到多级缓存OpenResty 的官方项目中也有对缓存的封装。你能找出来是哪几个项目吗?或者你知道一些其他缓存封装的 lua-resty 库吗?
欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。

View File

@@ -0,0 +1,187 @@
<audio id="audio" title="19 | OpenResty 的核心和精髓cosocket" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/39/b33be3f616df6012d81f7605d30a5e39.mp3"></audio>
你好,我是温铭,今天我们来学习下 OpenResty 中的核心技术cosocket。
其实在前面的课程中我们就已经多次提到过它了cosocket 是各种 `lua-resty-*` 非阻塞库的基础,没有 cosocket开发者就无法用 Lua 来快速连接各种外部的网络服务。
在早期的 OpenResty 版本中,如果你想要去与 Redis、memcached 这些服务交互的话,需要使用 `redis2-nginx-module``redis-nginx-module``memc-nginx-module`这些 C 模块.这些模块至今仍然在 OpenResty 的发行包中。
不过cosocket 功能加入以后,它们都已经被 `lua-resty-redis``lua-resty-memcached` 替代,基本上没人再去使用 C 模块连接外部服务了。
## 什么是 cosocket
那究竟什么是cosocket 呢事实上cosocket是 OpenResty 中的专有名词,是把协程和网络套接字的英文拼在一起形成的,即 cosocket = coroutine + socket。所以你可以把 cosocket 翻译为“协程套接字”。
cosocket 不仅需要 Lua 协程特性的支持,也需要 Nginx 中非常重要的事件机制的支持,这两者结合在一起,最终实现了非阻塞网络 I/O。另外cosocket 支持 TCP、UDP 和 Unix Domain Socket。
如果我们在 OpenResty 中调用一个 cosocket 相关函数,内部实现便是下面这张图的样子:
<img src="https://static001.geekbang.org/resource/image/80/06/80d16e11d2750d6e4127445c126c9f06.png" alt="">
记性比较好的同学应该发现了,在前面 OpenResty 原理和基本概念的那节课里,我也用过这张图。从图中你可以看到,用户的 Lua 脚本每触发一个网络操作,都会有协程的 yield 以及 resume。
遇到网络 I/O 时它会交出控制权yield把网络事件注册到 Nginx 监听列表中,并把权限交给 Nginx当有 Nginx 事件达到触发条件时便唤醒对应的协程继续处理resume
OpenResty 正是以此为蓝图,封装实现 connect、send、receive 等操作,形成了我们如今见到的 cosocket API。下面我就以处理 TCP 的 API 为例来介绍一下。处理 UDP 和 Unix Domain Socket 与TCP 的接口基本是一样的。
## cosocket API 和指令简介
TCP 相关的 cosocket API 可以分为下面这几类。
- 创建对象ngx.socket.tcp。
- 设置超时tcpsock:settimeout 和 tcpsock:settimeouts。
- 建立连接tcpsock:connect。
- 发送数据tcpsock:send。
- 接受数据tcpsock:receive、tcpsock:receiveany 和 tcpsock:receiveuntil。
- 连接池tcpsock:setkeepalive。
- 关闭连接tcpsock:close。
我们还要特别注意下,这些 API 可以使用的上下文:
```
rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_
```
这里我还要强调一点,归咎于 Nginx 内核的各种限制cosocket API 在 `set_by_lua*` `log_by_lua*` `header_filter_by_lua*``body_filter_by_lua*` 中是无法使用的。而在 `init_by_lua*``init_worker_by_lua*` 中暂时也不能用,不过 Nginx 内核对这两个阶段并没有限制,后面可以增加对这它们的支持。
此外,与这些 API 相关的,还有 8 个 `lua_socket_` 开头的 Nginx 指令,我们简单来看一下。
- `lua_socket_connect_timeout`:连接超时,默认 60 秒。
- `lua_socket_send_timeout`:发送超时,默认 60 秒。
- `lua_socket_send_lowat`发送阈值low water默认为 0。
- `lua_socket_read_timeout` 读取超时,默认 60 秒。
- `lua_socket_buffer_size`:读取数据的缓存区大小,默认 4k/8k。
- `lua_socket_pool_size`:连接池大小,默认 30。
- `lua_socket_keepalive_timeout`:连接池 cosocket 对象的空闲时间,默认 60 秒。
- `lua_socket_log_errors`cosocket 发生错误时,是否记录日志,默认为 on。
这里你也可以看到,有些指令和 API 的功能一样的比如设置超时时间和连接池大小等。不过如果两者有冲突的话API 的优先级高于指令,会覆盖指令设置的值。所以,一般来说,我们都推荐使用 API 来做设置,这样也会更加灵活。
接下来,我们一起来看一个具体的例子,弄明白到底如何使用这些 cosocket API。下面这段代码的功能很简单是发送 TCP 请求到一个网站,并把返回的内容打印出来:
```
$ resty -e 'local sock = ngx.socket.tcp()
sock:settimeout(1000) -- one second timeout
local ok, err = sock:connect(&quot;www.baidu.com&quot;, 80)
if not ok then
ngx.say(&quot;failed to connect: &quot;, err)
return
end
local req_data = &quot;GET / HTTP/1.1\r\nHost: www.baidu.com\r\n\r\n&quot;
local bytes, err = sock:send(req_data)
if err then
ngx.say(&quot;failed to send: &quot;, err)
return
end
local data, err, partial = sock:receive()
if err then
ngx.say(&quot;failed to receive: &quot;, err)
return
end
sock:close()
ngx.say(&quot;response is: &quot;, data)'
```
我们来具体分析下这段代码。
- 首先,通过 `ngx.socket.tcp()` ,创建 TCP 的 cosocket 对象,名字是 sock。
- 然后,使用 `settimeout()` ,把超时时间设置为 1 秒。注意这里的超时没有区分 connect、receive是统一的设置。
- 接着,使用 `connect()` 去连接指定网站的 80 端口,如果失败就直接退出。
- 连接成功的话,就使用 `send()` 来发送构造好的数据,如果发送失败就退出。
- 发送数据成功的话,就使用 `receive()` 来接收网站返回的数据。这里 `receive()` 的默认参数值是 `*l`,也就是只返回第一行的数据;如果参数设置为了`*a`,就是持续接收数据,直到连接关闭;
- 最后,调用 `close()` ,主动关闭 socket 连接。
你看,短短几步就可以完成,使用 cosocket API 来做网络通信,就是这么简单。不过,不能满足于此,接下来,我们对这个示例再做一些调整。
**第一个动作,对 socket 连接、发送和读取这三个动作,分别设置超时时间。**
我们刚刚用的`settimeout()` ,作用是把超时时间统一设置为一个值。如果要想分开设置,就需要使用 `settimeouts()` 函数,比如下面这样的写法:
```
sock:settimeouts(1000, 2000, 3000)
```
这行代码表示连接超时为 1 秒,发送超时为 2 秒,读取超时为 3 秒。
在OpenResty 和 lua-resty 库中,大部分和时间相关的 API 的参数,都以毫秒为单位,但也有例外,需要你在调用的时候特别注意下。
**第二个动作receive接收指定大小的内容。**
刚刚说了,`receive()` 接口可以接收一行数据,也可以持续接收数据。不过,如果你只想接收 10K 大小的数据,应该怎么设置呢?
这时,`receiveany()` 闪亮登场。它就是专为满足这种需求而设计的,一起来看下面这行代码:
```
local data, err, partial = sock:receiveany(10240)
```
这段代码就表示,最多只接收 10K 的数据。
当然关于receive还有另一个很常见的用户需求那就是一直获取数据直到遇到指定字符串才停止。
`receiveuntil()` 专门用来解决这类问题,它不会像 `receive()``receiveany()` 一样返回字符串,而会返回一个迭代器。这样,你就可以在循环中调用它来分段读取匹配到的数据,当读取完毕时,就会返回 nil。下面就是一个例子
```
local reader = sock:receiveuntil(&quot;\r\n&quot;)
while true do
local data, err, partial = reader(4)
if not data then
if err then
ngx.say(&quot;failed to read the data stream: &quot;, err)
break
end
ngx.say(&quot;read done&quot;)
break
end
ngx.say(&quot;read chunk: [&quot;, data, &quot;]&quot;)
end
```
这段代码中的 `receiveuntil` 会返回 `\r\n` 之前的数据,并通过迭代器每次读取其中的 4 个字节,也就实现了我们想要的功能。
**第三个动作,不直接关闭 socket而是放入连接池中。**
我们知道,没有连接池的话,每次请求进来都要新建一个连接,就会导致 cosocket 对象被频繁地创建和销毁,造成不必要的性能损耗。
为了避免这个问题,在你使用完一个 cosocket 后,可以调用 `setkeepalive()` 放到连接池中,比如下面这样的写法:
```
local ok, err = sock:setkeepalive(2 * 1000, 100)
if not ok then
ngx.say(&quot;failed to set reusable: &quot;, err)
end
```
这段代码设置了连接的空闲时间为 2 秒,连接池的大小为 100。这样在调用 `connect()` 函数时,就会优先从连接池中获取 cosocket 对象。
不过,关于连接池的使用,有两点需要我们注意一下。
- 第一,不能把发生错误的连接放入连接池,否则下次使用时,就会导致收发数据失败。这也是为什么我们需要判断每一个 API 调用是否成功的一个原因。
- 第二,要搞清楚连接的数量。连接池是 worker 级别的,每个 worker 都有自己的连接池。所以,如果你有 10 个 worker连接池大小设置为 30那么对于后端的服务来讲就等于有 300 个连接。
## 写在最后
总结一下今天我们学习了cosocket 的基本概念,以及相关的指令和 API并通过一个实际的例子熟悉了TCP 相关的 API 应该如何使用。而UDP 和 Unix Domain Socket的使用类似于TCP弄明白今天所学你基本上都能迎刃而解了。
从中你应该也能感受到cosocket 用起来还是比较容易上手的,而且用好它,你就可以去连接各种外部的服务了,可以说是给 OpenResty 插上了想象的翅膀。
最后,给你留两个作业题。
第一问,在今天的例子中,`tcpsock:send` 发送的是字符串,如果我们需要发送一个由字符串构成的 table又该怎么处理呢
第二问你也看到了cosocket 在很多阶段中不能使用,那么,你能否想到一些绕过的方式呢?
欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。

View File

@@ -0,0 +1,200 @@
<audio id="audio" title="20 | 超越 Web 服务器:特权进程和定时任务" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/38/8e18455afada7424d471e975ed42ff38.mp3"></audio>
你好,我是温铭。
前面我们介绍了 OpenResty API、共享字典缓存和 cosocket。它们实现的功能都还在 Nginx 和 Web 服务器的范畴之内,算是提供了开发成本更低、更容易维护的一种实现,提供了可编程的 Web 服务器。
不过OpenResty并不满足于此。我们今天就挑选几个OpenResty 中超越 Web 服务器的功能来介绍一下。它们分别是定时任务、特权进程和非阻塞的 ngx.pipe。
## 定时任务
在 OpenResty 中,我们有时候需要在后台定期地执行某些任务,比如同步数据、清理日志等。如果让你来设计,你会怎么做呢?最容易想到的方法,便是对外提供一个 API 接口,在接口中完成这些任务;然后用系统的 crontab 定时调用 curl来访问这个接口进而曲线地实现这个需求。
不过,这样一来不仅会有割裂感,也会给运维带来更高的复杂度。所以, OpenResty 提供了 `ngx.timer` 来解决这类需求。你可以把`ngx.timer` ,看作是 OpenResty 模拟的客户端请求,用以触发对应的回调函数。
其实OpenResty 的定时任务可以分为下面两种:
- `ngx.timer.at`,用来执行一次性的定时任务;
- `ngx.time.every`,用来执行固定周期的定时任务。
还记得上节课最后我留下的思考题吗?问题是如何突破 `init_worker_by_lua` 中不能使用 cosocket 的限制,这个答案其实就是 `ngx.timer`
下面这段代码,就是启动了一个延时为 0 的定时任务。它启动了回调函数 `handler`,并在这个函数中,用 cosocket 去访问一个网站:
```
init_worker_by_lua_block {
local function handler()
local sock = ngx.socket.tcp()
local ok, err = sock:connect(“www.baidu.com&quot;, 80)
end
local ok, err = ngx.timer.at(0, handler)
}
```
这样,我们就绕过了 cosocket 在这个阶段不能使用的限制。
再回到这部分开头时我们提到的的用户需求,`ngx.timer.at` 并没有解决周期性运行这个需求,在上面的代码示例中,它是一个一次性的任务。
那么,又该如何做到周期性运行呢?表面上来看,基于 `ngx.timer.at` 这个API 的话,你有两个选择:
- 你可以在回调函数中,使用一个 while true 的死循环,执行完任务后 sleep 一段时间,自己来实现周期任务;
- 你还可以在回调函数的最后,再创建另外一个新的 timer。
不过在做出选择之前有一点我们需要先明确下timer 的本质是一个请求,虽然这个请求不是终端发起的;而对于请求来讲,在完成自己的任务后它就要退出,不能一直常驻,否则很容易造成各种资源的泄漏。
所以,第一种使用 while true 来自行实现周期任务的方案并不靠谱。第二种方案虽然是可行的,但递归地创建 timer ,并不容易让人理解。
那么是否有更好的方案呢其实OpenResty 后面新增的 `ngx.time.every` API就是专门为了解决这个问题而出现的它是更加接近 crontab 的解决方案。
但美中不足的是,在启动了一个 timer 之后,你就再也没有机会来取消这个定时任务了,毕竟`ngx.timer.cancel` 还是一个 todo 的功能。
这时候,你就会面临一个问题:定时任务是在后台运行的,并且无法取消;如果定时任务的数量很多,就很容易耗尽系统资源。
所以OpenResty 提供了 `lua_max_pending_timers``lua_max_running_timers` 这两个指令,来对其进行限制。前者代表等待执行的定时任务的最大值,后者代表当前正在运行的定时任务的最大值。
你也可以通过 Lua API来获取当前等待执行和正在执行的定时任务的值下面是两个示例
```
content_by_lua_block {
ngx.timer.at(3, function() end)
ngx.say(ngx.timer.pending_count())
}
```
这段代码会打印出 1表示有 1 个计划任务正在等待被执行。
```
content_by_lua_block {
ngx.timer.at(0.1, function() ngx.sleep(0.3) end)
ngx.sleep(0.2)
ngx.say(ngx.timer.running_count())
}
```
这段代码会打印出 1表示有 1 个计划任务正在运行中。
## 特权进程
接着来看特权进程。我们都知道 Nginx 主要分为 master 进程和 worker 进程,其中,真正处理用户请求的是 worker 进程。我们可以通过 `lua-resty-core` 中提供的 `process.type` API ,获取到进程的类型。比如,你可以用 `resty` 运行下面这个函数:
```
$ resty -e 'local process = require &quot;ngx.process&quot;
ngx.say(&quot;process type:&quot;, process.type())'
```
你会看到,它返回的结果不是 `worker` 而是 `single`。这意味 `resty` 启动的 Nginx 只有 worker 进程,没有 master 进程。其实,事实也是如此。在 `resty` 的实现中,你可以看到,下面这样的一行配置, 关闭了 master 进程:
```
master_process off;
```
而OpenResty 在 Nginx 的基础上进行了扩展增加了特权进程privileged agent。特权进程很特别
- 它不监听任何端口,这就意味着不会对外提供任何服务;
- 它拥有和 master 进程一样的权限,一般来说是 `root` 用户的权限,这就让它可以做很多 worker 进程不可能完成的任务;
- 特权进程只能在 `init_by_lua` 上下文中开启;
- 另外,特权进程只有运行在 `init_worker_by_lua` 上下文中才有意义,因为没有请求触发,也就不会走到`content``access` 等上下文去。
下面,我们来看一个开启特权进程的示例:
```
init_by_lua_block {
local process = require &quot;ngx.process&quot;
local ok, err = process.enable_privileged_agent()
if not ok then
ngx.log(ngx.ERR, &quot;enables privileged agent failed error:&quot;, err)
end
}
```
通过这段代码开启特权进程后,再去启动 OpenResty 服务我们就可以看到Nginx 的进程中多了特权进程的身影:
```
nginx: master process
nginx: worker process
nginx: privileged agent process
```
不过,如果特权只在 `init_worker_by_lua` 阶段运行一次,显然不是一个好主意,那我们应该怎么来触发特权进程呢?
没错,答案就藏在刚刚讲过的知识里。既然它不监听端口,也就是不能被终端请求触发,那就只有使用我们刚才介绍的 `ngx.timer` ,来周期性地触发了:
```
init_worker_by_lua_block {
local process = require &quot;ngx.process&quot;
local function reload(premature)
local f, err = io.open(ngx.config.prefix() .. &quot;/logs/nginx.pid&quot;, &quot;r&quot;)
if not f then
return
end
local pid = f:read()
f:close()
os.execute(&quot;kill -HUP &quot; .. pid)
end
if process.type() == &quot;privileged agent&quot; then
local ok, err = ngx.timer.every(5, reload)
if not ok then
ngx.log(ngx.ERR, err)
end
end
}
```
上面这段代码,实现了每 5 秒给 master 进程发送 HUP 信号量的功能。自然,你也可以在此基础上实现更多有趣的功能,比如轮询数据库,看是否有特权进程的任务并执行。因为特权进程是 root 权限,这显然就有点儿“后门”程序的意味了。
## 非阻塞的 ngx.pipe
最后我们来看非阻塞的 ngx.pipe。刚刚讲过的这个代码示例中我们使用了 Lua 的标准库,来执行外部命令行,把信号发送给了 master 进程:
```
os.execute(&quot;kill -HUP &quot; .. pid)
```
这种操作自然是会阻塞的。那么,在 OpenResty 中,是否有非阻塞的方法来调用外部程序呢?毕竟,要知道,如果你是把 OpenResty 当做一个完整的开发平台,而非 Web 服务器来使用的话,这就是你的刚需了。
为此,`lua-resty-shell` 库应运而生,使用它来调用命令行就是非阻塞的:
```
$ resty -e 'local shell = require &quot;resty.shell&quot;
local ok, stdout, stderr, reason, status =
shell.run([[echo &quot;hello, world&quot;]])
ngx.say(stdout)
```
这段代码可以算是 hello world 的另外一种写法了,它调用系统的 `echo` 命令来完成输出。类似的,你可以用 `resty.shell` ,来替代 Lua 中的 `os.execute` 调用。
我们知道,`lua-resty-shell` 的底层实现,依赖了 `lua-resty-core` 中的 [[ngx.pipe](https://github.com/openresty/lua-resty-core/blob/master/lib/ngx/pipe.md)] API所以这个使用 `lua-resty-shell` 打印出 `hello wrold` 的示例,改用 `ngx.pipe` ,可以写成下面这样:
```
$ resty -e 'local ngx_pipe = require &quot;ngx.pipe&quot;
local proc = ngx_pipe.spawn({&quot;echo&quot;, &quot;hello world&quot;})
local data, err = proc:stdout_read_line()
ngx.say(data)'
```
这其实也就是 `lua-resty-shell` 底层的实现代码了。你可以去查看 `ngx.pipe` 的文档和测试案例,来获取更多的使用方法,这里我就不再赘述了。
## 写在最后
到此今天的主要内容我就讲完了。从上面的几个功能我们可以看出OpenResty 在做一个更好用的 Nginx 的前提下,也在尝试往通用平台的方向上靠拢,希望开发者能够尽量统一技术栈,都用 OpenResty 来解决开发需求。这对于运维来说是相当友好的,因为只要部署一个 OpenResty 就可以了,维护成本更低。
最后,给你留一个思考题。由于可能会存在多个 Nginx worker那么 timer 就会在每个 worker 中都运行一次,这在大多数场景下都是不能接受的。我们应该如何保证 timer 只能运行一次呢?
欢迎留言说说你的解决方法,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。

View File

@@ -0,0 +1,197 @@
<audio id="audio" title="21 | 带你玩转时间、正则表达式等常用API" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/7b/3fbd10a52dad9e660748b05f114fde7b.mp3"></audio>
你好我是温铭。在前面几节课中你已经熟悉了不少OpenResty 中重要的 Lua API 了,今天我们再来了解下其他一些通用的 API主要和正则表达式、时间、进程等相关。
## 正则
先来看下最常用,也是最重要的正则。在 OpenResty 中,我们应该一直使用 `ngx.re.*` 提供的一系列 API来处理和正则表达式相关的逻辑而不是用 Lua 自带的模式匹配。这不仅是出于性能方面的考虑还因为Lua 自带的正则是自成体系的,并非 PCRE 规范,这对于绝大部分开发者来说都是徒增烦恼。
在前面的课程中,你已经多多少少接触过一些 `ngx.re.*` 的 API了文档也写得非常详细我就不再一一列举了。这里我再单独强调两个内容。
### ngx.re.split
第一个是`ngx.re.split`。字符串切割是很常见的功能OpenResty 也提供了对应的 API但在社区的 QQ 交流群中,很多开发者都找不到这样的函数,只能选择自己手写。
为什么呢?其实, `ngx.re.split` 这个 API 并不在 lua-nginx-module 中,而是在 lua-resty-core 里面;并且它也不在 lua-resty-core 首页的文档中,而是在 `lua-resty-core/lib/ngx/re.md` 这个第三级目录的文档中出现的。多种原因,导致很多开发者完全不知道这个 API 的存在。
类似这种“藏在深闺无人识“的 API还有我们前面提到过的 `ngx_resp.add_header``enable_privileged_agent` 等等。那么怎么来最快地解决这种问题呢?除了阅读 lua-resty-core 首页文档外,你还需要把 `lua-resty-core/lib/ngx/` 这个目录下的 `.md` 格式的文档也通读一遍才行。
我们前面夸了很多 OpenResty 文档做得好的地方,不过,这一点上,也就是在一个页面能够查询到完整的 API 列表,确实还有很大的改进空间。
### lua_regex_match_limit
第二个,我想介绍一下`lua_regex_match_limit`。我们之前并没有花专门的篇幅,来讲 OpenResty 提供的 Nginx 指令,因为大部分情况下我们使用默认值就足够了,它们也没有在运行时去修改的必要性。不过,我们今天要讲的这个,和正则表达式相关的`lua_regex_match_limit` 指令,却是一个例外。
我们知道,如果我使用的正则引擎是基于回溯的 NFA 来实现的那么就有可能出现灾难性回溯Catastrophic Backtracking即正则在匹配的时候回溯过多造成 CPU 100%,正常服务被阻塞。
一旦发生灾难性回溯,我们就需要用 gdb 分析 dump或者 systemtap 分析线上环境才能定位而且事先也不容易发现因为只有特别的请求才会触发。这显然就给攻击者带来了可趁之机ReDoSRegEx Denial of Service就是指的这类攻击。
如果你对如何自动化发现和彻底解决这个问题感兴趣,可以参考我之前在公众号写的一篇文章:[如何彻底避免正则表达式的灾难性回溯](https://mp.weixin.qq.com/s/K9d60kjDdFn6ZwIdsLjqOw)
今天在这里,我主要给你介绍下,如何在 OpenResty 中简单有效地规避,也就是使用下面这行代码:
```
lua_regex_match_limit 100000;
```
`lua_regex_match_limit` ,就是用来限制 PCRE 正则引擎的回溯次数的。这样,即使出现了灾难性回溯,后果也会被限制在一个范围内,不会导致你的 CPU 满载。
这里我简单说一下,这个指令的默认值是 0也就是不做限制。如果你没有替换 OpenResty 自带的正则引擎,并且还涉及到了比较多的复杂的正则表达式,你可以考虑重新设置这个 Nginx 指令的值。
## 时间 API
接下来我们说说时间 API。OpenResty 提供了 10 个左右和时间相关的 API从这个数量你也可见它的重要性。一般来说最常用的时间 API就是 `ngx.now`,它可以打印出当前的时间戳,比如下面这行代码:
```
resty -e 'ngx.say(ngx.now())'
```
从打印的结果可以看出,`ngx.now` 包括了小数部分,所以更加精准。而与之相关的 `ngx.time` 则只返回了整数部分的值。至于其他的 `ngx.localtime``ngx.utctime``ngx.cookie_time``ngx.http_time` ,主要是返回和处理时间的不同格式。具体用到的话,你可以查阅文档,本身并不难理解,我就没有必要专门来讲了。
不过,值得一提的是,**这些返回当前时间的 API如果没有非阻塞网络 IO 操作来触发,便会一直返回缓存的值,而不是像我们想的那样,能够返回当前的实时时间**。可以看看下面这个示例代码:
```
$ resty -e 'ngx.say(ngx.now())
os.execute(&quot;sleep 1&quot;)
ngx.say(ngx.now())'
```
在两次调用 `ngx.now` 之间,我们使用 Lua 的阻塞函数 sleep 了 1 秒钟,但从打印的结果来看,这两次返回的时间戳却是一模一样的。
那么,如果换成是非阻塞的 sleep 函数呢?比如下面这段新的代码:
```
$ resty -e 'ngx.say(ngx.now())
ngx.sleep(1)
ngx.say(ngx.now())'
```
显然,它就会打印出不同的时间戳了。这里顺带引出了 `ngx.sleep` ,这个非阻塞的 sleep 函数。这个函数除了可以休眠指定的时间外,还有另外一个特别的用处。
举个例子,比如你有一段正在做密集运算的代码,需要花费比较多的时间,那么在这段时间内,这段代码对应的请求就会一直占用着 worker 和 CPU 资源,导致其他请求需要排队,无法得到及时的响应。这时,我们就可以在其中穿插 `ngx.sleep(0)`,使这段代码让出控制权,让其他请求也可以得到处理。
## worker 和进程 API
再来看worker 和进程相关的API。OpenResty 提供了 `ngx.worker.*``ngx.process.*` 这些 API 来获取 worker 和进程相关的信息。其中,前者和 Nginx worker 进程有关,后者则是泛指所有的 Nginx 进程,不仅有 worker 进程,还有 master 进程和特权进程等等。
事实上,`ngx.worker.*` 由 lua-nginx-module 提供,而`ngx.process.*` 则是由 lua-resty-core 提供。还记得上节课我们留的作业题吗,如何保证在多 worker 的情况下,只启动一个 timer其实这就需要用到 `ngx.worker.id` 这个 API 了。你可以在启动 timer 之前,先做一个简单的判断:
```
if ngx.worker.id == 0 then
start_timer()
end
```
这样,我们就能实现只启动一个 timer的目的了。这里注意worker id 是从 0 开始返回的,这和 Lua 中数组下标从 1 开始并不相同,千万不要混淆了。
至于其他 worker 和 process 相关的 API并没有什么特别需要注意的地方就交给你自己去学习和练习了。
## 真值和空值
最后我们来看看,真值和空值的问题。在 OpenResty 中,真值与空值的判断,一直是个让人头痛、也比较混乱的点。
我们先看来下 Lua 中真值的定义:**除了 nil 和 false 之外,都是真值。**
所以真值也就包括了0、空字符串、空表等等。
再来看下 Lua 中的空值nil它是未定义的意思比如你申明了一个变量但还没有初始化它的值就是 nil
```
$ resty -e 'local a
ngx.say(type(a))'
```
而 nil 也是 Lua 中的一种数据类型。
明白了这两点后,我们现在就来具体看看,基于这两个定义,衍生出来的其他坑。
### ngx.null
第一个坑是`ngx.null`。因为 Lua 的 nil 无法作为 table 的 value所以 OpenResty 引入了 `ngx.null`,作为 table 中的空值:
```
$ resty -e 'print(ngx.null)'
null
```
```
$ resty -e 'print(type(ngx.null))'
userdata
```
从上面两段代码你可以看出,`ngx.null` 被打印出来是 null而它的类型是 userdata。那么可以把它当作假值吗当然不行事实上`ngx.null` 的布尔值为真:
```
$ resty -e 'if ngx.null then
ngx.say(&quot;true&quot;)
end'
```
所以,要谨记,**只有 nil 和 false 是假值**。如果你遗漏了这一点,就很容易踩坑,比如你在使用 lua-resty-redis 的时候,做了下面这个判断:
```
local res, err = red:get(&quot;dog&quot;)
if not res then
res = res + &quot;test&quot;
end
```
如果返回值 res 是 nil就说明函数调用失败了如果 res 是 ngx.null就说明 redis 中不存在 `dog` 这个key。那么`dog` 这个 key 不存在的情况下,这段代码就 500 崩溃了。
### cdata:NULL
第二个坑是`cdata:NULL`。当你通过 LuaJIT FFI 接口去调用 C 函数,而这个函数返回一个 NULL 指针,那么你就会遇到另外一种空值,即`cdata:NULL`
```
$ resty -e 'local ffi = require &quot;ffi&quot;
local cdata_null = ffi.new(&quot;void*&quot;, nil)
if cdata_null then
ngx.say(&quot;true&quot;)
end'
```
`ngx.null` 一样,`cdata:NULL` 也是真值。但更让人匪夷所思的是,下面这段代码,会打印出 true也就是说`cdata:NULL` 是和 `nil` 相等的:
```
$ resty -e 'local ffi = require &quot;ffi&quot;
local cdata_null = ffi.new(&quot;void*&quot;, nil)
ngx.say(cdata_null == nil)'
```
那么我们应该如何处理 `ngx.null``cdata:NULL` 呢?显然,让应用层来关心这些闹心事儿是不现实的,最好是做一个二层封装,不要让调用者知道这些细节即可。
### cjson.null
最后,我们再来看下 cjson 中出现的空值。cjson 库会把 json 中的 NULL解码为 Lua 的 `lightuserdata`,并用 `cjson.null` 来表示:
```
$ resty -e 'local cjson = require &quot;cjson&quot;
local data = cjson.encode(nil)
local decode_null = cjson.decode(data)
ngx.say(decode_null == cjson.null)'
```
Lua 中的 nil被 json encode 和 decode 一圈儿之后,就变成了 `cjson.null`。你可以想得到,它引入的原因和 `ngx.null` 是一样的,因为 nil 无法在 table 中作为 value。
到现在为止,看了这么多 OpenResty 中的空值,不知道你蒙圈儿了没?不要慌张,这部分内容多看几遍,自己梳理一下,就不至于晕头转向分不清了。当然,你以后在写类似 `if not foo then` 的时候,就要多想想,这个条件到底能不能成立了。
## 写在最后
学完今天这节课后OpenResty 中常用的 Lua API 我们就都介绍过了,不知道你是否都清楚了呢?
最后,留一个思考题给你:在 `ngx.now` 的示例中,为什么在没有 yield 操作的时候,它的值不会修改呢?欢迎留言分享你的看法,也欢迎你把这篇文章分享出去,我们一起交流,一起进步。

View File

@@ -0,0 +1,37 @@
<video poster="https://static001.geekbang.org/resource/image/2b/04/2b19372f8c88bb89c799382bb4767504.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/55ac88a8-16ce81d8277-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/e63152456c494c38a25ef45b274c9610/f2a99770955f4dff8f68622573c395ca-37963b86cd6511e18c03ec2809815ddb-sd.m3u8" type="application/x-mpegURL"><source src="https://media001.geekbang.org/e63152456c494c38a25ef45b274c9610/f2a99770955f4dff8f68622573c395ca-fbd7fdcd1c8e947dad21fe0f44731b04-hd.m3u8" type="application/x-mpegURL"></video>
你好,我是温铭。
今天的内容,我同样会以视频的形式来讲解。老规矩,在你进行视频学习之前,我想先问你这么几个问题:
- 你在使用 OpenResty 的时候,是否注意到有 API 存在安全隐患呢?
- 在安全和性能之间,如何去平衡它们的关系呢?
这几个问题,也是今天视频课要解决的核心内容,希望你可以先自己思考一下,并带着问题来学习今天的视频内容。
同时,我会给出相应的文字介绍,方便你在听完视频内容后,及时总结与复习。下面是今天这节课的文字介绍部分。
## 今日核心
安全,是一个永恒的话题,不管你是写开发业务代码,还是做底层的架构,都离不开安全方面的考虑。
CVE-2018-9230 是与 OpenResty 相关的一个安全漏洞,但它并非 OpenResty 自身的安全漏洞。这听起来是不是有些拗口呢?没关系,接下来让我们具体看下,攻击者是如何构造请求的。
OpenResty 中的 `ngx.req.get_uri_args``ngx.req.get_post_args``ngx.req.get_headers`接口,默认只返回前 100 个参数。如果 WAF 的开发者没有注意到这个细节,就会被参数溢出的方式攻击。攻击者可以填入 100 个无用参数,把 payload 放在第 101 个参数中,借此绕过 WAF 的检测。
那么,应该如何处理这个 CVE 呢?
显然OpenResty 的维护者需要考虑到向下兼容、不引入更多安全风险和不影响性能这么几个因素,并要在其中做出一个平衡的选择。
最终OpenResty 维护者选择新增一个 err 的返回值来解决这个问题。如果输入参数超过 100 个err 的提示信息就是 truncated。这样一来这些 API 的调用者就必须要处理错误信息,自行判断拒绝请求还是放行。
其实,归根到底,安全是一种平衡。究竟是选择基于规则的黑名单方式,还是选择基于身份的白名单方式,抑或是两种方式兼用,都取决于你的实际业务场景。
## 课件参考
今天的课件已经上传到了我的GitHub上你可以自己下载学习。
链接如下:[https://github.com/iresty/geektime-slides](https://github.com/iresty/geektime-slides)
如果有不清楚的地方,你可以在留言区提问,另也可以在留言区分享你的学习心得。期待与你的对话,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流、一起进步。

View File

@@ -0,0 +1,31 @@
<video poster="https://static001.geekbang.org/resource/image/65/c2/6565cf0a87645948ef66c547192db3c2.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/3d0d7df0-16ce81ba96a-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/ed7db4a5658b4ed59f225967841c31f8/e7f659c5c1864bb3bde42a4c200eab4b-d5090411129213bc3a3a2d388e0daa0b-sd.m3u8" type="application/x-mpegURL"><source src="https://media001.geekbang.org/ed7db4a5658b4ed59f225967841c31f8/e7f659c5c1864bb3bde42a4c200eab4b-3d9bf901ccefc4abb20c7469df8134d4-hd.m3u8" type="application/x-mpegURL"></video>
你好,我是温铭。
今天的内容,我同样会以视频的形式来讲解。老规矩,在你进行视频学习之前,先问你这么几个问题:
- 面对多个相同功能的 lua-resty 库,我们应该从哪些方面来选择?
- 如何来组织一个 lua-resty 的结构?
这几个问题,也是今天视频课要解决的核心内容,希望你可以先自己思考一下,并带着问题来学习今天的视频内容。
同时,我会给出相应的文字介绍,方便你在听完视频内容后,及时总结与复习。下面是今天这节课的文字介绍部分。
## 今日核心
前面我们介绍过的 lua-resty 库都是官方自带的,但在 HTTP client 这个最常用的库上,官方并没有。这时候,我们就得自己来选择一个优秀的第三方库了。
那么,如何在众多的 lua-resty HTTP client 中,选择一个最好、最适合自己的第三方库呢?
这时候,你就需要综合考虑活跃度、作者、测试覆盖度、接口封装等各方面的因素了。我最后选择的是 lua-resty-requests[https://github.com/tokers/lua-resty-requests](https://github.com/tokers/lua-resty-requests)),它是由又拍云的工程师 tokers 贡献的,我个人很喜欢它的接口风格,也推荐给你。
在视频中我会从最简单的 get 接口入手,结合文档、测试案例和源码,来逐步展开。你可以看到一个优秀的 lua-resty 库是如何编写的,有哪些可以借鉴的地方。
## 课件参考
今天的课件已经上传到了我的GitHub上你可以自己下载学习。
链接如下:[https://github.com/iresty/geektime-slides](https://github.com/iresty/geektime-slides)
如果有不清楚的地方,你可以在留言区提问,另也可以在留言区分享你的学习心得。期待与你的对话,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流、一起进步。

View File

@@ -0,0 +1,263 @@
<audio id="audio" title="24 | 实战处理四层流量实现Memcached Server" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/f5/3f538d45054560e043ee575907fb5df5.mp3"></audio>
你好,我是温铭。
在前面几节课中,我们介绍了不少处理请求的 Lua API 不过它们都是和七层相关的。除此之外OpenResty 其实还提供了 `stream-lua-nginx-module` 模块来处理四层的流量。它提供的指令和 API ,与 `lua-nginx-module` 基本一致。
今天,我就带你一起用 OpenResty 来实现一个 memcached server而且大概只需要 100 多行代码就可以完成。在这个小的实战中,我们会用到不少前面学过的内容,也会带入一些后面测试和性能优化章节的内容。
所以,我希望你能够明确一点,我们这节课的重点,不在于你必须读懂每一行代码的具体作用,而是你要从需求、测试、开发等角度,把 OpenResty 如何从零开发一个项目的全貌了然于心。
## 原始需求和技术方案
在开发之前,我们都需要明白需求是什么,到底是用来解决什么问题的,否则就会在迷失在技术选择中。比如看到我们今天的主题,你就应该先反问一下自己,为什么要实现一个 memcached server 呢?直接安装一个原版的 memcached 或者 redis 不就行了吗?
我们知道HTTPS 流量逐渐成为主流,但一些比较老的浏览器并不支持 session ticket那么我们就需要在服务端把 session ID 存下来。如果本地存储空间不够,就需要一个集群进行存放,而这个数据又是可以丢弃的,所以选用 memcached 就比较合适。
这时候,直接引入 memcached ,应该是最简单直接的方案。但出于以下几个方面的考虑,我还是选择使用 OpenResty 来造一个轮子:
- 第一,直接引入会多引入一个进程,增加部署和维护成本;
- 第二,这个需求足够简单,只需要 get 和 set 操作,并且支持过期即可;
- 第三OpenResty 有 stream 模块,可以很快地实现这个需求。
既然要实现 memcached server我们就需要先弄明白它的协议。memcached 的协议可以支持 TCP 和 UDP这里我选择 TCP下面是 get 和 set 命令的具体协议:
```
Get
根据 key 获取 value
Telnet command: get &lt;key&gt;*\r\n
示例:
get key
VALUE key 0 4 data END
```
```
Set
存储键值对到 memcached 中
Telnet commandset &lt;key&gt; &lt;flags&gt; &lt;exptime&gt; &lt;bytes&gt; [noreply]\r\n&lt;value&gt;\r\n
示例:
set key 0 900 4 data
STORED
```
除了 get 和 set 外,我们还需要知道 memcached 的协议的“错误处理”是怎么样做的。“错误处理”对于服务端的程序是非常重要的,我们在编写程序时,除了要处理正常的请求,也要考虑到各种异常。比如下面这样的场景:
- memcached 发送了一个get、set 之外的请求,我要怎么处理呢?
- 服务端出错,我要给 memcached 的客户端一个什么样的反馈呢?
同时,我们希望写出能够兼容 memcached 的客户端程序。这样,使用者就不用区分这是 memcached 官方的版本,还是 OpenResty 实现的版本了。
下面这张图出自memcached 的文档,描述了出错的时候,应该返回什么内容和具体的格式,你可以用做参考:
<img src="https://static001.geekbang.org/resource/image/37/b0/3767ed0047e34aabaa7bf7d568438ab0.png" alt="">
现在再来确定下技术方案。我们知道OpenResty 的 shared dict 可以跨各个 worker 来使用,把数据放在 shared dict 里面,和放在 memcached 里面非常类似——它们都支持 get 和 set 操作,并且在进程重启后数据就丢失了。所以,使用 shared dict 来模拟 memcached 是非常合适的,它们的原理和行为都是一致的。
## 测试驱动开发
接下来就要开始动工了。不过,基于测试驱动开发的思想,在写具体的代码之前,让我们先来构造一个最简单的测试案例。这里我们不用 `test::nginx` 框架,毕竟它的上手难度也不低,我们不妨先用熟悉的 `resty` 来手动测试下:
```
$ resty -e 'local memcached = require &quot;resty.memcached&quot;
local memc, err = memcached:new()
memc:set_timeout(1000) -- 1 sec
local ok, err = memc:connect(&quot;127.0.0.1&quot;, 11212)
local ok, err = memc:set(&quot;dog&quot;, 32)
if not ok then
ngx.say(&quot;failed to set dog: &quot;, err)
return
end
local res, flags, err = memc:get(&quot;dog&quot;)
ngx.say(&quot;dog: &quot;, res)'
```
这段测试代码,使用 `lua-rety-memcached` 客户端库发起 connect 和 set 操作,并假设 memcached 的服务端监听本机的 11212 端口。
看起来应该没有问题了吧。你可以在自己的机器上执行一下这段代码,不出意外的话,会返回 `failed to set dog: closed` 这样的错误提示,因为此时服务并没有启动。
到现在为止,你的技术方案就已经明确了,那就是使用 stream 模块来接收和发送数据,同时使用 shared dict 来存储数据。
衡量需求是否完成的指标也很明确,那就是跑通上面这段代码,并把 dog 的实际值给打印出来。
## 搭建框架
那还等什么,开始动手写代码吧!
我个人的习惯,是先搭建一个最小的可以运行的代码框架,然后再逐步地去填充代码。这样的好处是,在编码过程中,你可以给自己设置很多小目标;而且在完成一个小目标后,测试案例也会给你正反馈。
让我们先来设置好 Nginx 的配置文件因为stream 和 shared dict 要在其中预设。下面是我设置的配置文件:
```
stream {
lua_shared_dict memcached 100m;
lua_package_path 'lib/?.lua;;';
server {
listen 11212;
content_by_lua_block {
local m = require(&quot;resty.memcached.server&quot;)
m.run()
}
}
}
```
你可以看到,这段配置文件中有几个关键的信息:
- 首先,代码运行在 Nginx 的 stream 上下文中,而非 HTTP 上下文中,并且监听了 11212 端口;
- 其次shared dict 的名字为 memcached大小是 100M这些在运行期是不可以修改的
- 另外,代码所在目录为 `lib/resty/memcached`, 文件名为 `server.lua`, 入口函数为 `run()`,这些信息你都可以从`lua_package_path``content_by_lua_block` 中找到。
接着,就该搭建代码框架了。你可以自己先动手试试,然后我们一起来看下我的框架代码:
```
local new_tab = require &quot;table.new&quot;
local str_sub = string.sub
local re_find = ngx.re.find
local mc_shdict = ngx.shared.memcached
local _M = { _VERSION = '0.01' }
local function parse_args(s, start)
end
function _M.get(tcpsock, keys)
end
function _M.set(tcpsock, res)
end
function _M.run()
local tcpsock = assert(ngx.req.socket(true))
while true do
tcpsock:settimeout(60000) -- 60 seconds
local data, err = tcpsock:receive(&quot;*l&quot;)
local command, args
if data then
local from, to, err = re_find(data, [[(\S+)]], &quot;jo&quot;)
if from then
command = str_sub(data, from, to)
args = parse_args(data, to + 1)
end
end
if args then
local args_len = #args
if command == 'get' and args_len &gt; 0 then
_M.get(tcpsock, args)
elseif command == &quot;set&quot; and args_len == 4 then
_M.set(tcpsock, args)
end
end
end
end
return _M
```
这段代码,便实现了入口函数 `run()` 的主要逻辑。虽然我还没有做异常处理,依赖的 `parse_args``get``set` 也都是空函数但这个框架已经完整表达了memcached server 的逻辑。
## 填充代码
接下来,让我们按照代码的执行顺序,逐个实现这几个空函数。
首先,我们可以根据 memcached [的协议](https://github.com/memcached/memcached/blob/master/doc/protocol.txt)[文档](https://github.com/memcached/memcached/blob/master/doc/protocol.txt),解析 memcached 命令的参数:
```
local function parse_args(s, start)
local arr = {}
while true do
local from, to = re_find(s, [[\S+]], &quot;jo&quot;, {pos = start})
if not from then
break
end
table.insert(arr, str_sub(s, from, to))
start = to + 1
end
return arr
end
```
这里,我的建议是,先用最直观的方式来实现一个版本,不用考虑任何性能的优化。毕竟,完成总是比完美更重要,而且,基于完成的逐步优化才可以趋近完美。
接下来,我们就来实现下 `get` 函数。它可以一次查询多个键,所以下面代码中我用了一个 for 循环:
```
function _M.get(tcpsock, keys)
local reply = &quot;&quot;
for i = 1, #keys do
local key = keys[i]
local value, flags = mc_shdict:get(key)
if value then
local flags = flags or 0
reply = reply .. &quot;VALUE&quot; .. key .. &quot; &quot; .. flags .. &quot; &quot; .. #value .. &quot;\r\n&quot; .. value .. &quot;\r\n&quot;
end
end
reply = reply .. &quot;END\r\n&quot;
tcpsock:settimeout(1000) -- one second timeout
local bytes, err = tcpsock:send(reply)
end
```
其实,这里最核心的代码只有一行:`local value, flags = mc_shdict:get(key)`,也就是从 shared dict 中查询到数据;至于其余的代码,都在按照 memcached 的协议拼接字符串,并最终 send 到客户端。
最后,我们再来看下 `set` 函数。它将接收到的参数转换为 shared dict API 的格式,把数据储存了起来;并在出错的时候,按照 memcached 的协议做出处理:
```
function _M.set(tcpsock, res)
local reply = &quot;&quot;
local key = res[1]
local flags = res[2]
local exptime = res[3]
local bytes = res[4]
local value, err = tcpsock:receive(tonumber(bytes) + 2)
if str_sub(value, -2, -1) == &quot;\r\n&quot; then
local succ, err, forcible = mc_shdict:set(key, str_sub(value, 1, bytes), exptime, flags)
if succ then
reply = reply .. “STORED\r\n&quot;
else
reply = reply .. &quot;SERVER_ERROR &quot; .. err .. “\r\n”
end
else
reply = reply .. &quot;ERROR\r\n&quot;
end
tcpsock:settimeout(1000) -- one second timeout
local bytes, err = tcpsock:send(reply)
end
```
另外,在填充上面这几个函数的过程中,你可以用测试案例来做检验,并用 `ngx.log` 来做 debug。比较遗憾的是OpenResty 中并没有断点调试的工具,所以我们都是使用 `ngx.say``ngx.log` 来调试的,在这方面可以说是还处于刀耕火种的时代。
## 写在最后
这个实战项目到现在就接近尾声了,最后,我想留一个动手作业。你可以把上面 memcached server 的实现代码,完整地运行起来,并通过测试案例吗?
今天的作业题估计要花费你不少的精力了,不过,这还是一个原始的版本,还没有错误处理、性能优化和自动化测试,这些就要放在后面继续完善了。我也希望通过后面内容的学习,你最终能够完成一个完善的版本。
如果对于今天的讲解或者自己的实践有什么疑惑,欢迎你留言和我讨论。也欢迎你把这篇文章转发给你的同事朋友,我们一起实战,一起进步。

View File

@@ -0,0 +1,142 @@
<audio id="audio" title="25 | 答疑(二):特权进程的权限到底是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/b0/00e033e4519ef941c92485d925cd3db0.mp3"></audio>
你好,我是温铭。
专栏更新到现在OpenResty第二版块 OpenResty API 篇,我们就已经学完了。恭喜你没有掉队,仍然在积极学习和实践操作,并且热情地留下了你的思考。
很多留言提出的问题很有价值大部分我都已经在App里回复过一些手机上不方便回复的或者比较典型、有趣的问题我专门摘了出来作为今天的答疑内容集中回复。另一方面也是为了保证所有人都不漏掉任何一个重点。
下面我们来看今天的这 6 个问题。
## 第一问,特权进程的权限
Q我想请问下特权进程是怎么回事如果启动 OpenResty 的本身就是普通用户如何获取root权限呢另外老师可以介绍下特权进程的使用场景有哪些吗
A其实特权进程的权限和 master 进程的权限保持一样。如果你用普通用户身份启动 OpenResty那么 master 就是普通用户的权限,这时候特权进程也就没有什么特权了。
这一点应该还是很好理解的,普通用户启动的进程,无论如何也不会有 root 权限。
至于特权进程的使用场景,我们一般用特权进程来处理的是清理日志、重启 OpenResty 自身等需要高权限的任务。你需要注意的是,不要把 worker 进程的任务交给特权进程来处理。这并非因为特权进程不能做到,而是其存在安全隐患。
我见到过一个开发者,他把定时器的任务都交给了特权进程来处理。他为什么这么做呢?因为特权进程只有一个,这样 timer 就不会重复启动。
是不是觉得这看上去很聪明呀,不用 worker.id 这种笨方法就做到了。但是,别忘了,如果定时器的任务和用户的输入有关,这不就等于留了一个后门吗?显然是非常危险的。
## 第二问,阶段和调试
Q老师是不是无论在哪个阶段运行`ngx.say('hello')`OpenResty都会在执行完本阶段的剩余代码后直接响应给客户端而不会继续执行其他阶段了呢我测试出来是这样的。
A事实上并非如此我们可以来看下它的执行阶段的[顺序图](https://github.com/moonbingbing/openresty-best-practices/blob/master/images/openresty_phases.png)
<img src="https://static001.geekbang.org/resource/image/71/bf/71b24c95f042f0bf79ac34211e2dd0bf.png" alt="">
你可以做个测试,先在 content 里面 `ngx.say`;然后,在 log 或者 body filter 阶段使用 `ngx.log` 来打印下日志试试。
在专栏中,我并没有专门提到在 OpenResty 中做代码调试的问题,这也是开发者经常困惑的地方,我正好顺着这个问题在答疑中聊一下。
其实OpenResty 中的代码调试,并没有断点这些高级功能(相应有一些付费的插件,但我并没有使用过),只能用 `ngx.say``ngx.log` 来看输出。我知道的开发者,包括 OpenResty 的作者和贡献者们,都是这样来做 debug 的。所以,你需要有强有力的测试案例和调试日志来作为保证。
## 第三问ngx.exit 和动手实验
Q老师文中的这句话“OpenResty 的 HTTP 状态码中,有一个特别的常量:`ngx.OK`。当 `ngx.exit(ngx.OK)` 时,请求会退出当前处理阶段,进入下一个阶段,而不是直接返回给客户端。”
我记得,`ngx.OK`应该不能算是HTTP状态码它对应的值是0。我的理解是
- `ngx.exit(ngx.OK)``ngx.exit(ngx.ERROR)``ngx.exit(ngx.DECLINED)`时,请求会退出当前处理阶段,进入下一个阶段;
- 而当`ngx.exit(ngx.HTTP_*)``ngx.HTTP_*`的各种HTTP状态码作为参数时会直接响应给客户端。
不知道这样想对不对呢?
A关于你的第一个问题`ngx.ok` 确实不是http状态码它是 OpenResty 中的一个常量值是0。
至于第二个问题,`ngx.exit` 的官方文档其实正好可以解答:
```
When status &gt;= 200 (i.e., ngx.HTTP_OK and above), it will interrupt the execution of the current request and return status code to nginx.
When status == 0 (i.e., ngx.OK), it will only quit the current phase handler (or the content handler if the content_by_lua* directive is used) and continue to run later phases (if any) for the current request.
```
不过,文档里并没有提到, OpenResty对于`ngx.exit(ngx.ERROR)``ngx.exit(ngx.DECLINED)`是如何处理的,我们可以自己来做个测试,比如下面这样:
```
location /lua {
rewrite_by_lua &quot;ngx.exit(ngx.ERROR)&quot;;
echo hello;
}
```
显然,访问这个 location你可以看到 http 响应码为空,响应体也是空,并没有进入下一个执行阶段。
其实,还是那句话,在 OpenResty 的学习过程中,随着你逐步深入,一定会在某个阶段发现,文档和测试案例都无法回答你的问题。这时候,就需要你自己构建测试案例来验证你的想法了。你可以手动测试,也可以添加在 `test::nginx` 搭建的测试案例集里面。
## 第四问,变量和竞争
Q老师你好我有下面几个问题想请教一下。
1. 前面讲过,`ngx.var`变量的作用域在nginx C和lua-nginx-module模块之间。这个我不太理解从请求的角度来看是指一个工作进程中的单个请求吗
1. 我的理解是在我们操作模块内的变量时如果两个操作之间有阻塞操作可能会出现竞争。那么如果两个操作之间没有阻塞操作恰好CPU时间到了后当前进程进入就绪队列这样可能产生竞争吗
A我们依次来看这几个问题。
第一,关于`ngx.var` 变量的问题,你的理解是正确的。实际上,`ngx.var` 的生命周期和请求一致,请求结束它也就消失了。但它的优势,是数据可以在 C 模块和 Lua 代码中传递。这是其他几种方式都无法做到的。
第二,关于变量竞争的问题,其实,只要两个操作之间有 `yield 操作`,就可能出现竞争,而不是阻塞操作;有阻塞操作时是不会出现竞争的。换句话说,只要你不把主动权交给 Nginx 的事件循环,就不会有竞争。
## 第五问,共享字典操作是否需要加锁呢?
Q老师如果多个worker并发存储数据是不是需要加锁呢比如下面这个例子
```
resty --shdict 'dogs 10m' -e 'local dogs = ngx.shared.dogs
local lock= ngx.xxxx.lock
lock.lock()
dogs:set(&quot;Jim&quot;, 8)
lock.unlock()
local v = dogs:get(&quot;Jim&quot;)
ngx.say(v)
'
```
A其实这里不用你自己加锁共享字典shared dict的操作都是原子性的不管是 get 还是 set。这种类似加锁的处理OpenResty已经帮你考虑到了。
## 第六问OpenResty 中如何更新时间?
Q`ngx.now()`取时间是发生在resume函数恢复堆栈阶段吗
ANginx 是以性能优先作为设计理念的,它会把时间缓存下来。这一点,我们从 `ngx.now` 的源码中就可以得到印证:
```
static int
ngx_http_lua_ngx_now(lua_State *L)
{
ngx_time_t *tp;
tp = ngx_timeofday();
lua_pushnumber(L, (lua_Number) (tp-&gt;sec + tp-&gt;msec / 1000.0L));
return 1;
}
```
可以看出,`ngx.now()`这个获取当前时间函数的背后,隐藏的其实是 Nginx 的 `ngx_timeofday` 函数。而`ngx_timeofday` 函数,其实是一个宏定义:
```
#define ngx_timeofday() (ngx_time_t *) ngx_cached_time
```
这里`ngx_cached_time` 的值,只在函数 `ngx_time_update` 中会更新。
所以,这个问题就简化成了, `ngx_time_update`什么时候会被调用?如果你在 Nginx 的源码中去跟踪它的话,就会发现, `ngx_time_update` 的调用都出现在事件循环中,这个问题也就明白了吧。
通过这个问题你应该也能发现,开源项目的好处就是,你可以根据蛛丝马迹,在源码中寻找答案,颇有一种破案的感觉。
今天主要解答这几个问题。最后,欢迎你继续在留言区写下你的疑问,我会持续不断地解答。希望可以通过交流和答疑,帮你把所学转化为所得。也欢迎你把这篇文章转发出去,我们一起交流、一起进步。