使用 Vue Formulate、S3 和 Lambda 实现更好的上传

2025-06-07

使用 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";
}


Enter fullscreen mode Exit fullscreen mode

太棒了——我们可以看到 PHP 神奇地创建了一个包含上传文件内容的临时文件,然后我们将该临时文件移动到文件系统上的永久位置(如果我们想保留该文件的话)。这种方法至今仍在各种平台上有效,那么为什么它已经过时了呢?让我们来重点介绍一下这种简单方法的一些不足之处:

  • 没有文件正在上传的用户反馈。没有进度条,没有加载动画,也没有禁用的提交按钮。用户只能坐在那里等待表单提交。文件很多?你的用户肯定会感到困惑,然后多次点击提交按钮。太棒了👌
  • 如果文件上传出现问题,用户必须等待整个上传过程完成后才能发现。
  • 您的后端需要配置才能处理文件上传。对于 PHP,这需要配置诸如和 之类php.ini的变量upload_max_filesizepost_max_sizemax_input_time
  • 如果您使用的是 Node 服务器,则上传时需要格外小心。由于 Node 的单线程特性,您的服务器很容易耗尽内存并崩溃。
  • 如果您使用无服务器堆栈,您的后端甚至没有文件系统来存储上传内容(这就是本文派上用场的地方👍)。
  • 您的服务器的磁盘空间有限,最终会耗尽。

其中一些问题可以通过将文件“穿过”服务器,然后传递到 S3 等云服务来解决。例如,上面的 PHP 代码可以使用流包装器将文件传递到 S3 存储桶而不是本地文件系统。然而,这实际上是双重上传——1) 客户端将文件上传到您的服务器 2) 然后您的服务器将文件上传到 S3。

用户体验更好的方法是通过fetch或上传文件XMLHttpRequestXMLHttpRequest由于不支持进度更新,因此仍是首选fetch)。然而,即使使用现有的库,搭建这些 AJAX 上传器也需要大量工作,而且它们本身也存在后端缺陷。

还有另一种方法

如果我们的后端服务器根本不接触文件上传会怎么样?如果我们可以直接从客户端浏览器将文件上传到云提供商会怎么样?如果我们的后端/数据库只存储上传文件的 URL 会怎么样?

Vue Formulate 允许你通过实现自定义函数来增强你的表单fileimage输入。以下描述了如何使用 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 全天候运行以等待执行此操作)。

lambda上传

我们的自定义 Vue Formulate 上传器功能将执行以下几个步骤:

  1. 收集需要上传的文件。
  2. 从我们的 AWS Lambda 函数请求签名的上传 URL。
  3. 使用签名的上传 URL 将文件上传到我们的 S3 存储桶。

一旦我们将自定义上传器添加到 Vue Formulate 实例,我们所有的fileimage输入都会自动使用此机制。听起来不错吧?好的——让我们开始吧!

1. 设置 AWS 账户

如果您还没有 AWS 账户,则需要先创建一个。这是一个标准的注册流程——您需要验证身份并提供账单信息(不用担心,AWS Lambda 函数调用价格和AWS S3存储价格非常便宜)。

portal.aws.amazon.com_billing_signup_nc2=h_ct&src=header_signup&redirect_url=https%3A%2F%2Faws.amazon.com%2Fregistration-confirmation

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": []
    }
]


Enter fullscreen mode Exit fullscreen mode

s3.console.aws.amazon.com_s3_home_region=us-east-2

4.创建 IAM 角色

接下来,我们需要为 Lambda 创建一个 IAM 角色。使用服务菜单导航到 IAM 服务(身份访问管理)。点击侧边栏中的角色,然后选择“创建角色”。从服务用例中选择 Lambda 的“用例”,然后继续下一步。

这是我们附加“策略”(基本上是权限)的地方。我们将添加,AWSLambdaBasicExecutionRole这使我们的新角色能够运行 Lambda 函数。

console.aws.amazon.com_iam_home_region=us-east-2

接下来,如果需要,添加标签(不是必需的),最后,为您的角色命名并提供您能识别的描述并创建该角色。

console.aws.amazon.com_iam_home_region=us-east-2 (1)

接下来,我们需要为该角色添加访问我们创建的 S3 存储桶的权限。选择我们刚刚创建的角色,选择“附加策略”,然后点击顶部的“创建策略”按钮。然后按照以下步骤操作:

  1. 选择 S3 服务
  2. 选择操作PutObject,然后PutObjectACL
  3. 指定存储桶 ARN 以及*存储桶中的“任意”( ) 对象。
  4. 审查并命名该策略,然后创建它。

创建 S3 访问策略

命名 S3 访问策略

最后回到我们创建的角色,刷新策略列表,搜索我们新创建的策略,并将其添加到角色中。

选择访问策略

完成后的角色策略

5.创建 Lambda 和 API

使用服务下拉菜单搜索 Lambda 服务。打开它,选择“创建函数”,然后按照提示操作:

  1. 选择“从头开始创作”
  2. 选择一个函数名称,在这个例子中,我将使用“VueFormulateUploadSigner”。
  3. 更改执行角色并选择“使用现有角色”。选择我们在上一步中创建的新角色。
  4. 保持高级设置不变并创建函数。

Lambda 创建屏幕

记住,这个 Lambda 函数负责创建我们签名的上传 URL,所以我们需要一个端点来触发 Lambda 的执行。为此,请点击“+ 添加触发器”按钮,选择“API 网关”,然后按照提示操作:

  1. 选择“创建 API”
  2. 对于“API 类型”,选择“HTTP API”
  3. 为了安全起见,请选择“打开”(如果您的特定应用程序需要,您可以随时回来并稍后添加 JWT)
  4. 将附加设置留空并“添加”网关。

6.添加功能代码

我们需要 Lambda 函数来创建一个签名putObjectURL。在“函数代码”部分中双击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)
    }
}



Enter fullscreen mode Exit fullscreen mode

将代码添加到函数中

添加此代码后,点击“部署”。现在,我们需要在 Lambda 中做的最后一件事是添加BUCKET_NAME环境变量。

从代码编辑器向下滚动,选择环境变量下的“编辑”。输入一个新键BUCKET_NAME,并将值设置为我们的 S3 存储桶名称(我选择uploads.vueformulate.com的名称)。点击“保存”,您的 Lambda 即可运行!

BUCKET_NAME 环境变量

7.配置 API 网关

快完成了!在开始向 Lambda 发送 HTTP 流量之前,我们需要配置我们创建的 API 网关。

导航到 API 网关服务,您应该会看到一个与我们的 Lambda 同名但带有-API后缀的服务——让我们点击它。API 网关服务是一个功能强大的实用程序,可以轻松配置哪些 Lambda 响应哪些 API 请求。如果您选择“开发 > 路由”,您将看到我们的 Lambda 已经连接到/{lambdaName}路由。

API 网关路由

就我个人而言,我更喜欢这种路由,类似于/signature。我们可以轻松地更改它,并且在进行更改的同时,让我们限制此端点仅响应 POST 请求。

编辑路线

但是有一个问题。由于我们将端点限制为POSTonly,浏览器的 CORSOPTIONS预检请求将会失败。

让我们为同一路径添加另一条路由,/signature该路由也指向我们的 Lambda(我们的代码将处理 CORS 请求)。创建路由,然后点击路由上的“创建并附加集成” OPTIONS,并按照提示操作:

  1. 选择“Lambda 函数”作为集成类型。
  2. 选择我们的 Lambda 的区域和功能。
  3. 创建集成。

创建 OPTIONS 路由

配置后的路由

对此默认 API 进行更改时,更改会自动部署到默认的“阶段”。您可以将阶段视为环境。在此添加多个阶段超出了我们此处讨论的范围。对于这样一个简单的函数,使用默认阶段完全没问题。

如果您导航回此 API 的主页,您将看到我们有一个“调用 URL” $default - 这是您的新 API URL!

API 端点

(如果您愿意,可以将其更改为自定义域,但本指南不关注这一点)

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


Enter fullscreen mode Exit fullscreen mode

您应该会收到带有签名 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"}


Enter fullscreen mode Exit fullscreen mode

成功了!我们的 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
})
}

Enter fullscreen mode Exit fullscreen mode




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
]
})

Enter fullscreen mode Exit fullscreen mode




一个工作示例

大功告成!完成这些更改后,Vue Formulate 实例中的所有输入fileimage将自动从客户端浏览器直接上传到 S3

您可以在项目中的任何和所有表单上使用任意数量的文件上传,无需进行额外的配置。

下面是一个实际示例:


如果您感兴趣,请访问vueformulate.com。您可以在 Twitter 上关注我Justin Schroeder,以及我的共同维护者Andrew Boyd

文章来源:https://dev.to/justinschroeder/better-uploads-with-vue-formulate-s3-and-lambda-58b8
PREV
介绍 AutoAnimate — 用一行代码为您的应用添加动作。
NEXT
React Hooks 的流行模式和反模式