19 | 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 相关函数,内部实现便是下面这张图的样子:

记性比较好的同学应该发现了,在前面 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("www.baidu.com", 80)
|
|
if not ok then
|
|
ngx.say("failed to connect: ", err)
|
|
return
|
|
end
|
|
|
|
local req_data = "GET / HTTP/1.1\r\nHost: www.baidu.com\r\n\r\n"
|
|
local bytes, err = sock:send(req_data)
|
|
if err then
|
|
ngx.say("failed to send: ", err)
|
|
return
|
|
end
|
|
|
|
local data, err, partial = sock:receive()
|
|
if err then
|
|
ngx.say("failed to receive: ", err)
|
|
return
|
|
end
|
|
|
|
sock:close()
|
|
ngx.say("response is: ", 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("\r\n")
|
|
|
|
while true do
|
|
local data, err, partial = reader(4)
|
|
if not data then
|
|
if err then
|
|
ngx.say("failed to read the data stream: ", err)
|
|
break
|
|
end
|
|
|
|
ngx.say("read done")
|
|
break
|
|
end
|
|
ngx.say("read chunk: [", data, "]")
|
|
end
|
这段代码中的 receiveuntil 会返回 \r\n 之前的数据,并通过迭代器每次读取其中的 4 个字节,也就实现了我们想要的功能。
第三个动作,不直接关闭 socket,而是放入连接池中。
我们知道,没有连接池的话,每次请求进来都要新建一个连接,就会导致 cosocket 对象被频繁地创建和销毁,造成不必要的性能损耗。
为了避免这个问题,在你使用完一个 cosocket 后,可以调用 setkeepalive() 放到连接池中,比如下面这样的写法:
|
local ok, err = sock:setkeepalive(2 * 1000, 100)
|
|
if not ok then
|
|
ngx.say("failed to set reusable: ", err)
|
|
end
|
这段代码设置了连接的空闲时间为 2 秒,连接池的大小为 100。这样,在调用 connect() 函数时,就会优先从连接池中获取 cosocket 对象。
不过,关于连接池的使用,有两点需要我们注意一下。
- 第一,不能把发生错误的连接放入连接池,否则下次使用时,就会导致收发数据失败。这也是为什么我们需要判断每一个 API 调用是否成功的一个原因。
- 第二,要搞清楚连接的数量。连接池是 worker 级别的,每个 worker 都有自己的连接池。所以,如果你有 10 个 worker,连接池大小设置为 30,那么对于后端的服务来讲,就等于有 300 个连接。
写在最后
总结一下,今天我们学习了 cosocket 的基本概念,以及相关的指令和 API,并通过一个实际的例子,熟悉了 TCP 相关的 API 应该如何使用。而 UDP 和 Unix Domain Socket 的使用类似于 TCP,弄明白今天所学,你基本上都能迎刃而解了。
从中你应该也能感受到,cosocket 用起来还是比较容易上手的,而且用好它,你就可以去连接各种外部的服务了,可以说是给 OpenResty 插上了想象的翅膀。
专栏更新到现在,OpenResty 第三版块 OpenResty 测试篇,我们就已经学完了。恭喜你没有掉队,仍然在积极学习和实践操作,并且热情地留下了你的思考。
很多留言提出的问题很有价值,大部分我都已经在 App 里回复过,一些手机上不方便回复的或者比较典型、有趣的问题,我专门摘了出来,作为今天的答疑内容,集中回复。另一方面,也是为了保证所有人都不漏掉任何一个重点。
下面我们来看今天的这 5 个问题。
问题一:如何搭建测试的网络结构?
Q:跑 wrk 的客户端,是应该放在外网上的机器上,还是和服务端同一局域网内的机器上呢?这两者,哪个更有性能测试意义?
A:其实,对于测试 web 相关的服务来说,选择正确的测试工具,只能算得上是一个好的开端,如何搭建测试的网络结构,也是后续的重要一环。
一般来说,我们肯定希望排除所有网络的干扰,单独测试出服务的性能极限来。出于这个目的,我们可以有两种搭建网络的方法来做压测。
- 第一种方法,把 wrk 和服务端程序都部署在同一台性能比较好的机器上。比如, 我们在 Nginx 中开启 8 个 worker,剩下的几个 CPU 资源分给 wrk。这样一来,就只有本地的网络通信,可以把网络的影响降到最低。
- 第二种方法,用专门的路由器搭建一个局域网,把 wrk 所在的机器和服务端所在的机器连在一起。
之所以不推荐你在已有的网络中直接测试,是因为大部分的网络中都存在交换机和防火墙,它们可能会对大流量的压测进行限制,造成测试结果的不准确。
另外,关于性能测试工具,我还想再多提几句。性能测试工具可能存在 Coordinated Omission 问题,在分析工具的延时数据的时候,你一定要特别留意。
简单地说,Coordinated Omission(协调遗漏) 是指,在做压力测试时,对于响应来说,只统计发送和收到回复之间的时间是不够的,这只是服务时间,这样统计会遗漏很多潜在的问题。因此,我们还需要把测试请求的等待时间也计算在内,这个整体才算是用户关心的响应时间。当然,如果你的服务端程序可能会出现阻塞,一定需要考虑这个问题,否则就可以忽略掉了。
问题二:test::nginx 可以测试 ssl 相关功能吗?
Q:ssl 相关功能,用test::nginx是不是测不了?
A:事实显然不是这样的,test::nginx 可以测试 ssl 的相关功能,你可以参考 https://github.com/iresty/apisix/blob/master/t/node/ssl.t,这个测试案例文件测试了 ssl 证书的全过程。你可以看到,测试案例使用 Lua 代码,来读取本地证书的公钥和私钥;然后,再通过 http API 设置好证书;最后,用 cosocket 来 ssl 握手和访问,验证证书是否生效。
其实,不仅仅是 ssl 这个功能,只要是 OpenResty 中包含的功能,使用 test::nginx 都是可以覆盖的。
当你不确定某个功能用 test::nginx能不能实现时,可以先去 lua-nginx-module 和其他的 OpenResty 开源项目的测试案例集中搜索,一般都能找到对应的示例。我也是用这种方法来解决这类问题的,毕竟,test::nginx的可玩性和变化性比较大,总有一些意想不到的使用组合和奇技淫巧在等着你发掘。
问题三:DSL 究竟是什么?
Q:DSL 的翻译是领域专用语言吗?文中讲了它是领域小语言,但我搜这个词没有搜到,只搜到了领域专用语言 DSL(Domain Specific Language)。
A:DSL 确实是领域专用语言的缩写,而小语言是 DSL 的俗称。之所以在前面加了一个“小”字,是因为 DSL 的目的和常用的开发语言不同,它不是为了解决通用领域的需求,而是要解决某个领域的需求。最著名的 DSL 就是 SQL,结构化查询语言,用在数据库领域。
至于test::nginx,它其实是为了解决 Nginx 和 OpenResty 的测试需求而创造出来的 DSL。实际上,OpenResty 的作者发明了很多小语言,这种 DSL 的思路,也将会给 OpenResty 社区带来不少新的尝试和解决方案。不过,正如之前文章中提到的一样,DSL 是把双刃剑,能否给最终使用者带来生产力的提升,才是衡量 DSL 是否有价值的主要标准。
问题四:test::nginx的安装问题
Q:在执行完git clone后,是否需要执行下面的命令,才能安装test::nginx呢?
|
cd test-nginx
|
|
perl Makefile.PL
|
|
make
|
|
sudo make install
|
A:事实上并非如此,这里其实你可以参考一些开源项目中 travis 的做法。
第一步,先通过包管理器安装 https://github.com/iresty/apisix/blob/master/.travis/linux_runner.sh#L20:
|
sudo cpanm --notest Test::Nginx >build.log 2>&1 || (cat build.log && exit 1)
|
第二步,git clone 最新的 test::nginx https://github.com/iresty/apisix/blob/master/.travis/linux_runner.sh#L35:
|
git clone https://github.com/openresty/test-nginx.git test-nginx
|
第三步,用 prove 命令的时候,把 test nginx 的目录包含进去:
|
prove -Itest-nginx/lib -r t
|
前面我也提到过,OpenResty 以及周边的项目,安装的最佳指南都存在于 travis CI 中,而不是文档中。这一点可能与其他项目的做法不同,主要是因为, OpenResty 自己维护了一些周边项目的 fork 或者特定版本;同时, OpenResty 也是强依赖 travis CI 的。所以,你应该按照 travis CI 中构建的方法来使用和测试 OpenResty,才能保证和官方一致。
问题五:ab 测试工具到底好不好用?
Q:我怎么记得春哥在 Google Groups 里,多次提到 ab 是当前最佳测试工具呢?
A:文章中我也提到过了,单从工具特性来说,ab 并不是一个好的性能测试工具。因为它不能够产生足够大的请求压力,而现在的服务端程序性能却已经非常强悍了。我们在 test::nginx 中确实会用到 ab,而不是 wrk,这是因为在 TEST_NGINX_BENCHMARK 模式下,test::nginx 会根据 HTTP 协议版本,选择使用 ab 或者 weighttp ,来作为压力测试的工具。
另外,希望你注意到的是,互联网技术的更新换代非常快,我们身在其中的每个人,都需要及时更新自己的知识和技能数。比如说test::nginx 的这个选择,在我看来现在已经需要更新了,而春哥当时可能还不知道 wrk 的存在。当然,也许再过一段时间,会有比 wrk 更好的性能测试工具出现,我们自然也应该抱着积极开放的心态去学习和选择。


浙公网安备 33010602011771号