apisix~自定義檔案上傳代理外掛~支援form-data檔案和kv引數

张占岭發表於2024-10-12

參考文獻

  • https://stackoverflow.com/questions/24535189/composing-multipart-form-data-with-a-different-content-type-on-each-parts-with-j
  • https://www.reddit.com/r/lua/comments/yaizxv/lua_post_multipartformdata_and_a_file_via/?rdt=60519
  • https://github.com/rstudio/redx/issues/19
  • client_body_buffer_size https://github.com/apache/apisix/issues/10692
  • client_body_buffer_size https://github.com/apache/apisix/issues/6741

問題產生的原因

後端有個檔案上傳服務,前端可以直接像檔案上傳到伺服器,但這個上傳服務除了有form-data檔案流之外,還需要有其它key/value的表單引數,這些引數是固定的,或者有一定的規則,這時我們透過apisix代理一下,就顯得更加靈活和理了。

http中的multipart/form-data訊息體如下

修改後的請求,是一個標準的http請求,你透過postman的codesnippet檢視也可以看到,程式碼如下

POST /mobile-server/manager/6.0.0.0.0/cdnManage/customUpload HTTP/1.1
Host: api-gw-test.pkulaw.com
Cookie: CookieId=b97385476b3c721c81a9163f1c8a85dd; SUB=347c9e9e-076c-45e3-be74-c482fffcc6e5; preferred_username=test; session_state=458053bd-5970-4200-9b6f-cf538ec9808b
Content-Length: 508
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="folder"

app/icon
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="domain"

https://static.pkulaw.com
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="fileName"

xzcf.png
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="multipartFile"; filename="/C:/Users/User/Pictures/21111.png"
Content-Type: image/png

(data)
----WebKitFormBoundary7MA4YWxkTrZu0gW--

開發過程中的一些坑

  1. 引數拼接錯誤,form-data的檔案流應該是第一個引數
服務端收到的請求體和引數為空
  1. 後端服務直接報錯,原因有以下幾個
  • 有空的boundary,
  • boundary與欄位之間沒有\r\n換行
  • 將所有\n替換為\r\n,可能會解決上傳檔案和引數在接收端為空的問題
  • http請求頭中的boundary是沒有開頭的兩個減號的,這塊非常容易出錯,例如ngx.req.set_header("Content-Type", "multipart/form-data; boundary=" .. boundary)
  • boundary在各欄位之前並不相同,需要著重看一下,一般是------開頭,看看是否-的數量不同,可能接收端會有下面的錯誤,表示請求體拼接不正確
Failed to parse multipart servlet request; nested exception is java.io.IOException: org.apache.tomcat.util.http.fileupload.FileUploadException: Stream ended unexpectedly
  • 請求報400,大於8K的檔案無法上傳
    • 原因:apisix中的nginx配置中,client_max_body_size預設設定為0,表示沒有限制,不是它的原因
    • 原因:client_body_buffer_size在apisix中沒有設定,它會使用預設值,預設為8K,大於8K的檔案就會報錯,是這個原因
    • 解決:修改apisix-helm/values.yaml檔案,nginx.configurationSnippet.httpStart節點新增配置client_body_buffer_size:15m
    nginx:
        # -- Custom configuration snippet.
        configurationSnippet:
          main: |
    
          httpStart: |
            client_body_buffer_size 20m;
    

file-upload-proxy檔案上傳轉發外掛原始碼

-- author: zhangzhanling
-- 檔案上傳服務代理
-- 代理前端,與檔案上傳服務進行通訊
-- 在請求體中,新增統一的引數
local core = require("apisix.core")
local uuid = require("resty.jit-uuid")
local ngx = require("ngx")
-- 定義原資料格式
local schema = {
    type = "object",
    properties = {
        folder = {
            type = "string",
            description = "相對目錄"
        },
        domain = {
            type = "string",
            description = "圖片服務的域名"
        }
    }
}

local _M = {
    version = 0.1,
    priority = 1009, --數值超大,優先順序越高,因為authz-keycloak是2000,它需要在authz-keycloak之後執行,所以把它定為1000,因為咱們也依賴proxy_rewrite外掛
    name = "file-upload-proxy",
    schema = schema
}

local function get_specific_header(ctx, header_name)
    local headers = core.request.headers(ctx)
    local value = headers[header_name]
    if type(value) == "table" then
        return table.concat(value, ", ")
    else
        return value
    end

end
-- 輔助函式:查詢邊界字串
local function find_boundary(content_type)
    return content_type:match("boundary=([^;]+)")
end

function _M.rewrite(conf, ctx)
    ngx.req.read_body()
    local body_data = ngx.req.get_body_data()

    if not body_data then
        core.log.warn("Failed to read request body.")
        return 400
    end

    local content_type = ngx.req.get_headers()["content-type"]
    local boundary = find_boundary(content_type)

    if not boundary then
        core.log.warn("No boundary found in content type.")
        return 400
    end

    local startBoundary = "--" .. boundary

    local sub_value = get_specific_header(ctx, "sub")
    local folder = conf.folder
    if sub_value then
        folder = folder .. "/" .. sub_value
    end

    ---- 構建新的請求體
    local new_body = ""

    local fileExt = ".jpg"
    local filename = string.match(body_data, 'filename="([^"]+)"')

    if filename then
        -- 從filename中提取副檔名
        local _, _, ext = string.find(filename, "%.([^.]+)$")
        if ext then
            core.log.info("副檔名為: " .. ext)
            fileExt = "." .. ext;
        end
    end

    -- 新增新欄位
    local new_fields = {
        { name = "domain", value = conf.domain },
        { name = "fileName", value = uuid() .. fileExt },
        { name = "folder", value = folder }
    }
    ---- 新增新欄位
    for _, field in ipairs(new_fields) do
        new_body = new_body .. string.format("\r\n%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s", startBoundary, field.name, field.value)
    end

    new_body = new_body .. "\r\n" .. body_data

    -- 設定新的請求體
    ngx.req.set_body_data(new_body)

    -- 更新 Content-Type 頭
    ngx.req.set_header("Content-Type", "multipart/form-data; boundary=" .. boundary)

    -- 計算並設定 Content-Length
    local content_length = string.len(new_body)
    ngx.req.set_header("Content-Length", content_length)

    -- 日誌輸出新請求體和內容長度
    core.log.warn("boundary:", boundary)
    core.log.warn("New request body: ", new_body)
    core.log.warn("Content-Length: ", content_length)
end

-- 註冊外掛
return _M

相關文章