如何在 Serverless 架構下優雅上傳文件?

傳統開發中,文件上傳是比較自由的:上傳什麼文件、怎麼上傳、存儲到哪裏等問題往往都是由開發者決定的,但是在Serverless架構下,上傳文件就沒有這麼自由了。無論是成本原因,還是某些服務限制,我們都需要尋求一些比較"優"的解決方案。

Serverless架構與文件上傳

由於Serverless架構中函數計算的部分是沒有辦法做文件持久化,函數執行的容器用完過後就會被回收,所以如果想要存儲文件,就需要藉助對象存儲等相關服務。

將文件上傳到對象存儲服務的方法很多,本文主要介紹兩種:

  • 函數計算->對象存儲
  • 對象存儲

一般情況下,如果某個有文件上傳功能,我們會用multipart/form-data,或者將文件進行base64編碼之後再上傳。但在Serverless架構下,這種思路需要做出一些改變。

函數計算->對象存儲是上傳文件比較常見,也是比較容易的方式。文件直接通過API網關,傳送到雲函數中,並重做一些處理(例如壓縮圖像、視頻轉碼、數據入庫等),然後再由雲函數將結果存儲到對象存儲中,做文件資源的持久化。

這個思路看起來很順暢,但是實際操作起來也會遇到很多問題:

首先,通過這種方式將文件傳給函數時,函數計算通過API網關得到的數據結構往往是JSON格式,或者是字符串。這樣的設計使得函數計算對二進制的支持非常不友好,我們只能將文件轉換爲base64編碼後再進行傳輸,通過API網關之後,函數接收到數據,再將base64編碼的文件解碼,經過相關處理後持久化對象存儲。

其次,無論是AWS的Lambda,還是騰訊雲的SCF,通過API網關觸發函數時都會有數據包大小限制的。以騰訊云爲例,數據包的限制是6M。也就是說,無論發送多大的數據,從API網關到函數計算都會有一個數據包的最大限制,上傳文件過大,就無法進行資源的傳輸。所以,上傳到雲函數的文件必須在6M以下,而函數計算對二進制文件不友好,經過base64編碼的數據包通常會大些,這樣上傳到雲函數的數據包必須在4M左右。

4M的圖片是什麼概念呢?如上圖所示,是我用手機隨機拍了一張圖片,大小是6.21M,這時我是無法將這張圖片上傳到SCF進行處理的。

除了對文件大小有限制之外,上述方法對成本也有一定影響,API網關並不是一個適合傳輸文件的方法。我們可以單從流量費用來對比一下對象存儲和API網關的區別:

  • API網關的收費:

  • 對象存儲的收費:

單從流量維度來看,API網關的費用比COS高了許多,主要原因可能是因爲API網關更側重於控制流,在數據存儲傳輸方面,對象存儲更適合。

那麼,有什麼方法可以直接將文件等資源上傳到對象存儲呢?這條資源數據又如何入庫呢(例如用戶上傳圖片到相冊功能,若使用傳統方法,系統接收到圖片之後,會將數據入庫,但若是將圖片直接上傳到對象存儲,我們如何得知這個圖片是誰給我們的)?另外,將文件上傳到對象存儲需要寫入權限,那麼是將權限開發?還是使用密鑰?如果是一個Web服務,這個密鑰信息又應該存儲在哪裏?如何存儲?

於是,就衍生出了第二種解決方法:

對象存儲方法中,客戶端會發起三個請求,分別是獲取臨時上傳地址、將文件上傳到COS、獲取處理結果。相比於之前的方法,這個方法會複雜一些,但是能夠很好的支持二進制上傳、文件資源的大小以及成本控制。

針對不同場景的的不同適用方案:

  • 場景1: 用戶上傳頭像功能

針對這樣的場景,直接選用方案1。

一般情況下,頭像都不會很大,完全可以在客戶端對圖像進行一次壓縮和裁剪之後,直接帶着用戶的參數,例如token等,上傳到函數計算,在函數計算中將圖片轉存到對象存儲,將圖像和用戶信息進行關聯,並將某些結果返回給客戶端。整個流程只需要一個函數,方便快捷。

  • 場景2: 用戶上傳圖片到相冊系統中

針對這樣的場景,方案2會更好。

如果用戶是上傳圖片到相冊,那麼基本都是希望保留原圖,不希望被壓縮,而原圖大小很可能會超過6M,這時方案1就不是特別合理了。使用對象存儲方法,用戶可以帶着圖像要上傳的相冊以及圖片名稱,用戶的token發起獲取臨時密鑰到函數1中,函數1將用戶、相冊、圖片以及狀態(例如待上傳、待處理、已處理等)等信息關聯、存儲,並將臨時地址返回給客戶端,客戶端將圖片上傳到對象存儲中,通過對象存儲觸發器觸發函數2,函數2對圖像進行壓縮(一般情況下,相冊列表都會顯示壓縮圖片,點到相冊詳情纔會有完整的無損圖片),並且和之前信息進行關聯,修改數據狀態。在用戶上傳圖片完成之後,如果有需要,客戶端就可以發起第三次請求獲取圖像存儲/處理結果,函數3會查詢數據庫狀態,在某個時間閾值內,如果數據狀態是完成,則表示數據已經上傳並且完成了部分處理,否則會返回對應的異常信息。

代碼實例

接下來分享上面兩種方法的實現過程:

函數1,實現第一種方案,文件通過Base64傳遞到SCF,由SCF轉存到COS:

def uploadToScf(event, context):
    print('event', event)
    print('context', context)
    body = json.loads(event['body'])

    # 可以通過客戶端傳來的token進行鑑權,只有鑑權通過纔可以獲得臨時上傳地址
    # 這一部分可以按需修改,例如用戶的token可以在redis獲取,可以通過某些加密方法獲取等
    # 也可以是傳來一個username和一個token,然後去數據庫中找這個username對應的token是否
    # 與之匹配等,這樣會儘可能的提升安全性
    if "key" not in body or "token" not in body or body['token'] != 'mytoken' or "key" not in body:
        return {"url": None}

    pictureBase64 = body["picture"].split("base64,")[1]
    with open('/tmp/%s' % body['key'], 'wb') as f:
        f.write(base64.b64decode(pictureBase64))
    region = os.environ.get("region")
    secret_id = os.environ.get("TENCENTCLOUD_SECRETID")
    secret_key = os.environ.get("TENCENTCLOUD_SECRETKEY")
    token = os.environ.get("TENCENTCLOUD_SESSIONTOKEN")
    config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key, Token=token)
    client = CosS3Client(config)
    response = client.upload_file(
        Bucket=os.environ.get("bucket_name"),
        LocalFilePath='/tmp/%s' % body['key'],
        Key=body['key'],
    )
    return {
        "uploaded": 1,
        "url": 'https://%s.cos.%s.myqcloud.com' % (
            os.environ.get("bucket_name"), os.environ.get("region")) + body['key']
    }

函數1,實現第二種方案,進行臨時簽名URL的獲取:

def getPresignedUrl(event, context):
    print('event', event)
    print('context', context)
    body = json.loads(event['body'])

    # 可以通過客戶端傳來的token進行鑑權,只有鑑權通過纔可以獲得臨時上傳地址
    # 這一部分可以按需修改,例如用戶的token可以在redis獲取,可以通過某些加密方法獲取等
    # 也可以是傳來一個username和一個token,然後去數據庫中找這個username對應的token是否
    # 與之匹配等,這樣會儘可能的提升安全性
    if "key" not in body or "token" not in body or body['token'] != 'mytoken' or "key" not in body:
        return {"url": None}

    # 初始化COS對象
    region = os.environ.get("region")
    secret_id = os.environ.get("TENCENTCLOUD_SECRETID")
    secret_key = os.environ.get("TENCENTCLOUD_SECRETKEY")
    token = os.environ.get("TENCENTCLOUD_SESSIONTOKEN")
    config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key, Token=token)
    client = CosS3Client(config)

    response = client.get_presigned_url(
        Method='PUT',
        Bucket=os.environ.get('bucket_name'),
        Key=body['key'],
        Expired=30,
    )
    return {"url": response.split("?sign=")[0],
            "sign": urllib.parse.unquote(response.split("?sign=")[1]),
            "token": os.environ.get("TENCENTCLOUD_SESSIONTOKEN")}

HTML頁面基本實現:

HTML部分:

<div style="width: 70%">
        <div style="text-align: center">
            <h3>Web端上傳文件</h3>
        </div>
        <hr>
        <div>
            <p>
                方案1:通過上傳到SCF,進行處理再轉存到COS,這種方法比較直觀,但是問題是SCF從APIGW處只能接收到小於6M的數據,而且對二進制文件處理並不好。
            </p>
            <input type="file" name="file" id="fileScf"/>
            <input type="button" onclick="UpladFileSCF()" value="上傳"/>
        </div>
        <hr>
        <div>
            <p>
                方案2:
                直接上傳到COS,流程是先從SCF獲得臨時地址,進行數據存儲(例如將文件信息存到redis等),然後再從客戶端進行上傳COS,上傳結束可通過COS觸發器觸發函數,從存儲系統(例如已經存儲到redis)讀取到更對信息,在對圖像進行處理。
            </p>
            <input type="file" name="file" id="fileCos"/>
            <input type="button" onclick="UpladFileCOS()" value="上傳"/>
        </div>
    </div>

方案1上傳部分JS:

function UpladFileSCF() {
    var oFReader = new FileReader();
    oFReader.readAsDataURL(document.getElementById("fileScf").files[0]);
    oFReader.onload = function (oFREvent) {
        const key = Math.random().toString(36).substr(2);
        var xmlhttp = window.XMLHttpRequest ? (new XMLHttpRequest()) : (new ActiveXObject("Microsoft.XMLHTTP"))
        xmlhttp.onreadystatechange = function () {
            if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
                if (JSON.parse(xmlhttp.responseText)['uploaded'] == 1) {
                    alert("上傳成功")
                }
            }
        }
        var url = " https://service-f1zk07f3-1256773370.bj.apigw.tencentcs.com/release/upload/cos"
        xmlhttp.open("POST", url, true);
        xmlhttp.setRequestHeader("Content-type", "application/json");
        var postData = {
            picture: oFREvent.target.result,
            token: 'mytoken',
            key: key,
        }
        xmlhttp.send(JSON.stringify(postData));
    }

}

方案2上傳部分JS:

function doUpload(key, bodyUrl, bodySign, bodyToken) {
    var fileObj = document.getElementById("fileCos").files[0];
    xmlhttp = window.XMLHttpRequest ? (new XMLHttpRequest()) : (new ActiveXObject("Microsoft.XMLHTTP"));
    xmlhttp.open("PUT", bodyUrl, true);
    xmlhttp.onload = function () {
        console.log(xmlhttp.responseText)
        if (!xmlhttp.responseText) {
            alert("上傳成功")
        }
    };
    xmlhttp.setRequestHeader("Authorization", bodySign);
    xmlhttp.setRequestHeader("x-cos-security-token", bodyToken);
    xmlhttp.send(fileObj);
}

function UpladFileCOS() {
    const key = Math.random().toString(36).substr(2);

    var xmlhttp = window.XMLHttpRequest ? (new XMLHttpRequest()) : (new ActiveXObject("Microsoft.XMLHTTP"))
    xmlhttp.onreadystatechange = function () {
        if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
            var body = JSON.parse(xmlhttp.responseText)
            if (body['url']) {
                doUpload(key, body['url'], body['sign'], body['token'])
            }
        }
    }
    var getUploadUrl = 'https://service-f1zk07f3-1256773370.bj.apigw.tencentcs.com/release/upload/presigned'
    xmlhttp.open("POST", getUploadUrl, true);
    xmlhttp.setRequestHeader("Content-type", "application/json");
    xmlhttp.send(JSON.stringify({
        token: 'mytoken',
        key: key,
    }));
}

這裏面可以看到獲取用戶密鑰信息的方法是os.environ.get(“TENCENTCLOUD_SECRETID”),想要通過這種方法獲取密鑰信息,需要給予函數相關的角色和對角色進行相關的權限,以Serverless Framework爲例,可以使用tencent-cam-role,例如創建一個全局組件:

Conf:
  component: "serverless-global"
  inputs:
    region: ap-beijing
    runtime: Python3.6
    role: SCF_UploadToCOSRole
    bucket_name: scf-upload-1256773370

然後創建一個增加Role的組件:

UploadToCOSRole:
  component: "@gosls/tencent-cam-role"
  inputs:
    roleName: ${Conf.role}
    service:
      - scf.qcloud.com
    policy:
      policyName:
        - QcloudCOSFullAccess

接下來就是函數的創建,函數創建時需要綁定剛纔的這個role:

getUploadPresignedUrl:
  component: "@gosls/tencent-scf"
  inputs:
    name: Upload_getUploadPresignedUrl
    role: ${Conf.role}
    codeUri: ./fileUploadToCos
    handler: index.getPresignedUrl
    runtime: ${Conf.runtime}
    region: ${Conf.region}
    description: 獲取cos臨時上傳地址
    memorySize: 64
    timeout: 3
    environment:
      variables:
        region: ${Conf.region}
        bucket_name: ${Conf.bucket_name}

同時將這個函數綁定APIGW:

UploadService:
  component: "@gosls/tencent-apigateway"
  inputs:
    region: ${Conf.region}
    protocols:
      - http
      - https
    serviceName: UploadAPI
    environment: release
    endpoints:
      - path: /upload/cos
        description: 通過SCF上傳cos
        method: POST
        enableCORS: TRUE
        function:
          functionName: Upload_uploadToSCFToCOS
      - path: /upload/presigned
        description: 獲取臨時地址
        method: POST
        enableCORS: TRUE
        function:
          functionName: Upload_getUploadPresignedUrl

另外,這個例子還需要一個COS存儲桶來作爲測試使用,由於Web服務可能存在跨域問題,所以需要對COS進行跨域設置:

SCFUploadBucket:
  component: '@gosls/tencent-cos'
  inputs:
    bucket: ${Conf.bucket_name}
    region: ${Conf.region}
    cors:
      - id: abc
        maxAgeSeconds: '10'
        allowedMethods:
          - POST
          - PUT
        allowedOrigins:
          - '*'
        allowedHeaders:
          - '*'

完成之後,可以快速部署:

(venv) DFOUNDERLIU-MB0:test dfounderliu$ sls --debug

  DEBUG ─ Resolving the template's static variables.
  DEBUG ─ Collecting components from the template.
  DEBUG ─ Downloading any NPM components found in the template.
  ... ...
    apis: 
      - 
        path:   /upload/cos
        method: POST
        apiId:  api-0lkhke0c
      - 
        path:   /upload/presigned
        method: POST
        apiId:  api-b7j5ikoc

  15s › uploadToSCFToCOS › done

至此,我們完成了項目部署,可以進行測試與適用。

總結

Serverless可以看作是一個新的技術、新的架構。我們在接觸新鮮事物的時候,或多或少都要有一個適應期,如何在Serverless架構下上傳文件,就是需要適應的部分。我們之前習慣了直接將文件上傳到服務器的,但在接觸Serverless架構之後,由於網關->函數對二進制支持和數據包大小問題,出於安全考慮,前端不方便直接放密鑰信息等問題,之前簡單的事情可能會變得複雜。

作者介紹:

劉宇,騰訊 Serverless 團隊後臺研發工程師。畢業於浙江大學,碩士研究生學歷,曾在滴滴出行、騰訊科技做產品經理,本科開始有自主創業經歷,是 Anycodes 在線編程的負責人(該軟件累計下載量超 100 萬次)。目前投身於 Serverless 架構研發,著書《Serverless 架構:從原理、設計到項目實戰》,參與開發和維護多個 Serverless 組件,是活躍的 Serverless Framework 的貢獻者,也曾多次公開演講和分享 Serverless 相關技術與經驗,致力於 Serverless 的落地與項目上雲。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章