傳統開發中,文件上傳是比較自由的:上傳什麼文件、怎麼上傳、存儲到哪裏等問題往往都是由開發者決定的,但是在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 的落地與項目上雲。