nginx 的配置文件使用的就是一門微型的編程語言,許多真實世界里的 Nginx 配置文件其實就是一個一個的小程序。當然,是不是“圖靈完全的”暫且不論,至少據我觀察,它在設計上受 Perl 和 Bourne shell 這兩種語言的影響很大。在這一點上,相比 Apache 和 Lighttpd 等其他 Web 服務器的配置記法,不能不說算是 Nginx 的一大特色了。既然是編程語言,一般也就少不了“變量”這種東西(當然,Haskell 這樣奇怪的函數式語言除外了)。
熟悉 Perl、Bourne shell、C/C++ 等命令式編程語言的朋友肯定知道,變量說白了就是存放“值”的容器。而所謂“值”,在許多編程語言里,既可以是 3.14 這樣的數值,也可以是 hello world 這樣的字符串,甚至可以是像數組、哈希表這樣的復雜數據結構。然而,在 Nginx 配置中,變量只能存放一種類型的值,因為也只存在一種類型的值,那就是字符串。
比如我們的 nginx.conf 文件中有下面這一行配置:
1
|
set $a "hello world"; |
我們使用了標準 ngx_rewrite 模塊的 set 配置指令對變量 $a 進行了賦值操作。特別地,我們把字符串 hello world 賦給了它。
我們看到,Nginx 變量名前面有一個 $ 符號,這是記法上的要求。所有的 Nginx 變量在 Nginx 配置文件中引用時都須帶上 $ 前綴。這種表示方法和 Perl、PHP 這些語言是相似的。
雖然 $ 這樣的變量前綴修飾會讓正統的 Java 和 C# 程序員不舒服,但這種表示方法的好處也是顯而易見的,那就是可以直接把變量嵌入到字符串常量中以構造出新的字符串:
1
2
|
set $a hello; set $b "$a, $a"; |
這里我們通過已有的 Nginx 變量 $a 的值,來構造變量 $b 的值,于是這兩條指令順序執行完之后,$a 的值是 hello,而 $b 的值則是 hello, hello. 這種技術在 Perl 世界里被稱為“變量插值”(variable interpolation),它讓專門的字符串拼接運算符變得不再那么必要。我們在這里也不妨采用此術語。
我們來看一個比較完整的配置示例:
1
2
3
4
5
6
7
8
|
server { listen 8080; location /test { set $foo hello; echo "foo: $foo"; } } |
這個例子省略了 nginx.conf 配置文件中最外圍的 http 配置塊以及 events 配置塊。使用 curl 這個 HTTP 客戶端在命令行上請求這個 /test 接口,我們可以得到
1
2
|
$ curl 'http://localhost:8080/test' foo: hello |
這里我們使用第三方 ngx_echo 模塊的 echo 配置指令將 $foo 變量的值作為當前請求的響應體輸出。
我們看到,echo 配置指令的參數也支持“變量插值”。不過,需要說明的是,并非所有的配置指令都支持“變量插值”。事實上,指令參數是否允許“變量插值”,取決于該指令的實現模塊。
如果我們想通過 echo 指令直接輸出含有“美元符”($)的字符串,那么有沒有辦法把特殊的 $ 字符給轉義掉呢?答案是否定的(至少到目前最新的 Nginx 穩定版 1.0.10)。不過幸運的是,我們可以繞過這個限制,比如通過不支持“變量插值”的模塊配置指令專門構造出取值為 $ 的 Nginx 變量,然后再在 echo 中使用這個變量。看下面這個例子:
1
2
3
4
5
6
7
8
9
10
11
|
geo $dollar { default "$"; } server { listen 8080; location /test { echo "This is a dollar sign: $dollar"; } } |
測試結果如下:
1
2
|
$ curl 'http://localhost:8080/test' This is a dollar sign: $ |
這里用到了標準模塊 ngx_geo 提供的配置指令 geo 來為變量 $dollar 賦予字符串 "$",這樣我們在下面需要使用美元符的地方,就直接引用我們的 $dollar 變量就可以了。其實 ngx_geo 模塊最常規的用法是根據客戶端的 IP 地址對指定的 Nginx 變量進行賦值,這里只是借用它以便“無條件地”對我們的 $dollar 變量賦予“美元符”這個值。
在“變量插值”的上下文中,還有一種特殊情況,即當引用的變量名之后緊跟著變量名的構成字符時(比如后跟字母、數字以及下劃線),我們就需要使用特別的記法來消除歧義,例如:
1
2
3
4
5
6
7
8
|
server { listen 8080; location /test { set $first "hello "; echo "${first}world"; } } |
這里,我們在 echo 配置指令的參數值中引用變量 $first 的時候,后面緊跟著 world 這個單詞,所以如果直接寫作 "$firstworld" 則 Nginx “變量插值”計算引擎會將之識別為引用了變量 $firstworld. 為了解決這個難題,Nginx 的字符串記法支持使用花括號在 $ 之后把變量名圍起來,比如這里的 ${first}. 上面這個例子的輸出是:
1
2
|
$ curl 'http://localhost:8080/test hello world |
set 指令(以及前面提到的 geo 指令)不僅有賦值的功能,它還有創建 Nginx 變量的副作用,即當作為賦值對象的變量尚不存在時,它會自動創建該變量。比如在上面這個例子中,如果 $a 這個變量尚未創建,則 set 指令會自動創建 $a 這個用戶變量。如果我們不創建就直接使用它的值,則會報錯。例如
1
2
3
4
5
6
7
|
server { listen 8080; location /bad { echo $foo; } } |
此時 Nginx 服務器會拒絕加載配置:
1
|
[emerg] unknown "foo" variable |
是的,我們甚至都無法啟動服務!
有趣的是,Nginx 變量的創建和賦值操作發生在全然不同的時間階段。Nginx 變量的創建只能發生在 Nginx 配置加載的時候,或者說 Nginx 啟動的時候;而賦值操作則只會發生在請求實際處理的時候。這意味著不創建而直接使用變量會導致啟動失敗,同時也意味著我們無法在請求處理時動態地創建新的 Nginx 變量。
Nginx 變量一旦創建,其變量名的可見范圍就是整個 Nginx 配置,甚至可以跨越不同虛擬主機的 server 配置塊。我們來看一個例子:
1
2
3
4
5
6
7
8
9
10
11
12
|
server { listen 8080; location /foo { echo "foo = [$foo]"; } location /bar { set $foo 32; echo "foo = [$foo]"; } } |
這里我們在 location /bar 中用 set 指令創建了變量 $foo,于是在整個配置文件中這個變量都是可見的,因此我們可以在 location /foo 中直接引用這個變量而不用擔心 Nginx 會報錯。
下面是在命令行上用 curl 工具訪問這兩個接口的結果:
1
2
3
4
5
6
|
$ 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 變量的另一個常見誤區是認為變量容器的生命期,是與 location 配置塊綁定的。其實不然。我們來看一個涉及“內部跳轉”的例子:
1
2
3
4
5
6
7
8
9
10
|
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 這行輸出。測試證實了這一點:
1
2
|
$ curl localhost:8080/foo a = [hello] |
但如果我們從客戶端直接訪問 /bar 接口,就會得到空的 $a 變量的值,因為它依賴于 location /foo 來對 $a 進行初始化。從上面這個例子我們看到,一個請求在其處理過程中,即使經歷多個不同的 location 配置塊,它使用的還是同一套 Nginx 變量的副本。這里,我們也首次涉及到了“內部跳轉”這個概念。值得一提的是,標準 ngx_rewrite 模塊的 rewrite 配置指令其實也可以發起“內部跳轉”,例如上面那個例子用 rewrite 配置指令可以改寫成下面這樣的形式:
1
2
3
4
5
6
7
8
9
10
|
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 (未經解碼,并且包含請求參數)。請看下面這個例子:
1
2
3
4
|
location /test { echo "uri = $uri"; echo "request_uri = $request_uri"; } |
這里為了簡單起見,連 server 配置塊也省略了,和前面所有示例一樣,我們監聽的依然是 8080 端口。在這個例子里,我們把 $uri 和 $request_uri 的值輸出到響應體中去。下面我們用不同的請求來測試一下這個 /test 接口:
1
2
3
4
5
6
7
8
9
|
$ 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 參數的值,而且還是未解碼的原始形式的值。我們來看一個比較完整的示例:
1
2
3
4
|
location /test { echo "name: $arg_name"; echo "class: $arg_class"; } |
然后在命令行上使用各種參數組合去請求這個 /test 接口:
1
2
3
4
5
6
7
8
9
|
$ 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,等等:
1
2
3
4
5
6
|
$ 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 配置指令:
1
2
3
4
5
6
|
location /test { set_unescape_uri $name $arg_name; set_unescape_uri $class $arg_class; echo "name: $name"; echo "class: $class"; } |
現在我們再看一下效果:
1
2
3
|
$ 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. 對只讀變量進行賦值是應當絕對避免的,因為會有意想不到的后果,比如:
1
2
3
4
|
location /bad { set $uri /blah; echo $uri; } |
這個有問題的配置會讓 Nginx 在啟動的時候報出一條令人匪夷所思的錯誤:
1
|
[emerg] the duplicate "uri" variable in ... |
如果你嘗試改寫另外一些只讀的內建變量,比如 $arg_XXX 變量,在某些 Nginx 的版本中甚至可能導致進程崩潰。
也有一些內建變量是支持改寫的,其中一個例子是 $args. 這個變量在讀取時返回當前請求的 URL 參數串(即請求 URL 中問號后面的部分,如果有的話 ),而在賦值時可以直接修改參數串。我們來看一個例子:
1
2
3
4
5
6
|
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 接口:
1
2
3
4
5
6
|
$ 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 的修改會影響到所有部分的功能。我們來看一個例子:
1
2
3
4
5
6
|
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 自然也會隨之變化。測試的結果證實了這一點:
1
2
3
|
$ 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 的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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 接口:
1
2
|
$ 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)。