如何在 Serverless 架构下优雅上传文件?

2020 年 6 月 03 日

如何在 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 的落地与项目上云。

2020 年 6 月 03 日 16:46 4030

评论 1 条评论

发布
用户头像
框架层面支持流,就不用这么麻烦了
2020 年 06 月 04 日 10:37
回复
没有更多评论了
发现更多内容

架构师训练营第七周学习总结

fenix

极客大学架构师训练营

elasticsearch-restful-api笔记

wkq2786130

elasticsearch

性能优化-架构师体现技术全面性的时刻

LEAF

一张PDF了解JDK11 GC调优秘籍-附PDF下载

程序那些事

Java jdk GC 秘籍 JDK11

随着并发的增加,响应时间和吞吐的变化

朱月俊

MySQL 锁表后快速解决方法 及 锁表原因

wkq2786130

MySQL

蚂蚁金服上市了,我不想努力了

YourBatman

IPO 财务自由 蚂蚁金服 财富自由

elasticsearch 游标 使用

wkq2786130

elasticsearch

Neo4j APOC 使用

wkq2786130

neo4j apoc

重置 Grafana admin 密码

耳东

Grafana Grafana password

K8S 中的 Grafana 数据持久化

耳东

Kubernetes k8s Grafana 配置文件持久化

过早三件套之面窝

zhoo299

美食

架构师训练营第7周

大丁💸💵💴💶🚀🐟

问题驱动

林昱榕

问题驱动 学习方式

neo4j 批量 导入 数据 的 几种方式

wkq2786130

neo4j

Cmder 使用 笔记

wkq2786130

cmder tools

写在《SRE生存指南》出版之际

Winfield

DevOps SRE

Scrapy爬虫入门

烫烫烫个喵啊

python 爬虫

话说性能那些事

朱月俊

neo4j load csv 使用

wkq2786130

GoF设计模式 | 单例模式

Peision

后端 23种设计模式 java\

第7周总结+作业

林毋梦

canal 笔记

wkq2786130

MySQL canal

JVM性能调优监控工具 jps jstat jinfo jmap jhat jstack

wkq2786130

Java JVM

jqGrid表格封装和使用方法

Seven_xw1213

JavaScript 前端 封装 jqgrid

GoF设计模式 | 工厂方法模式

Peision

23种设计模式 java\

解决 EXT4 使用无法挂载

耳东

ext4 journal

架构师训练营第7周

大丁💸💵💴💶🚀🐟

jvm-config

wkq2786130

Java JVM

OrientDB etl 工具 导入 rdbms数据

wkq2786130

手撕设计模式

Peision

后端 设计模式 23种设计模式 java\

跨越计算鸿沟:如何靠软硬件协同突破算力瓶颈?

跨越计算鸿沟:如何靠软硬件协同突破算力瓶颈?

如何在 Serverless 架构下优雅上传文件?-InfoQ