You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

248 lines
12 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 17 | 为什么能成为更好的Web服务器动态处理请求和响应是关键
你好,我是温铭。经过前面内容的铺垫后, 相信你已经对 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 "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](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
```
如果你想了解更多的状态码常量,可以从[文档](https://github.com/openresty/lua-nginx-module/#http-status-constants)中查询到。
### 响应头
说到响应头,其实,你有两种方法来设置它。第一种是最简单的:
```
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 是否足够满足你的需求呢?欢迎留言一起探讨,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。