(Updated: April 5, 2022 )
在上一篇文章,已經介紹如何安裝 Serverless Framework 以及註冊 AWS 帳號,接下來要組合使用AWS Lambda 和 DynamoDB 並透過 API gateway 的介導讓外部進行存取,實作一個記事本後端 CRUD API。
用預設模板(template)建立服務
俗話說「萬丈高樓平地起」,建立一個無伺服器服務就從模板開始。
在create
命令加上--help
列出幫助訊息,當中會列出有哪些預設模板可以用,不同的模板有不同的起始程式碼和設定。
serverless create --help
--template | 透過預設模板創建服務 |
--template-url | 讀取遠端儲藏庫來創建服務 |
--path | 指定創建服務的資料夾,不存在的話就會建立一個新的 |
我們要使用aws-nodejs
這個模板來建立我們的服務:
serverless create --template aws-nodejs --path serverless-notes-rest-api
cd serverless-notes-rest-api
npm init -y
serverless-notes-rest-api
資料夾下面有三個檔案handler.js
、serverless.yml
和package.json
,雖然裡面是很陽春的程式碼和設定,我們還是可以先部署到 AWS 看看。
serverless deploy --verbose
如果你在部署的過程中遇到錯誤,有可能是 AWS 帳戶權限給錯了,或是 serverless framework 讀不到正確的 credentials,所以最好在部署指令上加
--verbose
印出詳細的 log 去一一排除。
等部署完成後,調用 lambda function 測試一下:
serverless invoke -l -f hello
返回 status code 200 代表成功調用函式:
{
"statusCode": 200,
"body": "{\n \"message\": \"Go Serverless v1.0! Your function executed successfully!\",\n \"input\": {}\n}"
}
--------------------------------------------------------------------
START
END Duration: 2.66 ms Memory Used: 56 MB
撰寫 serverless.yml
基礎設施即代碼 (IaC),用文件來記錄、管理雲端資源的配置,服務需要的函式定義、事件類型(ex. http, sns, s3…等)、IAM權限、資料庫和物件儲存都記錄在 serverless.yml 文件中。當中大部分的設定,都需要去參考 AWS CloudFormation 的文件來撰寫。
這次設定檔大致分為4個主要區塊 (provider、functions 、 resources 和 plugins),拆解開來方便做說明,請再自行合併成一份檔案。
provider
可定義將服務部署至哪個雲端服務供應商,應用到全局的資源設定也會放在這裡,像是 Lambda function 的 runtime、timeout和 iam 等等,但是基於least-privilege permissions
的原則,我們不將相同的 IAM role 應用到所有的 function,而是個別進行設定。
更多的 provider 設定可參考這裡。
# serverless.yml
service: serverless-notes-api
frameworkVersion: "3"
provider:
name: aws
runtime: nodejs14.x
stage: ${opt:stage, 'dev'}
region: us-east-1
# Function environment variables
environment:
AWS_NODEJS_CONNECTION_REUSE_ENABLED : 1
NOTES_TABLE_NAME: !Ref notesTable
stage: ${opt:stage, 'dev'}
的意思是,用 serverless dploy 命令進行部署時,若參數--stage
為空,預設值就會是 dev
。這是相當方便的表示方式,有利於自動化和區分資源是用於開發環境或是生產環境。
AWS_NODEJS_CONNECTION_REUSE_ENABLED
是指 Lambda function 運行 Node.js 時使用 AWS SDK 向其他服務(例如:DynamoDB)發起連線時,可重複使用尚未關閉的連線以避免重新連線的開銷。
NOTES_TABLE_NAME: !Ref notesTable
取得 notesTable 資源所定義的 DynamoDB 表格名稱。
functions
這裡要來定義實現 CRUD 功能的 Lambda function 的 IAM role 和 http event 的規格。
# serverless.yml
functions:
createNote:
handler: handler.createNote
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:PutItem
Resource: !GetAtt notesTable.Arn
events:
- http:
method: post
path: notes
updateNote:
handler: handler.updateNote
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:UpdateItem
Resource: !GetAtt notesTable.Arn
events:
- http:
method: put
path: notes/{id}
deleteNote:
handler: handler.deleteNote
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:DeleteItem
Resource: !GetAtt notesTable.Arn
events:
- http:
method: delete
path: notes/{id}
getAllNotes:
handler: handler.getAllNotes
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:Scan
Resource: !GetAtt notesTable.Arn
events:
- http:
method: get
path: notes
handler: handler.createNote
表示 createNote 方法是透過 handler.js 匯出的。
iamRoleStatements
定義各個 function 的 IAM role,但是這種寫法需要另外安裝 plugin 才能在 serverless.yml 中使用。
events
中定義的 http 事件會在部署的時候建立相對應的 API Gateway。
resources
這裡定義一個 DynamoDB 表格,包含名稱為noteId
型別為字串 (S) 的 primary key。若是不屬於 primary key 或 sort key 的 attribute 就不需要寫在裡面。
# serverless.yml
resources:
Resources:
notesTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: notes-${self:provider.stage}
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: noteId
AttributeType: S
KeySchema:
- AttributeName: noteId
KeyType: HASH
plugins
每個 fuction 個別定義IAM role 的話需要使用 serverless-iam-roles-per-function
這個外掛,安裝指令:
npm i --save-dev serverless-iam-roles-per-function
# serverless.yml
plugins:
- serverless-iam-roles-per-function
handler.js
接下來,我們要將處理筆記本後端 CRUD 的 Lambda function handler 新增到handler.js
,因為需要和 AWS DynamoDB 資料庫連線,借助 AWS SDK for JavaScript 這個套件進行開發,使得編寫程式碼變得相當簡單。請用下列指令安裝套件:
npm i aws-sdk
'use strict';
const DynamoDB = require("aws-sdk/clients/dynamodb");
const documentClient = new DynamoDB.DocumentClient({ region: process.env.REGION });
const NOTES_TABLE_NAME = process.env.NOTES_TABLE_NAME;
const response = (statusCode, data) => {
return {
statusCode,
body: JSON.stringify(data),
};
};
module.exports.createNote = async (event, context, callback) => {
context.callbackWaitsForEmptyEventLoop = false;
console.log(event);
let data = JSON.parse(event.body);
try {
const params = {
TableName: NOTES_TABLE_NAME,
Item: {
noteId: data.id,
title: data.title,
content: data.content,
},
// The PutItem operation will not overwrite the item with the same key, if the item exists.
ConditionExpression: "attribute_not_exists(noteId)",
};
await documentClient.put(params).promise();
callback(null, response(201, data));
} catch (err) {
callback(null, response(500, err.message));
}
};
module.exports.updateNote = async (event, context, callback) => {
context.callbackWaitsForEmptyEventLoop = false;
let noteId = event.pathParameters.id;
let data = JSON.parse(event.body);
try {
const params = {
TableName: NOTES_TABLE_NAME,
Key: { noteId },
UpdateExpression: "set #title = :title, #content = :content",
ExpressionAttributeNames: {
"#title": "title",
"#content": "content",
},
ExpressionAttributeValues: {
":title": data.title,
":content": data.content,
},
ConditionExpression: "attribute_exists(noteId)",
};
await documentClient.update(params).promise();
callback(null, response(200, data));
} catch (err) {
callback(null, response(500, err.message));
}
};
module.exports.deleteNote = async (event, context, callback) => {
context.callbackWaitsForEmptyEventLoop = false;
let noteId = event.pathParameters.id;
try {
const params = {
TableName: NOTES_TABLE_NAME,
Key: { noteId },
ConditionExpression: "attribute_exists(noteId)",
};
await documentClient.delete(params).promise();
callback(null, response(200, noteId));
} catch (err) {
callback(null, response(500, err.message));
}
};
module.exports.getNotes = async (event, context, callback) => {
context.callbackWaitsForEmptyEventLoop = false;
console.log(JSON.stringify(event));
try {
const params = {
TableName: NOTES_TABLE_NAME,
};
const notes = await documentClient.scan(params).promise();
callback(null, response(200, notes));
} catch (err) {
callback(null, response(500, err.message));
}
};
context.callbackWaitsForEmptyEventLoop = false;
設為 false 將會在 callback 執行時立即傳送回應,而不會因為event loop 不為空,而等待到超時。這個在使用 async 非同步的情況下很好用,避免因為一些資料庫的連線事件導致 function 無法結束執行。
部署 & 測試
部署到 AWS
serverless deploy --verbose
開發期間,由於 function 修改得很頻繁,可以只要部署 function 就好。例如我只要更新雲端上的 createNote:
serverless deploy function -f createNote
或是只要更新特定 function 的設定:
serverless deploy function -f createNote --update-config
成功部署之後,登入AWS,進入 API Gateway 的 APIs 頁面,表格中可以看到剛部署好的 dev-serverless-notes-api
,點選他進入頁面後在左側欄位選 Stages
如下圖:
點選 /notes
的 GET method 並複製頁面最上方的 Invoke URL,貼到 postman 測試。
用 GET method 查詢目前存在的筆記,但是因為尚未新增任何筆記資料,所以拿到的結果 Items 是一個空陣列。
如果中途發生錯誤,記得善用 CloudWatch 進行除錯。每個 Lambda function 都會對應一個 log groups,記錄每一次的請求與回應的詳細訊息。
用 POST method 來新增一筆資料。
實際到 DynamoDB 可以看到剛剛新增的一筆資料。
用 PUT method 修改筆記,記得路徑要用 /notes/{id}
指定修改哪一筆紀錄。
再用 GET method 查詢一下所有的筆記紀錄,可以看到回傳的筆記是已經被修改的狀態。
刪除服務
最後,如果不再需要這個服務的話記得將它清空,避免產生不必要的支出。
serverless remove