2017年11月16日 星期四

C/C++ 伺服器藉由 FastCGI 快速提供 RESTful Web API

我負責的遊戲伺服器是以 C/C++ 開發的。而現今這年頭無論是跟哪家營運商合作,無可避免要提供 RESTful Web API 方便讓營運後台對伺服器進行動態的設定或提取即時資訊。我原本對 Web 後端技術所知不算多,不過整個流程摸索過一次之後,發現也沒想像中那麼複雜。以下說說心得。

首先你要知道 CGI 跟 FastCGI 是什麼東西。



CGI


CGI 的全名是 Common Gateway Interface,是一個很典型的「光看名字完全無法理解」的例子。我們先說明網頁伺服器 (HTTP 伺服器) 的一個最原始的運作方式:


  1. 使用者在瀏覽器中輸入網址 (URL)
  2. 瀏覽器連線到網頁伺服器,提出頁面要求
  3. 網頁伺服器開啟該頁面要求對應的檔案,並且把檔案內容讀出來送回給瀏覽器
不難想像這個運作方式在應用上會是很死板的 -- 一個網址必然只對應到一份靜態內容。然後 CGI 就被提出來解決這個問題了。CGI 的運作方式改為:

  1. 使用者在瀏覽器中輸入網址 (URL)
  2. 瀏覽器連線到網頁伺服器,提出頁面要求
  3. 網頁伺服器執行該頁面要求對應到的檔案,並且把執行輸出送回給瀏覽器
與原流程相比,只差在第三步 -- 網頁伺服器不再只是把檔案內容讀出、丟回去,而改成是去執行那個檔案、把執行輸出丟回去。如此一來,內容就可以是動態的了。使用者可以在頁面要求中,透過 URL 上夾帶的 Query String、客製化的 Header Fields、或主動上傳的資料 (POST, PUT) 等等方式,提供輸入資料給該執行程式 -- 稱為 CGI 應用程式 (CGI Application)。CGI 應用程式根據這些資料來進行動作,輸出結果,在網頁伺服器的幫忙下返回給使用者。現今常用為開發 "Web 後端" 的程式語言,如 asp/php/jsp 等等,也都是透過 CGI 與網頁伺服器整合。

最初的 CGI 應用程式編寫方式非常經典 -- 網頁伺服器把 CGI 應用程式帶起來後,會把網頁 Request 相關的資訊、以及 HTTP Header Fields 資訊,都放在環境變數裡;而把 POST、PUT 等請求所上傳的資料轉為 STDIN 帶給程式。而程式的輸出也是直接往 STDOUT 丟。也就是說:只要是能夠「讀取環境變數」、「對 STDIN/STDOUT」做讀寫的「可執行檔」,都可以搖身一變成為 CGI 程式,你甚至可用 shell script 來編寫,完全不需要先去特別學習什麼知識。而且要測試和除錯也很方便 -- 就仿照網頁伺服器提供的環境變數跟 STDIN 資料餵給 CGI 程式就可以進行測試了。直接用 Google 搜尋一下就有很多教學範例。

可惜,這樣的運作方式雖然有利於開發,但網頁伺服器必需為了每一個請求而各創建一個獨立程序 (process) 去運行 CGI 應用程式,這樣子伺服器上同時進行的程序數量會隨請求量而增加,導致與 c10k 類似的問題。於是乎現今的 CGI 應用程式都是改以 FastCGI 的方式來實作了。請見下述。

FastCGI


FastCGI 是 CGI 的改良版。網頁伺服器會常態性地把 FastCGI 應用程式帶起,保持在背景。在 FastCGI 的術語中,這個 FastCGI 應用程式其實應稱作「FastCGI 應用伺服器 (FastCGI Application Server)」。網頁伺服器在收到頁面請求後會與 FastCGI 應用伺服器建立連線,把原始 CGI 中透過環境變數、或是 STDIN 帶給程式的資料,改為透過連線帶給 FastCGI 應用伺服器,FastCGI 應用伺服器收到請求後,執行結果,並再將輸出透過同條連線回覆給網頁伺服器。如此一來就可以避免 CGI 每個請求都要創建一個程序的困境。

上述「網頁伺服器」以及「FastCGI 應用伺服器」之間,要怎麼進行連線,雙方怎麼傳送資料,這些 FastCGI 中也有規範,網路上可以找到不少實現該協議的現成函式庫。我在專案裡用的是元老級的 libfcgi,有多老哩? 老到他的官網都已經消失了,靠著志願者協助才在 github 上復原了它的線上文件,但預編譯好的 libfcgi 的函式庫倒是可以輕易地在各大 Unix 平台中透過套件系統來安裝,例如 Ubuntu 中可直接以 'apt-get install libfcgi-dev' 指令來安裝。libcgi 的 API 其實並不複雜,以下用簡單的程式例子來說明絕大部分會使用到的 API (程式的本身並沒有意義,純粹是說明流程) :

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

int main()
{
  int fcgi_fd;

  // fcgi 有提供保證 thread-safe 的 API 接口,但需求在使用前先統一由一個 thread 進行 fcgi 初始化
  if ( 0 != FCGX_Init() )
  {
    // FCGI_Init() 成功時應返回 0,其它值視為錯誤
    return 1;
  }

  // 創建接聽的 listening socket。支援 TCP 端口 (":{port}")、或是 named socket ("/path/xyz")
  // 第二個參數內部會用 listen() 的 blacklog 參數。
  fcgi_fd = FCGX_OpenSocket(":8080", 50 /* backlog */);
  if (-1 == fcgi_fd)
  {
    // FCGX_OpenSocket() 成功時返回 socket fd。錯誤時得到 -1。
    return 1;
  }

  // 主迴圈: 持續處理網頁伺服器傳遞來的頁面請求
  while(1)
  {
    // 準備空間 : 裝載頁面請求的資料結構 -- FCGX_Request
    FCGX_Request *req = (FCGX_Request*)malloc(sizeof(FCGX_Request));

    // 將 FCGX_Request 結構進行初始化。第二個參數即為 FCGX_OpenSocket() 產生的 socket fd
    // 若你出於任何原因,需要延用既有的 socket fd 而不想用 FCGX_OpenSocket() 生成的,
    // 請自行保證它是 listening socket 並在這帶入。
    FCGX_InitRequest(req, fcgi_fd, 0);

    // 等待接收網頁伺服器的頁面請求 (Blocking 操作)
    // 待返回後,若返回值非 -1,則 req 中已填入請求內容。
    if (-1 == FCGX_Accept_r(req) )
    {
      // FCGX_Accept_r() 返回 -1 代表錯誤
      return 1;
    }

    /******
     *  typedef struct FCGX_Request {  // FCGX_Request 內部成員
     *    int requestId;
     *    int role;
     *    FCGX_Stream *in;             // 由此 stream 讀入網頁請求所夾帶的上傳資料 (POST,PUT)
     *    FCGX_Stream *out;            // 由此 stream 輸出資料成為網頁內容
     *    FCGX_Stream *err;            // 由此 stream 輸出錯誤訊息,網頁伺服器應負責留存
     *    char **envp;                 // Request 相關資訊、以及 HTTP Header Fields
     *                                 // 以環境變數的方式來儲存
     *    (more implementation details...)
     *  } FCGX_Request;
     */

    // 讀出指定的 Request 相關資訊、或某個 HTTP Header Field
    char * value = FCGX_GetParam("REQUEST_URI", req->envp);

    // 讀取上傳資料 -- 讀取單個字符 
    int ch = FCGX_GetChar(req->in);

    // 讀取上傳資料 -- 讀取指定長度資料
    char stringbuf[1024];
    int cnt = FCGX_GetStr(stringbuf, 1024, req->in);

    // 請取上傳資料 -- 讀取直到斷行或滿足指定長度
    char *line = FCGX_GetLine(stringbuf, 1024, req->in);

    // 輸出網頁資料 -- 寫入單個字符
    ch = 'A';
    FCGX_PutChar(ch, req->out);

    // 輸出網頁資料 -- 寫入指定長度資料
    FCGX_PutStr(stringbuf, 1024, req->out);

    // 輸出網頁資料 -- 寫入以 null 結尾的字串
    const char *str = "test";
    FCGX_PutS(str, req->out);

    // 輸出網頁資料 -- 寫入格式化字串,支援仿 fprintf、以及 vfprintf 的方式
    FCGX_FPrintF(req->out, "%d %s", 123, "hello");
    //FCGX_VFPrintF(req->out, "%d %s", va_list);

    // 設定此請求的結束狀態,注意這並非網頁的狀態碼 (HTTP Status Code)
    // 0 表代正確處理。直接呼叫 FCGX_Finish_r() 而不進行 FCGX_SetExitStatus() 等同於 Status 0
    FCGX_SetExitStatus(0, req->out);

    // 將網頁請求標記為已完結,網頁伺服器發出請求後會等待完結,拖太久會直接回 504 Gateway Timeout
    FCGX_Finish_r(req);

    // 釋放記憶體
    free(req);
  }

  return 0;
}


上例中為了清楚顯示使用流程,是以單線程迴圈的方式來寫作,而實際的情況應該會在 FCGX_Accept_r() 完成後,將 FCGX_Request 分派到不同的工作線程中處理,最終也是在各別工作線程內完結、釋放 FCGX_Request。另外,也不需要一定將 FastCGI 應用伺服器實作為獨立執行的程式,在既有的伺服器程式中,只需另開線程,線程主體函數 (thread body) 的內容改為上例 main 的內容就可以在同程序中開始處理網頁伺服器來的請求了。

前面講 CGI 時有提到,網頁伺服器會把「Request 相關資訊」、以及「Request 內的 HTTP header fields」填入到環境變數中,供程式讀取。而 FastCGI 中改為用網路來傳送。這些資料被重組成類似環境變數的格式 (environ,見 man page) 放置在 FCGX_Request 內的 envp 成員中,可透過 FCGX_GetParam() 來提取。試寫一個 FastCGI 應用,走訪 envp,然後把內容物輸出為網頁內容,來列舉裡面倒底有什麼東西,以下是我得到的結果:


其中,以 "HTTP_" 開頭的 Key-Value,是來自原本 HTTP Request 的 Header Fields,不過 Field Name 會經過些許的變換:

  • 所有小寫字母改為大寫字母
  • 所有減號 ('-') 會改為底線 ('_')
  • 最後再加上 "HTTP_" 的前綴
舉例,Facebook Messenger 平台調用 Webhook 時,固定會在 HTTP Request 中加上 "X-Hub-Signature" 這個 Header Field。若把這個 Webhook Request 轉給 FastCGI 處理時,名稱會變換為 'HTTP_X_HUB_SIGNATURE'。

其它不是以 "HTTP_" 開頭的 Key-Value,則是由網頁伺服器提供、關於此 Request 的細節資訊。各資訊的說明請見維基上的列表。注意,依據使用的網頁伺服器軟體不同,可能會有少數項目有出入。例如上圖中的 "DOCUMENT_URI"  貌似只有 nginx 會提供,但義意類同 "REQUEST_URI"。

網頁伺服器管理 FastCGI 應用伺服器的方式可分為兩種: 一種是在背景常態性帶起固定數量的 FastCGI 應用伺服器程序,其生命週期完全交給網頁伺服器來托管,這樣的方式稱為「內部 FastCGI 應用」(Internal FastCGI App)。另一種是完全不插手 FastCGI 應用伺服器的生命周期,純粹在有需求時就連線到一個指定的 IP 位置/埠號 (或 named socket),若沒反應就回 502 Bad Gateway 錯誤。這樣的方式稱為「外部 FastCGI 應用」(External FastCGI App)。

如果是要在既有的伺服器程式上添加 Web API 處理,通常只能走外部的方式,因為它的生命週期不太可能「被托管」。且在開發期間,也是用外部的方式比較好進行除錯。像我主要是在 Windows MSVC 環境中開發,通常是直接運行 Debug 版本的 FastCGI 應用伺服器,在網頁伺服器上設定把 CGI 請求轉到 Windows 機器 IP 上 FastCGI 接聽的埠號上就可以下斷點偵錯。

各種網頁伺服器對「內部 FastCGI 應用」、和「外部 FastCGI 應用」的支援度不盡相同:

  • Apache : 內外皆可 (mod_fastcgi)
  • Nginx: 只支援「外部」
  • Lighttpd : 內外皆可
  • Microsoft IIS : 只支援「內部」


nginx + FastCGI


如果純粹是為了提供 RESTful Web API 的支援,我偏好使用 nginx (音: Engine-X),因為它輕量、設定簡單外,還兼有 load-balancer 的功能。

在 Ubuntu 上可直接以 'apt-get install nginx' 來安裝。它的設定檔位於 /etc/nginx 目錄內。要設定外部 FastCGI 應用伺服器位置,可直接編輯 /etc/nginx/sites-available/default ,在 server {} 區塊內添加以下 location 設定即可:

server {

    # ... 前面省略 ...

    location /my_fcgi_app {
        include fastcgi.conf;
        # {ip}:{port} 請自己代換
        fastcgi_pass 127.0.0.1:1234;
    }

}

設定完重新啟動 nginx 即可。Ubuntu 系統上使用 'service nginx restart' 指令。然後確定你指定的位置有 FastCGI 應用伺服器在接聽,接著只要用瀏覽器開啟 http://{你的ip}/my_fcgi_app 這個網頁就會是由你的 FastCGI 應用伺服器來處理頁面請求並且輸出結果。

開發 FastCGI 應用伺服器的過程中,程式會常常要修修改改、開開關關的,此時可能會發生「明明 FastCGI 應用伺服器是運行中,但是 nginx 卻回 504 Gateway Timeout」的情況。這是因為 nginx 會聯繫不上 FastCGI 應用伺服器後,內部會有一個 timeout 設定,而該預設值對開發期間來說太大了,可編輯 /etc/nginx/nginx.conf ,在 http {} 區塊的最後加上針對 fastcgi timeout 的設定值:

http {

    # ... 前面省略 ...

    # 單位: 秒
    fastcgi_read_timeout 10;
    fastcgi_send_timeout 10;
    fastcgi_connect_timeout 5;
}


設定 nginx 使支援 HTTPS


如果你的 Web API 是一個要給第三方網路平台使用的 Webhook (例如: LINE、Facebook),那你必需要確保支援 HTTPS。

要如果快速、免費地取得域名、以及非自簽的 HTTPS 憑證,請見我的這篇文章。照著進行後,最終你可以在 SSLForFree 平台上取到三個憑證相關的檔案: ca_bundle.crt、certificate.crt、以及 private.key。

接著你要把 certificate.crt、ca_bundle.crt 這兩個檔案內容相串接 (按前述順序,注意串接處可能會少一個斷行符),並且合併為一個檔案,取名叫 bundle.crt。把 bundle.crt 跟 private.key 放置到 /etc/nginx/ssl 底下 (目錄需自建)。

最後編輯 /etc/nginx/sites-available/default,開啟 SSL 設定:

server {

    # SSL configuration
    #
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    ssl_certificate /etc/nginx/ssl/bundle.crt;
    ssl_certificate_key /etc/nginx/ssl/private.key;

}


設定完重新啟動 nginx 即可。


2 則留言:

  1. 剛接觸 FastCGI, 您的文章對剛入門的我非常有幫助,感謝!

    回覆刪除
  2. 程式會卡到FCGX_Accept_r,攔封包看有三項交握完成,但馬上被斷線(FIN),我需要設定甚麼嗎?
    您的gcc方式可否提供,謝謝您

    回覆刪除