From: http://agentzh.org/misc/nginx/agentzh-nginx-tutorials-zhcn.html#02-NginxDirectiveExecOrder11
agentzh 的 Nginx 教程(版本 2012.09.27)
目錄
- 緣起
- Nginx 教程的連載計划
- Nginx 變量漫談(一)
- Nginx 變量漫談(二)
- Nginx 變量漫談(三)
- Nginx 變量漫談(四)
- Nginx 變量漫談(五)
- Nginx 變量漫談(六)
- Nginx 變量漫談(七)
- Nginx 變量漫談(八)
- Nginx 配置指令的執行順序(一)
- Nginx 配置指令的執行順序(二)
- Nginx 配置指令的執行順序(三)
- Nginx 配置指令的執行順序(四)
- Nginx 配置指令的執行順序(五)
- Nginx 配置指令的執行順序(六)
- Nginx 配置指令的執行順序(七)
- Nginx 配置指令的執行順序(八)
- Nginx 配置指令的執行順序(九)
- Nginx 配置指令的執行順序(十)
- Nginx 配置指令的執行順序(十一)
緣起
其實這兩年為 Nginx 世界做了這么多的事情,一直想通過一系列教程性的文章把我的那些工作成果和所學所知都介紹給更多的朋友。現在終於下決心在新浪博客 http://blog.sina.com.cn/openresty 上面用中文寫點東西,每一篇東西都會有一個小主題,但次序和組織上就不那么講究了,畢竟並不是一本完整的圖書,或許未來我會將之整理出書也不一定。
我現在編寫的教程是按所謂的“系列”來划分的,比如首先連載的“Nginx 變量漫談”系列。每一個系列基本上都可以粗略對應到未來出的 Nginx 書中的一“章”(當然內部還會重新組織內容並划分出“節”來)。我面向的讀者是各個水平層次的 Nginx 用戶,同時也包括未使用過 Nginx 的 Apache、Lighttpd 等服務器的老用戶。
我只保證這些教程中的例子至少兼容到 Nginx 0.8.54,別用更老的版本來找我的錯處,我一概不管,畢竟眼下最新的穩定版已經是 1.0.10 了。
凡在教程里面提到的模塊,都是經過生產環境檢驗過的。即便是標准模塊,如果沒有達到生產標准,或者有重要的 bug,我也不會提及。
我在教程中會大量使用非標准的第三方模塊,如果你怕麻煩,不願自己一個一個從網上下載和安裝那些個模塊,我推薦你下載和安裝我維護的 ngx_openresty 這個軟件包:
教程里提及的模塊,包括足夠新的 Nginx 穩定版核心,都包含在了這個軟件包中。
我在這些教程中遵循的一個原則是,盡量通過短小精悍的實例來佐證我陳述的原理和觀點。我希望幫助讀者養成不隨便聽信別人現成的觀點和陳述,而通過自 己運行實例來驗證的好習慣。這種風格或許也和我在 QA 方面的背景有關。事實上我在寫作過程中也經常按我設計的小例子的實際運行結果,不斷地對我的理解以及教程的內容進行修正。
對於有問題的代碼示例,我們會有意在排版上讓它們和其他合法示例所有區別,即在問題示例的每一行代碼前添加問號字符,即(?),一個例子是:
? server {
? listen 8080;
?
? location /bad {
? echo $foo;
? }
? }
未經我的同意,請不要隨便轉載或者以其他方式使用這些教程。因為其中的每一句話,除了特別引用的“名句”,都是我自己的,我保留所有 的權利。我不希望讀者轉載的另一大原因在於:轉載后的拷貝版本是死的,我就不能再同步更新了。而我經常會按照讀者的反饋,對已發表的老文章進行了大面積的 修訂。
我歡迎讀者多提寶貴意見,特別是建設性的批評意見。類似“太爛了!”這樣無聊中傷的意見我看還是算了。
所有這些文章的源都已經放在 GitHub 網站上進行版本控制了:
http://github.com/agentzh/nginx-tutorials/
源文件都在此項目的 zh-cn/ 目錄下。我使用了一種自己設計的 Wiki 和 POD 標記語言的混合物來撰寫這些文章,就是那些 .tut 文件。歡迎建立分支和提供補丁。
本教程適用於普通手機、Kindle、iPad/iPhone、Sony 等電子閱讀器的 .html、.mobi、.epub 以及 .pdf 等格式的電子書文件可以從下面這個位置下載:
章亦春 (agentzh) 於福州家中
2011 年 11 月 30 日
Nginx 教程的連載計划
下面以教程系列為單位,列舉出了已經發表和計划發表的連載教程:
- Nginx 新手起步
- Nginx 是如何匹配 URI 的
- Nginx 變量漫談
- Nginx 配置指令的執行順序
- Nginx 的 if 是邪惡的
- Nginx 子請求
- Nginx 靜態文件服務
- Nginx 的日志服務
- 基於 Nginx 的應用網關
- 基於 Nginx 的反向代理
- Nginx 與 Memcached
- Nginx 與 Redis
- Nginx 與 MySQL
- Nginx 與 PostgreSQL
- 基於 Nginx 的應用緩存
- Nginx 中的安全與訪問控制
- 基於 Nginx 的 Web Service
- Nginx 驅動的 Web 2.0 應用
- 測試 Nginx 及其應用的性能
- 借助 Nginx 社區的力量
這些系列的名字和最終我的 Nginx 書中的“章”名可以粗略地對應上,但不會等同。同時未發表的系列的名字也可能發生變化,同時實際發表的順序也會和這里列出的順序不太一樣。
上面的列表會隨時更新,以保證總是反映了最新的計划和發表情況。
Nginx 變量漫談(一)
Nginx 的配置文件使用的就是一門微型的編程語言,許多真實世界里的 Nginx 配置文件其實就是一個一個的小程序。當然,是不是“圖靈完全的”暫且不論,至少據我觀察,它在設計上受 Perl 和 Bourne Shell 這兩種語言的影響很大。在這一點上,相比 Apache 和 Lighttpd 等其他 Web 服務器的配置記法,不能不說算是 Nginx 的一大特色了。既然是編程語言,一般也就少不了“變量”這種東西(當然,Haskell 這樣奇怪的函數式語言除外了)。
熟悉 Perl、Bourne Shell、C/C++ 等命令式編程語言的朋友肯定知道,變量說白了就是存放“值”的容器。而所謂“值”,在許多編程語言里,既可以是 3.14 這樣的數值,也可以是 hello world 這樣的字符串,甚至可以是像數組、哈希表這樣的復雜數據結構。然而,在 Nginx 配置中,變量只能存放一種類型的值,因為也只存在一種類型的值,那就是字符串。
比如我們的 nginx.conf 文件中有下面這一行配置:
set $a "hello world";
我們使用了標准 ngx_rewrite 模塊的 set 配置指令對變量 $a 進行了賦值操作。特別地,我們把字符串 hello world 賦給了它。
我們看到,Nginx 變量名前面有一個 $ 符號,這是記法上的要求。所有的 Nginx 變量在 Nginx 配置文件中引用時都須帶上 $ 前綴。這種表示方法和 Perl、PHP 這些語言是相似的。
雖然 $ 這樣的變量前綴修飾會讓正統的 Java 和 C# 程序員不舒服,但這種表示方法的好處也是顯而易見的,那就是可以直接把變量嵌入到字符串常量中以構造出新的字符串:
set $a hello;
set $b "$a, $a";
這里我們通過已有的 Nginx 變量 $a 的值,來構造變量 $b 的值,於是這兩條指令順序執行完之后,$a 的值是 hello,而 $b 的值則是 hello, hello. 這種技術在 Perl 世界里被稱為“變量插值”(variable interpolation),它讓專門的字符串拼接運算符變得不再那么必要。我們在這里也不妨采用此術語。
我們來看一個比較完整的配置示例:
server {
listen 8080;
location /test {
set $foo hello;
echo "foo: $foo";
}
}
這個例子省略了 nginx.conf 配置文件中最外圍的 http 配置塊以及 events 配置塊。使用 curl 這個 HTTP 客戶端在命令行上請求這個 /test 接口,我們可以得到
$ curl 'http://localhost:8080/test'
foo: hello
這里我們使用第三方 ngx_echo 模塊的 echo 配置指令將 $foo 變量的值作為當前請求的響應體輸出。
我們看到, echo 配置指令的參數也支持“變量插值”。不過,需要說明的是,並非所有的配置指令都支持“變量插值”。事實上,指令參數是否允許“變量插值”,取決於該指令的實現模塊。
如果我們想通過 echo 指令直接輸出含有“美元符”($)的字符串,那么有沒有辦法把特殊的 $ 字符給轉義掉呢?答案是否定的(至少到目前最新的 Nginx 穩定版 1.0.10)。不過幸運的是,我們可以繞過這個限制,比如通過不支持“變量插值”的模塊配置指令專門構造出取值為 $ 的 Nginx 變量,然后再在 echo 中使用這個變量。看下面這個例子:
geo $dollar {
default "$";
}
server {
listen 8080;
location /test {
echo "This is a dollar sign: $dollar";
}
}
測試結果如下:
$ curl 'http://localhost:8080/test'
This is a dollar sign: $
這里用到了標准模塊 ngx_geo 提供的配置指令 geo 來為變量 $dollar 賦予字符串 "$",這樣我們在下面需要使用美元符的地方,就直接引用我們的 $dollar 變量就可以了。其實 ngx_geo 模塊最常規的用法是根據客戶端的 IP 地址對指定的 Nginx 變量進行賦值,這里只是借用它以便“無條件地”對我們的 $dollar 變量賦予“美元符”這個值。
在“變量插值”的上下文中,還有一種特殊情況,即當引用的變量名之后緊跟着變量名的構成字符時(比如后跟字母、數字以及下划線),我們就需要使用特別的記法來消除歧義,例如:
server {
listen 8080;
location /test {
set $first "hello ";
echo "${first}world";
}
}
這里,我們在 echo 配置指令的參數值中引用變量 $first 的時候,后面緊跟着 world 這個單詞,所以如果直接寫作 "$firstworld" 則 Nginx “變量插值”計算引擎會將之識別為引用了變量 $firstworld. 為了解決這個難題,Nginx 的字符串記法支持使用花括號在 $ 之后把變量名圍起來,比如這里的 ${first}. 上面這個例子的輸出是:
$ curl 'http://localhost:8080/test
hello world
set 指令(以及前面提到的 geo 指令)不僅有賦值的功能,它還有創建 Nginx 變量的副作用,即當作為賦值對象的變量尚不存在時,它會自動創建該變量。比如在上面這個例子中,如果 $a 這個變量尚未創建,則 set 指令會自動創建 $a 這個用戶變量。如果我們不創建就直接使用它的值,則會報錯。例如
? server {
? listen 8080;
?
? location /bad {
? echo $foo;
? }
? }
此時 Nginx 服務器會拒絕加載配置:
[emerg] unknown "foo" variable
是的,我們甚至都無法啟動服務!
有趣的是,Nginx 變量的創建和賦值操作發生在全然不同的時間階段。Nginx 變量的創建只能發生在 Nginx 配置加載的時候,或者說 Nginx 啟動的時候;而賦值操作則只會發生在請求實際處理的時候。這意味着不創建而直接使用變量會導致啟動失敗,同時也意味着我們無法在請求處理時動態地創建新的 Nginx 變量。
Nginx 變量一旦創建,其變量名的可見范圍就是整個 Nginx 配置,甚至可以跨越不同虛擬主機的 server 配置塊。我們來看一個例子:
server {
listen 8080;
location /foo {
echo "foo = [$foo]";
}
location /bar {
set $foo 32;
echo "foo = [$foo]";
}
}
這里我們在 location /bar 中用 set 指令創建了變量 $foo,於是在整個配置文件中這個變量都是可見的,因此我們可以在 location /foo 中直接引用這個變量而不用擔心 Nginx 會報錯。
下面是在命令行上用 curl 工具訪問這兩個接口的結果:
$ curl 'http://localhost:8080/foo'
foo = []
$ curl 'http://localhost:8080/bar'
foo = [32]
$ curl 'http://localhost:8080/foo'
foo = []
從這個例子我們可以看到,set 指令因為是在 location /bar 中使用的,所以賦值操作只會在訪問 /bar 的請求中執行。而請求 /foo 接口時,我們總是得到空的 $foo 值,因為用戶變量未賦值就輸出的話,得到的便是空字符串。
從這個例子我們可以窺見的另一個重要特性是,Nginx 變量名的可見范圍雖然是整個配置,但每個請求都有所有變量的獨立副本,或者說都有各變量用來存放值的容器的獨立副本,彼此互不干擾。比如前面我們請求了 /bar 接口后,$foo 變量被賦予了值 32,但它絲毫不會影響后續對 /foo 接口的請求所對應的 $foo 值(它仍然是空的!),因為各個請求都有自己獨立的 $foo 變量的副本。
對於 Nginx 新手來說,最常見的錯誤之一,就是將 Nginx 變量理解成某種在請求之間全局共享的東西,或者說“全局變量”。而事實上,Nginx 變量的生命期是不可能跨越請求邊界的。
Nginx 變量漫談(二)
關於 Nginx 變量的另一個常見誤區是認為變量容器的生命期,是與 location 配置塊綁定的。其實不然。我們來看一個涉及“內部跳轉”的例子:
server {
listen 8080;
location /foo {
set $a hello;
echo_exec /bar;
}
location /bar {
echo "a = [$a]";
}
}
這里我們在 location /foo 中,使用第三方模塊 ngx_echo 提供的 echo_exec 配置指令,發起到 location /bar 的“內部跳轉”。所謂“內部跳轉”,就是在處理請求的過程中,於服務器內部,從一個 location 跳轉到另一個 location 的過程。這不同於利用 HTTP 狀態碼 301 和 302 所進行的“外部跳轉”,因為后者是由 HTTP 客戶端配合進行跳轉的,而且在客戶端,用戶可以通過瀏覽器地址欄這樣的界面,看到請求的 URL 地址發生了變化。內部跳轉和 Bourne Shell(或 Bash)中的 exec 命令很像,都是“有去無回”。另一個相近的例子是 C 語言中的 goto 語句。
既然是內部跳轉,當前正在處理的請求就還是原來那個,只是當前的 location 發生了變化,所以還是原來的那一套 Nginx 變量的容器副本。對應到上例,如果我們請求的是 /foo 這個接口,那么整個工作流程是這樣的:先在 location /foo 中通過 set 指令將 $a 變量的值賦為字符串 hello,然后通過 echo_exec 指令發起內部跳轉,又進入到 location /bar 中,再輸出 $a 變量的值。因為 $a 還是原來的 $a,所以我們可以期望得到 hello 這行輸出。測試證實了這一點:
$ curl localhost:8080/foo
a = [hello]
但如果我們從客戶端直接訪問 /bar 接口,就會得到空的 $a 變量的值,因為它依賴於 location /foo 來對 $a 進行初始化。
從上面這個例子我們看到,一個請求在其處理過程中,即使經歷多個不同的 location 配置塊,它使用的還是同一套 Nginx 變量的副本。這里,我們也首次涉及到了“內部跳轉”這個概念。值得一提的是,標准 ngx_rewrite 模塊的 rewrite 配置指令其實也可以發起“內部跳轉”,例如上面那個例子用 rewrite 配置指令可以改寫成下面這樣的形式:
server {
listen 8080;
location /foo {
set $a hello;
rewrite ^ /bar;
}
location /bar {
echo "a = [$a]";
}
}
其效果和使用 echo_exec 是完全相同的。后面我們還會專門介紹這個 rewrite 指令的更多用法,比如發起 301 和 302 這樣的“外部跳轉”。
從上面這個例子我們看到,Nginx 變量值容器的生命期是與當前正在處理的請求綁定的,而與 location 無關。
前面我們接觸到的都是通過 set 指令隱式創建的 Nginx 變量。這些變量我們一般稱為“用戶自定義變量”,或者更簡單一些,“用戶變量”。既然有“用戶自定義變量”,自然也就有由 Nginx 核心和各個 Nginx 模塊提供的“預定義變量”,或者說“內建變量”(builtin variables)。
Nginx 內建變量最常見的用途就是獲取關於請求或響應的各種信息。例如由 ngx_http_core 模塊提供的內建變量 $uri,可以用來獲取當前請求的 URI(經過解碼,並且不含請求參數),而 $request_uri 則用來獲取請求最原始的 URI (未經解碼,並且包含請求參數)。請看下面這個例子:
location /test {
echo "uri = $uri";
echo "request_uri = $request_uri";
}
這里為了簡單起見,連 server 配置塊也省略了,和前面所有示例一樣,我們監聽的依然是 8080 端口。在這個例子里,我們把 $uri 和 $request_uri 的值輸出到響應體中去。下面我們用不同的請求來測試一下這個 /test 接口:
$ curl 'http://localhost:8080/test'
uri = /test
request_uri = /test
$ curl 'http://localhost:8080/test?a=3&b=4'
uri = /test
request_uri = /test?a=3&b=4
$ curl 'http://localhost:8080/test/hello%20world?a=3&b=4'
uri = /test/hello world
request_uri = /test/hello%20world?a=3&b=4
另一個特別常用的內建變量其實並不是單獨一個變量,而是有無限多變種的一群變量,即名字以 arg_ 開頭的所有變量,我們估且稱之為 $arg_XXX 變量群。一個例子是 $arg_name,這個變量的值是當前請求名為 name 的 URI 參數的值,而且還是未解碼的原始形式的值。我們來看一個比較完整的示例:
location /test {
echo "name: $arg_name";
echo "class: $arg_class";
}
然后在命令行上使用各種參數組合去請求這個 /test 接口:
$ curl 'http://localhost:8080/test'
name:
class:
$ curl 'http://localhost:8080/test?name=Tom&class=3'
name: Tom
class: 3
$ curl 'http://localhost:8080/test?name=hello%20world&class=9'
name: hello%20world
class: 9
其實 $arg_name 不僅可以匹配 name 參數,也可以匹配 NAME 參數,抑或是 Name,等等:
$ curl 'http://localhost:8080/test?NAME=Marry'
name: Marry
class:
$ curl 'http://localhost:8080/test?Name=Jimmy'
name: Jimmy
class:
Nginx 會在匹配參數名之前,自動把原始請求中的參數名調整為全部小寫的形式。
如果你想對 URI 參數值中的 %XX 這樣的編碼序列進行解碼,可以使用第三方 ngx_set_misc 模塊提供的 set_unescape_uri 配置指令:
location /test {
set_unescape_uri $name $arg_name;
set_unescape_uri $class $arg_class;
echo "name: $name";
echo "class: $class";
}
現在我們再看一下效果:
$ curl 'http://localhost:8080/test?name=hello%20world&class=9'
name: hello world
class: 9
空格果然被解碼出來了!
從這個例子我們同時可以看到,這個 set_unescape_uri 指令也像 set 指令那樣,擁有自動創建 Nginx 變量的功能。后面我們還會專門介紹到 ngx_set_misc 模塊。
像 $arg_XXX 這種類型的變量擁有無窮無盡種可能的名字,所以它們並不對應任何存放值的容器。而且這種變量在 Nginx 核心中是經過特別處理的,第三方 Nginx 模塊是不能提供這樣充滿魔法的內建變量的。
類似 $arg_XXX 的內建變量還有不少,比如用來取 cookie 值的 $cookie_XXX 變量群,用來取請求頭的 $http_XXX 變量群,以及用來取響應頭的 $sent_http_XXX 變量群。這里就不一一介紹了,感興趣的讀者可以參考 ngx_http_core 模塊的官方文檔。
需要指出的是,許多內建變量都是只讀的,比如我們剛才介紹的 $uri 和 $request_uri. 對只讀變量進行賦值是應當絕對避免的,因為會有意想不到的后果,比如:
? location /bad {
? set $uri /blah;
? echo $uri;
? }
這個有問題的配置會讓 Nginx 在啟動的時候報出一條令人匪夷所思的錯誤:
[emerg] the duplicate "uri" variable in ...
如果你嘗試改寫另外一些只讀的內建變量,比如 $arg_XXX 變量,在某些 Nginx 的版本中甚至可能導致進程崩潰。
Nginx 變量漫談(三)
也有一些內建變量是支持改寫的,其中一個例子是 $args. 這個變量在讀取時返回當前請求的 URL 參數串(即請求 URL 中問號后面的部分,如果有的話),而在賦值時可以直接修改參數串。我們來看一個例子:
location /test {
set $orig_args $args;
set $args "a=3&b=4";
echo "original args: $orig_args";
echo "args: $args";
}
這里我們把原始的 URL 參數串先保存在 $orig_args 變量中,然后通過改寫 $args 變量來修改當前的 URL 參數串,最后我們用 echo 指令分別輸出 $orig_args 和 $args 變量的值。接下來我們這樣來測試這個 /test 接口:
$ curl 'http://localhost:8080/test'
original args:
args: a=3&b=4
$ curl 'http://localhost:8080/test?a=0&b=1&c=2'
original args: a=0&b=1&c=2
args: a=3&b=4
在第一次測試中,我們沒有設置任何 URL 參數串,所以輸出 $orig_args 變量的值時便得到空。而在第一次和第二次測試中,無論我們是否提供 URL 參數串,參數串都會在 location /test 中被強行改寫成 a=3&b=4.
需要特別指出的是,這里的 $args 變量和 $arg_XXX 一樣,也不再使用屬於自己的存放值的容器。當我們讀取 $args 時,Nginx 會執行一小段代碼,從 Nginx 核心中專門存放當前 URL 參數串的位置去讀取數據;而當我們改寫 $args 時,Nginx 會執行另一小段代碼,對相同位置進行改寫。Nginx 的其他部分在需要當前 URL 參數串的時候,都會從那個位置去讀數據,所以我們對 $args 的修改會影響到所有部分的功能。我們來看一個例子:
location /test {
set $orig_a $arg_a;
set $args "a=5";
echo "original a: $orig_a";
echo "a: $arg_a";
}
這里我們先把內建變量 $arg_a 的值,即原始請求的 URL 參數 a 的值,保存在用戶變量 $orig_a 中,然后通過對內建變量 $args 進行賦值,把當前請求的參數串改寫為 a=5 ,最后再用 echo 指令分別輸出 $orig_a 和 $arg_a 變量的值。因為對內建變量 $args 的修改會直接導致當前請求的 URL 參數串發生變化,因此內建變量 $arg_XXX 自然也會隨之變化。測試的結果證實了這一點:
$ curl 'http://localhost:8080/test?a=3'
original a: 3
a: 5
我們看到,因為原始請求的 URL 參數串是 a=3, 所以 $arg_a 最初的值為 3, 但隨后通過改寫 $args 變量,將 URL 參數串又強行修改為 a=5, 所以最終 $arg_a 的值又自動變為了 5.
我們再來看一個通過修改 $args 變量影響標准的 HTTP 代理模塊 ngx_proxy 的例子:
server {
listen 8080;
location /test {
set $args "foo=1&bar=2";
proxy_pass http://127.0.0.1:8081/args;
}
}
server {
listen 8081;
location /args {
echo "args: $args";
}
}
這里我們在 http 配置塊中定義了兩個虛擬主機。第一個虛擬主機監聽 8080 端口,其 /test 接口自己通過改寫 $args 變量,將當前請求的 URL 參數串無條件地修改為 foo=1&bar=2. 然后 /test 接口再通過 ngx_proxy 模塊的 proxy_pass 指令配置了一個反向代理,指向本機的 8081 端口上的 HTTP 服務 /args. 默認情況下, ngx_proxy 模塊在轉發 HTTP 請求到遠方 HTTP 服務的時候,會自動把當前請求的 URL 參數串也轉發到遠方。
而本機的 8081 端口上的 HTTP 服務正是由我們定義的第二個虛擬主機來提供的。我們在第二個虛擬主機的 location /args 中利用 echo 指令輸出當前請求的 URL 參數串,以檢查 /test 接口通過 ngx_proxy 模塊實際轉發過來的 URL 請求參數串。
我們來實際訪問一下第一個虛擬主機的 /test 接口:
$ curl 'http://localhost:8080/test?blah=7'
args: foo=1&bar=2
我們看到,雖然請求自己提供了 URL 參數串 blah=7,但在 location /test 中,參數串被強行改寫成了 foo=1&bar=2. 接着經由 proxy_pass 指令將我們被改寫掉的參數串轉發給了第二個虛擬主機上配置的 /args 接口,然后再把 /args 接口的 URL 參數串輸出。事實證明,我們對 $args 變量的賦值操作,也成功影響到了 ngx_proxy 模塊的行為。
在讀取變量時執行的這段特殊代碼,在 Nginx 中被稱為“取處理程序”(get handler);而改寫變量時執行的這段特殊代碼,則被稱為“存處理程序”(set handler)。不同的 Nginx 模塊一般會為它們的變量准備不同的“存取處理程序”,從而讓這些變量的行為充滿魔法。
其實這種技巧在計算世界並不鮮見。比如在面向對象編程中,類的設計者一般不會把類的成員變量直接暴露給類的用戶,而是另行提供兩個方法 (method),分別用於該成員變量的讀操作和寫操作,這兩個方法常常被稱為“存取器”(accessor)。下面是 C++ 語言中的一個例子:
#include <string>
using namespace std;
class Person {
public:
const string get_name() {
return m_name;
}
void set_name(const string name) {
m_name = name;
}
private:
string m_name;
};
在這個名叫 Person 的 C++ 類中,我們提供了 get_name 和 set_name 這兩個公共方法,以作為私有成員變量 m_name 的“存取器”。
這樣設計的好處是顯而易見的。類的設計者可以在“存取器”中執行任意代碼,以實現所需的業務邏輯以及“副作用”,比如自動更新與當前成員變量存在依 賴關系的其他成員變量,抑或是直接修改某個與當前對象相關聯的數據庫表中的對應字段。而對於后一種情況,也許“存取器”所對應的成員變量壓根就不存在,或 者即使存在,也頂多扮演着數據緩存的角色,以緩解被代理數據庫的訪問壓力。
與面向對象編程中的“存取器”概念相對應,Nginx 變量也是支持綁定“存取處理程序”的。Nginx 模塊在創建變量時,可以選擇是否為變量分配存放值的容器,以及是否自己提供與讀寫操作相對應的“存取處理程序”。
不是所有的 Nginx 變量都擁有存放值的容器。擁有值容器的變量在 Nginx 核心中被稱為“被索引的”(indexed);反之,則被稱為“未索引的”(non-indexed)。
我們前面在 (二) 中已經知道,像 $arg_XXX 這樣具有無數變種的變量群,是“未索引的”。當讀取這樣的變量時,其實是它的“取處理程序”在起作用,即實時掃描當前請求的 URL 參數串,提取出變量名所指定的 URL 參數的值。很多新手都會對 $arg_XXX 的實現方式產生誤解,以為 Nginx 會事先解析好當前請求的所有 URL 參數,並且把相關的 $arg_XXX 變量的值都事先設置好。然而事實並非如此,Nginx 根本不會事先就解析好 URL 參數串,而是在用戶讀取某個 $arg_XXX 變量時,調用其“取處理程序”,即時去掃描 URL 參數串。類似地,內建變量 $cookie_XXX 也是通過它的“取處理程序”,即時去掃描 Cookie 請求頭中的相關定義的。
Nginx 變量漫談(四)
在設置了“取處理程序”的情況下,Nginx 變量也可以選擇將其值容器用作緩存,這樣在多次讀取變量的時候,就只需要調用“取處理程序”計算一次。我們下面就來看一個這樣的例子:
map $args $foo {
default 0;
debug 1;
}
server {
listen 8080;
location /test {
set $orig_foo $foo;
set $args debug;
echo "orginal foo: $orig_foo";
echo "foo: $foo";
}
}
這里首次用到了標准 ngx_map 模塊的 map 配置指令,我們有必要在此介紹一下。map 在英文中除了“地圖”之外,也有“映射”的意思。比方說,中學數學里講的“函數”就是一種“映射”。而 Nginx 的這個 map 指令就可以用於定義兩個 Nginx 變量之間的映射關系,或者說是函數關系。回到上面這個例子,我們用 map 指令定義了用戶變量 $foo 與 $args 內建變量之間的映射關系。特別地,用數學上的函數記法 y = f(x) 來說,我們的 $args 就是“自變量” x,而 $foo 則是“因變量” y,即 $foo 的值是由 $args 的值來決定的,或者按照書寫順序可以說,我們將 $args 變量的值映射到了 $foo 變量上。
現在我們再來看 map 指令定義的映射規則:
map $args $foo {
default 0;
debug 1;
}
花括號中第一行的 default 是一個特殊的匹配條件,即當其他條件都不匹配的時候,這個條件才匹配。當這個默認條件匹配時,就把“因變量” $foo 映射到值 0. 而花括號中第二行的意思是說,如果“自變量” $args 精確匹配了 debug 這個字符串,則把“因變量” $foo 映射到值 1. 將這兩行合起來,我們就得到如下完整的映射規則:當 $args 的值等於 debug 的時候,$foo 變量的值就是 1,否則 $foo 的值就為 0.
明白了 map 指令的含義,再來看 location /test. 在那里,我們先把當前 $foo 變量的值保存在另一個用戶變量 $orig_foo 中,然后再強行把 $args 的值改寫為 debug,最后我們再用 echo 指令分別輸出 $orig_foo 和 $foo 的值。
從邏輯上看,似乎當我們強行改寫 $args 的值為 debug 之后,根據先前的 map 映射規則,$foo 變量此時的值應當自動調整為字符串 1, 而不論 $foo 原先的值是怎樣的。然而測試結果並非如此:
$ curl 'http://localhost:8080/test'
original foo: 0
foo: 0
第一行輸出指示 $orig_foo 的值為 0,這正是我們期望的:上面這個請求並沒有提供 URL 參數串,於是 $args 最初的取值就是空,再根據我們先前定義的映射規則,$foo 變量在第一次被讀取時的值就應當是 0(即匹配默認的那個 default 條件)。
而第二行輸出顯示,在強行改寫 $args 變量的值為字符串 debug 之后,$foo 的條件仍然是 0 ,這顯然不符合映射規則,因為當 $args 為 debug 時,$foo 的值應當是 1. 這究竟是為什么呢?
其實原因很簡單,那就是 $foo 變量在第一次讀取時,根據映射規則計算出的值被緩存住了。剛才我們說過,Nginx 模塊可以為其創建的變量選擇使用值容器,作為其“取處理程序”計算結果的緩存。顯然, ngx_map 模塊認為變量間的映射計算足夠昂貴,需要自動將因變量的計算結果緩存下來,這樣在當前請求的處理過程中如果再次讀取這個因變量,Nginx 就可以直接返回緩存住的結果,而不再調用該變量的“取處理程序”再行計算了。
為了進一步驗證這一點,我們不妨在請求中直接指定 URL 參數串為 debug:
$ curl 'http://localhost:8080/test?debug'
original foo: 1
foo: 1
我們看到,現在 $orig_foo 的值就成了 1,因為變量 $foo 在第一次被讀取時,自變量 $args 的值就是 debug,於是按照映射規則,“取處理程序”計算返回的值便是 1. 而后續再讀取 $foo 的值時,就總是得到被緩存住的 1 這個結果,而不論 $args 后來變成什么樣了。
map 指令其實是一個比較特殊的例子,因為它可以為用戶變量注冊“取處理程序”,而且用戶可以自己定義這個“取處理程序”的計算規則。當然,此規則在這里被限定為與另一個變量的映射關系。同時,也並非所有使用了“取處理程序”的變量都會緩存結果,例如我們前面在 (三) 中已經看到 $arg_XXX 並不會使用值容器進行緩存。
類似 ngx_map 模塊,標准的 ngx_geo 等模塊也一樣使用了變量值的緩存機制。
在上面的例子中,我們還應當注意到 map 指令是在 server 配置塊之外,也就是在最外圍的 http 配置塊中定義的。很多讀者可能會對此感到奇怪,畢竟我們只是在 location /test 中用到了它。這倒不是因為我們不想把 map 語句直接挪到 location 配置塊中,而是因為 map 指令只能在 http 塊中使用!
很多 Nginx 新手都會擔心如此“全局”范圍的 map 設置會讓訪問所有虛擬主機的所有 location 接口的請求都執行一遍變量值的映射計算,然而事實並非如此。前面我們已經了解到 map 配置指令的工作原理是為用戶變量注冊 “取處理程序”,並且實際的映射計算是在“取處理程序”中完成的,而“取處理程序”只有在該用戶變量被實際讀取時才會執行(當然,因為緩存的存在,只在請 求生命期中的第一次讀取中才被執行),所以對於那些根本沒有用到相關變量的請求來說,就根本不會執行任何的無用計算。
這種只在實際使用對象時才計算對象值的技術,在計算領域被稱為“惰性求值”(lazy evaluation)。提供“惰性求值” 語義的編程語言並不多見,最經典的例子便是 Haskell. 與之相對的便是“主動求值” (eager evaluation)。我們有幸在 Nginx 中也看到了“惰性求值”的例子,但“主動求值”語義其實在 Nginx 里面更為常見,例如下面這行再普通不過的 set 語句:
set $b "$a,$a";
這里會在執行 set 規定的賦值操作時,“主動”地計算出變量 $b 的值,而不會將該求值計算延緩到變量 $b 實際被讀取的時候。
Nginx 變量漫談(五)
前面在 (二) 中我們已經了解到變量值容器的生命期是與請求綁定的,但是我當時有意避開了“請求”的正式定義。大家應當一直默認這里的“請求”都是指客戶端發起的 HTTP 請求。其實在 Nginx 世界里有兩種類型的“請求”,一種叫做“主請求”(main request),而另一種則叫做“子請求”(subrequest)。我們先來介紹一下它們。
所謂“主請求”,就是由 HTTP 客戶端從 Nginx 外部發起的請求。我們前面見到的所有例子都只涉及到“主請求”,包括 (二) 中那兩個使用 echo_exec 和 rewrite 指令發起“內部跳轉”的例子。
而“子請求”則是由 Nginx 正在處理的請求在 Nginx 內部發起的一種級聯請求。“子請求”在外觀上很像 HTTP 請求,但實現上卻和 HTTP 協議乃至網絡通信一點兒關系都沒有。它是 Nginx 內部的一種抽象調用,目的是為了方便用戶把“主請求”的任務分解為多個較小粒度的“內部請求”,並發或串行地訪問多個 location 接口,然后由這些 location 接口通力協作,共同完成整個“主請求”。當然,“子請求”的概念是相對的,任何一個“子請求”也可以再發起更多的“子子請求”,甚至可以玩遞歸調用(即自 己調用自己)。當一個請求發起一個“子請求”的時候,按照 Nginx 的術語,習慣把前者稱為后者的“父請求”(parent request)。值得一提的是,Apache 服務器中其實也有“子請求”的概念,所以來自 Apache 世界的讀者對此應當不會感到陌生。
下面就來看一個使用了“子請求”的例子:
location /main {
echo_location /foo;
echo_location /bar;
}
location /foo {
echo foo;
}
location /bar {
echo bar;
}
這里在 location /main 中,通過第三方 ngx_echo 模塊的 echo_location 指令分別發起到 /foo 和 /bar 這兩個接口的 GET 類型的“子請求”。由 echo_location 發起的“子請求”,其執行是按照配置書寫的順序串行處理的,即只有當 /foo 請求處理完畢之后,才會接着處理 /bar 請求。這兩個“子請求”的輸出會按執行順序拼接起來,作為 /main 接口的最終輸出:
$ curl 'http://localhost:8080/main'
foo
bar
我們看到,“子請求”方式的通信是在同一個虛擬主機內部進行的,所以 Nginx 核心在實現“子請求”的時候,就只調用了若干個 C 函數,完全不涉及任何網絡或者 UNIX 套接字(socket)通信。我們由此可以看出“子請求”的執行效率是極高的。
回到先前對 Nginx 變量值容器的生命期的討論,我們現在依舊可以說,它們的生命期是與當前請求相關聯的。每個請求都有所有變量值容器的獨立副本,只不過當前請求既可以是“主 請求”,也可以是“子請求”。即便是父子請求之間,同名變量一般也不會相互干擾。讓我們來通過一個小實驗證明一下這個說法:
location /main {
set $var main;
echo_location /foo;
echo_location /bar;
echo "main: $var";
}
location /foo {
set $var foo;
echo "foo: $var";
}
location /bar {
set $var bar;
echo "bar: $var";
}
在這個例子中,我們分別在 /main,/foo 和 /bar 這三個 location 配置塊中為同一名字的變量,$var,分別設置了不同的值並予以輸出。特別地,我們在 /main 接口中,故意在調用過 /foo 和 /bar 這兩個“子請求”之后,再輸出它自己的 $var 變量的值。請求 /main 接口的結果是這樣的:
$ curl 'http://localhost:8080/main'
foo: foo
bar: bar
main: main
顯然,/foo 和 /bar 這兩個“子請求”在處理過程中對變量 $var 各自所做的修改都絲毫沒有影響到“主請求” /main. 於是這成功印證了“主請求”以及各個“子請求”都擁有不同的變量 $var 的值容器副本。
不幸的是,一些 Nginx 模塊發起的“子請求”卻會自動共享其“父請求”的變量值容器,比如第三方模塊 ngx_auth_request. 下面是一個例子:
location /main {
set $var main;
auth_request /sub;
echo "main: $var";
}
location /sub {
set $var sub;
echo "sub: $var";
}
這里我們在 /main 接口中先為 $var 變量賦初值 main,然后使用 ngx_auth_request 模塊提供的配置指令 auth_request,發起一個到 /sub 接口的“子請求”,最后利用 echo 指令輸出變量 $var 的值。而我們在 /sub 接口中則故意把 $var 變量的值改寫成 sub. 訪問 /main 接口的結果如下:
$ curl 'http://localhost:8080/main'
main: sub
我們看到,/sub 接口對 $var 變量值的修改影響到了主請求 /main. 所以 ngx_auth_request 模塊發起的“子請求”確實是與其“父請求”共享一套 Nginx 變量的值容器。
對於上面這個例子,相信有讀者會問:“為什么‘子請求’ /sub 的輸出沒有出現在最終的輸出里呢?”答案很簡單,那就是因為 auth_request 指令會自動忽略“子請求”的響應體,而只檢查“子請求”的響應狀態碼。當狀態碼是 2XX 的時候,auth_request 指令會忽略“子請求”而讓 Nginx 繼續處理當前的請求,否則它就會立即中斷當前(主)請求的執行,返回相應的出錯頁。在我們的例子中,/sub “子請求”只是使用 echo 指令作了一些輸出,所以隱式地返回了指示正常的 200 狀態碼。
如 ngx_auth_request 模塊這樣父子請求共享一套 Nginx 變量的行為,雖然可以讓父子請求之間的數據雙向傳遞變得極為容易,但是對於足夠復雜的配置,卻也經常導致不少難於調試的詭異 bug. 因為用戶時常不知道“父請求”的某個 Nginx 變量的值,其實已經在它的某個“子請求”中被意外修改了。諸如此類的因共享而導致的不好的“副作用”,讓包括 ngx_echo, ngx_lua,以及 ngx_srcache 在內的許多第三方模塊都選擇了禁用父子請求間的變量共享。
Nginx 變量漫談(六)
Nginx 內建變量用在“子請求”的上下文中時,其行為也會變得有些微妙。
前面在 (三) 中我們已經知道,許多內建變量都不是簡單的“存放值的容器”,它們一般會通過注冊“存取處理程序”來表現得與眾不同,而它們即使有存放值的容器,也只是用於緩存“存取處理程序”的計算結果。我們之前討論過的 $args 變量正是通過它的“取處理程序”來返回當前請求的 URL 參數串。因為當前請求也可以是“子請求”,所以在“子請求”中讀取 $args,其“取處理程序”會很自然地返回當前“子請求”的參數串。我們來看這樣的一個例子:
location /main {
echo "main args: $args";
echo_location /sub "a=1&b=2";
}
location /sub {
echo "sub args: $args";
}
這里在 /main 接口中,先用 echo 指令輸出當前請求的 $args 變量的值,接着再用 echo_location 指令發起子請求 /sub. 這里值得注意的是,我們在 echo_location 語句中除了通過第一個參數指定“子請求”的 URI 之外,還提供了第二個參數,用以指定該“子請求”的 URL 參數串(即 a=1&b=2)。最后我們定義了 /sub 接口,在里面輸出了一下 $args 的值。請求 /main 接口的結果如下:
$ curl 'http://localhost:8080/main?c=3'
main args: c=3
sub args: a=1&b=2
顯然,當 $args 用在“主請求” /main 中時,輸出的就是“主請求”的 URL 參數串,c=3;而當用在“子請求” /sub 中時,輸出的則是“子請求”的參數串,a=1&b=2。這種行為正符合我們的直覺。
與 $args 類似,內建變量 $uri 用在“子請求”中時,其“取處理程序”也會正確返回當前“子請求”解析過的 URI:
location /main {
echo "main uri: $uri";
echo_location /sub;
}
location /sub {
echo "sub uri: $uri";
}
請求 /main 的結果是
$ curl 'http://localhost:8080/main'
main uri: /main
sub uri: /sub
這依然是我們所期望的。
但不幸的是,並非所有的內建變量都作用於當前請求。少數內建變量只作用於“主請求”,比如由標准模塊 ngx_http_core 提供的內建變量 $request_method.
變量 $request_method 在讀取時,總是會得到“主請求”的請求方法,比如 GET、POST 之類。我們來測試一下:
location /main {
echo "main method: $request_method";
echo_location /sub;
}
location /sub {
echo "sub method: $request_method";
}
在這個例子里,/main 和 /sub 接口都會分別輸出 $request_method 的值。同時,我們在 /main 接口里利用 echo_location 指令發起一個到 /sub 接口的 GET “子請求”。我們現在利用 curl 命令行工具來發起一個到 /main 接口的 POST 請求:
$ curl --data hello 'http://localhost:8080/main'
main method: POST
sub method: POST
這里我們利用 curl 程序的 --data 選項,指定 hello 作為我們的請求體數據,同時 --data 選項會自動讓發送的請求使用 POST 請求方法。測試結果證明了我們先前的預言, $request_method 變量即使在 GET “子請求” /sub 中使用,得到的值依然是“主請求” /main 的請求方法,POST.
有的讀者可能覺得我們在這里下的結論有些草率,因為上例是先在“主請求”里讀取(並輸出) $request_method 變量,然后才發“子請求”的,所以這些讀者可能認為這並不能排除 $request_method 在進入子請求之前就已經把第一次讀到的值給緩存住,從而影響到后續子請求中的輸出結果。不過,這樣的顧慮是多余的,因為我們前面在 (五) 中也特別提到過,緩存所依賴的變量的值容器,是與當前請求綁定的,而由 ngx_echo 模塊發起的“子請求”都禁用了父子請求之間的變量共享,所以在上例中, $request_method 內建變量即使真的使用了值容器作為緩存(事實上它也沒有),它也不可能影響到 /sub 子請求。
為了進一步消除這部分讀者的疑慮,我們不妨稍微修改一下剛才那個例子,將 /main 接口輸出 $request_method 變量的時間推遲到“子請求”執行完畢之后:
location /main {
echo_location /sub;
echo "main method: $request_method";
}
location /sub {
echo "sub method: $request_method";
}
讓我們重新測試一下:
$ curl --data hello 'http://localhost:8080/main'
sub method: POST
main method: POST
可以看到,再次以 POST 方法請求 /main 接口的結果與原先那個例子完全一致,除了父子請求的輸出順序顛倒了過來(因為我們在本例中交換了 /main 接口中那兩條輸出配置指令的先后次序)。
由此可見,我們並不能通過標准的 $request_method 變量取得“子請求”的請求方法。為了達到我們最初的目的,我們需要求助於第三方模塊 ngx_echo 提供的內建變量 $echo_request_method:
location /main {
echo "main method: $echo_request_method";
echo_location /sub;
}
location /sub {
echo "sub method: $echo_request_method";
}
此時的輸出終於是我們想要的了:
$ curl --data hello 'http://localhost:8080/main'
main method: POST
sub method: GET
我們看到,父子請求分別輸出了它們各自不同的請求方法,POST 和 GET.
類似 $request_method,內建變量 $request_uri 一般也返回的是“主請求”未經解析過的 URL,畢竟“子請求”都是在 Nginx 內部發起的,並不存在所謂的“未解析的”原始形式。
如果真如前面那部分讀者所擔心的,內建變量的值緩存在共享變量的父子請求之間起了作用,這無疑是災難性的。我們前面在 (五) 中已經看到 ngx_auth_request 模塊發起的“子請求”是與其“父請求”共享一套變量的。下面是一個這樣的可怕例子:
map $uri $tag {
default 0;
/main 1;
/sub 2;
}
server {
listen 8080;
location /main {
auth_request /sub;
echo "main tag: $tag";
}
location /sub {
echo "sub tag: $tag";
}
}
這里我們使用久違了的 map 指令來把內建變量 $uri 的值映射到用戶變量 $tag 上。當 $uri 的值為 /main 時,則賦予 $tag 值 1,當 $uri 取值 /sub 時,則賦予 $tag 值 2,其他情況都賦 0. 接着,我們在 /main 接口中先用 ngx_auth_request 模塊的 auth_request 指令發起到 /sub 接口的子請求,然后再輸出變量 $tag 的值。而在 /sub 接口中,我們直接輸出變量 $tag. 猜猜看,如果我們訪問接口 /main,將會得到什么樣的輸出呢?
$ curl 'http://localhost:8080/main'
main tag: 2
咦?我們不是分明把 /main 這個值映射到 1 上的么?為什么實際輸出的是 /sub 映射的結果 2 呢?
其實道理很簡單,因為我們的 $tag 變量在“子請求” /sub 中首先被讀取,於是在那里計算出了值 2(因為 $uri 在那里取值 /sub,而根據 map 映射規則,$tag 應當取值 2),從此就被 $tag 的值容器給緩存住了。而 auth_request 發起的“子請求”又是與“父請求”共享一套變量的,於是當 Nginx 的執行流回到“父請求”輸出 $tag 變量的值時,Nginx 就直接返回緩存住的結果 2 了。這樣的結果確實太意外了。
從這個例子我們再次看到,父子請求間的變量共享,實在不是一個好主意。
Nginx 變量漫談(七)
在 (一) 中我們提到過,Nginx 變量的值只有一種類型,那就是字符串,但是變量也有可能壓根就不存在有意義的值。沒有值的變量也有兩種特殊的值:一種是“不合法”(invalid),另一種是“沒找到”(not found)。
舉例說來,當 Nginx 用戶變量 $foo 創建了卻未被賦值時,$foo 的值便是“不合法”;而如果當前請求的 URL 參數串中並沒有提及 XXX 這個參數,則 $arg_XXX 內建變量的值便是“沒找到”。
無論是“不合法”也好,還是“沒找到”也罷,這兩種 Nginx 變量所擁有的特殊值,和空字符串("")這種取值是完全不同的,比如 JavaScript 語言中也有專門的 undefined 和 null 這兩種特殊值,而 Lua 語言中也有專門的 nil 值: 它們既不等同於空字符串,也不等同於數字 0,更不是布爾值 false. 其實 SQL 語言中的 NULL 也是類似的一種東西。
雖然前面在 (一) 中我們看到,由 set 指令創建的變量未初始化就用在“變量插值”中時,效果等同於空字符串,但那是因為 set 指令為它創建的變量自動注冊了一個“取處理程序”,將“不合法”的變量值轉換為空字符串。為了驗證這一點,我們再重新看一下 (一) 中討論過的那個例子:
location /foo {
echo "foo = [$foo]";
}
location /bar {
set $foo 32;
echo "foo = [$foo]";
}
這里為了簡單起見,省略了原先寫出的外圍 server 配置塊。在這個例子里,我們在 /bar 接口中用 set 指令隱式地創建了 $foo 變量這個名字,然后我們在 /foo 接口中不對 $foo 進行初始化就直接使用 echo 指令輸出。我們當時測試 /foo 接口的結果是
$ curl 'http://localhost:8080/foo'
foo = []
從輸出上看,未初始化的 $foo 變量確實和空字符串的效果等同。但細心的讀者當時應該就已經注意到,對於上面這個請求,Nginx 的錯誤日志文件(一般文件名叫做 error.log)中多出一行類似下面這樣的警告:
[warn] 5765#0: *1 using uninitialized "foo" variable, ...
這一行警告是誰輸出的呢?答案是 set 指令為 $foo 注冊的“取處理程序”。當 /foo 接口中的 echo 指令實際執行的時候,它會對它的參數 "foo = $foo]" 進行“變量插值”計算。於是,參數串中的 $foo 變量會被讀取,而 Nginx 會首先檢查其值容器里的取值,結果它看到了“不合法”這個特殊值,於是它這才決定繼續調用 $foo 變量的“取處理程序”。於是 $foo 變量的“取處理程序”開始運行,它向 Nginx 的錯誤日志打印出上面那條警告消息,然后返回一個空字符串作為 $foo 的值,並從此緩存在 $foo 的值容器中。
細心的讀者會注意到剛剛描述的這個過程其實就是那些支持值緩存的內建變量的工作原理,只不過 set 指令在這里借用了這套機制來處理未正確初始化的 Nginx 變量。值得一提的是,只有“不合法”這個特殊值才會觸發 Nginx 調用變量的“取處理程序”,而特殊值“沒找到”卻不會。
上面這樣的警告一般會指示出我們的 Nginx 配置中存在變量名拼寫錯誤,抑或是在錯誤的場合使用了尚未初始化的變量。因為值緩存的存在,這條警告在一個請求的生命期中也不會打印多次。當然, ngx_rewrite 模塊專門提供了一條 uninitialized_variable_warn 配置指令可用於禁止這條警告日志。
剛才提到,內建變量 $arg_XXX 在請求 URL 參數 XXX 並不存在時會返回特殊值“找不到”,但遺憾的是在 Nginx 原生配置語言(我們估且這么稱呼它)中是不能很方便地把它和空字符串區分開來的,比如:
location /test {
echo "name: [$arg_name]";
}
這里我們輸出 $arg_name 變量的值同時故意在請求中不提供 URL 參數 name:
$ curl 'http://localhost:8080/test'
name: []
我們看到,輸出特殊值“找不到”的效果和空字符串是相同的。因為這一回是 Nginx 的“變量插值”引擎自動把“找不到”給忽略了。
那么我們究竟應當如何捕捉到“找不到”這種特殊值的蹤影呢?換句話說,我們應當如何把它和空字符串給區分開來呢?顯然,下面這個請求中,URL 參數 name 是有值的,而且其值應當是空字符串:
$ curl 'http://localhost:8080/test?name='
name: []
但我們卻無法將之和前面完全不提供 name 參數的情況給區分開。
幸運的是,通過第三方模塊 ngx_lua,我們可以輕松地在 Lua 代碼中做到這一點。請看下面這個例子:
location /test {
content_by_lua '
if ngx.var.arg_name == nil then
ngx.say("name: missing")
else
ngx.say("name: [", ngx.var.arg_name, "]")
end
';
}
這個例子和前一個例子功能上非常接近,除了我們在 /test 接口中使用了 ngx_lua 模塊的 content_by_lua 配置指令,嵌入了一小段我們自己的 Lua 代碼來對 Nginx 變量 $arg_name 的特殊值進行判斷。在這個例子中,當 $arg_name 的值為“沒找到”(或者“不合法”)時,/foo 接口會輸出 name: missing 這一行結果:
curl 'http://localhost:8080/test'
name: missing
因為這是我們第一次接觸到 ngx_lua 模塊,所以需要先簡單介紹一下。 ngx_lua 模塊將 Lua 語言解釋器(或者 LuaJIT 即時編譯器)嵌入到了 Nginx 核心中,從而可以讓用戶在 Nginx 核心中直接運行 Lua 語言編寫的程序。我們可以選擇在 Nginx 不同的請求處理階段插入我們的 Lua 代碼。這些 Lua 代碼既可以直接內聯在 Nginx 配置文件中,也可以單獨放置在外部 .lua 文件里,然后在 Nginx 配置文件中引用 .lua 文件的路徑。
回到上面這個例子,我們在 Lua 代碼里引用 Nginx 變量都是通過 ngx.var 這個由 ngx_lua 模塊提供的 Lua 接口。比如引用 Nginx 變量 $VARIABLE 時,就在 Lua 代碼里寫作 ngx.var.VARIABLE 就可以了。當 Nginx 變量 $arg_name 為特殊值“沒找到”(或者“不合法”)時, ngx.var.arg_name 在 Lua 世界中的值就是 nil,即 Lua 語言里的“空”(不同於 Lua 空字符串)。我們在 Lua 里輸出響應體內容的時候,則使用了 ngx.say 這個 Lua 函數,也是 ngx_lua 模塊提供的,功能上等價於 ngx_echo 模塊的 echo 配置指令。
現在,如果我們提供空字符串取值的 name 參數,則輸出就和剛才不相同了:
$ curl 'http://localhost:8080/test?name='
name: []
在這種情況下,Nginx 變量 $arg_name 的取值便是空字符串,這既不是“沒找到”,也不是“不合法”,因此在 Lua 里,ngx.var.arg_name 就返回 Lua 空字符串(""),和剛才的 Lua nil 值就完全區分開了。
這種區分在有些應用場景下非常重要,比如有的 web service 接口會根據 name 這個 URL 參數是否存在來決定是否按 name 屬性對數據集合進行過濾,而顯然提供空字符串作為 name 參數的值,也會導致對數據集中取值為空串的記錄進行篩選操作。
不過,標准的 $arg_XXX 變量還是有一些局限,比如我們用下面這個請求來測試剛才那個 /test 接口:
$ curl 'http://localhost:8080/test?name'
name: missing
此時,$arg_name 變量仍然讀出“找不到”這個特殊值,這就明顯有些違反常識。此外, $arg_XXX 變量在請求 URL 中有多個同名 XXX 參數時,就只會返回最先出現的那個 XXX 參數的值,而默默忽略掉其他實例:
$ curl 'http://localhost:8080/test?name=Tom&name=Jim&name=Bob'
name: [Tom]
要解決這些局限,可以直接在 Lua 代碼中使用 ngx_lua 模塊提供的 ngx.req.get_uri_args 函數。
Nginx 變量漫談(八)
與 $arg_XXX 類似,我們在 (二) 中提到過的內建變量 $cookie_XXX 變量也會在名為 XXX 的 cookie 不存在時返回特殊值“沒找到”:
location /test {
content_by_lua '
if ngx.var.cookie_user == nil then
ngx.say("cookie user: missing")
else
ngx.say("cookie user: [", ngx.var.cookie_user, "]")
end
';
}
利用 curl 命令行工具的 --cookie name=value 選項可以指定 name=value 為當前請求攜帶的 cookie(通過添加相應的 Cookie 請求頭)。下面是若干次測試結果:
$ curl --cookie user=agentzh 'http://localhost:8080/test'
cookie user: [agentzh]
$ curl --cookie user= 'http://localhost:8080/test'
cookie user: []
$ curl 'http://localhost:8080/test'
cookie user: missing
我們看到,cookie user 不存在以及取值為空字符串這兩種情況被很好地區分開了:當 cookie user 不存在時,Lua 代碼中的 ngx.var.cookie_user 返回了期望的 Lua nil 值。
在 Lua 里訪問未創建的 Nginx 用戶變量時,在 Lua 里也會得到 nil 值,而不會像先前的例子那樣直接讓 Nginx 拒絕加載配置:
location /test {
content_by_lua '
ngx.say("$blah = ", ngx.var.blah)
';
}
這里假設我們並沒有在當前的 nginx.conf 配置文件中創建過用戶變量 $blah,然后我們在 Lua 代碼中通過 ngx.var.blah 直接引用它。上面這個配置可以順利啟動,因為 Nginx 在加載配置時只會編譯 content_by_lua 配置指令指定的 Lua 代碼而不會實際執行它,所以 Nginx 並不知道 Lua 代碼里面引用了 $blah 這個變量。於是我們在運行時也會得到 nil 值。而 ngx_lua 提供的 ngx.say 函數會自動把 Lua 的 nil 值格式化為字符串 "nil" 輸出,於是訪問 /test 接口的結果是:
curl 'http://localhost:8080/test'
$blah = nil
這正是我們所期望的。
上面這個例子中另一個值得注意的地方是,我們在 content_by_lua 配置指令的參數中提及了 $bar 符號,但卻並沒有觸發“變量插值”(否則 Nginx 會在啟動時抱怨 $blah 未創建)。這是因為 content_by_lua 配置指令並不支持參數的“變量插值”功能。我們前面在 (一) 中提到過,配置指令的參數是否允許“變量插值”,其實取決於該指令的實現模塊。
設計返回“不合法”這一特殊值的例子是困難的,因為我們前面在 (七) 中已經看到,由 set 指令創建的變量在未初始化時確實是“不合法”,但一旦嘗試讀取它們時,Nginx 就會自動調用其“取處理程序”,而它們的“取處理程序”會自動返回空字符串並將之緩存住。於是我們最終得到的是完全合法的空字符串。下面這個使用了 Lua 代碼的例子證明了這一點:
location /foo {
content_by_lua '
if ngx.var.foo == nil then
ngx.say("$foo is nil")
else
ngx.say("$foo = [", ngx.var.foo, "]")
end
';
}
location /bar {
set $foo 32;
echo "foo = [$foo]";
}
請求 /foo 接口的結果是:
$ curl 'http://localhost:8080/foo'
$foo = []
我們看到在 Lua 里面讀取未初始化的 Nginx 變量 $foo 時得到的是空字符串。
最后值得一提的是,雖然前面反復指出 Nginx 變量只有字符串這一種數據類型,但這並不能阻止像 ngx_array_var 這樣的第三方模塊讓 Nginx 變量也能存放數組類型的值。下面就是這樣的一個例子:
location /test {
array_split "," $arg_names to=$array;
array_map "[$array_it]" $array;
array_join " " $array to=$res;
echo $res;
}
這個例子中使用了 ngx_array_var 模塊的 array_split、 array_map 和 array_join 這三條配置指令,其含義很接近 Perl 語言中的內建函數 split、map 和 join(當然,其他腳本語言也有類似的等價物)。我們來看看訪問 /test 接口的結果:
$ curl 'http://localhost:8080/test?names=Tom,Jim,Bob
[Tom] [Jim] [Bob]
我們看到,使用 ngx_array_var 模塊可以很方便地處理這樣具有不定個數的組成元素的輸入數據,例如此例中的 names URL 參數值就是由不定個數的逗號分隔的名字所組成。不過,這種類型的復雜任務通過 ngx_lua 來做通常會更靈活而且更容易維護。
至此,本系列教程對 Nginx 變量的介紹終於可以告一段落了。我們在這個過程中接觸到了許多標准的和第三方的 Nginx 模塊,這些模塊讓我們得以很輕松地構造出許多有趣的小例子,從而可以深入探究 Nginx 變量的各種行為和特性。在后續的教程中,我們還會有很多機會與這些模塊打交道。
通過前面討論過的眾多例子,我們應當已經感受到 Nginx 變量在 Nginx 配置語言中所扮演的重要角色:它是獲取 Nginx 中各種信息(包括當前請求的信息)的主要途徑和載體,同時也是各個模塊之間傳遞數據的主要媒介之一。在后續的教程中,我們會經常看到 Nginx 變量的身影,所以現在很好地理解它們是非常重要的。
在下一個系列的教程,即 Nginx 配置指令的執行順序系列 中,我們將深入探討 Nginx 配置指令的執行順序以及請求的各個處理階段,因為很多 Nginx 用戶都搞不清楚他們書寫的眾多配置指令之間究竟是按照何種時間順序執行的,也搞不懂為什么這些指令實際執行的順序經常和配置文件里的書寫順序大相徑庭。
Nginx 配置指令的執行順序(一)
大多數 Nginx 新手都會頻繁遇到這樣一個困惑,那就是當同一個 location 配置塊使用了多個 Nginx 模塊的配置指令時,這些指令的執行順序很可能會跟它們的書寫順序大相徑庭。於是許多人選擇了“試錯法”,然后他們的配置文件就時常被改得一片狼藉。這個系列的教程就旨在幫助讀者逐步地理解這些配置指令背后的執行時間和先后順序的奧秘。
現在就來看這樣一個令人困惑的例子:
? location /test {
? set $a 32;
? echo $a;
?
? set $a 56;
? echo $a;
? }
從這個例子的本意來看,我們期望的輸出是一行 32 和一行 56,因為我們第一次用 echo 配置指令輸出了 $a 變量的值以后,又緊接着使用 set 配置指令修改了 $a. 然而不幸的是,事實並非如此:
$ curl 'http://localhost:8080/test
56
56
我們看到,語句 set $a 56 似乎在第一條 echo $a 語句之前就執行過了。這究竟是為什么呢?難道我們遇到了 Nginx 中的一個 bug?
顯然,這里並沒有 Nginx 的 bug;要理解這里發生的事情,就首先需要知道 Nginx 處理每一個用戶請求時,都是按照若干個不同階段(phase)依次處理的。
Nginx 的請求處理階段共有 11 個之多,我們先介紹其中 3 個比較常見的。按照它們執行時的先后順序,依次是 rewrite 階段、access 階段以及 content 階段(后面我們還有機會見到其他更多的處理階段)。
所有 Nginx 模塊提供的配置指令一般只會注冊並運行在其中的某一個處理階段。比如上例中的 set 指令就是在 rewrite 階段運行的,而 echo 指令就只會在 content 階段運行。前面我們已經知道,在單個請求的處理過程中,rewrite 階段總是在 content 階段之前執行,因此屬於 rewrite 階段的配置指令也總是會無條件地在 content 階段的配置指令之前執行。於是在同一個 location 配置塊中, set 指令總是會在 echo 指令之前執行,即使我們在配置文件中有意把 set 語句寫在 echo 語句的后面。
回到剛才那個例子,
set $a 32;
echo $a;
set $a 56;
echo $a;
實際的執行順序應當是
set $a 32;
set $a 56;
echo $a;
echo $a;
即先在 rewrite 階段執行完這里的兩條 set 賦值語句,然后再在后面的 content 階段依次執行那兩條 echo 語句。分屬兩個不同處理階段的配置指令之間是不能穿插着運行的。
為了進一步驗證這一點,我們不妨借助 Nginx 的“調試日志”(debug log)來一窺 Nginx 的實際執行過程。
因為這是我們第一次提及 Nginx 的“調試日志”,所以有必要先簡單介紹一下它的啟用方法。“調試日志”默認是禁用的,因為它會引入比較大的運行時開銷,讓 Nginx 服務器顯著變慢。一般我們需要重新編譯和構造 Nginx 可執行文件,並且在調用 Nginx 源碼包提供的 ./configure 腳本時傳入 --with-debug 命令行選項。例如我們下載完 Nginx 源碼包后在 Linux 或者 Mac OS X 等系統上構建時,典型的步驟是這樣的:
tar xvf nginx-1.0.10.tar.gz
cd nginx-1.0.10/
./configure --with-debug
make
sudu make install
如果你使用的是我維護的 ngx_openresty 軟件包,則同樣可以向它的 ./configure 腳本傳遞 --with-debug 命令行選項。
當我們啟用 --with-debug 選項重新構建好調試版的 Nginx 之后,還需要同時在配置文件中通過標准的 error_log 配置指令為錯誤日志使用 debug 日志級別(這同時也是最低的日志級別):
error_log logs/error.log debug;
這里重要的是 error_log 指令的第二個參數,debug,而前面第一個參數是錯誤日志文件的路徑,logs/error.log. 當然,你也可以指定其他路徑,但后面我們會檢查這個文件的內容,所以請特別留意一下這里實際配置的文件路徑。
現在我們重新啟動 Nginx(注意,如果 Nginx 可執行文件也被更新過,僅僅讓 Nginx 重新加載配置是不夠的,需要關閉再啟動 Nginx 主服務進程),然后再請求一下我們剛才那個示例接口:
$ curl 'http://localhost:8080/test'
56
56
現在可以檢查一下前面配置的 Nginx 錯誤日志文件中的輸出。因為文件中的輸出比較多(在我的機器上有 700 多行),所以不妨用 grep 命令在終端上過濾出我們感興趣的部分:
grep -E 'http (output filter|script (set|value))' logs/error.log
在我機器上的輸出是這個樣子的(為了方便呈現,這里對 grep 命令的實際輸出作了一些簡單的編輯,略去了每一行的行首時間戳):
[debug] 5363#0: *1 http script value: "32"
[debug] 5363#0: *1 http script set $a
[debug] 5363#0: *1 http script value: "56"
[debug] 5363#0: *1 http script set $a
[debug] 5363#0: *1 http output filter "/test?"
[debug] 5363#0: *1 http output filter "/test?"
[debug] 5363#0: *1 http output filter "/test?"
這里需要稍微解釋一下這些調試信息的具體含義。 set 配置指令在實際運行時會打印出兩行以 http script 起始的調試信息,其中第一行信息是 set 語句中被賦予的值,而第二行則是 set 語句中被賦值的 Nginx 變量名。於是上面首先過濾出來的
[debug] 5363#0: *1 http script value: "32"
[debug] 5363#0: *1 http script set $a
這兩行就對應我們例子中的配置語句
set $a 32;
而接下來這兩行調試信息
[debug] 5363#0: *1 http script value: "56"
[debug] 5363#0: *1 http script set $a
則對應配置語句
set $a 56;
此外,凡在 Nginx 中輸出響應體數據時,都會調用 Nginx 的所謂“輸出過濾器”(output filter),我們一直在使用的 echo 指令自然也不例外。而一旦調用 Nginx 的“輸出過濾器”,便會產生類似下面這樣的調試信息:
[debug] 5363#0: *1 http output filter "/test?"
當然,這里的 "/test?" 部分對於其他接口可能會發生變化,因為它顯示的是當前請求的 URI. 這樣聯系起來看,就不難發現,上例中的那兩條 set 語句確實都是在那兩條 echo 語句之前執行的。
細心的讀者可能會問,為什么這個例子明明只使用了兩條 echo 語句進行輸出,但卻有三行 http output filter 調試信息呢?其實,前兩行 http output filter 信息確實分別對應那兩條 echo 語句,而最后那一行信息則是對應 ngx_echo 模塊輸出指示響應體末尾的結束標記。正是為了輸出這個特殊的結束標記,才會多出一次對 Nginx “輸出過濾器”的調用。包括 ngx_proxy 在內的許多模塊在輸出響應體數據流時都具有此種行為。
現在我們就不會再為前面那個例子輸出兩行一模一樣的 56 而感到驚訝了。我們根本沒有機會在第二條 set 語句之前用 echo 輸出。幸運的是,仍然可以借助一些小技巧來達到最初的目的:
location /test {
set $a 32;
set $saved_a $a;
set $a 56;
echo $saved_a;
echo $a;
}
此時的輸出便符合那個問題示例的初衷了:
$ curl 'http://localhost:8080/test'
32
56
這里通過引入新的用戶變量 $saved_a,在改寫 $a 之前及時保存了 $a 的初始值。而對於多條 set 指令而言,它們之間的執行順序是由 ngx_rewrite 模塊來保證與書寫順序相一致的。同理, ngx_echo 模塊自身也會保證它的多條 echo 指令之間的執行順序。
細心的讀者應當發現,我們在 Nginx 變量漫談系列 的示例中已經廣泛使用了這種技巧,來繞過因處理階段而引起的指令執行順序上的限制。
看到這里,有的讀者可能會問:“那么我在使用一條陌生的配置指令之前,如何知道它究竟運行在哪一個處理階段呢?”答案是:查看該指令的文檔(當然,高級開發人員也可以直接查看模塊的 C 源碼)。在許多模塊的文檔中,都會專門標記其配置指令所運行的具體階段。例如 echo 指令的文檔中有這么一行:
phase: content
這一行便是說,當前配置指令運行在 content 階段。如果你使用的 Nginx 模塊碰巧沒有指示運行階段的文檔,可以直接聯系該模塊的作者請求補充。不過,值得一提的是,並非所有的配置指令都與某個處理階段相關聯,例如我們先前在 Nginx 變量漫談(一) 中提到過的 geo 指令以及在 Nginx 變量漫談(四) 中介紹過的 map 指令。這些不與處理階段相關聯的配置指令基本上都是“聲明性的”(declarative),即不直接產生某種動作或者過程。Nginx 的作者 Igor Sysoev 在公開場合曾不止一次地強調,Nginx 配置文件所使用的語言本質上是“聲明性的”,而非“過程性的”(procedural)。
Nginx 配置指令的執行順序(二)
我們前面已經知道,當 set 指令用在 location 配置塊中時,都是在當前請求的 rewrite 階段運行的。事實上,在此上下文中, ngx_rewrite 模塊中的幾乎全部指令,都運行在 rewrite 階段,包括 Nginx 變量漫談(二) 中介紹過的 rewrite 指令。不過,值得一提的是,當這些指令使用在 server 配置塊中時,則會運行在一個我們尚未提及的更早的處理階段,server-rewrite 階段。
Nginx 變量漫談(二) 中介紹過的 ngx_set_misc 模塊的 set_unescape_uri 指令同樣也運行在 rewrite 階段。特別地, ngx_set_misc 模塊的指令還可以和 ngx_rewrite 的指令混合在一起依次執行。我們來看這樣的一個例子:
location /test {
set $a "hello%20world";
set_unescape_uri $b $a;
set $c "$b!";
echo $c;
}
訪問這個接口可以得到:
$ curl 'http://localhost:8080/test'
hello world!
我們看到, set_unescape_uri 語句前后的 set 語句都按書寫時的順序一前一后地執行了。
為了進一步確認這一點,我們不妨再檢查一下 Nginx 的“調試日志”(如果你還不清楚如何開啟“調試日志”的話,可以參考 (一) 中的步驟):
grep -E 'http script (value|copy|set)' t/servroot/logs/error.log
過濾出來的調試日志信息如下所示:
[debug] 11167#0: *1 http script value: "hello%20world"
[debug] 11167#0: *1 http script set $a
[debug] 11167#0: *1 http script value (post filter): "hello world"
[debug] 11167#0: *1 http script set $b
[debug] 11167#0: *1 http script copy: "!"
[debug] 11167#0: *1 http script set $c
開頭的兩行信息
[debug] 11167#0: *1 http script value: "hello%20world"
[debug] 11167#0: *1 http script set $a
就對應我們的配置語句
set $a "hello%20world";
而接下來的兩行
[debug] 11167#0: *1 http script value (post filter): "hello world"
[debug] 11167#0: *1 http script set $b
則對應配置語句
set_unescape_uri $b $a;
我們看到第一行信息與 set 指令略有區別,多了 "(post filter)" 這個標記,而且最后顯示出 URI 解碼操作確實如我們期望的那樣工作了,即 "hello%20world" 在這里被成功解碼為 "hello world".
而最后兩行調試信息
[debug] 11167#0: *1 http script copy: "!"
[debug] 11167#0: *1 http script set $c
則對應最后一條 set 語句:
set $c "$b!";
注意,因為這條指令在為 $c 變量賦值時使用了“變量插值”功能,所以第一行調試信息是以 http script copy 起始的,后面則是拼接到最終取值的字符串常量 "!".
把這些調試信息聯系起來看,我們不難發現,這些配置指令的實際執行順序是:
set $a "hello%20world";
set_unescape_uri $b $a;
set $c "$b!";
這與它們在配置文件中的書寫順序完全一致。
我們在 Nginx 變量漫談(七) 中初識了第三方模塊 ngx_lua,它提供的 set_by_lua 配置指令也和 ngx_set_misc 模塊的指令一樣,可以和 ngx_rewrite 模塊的指令混合使用。 set_by_lua 指令支持通過一小段用戶 Lua 代碼來計算出一個結果,然后賦給指定的 Nginx 變量。和 set 指令相似, set_by_lua 指令也有自動創建不存在的 Nginx 變量的功能。
下面我們就來看一個 set_by_lua 指令與 set 指令混合使用的例子:
location /test {
set $a 32;
set $b 56;
set_by_lua $c "return ngx.var.a + ngx.var.b";
set $equation "$a + $b = $c";
echo $equation;
}
這里我們先將 $a 和 $b 變量分別初始化為 32 和 56,然后利用 set_by_lua 指令內聯一行我們自己指定的 Lua 代碼,計算出 Nginx 變量 $a 和 $b 的“代數和”(sum),並賦給變量 $c,接着利用“變量插值”功能,把變量 $a、 $b 和 $c 的值拼接成一個字符串形式的等式,賦予變量 $equation,最后再用 echo 指令輸出 $equation 的值。
這個例子值得注意的地方是:首先,我們在 Lua 代碼中是通過 ngx.var.VARIABLE 接口來讀取 Nginx 變量 $VARIABLE 的;其次,因為 Nginx 變量的值只有字符串這一種類型,所以在 Lua 代碼里讀取 ngx.var.a 和 ngx.var.b 時得到的其實都是 Lua 字符串類型的值 "32" 和 "56";接着,我們對兩個字符串作加法運算會觸發 Lua 對加數進行自動類型轉換(Lua 會把兩個加數先轉換為數值類型再求和);然后,我們在 Lua 代碼中把最終結果通過 return 語句返回給外面的 Nginx 變量 $c;最后, ngx_lua 模塊在給 $c 實際賦值之前,也會把 return 語句返回的數值類型的結果,也就是 Lua 加法計算得出的“和”,自動轉換為字符串(這同樣是因為 Nginx 變量的值只能是字符串)。
這個例子的實際運行結果符合我們的期望:
$ curl 'http://localhost:8080/test'
32 + 56 = 88
於是這驗證了 set_by_lua 指令確實也可以和 set 這樣的 ngx_rewrite 模塊提供的指令混合在一起工作。
還有不少第三方模塊,例如 Nginx 變量漫談(八) 中介紹過的 ngx_array_var 以及后面即將接觸到的用於加解密用戶會話(session)的 ngx_encrypted_session,也都可以和 ngx_rewrite 模塊的指令無縫混合工作。
標准 ngx_rewrite 模塊的應用是如此廣泛,所以能夠和它的配置指令混合使用的第三方模塊是幸運的。事實上,上面提到的這些第三方模塊都采用了特殊的技術,將它們自己的配置指令“注入”到了 ngx_rewrite 模塊的指令序列中(它們都借助了 Marcus Clyne 編寫的第三方模塊 ngx_devel_kit)。換句話說,更多常規的在 Nginx 的 rewrite 階段注冊和運行指令的第三方模塊就沒那么幸運了。這些“常規模塊”的指令雖然也運行在 rewrite 階段,但其配置指令和 ngx_rewrite 模塊(以及同一階段內的其他模塊)都是分開獨立執行的。在運行時,不同模塊的配置指令集之間的先后順序一般是不確定的(嚴格來說,一般是由模塊的加載順序決定的,但也有例外的情況)。比如 A 和 B 兩個模塊都在 rewrite 階段運行指令,於是要么是 A 模塊的所有指令全部執行完再執行 B 模塊的那些指令,要么就是反過來,把 B 的指令全部執行完,再去運行 A 的指令。除非模塊的文檔中有明確的交待,否則用戶一般不應編寫依賴於此種不確定順序的配置。
Nginx 配置指令的執行順序(三)
如前文所述,除非像 ngx_set_misc 模塊那樣使用特殊技術,其他模塊的配置指令即使是在 rewrite 階段運行,也不能和 ngx_rewrite 模塊的指令混合使用。不妨來看幾個這樣的例子。
第三方模塊 ngx_headers_more 提供了一系列配置指令,用於操縱當前請求的請求頭和響應頭。其中有一條名叫 more_set_input_headers 的指令可以在 rewrite 階段改寫指定的請求頭(或者在請求頭不存在時自動創建)。這條指令總是運行在 rewrite 階段的末尾,該指令的文檔中有這么一行標記:
phase: rewrite tail
其中的 rewrite tail 的意思就是 rewrite 階段的末尾。
既然運行在 rewrite 階段的末尾,那么也就總是會運行在 ngx_rewrite 模塊的指令之后,即使我們在配置文件中把它寫在前面,例如:
? location /test {
? set $value dog;
? more_set_input_headers "X-Species: $value";
? set $value cat;
?
? echo "X-Species: $http_x_species";
? }
這個例子用到的 $http_XXX 內建變量在讀取時會返回當前請求中名為 XXX 的請求頭,我們在 Nginx 變量漫談(二) 中曾經簡單提過它。需要注意的是, $http_XXX 變量在匹配請求頭時會自動對請求頭的名字進行歸一化,即將名字的大寫字母轉換為小寫字母,同時把間隔符(-)替換為下划線(_),所以變量名 $http_x_species 才得以成功匹配 more_set_input_headers 語句中設置的請求頭 X-Species.
此例書寫的指令順序會誤導我們認為 /test 接口輸出的 X-Species 頭的值是 dog,然而實際的結果卻並非如此:
$ curl 'http://localhost:8080/test'
X-Species: cat
顯然,寫在 more_set_input_headers 指令之后的 set $value cat 語句卻先執行了。
上面這個例子證明了即使運行在同一個請求處理階段,分屬不同模塊的配置指令也可能會分開獨立運行(除非像 ngx_set_misc 等模塊那樣針對 ngx_rewrite 模塊提供特殊支持)。換句話說,在單個請求處理階段內部,一般也會以 Nginx 模塊為單位進一步地划分出內部子階段。
第三方模塊 ngx_lua 提供的 rewrite_by_lua 配置指令也和 more_set_input_headers 一樣運行在 rewrite 階段的末尾。我們來驗證一下:
? location /test {
? set $a 1;
? rewrite_by_lua "ngx.var.a = ngx.var.a + 1";
? set $a 56;
?
? echo $a;
? }
這里我們在 rewrite_by_lua 語句內聯的 Lua 代碼中對 Nginx 變量 $a 進行了自增計算。從該例的指令書寫順序上看,我們或許會期望輸出是 56,可是因為 rewrite_by_lua 會在所有的 set 語句之后執行,所以結果是 57:
$ curl 'http://localhost:8080/test'
57
顯然, rewrite_by_lua 指令的行為不同於我們前面在 (二) 中介紹過的 set_by_lua 指令。
有的讀者可能要問,既然 more_set_input_headers 和 rewrite_by_lua 指令都運行在 rewrite 階段的末尾,那么它們之間的先后順序又是怎樣的呢?答案是:不一定。我們應當避免寫出依賴它們二者間順序的配置。
Nginx 的 rewrite 階段是一個比較早的請求處理階段,這個階段的配置指令一般用來對當前請求進行各種修改(比如對 URI 和 URL 參數進行改寫),或者創建並初始化一系列后續處理階段可能需要的 Nginx 變量。當然,也不能阻止一些用戶在 rewrite 階段做一系列更復雜的事情,比如讀取請求體,或者訪問數據庫等遠方服務,畢竟有 rewrite_by_lua 這樣的指令可以嵌入任意復雜的 Lua 代碼。
在 rewrite 階段之后,有一個名叫 access 的請求處理階段。Nginx 變量漫談(五) 中介紹過的第三方模塊 ngx_auth_request 的指令就運行在 access 階段。在 access 階段運行的配置指令多是執行訪問控制性質的任務,比如檢查用戶的訪問權限,檢查用戶的來源 IP 地址是否合法,諸如此類。
例如,標准模塊 ngx_access 提供的 allow 和 deny 配置指令可用於控制哪些 IP 地址可以訪問,哪些不可以:
location /hello {
allow 127.0.0.1;
deny all;
echo "hello world";
}
這個 /hello 接口被配置為只允許從本機(IP 地址為保留的 127.0.0.1)訪問,而從其他 IP 地址訪問都會被拒(返回 403 錯誤頁)。 ngx_access 模塊自己的多條配置指令之間是按順序執行的,直到遇到第一條滿足條件的指令就不再執行后續的 allow 和 deny 指令。如果首先匹配的指令是 allow,則會繼續執行后續其他模塊的指令或者跳到后續的處理階段;而如果首先滿足的是 deny 則會立即中止當前整個請求的處理,並立即返回 403 錯誤頁。所以看上面這個例子,如果是從本地訪問的,則首先匹配 allow 127.0.0.1 這一條語句,於是 Nginx 就繼續往下執行其他模塊的指令以及后續的處理階段;而如果是從其他機器訪問,則首先匹配的則是 deny all 這一條語句,即拒絕所有地址,它會導致 403 錯誤頁立即返回給客戶端。
我們來實測一下。從本機訪問這個接口可以得到
$ curl 'http://localhost:8080/hello'
hello world
而從另一台機器訪問這台機器(假設運行 Nginx 的機器地址是 192.168.1.101)提供的接口時則得到
$ curl 'http://192.168.1.101:8080/hello'
<html>
<head><title>403 Forbidden</title></head>
<body bgcolor="white">
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx</center>
</body>
</html>
值得一提的是, ngx_access 模塊還支持所謂的“CIDR 記法”來表示一個網段,例如 169.200.179.4/24 則表示路由前綴是 169.200.179.0(或者說子網掩碼是 255.255.255.0)的網段。
因為 ngx_access 模塊的指令運行在 access 階段,而 access 階段又處於 rewrite 階段之后,所以前面我們見到的所有那些在 rewrite 階段運行的配置指令,都總是在 allow 和 deny 之前執行,而無論它們在配置文件中的書寫順序是怎樣的。所以,為了避免閱讀配置時的混亂,我們應該總是讓指令的書寫順序和它們的實際執行順序保持一致。
Nginx 配置指令的執行順序(四)
ngx_lua 模塊提供了配置指令 access_by_lua,用於在 access 請求處理階段插入用戶 Lua 代碼。這條指令運行於 access 階段的末尾,因此總是在 allow 和 deny 這樣的指令之后運行,雖然它們同屬 access 階段。一般我們通過 access_by_lua 在 ngx_access 這樣的模塊檢查過客戶端 IP 地址之后,再通過 Lua 代碼執行一系列更為復雜的請求驗證操作,比如實時查詢數據庫或者其他后端服務,以驗證當前用戶的身份或權限。
我們來看一個簡單的例子,利用 access_by_lua 來實現 ngx_access 模塊的 IP 地址過濾功能:
location /hello {
access_by_lua '
if ngx.var.remote_addr == "127.0.0.1" then
return
end
ngx.exit(403)
';
echo "hello world";
}
這里在 Lua 代碼中通過引用 Nginx 標准的內建變量 $remote_addr 來獲取字符串形式的客戶端 IP 地址,然后用 Lua 的 if 語句判斷是否為本機地址,即是否等於 127.0.0.1. 如果是本機地址,則直接利用 Lua 的 return 語句返回,讓 Nginx 繼續執行后續的請求處理階段(包括 echo 指令所處的 content 階段);而如果不是本機地址,則通過 ngx_lua 模塊提供的 Lua 函數 ngx.exit 中斷當前的整個請求處理流程,直接返回 403 錯誤頁給客戶端。
這個例子在功能上完全等價於先前在 (三) 中介紹過的那個使用 ngx_access 模塊的例子:
location /hello {
allow 127.0.0.1;
deny all;
echo "hello world";
}
雖然這兩個例子在功能上完全相同,但在性能上還是有區別的,畢竟 ngx_access 是用純 C 實現的專門化的 Nginx 模塊。
下面我們不妨來實際測量一下這兩個例子的性能差別。因為我們使用 Nginx 就是為了追求性能,而量化的性能比較,在工程上具有很大的現實意義,所以我們順便介紹一下重要的測量技術。由於無論是 ngx_access 還是 ngx_lua 在進行 IP 地址驗證方面的性能都非常之高,所以為了減少測量誤差,我們希望能對 access 階段的用時進行直接測量。為了做到這一點,傳統的做法一般會涉及到修改 Nginx 源碼,自己插入專門的計時代碼和統計輸出代碼,抑或是重新編譯 Nginx 以啟用像 GNU gprof 這樣專門的性能監測工具。
幸運的是,在新一點的 Solaris, Mac OS X, 以及 FreeBSD 等系統上存在一個叫做 dtrace 的工具,可以對任意的用戶程序進行微觀性能分析(以及行為分析),而無須對用戶程序的源碼進行修改或者對用戶程序進行重新編譯。因為 Mac OS X 10.5 以后就自帶了 dtrace,所以為方便起見,下面在我的 MacBook Air 筆記本上演示一下這里的測量過程。
首先,在 Mac OS X 系統中打開一個命令行終端,在某一個文件目錄下面創建一個名為 nginx-access-time.d 的文件,並編輯內容如下:
#!/usr/bin/env dtrace -s
pid$1::ngx_http_handler:entry
{
elapsed = 0;
}
pid$1::ngx_http_core_access_phase:entry
{
begin = timestamp;
}
pid$1::ngx_http_core_access_phase:return
/begin > 0/
{
elapsed += timestamp - begin;
begin = 0;
}
pid$1::ngx_http_finalize_request:return
/elapsed > 0/
{
@elapsed = avg(elapsed);
elapsed = 0;
}
保存好此文件后,再賦予它可執行權限:
$ chmod +x ./nginx-access-time.d
這個 .d 文件中的代碼是用 dtrace 工具自己提供的 D 語言來編寫的(注意,這里的 D 語言並不同於 Walter Bright 作為另一種“更好的 C++”而設計的 D 語言)。由於本系列教程並不打算介紹如何編寫 dtrace 的 D 腳本,同時理解這個腳本需要不少有關 Nginx 內部源碼實現的細節,所以這里我們不展開介紹。大家只需要知道這個腳本的功能是:統計指定的 Nginx worker 進程在處理每個請求時,平均花費在 access 階段上的時間。
現在來演示一下這個 D 腳本的運行方法。這個腳本接受一個命令行參數用於指定監視的 Nginx worker 進程的進程號(pid)。由於 Nginx 支持多 worker 進程,所以我們測試時發起的 HTTP 請求可能由其中任意一個 worker 進程服務。為了確保所有測試請求都為固定的 worker 進程處理,不妨在 nginx.conf 配置文件中指定只啟用一個 worker 進程:
worker_processes 1;
重啟 Nginx 服務器之后,可以利用 ps 命令得到當前 worker 進程的進程號:
$ ps ax|grep nginx|grep worker|grep -v grep
在我機器上的一次典型輸出是
10975 ?? S 0:34.28 nginx: worker process
其中第一列的數值便是我的 nginx worker 進程的進程號,10975。如果你得到的輸出不止一行,則通常意味着你的系統中同時運行着多個 Nginx 服務器實例,或者當前 Nginx 實例啟用了多個 worker 進程。
接下來使用剛剛得到的 worker 進程號以及 root 身份來運行 nginx-access-time.d 腳本:
$ sudo ./nginx-access-time.d 10975
如果一切正常,則會看到這樣一行輸出:
dtrace: script './nginx-access-time.d' matched 4 probes
這行輸出是說,我們的 D 腳本已成功向目標進程動態植入了 4 個 dtrace “探針”(probe)。緊接着這個腳本就掛起了,表明 dtrace 工具正在對進程 10975 進行持續監視。
然后我們再打開一個新終端,在那里使用 curl 這樣的工具多次請求我們正在監視的接口
$ curl 'http://localhost:8080/hello'
hello world
$ curl 'http://localhost:8080/hello'
hello world
最后我們回到原先那個一直在運行 D 腳本的終端,按下 Ctrl-C 組合鍵中止 dtrace 的運行。而該腳本在退出時會向終端打印出最終統計結果。例如我的終端此時是這個樣子的:
$ sudo ./nginx-access-time.d 10975
dtrace: script './nginx-access-time.d' matched 4 probes
^C
19219
最后一行輸出 19219 便是那幾次 curl 請求在 access 階段的平均用時(以納秒,即 10 的負 9 次方秒為單位)。
通過上面介紹的步驟,可以通過 nginx-access-time.d 腳本分別統計出各種不同的 Nginx 配置下 access 階段的平均用時。針對我們感興趣的三種情況可以進行三組平行試驗,即使用 ngx_access 過濾 IP 地址的情況,使用 access_by_lua 過濾 IP 地址的情況,以及不在 access 階段使用任何配置指令的情況。最后一種情況屬於“空白對照組”,用於校正測試過程中因 dtrace 探針等其他因素而引入的“系統誤差”。另外,為了最小化各種不可控的“隨機誤差”,可以用 ab 這樣的批量測試工具來取代 curl 發起連續十萬次以上的請求,例如
$ ab -k -c1 -n100000 'http://127.0.0.1:8080/hello'
這樣我們的 D 腳本統計出來的平均值將更加接近“真實值”。
在我的蘋果系統上,一次典型的測試結果如下:
ngx_access 組 18146
access_by_lua 組 35011
空白對照組 15887
把前兩組的結果分別減去“空白對照組”的結果可以得到
ngx_access 組 2259
access_by_lua 組 19124
可以看到, ngx_access 組比 access_by_lua 組快了大約一個數量級,這正是我們所預期的。不過其絕對時間差是極小的,對於我的 Intel Core2Duo 1.86 GHz 的 CPU 而言,也只有區區十幾微秒,或者說是在十萬分之一秒的量級。
當然,上面使用 access_by_lua 的例子還可以通過換用 $binary_remote_addr 內建變量進行優化,因為 $binary_remote_addr 讀出的是二進制形式的 IP 地址,而 $remote_addr 則返回更長一些的字符串形式的地址。更短的地址意味着用 Lua 進行字符串比較時通常可以更快。
值得注意的是,如果按 (一) 中介紹的方法為 Nginx 開啟了“調試日志”的話,上面統計出來的時間會顯著增加,因為“調試日志”自身的開銷是很大的。
Nginx 配置指令的執行順序(五)
Nginx 的 content 階段是所有請求處理階段中最為重要的一個,因為運行在這個階段的配置指令一般都肩負着生成“內容”(content)並輸出 HTTP 響應的使命。正因為其重要性,這個階段的配置指令也異常豐富,例如前面我們一直在示例中廣泛使用的 echo 指令,在 Nginx 變量漫談(二) 中接觸到的 echo_exec 指令, Nginx 變量漫談(三) 中接觸到的 proxy_pass 指令,Nginx 變量漫談(五) 中介紹過的 echo_location 指令,以及 Nginx 變量漫談(七) 中介紹過的 content_by_lua 指令,都運行在這個階段。
content 階段屬於一個比較靠后的處理階段,運行在先前介紹過的 rewrite 和 access 這兩個階段之后。當和 rewrite、access 階段的指令一起使用時,這個階段的指令總是最后運行,例如:
location /test {
# rewrite phase
set $age 1;
rewrite_by_lua "ngx.var.age = ngx.var.age + 1";
# access phase
deny 10.32.168.49;
access_by_lua "ngx.var.age = ngx.var.age * 3";
# content phase
echo "age = $age";
}
這個例子中各個配置指令的執行順序便是它們的書寫順序。測試結果完全符合預期:
$ curl 'http://localhost:8080/test'
age = 6
即使改變它們的書寫順序,也不會影響到執行順序。其中, set 指令來自 ngx_rewrite 模塊,運行於 rewrite 階段;而 rewrite_by_lua 指令來自 ngx_lua 模塊,運行於 rewrite 階段的末尾;接下來, deny 指令來自 ngx_access 模塊,運行於 access 階段;再下來, access_by_lua 指令同樣來自 ngx_lua 模塊,運行於 access 階段的末尾;最后,我們的老朋友 echo 指令則來自 ngx_echo 模塊,運行在 content 階段。
這個例子展示了通過同時使用多個處理階段的配置指令來實現多個模塊協同工作的效果。在這個過程中,Nginx 變量則經常扮演着在指令間乃至模塊間傳遞(小份)數據的角色。這些配置指令的執行順序,也強烈地受到請求處理階段的影響。
進一步地,在 rewrite 和 access 這兩個階段,多個模塊的配置指令可以同時使用,譬如上例中的 set 指令和 rewrite_by_lua 指令同處 rewrite 階段,而 deny 指令和 access_by_lua 指令則同處 access 階段。但不幸的是,這通常不適用於 content 階段。
絕大多數 Nginx 模塊在向 content 階段注冊配置指令時,本質上是在當前的 location 配置塊中注冊所謂的“內容處理程序”(content handler)。每一個 location 只能有一個“內容處理程序”,因此,當在 location 中同時使用多個模塊的 content 階段指令時,只有其中一個模塊能成功注冊“內容處理程序”。考慮下面這個有問題的例子:
? location /test {
? echo hello;
? content_by_lua 'ngx.say("world")';
? }
這里, ngx_echo 模塊的 echo 指令和 ngx_lua 模塊的 content_by_lua 指令同處 content 階段,於是只有其中一個模塊能注冊和運行這個 location 的“內容處理程序”:
$ curl 'http://localhost:8080/test'
world
實際運行結果表明,寫在后面的 content_by_lua 指令反而勝出了,而 echo 指令則完全沒有運行。具體哪一個模塊的指令會勝出是不確定的,例如把上例中的 echo 語句和 content_by_lua 語句交換順序,則輸出就會變成 hello,即 ngx_echo 模塊勝出。所以我們應當避免在同一個 location 中使用多個模塊的 content 階段指令。
將上例中的 content_by_lua 指令替換為 echo 指令就可以如願了:
location /test {
echo hello;
echo world;
}
測試結果證明了這一點:
$ curl 'http://localhost:8080/test'
hello
world
這里使用多條 echo 指令是沒問題的,因為它們同屬 ngx_echo 模塊,而且 ngx_echo 模塊規定和實現了它們之間的執行順序。值得一提的是,並非所有模塊的指令都支持在同一個 location 中被使用多次,例如 content_by_lua 就只能使用一次,所以下面這個例子是錯誤的:
? location /test {
? content_by_lua 'ngx.say("hello")';
? content_by_lua 'ngx.say("world")';
? }
這個配置在 Nginx 啟動時就會報錯:
[emerg] "content_by_lua" directive is duplicate ...
正確的寫法應當是:
location /test {
content_by_lua 'ngx.say("hello") ngx.say("world")';
}
即在 content_by_lua 內聯的 Lua 代碼中調用兩次 ngx.say 函數,而不是在當前 location 中使用兩次 content_by_lua 指令。
類似地, ngx_proxy 模塊的 proxy_pass 指令和 echo 指令也不能同時用在一個 location 中,因為它們也同屬 content 階段。不少 Nginx 新手都會犯類似下面這樣的錯誤:
? location /test {
? echo "before...";
? proxy_pass http://127.0.0.1:8080/foo;
? echo "after...";
? }
?
? location /foo {
? echo "contents to be proxied";
? }
這個例子表面上是想在 ngx_proxy 模塊返回的內容前后,通過 ngx_echo 模塊的 echo 指令分別輸出字符串 "before..." 和 "after...",但其實只有其中一個模塊能在 content 階段運行。測試結果表明,在這個例子中是 ngx_proxy 模塊勝出,而 ngx_echo 模塊的 echo 指令根本沒有運行:
$ curl 'http://localhost:8080/test'
contents to be proxied
要實現這個例子希望達到的效果,需要改用 ngx_echo 模塊提供的 echo_before_body 和 echo_after_body 這兩條配置指令:
location /test {
echo_before_body "before...";
proxy_pass http://127.0.0.1:8080/foo;
echo_after_body "after...";
}
location /foo {
echo "contents to be proxied";
}
測試結果表明這一次我們成功了:
$ curl 'http://localhost:8080/test'
before...
contents to be proxied
after...
配置指令 echo_before_body 和 echo_after_body 之所以可以和其他模塊運行在 content 階段的指令一起工作,是因為它們運行在 Nginx 的“輸出過濾器”中。前面我們在 (一) 中分析 echo 指令產生的“調試日志”時已經知道,Nginx 在輸出響應體數據時都會調用“輸出過濾器”,所以 ngx_echo 模塊才有機會在“輸出過濾器”中對 ngx_proxy 模塊產生的響應體輸出進行修改(即在首尾添加新的內容)。值得一提的是,“輸出過濾器”並不屬於 (一) 中提到的那 11 個請求處理階段(畢竟許多階段都可以通過輸出響應體數據來調用“輸出過濾器”),但這並不妨礙 echo_before_body 和 echo_after_body 指令在文檔中標記下面這一行:
phase: output filter
這一行的意思是,當前配置指令運行在“輸出過濾器”這個特殊的階段。
Nginx 配置指令的執行順序(六)
前面我們在 (五) 中提到,在一個 location 中使用 content 階段指令時,通常情況下就是對應的 Nginx 模塊注冊該 location 中的“內容處理程序”。那么當一個 location 中未使用任何 content 階段的指令,即沒有模塊注冊“內容處理程序”時,content 階段會發生什么事情呢?誰又來擔負起生成內容和輸出響應的重擔呢?答案就是那些把當前請求的 URI 映射到文件系統的靜態資源服務模塊。當存在“內容處理程序”時,這些靜態資源服務模塊並不會起作用;反之,請求的處理權就會自動落到這些模塊上。
Nginx 一般會在 content 階段安排三個這樣的靜態資源服務模塊(除非你的 Nginx 在構造時顯式禁用了這三個模塊中的一個或者多個,又或者啟用了這種類型的其他模塊)。按照它們在 content 階段的運行順序,依次是 ngx_index 模塊, ngx_autoindex 模塊,以及 ngx_static 模塊。下面就來逐一介紹一下這三個模塊。
ngx_index 和 ngx_autoindex 模塊都只會作用於那些 URI 以 / 結尾的請求,例如請求 GET /cats/,而對於不以 / 結尾的請求則會直接忽略,同時把處理權移交給 content 階段的下一個模塊。而 ngx_static 模塊則剛好相反,直接忽略那些 URI 以 / 結尾的請求。
ngx_index 模塊主要用於在文件系統目錄中自動查找指定的首頁文件,類似 index.html 和 index.htm 這樣的,例如:
location / {
root /var/www/;
index index.htm index.html;
}
這樣,當用戶請求 / 地址時,Nginx 就會自動在 root 配置指令指定的文件系統目錄下依次尋找 index.htm 和 index.html 這兩個文件。如果 index.htm 文件存在,則直接發起“內部跳轉”到 /index.htm 這個新的地址;而如果 index.htm 文件不存在,則繼續檢查 index.html 是否存在。如果存在,同樣發起“內部跳轉”到 /index.html;如果 index.html 文件仍然不存在,則放棄處理權給 content 階段的下一個模塊。
我們前面已經在 Nginx 變量漫談(二) 中提到, echo_exec 指令和 rewrite 指令可以發起“內部跳轉”。這種跳轉會自動修改當前請求的 URI,並且重新匹配與之對應的 location 配置塊,再重新執行 rewrite、access、content 等處理階段。因為是“內部跳轉”,所以有別於 HTTP 協議中定義的基於 302 和 301 響應的“外部跳轉”,最終用戶的瀏覽器的地址欄也不會發生變化,依然是原來的 URI 位置。而 ngx_index 模塊一旦找到了 index 指令中列舉的文件之后,就會發起這樣的“內部跳轉”,仿佛用戶是直接請求的這個文件所對應的 URI 一樣。
為了進一步確認 ngx_index 模塊在找到文件時的“內部跳轉”行為,我們不妨設計下面這個小例子:
location / {
root /var/www/;
index index.html;
}
location /index.html {
set $a 32;
echo "a = $a";
}
此時我們在本機的 /var/www/ 目錄下創建一個空白的 index.html 文件,並確保該文件的權限設置對於運行 Nginx worker 進程的帳戶可讀。然后我們來請求一下根位置(/):
$ curl 'http://localhost:8080/'
a = 32
這里發生了什么?為什么輸出不是 index.html 文件的內容(即空白)?首先對於用戶的原始請求 GET /,Nginx 匹配出 location / 來處理它,然后 content 階段的 ngx_index 模塊在 /var/www/ 下找到了 index.html,於是立即發起一個到 /index.html 位置的“內部跳轉”。
到這里,相信大家都不會有問題。接下來有趣的事情發生了!在重新為 /index.html 這個新位置匹配 location 配置塊時,location /index.html 的優先級要高於 location /,因為 location 塊按照 URI 前綴來匹配時遵循所謂的“最長子串匹配語義”。這樣,在進入 location /index.html 配置塊之后,又重新開始執行 rewrite 、access、以及 content 等階段。最終輸出 a = 32 自然也就在情理之中了。
我們接着研究上面這個例子。如果此時把 /var/www/index.html 文件刪除,再訪問 / 又會發生什么事情呢?答案是返回 403 Forbidden 出錯頁。為什么呢?因為 ngx_index 模塊找不到 index 指令指定的文件(在這里就是 index.html),接着把處理權轉給 content 階段的后續模塊,而后續的模塊也都無法處理這個請求,於是 Nginx 只好放棄,輸出了錯誤頁,並且在 Nginx 錯誤日志中留下了類似這一行信息:
[error] 28789#0: *1 directory index of "/var/www/" is forbidden
所謂 directory index 便是生成“目錄索引”的意思,典型的方式就是生成一個網頁,上面列舉出 /var/www/ 目錄下的所有文件和子目錄。而運行在 ngx_index 模塊之后的 ngx_autoindex 模塊就可以用於自動生成這樣的“目錄索引”網頁。我們來把上例修改一下:
location / {
root /var/www/;
index index.html;
autoindex on;
}
此時仍然保持文件系統中的 /var/www/index.html 文件不存在。我們再訪問 / 位置時,就會得到一張漂亮的網頁:
$ curl 'http://localhost:8080/'
<html>
<head><title>Index of /</title></head>
<body bgcolor="white">
<h1>Index of /</h1><hr><pre><a href="../">../</a>
<a href="cgi-bin/">cgi-bin/</a> 08-Mar-2010 19:36 -
<a href="error/">error/</a> 08-Mar-2010 19:36 -
<a href="htdocs/">htdocs/</a> 05-Apr-2010 03:55 -
<a href="icons/">icons/</a> 08-Mar-2010 19:36 -
</pre><hr></body>
</html>
生成的 HTML 源碼顯示,我本機的 /var/www/ 目錄下還有 cgi-bin/, error/, htdocs/, 以及 icons/ 這幾個子目錄。在你的系統中嘗試上面的例子,輸出很可能會不太一樣。
值得一提的是,當你的文件系統中存在 /var/www/index.html 時,優先運行的 ngx_index 模塊就會發起“內部跳轉”,根本輪不到 ngx_autoindex 執行。感興趣的讀者可以自己測試一下。
在 content 階段默認“墊底”的最后一個模塊便是極為常用的 ngx_static 模塊。這個模塊主要實現服務靜態文件的功能。比方說,一個網站的靜態資源,包括靜態 .html 文件、靜態 .css 文件、靜態 .js 文件、以及靜態圖片文件等等,全部可以通過這個模塊對外服務。前面介紹的 ngx_index 模塊雖然可以在指定的首頁文件存在時發起“內部跳轉”,但真正把相應的首頁文件服務出去(即把該文件的內容作為響應體數據輸出,並設置相應的響應頭),還是得靠這個 ngx_static 模塊來完成。
Nginx 配置指令的執行順序(七)
來看一個 ngx_static 模塊服務磁盤文件的例子。我們使用下面這個配置片段:
location / {
root /var/www/;
}
同時在本機的 /var/www/ 目錄下創建兩個文件,一個文件叫做 index.html,內容是一行文本 this is my home;另一個文件叫做 hello.html,內容是一行文本 hello world. 同時注意這兩個文件的權限設置,確保它們都對運行 Nginx worker 進程的系統帳戶可讀。
現在來通過 HTTP 協議請求一下這兩個文件所對應的 URI:
$ curl 'http://localhost:8080/index.html'
this is my home
$ curl 'http://localhost:8080/hello.html'
hello world
我們看到,先前創建的那兩個磁盤文件的內容被分別輸出了。
不妨來分析一下這里發生的事情:location / 中沒有使用運行在 content 階段的模塊指令,於是也就沒有模塊注冊這個 location 的“內容處理程序”,處理權便自動落到了在 content 階段“墊底”的那 3 個靜態資源服務模塊。首先運行的 ngx_index 和 ngx_autoindex 模塊先后看到當前請求的 URI,/index.html 和 /hello.html,並不以 / 結尾,於是直接棄權,將處理權轉給了最后運行的 ngx_static 模塊。ngx_static 模塊根據 root 指令指定的“文檔根目錄”(document root),分別將請求 URI /index.html 和 /hello.html 映射為文件系統路徑 /var/www/index.html 和 /var/www/hello.html,在確認這兩個文件存在后,將它們的內容分別作為響應體輸出,並自動設置 Content-Type、Content-Length 以及 Last-Modified 等響應頭。
為了確認 ngx_static 模塊確實運行了,可以啟用 (一) 中介紹過的 Nginx “調試日志”,然后再次請求 /index.html 這個接口。此時,在 Nginx 錯誤日志文件中可以看到類似下面這一行的調試信息:
[debug] 3033#0: *1 http static fd: 8
這一行信息便是 ngx_static 模塊生成的,其含義是“正在輸出的靜態文件的描述符是數字 8”。當然,具體的文件描述符編號會經常發生變化,這里只是我機器的一次典型輸出。值得一提的是,能生成這一行調試信息的還有標准模塊 ngx_gzip_static ,但它默認是不啟用的,后面會專門介紹到這個模塊。
注意上面這個例子中使用的 root 配置指令只起到了聲明“文檔根目錄”的作用,並不是它開啟了 ngx_static 模塊。ngx_static 模塊總是處於開啟狀態,但是否輪得到它運行就要看 content 階段先於它運行的那些模塊是否“棄權”了。為了進一步確認這一點,來看下面這個空白 location 的定義:
location / {
}
因為沒有配置 root 指令,所以在訪問這個接口時,Nginx 會自動計算出一個缺省的“文檔根目錄”。該缺省值是取所謂的“配置前綴”(configure prefix)路徑下的 html/ 子目錄。舉一個例子,假設“配置前綴”是 /foo/bar/,則缺省的“文檔根目錄”便是 /foo/bar/html/.
那么“配置前綴”是由什么來決定的呢?默認情況下,就是 Nginx 安裝時的根目錄(或者說 Nginx 構造時傳遞給 ./configure 腳本的 --prefix 選項的路徑值)。如果 Nginx 安裝到了 /usr/local/nginx/ 下,則“配置前綴”便是 /usr/local/nginx/,同時默認的“文檔根目錄”便是 /usr/local/nginx/html/. 不過,我們也可以在啟動 Nginx 的時候,通過 --prefix 命令行選項臨時指定自己的“配置前綴”路徑。假設我們啟動 Nginx 時使用的命令是
nginx -p /home/agentzh/test/
則對於該服務器實例,其“配置前綴”便是 /home/agentzh/test/,而默認的“文檔根目錄”便是 /home/agentzh/test/html/. “配置前綴”不僅會決定默認的“文檔根目錄”,還決定着 Nginx 配置文件中許多相對路徑值如何解釋為絕對路徑,后面我們還會看到許多需要引用到“配置前綴”的例子。
獲取當前“文檔根目錄”的路徑有一個非常簡便的方法,那就是請求一個肯定不存在的文件所對應的資源名,例如:
$ curl 'http://localhost:8080/blah-blah.txt'
<html>
<head><title>404 Not Found</title></head>
<body bgcolor="white">
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>
我們會很自然地得到 404 錯誤頁。此時再看 Nginx 錯誤日志文件,應該會看到類似下面這一行錯誤消息:
[error] 9364#0: *1 open() "/home/agentzh/test/html/blah-blah.txt" failed (2: No such file or directory)
這條錯誤消息是 ngx_static 模塊打印出來的,因為它並不能在文件系統的對應路徑上找到名為 blah-blah.txt 的文件。因為這條錯誤信息中包含有 ngx_static 試圖打開的文件的絕對路徑,所以從這個路徑不難看出,當前的“文檔根目錄”是 /home/agentzh/test/html/.
很多初學者會想當然地把 404 錯誤理解為某個 location 不存在,其實上面這個例子表明,即使 location 存在並成功匹配,也是可能返回 404 錯誤頁的。因為決定着 404 錯誤頁的是抽象的“資源”是否存在,而非某個具體的 location 是否存在。
初學者常犯的一個錯誤是忘記配置 content 階段的模塊指令,而他們自己其實並不期望使用 content 階段缺省運行的靜態資源服務,例如:
location /auth {
access_by_lua '
-- a lot of Lua code omitted here...
';
}
顯然,這個 /auth 接口只定義了 access 階段的配置指令,即 access_by_lua,並未定義任何 content 階段的配置指令。於是當我們請求 /auth 接口時,在 access 階段的 Lua 代碼會如期執行,然后 content 階段的那些靜態文件服務會緊接着自動發生作用,直至 ngx_static 模塊去文件系統上找名為 auth 的文件。而經常地,404 錯誤頁會拋出,除非運氣太好,在對應路徑上確實存在一個叫做 auth 的文件。所以,一條經驗是,當遇到意外的 404 錯誤並且又不涉及靜態文件服務時,應當首先檢查是否在對應的 location 配置塊中恰當地配置了 content 階段的模塊指令,例如 content_by_lua、 echo 以及 proxy_pass 之類。當然,Nginx 的 error.log 文件一般總是會提供各種意外問題的答案,例如對於上面這個例子,我的 error.log 中有下面這條錯誤信息:
[error] 9364#0: *1 open() "/home/agentzh/test/html/auth" failed (2: No such file or directory)
Nginx 配置指令的執行順序(八)
前面我們詳細討論了 rewrite、access 和 content 這三個最為常見的 Nginx 請求處理階段,在此過程中,也順便介紹了運行在這三個階段的眾多 Nginx 模塊及其配置指令。同時可以看到,請求處理階段的划分直接影響到了配置指令的執行順序,熟悉這些階段對於正確配置不同的 Nginx 模塊並實現它們彼此之間的協同工作是非常必要的。所以接下來我們接着討論余下的那些階段。
前面在 (一) 中提到,Nginx 處理請求的過程一共划分為 11 個階段,按照執行順序依次是 post-read、server-rewrite、find-config、rewrite、post-rewrite、preaccess、access、post-access、try-files、content 以及 log.
最先執行的 post-read 階段在 Nginx 讀取並解析完請求頭(request headers)之后就立即開始運行。這個階段像前面介紹過的 rewrite 階段那樣支持 Nginx 模塊注冊處理程序。比如標准模塊 ngx_realip 就在 post-read 階段注冊了處理程序,它的功能是迫使 Nginx 認為當前請求的來源地址是指定的某一個請求頭的值。下面這個例子就使用了 ngx_realip 模塊提供的 set_real_ip_from 和 real_ip_header 這兩條配置指令:
server {
listen 8080;
set_real_ip_from 127.0.0.1;
real_ip_header X-My-IP;
location /test {
set $addr $remote_addr;
echo "from: $addr";
}
}
這里的配置是讓 Nginx 把那些來自 127.0.0.1 的所有請求的來源地址,都改寫為請求頭 X-My-IP 所指定的值。同時該例使用了標准內建變量 $remote_addr 來輸出當前請求的來源地址,以確認是否被成功改寫。
首先在本地請求一下這個 /test 接口:
$ curl -H 'X-My-IP: 1.2.3.4' localhost:8080/test
from: 1.2.3.4
這里使用了 curl 工具的 -H 選項指定了額外的 HTTP 請求頭 X-My-IP: 1.2.3.4. 從輸出可以看到, $remote_addr 變量的值確實在 rewrite 階段就已經成為了 X-My-IP 請求頭中指定的值,即 1.2.3.4. 那么 Nginx 究竟是在什么時候改寫了當前請求的來源地址呢?答案是:在 post-read 階段。由於 rewrite 階段的運行遠在 post-read 階段之后,所以當在 location 配置塊中通過 set 配置指令讀取 $remote_addr 內建變量時,讀出的來源地址已經是經過 post-read 階段篡改過的。
如果在請求上例中的 /test 接口時沒有指定 X-My-IP 請求頭,或者提供的 X-My-IP 請求頭的值不是合法的 IP 地址,那么 Nginx 就不會對來源地址進行改寫,例如:
$ curl localhost:8080/test
from: 127.0.0.1
$ curl -H 'X-My-IP: abc' localhost:8080/test
from: 127.0.0.1
如果從另一台機器訪問這個 /test 接口,那么即使指定了合法的 X-My-IP 請求頭,也不會觸發 Nginx 對來源地址進行改寫。這是因為上例已經使用 set_real_ip_from 指令規定了來源地址的改寫操作只對那些來自 127.0.0.1 的請求生效。這種過濾機制可以避免來自其他不受信任的地址的惡意欺騙。當然,也可以通過 set_real_ip_from 指令指定一個 IP 網段(利用 (三) 中介紹過的“CIDR 記法”)。此外,同時配置多個 set_real_ip_from 語句也是允許的,這樣可以指定多個受信任的來源地址或地址段。下面是一個例子:
set_real_ip_from 10.32.10.5;
set_real_ip_from 127.0.0.0/24;
有的讀者可能會問, ngx_realip 模塊究竟有什么實際用途呢?為什么我們需要去改寫請求的來源地址呢?答案是:當 Nginx 處理的請求經過了某個 HTTP 代理服務器的轉發時,這個模塊就變得特別有用。當原始的用戶請求經過轉發之后,Nginx 接收到的請求的來源地址無一例外地變成了該代理服務器的 IP 地址,於是 Nginx 以及 Nginx 背后的應用就無法知道原始請求的真實來源。所以,一般我們會在 Nginx 之前的代理服務器中把請求的原始來源地址編碼進某個特殊的 HTTP 請求頭中(例如上例中的 X-My-IP 請求頭),然后再在 Nginx 一側把這個請求頭中編碼的地址恢復出來。這樣 Nginx 中的后續處理階段(包括 Nginx 背后的各種后端應用)就會認為這些請求直接來自那些原始的地址,代理服務器就仿佛不存在一樣。正是因為這個需求,所以 ngx_realip 模塊才需要在第一個處理階段,即 post-read 階段,注冊處理程序,以便盡可能早地改寫請求的來源。
post-read 階段之后便是 server-rewrite 階段。我們曾在 (二) 中簡單提到,當 ngx_rewrite 模塊的配置指令直接書寫在 server 配置塊中時,基本上都是運行在 server-rewrite 階段。下面就來看這樣的一個例子:
server {
listen 8080;
location /test {
set $b "$a, world";
echo $b;
}
set $a hello;
}
這里,配置語句 set $a hello 直接寫在了 server 配置塊中,因此它就運行在 server-rewrite 階段。而 server-rewrite 階段要早於 rewrite 階段運行,因此寫在 location 配置塊中的語句 set $b "$a, world" 便晚於外面的 set $a hello 語句運行。該例的測試結果證明了這一點:
$ curl localhost:8080/test
hello, world
由於 server-rewrite 階段位於 post-read 階段之后,所以 server 配置塊中的 set 指令也就總是運行在 ngx_realip 模塊改寫請求的來源地址之后。來看下面這個例子:
server {
listen 8080;
set $addr $remote_addr;
set_real_ip_from 127.0.0.1;
real_ip_header X-Real-IP;
location /test {
echo "from: $addr";
}
}
請求 /test 接口的結果如下:
$ curl -H 'X-Real-IP: 1.2.3.4' localhost:8080/test
from: 1.2.3.4
在這個例子中,雖然 set 指令寫在了 ngx_realip 的配置指令之前,但仍然晚於 ngx_realip 模塊執行。所以 $addr 變量在 server-rewrite 階段被 set 指令賦值時,從 $remote_addr 變量讀出的來源地址已經是經過改寫過的了。
Nginx 配置指令的執行順序(九)
緊接在 server-rewrite 階段后邊的是 find-config 階段。這個階段並不支持 Nginx 模塊注冊處理程序,而是由 Nginx 核心來完成當前請求與 location 配置塊之間的配對工作。換句話說,在此階段之前,請求並沒有與任何 location 配置塊相關聯。因此,對於運行在 find-config 階段之前的 post-read 和 server-rewrite 階段來說,只有 server 配置塊以及更外層作用域中的配置指令才會起作用。這就是為什么只有寫在 server 配置塊中的 ngx_rewrite 模塊的指令才會運行在 server-rewrite 階段,這也是為什么前面所有例子中的 ngx_realip 模塊的指令也都特意寫在了 server 配置塊中,以確保其注冊在 post-read 階段的處理程序能夠生效。
當 Nginx 在 find-config 階段成功匹配了一個 location 配置塊后,會立即打印一條調試信息到錯誤日志文件中。我們來看這樣的一個例子:
location /hello {
echo "hello world";
}
如果啟用了 Nginx 的“調試日志”,那么當請求 /hello 接口時,便可以在 error.log 文件中過濾出下面這一行信息:
$ grep 'using config' logs/error.log
[debug] 84579#0: *1 using configuration "/hello"
我們有意省略了信息行首的時間戳,以便放在這里。
運行在 find-config 階段之后的便是我們的老朋友 rewrite 階段。由於 Nginx 已經在 find-config 階段完成了當前請求與 location 的配對,所以從 rewrite 階段開始,location 配置塊中的指令便可以產生作用。前面已經介紹過,當 ngx_rewrite 模塊的指令用於 location 塊中時,便是運行在這個 rewrite 階段。另外, ngx_set_misc 模塊的指令也是如此,還有 ngx_lua 模塊的 set_by_lua 指令和 rewrite_by_lua 指令也不例外。
rewrite 階段再往后便是所謂的 post-rewrite 階段。這個階段也像 find-config 階段那樣不接受 Nginx 模塊注冊處理程序,而是由 Nginx 核心完成 rewrite 階段所要求的“內部跳轉”操作(如果 rewrite 階段有此要求的話)。先前在 (二) 中已經介紹過了“內部跳轉”的概念,同時演示了如何通過 echo_exec 指令或者 rewrite 指令來發起“內部跳轉”。由於 echo_exec 指令運行在 content 階段,與這里討論的 post-rewrite 階段無關,於是我們感興趣的便只剩下運行在 rewrite 階段的 rewrite 指令。回顧一下 (二) 中演示過的這個例子:
server {
listen 8080;
location /foo {
set $a hello;
rewrite ^ /bar;
}
location /bar {
echo "a = [$a]";
}
}
這里在 location /foo 中通過 rewrite 指令把當前請求的 URI 無條件地改寫為 /bar,同時發起一個“內部跳轉”,最終跳進了 location /bar 中。這里比較有趣的地方是“內部跳轉”的工作原理。“內部跳轉”本質上其實就是把當前的請求處理階段強行倒退到 find-config 階段,以便重新進行請求 URI 與 location 配置塊的配對。比如上例中,運行在 rewrite 階段的 rewrite 指令就讓當前請求的處理階段倒退回了 find-config 階段。由於此時當前請求的 URI 已經被 rewrite 指令修改為了 /bar,所以這一次換成了 location /bar 與當前請求相關聯,然后再接着從 rewrite 階段往下執行。
不過這里更有趣的地方是,倒退回 find-config 階段的動作並不是發生在 rewrite 階段,而是發生在后面的 post-rewrite 階段。上例中的 rewrite 指令只是簡單地指示 Nginx 有必要在 post-rewrite 階段發起“內部跳轉”。這個設計對於 Nginx 初學者來說,或許顯得有些古怪:“為什么不直接在 rewrite 指令執行時立即進行跳轉呢?”答案其實很簡單,那就是為了在最初匹配的 location 塊中支持多次反復地改寫 URI,例如:
location /foo {
rewrite ^ /bar;
rewrite ^ /baz;
echo foo;
}
location /bar {
echo bar;
}
location /baz {
echo baz;
}
這里在 location /foo 中連續把當前請求的 URI 改寫了兩遍:第一遍先無條件地改寫為 /bar,第二遍再無條件地改寫為 /baz. 而這兩條 rewrite 語句只會最終導致 post-rewrite 階段發生一次“內部跳轉”操作,從而不至於在第一次改寫 URI 時就直接跳離了當前的 location 而導致后面的 rewrite 語句沒有機會執行。請求 /foo 接口的結果證實了這一點:
$ curl localhost:8080/foo
baz
從輸出結果可以看到,上例確實成功地從 /foo 一步跳到了 /baz 中。如果啟用 Nginx “調試日志”的話,還可以從 find-config 階段生成的 location 塊的匹配信息中進一步證實這一點:
$ grep 'using config' logs/error.log
[debug] 89449#0: *1 using configuration "/foo"
[debug] 89449#0: *1 using configuration "/baz"
我們看到,對於該次請求,Nginx 一共只匹配過 /foo 和 /baz 這兩個 location,從而只發生過一次“內部跳轉”。
當然,如果在 server 配置塊中直接使用 rewrite 配置指令對請求 URI 進行改寫,則不會涉及“內部跳轉”,因為此時 URI 改寫發生在 server-rewrite 階段,早於執行 location 配對的 find-config 階段。比如下面這個例子:
server {
listen 8080;
rewrite ^/foo /bar;
location /foo {
echo foo;
}
location /bar {
echo bar;
}
}
這里,我們在 server-rewrite 階段就把那些以 /foo 起始的 URI 改寫為 /bar,而此時請求並沒有和任何 location 相關聯,所以 Nginx 正常往下運行 find-config 階段,完成最終的 location 匹配。如果我們請求上例中的 /foo 接口,那么 location /foo 根本就沒有機會匹配,因為在第一次(也是唯一的一次)運行 find-config 階段時,當前請求的 URI 已經被改寫為 /bar,從而只會匹配 location /bar. 實際請求的輸出正是如此:
$ curl localhost:8080/foo
bar
Nginx “調試日志”可以再一次佐證我們的結論:
$ grep 'using config' logs/error.log
[debug] 92693#0: *1 using configuration "/bar"
可以看到,Nginx 總共只進行過一次 location 匹配,並無“內部跳轉”發生。
Nginx 配置指令的執行順序(十)
運行在 post-rewrite 階段之后的是所謂的 preaccess 階段。該階段在 access 階段之前執行,故名 preaccess.
標准模塊 ngx_limit_req 和 ngx_limit_zone 就運行在此階段,前者可以控制請求的訪問頻度,而后者可以限制訪問的並發度。這里我們僅僅和它們打個照面,后面還會有機會專門接觸到這兩個模塊。
前面反復提到的標准模塊 ngx_realip 其實也在這個階段注冊了處理程序。有些讀者可能會問:“這是為什么呢?它不是已經在 post-read 階段注冊處理程序了嗎?”我們不妨通過下面這個例子來揭曉答案:
server {
listen 8080;
location /test {
set_real_ip_from 127.0.0.1;
real_ip_header X-Real-IP;
echo "from: $remote_addr";
}
}
與先看前到的例子相比,此例最重要的區別在於把 ngx_realip 的配置指令放在了 location 配置塊中。前面我們介紹過,Nginx 匹配 location 的動作發生在 find-config 階段,而 find-config 階段遠遠晚於 post-read 階段執行,所以在 post-read 階段,當前請求還沒有和任何 location 相關聯。在這個例子中,因為 ngx_realip 的配置指令都寫在了 location 配置塊中,所以在 post-read 階段, ngx_realip 模塊的處理程序沒有看到任何可用的配置信息,便不會執行來源地址的改寫工作了。
為了解決這個難題, ngx_realip 模塊便又特意在 preaccess 階段注冊了處理程序,這樣它才有機會運行 location 塊中的配置指令。正是因為這個緣故,上面這個例子的運行結果才符合直覺預期:
$ curl -H 'X-Real-IP: 1.2.3.4' localhost:8080/test
from: 1.2.3.4
不幸的是, ngx_realip 模塊的這個解決方案還是存在漏洞的,比如下面這個例子:
server {
listen 8080;
location /test {
set_real_ip_from 127.0.0.1;
real_ip_header X-Real-IP;
set $addr $remote_addr;
echo "from: $addr";
}
}
這里,我們在 rewrite 階段將 $remote_addr 的值保存到了用戶變量 $addr 中,然后再輸出。因為 rewrite 階段先於 preaccess 階段執行,所以當 ngx_realip 模塊尚未在 preaccess 階段改寫來源地址時,最初的來源地址就已經在 rewrite 階段被讀取了。上例的實際請求結果證明了我們的結論:
$ curl -H 'X-Real-IP: 1.2.3.4' localhost:8080/test
from: 127.0.0.1
輸出的地址確實是未經改寫過的。Nginx 的“調試日志”可以進一步確認這一點:
$ grep -E 'http script (var|set)|realip' logs/error.log
[debug] 32488#0: *1 http script var: "127.0.0.1"
[debug] 32488#0: *1 http script set $addr
[debug] 32488#0: *1 realip: "1.2.3.4"
[debug] 32488#0: *1 realip: 0100007F FFFFFFFF 0100007F
[debug] 32488#0: *1 http script var: "127.0.0.1"
其中第一行調試信息
[debug] 32488#0: *1 http script var: "127.0.0.1"
是 set 語句讀取 $remote_addr 變量時產生的。信息中的字符串 "127.0.0.1" 便是 $remote_addr 當時讀出來的值。
而第二行調試信息
[debug] 32488#0: *1 http script set $addr
則顯示我們對變量 $addr 進行了賦值操作。
后面兩行信息
[debug] 32488#0: *1 realip: "1.2.3.4"
[debug] 32488#0: *1 realip: 0100007F FFFFFFFF 0100007F
是 ngx_realip 模塊在 preaccess 階段改寫當前請求的來源地址。我們看到,改寫后的新地址確實是期望的 1.2.3.4. 但很明顯這個操作發生在 $addr 變量賦值之后,所以已經太遲了。
而最后一行信息
[debug] 32488#0: *1 http script var: "127.0.0.1"
則是 echo 配置指令在輸出時讀取變量 $addr 時產生的,我們看到它的值是改寫前的來源地址。
看到這里,有的讀者可能會問:“如果 ngx_realip 模塊不在 preaccess 階段注冊處理程序,而在 rewrite 階段注冊,那么上例不就可以工作了?”答案是:不一定。因為 ngx_rewrite 模塊的處理程序也同樣注冊在 rewrite 階段,而前面我們在 (二) 中特別提到,在這種情況下,不同模塊之間的執行順序一般是不確定的,所以 ngx_realip 的處理程序可能仍然在 set 語句之后執行。
一個建議是:盡量在 server 配置塊中配置 ngx_realip 這樣的模塊,以避免上面介紹的這種棘手的例外情況。
運行在 preaccess 階段之后的則是我們的另一個老朋友,access 階段。前面我們已經知道了,標准模塊 ngx_access、第三方模塊 ngx_auth_request 以及第三方模塊 ngx_lua 的 access_by_lua 指令就運行在這個階段。
access 階段之后便是 post-access 階段。從這個階段的名字,我們也能一眼看出它是緊跟在 access 階段后面執行的。這個階段也和 post-rewrite 階段類似,並不支持 Nginx 模塊注冊處理程序,而是由 Nginx 核心自己完成一些處理工作。post-access 階段主要用於配合 access 階段實現標准 ngx_http_core 模塊提供的配置指令 satisfy 的功能。
對於多個 Nginx 模塊注冊在 access 階段的處理程序, satisfy 配置指令可以用於控制它們彼此之間的協作方式。比如模塊 A 和 B 都在 access 階段注冊了與訪問控制相關的處理程序,那就有兩種協作方式,一是模塊 A 和模塊 B 都得通過驗證才算通過,二是模塊 A 和模塊 B 只要其中任一個通過驗證就算通過。第一種協作方式稱為 all 方式(或者說“與關系”),第二種方式則被稱為 any 方式(或者說“或關系”)。默認情況下,Nginx 使用的是 all 方式。下面是一個例子:
location /test {
satisfy all;
deny all;
access_by_lua 'ngx.exit(ngx.OK)';
echo something important;
}
這里,我們在 /test 接口中同時配置了 ngx_access 模塊和 ngx_lua 模塊,這樣 access 階段就由這兩個模塊一起來做檢驗工作。其中,語句 deny all 會讓 ngx_access 模塊的處理程序總是拒絕當前請求,而語句 access_by_lua 'ngx.exit(ngx.OK)' 則總是允許訪問。當我們通過 satisfy 指令配置了 all 方式時,就需要 access 階段的所有模塊都通過驗證,但不幸的是,這里 ngx_access 模塊總是會拒絕訪問,所以整個請求就會被拒:
$ curl localhost:8080/test
<html>
<head><title>403 Forbidden</title></head>
<body bgcolor="white">
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx</center>
</body>
</html>
細心的讀者會在 Nginx 錯誤日志文件中看到類似下面這一行的出錯信息:
[error] 6549#0: *1 access forbidden by rule
然而,如果我們把上例中的 satisfy all 語句更改為 satisfy any,
location /test {
satisfy any;
deny all;
access_by_lua 'ngx.exit(ngx.OK)';
echo something important;
}
結果則會完全不同:
$ curl localhost:8080/test
something important
即請求反而最終通過了驗證。這是因為在 any 方式下,access 階段只要有一個模塊通過了驗證,就會認為請求整體通過了驗證,而在上例中, ngx_lua 模塊的 access_by_lua 語句總是會通過驗證的。
在配置了 satisfy any 的情況下,只有當 access 階段的所有模塊的處理程序都拒絕訪問時,整個請求才會被拒,例如:
location /test {
satisfy any;
deny all;
access_by_lua 'ngx.exit(ngx.HTTP_FORBIDDEN)';
echo something important;
}
此時訪問 /test 接口才會得到 403 Forbidden 錯誤頁。這里,post-access 階段參與了 access 階段各模塊處理程序的“或關系”的實現。
值得一提的是,上面這幾個的例子需要 ngx_lua 0.5.0rc19 或以上版本;之前的版本是不能和 satisfy any 配置語句一起工作的。
Nginx 配置指令的執行順序(十一)
緊跟在 post-access 階段之后的是 try-files 階段。這個階段專門用於實現標准配置指令 try_files 的功能,並不支持 Nginx 模塊注冊處理程序。由於 try_files 指令在許多 FastCGI 應用的配置中都有用到,所以我們不妨在這里簡單介紹一下。
try_files 指令接受兩個以上任意數量的參數,每個參數都指定了一個 URI. 這里假設配置了 N 個參數,則 Nginx 會在 try-files 階段,依次把前 N-1 個參數映射為文件系統上的對象(文件或者目錄),然后檢查這些對象是否存在。一旦 Nginx 發現某個文件系統對象存在,就會在 try-files 階段把當前請求的 URI 改寫為該對象所對應的參數 URI(但不會包含末尾的斜杠字符,也不會發生 “內部跳轉”)。如果前 N-1 個參數所對應的文件系統對象都不存在,try-files 階段就會立即發起“內部跳轉”到最后一個參數(即第 N 個參數)所指定的 URI.
前面在 (六) 和 (七) 中已經看到靜態資源服務模塊會把當前請求的 URI 映射到文件系統,通過 root 配置指令所指定的“文檔根目錄”進行映射。例如,當“文檔根目錄”是 /var/www/ 的時候,請求 URI /foo/bar 會被映射為文件 /var/www/foo/bar,而請求 URI /foo/baz/ 則會被映射為目錄 /var/www/foo/baz/. 注意這里是如何通過 URI 末尾的斜杠字符是否存在來區分“目錄”和“文件”的。我們正在討論的 try_files 配置指令使用同樣的規則來完成其各個參數 URI 到文件系統對象的映射。
不妨來看下面這個例子:
root /var/www/;
location /test {
try_files /foo /bar/ /baz;
echo "uri: $uri";
}
location /foo {
echo foo;
}
location /bar/ {
echo bar;
}
location /baz {
echo baz;
}
這里通過 root 指令把“文檔根目錄”配置為 /var/www/,如果你系統中的 /var/www/ 路徑下存放有重要數據,則可以把它替換為其他任意路徑,但此路徑對運行 Nginx worker 進程的系統帳號至少有可讀權限。我們在 location /test 中使用了 try_files 配置指令,並提供了三個參數,/foo、/bar/ 和 /baz. 根據前面對 try_files 指令的介紹,我們可以知道,它會在 try-files 階段依次檢查前兩個參數 /foo 和 /bar/ 所對應的文件系統對象是否存在。
不妨先來做一組實驗。假設現在 /var/www/ 路徑下是空的,則第一個參數 /foo 映射成的文件 /var/www/foo 是不存在的;同樣,對於第二個參數 /bar/ 所映射成的目錄 /var/www/bar/ 也是不存在的。於是此時 Nginx 會在 try-files 階段發起到最后一個參數所指定的 URI(即 /baz)的“內部跳轉”。實際的請求結果證實了這一點:
$ curl localhost:8080/test
baz
顯然,該請求最終和 location /baz 綁定在一起,執行了輸出 baz 字符串的工作。上例中定義的 location /foo 和 location /bar/ 完全不會參與這里的運行過程,因為對於 try_files 的前 N-1 個參數,Nginx 只會檢查文件系統,而不會去執行 URI 與 location 之間的匹配。
對於上面這個請求,Nginx 會產生類似下面這樣的“調試日志”:
$ grep trying logs/error.log
[debug] 3869#0: *1 trying to use file: "/foo" "/var/www/foo"
[debug] 3869#0: *1 trying to use dir: "/bar" "/var/www/bar"
[debug] 3869#0: *1 trying to use file: "/baz" "/var/www/baz"
通過這些信息可以清楚地看到 try-files 階段發生的事情:Nginx 依次檢查了文件 /var/www/foo 和目錄 /var/www/bar,末了又處理了最后一個參數 /baz. 這里最后一條“調試信息”容易產生誤解,會讓人誤以為 Nginx 也把最后一個參數 /baz 給映射成了文件系統對象進行檢查,事實並非如此。當 try_files 指令處理到它的最后一個參數時,總是直接執行“內部跳轉”,而不論其對應的文件系統對象是否存在。
接下來再做一組實驗:在 /var/www/ 下創建一個名為 foo 的文件,其內容為 hello world(注意你需要有 /var/www/ 目錄下的寫權限):
$ echo 'hello world' > /var/www/foo
然后再請求 /test 接口:
$ curl localhost:8080/test
uri: /foo
這里發生了什么?我們來看, try_files 指令的第一個參數 /foo 可以映射為文件 /var/www/foo,而 Nginx 在 try-files 階段發現此文件確實存在,於是立即把當前請求的 URI 改寫為這個參數的值,即 /foo,並且不再繼續檢查后面的參數,而直接運行后面的請求處理階段。
上面這個請求在 try-files 階段所產生的“調試日志”如下:
$ grep trying logs/error.log
[debug] 4132#0: *1 trying to use file: "/foo" "/var/www/foo"
顯然,在 try-files 階段,Nginx 確實只檢查和處理了 /foo 這一個參數,而后面的參數都被“短路”掉了。
類似地,假設我們刪除剛才創建的 /var/www/foo 文件,而在 /var/www/ 下創建一個名為 bar 的子目錄:
$ mkdir /var/www/bar
則請求 /test 的結果也是類似的:
$ curl localhost:8080/test
uri: /bar
在這種情況下,Nginx 在 try-files 階段發現第一個參數 /foo 對應的文件不存在,就會轉向檢查第二個參數對應的文件系統對象(在這里便是目錄 /var/www/bar/)。由於此目錄存在,Nginx 就會把當前請求的 URI 改寫為第二個參數的值,即 /bar(注意,原始參數值是 /bar/,但 try_files 會自動去除末尾的斜杠字符)。
這一組實驗所產生的“調試日志”如下:
$ grep trying logs/error.log
[debug] 4223#0: *1 trying to use file: "/foo" "/var/www/foo"
[debug] 4223#0: *1 trying to use dir: "/bar" "/var/www/bar"
我們看到, try_files 指令在這里只檢查和處理了它的前兩個參數。
通過前面這幾組實驗不難看到, try_files 指令本質上只是有條件地改寫當前請求的 URI,而這里說的“條件”其實就是文件系統上的對象是否存在。當“條件”都不滿足時,它就會無條件地發起一個指定的“內部跳轉”。當然,除了無條件地發起“內部跳轉”之外, try_files 指令還支持直接返回指定狀態碼的 HTTP 錯誤頁,例如:
try_files /foo /bar/ =404;
這行配置是說,當 /foo 和 /bar/ 參數所對應的文件系統對象都不存在時,就直接返回 404 Not Found 錯誤頁。注意這里它是如何使用等號字符前綴來標識 HTTP 狀態碼的。
