WEB編程基礎

談論WEB編程的時候常說天天在寫CGI,那么CGI是什么呢?可能很多時候并不會去深究這些基礎概念,再比如除了CGI還有FastCGI, wsgi, uwsgi等,那這些又有什么區別呢?為了總結這些這些WEB編程基礎知識,于是寫了此文,如有錯誤,懇請指正,示例代碼見 web-basis

1 CGI

1.1 CGI原理

在說明CGI是什么之前,我們先來說說CGI不是什么。

  • CGI不是一門編程語言。它的實現對編程語言沒有限定,你可以用python,php,perl,shell,C語言等。
  • CGI不是一個編程模式。你可以使用任何你熟悉的方式實現它。
  • CGI也不復雜,不需要你是一個編程老鳥,菜鳥一樣可以愉快的寫自己的CGI。

那么CGI到底是什么?CGI全稱是Common Gateway Interface,即通用網關接口。我們可能對API(Application Programming Interface)會很熟悉,CGI就是WEB服務器的API。WEB服務器顧名思義,就是發送網頁給瀏覽器的軟件,瀏覽器稱之為web client,WEB服務器是web server。瀏覽器作為客戶端,它做的工作就是向WEB服務器請求文件(比如HTML文檔,圖片,樣式文件以及任何其他文件等),一般的WEB服務器的功能就是發送存儲在服務器上的靜態文件給發送請求的客戶端。

那么問題來了,有些時候,我們需要發送動態的數據給客戶端,這就需要我們寫程序來動態生成數據并返回,這就是CGI用處所在。需要強調的是,WEB服務器和客戶端之間是不能交互的,CGI程序不能要求用戶輸入一些參數,處理并返回輸出,然后要求用戶繼續輸入,這也是CGI能夠保持簡單的原因之一。CGI程序每次只能最多獲取一次用戶輸入,然后處理并返回一次輸出。那么CGI如何獲取用戶輸入呢?

CGI程序獲取用戶輸入依賴瀏覽器發送請求的方式。一般來說,瀏覽器的HTTP請求會以GET或者POST的方式發送。瀏覽器使用HTML表單獲取用戶輸入,HTML表單可以指定瀏覽器發送請求的方法是GET還是POST,它們不同在于GET方法會將用戶輸入參數作為URL一部分,而POST的優勢在于:

  • 你可以發送更多的數據(URL長度是有限制的)
  • 發送數據不會在URL中被記錄(例如你要發送密碼放到URL中是不太安全的),也不會出現在瀏覽器的地址欄中。

那么CGI程序如何知道客戶端請求是哪種方法呢?在WEB服務器加載你的CGI程序前,會設置一些環境變量讓CGI程序知道去哪里獲取用戶輸入數據以及數據大小。比如 REQUEST_METHOD這個環境變量會設置為客戶端的請求方法如GET/POST/HEAD等。而CONTENT_LENGTH環境變量會告訴你應該從stdin中讀取多少字節數據。CONTENT_TYPE則是告訴你客戶端數據類型,是來自表單還是其他來源。

當CGI程序讀取到了用戶輸入數據后,可以處理數據并將響應發送到stdout。CGI程序可以返回HTML數據或者其他類型數據如GIF圖片等。這也是為什么你在返回數據前要先在第一行說明你返回數據的類型,如Content-type: text/html,然后加兩個CRLF后(HTTP協議的規定),再返回真正的輸出數據。

1.2 CGI實現

在現實應用中,WEB服務器常用的有nginx和apache。apache提供了很多模塊,可以直接加載CGI程序,和上一章提到的方式基本一致。而nginx是不能加載CGI程序的,必須另外單獨運行一個CGI程序處理器來處理CGI請求,先來看下CGI實現,WEB服務器代碼cgi.c。編譯并運行:

$ gcc -o cgi cgi.c
$ ./cgi

CGI程序如下,可以為C語言編寫,如 cgi_hello.c,也可以是shell,python等其他語言,如 cgi_hello.sh。編譯cgi_hello.c,放到cgi.c同一個目錄下面。

$ gcc -o cgi_hello cgi_hello.c

使用C實現一個cgi服務器,其實就是WEB服務器并附帶調用cgi程序功能。根據URL中的路徑獲取cgi程序名,并執行該cgi程序獲取返回結果并返回給客戶端。注意,是在WEB服務器程序中設置的環境變量,通過execl執行cgi程序,cgi程序因為是fork+exec執行的,子進程是會復制父進程環境變量表到自己的進程空間的,所以可以讀取環境變量QUERY_STRING。在瀏覽器輸入 http://192.168.56.18:6006/cgi_hello?name=ssj(測試機ip為192.168.56.18)
可以看到返回 Hello: ssj

2 FastCGI協議

2.1 FastCGI原理

如前面提到的,nginx是不能直接加載CGI程序的,由此需要一個專門的CGI程序管理器,nginx通過unix-socket或tcp-socket與CGI程序管理器通信。如php常用php-fpm,python常用uWSGI等,不過它們的協議不同,php-fpm用的是fastcgi協議,而uWSGI用的是uwsgi協議。nginx對這兩種協議都支持,nginx配置文件/etc/nginx/fastcgi_params/etc/nginx/uwsgi_params就是分別針對這兩種協議的。

先來看看FastCGI協議。顧名思義,FastCGI協議不過是CGI協議的變種,不同之處僅僅在于WEB服務器和CGI程序的交互方式。CGI協議中WEB服務器和CGI程序是通過環境變量來傳遞信息,WEB服務器fork+exec來執行CGI程序,CGI程序將輸出打印到標準輸出,執行完成后即退出。而FastCGI做的事情幾乎和CGI一樣,不同點在于FastCGI是通過進程間通信來傳遞信息,比如unix socket或tcp socket。那么,如果只是這么小的不同,FastCGI協議的意義何在呢?FastCGI的意義在于可以讓WEB應用程序架構完全變化,CGI協議下,應用程序的生命周期是一次http請求,而在FastCGI協議里面,應用程序可以一直存在,處理多個http請求再退出,大幅提升了WEB應用程序性能。

FastCGI協議是一個交互協議,盡管底層傳輸機制是面向連接的,但是它本身不是面向連接的。WEB服務器和CGI程序管理器之間通過FastCGI的消息通信,消息由header和body兩部分組成。其中header包含的字段如下:

Version: FastCGI協議版本號,目前一般是1.
Type: 標識消息類型。后面會有提到。
Request ID: 標識消息數據包所屬的請求。
Content Length: 該數據包中body長度

FastCGI主要的消息類型如下:

  • BEGIN_REQUEST:WEB服務器 => 應用程序,請求開始時發送。
  • ABORT_REQUEST:WEB服務器 => 應用程序,準備終止正在運行的請求時發送。常見情況是用戶點擊了瀏覽器的停止按鈕。
  • END_REQUEST:應用程序 => WEB服務器,請求處理完成后發送。這種消息的body會包含一個return code,標識請求成功還是失敗。
  • PARAMS:WEB服務器 => 應用程序,稱之為“stream packet”,一個請求里面可能發送多個PARAMS類型的消息。最后一個body長度為0的消息標識這類消息結束。PARAMS類型消息里面包含的數據正是CGI里面設置到環境變量里面的那些變量。
  • STDIN: WEB服務器 => 應用程序,這也是一個“stream packet”,POST相關數據會在STDIN消息中發送。在發送完POST數據后,會發送一個空的STDIN消息以標識STDIN類型消息結束。
  • STDOUT: 應用程序 => WEB服務器,這也是一個“stream packet”,是應用程序發送給WEB服務器的包含用戶請求對應的響應數據。響應數據發送完成后,也會發送一個空的STDOUT消息以標識STDOUT類型消息結束。

WEB服務器和FastCGI應用程序之間交互流程通常是這樣的:

  • WEB服務器接收到一個需要FastCGI應用程序處理的客戶端請求。因此,WEB服務器通過unix-socket或者TCP-socket連接到FastCGI程序。
  • FastCGI程序看到了到來的連接,它可以選擇拒絕或者接收該連接。若接收連接,則FastCGI程序開始從連接的數據流中讀取數據包。
  • 如果FastCGI程序沒有在預期時間內接收連接,則請求失敗。否則,WEB服務器會發送一個 BEGIN_REQUEST 的消息給FastCGI程序,該消息有一個唯一的請求ID。接下來的消息都用這個在header中聲明的同樣的ID。接著,WEB服務器會發送一定數目的PARAMS消息給FastCGI程序,當變量都發送完成時,WEB服務器再發送一個空的PARAMS消息關閉PARAMS數據流。而且,WEB服務器會將收到的來自客戶端的POST數據通過STDIN消息傳給FastCGI程序,當所有POST數據傳輸完成,一樣也會發送一個空的STDIN類型的消息以標識結束。
  • 同時,當FastCGI程序接收到BEGIN_REQUEST包后,它可以回復一個END_REQUEST包拒絕該請求,也可以接收并處理該請求。如果接收請求,則它會等到PARAMSSTDIN包都接收完成再一起處理,響應結果會通過STDOUT包發送回WEB服務器,最終會發送END_REQUEST包給WEB服務器讓其知道請求是成功還是失敗了。

有人可能會有點奇怪,為什么消息頭中需要一個Request ID,如果一個請求一個連接,那這個字段是多余的。也許你猜到了,一個連接可能包含多個請求,這樣就需要標識消息數據包是屬于哪個請求,這也是FastCGI為什么要采用面向數據包的協議,而不是面向數據流的協議。一個連接中可能混合多個請求,在軟件工程里面也稱之為多路傳輸。由于每個數據包都有一個請求ID,所以WEB服務器可以在一個連接中同時傳輸任意個數據包給FastCGI應用程序。而且,FastCGI程序可以同時接收大量的連接,每個連接可以同時包含多個請求。

此外,上面描述的通信流程并不是順序的。也就是說,WEB服務器可以先發送20個BEGIN_REQUEST包,然后再發送一些PARAMS包,接著發送一些STDIN包,然后又發送一些PARAMS包等等。

2.2 FastCGI實例分析

測試環境配置和抓包

FastCGI實現方式很多,如PHP的php-fpm,或者比較簡單的fcgiwrap,在這里,我用fcgiwrap這個比較簡單的實現來分析FastCGI協議,驗證上一節說的原理。

先安裝fcgiwrap,可以源碼安裝,如果是ubuntu/debian系統也可以直接apt-get安裝。通過/etc/init.d/fcgiwrap start啟動fcgiwrap默認會以unix-socket方式運行,如果要改成tcp-socket運行,可以fcgiwrap -f -s tcp:ip:port這樣運行。

# sudo apt-get install fcgiwrap

在測試的nginx配置的server段里面添加一行

include /etc/nginx/fcgi.conf;

其中fcgi.conf文件內容見 fcgi.conf

測試用的cgi程序都放在 /usr/share/nginx/cgi-bin目錄下面。測試cgi程序為 fcgi_hello.sh

在瀏覽器輸入http://192.168.56.18/cgi-bin/fcgi_hello.sh?foo=bar可以看到返回結果。

為了避免其他干擾,我沒用tcp-socket運行fcgiwrap,這樣為了抓unix-socket的包,需要使用socat這個工具。為了抓包,需要簡單改下nginx的配置,將 /etc/nginx/fcgi.conf中的fastcgi_pass這一行修改下,如下所示。

# fastcgi_pass  unix:/var/run/fcgiwrap.socket;
fastcgi_pass  unix:/var/run/fcgiwrap.socket.socat;

reload nginx并在命令行打開socat命令

socat -t100 -x -v UNIX-LISTEN:/var/run/fcgiwrap.socket.socat,mode=777,reuseaddr,fork UNIX-CONNECT:/var/run/fcgiwrap.socket

此時,在瀏覽器輸入http://192.168.56.18/cgi-bin/fcgi_hello.sh?foo=bar可以看到socat命令會有輸出如下:

> 2018/01/30 06:16:42.309659  length=960 from=0 to=959
01 01 00 01 00 08 00 00 00 01 00 00 00 00 00 00  ................
01 04 00 01 03 92 06 00 0c 07 51 55 45 52 59 5f  ..........QUERY_
53 54 52 49 4e 47 66 6f 6f 3d 62 61 72 0e 03 52  STRINGfoo=bar..R
45 51 55 45 53 54 5f 4d 45 54 48 4f 44 47 45 54  EQUEST_METHODGET
......
66 72 3b 71 3d 30 2e 36 00 00 00 00 00 00 01 04  fr;q=0.6........
00 01 00 00 00 00 01 05 00 01 00 00 00 00        ..............
--
< 2018/01/30 06:16:42.312909  length=136 from=0 to=135
01 06 00 01 00 61 07 00 53 74 61 74 75 73 3a 20  .....a..Status: 
32 30 30 0d 0a                                   200..
43 6f 6e 74 65 6e 74 2d 54 79 70 65 3a 20 74 65  Content-Type: te
78 74 2f 70 6c 61 69 6e 0d 0a                    xt/plain..
0d 0a                                            ..
52 45 51 55 45 53 54 20 4d 45 54 48 4f 44 3a 20  REQUEST METHOD: 
20 47 45 54 0a                                    GET.
50 41 54 48 5f 49 4e 46 4f 3a 20 0a              PATH_INFO: .
51 55 45 52 59 5f 53 54 52 49 4e 47 3a 20 20 66  QUERY_STRING:  f
6f 6f 3d 62 61 72 0a                             oo=bar.
00 00 00 00 00 00 00 01 06 00 01 00 00 00 00 01  ................
03 00 01 00 08 00 00 00 00 00 00 00 00 00 00     ...............

在ubuntu/debian上通過 sudo apt-get install libfcgi-dev后,可以在/usr/local/fastcgi.h中找到各個類型的消息的定義,接下來我們對照上一節說的FastCGI類型逐個分析下。

分析

WEB服務器和FastCGI之間通常的交互流程是這樣的,下面會通過抓包詳細分析。

{FCGI_BEGIN_REQUEST,   1, {FCGI_RESPONDER, 0}}
{FCGI_PARAMS,          1, "\013\007QUERY_STRINGfoo=bar"}
{FCGI_PARAMS,          1, ""}
{FCGI_STDIN,           1, "id=1&name=ssj"}
{FCGI_STDIN,           1, ""}

    {FCGI_STDOUT,      1, "Content-type: text/html\r\n\r\n<html>\n<head> ... "}
    {FCGI_STDOUT,      1, ""}
    {FCGI_END_REQUEST, 1, {0, FCGI_REQUEST_COMPLETE}}

WEB服務器發送給FastCGI程序的數據包:

  • 第一個消息是 BEGIN_REQUEST,可以看到第1個字節為01,也就是version為1,第2個字節為01,即消息類型是 BEGIN_REQUEST,接著3-4字節0001是requestId為1。再接著5-6字節0008是消息體長度為8。然后7-8字節0000是保留字段和填充字段。接著8個字節就是消息體了,9-10字節0001為role值,表示FCGI_RESPONDER,也就是這是一個需要響應的消息。11字節00為flag,表示應用在本次請求后關閉連接。然后12-16的5個字節0000000000為保留字段。

  • 第二個消息的第1個字節是01,也是version為1,第2個字節為04,表示消息類型為PARAMS。接著3-4字節為0001是requestId也是1。5-6字節0x0392消息體長度為914字節。后面7-8是0600位填充字段6字節。后面的為消息體內容,也就是QUERY_STRING, REQUEST_METHOD這些在CGI中設置到環境變量中的變量和值。接下來是PARAMS消息體。PARAMS消息用的是Name-Value對這種形式組織的數據結構,先是變量名稱長度,然后是變量值長度,接著才是名字和值的具體數據。注意,名和值的長度如果超過1字節,則用4個字節來存儲,具體是1字節還是4字節根據長度值的第一個字節的最高位來區分,如果為1則是4字節,如果為0則是1字節。如此可以分析PARAMS消息體了,頭兩個字節0c07表示名字長度為12,值長度為7,然后就是13個字節的變量名QUERY_STRING,7字節的值foo=bar,以此類推,接著的2個字節0e03就是名字長度為14,值長度為3,變量名是REQUEST_METHOD,值為GET...后續數據就是剩下的其他變量。最后面的6個字節000000000000是填充字節。

  • 第三個消息也是PARAMS,這是一個空的PARAMS消息。第1字節為01,第2字節為04表示PARAMS,3-4字節0001是requestId為1,5-6字節0000表示消息體長度為0,7-8字節0000表示填充和保留字節為0。

  • 第四個消息為STDIN,第1個字節01是version,第2個字節05表示類型為STDIN,接下來是3-4字節0001是requestId為1,5-6字節表示消息體長度為0,因為我們沒有POST數據。后面7-8字節為0。(如果有POST數據,則STDIN這里消息體長度不為0,而它的消息體就是POST的數據,注意STDIN不是Name-Value對,它是直接將POST的數據字段連在一起的,如這樣id=1&name=ssj)。到此,WEB服務器發送給FastCGI程序的數據包結束。

FastCGI程序發送給WEB服務器的數據包:

  • 第一個消息是 STDOUT 。第1個字節還是01為version,第2個字節06表示類型為STDOUT,接著3-4字節0001還是requestId,5-6字節0061為消息體長度97,7-8字節0700表示填充字段為7字節。接下來消息體就是返回的內容Status: 200\r\n...

  • 第二個消息還是 STDOUT,不過是空的STDOUT消息,用來標識STDOUT消息結束。

  • 第三個消息是 END_REQUEST。第1個字節01還是version,第2個字節03標識類型 END_REQUEST,3-4字節為requestId為1,5-6字節為消息體大小為8,7-8字節0000為填充字節長度。后面消息體內容為8個0字節。也就是說appStatus為0,protocolStatus也為0.其中protocalStatus是協議級的狀態碼,為0表示 REQUEST_COMPLETE,即請求正常完成。

// 消息類型定義
#define FCGI_BEGIN_REQUEST       1
#define FCGI_ABORT_REQUEST       2
#define FCGI_END_REQUEST         3
#define FCGI_PARAMS              4
#define FCGI_STDIN               5
#define FCGI_STDOUT              6
#define FCGI_STDERR              7
#define FCGI_DATA                8
#define FCGI_GET_VALUES          9
#define FCGI_GET_VALUES_RESULT  10
#define FCGI_UNKNOWN_TYPE       11
#define FCGI_MAXTYPE (FCGI_UNKNOWN_TYPE)

2.3 fcgiwrap分析

fcgiwrap用到了libfcgi庫,libfcgi庫提供了一些函數封裝,以方便實現fastcgi管理器。fcgiwrap啟動參數如下:

fcgiwrap -f -s unix:/var/run/fcgiwrap.socket -c 2

其中-s指定socket類型,若要用tcp-socket則用 -s tcp:ip:port。-c參數指定子進程數目,這里為2個。

fcgiwrap的核心代碼如下,即先創建一個listen socket,然后將該socket通過dup2復制到文件描述符0,因為libfcgi庫里面固定從fd 0來監聽網絡數據。prefork是創建參數指定數目的子進程數目,然后父進程通過pause()調用停止運行,接著每個子進程繼續往下執行fcgiwrap_main()函數。

int main(int argc, char **argv) {
    fd = setup_socket(socket_url);
    prefork(nchildren);
    fcgiwrap_main();
}

fcgiwrap_main()核心代碼如下,即不停的通過 FCGI_Accept()函數監聽連接并處理請求。其中FCGI_Accept()函數是libfcgi庫提供的,主要作用就是監聽listen socket上的請求,然后根據fastcgi協議讀取數據并解析為方便處理的結構,設置環境變量environ等,這樣handle_fcgi_request()就能跟cgi程序一樣通過讀取環境變量還獲取cgi文件名等內容。

static void fcgiwrap_main(void)
{
   ...... //略去了一些信號處理代碼
   inherited_environ = environ;

    while (FCGI_Accept() >= 0 && !sigint_received) {
        handle_fcgi_request();
    }
}

handle_fcgi_request()就是處理請求的函數了,先是fork出子進程去執行CGI程序,將執行結果寫入到管道中,而父進程則讀取管道中的數據并返回給WEB服務器。這里有幾點注意下:

  • 子進程中代碼dup2(pipe_in[0], 0)執行后,子進程從pipe_in[0]作為標準輸入,而父進程設置了 fc.fd_stdin = pipe_in[1],在函數fcgi_pass()中,會先調用子函數fcgi_pass_request()讀取FCGI_stdin中的數據(也就是前一節提到的STDIN類型的消息,也就是POST中的表單數據)并寫入fc.fd_stdin,也就是寫入到了pipe_in管道中,則子進程此時就可以從標準輸入中(因為前面的dup2)讀取到數據。同理,子進程中代碼dup2(pipe_out[1], 1)即說明子進程的標準輸出會輸出到管道pipe_out中,父進程在fcgi_pass()中同理可以通過管道讀取到子進程的運行輸出結果(這里fcgi_pass()使用了select()方式來輪詢fd_stdout和fd_stderr文件描述符)。父進程讀取到輸出結果后,返回STDOUTFCGI_END_REQUEST消息給nginx服務器,完成本次請求。
static void handle_fcgi_request(void)
{
    int pipe_in[2];
    int pipe_out[2];
    int pipe_err[2];
    char *filename;
    char *last_slash;
    char *p;
    pid_t pid;

    struct fcgi_context fc;

    switch((pid = fork())) {
        case -1:
            goto err_fork;

        case 0: /* child */
            close(pipe_in[1]);
            close(pipe_out[0]);
            close(pipe_err[0]);

            dup2(pipe_in[0], 0);
            dup2(pipe_out[1], 1);
            dup2(pipe_err[1], 2);

            close(pipe_in[0]);
            close(pipe_out[1]);
            close(pipe_err[1]);

            close(FCGI_fileno(FCGI_stdout));

            signal(SIGCHLD, SIG_DFL);
            signal(SIGPIPE, SIG_DFL);

            filename = get_cgi_filename();
            inherit_environment();
            ...... //省略了檢查文件是否存在和文件權限的代碼

            execl(filename, filename, (void *)NULL);
            cgi_error("502 Bad Gateway", "Cannot execute script", filename);

        default: /* parent */
            close(pipe_in[0]);
            close(pipe_out[1]);
            close(pipe_err[1]);

            fc.fd_stdin = pipe_in[1];
            fc.fd_stdout = pipe_out[0];
            fc.fd_stderr = pipe_err[0];
            fc.reply_state = REPLY_STATE_INIT;
            fc.cgi_pid = pid;

            fcgi_pass(&fc);
    }
    return;

   ...... // 省略部分錯誤處理代碼
    FCGI_puts("Status: 502 Bad Gateway\nContent-type: text/plain\n");
    FCGI_puts("System error");
}

實際應用中,像php-fpm(fpm是fastcgi process manager的意思)這種Fastcgi進程管理器,它會有master進程和worker進程,然后統一由master進程來分發請求管理worker,但是用的都是fastcgi協議,與本文分析的一致。

3 WSGI

3.1 WSGI規范

WSGI是Web服務器網關接口(Python Web Server Gateway Interface,縮寫為WSGI)是為Python語言定義的WEB服務器和WEB應用程序或框架之間的一種簡單而通用的接口,它與CGI類似,它不是一種框架,也不是模塊,而是一種服務器(Web Server)和應用程序(Web Application)之間規范。WSGI協議實際上是定義了一種WEB服務器與WEB框架解耦的規范,開發者可以選擇任意的WEB 服務器和WEB應用組合實現自己的web應用。例如常用的uWSGI和Gunicorn都是實現了WSGI Server協議的服務器(uWSGI還兼有進程管理器,監控,日志,插件,網關等功能),Flask是實現了WSGI Application協議的應用框架(當然Flask也自帶有一個簡單的WEB服務器,雖然我們通常是用nginx來處理靜態文件),可以根據項目情況搭配使用。

WSGI分為兩端:服務器/網關端 和 應用/框架端,服務器端調用應用端提供的可調用的對象。可調用對象可以是函數、方法、類或者實現了__call__方法的實例,這取決于服務器和應用選擇哪種實現技術。除了純正的服務器和應用,也可以使用中間件技術來實現該規范。

應用/框架

應用對象就是一個接受兩個參數的可調用對象,它可以是函數,方法,類等。應用對象必須可以被多次調用。雖然我們稱之為應用對象,但這并不意味著應用開發者要用WSGI作為WEB編程API。應用開發者可以繼續使用已經存在的、高級框架服務去開發他們的應用。WSGI 是一個為框架開發者和服務器開發者準備的工具,應用開發者不需要直接使用 WSGI。

app.py是包含兩個應用對象的示例,其中一個是用函數實現,另一個是用類實現。

服務器/網關

服務器/網關每次從 HTTP 客戶端收到一個請求,就調用一次應用對象。為了便于說明,這里有個簡單的CGI網關的例子 server.py,接收請求并調用應用對象app處理請求,實際負責處理請求的地方在handles.py中。

中間件:可以扮演兩種角色

中間件是這樣一種對象,它既可以作為服務器端跟應用端交互,也可以作為應用端跟服務器端交互。中間件組件通常具備下面幾個功能:

  • 在重寫了環境變量后,根據目標URL將請求路由到不同的應用對象。
  • 允許多個應用或框架在同一個進程中依次執行。
  • 通過轉發請求和響應,支持負載均衡和遠程處理。
  • 支持對內容進行后續處理。

中間件的存在對于接口的“服務器/網關”和“應用/框架”這兩端是透明的,并不需要特別的支持。大多數情況下,中間件必須符合WSGI的服務器和應用程序端的限制和要求。

3.2 WSGI細節

規范細節

應用對象必須接受兩個位置參數。為了便于說明,我們將參數命名為environ和start_response,當然你也可以用其他的名稱。服務器/網關必須使用位置參數(非關鍵字參數)調用應用對象,如result = application(environ, start_response)

environ參數是一個字典對象,包含CGI風格的環境變量。這個對象必須是一個內置的Python字典(不是子類、UserDict等),并允許應用程序修改字典。字典還必須包含某些WSGI必需的變量(在后面的章節中介紹),還可能包含特定的服務器的擴展變量,按照約定方式進行命名。

start_response參數是一個可調用的對象,它接受兩個必填的位置參數和一個可選參數。這三個參數通常命名為status,response_headers和exc_info。應用程序通常通過start_response(status,response_headers)方式調用它。

status參數是形式為“200 OK”這樣的狀態字符串,response_headers是描述HTTP響應頭的(header_name,header_value)元組列表。可選的exc_info參數僅在應用程序捕獲錯誤并嘗試向瀏覽器顯示錯誤消息時使用。start_response必須返回一個write(body_data)的可調用對象,它接受一個位置參數,該參數作為HTTP響應主體的一部分。

當被服務器調用時,應用對象必須返回一個產生零個或多個字節串的迭代,比如一個Python列表。如果應用程序返回的迭代對象具有close()方法,則服務器/網關在結束當前請求前必須調用該方法,無論請求是正常完成還是由于迭代期間的因為瀏覽器斷開連接產生了應用程序錯誤而提前終止。調用close()方法是為了釋放應用程序的資源。

環境變量

environ字典中必須包含CGI規范中定義的變量,包括下面這些:

  • REQUEST_METHOD
  • SCRIPT_NAME
  • PATH_INFO
  • QUERY_STRING
  • CONTENT_TYPE
  • CONTENT_LENGTH
  • SERVER_NAME, SERVER_PORT
  • SERVER_PROTOCOL
  • HTTP_Variables

除了CGI定義的環境變量之外,environ字典中還要包含下面幾個變量:

  • wsgi.version:WSGI版本,元組(1,0)表示版本為1.0.
  • wsgi.url_scheme:URL模式,值通常為http或者https。
  • wsgi.input:可以讀取HTTP請求體的輸入流。(當被應用對象請求時,服務器/網關執行 read ,可以預讀取請求體,緩存到內存或者磁盤中,或者用其他處理輸入流的技術)
  • wsgi.errors:錯誤輸出流。在許多服務器中,wsgi.errors通常是服務器的日志。
  • wsgi.multithread:應用對象如果支持多線程,則設置為true。
  • wsgi.multiprocess:應用對象如果支持多進程,則設置為true。
  • wsgi.run_once:如果服務器/網關希望應用對象在包含它的進程中僅執行一次這個請求,它的值為true。正常情況下,只有是基于CGI的網關才是true。

最后,environ字典也可能包含服務器定義的變量。這些變量只能使用小寫字母,數字,點和下劃線來命名,并且應該用該服務器/網關唯一的名稱作為前綴。例如,mod_python可能會定義名稱為mod_python.some_variable的變量。

輸入流和錯誤流

服務器提供的輸入流和錯誤流需提供如下方法:

方法
read(size) input
readline() input
readlines(hint) input
iter() input
flush() errors
write(str) errors
writelines(seq) errors

方法含義可以在標準庫中查找,不過有幾點要注意:

  • 服務器不能讀取超過客戶端指定的Content-Length的數據,而如果應用對象嘗試讀取超過Content-Length的內容,服務器應該模擬已經讀到文件結束。服務器應該允許read()在沒有參數的情況下被調用,并返回客戶端輸入流的其余部分。從一個空的或者已經讀完的輸入流讀取時,服務器應該返回空字節串。

  • 服務器應該支持readline()的可選“size”參數,但是在WSGI 1.0中,服務器不支持該參數也是可以的。

  • 錯誤流一般不會重讀,因此服務器/網關可以直接轉發寫入操作,而不需要緩沖。在這種情況下,flush()方法可能是沒有操作的。但是,便攜式應用程序不能假定輸出是無緩沖的或者flush()是無操作的。如果他們需要確保輸出已經被寫入,他們必須調用flush(),而不管flush()具體做了什么。

  • 上表中列出的方法必須得到符合本規范的所有服務器的支持。符合本規范的應用程序不得使用輸入流或錯誤流對象的任何其他方法或屬性。特別是,應用程序不能嘗試關閉這些流,即使它們擁有close()方法。

start_response

start_response是傳遞給應用對象的第二個參數是可調用對象(通常就是個函數),start_response(status,response_headers,exc_info = None)。 (與所有的WSGI可調用對象參數一樣,這里必須是位置參數,不能用關鍵字參數)。start_response用于開始HTTP響應,它必須返回一個write(body_data)的可調用對象。

status參數就是"200 OK"或者"404 Not Found"這種狀態字符串,由狀態碼和狀態說明組成的字符串,由一個空格分開,沒有周圍的空格或其他字符(更多請參見RFC2616第6.1.1節)。字符串不能包含控制字符,也不能以回車、換行符或它們的組合結束。

response_headers參數是(header_name,header_value)元組的列表。它必須是一個Python列表類型,并且服務器可以任意改變其內容。每個header_name必須是一個有效的HTTP頭部字段名稱(由RFC2616,第4.2節定義)。header_nameheader_value不能包含任何控制字符(包括回車符或換行符)。服務器/網關負責確保向客戶端發送正確的響應頭部:如果應用對象省略了HTTP響應所需的頭部,則服務器/網關必須添加它。例如,HTTP的Date和Server頭部通常由服務器/網關提供。(注意:HTTP頭部字段不區分大小寫,因此在檢查應用程序提供的頭部時務必考慮這一點!)。禁止應用對象使用 HTTP 1.1的 hop-by-hop 特性或者頭(如Keep-Alive),以及任何在 HTTP/1.0中等價的特性,或任何影響客戶端到 web服務器端持久化連接的頭部。

服務器應該在調用start_response的時候檢查頭文件中的錯誤,以便應用程序仍在運行時拋出錯誤。但是,start_response實際上并不傳輸響應頭。相反,它必須將它們存儲在服務器/網關上,以便僅在應用返回值時或者在應用首次調用write()時傳輸。響應頭傳輸的這種延遲是為了確保緩沖和異步應用程序可以用錯誤輸出來替換它們原來預期的輸出,直到最后的可能時刻。例如,如果在應用程序緩沖區內生成正文時發生錯誤,則應用程序可能需要將status從“200 OK”更改為“500 Internal Error”。

exc_info參數(如果提供)必須是Python sys.exc_info()元組。只有在錯誤處理程序調用start_response的情況下,應用程序才能提供此參數。如果提供了exc_info,并且還沒有發送HTTP頭,start_response應該用新提供的頭部替換當前存儲的HTTP頭部,從而允許應用程序在發生錯誤時“改變主意”。但是,如果此時已經發送了HTTP頭部,則start_response必須再次拋出異常。

處理Content-Length頭部

如果應用程序提供Content-Length頭,則服務器不應該發送比Content-Length更多的字節,并且應該在發送完Content-Length字節后停止發送響應,如果應用程序此時還繼續嘗試寫入,則應該拋出錯誤)。

如果應用程序不提供Content-Length頭部,則服務器或網關可以選擇幾種方法之一來處理它。最簡單的是在響應完成時關閉客戶端連接。但是,在某些情況下,服務器/網關可以生成一個Content-Length頭部來避免關閉客戶端連接。注意:應用程序和中間件的輸出中不能使用任何類型的Transfer-Encoding,例如chunking或gzip,這些傳輸編碼是Web服務器/網關的職責

緩沖和流

一般來說,應用程序通過緩沖輸出并一次發送全部數據來實現最佳吞吐量。在Zope等現有框架中,這是一種常見的方法:輸出緩存在一個StringIO或類似的對象中,然后與響應頭一起傳輸。

3.3 實現

總體上看來,WSGI服務器端就是接收請求,設置好環境變量,然后調用應用對象處理請求。而應用對象調用start_response函數設置頭部(注意,此時還沒有返回響應給客戶端),然后應用對象返回一個可迭代對象(如Python的列表)給服務器端。服務端對應用對象返回的迭代數據進行輸出,輸出前會先調用send_headers()來發送響應頭部。

完整的示例代碼參見 web-basis-wsgi,代碼基本來源于Python自帶的wsgiref和http相關模塊。

4 uWSGI和uwsgi協議

4.1 uWSGI安裝配置

uWSGI是一個WEB服務器,它實現了WSGI協議、uwsgi協議、http協議等。這里要區分下:uWSGI是WEB服務器,而小寫的uwsgi是協議。安裝uWSGI的步驟比較簡單,如下:

# sudo apt-get install build-essential python-dev
# sudo pip install uwsgi

然后我們可以編寫一個簡單的符合WSGI規范的python程序:

# foobar.py
def application(env, start_response):
    start_response('200 OK', [('Content-Type','text/html')])
    return [b"Hello World"]

運行:

# uwsgi --http :9090 --wsgi-file foobar.py

此時,我們就可以在瀏覽器輸入 http://127.0.0.1:9090來訪問了。指定參數--http則是以HTTP服務器方式運行,在實際項目中,通常會以socket的方式運行,nginx負責處理靜態資源,動態請求則由nginx通過uwsgi協議與uWSGI服務器交互。

配置nginx如下:

# /etc/nginx/sites-enabled/uwsgi
server {
    listen 9090;
    location / {
        include uwsgi_params;
         uwsgi_pass 127.0.0.1:3031;
    }
}

以socket的方式運行uWSGI如下(加了進程和線程數配置):

uwsgi --socket 127.0.0.1:3031 --wsgi-file foobar.py --master --processes 4 --threads 2

為了方便,可以將啟動參數放到配置文件 config.ini中,然后 uwsgi config.ini即可。

## config.ini示例
[uwsgi]
uid = nobody
gid = nogroup
socket = 127.0.0.1:3031
chdir = /home/vagrant/project/uwsgi
wsgi-file = foobar.py
processes = 4
threads = 2

如果nginx里面配置的是proxy_pass http://127.0.0.1:3031,則此時需要將uwsgi以 http-socket的方式運行,即

uwsgi --http-socket 127.0.0.1:3031 --wsgi-file foobar.py --master --processes 4 --threads 2 

4.2 uwsgi協議

前面我們分析過fastcgi和wsgi協議,而uwsgi是uWSGI獨有的用于與WEB服務器通信的協議,它是一個字節協議,可以傳輸任意數據類型,nginx也已經支持uwsgi協議,如我們前面用到的uwsgi_pass。

uwsgi協議的包頭如下:共32位,前面8位為標識,中間16位是數據包大小,最后8位也為標識。我這里分析的是modifier1為0的情況,即數據包為WSGI變量,datasize為WSGI塊變量大小(不包括請求體)。其他選項詳細含義可以參見 uwsgi-protocol.

struct uwsgi_packet_header {
    uint8_t modifier1;
    uint16_t datasize;
    uint8_t modifier2;
};

struct uwsgi_var {
    uint16_t key_size;
    uint8_t key[key_size];
    uint16_t val_size;
    uint8_t val[val_size];
}

實例分析:

以前面例子說明,我們使用tcpdump命令查看nginx和uWSGI之間的通信包

tcpdump -i lo port 3031 -n -X -vvvv

運行 curl -i http://127.0.0.1:9090
可以看到如下輸出:

 127.0.0.1.36705 > 127.0.0.1.3031
    0x0000:  4500 0193 9511 4000 4006 a651 7f00 0001  E.....@.@..Q....
    0x0010:  7f00 0001 8f61 0bd7 441d 5d4e c7f6 50e7  .....a..D.]N..P.
    0x0020:  8018 02ab ff87 0000 0101 080a 0009 c842  ...............B
    0x0030:  0009 c842 005b 0100 0c00 5155 4552 595f  ...B.[....QUERY_
    0x0040:  5354 5249 4e47 0000 0e00 5245 5155 4553  STRING....REQUES
    0x0050:  545f 4d45 5448 4f44 0300 4745 540c 0043  T_METHOD..GET..C
    ...
    
127.0.0.1.3031 > 127.0.0.1.36705
   ...
    0x0030:  0009 c842 4854 5450 2f31 2e31 2032 3030  ...BHTTP/1.1.200
    0x0040:  204f 4b0d 0a43 6f6e 7465 6e74 2d54 7970  .OK..Content-Typ
    0x0050:  653a 2074 6578 742f 6874 6d6c 0d0a 0d0a  e:.text/html....
    0x0060:  4865 6c6c 6f20 576f 726c 64              Hello.World

除去前面ip和tcp包頭,可以看到實際內容從 005b 0100開始,即uwsgi包的長度為 0x015b共347字節,后面則是實際內容,如QUERY_STRING這個變量key長度為12(0c00),后面緊跟key,而value長度為0(0000),沒有內容。后面REQUEST_METHOD的key長度為14(0e00),value為GET,長度為3(0300),依此類推,跟fastcgi協議有點相似,這些變量來自/etc/nginx/uwsgi_params中定義和nginx添加的HTTP_USER_AGENT等HTTP變量。uWSGI發送給nginx的響應報文則是標準的HTTP響應。

4.3 uWSGI與應用框架組合使用

uWSGI可以與Flask,Django,web2py等框架組合使用,在我們的實際項目中,架構通常是nginx + uWSGI + Flask,即靜態請求由nginx處理,動態請求轉發到uWSGI,然后組合使用Flask框架來編寫業務邏輯,當然里面通常還會用到gevent等。

uWSGI與Flask組合使用也很簡單。因為Flask框架將WSGI的可調用應用對象暴露出來了,我們只要在啟動參數中指明入口的app即可。先安裝flask模塊:

# sudo pip install flask

然后編寫flask程序如下:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def index():
    return "<span style='color:red'>I am app 1</span>"

然后啟動uWSGI(比之前只是多一個 --callable 參數):

uwsgi --socket 127.0.0.1:3031 --wsgi-file flaskapp.py --callable app --processes 4 --threads 2

5 總結

從CGI,FastCGI,WSGI到uWSGI,涉及內容很基礎也很繁雜,本文只是拋磚引玉,說出了我對這些概念的基本理解,如有錯漏,懇請指正。實際項目中用到的架構如 nginx + uWSGI + Flask (配合gevent,mysql線程池等),后面有時間再做總結。

6 參考資料

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容