agentzh 的 Nginx 教程(版本 2015.03.19) 第一篇

轉載:http://openresty.org/download/agentzh-nginx-tutorials-zhcn.html#02-NginxDirectiveExecOrder10

目錄

緣起

其實這兩年爲 Nginx 世界做了這麼多的事情,一直想通過一系列教程性的文章把我的那些工作成果和所學所知都介紹給更多的朋友。現在終於下決心在新浪博客 http://blog.sina.com.cn/openresty 上面用中文寫點東西,每一篇東西都會有一個小主題,但次序和組織上就不那麼講究了,畢竟並不是一本完整的圖書,或許未來我會將之整理出書也不一定。

我現在編寫的教程是按所謂的“系列”來劃分的,比如首先連載的“Nginx 變量漫談”系列。每一個系列基本上都可以粗略對應到未來出的 Nginx 書中的一“章”(當然內部還會重新組織內容並劃分出“節”來)。我面向的讀者是各個水平層次的 Nginx 用戶,同時也包括未使用過 Nginx 的 Apache、Lighttpd 等服務器的老用戶。

我只保證這些教程中的例子至少兼容到 Nginx 0.8.54,別用更老的版本來找我的錯處,我一概不管,畢竟眼下最新的穩定版已經是 1.0.10 了。

凡在教程裏面提到的模塊,都是經過生產環境檢驗過的。即便是標準模塊,如果沒有達到生產標準,或者有重要的 bug,我也不會提及。

我在教程中會大量使用非標準的第三方模塊,如果你怕麻煩,不願自己一個一個從網上下載和安裝那些個模塊,我推薦你下載和安裝我維護的 ngx_openresty 這個軟件包:

http://openresty.org/

教程裏提及的模塊,包括足夠新的 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 等格式的電子書文件可以從下面這個位置下載:

http://openresty.org/#eBooks

章亦春 (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 "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 在讀取時,總是會得到“主請求”的請求方法,比如 GETPOST 之類。我們來測試一下:

    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 了。這樣的結果確實太意外了。

從這個例子我們再次看到,父子請求間的變量共享,實在不是一個好主意。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章