(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.jsserverless.ymlpackage.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個主要區塊 (providerfunctionsresourcesplugins),拆解開來方便做說明,請再自行合併成一份檔案。

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 如下圖:

api_gateway_stage

點選 /notes 的 GET method 並複製頁面最上方的 Invoke URL,貼到 postman 測試。

用 GET method 查詢目前存在的筆記,但是因為尚未新增任何筆記資料,所以拿到的結果 Items 是一個空陣列。

如果中途發生錯誤,記得善用 CloudWatch 進行除錯。每個 Lambda function 都會對應一個 log groups,記錄每一次的請求與回應的詳細訊息。

postman_get_empty

用 POST method 來新增一筆資料。

postman_post

實際到 DynamoDB 可以看到剛剛新增的一筆資料。

dynamodb

用 PUT method 修改筆記,記得路徑要用 /notes/{id} 指定修改哪一筆紀錄。

postman_put

再用 GET method 查詢一下所有的筆記紀錄,可以看到回傳的筆記是已經被修改的狀態。

postman_get_2

刪除服務

最後,如果不再需要這個服務的話記得將它清空,避免產生不必要的支出。

serverless remove

Reference