Node.js 后端开发指南:搭建、优化与部署
使用 AWS Lambda 和 AWS API Gateway 的无服务器 API 深度指南(第 2 部分)
在第一部分中,我们了解了身份验证、请求主体、状态代码、CORS 和响应标头。我们建立了一个连接 API-Gateway、Lambda 和 Cognito 的 AWS SAM 项目,以便用户可以注册和登录。
在本文中,我们将讨论第三方集成、数据存储和检索以及“从 Lambda 函数调用 Lambda 函数”(我们实际上不会这样做,而是了解一种实现类似操作的方法。
添加图片上传
我们将从图片上传开始。其工作原理如下:
- 通过我们的 API 请求预签名的 S3 URL
- 通过预签名的 URL 将图像直接上传到 S3
因此我们需要两个新的 SAM/CloudFormation 资源。一个 Lambda 函数,用于为我们的图像生成预签名 URL 和一个 S3 存储桶。
让我们更新 SAM 模板:
AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: "A example REST API build with serverless technology"
Globals:
Function:
Runtime: nodejs8.10
Handler: index.handler
Timeout: 30
Tags:
Application: Serverless API
Resources:
ServerlessApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
Cors: "'*'"
Auth:
DefaultAuthorizer: CognitoAuthorizer
Authorizers:
CognitoAuthorizer:
UserPoolArn: !GetAtt UserPool.Arn
GatewayResponses:
UNAUTHORIZED:
StatusCode: 401
ResponseParameters:
Headers:
Access-Control-Expose-Headers: "'WWW-Authenticate'"
Access-Control-Allow-Origin: "'*'"
WWW-Authenticate: >-
'Bearer realm="admin"'
# ============================== Auth ==============================
AuthFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/auth/
Environment:
Variables:
USER_POOL_ID: !Ref UserPool
USER_POOL_CLIENT_ID: !Ref UserPoolClient
Events:
Signup:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /signup
Method: POST
Auth:
Authorizer: NONE
Signin:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /signin
Method: POST
Auth:
Authorizer: NONE
PreSignupFunction:
Type: AWS::Serverless::Function
Properties:
InlineCode: |
exports.handler = async event => {
event.response = { autoConfirmUser: true };
return event;
};
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: ApiUserPool
LambdaConfig:
PreSignUp: !GetAtt PreSignupFunction.Arn
Policies:
PasswordPolicy:
MinimumLength: 6
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
ClientName: ApiUserPoolClient
GenerateSecret: no
LambdaCognitoUserPoolExecutionPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt PreSignupFunction.Arn
Principal: cognito-idp.amazonaws.com
SourceArn: !Sub 'arn:${AWS::Partition}:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${UserPool}'
# ============================== Images ==============================
ImageBucket:
Type: AWS::S3::Bucket
Properties:
AccessControl: PublicRead
CorsConfiguration:
CorsRules:
- AllowedHeaders:
- "*"
AllowedMethods:
- HEAD
- GET
- PUT
- POST
AllowedOrigins:
- "*"
ImageBucketPublicReadPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref ImageBucket
PolicyDocument:
Statement:
- Action: s3:GetObject
Effect: Allow
Principal: "*"
Resource: !Join ["", ["arn:aws:s3:::", !Ref "ImageBucket", "/*" ]]
ImageFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/images/
Policies:
- AmazonS3FullAccess
Environment:
Variables:
IMAGE_BUCKET_NAME: !Ref ImageBucket
Events:
CreateImage:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /images
Method: POST
Outputs:
ApiUrl:
Description: The target URL of the created API
Value: !Sub "https://${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
Export:
Name: ApiUrl
好的,我们最终得到了三个新资源。我们还需要一个BucketPolicy
允许公开读取我们新图片存储桶的资源。
有ImagesFunction
一个 API 事件,因此我们可以使用它来处理 POST 请求。该函数获取 S3 访问策略和环境变量,因此它知道ImageBucket
。
我们需要为函数代码创建一个新文件functions/images/index.js
。
const AWS = require("aws-sdk");
exports.handler = async event => {
const userName = event.requestContext.authorizer.claims["cognito:username"];
const fileName = "" + Math.random() + Date.now() + "+" + userName;
const { url, fields } = await createPresignedUploadCredentials(fileName);
return {
statusCode: 201,
body: JSON.stringify({
formConfig: {
uploadUrl: url,
formFields: fields
}
}),
headers: { "Access-Control-Allow-Origin": "*" }
};
};
const s3Client = new AWS.S3();
const createPresignedUploadCredentials = fileName => {
const params = {
Bucket: process.env.IMAGE_BUCKET_NAME,
Fields: { Key: fileName }
};
return new Promise((resolve, reject) =>
s3Client.createPresignedPost(params, (error, result) =>
error ? reject(error) : resolve(result)
)
);
};
那么,这个函数中发生了什么?
userName
首先,我们从对象中提取event
。API-Gateway 和 Cognito 控制对我们函数的访问,因此我们可以确保event
在调用该函数时对象中存在用户。
fileName
接下来,我们根据随机数、当前时间戳和用户名创建一个唯一的。
辅助函数createPresignedUploadCredentials
将创建预签名的 S3 URL。它将返回一个具有url
和fields
属性的对象。
我们的 API 客户端必须向发送 POST 请求url
,并在其正文中包含所有字段和文件。
整合图像识别
现在,我们需要集成第三方图像识别服务。
当图像上传时,S3 将触发一个上传事件,该事件由 lambda 函数处理。
这使我们想到第一篇文章开头提出的一个问题。
如何从 Lambda 调用 Lambda?
简短的回答是:你不需要。
为什么?虽然您可以通过 AWS-SDK 直接从 Lambda 调用 Lambda,但这会带来一个问题。如果出现问题,我们必须实现所有需要发生的事情,例如重试等。此外,在某些情况下,调用 Lambda 必须等待被调用的 Lambda 完成,我们也必须为等待时间付费。
那么还有哪些替代方案呢?
Lambda 是一个基于事件的系统,我们的函数可以由不同的事件源触发。主要做法是使用另一个服务来执行此操作。
在我们的例子中,我们希望在文件上传完成时调用 Lambda,因此我们必须使用 S3 事件作为源。但也有其他事件源。
- Step Functions让我们能够协调 Lambda 函数与状态机
- SQS是一个队列,我们可以将结果推送到其中,以便其他 Lambda 可以获取它们
- SNS是一种服务,它允许我们将一个 Lambda 结果并行分发到许多其他 Lambda。
我们向 SAM 模板添加了一个新的 Lambda 函数,该函数将被 S3 调用。
AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: "An example REST API build with serverless technology"
Globals:
Function:
Runtime: nodejs8.10
Handler: index.handler
Timeout: 30
Tags:
Application: Serverless API
Resources:
ServerlessApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
Cors: "'*'"
Auth:
DefaultAuthorizer: CognitoAuthorizer
Authorizers:
CognitoAuthorizer:
UserPoolArn: !GetAtt UserPool.Arn
GatewayResponses:
UNAUTHORIZED:
StatusCode: 401
ResponseParameters:
Headers:
Access-Control-Expose-Headers: "'WWW-Authenticate'"
Access-Control-Allow-Origin: "'*'"
WWW-Authenticate: >-
'Bearer realm="admin"'
# ============================== Auth ==============================
AuthFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/auth/
Environment:
Variables:
USER_POOL_ID: !Ref UserPool
USER_POOL_CLIENT_ID: !Ref UserPoolClient
Events:
Signup:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /signup
Method: POST
Auth:
Authorizer: NONE
Signin:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /signin
Method: POST
Auth:
Authorizer: NONE
PreSignupFunction:
Type: AWS::Serverless::Function
Properties:
InlineCode: |
exports.handler = async event => {
event.response = { autoConfirmUser: true };
return event;
};
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: ApiUserPool
LambdaConfig:
PreSignUp: !GetAtt PreSignupFunction.Arn
Policies:
PasswordPolicy:
MinimumLength: 6
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
ClientName: ApiUserPoolClient
GenerateSecret: no
LambdaCognitoUserPoolExecutionPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt PreSignupFunction.Arn
Principal: cognito-idp.amazonaws.com
SourceArn: !Sub 'arn:${AWS::Partition}:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${UserPool}'
# ============================== Images ==============================
ImageBucket:
Type: AWS::S3::Bucket
Properties:
AccessControl: PublicRead
CorsConfiguration:
CorsRules:
- AllowedHeaders:
- "*"
AllowedMethods:
- HEAD
- GET
- PUT
- POST
AllowedOrigins:
- "*"
ImageBucketPublicReadPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref ImageBucket
PolicyDocument:
Statement:
- Action: s3:GetObject
Effect: Allow
Principal: "*"
Resource: !Join ["", ["arn:aws:s3:::", !Ref "ImageBucket", "/*" ]]
ImageFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/images/
Policies:
- AmazonS3FullAccess
Environment:
Variables:
IMAGE_BUCKET_NAME: !Ref ImageBucket
Events:
CreateImage:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /images
Method: POST
TagsFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/tags/
Environment:
Variables:
PARAMETER_STORE_CLARIFAI_API_KEY: /serverless-api/CLARIFAI_API_KEY_ENC
Policies:
- AmazonS3ReadOnlyAccess # Managed policy
- Statement: # Inline policy document
- Action: [ 'ssm:GetParameter' ]
Effect: Allow
Resource: '*'
Events:
ExtractTags:
Type: S3
Properties:
Bucket: !Ref ImageBucket
Events: s3:ObjectCreated:*
Outputs:
ApiUrl:
Description: The target URL of the created API
Value: !Sub "https://${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
Export:
Name: ApiUrl
我们添加了一个新的无服务器功能资源,该资源具有一个 S3 事件,当我们在 中创建新的 S3 对象时会调用该事件ImageBucket
。
我们的 Lambda 函数需要调用第三方 API,即 Clarifai API,这引出了下一个问题。
如何存储第三方 API 的凭证?
有很多方法可以做到这一点。
一种方法是使用 KMS 加密凭证。我们通过 CLI 对其进行加密,将加密密钥作为环境变量添加到 template.yaml 中,然后在 Lambda 内部使用 AWS-SDK 解密凭证,然后再使用它们。
另一种方法是使用AWS Systems Manager的参数存储。此服务允许在 Lambda 中存储可通过 AWS-SDK 检索的加密字符串。我们只需为 Lambda 提供定义凭证存储位置的名称即可。
在这个例子中,我们将使用参数存储。
如果您尚未创建Clarifai帐户,现在是时候了。他们将为您提供 API 密钥,我们接下来需要将其存储到参数存储中。
aws ssm put-parameter \
--name "/serverless-api/CLARIFAI_API_KEY" \
--type "SecureString" \
--value "<CLARIFAI_API_KEY>"
该命令会将密钥放入参数存储并对其进行加密。
接下来,我们需要通过环境变量告诉我们的 Lambda 函数名称,并授予它getParameter
通过 AWS-SDK 调用的权限。
Environment:
Variables:
PARAMETER_STORE_CLARIFAI_API_KEY: /serverless-api/CLARIFAI_API_KEY_ENC
Policies:
- Statement:
- Action: [ 'ssm:GetParameter' ]
Effect: Allow
Resource: '*'
让我们看看 JavaScript 方面的事情,为此,我们创建一个新文件functions/tags/index.js
。
const AWS = require("aws-sdk");
const Clarifai = require("clarifai");
exports.handler = async event => {
const record = event.Records[0];
const bucketName = record.s3.bucket.name;
const fileName = record.s3.object.key;
const tags = await predict(`https://${bucketName}.s3.amazonaws.com/${fileName}`);
await storeTagsSomewhere({ fileName, tags });
};
const ssm = new AWS.SSM();
const predict = async imageUrl => {
const result = await ssm.getParameter({
Name: process.env.PARAMETER_STORE_CLARIFAI_API_KEY,
WithDecryption: true
}).promise();
const clarifaiApp = new Clarifai.App({
apiKey: result.Parameter.Value
});
const model = await clarifaiApp.models.initModel({
version: "aa7f35c01e0642fda5cf400f543e7c40",
id: Clarifai.GENERAL_MODEL
});
const clarifaiResult = await model.predict(imageUrl);
const tags = clarifaiResult.outputs[0].data.concepts
.filter(concept => concept.value > 0.9)
.map(concept => concept.name);
return tags;
};
使用 S3 对象创建事件调用处理程序。只有一条记录,但我们也可以告诉 S3 将记录批量处理在一起。
然后我们为新创建的图像创建一个 URL 并将其提供给函数predict
。
该predict
函数使用我们的PARAMETER_STORE_CLARIFAI_API_KEY
环境变量来获取参数存储中的参数名称。这使我们能够更改目标参数而无需更改 Lambda 代码。
我们解密了 API 密钥,就可以调用第三方 API。然后我们将标签存储在某处。
列出和删除带标签的图像
现在我们可以上传图像并且它们会被自动标记,下一步就是列出所有图像,按标签过滤它们,如果不再需要就删除它们。
让我们更新 SAM 模板!
AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: "An example REST API build with serverless technology"
Globals:
Function:
Runtime: nodejs8.10
Handler: index.handler
Timeout: 30
Tags:
Application: Serverless API
Resources:
ServerlessApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
Cors: "'*'"
Auth:
DefaultAuthorizer: CognitoAuthorizer
Authorizers:
CognitoAuthorizer:
UserPoolArn: !GetAtt UserPool.Arn
GatewayResponses:
UNAUTHORIZED:
StatusCode: 401
ResponseParameters:
Headers:
Access-Control-Expose-Headers: "'WWW-Authenticate'"
Access-Control-Allow-Origin: "'*'"
WWW-Authenticate: >-
'Bearer realm="admin"'
# ============================== Auth ==============================
AuthFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/auth/
Environment:
Variables:
USER_POOL_ID: !Ref UserPool
USER_POOL_CLIENT_ID: !Ref UserPoolClient
Events:
Signup:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /signup
Method: POST
Auth:
Authorizer: NONE
Signin:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /signin
Method: POST
Auth:
Authorizer: NONE
PreSignupFunction:
Type: AWS::Serverless::Function
Properties:
InlineCode: |
exports.handler = async event => {
event.response = { autoConfirmUser: true };
return event;
};
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: ApiUserPool
LambdaConfig:
PreSignUp: !GetAtt PreSignupFunction.Arn
Policies:
PasswordPolicy:
MinimumLength: 6
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
ClientName: ApiUserPoolClient
GenerateSecret: no
LambdaCognitoUserPoolExecutionPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt PreSignupFunction.Arn
Principal: cognito-idp.amazonaws.com
SourceArn: !Sub 'arn:${AWS::Partition}:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${UserPool}'
# ============================== Images ==============================
ImageBucket:
Type: AWS::S3::Bucket
Properties:
AccessControl: PublicRead
CorsConfiguration:
CorsRules:
- AllowedHeaders:
- "*"
AllowedMethods:
- HEAD
- GET
- PUT
- POST
AllowedOrigins:
- "*"
ImageBucketPublicReadPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref ImageBucket
PolicyDocument:
Statement:
- Action: s3:GetObject
Effect: Allow
Principal: "*"
Resource: !Join ["", ["arn:aws:s3:::", !Ref "ImageBucket", "/*" ]]
ImageFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/images/
Policies:
- AmazonS3FullAccess
Environment:
Variables:
IMAGE_BUCKET_NAME: !Ref ImageBucket
Events:
ListImages:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /images
Method: GET
DeleteImage:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /images/{imageId}
Method: DELETE
CreateImage:
Type: Api
Properties:
RestApiId: !Ref ServerlessApi
Path: /images
Method: POST
TagsFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/tags/
Environment:
Variables:
PARAMETER_STORE_CLARIFAI_API_KEY: /serverless-api/CLARIFAI_API_KEY_ENC
Policies:
- AmazonS3ReadOnlyAccess # Managed policy
- Statement: # Inline policy document
- Action: [ 'ssm:GetParameter' ]
Effect: Allow
Resource: '*'
Events:
ExtractTags:
Type: S3
Properties:
Bucket: !Ref ImageBucket
Events: s3:ObjectCreated:*
Outputs:
ApiUrl:
Description: The target URL of the created API
Value: !Sub "https://${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
Export:
Name: ApiUrl
我们添加了一些新的 API 事件ImagesFunction
,现在也需要为其更新 JavaScript。
const AWS = require("aws-sdk");
exports.handler = async event => {
switch (event.httpMethod.toLowerCase()) {
case "post":
return createImage(event);
case "delete":
return deleteImage(event);
default:
return listImages(event);
}
};
const createImage = async event => {
const userName = extractUserName(event);
const fileName = "" + Math.random() + Date.now() + "+" + userName;
const { url, fields } = await createPresignedUploadCredentials(fileName);
return response({
formConfig: {
uploadUrl: url,
formFields: fields
}
}, 201);
};
const deleteImage = async event => {
const { imageId } = event.pathParameters;
await deleteImageSomewhere(imageId);
return response({ message: "Deleted image: " + imageId });
};
// Called with API-GW event
const listImages = async event => {
const { tags } = event.queryStringParameters;
const userName = extractUserName(event);
const images = await loadImagesFromSomewhere(tags.split(","), userName);
return response({ images });
};
// ============================== HELPERS ==============================
const extractUserName = event => event.requestContext.authorizer.claims["cognito:username"];
const response = (data, statusCode = 200) => ({
statusCode,
body: JSON.stringify(data),
headers: { "Access-Control-Allow-Origin": "*" }
});
const s3Client = new AWS.S3();
const createPresignedUploadCredentials = fileName => {
const params = {
Bucket: process.env.IMAGE_BUCKET_NAME,
Fields: { Key: fileName }
};
return new Promise((resolve, reject) =>
s3Client.createPresignedPost(params, (error, result) =>
error ? reject(error) : resolve(result)
)
);
};
删除功能很简单,但也回答了我们的一个问题。
如何使用查询字符串或路由参数?
const deleteImage = async event => {
const { imageId } = event.pathParameters;
...
};
API-Gateway 事件对象有一个pathParameters
属性,该属性保存了我们在 SAM 模板中为该事件定义的所有参数。在我们的例子中,imageId
因为我们定义了Path: /images/{imageId}
。
以类似方式使用的查询字符串。
const listImages = async event => {
const { tags } = event.queryStringParameters;
...
};
其余代码不太复杂。我们将使用偶数数据来加载或删除图像。
查询字符串将作为对象存储在queryStringParameters
我们event
对象的属性内。
结论
有时新技术会带来范式转变。无服务器就是其中一种技术。
它的全部功能通常归结为尽可能使用托管服务。不要自己动手加密、身份验证、存储或计算。使用云提供商已经实现的功能。
这里最大的问题往往是做事的方法有很多。没有唯一正确的方法。我们应该直接使用 KMS 吗?我们应该让系统管理器为我们处理事情吗?我们应该通过 Lambda 实现身份验证吗?我们应该使用 Cognito 来完成它吗?
我希望我能在某种程度上回答使用 Lambda 时出现的问题。
文章来源:In Depth Guide to Serverless APIs with AWS Lambda and AWS API Gateway (Part 2)