如何使用 API 网关构建无服务器照片上传服务
您正在构建一个 REST API,需要添加对从 Web 或移动应用上传文件的支持。您还需要在数据库中为这些上传的文件添加实体引用,以及客户端提供的元数据。
本文将介绍如何使用 AWS API Gateway、Lambda 和 S3 实现此功能。我们将以一个活动管理 Web 应用程序为例,参与者可以登录并上传与特定活动相关的照片,并添加标题和描述。我们将使用 S3 存储照片,并使用 API Gateway API 处理上传请求。所需条件如下:
- 用户可以登录该应用程序,查看特定事件的照片列表,以及每张照片的元数据(日期、标题、描述等)。
- 只有注册参加该活动的用户才能上传活动照片。
- 使用基础设施即代码 (IaC) 管理所有云资源,以便轻松将其部署到多个环境。(这里不允许使用 AWS 控制台进行可变操作 🚫🤠)
考虑实施方案
由于过去曾使用非无服务器技术(例如 Express.js)构建过类似的功能,我最初的想法是研究如何使用 Lambda 支持的 API 网关端点来处理所有事情:身份验证、授权、文件上传,以及最终将 S3 位置和元数据写入数据库。
虽然这种方法可行,但也存在一些局限性:
- 您需要在 Lambda 函数内部编写代码来管理多部分文件上传以及相关的特殊情况,而现有的 S3 SDK 已经针对此进行了优化。
- Lambda 的定价是基于持续时间的,因此对于较大的文件,您的函数需要更长时间才能完成,从而导致更高的费用。
- API 网关的有效负载大小硬性限制为 10MB。相比之下,S3 文件大小限制为 5GB。
使用 S3 预签名 URL 进行上传
经过进一步研究,我发现了一个更好的解决方案,即使用预签名 URL 将对象上传到 S3,这样既可以进行上传前的授权检查,也可以预先为上传的照片添加结构化元数据。
下图显示了来自 Web 应用程序的请求流程。
需要注意的是,从网页客户端的角度来看,这是一个两步过程:
- 发起上传请求,发送与照片相关的元数据(例如活动 ID、标题、描述等)。API 随后进行身份验证,执行业务逻辑(例如,仅允许参加过该活动的用户访问),最后生成并返回一个安全的预签名 URL。
- 使用预签名URL上传文件本身。
我这里使用的是 Cognito 作为用户存储,但如果你的 API 使用不同的身份验证机制,你可以轻松地将其替换为自定义Lambda 授权器。
让我们深入探讨一下……
步骤 1:创建 S3 存储桶
我使用Serverless Framework来管理所有云资源的配置和部署。对于这个应用,我使用了两个独立的“服务”(或堆栈),它们可以独立部署:
infra服务:包含 S3 存储桶、CloudFront 分发、DynamoDB 表和 Cognito 用户池资源。photos-api服务:包含 API 网关和 Lambda 函数。
您可以在Github 仓库中查看每个堆栈的完整配置,但我们将在下面介绍关键点。
S3存储桶的定义如下:
resources:
Resources:
PhotosBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub ‘${self:custom.photosBucketName}’
AccessControl: Private
CorsConfiguration:
CorsRules:
- AllowedHeaders: [‘*’]
AllowedMethods: [‘PUT’]
AllowedOrigins: [‘*’]
CORS 配置在这里至关重要,因为如果没有它,您的 Web 客户端在获取签名 URL 后将无法执行 PUT 请求。我还使用了 CloudFront 作为 CDN,以最大限度地减少用户下载照片的延迟。您可以在此处
查看 CloudFront 分发的配置。但是,这是一个可选组件,如果您希望客户端直接从 S3 读取照片,则可以更改上面的属性。AccessControlPublicRead
步骤 2:创建“发起上传”API 网关端点
下一步是添加一个新的 API 路径,客户端端点可以调用该路径来请求已签名的 URL。对此路径的请求将如下所示:
POST /events/{eventId}/photos/initiate-upload
{
"title": "Keynote Speech",
"description": "Steve walking out on stage",
"contentType": "image/png"
}
响应中将包含一个对象,该对象只有一个s3PutObjectUrl字段,客户端可以使用该字段将文件上传到 S3。此 URL 格式如下:
https://s3.eu-west-1.amazonaws.com/eventsapp-photos-dev.sampleapps.winterwindsoftware.com/uploads/event_1234/1d80868b-b05b-4ac7-ae52-bdb2dfb9b637.png?AWSAccessKeyId=XXXXXXXXXXXXXXX&Cache-Control=max-age%3D31557600&Content-Type=image%2Fpng&Expires=1571396945&Signature=F5eRZQOgJyxSdsAS9ukeMoFGPEA%3D&x-amz-meta-contenttype=image%2Fpng&x-amz-meta-description=Steve%20walking%20out%20on%20stage&x-amz-meta-eventid=1234&x-amz-meta-photoid=1d80868b-b05b-4ac7-ae52-bdb2dfb9b637&x-amz-meta-title=Keynote%20Speech&x-amz-security-token=XXXXXXXXXX
请特别注意查询字符串中嵌入的以下字段:
x-amz-meta-XXX— 这些字段包含我们的 Lambda 函数将设置的元数据值initiateUpload。x-amz-security-token— 这包含用于向 S3 进行身份验证的临时安全令牌Signature— 这确保客户端无法更改 PUT 请求(例如,通过更改元数据值)。
以下摘录serverless.yml显示了函数配置:
# serverless.yml
service: eventsapp-photos-api
…
custom:
appName: eventsapp
infraStack: ${self:custom.appName}-infra-${self:provider.stage}
awsAccountId: ${cf:${self:custom.infraStack}.AWSAccountId}
apiAuthorizer:
arn: arn:aws:cognito-idp:${self:provider.region}:${self:custom.awsAccountId}:userpool/${cf:${self:custom.infraStack}.UserPoolId}
corsConfig: true
functions:
…
httpInitiateUpload:
handler: src/http/initiate-upload.handler
iamRoleStatements:
- Effect: Allow
Action:
- s3:PutObject
Resource: arn:aws:s3:::${cf:${self:custom.infraStack}.PhotosBucket}*
events:
- http:
path: events/{eventId}/photos/initiate-upload
method: post
authorizer: ${self:custom.apiAuthorizer}
cors: ${self:custom.corsConfig}
这里有几点需要注意:
- Lambda
httpInitiateUpload函数将处理发送到指定路径的 POST 请求。 infra函数属性中引用了Cognito 用户池(堆栈输出)authorizer。这确保了AuthorizationAPI 网关会拒绝 HTTP 标头中缺少有效令牌的请求。- 所有 API 端点均已启用 CORS。
- 最后,该
iamRoleStatements属性会创建一个 IAM 角色,该函数将以该角色运行。此角色允许PutObject对 S3 照片存储桶执行操作。尤其重要的是,此权限集必须遵循最小权限原则,因为返回给客户端的签名 URL 包含一个临时访问令牌,该令牌允许令牌持有者获得生成该签名 URL 的 IAM 角色的所有权限。
现在我们来看一下处理程序代码:
import S3 from 'aws-sdk/clients/s3';
import uuid from 'uuid/v4';
import { InitiateEventPhotoUploadResponse, PhotoMetadata } from '@common/schemas/photos-api';
import { isValidImageContentType, getSupportedContentTypes, getFileSuffixForContentType } from '@svc-utils/image-mime-types';
import { s3 as s3Config } from '@svc-config';
import { wrap } from '@common/middleware/apigw';
import { StatusCodeError } from '@common/utils/errors';
const s3 = new S3();
export const handler = wrap(async (event) => {
// Read metadata from path/body and validate
const eventId = event.pathParameters!.eventId;
const body = JSON.parse(event.body || '{}');
const photoMetadata: PhotoMetadata = {
contentType: body.contentType,
title: body.title,
description: body.description,
};
if (!isValidImageContentType(photoMetadata.contentType)) {
throw new StatusCodeError(400, `Invalid contentType for image. Valid values are: ${getSupportedContentTypes().join(',')}`);
}
// TODO: Add any further business logic validation here (e.g. that current user has write access to eventId)
// Create the PutObjectRequest that will be embedded in the signed URL
const photoId = uuid();
const req: S3.Types.PutObjectRequest = {
Bucket: s3Config.photosBucket,
Key: `uploads/event_${eventId}/${photoId}.${getFileSuffixForContentType(photoMetadata.contentType)!}` ,
ContentType: photoMetadata.contentType,
CacheControl: 'max-age=31557600', // instructs CloudFront to cache for 1 year
// Set Metadata fields to be retrieved post-upload and stored in DynamoDB
Metadata: {
...(photoMetadata as any),
photoId,
eventId,
},
};
// Get the signed URL from S3 and return to client
const s3PutObjectUrl = await s3.getSignedUrlPromise('putObject', req);
const result: InitiateEventPhotoUploadResponse = {
photoId,
s3PutObjectUrl,
};
return {
statusCode: 201,
body: JSON.stringify(result),
};
});
这s3.getSignedUrlPromise是这里最关键的一点。它将 PutObject 请求序列化为签名 URL。
我正在使用wrap中间件函数来处理诸如添加 CORS 标头和未捕获的错误日志记录等横切 API 问题。
步骤 3:从 Web 应用程序上传文件
现在来实现客户端逻辑。我创建了一个非常基础(或者说:很丑陋)的create-react-app示例(代码在这里)。我使用了Amplify 的 Auth 库来管理 Cognito 身份验证,然后创建了一个PhotoUploaderReact 组件,该组件使用了React Dropzone库:
// components/Photos/PhotoUploader.tsx
import React, { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { uploadPhoto } from '../../utils/photos-api-client';
const PhotoUploader: React.FC<{ eventId: string }> = ({ eventId }) => {
const onDrop = useCallback(async (files: File[]) => {
console.log('starting upload', { files });
const file = files[0];
try {
const uploadResult = await uploadPhoto(eventId, file, {
// should enhance this to read title and description from text input fields.
title: 'my title',
description: 'my description',
contentType: file.type,
});
console.log('upload complete!', uploadResult);
return uploadResult;
} catch (error) {
console.error('Error uploading', error);
throw error;
}
}, [eventId]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
{
isDragActive
? <p>Drop the files here ...</p>
: <p>Drag and drop some files here, or click to select files</p>
}
</div>
);
};
export default PhotoUploader;
// utils/photos-api-client.ts
import { API, Auth } from 'aws-amplify';
import axios, { AxiosResponse } from 'axios';
import config from '../config';
import { PhotoMetadata, InitiateEventPhotoUploadResponse, EventPhoto } from '../../../../services/common/schemas/photos-api';
API.configure(config.amplify.API);
const API_NAME = 'PhotosAPI';
async function getHeaders(): Promise<any> {
// Set auth token headers to be passed in all API requests
const headers: any = { };
const session = await Auth.currentSession();
if (session) {
headers.Authorization = `${session.getIdToken().getJwtToken()}`;
}
return headers;
}
export async function getPhotos(eventId: string): Promise<EventPhoto[]> {
return API.get(API_NAME, `/events/${eventId}/photos`, { headers: await getHeaders() });
}
export async function uploadPhoto(
eventId: string, photoFile: any, metadata: PhotoMetadata,
): Promise<AxiosResponse> {
const initiateResult: InitiateEventPhotoUploadResponse = await API.post(
API_NAME, `/events/${eventId}/photos/initiate-upload`, { body: metadata, headers: await getHeaders() },
);
return axios.put(initiateResult.s3PutObjectUrl, photoFile, {
headers: {
'Content-Type': metadata.contentType,
},
});
}
uploadPhoto文件中的函数是photos-api-client.ts关键所在。它执行我们之前提到的两步流程:首先调用我们的initiate-uploadAPI 网关端点,然后向其s3PutObjectUrl返回的值发送 PUT 请求。请确保Content-Type在 S3 PUT 请求中设置了请求头,否则请求将因签名不匹配而被拒绝。
步骤 4:将照片数据推送到数据库
照片上传完成后,Web 应用程序需要一种方法来列出为某个活动上传的所有照片(使用getPhotos上面的函数)。
为了完成这个循环并实现这个查询,我们需要将照片数据记录到数据库中。为此,我们创建了第二个 Lambda 函数,processUploadedPhoto每当有新对象添加到我们的 S3 存储桶时,该函数都会被触发。
我们来看一下它的配置:
# serverless.yml
service: eventsapp-photos-api
…
functions:
…
s3ProcessUploadedPhoto:
handler: src/s3/process-uploaded-photo.handler
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
Resource: arn:aws:dynamodb:${self:provider.region}:${self:custom.awsAccountId}:table/${cf:${self:custom.infraStack}.DynamoDBTablePrefix}*
- Effect: Allow
Action:
- s3:GetObject
- s3:HeadObject
Resource: arn:aws:s3:::${cf:${self:custom.infraStack}.PhotosBucket}*
events:
- s3:
bucket: ${cf:${self:custom.infraStack}.PhotosBucket}
event: s3:ObjectCreated:*
rules:
- prefix: uploads/
existing: true
它由事件触发s3:ObjectCreated,并且只会对添加到uploads/顶级文件夹下的文件执行。
在该iamRoleStatements部分中,我们允许该函数写入 DynamoDB 表并从 S3 存储桶读取数据。
现在我们来看一下函数代码:
import { S3Event } from 'aws-lambda';
import S3 from 'aws-sdk/clients/s3';
import log from '@common/utils/log';
import { EventPhotoCreate } from '@common/schemas/photos-api';
import { cloudfront } from '@svc-config';
import { savePhoto } from '@svc-models/event-photos';
const s3 = new S3();
export const handler = async (event: S3Event): Promise<void> => {
const s3Record = event.Records[0].s3;
// First fetch metadata from S3
const s3Object = await s3.headObject({ Bucket: s3Record.bucket.name, Key: s3Record.object.key }).promise();
if (!s3Object.Metadata) {
// Shouldn't get here
const errorMessage = 'Cannot process photo as no metadata is set for it';
log.error(errorMessage, { s3Object, event });
throw new Error(errorMessage);
}
// S3 metadata field names are converted to lowercase, so need to map them out carefully
const photoDetails: EventPhotoCreate = {
eventId: s3Object.Metadata.eventid,
description: s3Object.Metadata.description,
title: s3Object.Metadata.title,
id: s3Object.Metadata.photoid,
contentType: s3Object.Metadata.contenttype,
// Map the S3 bucket key to a CloudFront URL to be stored in the DB
url: `https://${cloudfront.photosDistributionDomainName}/${s3Record.object.key}`,
};
// Now write to DDB
await savePhoto(photoDetails);
};
传递给 Lambda 处理函数的事件对象仅包含触发该事件的对象的存储桶名称和键。因此,为了获取元数据,我们需要使用headObjectS3 API 调用。
提取所需的元数据字段后,我们构建照片的 CloudFront URL(使用通过环境变量传入的 CloudFront 分发域名),并将其保存到 DynamoDB。
未来改进
上传流程的一个潜在改进是在将图像保存到数据库之前添加一个图像优化步骤。这需要使用一个 Lambda 函数监听特定键前缀S3:ObjectCreated下的事件upload/,读取图像文件,调整其大小并进行相应的优化,然后将新副本保存到同一个存储桶,但使用一个新的optimized/键前缀。之后,需要更新用于将图像保存到数据库的 Lambda 函数的配置,使其使用这个新的键前缀触发。
💌如果您喜欢这篇文章,可以订阅我的每周新闻简报,了解如何在 AWS 中构建无服务器应用程序。
原文发表于winterwindsoftware.com。
