深入理解 FastCGI 協議以及在 PHP 中的實現

周夢康發表於2017-06-22

在討論 FastCGI 之前,不得不說傳統的 CGI 的工作原理,同時應該大概瞭解 CGI 1.1 協議

傳統 CGI 工作原理分析

客戶端訪問某個 URL 地址之後,通過 GET/POST/PUT 等方式提交資料,並通過 HTTP 協議向 Web 伺服器發出請求,伺服器端的 HTTP Daemon(守護程式)將 HTTP 請求裡描述的資訊通過標準輸入 stdin 和環境變數(environment variable)傳遞給主頁指定的 CGI 程式,並啟動此應用程式進行處理(包括對資料庫的處理),處理結果通過標準輸出 stdout 返回給 HTTP Daemon 守護程式,再由 HTTP Daemon 程式通過 HTTP 協議返回給客戶端。

上面的這段話理解可能還是比較抽象,下面我們就通過一次GET請求為例進行詳細說明。

下面用程式碼來實現圖中表述的功能。Web 伺服器啟動一個 socket 監聽服務,然後在本地執行 CGI 程式。後面有比較詳細的程式碼解讀。

Web 伺服器程式碼

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <string.h>

#define SERV_PORT 9003

char* str_join(char *str1, char *str2);
char* html_response(char *res, char *buf);

int main(void)
{
    int lfd, cfd;
    struct sockaddr_in serv_addr,clin_addr;
    socklen_t clin_len;
    char buf[1024],web_result[1024];
    int len;
    FILE *cin;

    if((lfd = socket(AF_INET,SOCK_STREAM,0)) == -1){
        perror("create socket failed");
        exit(1);
    }

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(SERV_PORT);

    if(bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
    {
        perror("bind error");
        exit(1);
    }

    if(listen(lfd, 128) == -1)
    {
        perror("listen error");
        exit(1);
    }

    signal(SIGCLD,SIG_IGN);

    while(1)
    {
        clin_len = sizeof(clin_addr);
        if ((cfd = accept(lfd, (struct sockaddr *)&clin_addr, &clin_len)) == -1)
        {
            perror("接收錯誤\n");
            continue;
        }

        cin = fdopen(cfd, "r");
        setbuf(cin, (char *)0);
        fgets(buf,1024,cin); //讀取第一行
        printf("\n%s", buf);

        //============================ cgi 環境變數設定演示 ============================

        // 例如 "GET /user.cgi?id=1 HTTP/1.1";

        char *delim = " ";
        char *p;
        char *method, *filename, *query_string;
        char *query_string_pre = "QUERY_STRING=";

        method = strtok(buf,delim);         // GET
        p = strtok(NULL,delim);             // /user.cgi?id=1 
        filename = strtok(p,"?");           // /user.cgi

        if (strcmp(filename,"/favicon.ico") == 0)
        {
            continue;
        }

        query_string = strtok(NULL,"?");    // id=1
        putenv(str_join(query_string_pre,query_string));

        //============================ cgi 環境變數設定演示 ============================

        int pid = fork();

        if (pid > 0)
        {
            close(cfd);
        }
        else if (pid == 0)
        {
            close(lfd);
            FILE *stream = popen(str_join(".",filename),"r");
            fread(buf,sizeof(char),sizeof(buf),stream);
            html_response(web_result,buf);
            write(cfd,web_result,sizeof(web_result));
            pclose(stream);
            close(cfd);
            exit(0);
        }
        else
        {
            perror("fork error");
            exit(1);
        }
    }

    close(lfd);

    return 0;
}

char* str_join(char *str1, char *str2)
{
    char *result = malloc(strlen(str1)+strlen(str2)+1);
    if (result == NULL) exit (1);
    strcpy(result, str1);
    strcat(result, str2);

    return result;
}

char* html_response(char *res, char *buf)
{
    char *html_response_template = "HTTP/1.1 200 OK\r\nContent-Type:text/html\r\nContent-Length: %d\r\nServer: mengkang\r\n\r\n%s";

    sprintf(res,html_response_template,strlen(buf),buf);

    return res;
}

如上程式碼中的重點:

  • 66~81行找到CGI程式的相對路徑(我們為了簡單,直接將其根目錄定義為Web程式的當前目錄),這樣就可以在子程式中執行 CGI 程式了;同時設定環境變數,方便CGI程式執行時讀取;
  • 94~95行將 CGI 程式的標準輸出結果寫入 Web 伺服器守護程式的快取中;
  • 97行則將包裝後的 html 結果寫入客戶端 socket 描述符,返回給連線Web伺服器的客戶端。

CGI 程式(user.c)

#include <stdio.h>
#include <stdlib.h>
// 通過獲取的 id 查詢使用者的資訊
int main(void){

    //============================ 模擬資料庫 ============================
    typedef struct 
    {
        int  id;
        char *username;
        int  age;
    } user;

    user users[] = {
        {},
        {
            1,
            "mengkang.zhou",
            18
        }
    };
    //============================ 模擬資料庫 ============================

    char *query_string;
    int id;

    query_string = getenv("QUERY_STRING");

    if (query_string == NULL)
    {
        printf("沒有輸入資料");
    } else if (sscanf(query_string,"id=%d",&id) != 1)
    {
        printf("沒有輸入id");
    } else
    {
        printf("使用者資訊查詢<br>學號: %d<br>姓名: %s<br>年齡: %d",id,users[id].username,users[id].age);
    }

    return 0;
}

將上面的 CGI 程式編譯成gcc user.c -o user.cgi,放在上面web程式的同級目錄。

程式碼中的第28行,從環境變數中讀取前面在Web伺服器守護程式中設定的環境變數,是我們演示的重點。

FastCGI 工作原理分析

相對於 CGI/1.1 規範在 Web 伺服器在本地 fork 一個子程式執行 CGI 程式,填充 CGI 預定義的環境變數,放入系統環境變數,把 HTTP body 體的 content 通過標準輸入傳入子程式,處理完畢之後通過標準輸出返回給 Web 伺服器。FastCGI 的核心則是取締傳統的 fork-and-execute 方式,減少每次啟動的巨大開銷(後面以 PHP 為例說明),以常駐的方式來處理請求。

FastCGI 工作流程如下:

  1. FastCGI 程式管理器自身初始化,啟動多個 CGI 直譯器程式,並等待來自 Web Server 的連線。
  2. Web 伺服器與 FastCGI 程式管理器進行 Socket 通訊,通過 FastCGI 協議傳送 CGI 環境變數和標準輸入資料給 CGI 直譯器程式。
  3. CGI 直譯器程式完成處理後將標準輸出和錯誤資訊從同一連線返回 Web Server。
  4. CGI 直譯器程式接著等待並處理來自 Web Server 的下一個連線。

FastCGI 與傳統 CGI 模式的區別之一則是 Web 伺服器不是直接執行 CGI 程式了,而是通過 socket 與 FastCGI 響應器(FastCGI 程式管理器)進行互動,Web 伺服器需要將 CGI 介面資料封裝在遵循 FastCGI 協議包中傳送給 FastCGI 響應器程式。正是由於 FastCGI 程式管理器是基於 socket 通訊的,所以也是分散式的,Web伺服器和CGI響應器伺服器分開部署。

再囉嗦一句,FastCGI 是一種協議,它是建立在CGI/1.1基礎之上的,把CGI/1.1裡面的要傳遞的資料通過FastCGI協議定義的順序、格式進行傳遞。

準備工作

可能上面的內容理解起來還是很抽象,這是由於第一對FastCGI協議還沒有一個大概的認識,第二沒有實際程式碼的學習。所以需要預先學習下 FastCGI 協議的內容,不一定需要完全看懂,可大致瞭解之後,看完本篇再結合著學習理解消化。

http://www.fastcgi.com/devkit… (英文原版)
http://andylin02.iteye.com/bl… (中文版)

FastCGI 協議分析

下面結合 PHP 的 FastCGI 的程式碼進行分析,不作特殊說明以下程式碼均來自於 PHP 原始碼。

FastCGI 訊息型別

FastCGI 將傳輸的訊息做了很多型別的劃分,其結構體定義如下:

typedef enum _fcgi_request_type {
    FCGI_BEGIN_REQUEST      =  1, /* [in]                              */
    FCGI_ABORT_REQUEST      =  2, /* [in]  (not supported)             */
    FCGI_END_REQUEST        =  3, /* [out]                             */
    FCGI_PARAMS             =  4, /* [in]  environment variables       */
    FCGI_STDIN              =  5, /* [in]  post data                   */
    FCGI_STDOUT             =  6, /* [out] response                    */
    FCGI_STDERR             =  7, /* [out] errors                      */
    FCGI_DATA               =  8, /* [in]  filter data (not supported) */
    FCGI_GET_VALUES         =  9, /* [in]                              */
    FCGI_GET_VALUES_RESULT  = 10  /* [out]                             */
} fcgi_request_type;

訊息的傳送順序

下圖是一個簡單的訊息傳遞流程

最先傳送的是FCGI_BEGIN_REQUEST,然後是FCGI_PARAMSFCGI_STDIN,由於每個訊息頭(下面將詳細說明)裡面能夠承載的最大長度是65535,所以這兩種型別的訊息不一定只傳送一次,有可能連續傳送多次。

FastCGI 響應體處理完畢之後,將傳送FCGI_STDOUTFCGI_STDERR,同理也可能多次連續傳送。最後以FCGI_END_REQUEST表示請求的結束。

需要注意的一點,FCGI_BEGIN_REQUESTFCGI_END_REQUEST分別標識著請求的開始和結束,與整個協議息息相關,所以他們的訊息體的內容也是協議的一部分,因此也會有相應的結構體與之對應(後面會詳細說明)。而環境變數、標準輸入、標準輸出、錯誤輸出,這些都是業務相關,與協議無關,所以他們的訊息體的內容則無結構體對應。

由於整個訊息是二進位制連續傳遞的,所以必須定義一個統一的結構的訊息頭,這樣以便讀取每個訊息的訊息體,方便訊息的切割。這在網路通訊中是非常常見的一種手段。

FastCGI 訊息頭

如上,FastCGI 訊息分10種訊息型別,有的是輸入有的是輸出。而所有的訊息都以一個訊息頭開始。其結構體定義如下:

typedef struct _fcgi_header {
    unsigned char version;
    unsigned char type;
    unsigned char requestIdB1;
    unsigned char requestIdB0;
    unsigned char contentLengthB1;
    unsigned char contentLengthB0;
    unsigned char paddingLength;
    unsigned char reserved;
} fcgi_header;

欄位解釋下:

  • version標識FastCGI協議版本。
  • type 標識FastCGI記錄型別,也就是記錄執行的一般職能。
  • requestId標識記錄所屬的FastCGI請求。
  • contentLength記錄的contentData元件的位元組數。

關於上面的xxB1xxB0的協議說明:當兩個相鄰的結構元件除了字尾“B1”和“B0”之外命名相同時,它表示這兩個元件可視為估值為B1<<8 + B0的單個數字。該單個數字的名字是這些元件減去字尾的名字。這個約定歸納了一個由超過兩個位元組表示的數字的處理方式。

比如協議頭中requestIdcontentLength表示的最大值就是65535

#include <stdio.h>
#include <stdlib.h>
#include <limits.h>

int main()
{
   unsigned char requestIdB1 = UCHAR_MAX;
   unsigned char requestIdB0 = UCHAR_MAX;
   printf("%d\n", (requestIdB1 << 8) + requestIdB0); // 65535
}

你可能會想到如果一個訊息體長度超過65535怎麼辦,則分割為多個相同型別的訊息傳送即可。

FCGI_BEGIN_REQUEST 的定義

typedef struct _fcgi_begin_request {
    unsigned char roleB1;
    unsigned char roleB0;
    unsigned char flags;
    unsigned char reserved[5];
} fcgi_begin_request;

欄位解釋

role表示Web伺服器期望應用扮演的角色。分為三個角色(而我們這裡討論的情況一般都是響應器角色)

typedef enum _fcgi_role {
    FCGI_RESPONDER    = 1,
    FCGI_AUTHORIZER    = 2,
    FCGI_FILTER        = 3
} fcgi_role;

FCGI_BEGIN_REQUEST中的flags元件包含一個控制線路關閉的位:flags & FCGI_KEEP_CONN:如果為0,則應用在對本次請求響應後關閉線路。如果非0,應用在對本次請求響應後不會關閉線路;Web伺服器為線路保持響應性。

FCGI_END_REQUEST 的定義

typedef struct _fcgi_end_request {
    unsigned char appStatusB3;
    unsigned char appStatusB2;
    unsigned char appStatusB1;
    unsigned char appStatusB0;
    unsigned char protocolStatus;
    unsigned char reserved[3];
} fcgi_end_request;

欄位解釋

appStatus元件是應用級別的狀態碼。
protocolStatus元件是協議級別的狀態碼;protocolStatus的值可能是:

FCGI_REQUEST_COMPLETE:請求的正常結束。
FCGI_CANT_MPX_CONN:拒絕新請求。這發生在Web伺服器通過一條線路嚮應用傳送併發的請求時,後者被設計為每條線路每次處理一個請求。
FCGI_OVERLOADED:拒絕新請求。這發生在應用用完某些資源時,例如資料庫連線。
FCGI_UNKNOWN_ROLE:拒絕新請求。這發生在Web伺服器指定了一個應用不能識別的角色時。

protocolStatus在 PHP 中的定義如下

typedef enum _fcgi_protocol_status {
    FCGI_REQUEST_COMPLETE    = 0,
    FCGI_CANT_MPX_CONN        = 1,
    FCGI_OVERLOADED            = 2,
    FCGI_UNKNOWN_ROLE        = 3
} dcgi_protocol_status;

需要注意dcgi_protocol_statusfcgi_role各個元素的值都是 FastCGI 協議裡定義好的,而非 PHP 自定義的。

訊息通訊樣例

為了簡單的表示,訊息頭只顯示訊息的型別和訊息的 id,其他欄位都不予以顯示。下面的例子來自於官網

{FCGI_BEGIN_REQUEST,   1, {FCGI_RESPONDER, 0}}
{FCGI_PARAMS,          1, "\013\002SERVER_PORT80\013\016SERVER_ADDR199.170.183.42 ... "}
{FCGI_STDIN,           1, "quantity=100&item=3047936"}
{FCGI_STDOUT,          1, "Content-type: text/html\r\n\r\n<html>\n<head> ... "}
{FCGI_END_REQUEST,     1, {0, FCGI_REQUEST_COMPLETE}}

配合上面各個結構體,則可以大致想到 FastCGI 響應器的解析和響應流程:

首先讀取訊息頭,得到其型別為FCGI_BEGIN_REQUEST,然後解析其訊息體,得知其需要的角色就是FCGI_RESPONDERflag為0,表示請求結束後關閉線路。然後解析第二段訊息,得知其訊息型別為FCGI_PARAMS,然後直接將訊息體裡的內容以回車符切割後存入環境變數。與之類似,處理完畢之後,則返回了FCGI_STDOUT訊息體和FCGI_END_REQUEST訊息體供 Web 伺服器解析。

PHP 中的 FastCGI 的實現

下面對程式碼的解讀筆記只是我個人知識的一個梳理提煉,如有勘誤,請大家指出。對不熟悉該程式碼的同學來說可能是一個引導,初步認識,如果覺得很模糊不清晰,那麼還是需要自己逐行去閱讀。

php-src/sapi/cgi/cgi_main.c為例進行分析說明,假設開發環境為 unix 環境。main 函式中一些變數的定義,以及 sapi 的初始化,我們就不討論在這裡討論了,只說明關於 FastCGI 相關的內容。

1.開啟一個 socket 監聽服務

fcgi_fd = fcgi_listen(bindpath, 128);

從這裡開始監聽,而fcgi_listen函式裡面則完成 socket 服務前三步socket,bind,listen

2.初始化請求物件

fcgi_request物件分配記憶體,繫結監聽的 socket 套接字。

fcgi_init_request(&request, fcgi_fd);

整個請求從輸入到返回,都圍繞著fcgi_request結構體物件在進行。

typedef struct _fcgi_request {
    int            listen_socket;
    int            fd;
    int            id;
    int            keep;
    int            closed;

    int            in_len;
    int            in_pad;

    fcgi_header   *out_hdr;
    unsigned char *out_pos;
    unsigned char  out_buf[1024*8];
    unsigned char  reserved[sizeof(fcgi_end_request_rec)];

    HashTable     *env;
} fcgi_request;

3.建立多個 CGI 解析器子程式

這裡子程式的個數預設是0,從配置檔案中讀取設定到環境變數,然後在程式中讀取,然後建立指定數目的子程式來等待處理 Web 伺服器的請求。

if (getenv("PHP_FCGI_CHILDREN")) {
    char * children_str = getenv("PHP_FCGI_CHILDREN");
    children = atoi(children_str);
    ...
}

do {
    pid = fork();
    switch (pid) {
    case 0:
        parent = 0; // 將子程式中的父程式標識改為0,防止迴圈 fork

        /* don't catch our signals */
        sigaction(SIGTERM, &old_term, 0);
        sigaction(SIGQUIT, &old_quit, 0);
        sigaction(SIGINT,  &old_int,  0);
        break;
    case -1:
        perror("php (pre-forking)");
        exit(1);
        break;
    default:
        /* Fine */
        running++;
        break;
    }
} while (parent && (running < children));

4.在子程式中接收請求

到這裡一切都還是 socket 的服務的套路。接受請求,然後呼叫了fcgi_read_request

fcgi_accept_request(&request)
int fcgi_accept_request(fcgi_request *req)
{
    int listen_socket = req->listen_socket;
    sa_t sa;
    socklen_t len = sizeof(sa);
    req->fd = accept(listen_socket, (struct sockaddr *)&sa, &len);

    ...

    if (req->fd >= 0) {
        // 採用多路複用的機制
        struct pollfd fds;
        int ret;

        fds.fd = req->fd;
        fds.events = POLLIN;
        fds.revents = 0;
        do {
            errno = 0;
            ret = poll(&fds, 1, 5000);
        } while (ret < 0 && errno == EINTR);
        if (ret > 0 && (fds.revents & POLLIN)) {
            break;
        }
        // 僅僅是關閉 socket 連線,不清空 req->env
        fcgi_close(req, 1, 0);
    }

    ...

    if (fcgi_read_request(req)) {
        return req->fd;
    }
}

並且把request放入全域性變數sapi_globals.server_context,這點很重要,方便了在其他地方對請求的呼叫。

SG(server_context) = (void *) &request;

5.讀取資料

下面的程式碼刪除一些異常情況的處理,只顯示了正常情況下執行順序。

fcgi_read_request中則完成我們在訊息通訊樣例中的訊息讀取,而其中很多的len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;操作,已經在前面的FastCGI 訊息頭中解釋過了。

這裡是解析 FastCGI 協議的關鍵。

static inline ssize_t safe_read(fcgi_request *req, const void *buf, size_t count)
{
    int    ret;
    size_t n = 0;

    do {
        errno = 0;
        ret = read(req->fd, ((char*)buf)+n, count-n);
        n += ret;
    } while (n != count);
    return n;
}
static int fcgi_read_request(fcgi_request *req)
{
    ...

    if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) || hdr.version < FCGI_VERSION_1) {
        return 0;
    }

    len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
    padding = hdr.paddingLength;

    req->id = (hdr.requestIdB1 << 8) + hdr.requestIdB0;

    if (hdr.type == FCGI_BEGIN_REQUEST && len == sizeof(fcgi_begin_request)) {
        char *val;

        if (safe_read(req, buf, len+padding) != len+padding) {
            return 0;
        }

        req->keep = (((fcgi_begin_request*)buf)->flags & FCGI_KEEP_CONN);

        switch ((((fcgi_begin_request*)buf)->roleB1 << 8) + ((fcgi_begin_request*)buf)->roleB0) {
            case FCGI_RESPONDER:
                val = estrdup("RESPONDER");
                zend_hash_update(req->env, "FCGI_ROLE", sizeof("FCGI_ROLE"), &val, sizeof(char*), NULL);
                break;
            ...
            default:
                return 0;
        }

        if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) || hdr.version < FCGI_VERSION_1) {
            return 0;
        }

        len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
        padding = hdr.paddingLength;

        while (hdr.type == FCGI_PARAMS && len > 0) {
            if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) || hdr.version < FCGI_VERSION_1) {
                req->keep = 0;
                return 0;
            }
            len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
            padding = hdr.paddingLength;
        }

        ...
    }
}

6.執行指令碼

假設此次請求為PHP_MODE_STANDARD則會呼叫php_execute_script執行PHP檔案。這裡就不展開了。

7.結束請求

fcgi_finish_request(&request, 1);
int fcgi_finish_request(fcgi_request *req, int force_close)
{
    int ret = 1;

    if (req->fd >= 0) {
        if (!req->closed) {
            ret = fcgi_flush(req, 1);
            req->closed = 1;
        }
        fcgi_close(req, force_close, 1);
    }
    return ret;
}

fcgi_finish_request中呼叫fcgi_flushfcgi_flush中封裝一個FCGI_END_REQUEST訊息體,再通過safe_write寫入 socket 連線的客戶端描述符。

8.標準輸入標準輸出的處理

標準輸入和標準輸出在上面沒有一起討論,實際在cgi_sapi_module結構體中有定義,但是cgi_sapi_module這個sapi_module_struct結構體與其他程式碼耦合太多,我自己也沒深入的理解,這裡簡單做下比較,希望其他網友予以指點、補充。

cgi_sapi_module中定義了sapi_cgi_read_post來處理POST資料的讀取.

while (read_bytes < count_bytes) {
    fcgi_request *request = (fcgi_request*) SG(server_context);
    tmp_read_bytes = fcgi_read(request, buffer + read_bytes, count_bytes - read_bytes);
    read_bytes += tmp_read_bytes;
}

fcgi_read中則對FCGI_STDIN的資料進行讀取。
同時cgi_sapi_module中定義了sapi_cgibin_ub_write來接管輸出處理,而其中又呼叫了sapi_cgibin_single_write,最後實現了FCGI_STDOUT FastCGI 資料包的封裝.

fcgi_write(request, FCGI_STDOUT, str, str_length);

寫在最後

把 FastCGI 的知識學習理解的過程做了這樣一篇筆記,把自己理解的內容(自我認為)有條理地寫出來,能夠讓別人比較容易看明白也是一件不挺不容易的事。同時也讓自己對這個知識點的理解又深入了一層。對 PHP 程式碼學習理解中還有很多困惑的地方還需要我自己後期慢慢消化和理解。

本文都是自己的一些理解,水平有限,如有勘誤,希望大家予以指正。

堅持看完本的都是老司機,說實話,後面有些太枯燥了!如果能把每個知識點真正理解消化,絕對獲益良多。

相關文章