使用 Vue Formulate、S3 和 Lambda 实现更好的上传
很多开发者都不喜欢创建表单——即使是那些自称喜欢的怪咖,也不喜欢文件上传(或者他们在撒谎🤷♂️)。文件上传很麻烦,而且更糟的是,在完成了所有必要的技术工作之后,最终用户体验通常仍然很差。
朋友们聚在一起,今天我想分享另一种上传文件的方法,这种方法使编写文件上传变得像一样简单<FormulateInput type="file" />
,提供流畅的用户体验,并且不需要服务器端代码(嗯 - AWS Lambdas 在技术上是服务器......呃,你明白了)。
这篇文章很长,但最终的结果值得我们付出努力。以下是我们将要讨论的内容:
看到了吗?内容很多,但请记住,最终结果是<FormulateInput type="file" />
直接上传到 AWS S3。跟我一起,我们一定能搞定。
古老方式
过去,我们通过在包含 HTML 属性的<input type="file">
中插入一个或多个输入框来上传文件。这会帮我们完成所有繁琐的工作,包括上传文件并将其提交到后端。我们的后端代码会处理这些文件,并通常将它们放置在文件系统的某个位置。例如,以下是一段处理文件上传的PHP 脚本(来自 PHP 官方文档):<form>
enctype="multipart-form-data"
<?php
$uploaddir = '/var/www/uploads/';
$uploadfile = $uploaddir . basename($_FILES['userfile']['name']);
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
echo "File is valid, and was successfully uploaded.\n";
} else {
echo "Possible file upload attack!\n";
}
太棒了——我们可以看到 PHP 神奇地创建了一个包含上传文件内容的临时文件,然后我们将该临时文件移动到文件系统上的永久位置(如果我们想保留该文件的话)。这种方法至今仍在各种平台上有效,那么为什么它已经过时了呢?让我们来重点介绍一下这种简单方法的一些不足之处:
- 没有文件正在上传的用户反馈。没有进度条,没有加载动画,也没有禁用的提交按钮。用户只能坐在那里等待表单提交。文件很多?你的用户肯定会感到困惑,然后多次点击提交按钮。太棒了👌
- 如果文件上传出现问题,用户必须等待整个上传过程完成后才能发现。
- 您的后端需要配置才能处理文件上传。对于 PHP,这需要配置诸如、和 之类
php.ini
的变量。upload_max_filesize
post_max_size
max_input_time
- 如果您使用的是 Node 服务器,则上传时需要格外小心。由于 Node 的单线程特性,您的服务器很容易耗尽内存并崩溃。
- 如果您使用无服务器堆栈,您的后端甚至没有文件系统来存储上传内容(这就是本文派上用场的地方👍)。
- 您的服务器的磁盘空间有限,最终会耗尽。
其中一些问题可以通过将文件“穿过”服务器,然后传递到 S3 等云服务来解决。例如,上面的 PHP 代码可以使用流包装器将文件传递到 S3 存储桶而不是本地文件系统。然而,这实际上是双重上传——1) 客户端将文件上传到您的服务器 2) 然后您的服务器将文件上传到 S3。
用户体验更好的方法是通过fetch
或上传文件XMLHttpRequest
(XMLHttpRequest
由于不支持进度更新,因此仍是首选fetch
)。然而,即使使用现有的库,搭建这些 AJAX 上传器也需要大量工作,而且它们本身也存在后端缺陷。
还有另一种方法
如果我们的后端服务器根本不接触文件上传会怎么样?如果我们可以直接从客户端浏览器将文件上传到云提供商会怎么样?如果我们的后端/数据库只存储上传文件的 URL 会怎么样?
Vue Formulate 允许你通过实现自定义函数来增强你的表单file
和image
输入。以下描述了如何使用 AWS Lambda 和 S3 来实现这一点uploader
。什么是 Vue Formulate?很高兴你问到这个问题——它是构建 Vue 表单最简单的方法——我写了一篇关于它的介绍文章,你可能会感兴趣。
为了提供最佳的用户体验,Vue Formulate 以独特的方式处理文件上传。该库负责处理所有用户体验,例如创建拖放区、显示选定文件、进度条、文件验证、显示上传错误以及将已完成的上传推送到表单模型。您只需提供一个 Axios 实例或一个自定义的上传函数来执行所需的 XHR 请求(别担心,我们将在本文中一起探讨这个问题)。
当用户提交表单并@submit
调用处理程序时,Vue Formulate 已经完成了表单中的所有文件上传,并将文件 URL 合并到表单数据中。您的后端可以接收一个简单的 JSON 负载,而无需处理原始文件本身。更棒的是,只需稍加修改,我们就可以将这些文件直接上传到 S3。
那么这种“直接上传”是如何运作的呢?我们又该如何安全地进行操作呢?S3 支持一项功能,允许创建“签名 URL”,这些 URL 包含执行一项预先批准的功能(例如将对象放入 S3 存储桶)所需的所有凭证 😉!然而,要创建这些签名 URL,我们需要在安全的环境中执行一些代码——这个环境可以是标准的后端服务器,但就我们的目的而言,我们将使用一个简单的 Lambda 函数。这是一个很好的 Lambda 用例,因为它是一个小型的、独立的操作,只需在用户将文件添加到表单时运行(无需服务器 24/7 全天候运行以等待执行此操作)。
我们的自定义 Vue Formulate 上传器功能将执行以下几个步骤:
- 收集需要上传的文件。
- 从我们的 AWS Lambda 函数请求签名的上传 URL。
- 使用签名的上传 URL 将文件上传到我们的 S3 存储桶。
一旦我们将自定义上传器添加到 Vue Formulate 实例,我们所有的file
和image
输入都会自动使用此机制。听起来不错吧?好的——让我们开始吧!
1. 设置 AWS 账户
如果您还没有 AWS 账户,则需要先创建一个。这是一个标准的注册流程——您需要验证身份并提供账单信息(不用担心,AWS Lambda 函数调用价格和AWS S3存储价格非常便宜)。
2.创建 S3 存储桶
使用服务下拉菜单导航到 S3,以便我们可以创建一个新的存储桶。创建存储桶时,您需要回答一系列问题。其中包括:
- 存储桶名称——我通常会选择一些可以作为子域名的名称,以便将来为它们创建 DNS 记录。在本例中,我将使用它
uploads.vueformulate.com
作为存储桶名称。 - 区域名称(选择地理位置最接近您的区域)
- 阻止公开访问的存储桶设置——取消选中所有这些框,因为我们将允许公开下载。在本例中,我们不会创建私有文件上传,但同样的流程也适用于该用例。
- 存储桶版本控制 - 您可以禁用此功能,这样更便宜,并且我们将使用随机 ID 来确保不会意外地用新上传的文件覆盖现有文件。
- 标签——这些是可选的,仅在您需要使用时才需要。如果您使用大量 AWS 资源,这些标签有助于跟踪账单成本。
- 高级设置 - 保持“对象锁定”禁用。
3. 为 bucket 配置 CORS
接下来,我们需要确保为存储桶配置 CORS,以启用直接上传功能。在本例中,我将采用更宽松的方式,Access-Control-Allow-Origin: *
因为我希望我的示例可以在任何域中运行。如果您想限制哪些域可以上传文件到您的 S3 存储桶,您可以更具体地设置访问控制。
点击您的存储桶,然后在标签栏中选择“权限”。向下滚动到“跨域资源共享”,点击“编辑”,并输入以下 JSON 配置。最后,点击“保存更改”:
[
{
"AllowedHeaders": [
"Content-Type"
],
"AllowedMethods": [
"PUT"
],
"AllowedOrigins": [
"*"
],
"ExposeHeaders": []
}
]
4.创建 IAM 角色
接下来,我们需要为 Lambda 创建一个 IAM 角色。使用服务菜单导航到 IAM 服务(身份访问管理)。点击侧边栏中的角色,然后选择“创建角色”。从服务用例中选择 Lambda 的“用例”,然后继续下一步。
这是我们附加“策略”(基本上是权限)的地方。我们将添加,AWSLambdaBasicExecutionRole
这使我们的新角色能够运行 Lambda 函数。
接下来,如果需要,添加标签(不是必需的),最后,为您的角色命名并提供您能识别的描述并创建该角色。
接下来,我们需要为该角色添加访问我们创建的 S3 存储桶的权限。选择我们刚刚创建的角色,选择“附加策略”,然后点击顶部的“创建策略”按钮。然后按照以下步骤操作:
- 选择 S3 服务
- 选择操作
PutObject
,然后PutObjectACL
- 指定存储桶 ARN 以及
*
存储桶中的“任意”( ) 对象。 - 审查并命名该策略,然后创建它。
最后回到我们创建的角色,刷新策略列表,搜索我们新创建的策略,并将其添加到角色中。
5.创建 Lambda 和 API
使用服务下拉菜单搜索 Lambda 服务。打开它,选择“创建函数”,然后按照提示操作:
- 选择“从头开始创作”
- 选择一个函数名称,在这个例子中,我将使用“VueFormulateUploadSigner”。
- 更改执行角色并选择“使用现有角色”。选择我们在上一步中创建的新角色。
- 保持高级设置不变并创建函数。
记住,这个 Lambda 函数负责创建我们签名的上传 URL,所以我们需要一个端点来触发 Lambda 的执行。为此,请点击“+ 添加触发器”按钮,选择“API 网关”,然后按照提示操作:
- 选择“创建 API”
- 对于“API 类型”,选择“HTTP API”
- 为了安全起见,请选择“打开”(如果您的特定应用程序需要,您可以随时回来并稍后添加 JWT)
- 将附加设置留空并“添加”网关。
6.添加功能代码
我们需要 Lambda 函数来创建一个签名putObject
URL。在“函数代码”部分中双击index.js
。该文件是 Lambda 运行时将执行的实际代码。在本例中,我们想使用 AWS SDK for node.jsputObject
为 S3 创建一个签名 URL。
这里有一些代码可以实现这个功能。你可以直接复制粘贴到代码编辑器中——不过你需要通读一遍才能理解它的作用。
var S3 = require('aws-sdk/clients/s3');
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type'
}
/**
* Return an error response code with a message
*/
function invalid (message, statusCode = 422) {
return {
isBase64Encoded: false,
statusCode,
body: JSON.stringify({ message }),
headers: {
"Content-Type": "application/json",
...CORS
}
}
}
/**
* Generate a random slug-friendly UUID
*/
function uuid (iterations = 1) {
let randomStr = Math.random().toString(36).substring(2, 15)
return iterations <= 0 ? randomStr : randomStr + uuid(iterations - 1)
}
/**
* Our primary Lambda handler.
*/
exports.handler = async (event) => {
// Handle CORS preflight requests
if (event.requestContext.http.method === 'OPTIONS') {
return {
statusCode: 200,
headers: CORS
}
}
// Lets make sure this request has a fileName
const body = JSON.parse(event.body)
// First, let's do some basic validation to ensure we recieved proper data
if (!body && typeof body !== 'object' || !body.extension || !body.mime) {
return invalid('Request must include "extension" and "mime" properties.')
}
/**
* We generate a random filename to store this file at. This generally good
* practice as it helps prevent unintended naming collisions, and helps
* reduce the exposure of the files (slightly). If we want to keep the name
* of the original file, store that server-side with a record of this new
* name.
*/
const filePath = `${uuid()}.${body.extension}`
/**
* These are the configuration options that we want to apply to the signed
* 'putObject' URL we are going to generate. In this case, we want to add
* a file with a public upload. The expiration here ensures this upload URL
* is only valid for 5 minutes.
*/
var params = {
Bucket: process.env.BUCKET_NAME,
Key: filePath,
Expires: 300,
ACL: 'public-read'
};
/**
* Now we create a new instance of the AWS SDK for S3. Notice how there are
* no credentials here. This is because AWS will automatically use the
* IAM role that has been assigned to this Lambda runtime.
*
* The signature that gets generated uses the permissions assigned to this
* role, so you must ensure that the Lambda role has permissions to
* `putObject` on the bucket you specified above. If this is not true, the
* signature will still get produced (getSignedUrl is just computational, it
* does not actually check permissions) but when you try to PUT to the S3
* bucket you will run into an Access Denied error.
*/
const client = new S3({
signatureVersion: 'v4',
region: 'us-east-1',
})
try {
/**
* Now we create the signed 'putObject' URL that will allow us to upload
* files directly to our S3 bucket from the client-side.
*/
const uploadUrl = await new Promise((resolve, reject) => {
client.getSignedUrl('putObject', params, function (err, url) {
return (err) ? reject(err) : resolve(url)
});
})
// Finally, we return the uploadUrl in the HTTP response
return {
headers: {
'Content-Type': 'application/json',
...CORS
},
statusCode: 200,
body: JSON.stringify({ uploadUrl })
}
} catch (error) {
// If there are any errors in the signature generation process, we
// let the end user know with a 500.
return invalid('Unable to create the signed URL.', 500)
}
}
添加此代码后,点击“部署”。现在,我们需要在 Lambda 中做的最后一件事是添加BUCKET_NAME
环境变量。
从代码编辑器向下滚动,选择环境变量下的“编辑”。输入一个新键BUCKET_NAME
,并将值设置为我们的 S3 存储桶名称(我选择uploads.vueformulate.com
的名称)。点击“保存”,您的 Lambda 即可运行!
7.配置 API 网关
快完成了!在开始向 Lambda 发送 HTTP 流量之前,我们需要配置我们创建的 API 网关。
导航到 API 网关服务,您应该会看到一个与我们的 Lambda 同名但带有-API
后缀的服务——让我们点击它。API 网关服务是一个功能强大的实用程序,可以轻松配置哪些 Lambda 响应哪些 API 请求。如果您选择“开发 > 路由”,您将看到我们的 Lambda 已经连接到/{lambdaName}
路由。
就我个人而言,我更喜欢这种路由,类似于/signature
。我们可以轻松地更改它,并且在进行更改的同时,让我们限制此端点仅响应 POST 请求。
但是有一个问题。由于我们将端点限制为POST
only,浏览器的 CORSOPTIONS
预检请求将会失败。
让我们为同一路径添加另一条路由,/signature
该路由也指向我们的 Lambda(我们的代码将处理 CORS 请求)。创建路由,然后点击路由上的“创建并附加集成” OPTIONS
,并按照提示操作:
- 选择“Lambda 函数”作为集成类型。
- 选择我们的 Lambda 的区域和功能。
- 创建集成。
对此默认 API 进行更改时,更改会自动部署到默认的“阶段”。您可以将阶段视为环境。在此添加多个阶段超出了我们此处讨论的范围。对于这样一个简单的函数,使用默认阶段完全没问题。
如果您导航回此 API 的主页,您将看到我们有一个“调用 URL” $default
- 这是您的新 API URL!
(如果您愿意,可以将其更改为自定义域,但本指南不关注这一点)
8.测试您的端点!
呼——这需要一些时间,但我们现在应该可以正常运行了。要测试,请复制“调用 URL”并将其附加/signature
到其末尾。让我们尝试使用 cURL 请求 ping 我们的端点。请务必将值替换为您自己的端点值:
curl -d '{"extension": "pdf", "mime": "application/json"}' \
-H 'Content-Type: application/json' \
-X POST https://cq2cm6d0h6.execute-api.us-east-1.amazonaws.com/signature
您应该会收到带有签名 URL 的 JSON 响应:
{"uploadUrl":"https://s3.amazonaws.com/uploads.vueformulate.com/hf8wj10h5svg3irf42gf.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIA2EL2NL4LVYXJTOK2%2F20210105%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20210105T165545Z&X-Amz-Expires=300&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEFEaCXVzLWVhc3QtMiJHMEUCICwx61VITKOKB77AbzOBYFQ54hPigpS8YjXBn3opFCBHAiEAw4bNSBBAnrugIVs0kxFgFU%2Bxich0WrN%2BS96WJBqLt%2BYq1wEIGhAAGgw2OTY1NzgzMDE3MTkiDJLL6F8ULm9ygw6pIyq0Ac1moVu2PgGMBz4th89uCWW6XUsUAD%2FNcY5JUf06%2Btl2LU7j9DjxLtm5fKt0Bkc6Z96U03HmP4job3vYTKXR2wQPaW381fd5UKQLgiqz3o4ENwg8E92unTtZZ8DrP4yjntkkqUrw8Ybavyrik2eAPnp2ME%2FQe2kzl85rBWFgQdHj8bXBYPxgV1dIGyAi%2BQtp0XMMcJyJNR5Lgdh05py3WEpf0mCVS3vBe1MJP3m6kph7OMZLWDCnsNL%2FBTrgAQplCeuZMLkutxSWG8KHYUUGB5fLkJQJtK4xJab4zmxBndNXRT4tPLDPpiyyX%2B25DQbAxD48azztgdEOOim8%2BnY6rZTsY7KTd1%2FuQwryAr%2Bt9rzvL0ubkCo3PWK1UD0TBhx%2BjpE1KPyYjA4df0xlQyx0D1ee0uVRthn9FY9bDkuN8EWs2KNVxbt%2BbWYxAUJ5mqOtq1zWWa%2BXTWR20BlzWGG8NZTy0krkp9mBLM1mPIHdVNpgbgdMsnW3L0UtZXpCYT8n1QpVsMnIDuYcAK3ogOYLcIq0KOK8PWOk6whbz39W&X-Amz-Signature=362c8bc5cb11d6b5a14c52f82b58c25eae56b70bfaf22e01b25ac4ba4436b71e&X-Amz-SignedHeaders=host%3Bx-amz-acl&x-amz-acl=public-read"}
成功了!我们的 Lambda 代码创建了 5 分钟后过期的上传 URL——这不是问题,因为 Vue Formulate 会立即使用签名的 URL,但如果你手动修改 URL,则需要记住过期时间限制。
注意:上面的 CURL 请求是我管理的实际活动 lambda,请随意测试,请注意所有文件将在 24 小时后自动删除👍
9.上传器功能
我们流程的最后一步是为 Vue Formulate 编写一个自定义上传器。记住,当 Vue Formulate 从最终用户那里收到文件时,它会将该文件传递给上传器函数(或 axios)。我们希望使用上传器函数的自定义实现来获取签名的 URL,然后XMLHttpRequest
使用文件数据对该 URL 执行 (xhr) 操作。具体实现细节会根据项目的具体情况略有不同,但以下是如何通过 Vue Formulate 插件全局实现此操作的方法:
s3-上传器-插件.js
async function uploadToS3 (file, progress, error, options) {
const matches = file.name.match(/.([a-zA-Z0-9]+)$/)
const extension = (matches) ? matches[1] : 'txt'
progress(5)
const response = await fetch(options.uploadUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
extension,
mime: file.type || 'application/octet-stream'
})
})
if (response.ok) {
const { uploadUrl } = await response.json()
progress(10)
const xhr = new XMLHttpRequest()
xhr.open('PUT', uploadUrl)
xhr.upload.addEventListener('progress', e => progress(Math.round(e.loaded / e.total * 90) + 10))
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
try {
await new Promise((resolve, reject) => {
xhr.onload = e => (xhr.status - 200) < 100 ? resolve() : reject(new Error('Failed to upload'))
xhr.onerror = e => reject(new Error('Failed to upload'))
xhr.send(file)
})
progress(100)
const url = new URL(uploadUrl)
return {
url: </span><span class="p">${</span><span class="nx">url</span><span class="p">.</span><span class="nx">protocol</span><span class="p">}</span><span class="s2">//</span><span class="p">${</span><span class="nx">url</span><span class="p">.</span><span class="nx">host</span><span class="p">}${</span><span class="nx">url</span><span class="p">.</span><span class="nx">pathname</span><span class="p">}</span><span class="s2">
,
name: file.name
}
} catch {
// we'll suppress this since we have a catch all error
}
}
// Catch all error
error('There was an error uploading your file.')
}
export default function (instance) {
instance.extend({
uploader: uploadToS3
})
}
main.js
import Vue from 'vue'
import VueFormulate from '@braid/vue-formulate'
import S3UploaderPlugin from './s3-uploader-plugin'
// Your main.js file or wherever you initialize Vue Formulate.
Vue.use(VueFormulate, {
// Use API Gateway URL + route path 😉
uploadUrl: 'https://6etx7kng79.execute-api.us-east-2.amazonaws.com/signature',
plugins: [
S3UploaderPlugin
]
})
一个工作示例
大功告成!完成这些更改后,Vue Formulate 实例中的所有输入file
都image
将自动从客户端浏览器直接上传到 S3
。
您可以在项目中的任何和所有表单上使用任意数量的文件上传,无需进行额外的配置。
下面是一个实际示例:
如果您感兴趣,请访问vueformulate.com。您可以在 Twitter 上关注我Justin Schroeder,以及我的共同维护者Andrew Boyd。
文章来源:https://dev.to/justinschroeder/better-uploads-with-vue-formulate-s3-and-lambda-58b8