寫了一個 FastCGI 實現

moodrain發表於2021-03-25

github 地址 github.com/moodrain/fastcgi-go

最近在學習 PHP 進階相關,一直都知道 Nginx 和 PHP 的互動是靠 PHP-FPM,並且是透過 FastCGI 協議的。按道理來說 FastCGI 只是一個協議,用什麼語言實現都可以,就用 Golang 來寫一個吧

協議內容

原文 fastcgi-archives.github.io/FastCGI...
翻譯 mp.weixin.qq.com/s/oRTd_2WYabAvM-Q...

簡單來說就是每一個包分為頭和內容,頭定義了包的型別和長度,根據這這些引數去處理包的內容

主要結構

頭 結構

type FcgiHeader struct {
    Version byte
    Type byte
    RequestId uint16
    ContentLength uint16
    // RequestIdB1 byte
    // RequestIdB0 byte
    // ContentLengthB1 byte
    // ContentLengthB0 byte
    PaddingLength byte
    Reserved byte
}

頭的長度是 8 byte,如果每個欄位都是 1 byte,那麼 8 個欄位就剛剛好(事實上規範也是如此)。不過 RequestIdB1 和 RequestIdB0 共同表示請求 ID(ContentLengthB 也是類似),這樣請求 ID 最大長度可以到 2 byte。為了方便處理,定義 Golang 結構體的時候把這些分成兩個欄位的用一個 uint16 表示了

內容 請求開始 結構
Role 表示伺服器希望 FastCGI 擔任的角色,一般是 FCGI_RESPONDER

type FcgiBeginRequestBody struct {
    Role uint16
    // RoleB1 byte
    // RoleB0 byte
    Flags    byte
    Reserved [5]byte
}

內容 請求結束 結構
ProtocolStatus 表示返回的狀態 正常的話是 FCGI_REQUEST_COMPLETE

type FcgiEndRequestBody struct {
    AppStatus uint32
    // AppStatusB3 byte
    // AppStatusB2 byte
    // AppStatusB1 byte
    // AppStatusB0 byte
    ProtocolStatus byte
    Reserved [3]byte
}

可以看到這些包長度都是 8 byte 的(位元組對齊),協議建議在記憶體中處理記錄時,儘量保持每個記錄的起始指標地址為 8 位元組的整數倍

主要流程

var id uint16
env := make(map[string]string)
var data string
process := make(map[string]bool)
for {
    buff := make([]byte, HEAD_LEN)
    // 請求完成後伺服器可能會斷開連線,捕捉錯誤防止程式退出
    if _, err := conn.Read(buff); err != nil {
        _ = conn.Close()
        if conn, err = server.Accept(); err != nil {
            log.Fatal("Network Error")
        } else {
            if _, err = conn.Read(buff); err != nil {
                log.Fatal("Network Error")
            }
        }
    }
    head := ReadHead(buff)
    // 目前只做一次只接受一個請求的方案,請求 ID 不一致就跳過
    if id == 0 && head.RequestId != 0 {
        id = head.RequestId
    }
    if id != head.RequestId {
        continue
    }
    switch head.Type {
    case FCGI_BEGIN_REQUEST: ReadBeginRequest(head, conn)
    case FCGI_PARAMS:
        if newEnv := ReadParamsRequest(head, conn); len(newEnv) > 0 {
            env = newEnv
        }
        process["params"] = true
    case FCGI_STDIN:
        if newData := ReadStdinRequest(head, conn); len(newData) > 0 {
            data = newData
        }
        process["stdin"] = true
    default:
        log.Fatal("Unknown Request Type", head.Type)
    }
    // 經過 Params 和 Stdin 兩個階段後開始呼叫 PHP 處理請求
    if process["params"] && process["stdin"] {
        rs := ExecPhp(env, data)
        SendResponse(id, rs, conn)
        // 完成請求後重置 process 狀態
        id = 0
        process["params"] = false
        process["stdin"] = false
    }
}

PHP 測試程式碼

<?php
// 由於此時 PHP 不像 PHP-FPM 那樣執行在 CGI 環境
// $_GET, $_POST 這些全域性變數都是無法使用的,這裡需要傳參模擬來相容
if (php_sapi_name() == 'cli') { // 在 PHP-FPM 會返回 cgi-fcgi
    $post = getopt('', ['post:'])['post'] ?? '';
    parse_str($post, $_POST);
}

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    $id = $_POST['id'] ?? 1;
    $name = $_POST['name'] ?? 'user';
    $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown Agent';

    echo $id . '-' . $name . ' from ' . $ua;
} else {
    echo 'Hello World';
}

測試

訪問 localhost:8080,在表單隨便寫點內容,然後提交

寫了一個 FastCGI 實現

PHP 字尾的請求正確被轉發到 FastCGI 處理了

寫了一個 FastCGI 實現

再看下 Golang 程式的輸出,截圖省去了中間 FCGI_PARAMS 的大部分欄位,這些欄位都會儲存在 PHP 的 $_SERVER 全域性變數裡

寫了一個 FastCGI 實現

備註

程式碼參考了 github.com/zhyee/fastcgi-demo 的 FastCGI C 語言版本
本人也在學習 Golang 和 PHP 過程中,程式碼和解釋難免出現問題,如果發現請不吝賜教

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章