agentzh 的 Nginx 教程
目錄
- 緣起
- Nginx 教程的連載計劃
- Nginx 變量漫談(一)
- Nginx 變量漫談(二)
- Nginx 變量漫談(三)
- Nginx 變量漫談(四)
- Nginx 變量漫談(五)
- Nginx 變量漫談(六)
- Nginx 變量漫談(七)
- Nginx 變量漫談(八)
- Nginx 配置指令的執行順序(一)
- Nginx 配置指令的執行順序(二)
- Nginx 配置指令的執行順序(三)
- Nginx 配置指令的執行順序(四)
- Nginx 配置指令的執行順序(五)
- Nginx 配置指令的執行順序(六)
- Nginx 配置指令的執行順序(七)
- Nginx 配置指令的執行順序(八)
- Nginx 配置指令的執行順序(九)
- Nginx 配置指令的執行順序(十)
- Nginx 配置指令的執行順序(十一) </ul>
- 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 社區的力量 </ul>
緣起
其實這兩年為 Nginx 世界做了這么多的事情,一直想通過一系列教程性的文章把我的那些工作成果和所學所知都介紹給更多的朋友。現在終于下決心在新浪博客 http://blog.sina.com.cn/openresty 上面用中文寫點東西,每一篇東西都會有一個小主題,但次序和組織上就不那么講究了,畢竟并不是一本完整的圖書,或許未來我會將之整理出書也不一定。
我現在編寫的教程是按所謂的“系列”來劃分的,比如首先連載的“Nginx 變量漫談”系列。每一個系列基本上都可以粗略對應到未來出的 Nginx 書中的一“章”(當然內部還會重新組織內容并劃分出“節”來)。我面向的讀者是各個水平層次的 Nginx 用戶,同時也包括未使用過 Nginx 的 Apache、Lighttpd 等服務器的老用戶。
我只保證這些教程中的例子至少兼容到 Nginx0.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 變量漫談(一)
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/testhello 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/fooa = [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 "original 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源碼文件(或者 Lua 字節碼文件)里,然后在 Nginx 配置文件中引用這些文件的路徑。
回到上面這個例子,我們在 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 空字符串(""),和剛才的 Luanil值就完全區分開了。
這種區分在有些應用場景下非常重要,比如有的 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
我們看到,cookieuser不存在以及取值為空字符串這兩種情況被很好地區分開了:當 cookieuser不存在時,Lua 代碼中的ngx.var.cookie_user返回了期望的 Luanil值。
在 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 配置指令的參數中提及了$blah符號,但卻并沒有觸發“變量插值”(否則 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 模塊可以很方便地處理這樣具有不定個數的組成元素的輸入數據,例如此例中的namesURL 參數值就是由不定個數的逗號分隔的名字所組成。不過,這種類型的復雜任務通過 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/test56
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.gzcd 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 ' <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 -spid$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 10975dtrace: 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 '這樣我們的D腳本統計出來的平均值將更加接近“真實值”。
在我的蘋果系統上,一次典型的測試結果如下:
ngx_access 組 18146access_by_lua 組 35011
空白對照組 15887
把前兩組的結果分別減去“空白對照組”的結果可以得到
ngx_access 組 2259access_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/testfrom: 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/testfrom: 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/testhello, 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/testfrom: 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/foobaz
從輸出結果可以看到,上例確實成功地從/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/foobar
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/testfrom: 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/testfrom: 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/testsomething 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/testbaz
顯然,該請求最終和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/testuri: /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/testuri: /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 狀態碼的。
來自:https://openresty.org/download/agentzh-nginx-tutorials-zhcn.html#01-NginxVariables03