谈谈 OpenResty 中的 body_filter_by_lua*,


结论:

多次调用的 body_filter_by_lua*

正如 OpenResty 文档中指出,body_filter_by_lua* 可能会在一次请求中多次调用。

Nginx output filters may be called multiple times for a single request because response body may be delivered in chunks.
Thus, the Lua code specified by in this directive may also run multiple times in the lifetime of a single HTTP request.

文档中举了个例子:

location /t {
  echo hello world;
  echo hiya globe;

body_filter_by_lua* 首次调用时,ngx.arg[1] 的值只是 hello world,不包括下面的 hiya globe

不过初看下来,很难将echo hello world;delivered in chunks 联系起来。这个 chunk 的大小是怎么确定的?看例子,应该跟echo/ngx.say 这一类输出方式有关。但是会不会跟输出的大小也有关?如果我一次性 ngx.say 了很多内容,是否会分成多个 chunks 发送?如果响应来自上游服务器,chunks 的数目又怎么定?

要回答这个问题,需要看看 Nginx 内响应内容的组织方式。Nginx 上游产生的内容,存储为 ngx_chain_t 类型的数据。这其实是一条 ngx_buf_t 链表。很容易可以想像到,这个链表就代表着数据流。上游产生的内容,像流水线上的包裹一样,不停地向下游传递。output filter 阶段像流水线上的机器,处理这些“包裹”。跟流水线上的机器不同的是,Nginx 中的 output filter 并非逐个处理这些“包裹”,而是一批一批地处理。上游成批成批地生产出这些包裹,每批包裹构成 ngx_chain_t 的子串,而 output fiter 则遍历这一子串,把其中的每个包裹打开处理。

想到 body_filter_by_lua* 其实属于 output filter 的一种,我们就回到了一开始讨论的问题。既然 body_filter_by_lua* 是一批一批处理上游的响应,那么它的调用次数就取决于上游的响应次数。上游的一次响应,如一次 ngx.say,会产生一个 ngx_chain_t 的子串(就ngx.say 而言,这个子串仅包含单个 ngx_buf_t)。至于响应的大小,最多只会影响到子串的长短,具体情况则取决于具体实现。

以我们常用的 ngx.say 为例:

ngx.say('This ', 'will ', 'be ', 'in ', 'a ', 'buffer')

以上几个字符串会通过栈从 lua 域传递给 C 域。接着 OpenResty 计算它们的总长度,从 buffer chain 中找出一个空闲的大小合适的 ngx_buf_t,把它们拷贝进来。 之后就走 http_output_filter 把这个 ngx_buf_t (准确来说,是它所在的链表)发送出去。

那么,上游什么时候会把数据发完了?Nginx 采用了一个 last_buf 的标志位,如果某个ngx_buf_t 是链表中的最后一个,跟上游交互的模块会设置这一个标志位为1. 映射回 OpenResty 的 lua 域,则是 body_filter_by_lua* 中的 ngx.arg[2]。你可能会注意到,last_buf 是一个设置在 ngx_buf_t 上的标志位,而传递给 output filter 的是 ngx_chain_t。OpenResty 把这一差别隐藏在实现之下——它会遍历当前输入的子串,如果某个 ngx_buf_t 存在 last_buf,那么就返回 true。

出人意料的 body_filter_by_lua*

一个有趣的事实是,开头的例子中:

location /t {
  echo hello world;
  echo hiya globe;

尽管只有两个 echo,但是 body_filter_by_lua* 会被调用三次!第三次调用的时候,ngx.arg[1] 为空字符串,而 ngx.arg[2] 为 true。这是因为,Nginx 的 upstream 相关模块,以及 OpenResty 的 content_by_lua,会单独发送一个设置了 last_buf 的空 buffer,来表示流的结束。这算是一个约定俗成的惯例,所以有必要在运行相关逻辑之前,检查 ngx.arg[1] 是否为空。当然反过来不一定成立,ngx.arg[2] == true 并不代表 ngx.arg[1] 一定为空。

也许你已经发现了,子请求也会走到 body_filter_by_lua* 的流程。严格意义上,如果只希望 body_filter_by_lua* 修改响应给客户端的内容,需要额外用 ngx.is_subrequest 判断下:

if ngx.arg[1] and not ngx.is_subrequest then

毕竟,你只能设定一次 body_filter_by_lua*,同一套逻辑可能会被不同的 location 用到。

还有一个有趣的事实是,当代码运行到 body_filter_by_lua* 时,HTTP 报头(header)已经发送出去了。如果在之前设置了跟响应体相关的报头,而又在 body_filter_by_lua* 中修改了响应体,会导致响应报头和实际响应的不一致。举个简单的例子:假设上游的服务器返回了 Content-Length 报头,而 body_filter_by_lua* 又修改了响应体的实际大小。客户端收到这个报头后,按其中的 Content-Length 去处理,顺着一头栽进坑里。由于Nginx 的流式响应,发出去的报头就像泼出去的水,要想修改只能提前进行。OpenResty 提供了跟 body_filter_by_lua* 相对应的 header_filter_by_lua*header_filter 会在 Nginx 发送报头之前调用,所以可以在这里置空 Content-Length 报头:

header_filter_by_lua_block {
    ngx.header.content_length = nil
}

body_filter_by_lua_...

现在 Nginx 会代以 Transfer-Encoding: chunked,再也不会误导客户端了。同样可能需要处理的还有 accept-rangeetag等跟响应体相关的报头。

相关内容

    暂无相关文章