原理
回顧一下OpenResty的架構圖
OpenResty 的 master 和 worker 進程中,都包含一個 LuaJIT VM。在同一個進程內的所有協程,都會共享這個 VM,並在這個 VM 中運行 Lua 代碼。
在同一個時間點上,每個 worker 進程只能處理一個用戶的請求,也就是只有一個協程在運行。
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。
如果 Lua 代碼中沒有 I/O 或者 sleep 操作,比如全是密集的加解密運算,那么 Lua 協程就 會一直占用 LuaJIT VM,直到處理完整個請求。
以ngx.sleep 作為示例,代碼位於 ngx_http_lua_sleep.c 中,可以在 lua-nginx-module 項目的 src 目錄中找到。
void ngx_http_lua_inject_sleep_api(lua_State *L) { lua_pushcfunction(L, ngx_http_lua_ngx_sleep); lua_setfield(L, -2, "sleep"); }
下面便是 sleep 的主函數,這里只摘取了幾行主要的代碼:
static int ngx_http_lua_ngx_sleep(lua_State *L) { coctx->sleep.handler = ngx_http_lua_sleep_handler; ngx_add_timer(&coctx->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 協程。
基本概念
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 階段,如果在它不支持的 log 階段使用 sleep 的話,在 NGINX 的錯誤日志中,就會出現 error 級別的提示:
location / { log_by_lua_block { ngx.sleep(1) } }
由 OpenResty 提供的所有 API,都是非阻塞的。以 sleep 1 秒這個需求為例來說明,如果要在 Lua 中實現它,需要這樣做:
function sleep(s) local ntime = os.time() + s repeat until os.time() > ntime end
因為標准 Lua 沒有直接的 sleep 函數,所以這里我用一個循環,來不停地判斷是否達到指定的時間。這個實現就是阻塞的,在 sleep 的這一秒鍾時間內,Lua 正在做無用功,而其他需要處理的請求,只能在一邊等待。
不過,要是換成 ngx.sleep(1) 來實現的話,根據上面分析過的源碼,在這一秒鍾的時間內, OpenResty 依然可以去處理其他請求(比如 B 請求),當前請求(我們叫它 A 請求)的上下文會被保存起 來,並由 NGINX 的事件機制來喚醒,再回到 A 請求,這樣 CPU 就一直處於真正的工作狀態。
變量和生命周期
在 OpenResty 中,推薦把所有變量都聲明為局部變量,並用 luacheck 和 lua-releng 這樣的工具來檢測全局變量。這其實對於模塊來說也是一樣的,比如下面這樣的寫法:
local ngx_re = require "ngx.re"
在 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 "hello" ngx.say(hello.color.green) } }
這段配置會在 content 階段中 require 這個模塊,並把 green 的值作為 http 請求返回體打印出來。
在同一 worker 進程中,模塊只會被加載一次;之后這個 worker 處理的所有請求,就可以共享模塊中的數據了。“全局”的數據很適合封裝在模塊內,是因為 OpenResty 的 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) } }
ngx.ctx 也有自己的局限性:
- 使用 ngx.location.capture 創建的子請求,會有自己獨立的 ngx.ctx 數據,和父請求的 ngx.ctx 互不影響;
- 使用 ngx.exec 創建的內部重定向,會銷毀原始請求的 ngx.ctx,重新生成空白的 ngx.ctx。
動態處理請求和響應
雖然 OpenResty 是基於 NGINX 的 Web 服務器,但它與 NGINX 卻有本質的不同:NGINX 由靜態的配置文 件驅動,而 OpenResty 是由 Lua API 驅動的,所以能提供更多的靈活性和可編程性。
API 分類
OpenResty 的 API 主要分為下面幾個大類:
處理請求和響應;
SSL 相關;
shared dict;
cosocket;
處理四層流量;
process 和 worker;
獲取 NGINX 變量和配置;
字符串、時間、編解碼等通用功能。
OpenResty 的 API 不僅僅存在於 nginx-lua-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 。
對於不在 nginx-lua-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) > ' cd
這可能會給你帶來一個困惑:在 nginx-lua-module 項目中,明明有 ngx.re.sub、ngx.re.find 等好幾 個 ngx.re 開頭的 API,為什么單單是 ngx.re.split 這個 API ,需要 require 后才能使用呢?
OpenResty 新的 API 都是通過 FFI 的方式在 luarety-core 倉庫中實現的,所以難免就會存在這種割裂感。
請求
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 來返回同樣的值。
既然可以通過ngx.var.* 這種返回變量值的方法,來得到請求行中的數據,為什么 OpenResty 還要單獨提供針對請求行的 API 呢?
- 首先是對性能的考慮。ngx.var 的效率不高,不建議反復讀取;
- ngx.var 返回的是字符串,而非 Lua 對象,遇到獲取 args 這種可能返回多個值 的情況,就不好處理了;
- 絕大部分的 ngx.var 是只讀的,只有很少數的變量是可寫的,比如 $args 和 limit_rate,可很多時候,我們會有修改 method、URI 和 args 的需求。
OpenResty 提供了多個專門操作請求行的 API,它們可以對請求行進行改寫,以便后續的重定向等 操作。
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)
$ resty -e 'print(ngx.HTTP_POST)'
在改寫請求行的方法中,還有 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")
請求頭
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,分別接受字符串和本地磁盤文件做為輸入參數,來完成請求體的改寫。不過,這類操作並不常見,
響應
處理完請求后,我們就需要發送響應返回給客戶端了。和請求報文一樣,響應報文也由幾個部分組成,即狀 態行、響應頭和響應體。
狀態行
狀態行中,我們主要關注的是狀態碼。在默認情況下,返回的 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_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 函數去處理。