你好,我是温铭。经过前面内容的铺垫后, 相信你已经对 OpenResty 的概念和如何学习它有了基本的认识。今天这节课,我们来看一下 OpenResty 如何处理终端请求和响应。
虽然 OpenResty 是基于 NGINX 的 Web 服务器,但它与 NGINX 却有本质的不同:NGINX 由静态的配置文件驱动,而 OpenResty 是由 Lua API 驱动的,所以能提供更多的灵活性和可编程性。
下面,就让我来带你领略 Lua API 带来的好处吧。
首先我们要知道,OpenResty 的 API 主要分为下面几个大类:
这里,我建议你同时打开 OpenResty 的 Lua API 文档,对照着其中的 API 列表 ,看看是否能和这个分类联系起来。
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 "ngx.re"
local res, err = ngx_re.split("a,b,c,d", ",", 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。
那么问题就来了:既然可以通过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("a=3")
ngx.req.set_uri("/foo")
其实,如果你看过官方文档,就会发现 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 == "truncated" 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("Content-Type", "text/css")
ngx.req.clear_header("Content-Type")
当然,官方文档中也提到了其他方法来删除请求头,比如把 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
如果你想了解更多的状态码常量,可以从文档中查询到。
说到响应头,其实,你有两种方法来设置它。第一种是最简单的:
ngx.header.content_type = 'text/plain'
ngx.header["X-My-Header"] = 'blah blah'
ngx.header["X-My-Header"] = nil -- 删除
这里的 ngx.header 保存了响应头的信息,可以读取、修改和删除。
第二种设置响应头的方法是 ngx_resp.add_header
,来自 lua-resty-core 仓库,它可以增加一个头信息,用下面的方法来调用:
local ngx_resp = require "ngx.resp"
ngx_resp.add_header("Foo", "bar")
与第一种方法的不同之处在于,add header 不会覆盖已经存在的同名字段。
最后看下响应体,在 OpenResty 中,你可以使用 ngx.say
和 ngx.print
来输出响应体:
ngx.say('hello, world')
这两个 API 的功能是一致的,唯一的不同在于, ngx.say
会在最后多一个换行符。
为了避免字符串拼接的低效,ngx.say / ngx.print
不仅支持字符串作为参数,也支持数组格式:
$ resty -e 'ngx.say({"hello", ", ", "world"})'
hello, world
这样在 Lua 层面就跳过了字符串的拼接,把这个它不擅长的事情丢给了 C 函数去处理。
到此,让我们回顾下今天的内容。我们按照请求报文和响应报文的内容,依次介绍了与之相关的 OpenResty API。你可以看得出来,和 NGINX 的指令相比,OpenResty API更加灵活和强大。
那么,在你处理 HTTP 请求时,OpenResty 提供的 Lua API 是否足够满足你的需求呢?欢迎留言一起探讨,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。
评论