這是一個對于 AWS Lambda Functions 的簡單 REST API 項目,使用 TypeScript 語言編寫,資料存儲采用 MongoDB Atlas 雲資料庫,從編碼到 AWS Lambda 下的單元測試,再到部署、日志調試完整的介紹了如何快速編寫一個 FaaS 函數。
本文你将學習到
REST API with typescript
MongoDB Atlas data storage
Multi-environment management under Serverless
Mocha unit tests and lambda-tester interface test
AWS lambda function log view
REST API 規劃
以下是我們将要完成的 REST API 規劃,包含四個 CRUD 操作
CRUD API Routes Description
POST /books 增加一本書
GET /books 擷取所有書籍清單
PUT /books/:id 根據 id 更新指定編号書籍
DELETE /books/:id 根據 id 删除指定編号書籍
目錄結構定義
├── app
│ ├── contrller # 控制層,解析使用者輸入資料,處理結果傳回
│ ├── model # 資料庫模型
│ ├── service # 業務邏輯層
│ └── utils # 工具類
├── config # 環境變量和配置相關
├── docs # 文檔
├── tests # 單元測試
├── tsconfig.json # 指定 TypeScript 編譯的參數資訊
└── tslint.json # 指定 TypeScript 代碼規範
├── .editorconfig # 約定編輯器的代碼風格
├── .gitignore # git 送出忽略指定檔案
├── .nycrc.json
├── package.json # package.json
├── serverless.yml # Serverless 配置檔案
├── README.md
複制代碼
Serverless 相關插件
serverless-offline
使用這個 serverless-offline 插件可以在本地啟動一個 HTTP 伺服器模拟 API Gateway。
安裝
npm install serverless-offline -D
添加 serverless-offline 到 serverless.yml 檔案
plugins:
serverless-plugin-typescript 插件
零配置 TypeScript 支援的 ServerLess 插件,Github serverless-plugin-typescript
npm install -D serverless-plugin-typescript typescript
添加 serverless-plugin-typescript 到 serverless.yml 檔案,確定其位于 serverless-offline 之前
- serverless-plugin-typescript
多配置環境管理
實際業務中,都會存在多套環境配置,例如:測試、預發、生産,那麼在 Serverless 中如何做環境切換呢?
為雲函數配置環境變量
修改 serverless.yml 檔案為雲函數配置環境變量,例如設定變量 NODE_ENV = dev
provider:
environment:
NODE_ENV: dev
配置檔案上傳時的 incldue 和 exclude
修改 serverless.yml 檔案,新增 exclude 和 incldue 配置,實作僅上傳對應配置檔案
exclude: 要忽略的配置檔案
include: 指定的配置檔案會被上傳
package:
exclude:
- config/.env.stg
- config/.env.pro
include:
- config/.env.dev
注:因為 TS 最終編譯隻會編譯 .ts 結尾的檔案,預設情況下 config 裡面指定的配置檔案是不會上傳的
Dotenv 子產品
預設情況如果我們設定了 .env 檔案,dotenv 可以将此檔案裡設定的環境變量注入到 process.env 對象中,如果你有自己個性化定義的 .env 檔案,在 dotenv 加載時指定 path 也可。
npm i dotenv -S
npm i @types/dotenv-safe -D
項目中使用
通過提取上面雲函數中設定的環境變量 NODE_ENV,拼接路徑 path 為 .env 指定檔案路徑
import dotenv from 'dotenv';
import path from 'path';
// 具體路徑根據自己的項目配置來
const dotenvPath = path.join(__dirname, '../',
config/.env.${process.env.NODE_ENV}
);
dotenv.config({
path: dotenvPath,
});
dotenv 環境變量配置參考
github.com/motdotla/dotenv
serverlesscloud.cn/best-practice/2020-03-10-serverless-env
編碼實踐核心講解
路由指定
Serverless.yml 檔案中通過 handler 指定函數的通路路徑,http.path 指定通路的路由,method 指定函數的請求方法。
相當于傳統應用開發中,我們這樣來定義 router.get('books/:id', () => { ... }) 一個路由
functions:
create:
handler: app/handler.create
events:
- http:
path: books
method: post
findOne:
handler: app/handler.findOne
events:
- http:
path: books/{id}
method: get
handler 入口函數處理
入口函數,利用函數的執行上下文重用,啟動環境執行代碼時初始化我們的資料庫連結、加載環境變量。
event、context 這些參數由 FaaS 平台提供,從 aws-lambda 中可以找到 Handler、Context 的聲明,但是并沒有找到關于 event 的。
// app/handler.ts
import { Handler, Context } from 'aws-lambda';
config/.env.${process.env.NODE_ENV}
import { books } from './model';
import { BooksController } from './contrller/books';
const booksController = new BooksController(books);
export const create: Handler = (event: any, context: Context) => {
return booksController.create(event, context);
};
export const findOne: Handler = (event: any, context: Context) => {
return booksController.findOne(event, context);
...
Controller 控制器層
通過路由指定和 handler 入口函數的處理,将使用者的請求基于 Path 和 Method 分發至相應 Controller 層,解析使用者的輸入,處理後傳回。
這一層不應存在任何形式的 “SQL 查詢”,如有需要它應該調用 Service 層處理業務,然後封裝結果傳回。
// app/controller/books.ts
export class BooksController extends BooksService {
constructor (books: Model) {
super(books);
}
/**
- Create book
- @param {*} event
*/
async create (event: any, context?: Context) {
console.log('functionName', context.functionName);
const params: CreateBookDTO = JSON.parse(event.body);
try {
const result = await this.createBook({
name: params.name,
id: params.id,
});
return MessageUtil.success(result);
} catch (err) {
console.error(err);
return MessageUtil.error(err.code, err.message);
}
- Query book by id
- @param event
async findOne (event: any, context: Context) {
// The amount of memory allocated for the function
console.log('memoryLimitInMB: ', context.memoryLimitInMB);
const id: number = Number(event.pathParameters.id);
try {
const result = await this.findOneBookById(id);
return MessageUtil.success(result);
} catch (err) {
console.error(err);
return MessageUtil.error(err.code, err.message);
}
...
Service 服務層
為了保證 Controller 層邏輯更加簡潔,針對複雜的業務邏輯可以抽象出來做一個服務層,做到獨立性、可複用性(可以被多個 Controller 層調用),這樣也更有利于單元測試的編寫。
// app/service/books.ts
export class BooksService {
private books: Model;
constructor(books: Model) {
this.books = books;
- @param params
protected async createBook (params: CreateBookDTO): Promise
try {
const result = await this.books.create({
name: params.name,
id: params.id,
});
// Do something
return result;
} catch (err) {
console.error(err);
throw err;
}
- @param id
protected findOneBookById (id: number) {
return this.books.findOne({ id });
Model 資料層
這一層連結我們的 DB,定義我們需要的 Schema,每個 Schema 都會映射到一個 MongoDB Collection 中。
// app/model/mongoose-db.ts
import mongoose from 'mongoose';
export default mongoose.connect(process.env.DB_URL, {
dbName: process.env.DB_NAME,
useUnifiedTopology: true,
useNewUrlParser: true,
// app/model/books.ts
export type BooksDocument = mongoose.Document & {
name: string,
id: number,
description: string,
createdAt: Date,
const booksSchema = new mongoose.Schema({
name: String,
id: { type: Number, index: true, unique: true },
description: String,
createdAt: { type: Date, default: Date.now },
// Note: OverwriteModelError: Cannot overwrite
Books
model once compiled. error
export const books = mongoose.models.books || mongoose.model('books', booksSchema, process.env.DB_BOOKS_COLLECTION);
單元測試
安裝插件
這些插件都有什麼用途,下面會介紹。
npm i @types/lambda-tester @types/chai chai @types/mocha mocha ts-node -D
lambda-tester
以前我們可以使用 supertest 做
買QQ靓号平台接口測試,但是現在我們使用 AWS Lambda 編寫的 FaaS 函數則不可以這樣做,例如請求中的 event、context 是與雲廠商是有關聯的,這裡推薦一個 lambda-tester 可以實作我們需要的接口測試。
npm i lambda-tester @types/lambda-tester -D
一個簡單的應用示例
在接口的路徑(path)上傳入參數 id
lambdaTester(findOne)
.event({ pathParameters: { id: 25768396 } })
.expectResult((result: any) => {
...
sinon
例如,我們請求一個接口,接口内部依賴于 DB 擷取資料,但是在做單元測試中我們如果不需要擷取實際的對象,就需要使用 Stub/Mock 對我們的代碼進行模拟操作。
npm i sinon @types/sinon -D
示例
以下例子中,我會做一個接口測試,通過 sinon 來模拟 mongoose 的各種方法操作。
const s = sinon
.mock(BooksModel);
s.expects('findOne')
.atLeast(1)
.atMost(3)
.resolves(booksMock.findOne);
// .rejects(booksMock.findOneError);
return lambdaTester(findOne)
.event({ pathParameters: { id: 25768396 } })
.expectResult((result: any) => {
// ...
以上對 booksMock 的 findOne 做了資料傳回 Mock 操作,使用 s.resolves 方法模拟了 fulfilled 成功态,如需測試 rejected 失敗态需指定 s.rejects 函數。
一些常用方法
s.atLeast(1) 最少調用一次。
s.atMost(3) 最多調用三次。
s.verify() 用來驗證 findOne 這個方法是否滿足上面的條件。
s.restore() 使用後複原該函數,适合于對某個函數的屬性進行多次 stub 操作。
測試覆寫率
單元測試用來驗證代碼,測試覆寫率則可以驗證測試用例,這裡我們選擇使用 nyc。
npm i nyc -D
.nycrc.json 配置檔案
"all": true, // 檢測所有檔案
"report-dir": "./coverage", // 報告檔案存放位置
"extension": [".ts"], // 除了 .js 之外應嘗試的擴充清單
"exclude": [ // 排除的一些檔案
"coverage",
"tests"
]
測試報告
下圖是對本項目做的一個測試用例覆寫率報告。
圖檔描述
Deploy And Usage
本地部署測試
運作 npm install 安裝需要的依賴
運作 npm run local 實際使用的是 serverless offline 在本地開啟測試。
在 AWS 上的部署, 運作:
$ npm run deploy
or
$ serverless deploy
期望的結果應該如下所示:
Serverless: Compiling with Typescript...
Serverless: Using local tsconfig.json
Serverless: Typescript compiled.
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service aws-node-rest-api-typescript.zip file to S3 (1.86 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
......................................
Serverless: Stack update finished...
Service Information
service: aws-node-rest-api-typescript
stage: dev
region: us-east-1
stack: aws-node-rest-api-typescript-dev
resources: 32
api keys:
None
endpoints:
POST -
https://xxxxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/booksPUT -
https://xxxxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/books/{id}
GET -
GET -
DELETE -
create: aws-node-rest-api-typescript-dev-create
update: aws-node-rest-api-typescript-dev-update
find: aws-node-rest-api-typescript-dev-find
findOne: aws-node-rest-api-typescript-dev-findOne
deleteOne: aws-node-rest-api-typescript-dev-deleteOne
layers:
Serverless: Removing old service artifacts from S3...
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.
Usage
使用 curl 之類的工具直接向端點發送一個 HTTP 請求。
curl
https://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/booksAWS Lambda 檢視 Serverless 函數日志
服務上線之後難免有時會需要通過日志來排查問題,AWS 中我們可以通過管理控制台和 CLI 本地化兩種方式檢視。
AWS 管理控制台檢視
打開 CloudWatch 控制台的日志頁面。
選擇您的函數 (/aws/lambda/function-name) 的日志組。
選擇清單中的日志流。
AWS CLI 方式檢視
docs.aws.amazon.com/cli/latest/…
确認是否安裝成功
which aws 或 aws --version 指令檢測是否安裝成功,類似以下結果,安裝成功
$ which aws
/usr/local/bin/aws
$ aws --version
aws-cli/2.0.12 Python/3.7.4 Darwin/19.3.0 botocore/2.0.0dev16
認證
安裝成功,需先執行 aws configure 指令配置 aws-cli 和憑據
$ aws configure
AWS Access Key ID [None]: AKIAIOSFODNN7EXAMPLE
AWS Secret Access Key [None]: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Default region name [None]: us-west-1
Default output format [None]:
區域名稱 region 一定要配置,如果不知道的,當時 serverless deploy 的時候也有顯示,可以留意下。
終端檢視
預設展示 base64 編碼之後的資料
$ aws lambda invoke --function-name aws-node-rest-api-typescript-dev-find out-logger.json --log-type Tail
base64 解碼日志
$ aws lambda invoke --function-name aws-node-rest-api-typescript-dev-find out-logger.json --log-type Tail --query 'LogResult' --output text | base64 -d
Github
本示例項目,你可以在 Github 找到 Clone 下來進行學習。
倉庫:github.com/Q-Angelo/aw… <-- 戳戳 Star
總結
Serverless 下的雲函數開發,可以使我們更關注于業務本身,從上面示例中也可以看到我們的業務代碼并沒有什麼差別,更多的是避免了運維、後期的擴所容等一些成本問題,還有一點不同的是入口函數,傳統的應用開發我們可以通過 HTTP 的 Request、Response 做處理和響應,例如在 AWS Lambda 下我們則是通過 event、context 來處理請求和一些上下文資訊。
FaaS 這一層應盡可能的輕量,更多的是業務邏輯的處理,對于資料庫這種是很難做到動态化、自動伸縮,但是如果每次冷啟動都去建立連結對于資料庫本身也會造成壓力,一方面可以選擇雲平台提供的,另一方面也可以自己資料庫 BaaS 化,經過包裝進行調用。