Serverless Typescript với AWS Lambda, API Gateway và DynamoDB trên môi trường offline - Phần 02

Phần 01 - https://viblo.asia/p/serverless-typescript-voi-aws-lambda-api-gateway-va-dynamodb-tren-moi-truong-offline-phan-01-3P0lPk7PZox

Như ở bài viết trước chúng ta đã tạo được một ứng dụng serverless có thể deploy lên hệ sinh thái aws, thực hiện phát triển ở mội trường local.

Với cấu trúc code như đã viết trong bài trước, mỗi một chức năng sẽ được xử lý bởi một hàm và được trỏ bởi một api endpoind.

functions:
  createCat:
    handler: handler.createCat
    events:
     - http:
         path: cats
         method: POST
  findCatById:
   handler: handler.findCatById
   events:
    - http:
        path: cats/:id
        method: GET

Cấu trúc code theo hướng này được gọi là Microservices Pattern theo serverless framework. Kiểu này có một nhược điểm lớn: Giới hạn kích thước của CloudFormation template (khoảng 200 resources), ứng dụng của chúng ta sẽ có giới hạn về số lượng các chức năng.

Ví dụ: Một chức năng createCat chúng ta sẽ cần ít nhất 4 resource: 1 AWS::Lambda::Function + 1 AWS::Lambda::Permission(ApiGateway) + 1 AWS::Lambda::Version + 1 AWS::Logs::LogGroup

Mỗi bảng dynamodb chúng ta mất một tài nguyên(dynamodb chỉ định nghĩa bảng không định nghĩa database (??))

Mình sẽ viết một bài viết khác để chia sẻ về các kiểu cấu trúc code cho một dự án theo hướng serverless.

Serverless có vẻ còn khá mới, để phát triển một ứng dụng với thời gian nhanh nhất có thể hoặc tất cả các members trong team có thể tham gia phát triền với mô hình serverless mà không cần quá nhiều thời gian tìm hiểu, chúng ta sẽ sử dụng những cấu trúc cũ để giải quyết vấn đề. Monolithic Pattern một function giải quyết tất cả tính năng (khoc2) (tất nhiên hướng này cũng có những nhược điểm nhất định).

Do mục đích cuối cùng là xây dựng một ứng dụng cung cấp API chuẩn RESTFul chúng ta sẽ nghĩ tới việc sử dụng express framework để tạo ra một ứng dụng như vậy.

Việc chuyển đổi từ một trigger event của Aws Lambda Function sang request theo express các bạn có thể đọc bài viết này https://viblo.asia/p/expressjs-style-flow-for-aws-lambda-vyDZOXa9lwj của tác giải @Kinyakin (bow).

Còn trong bài viết này chúng ta sẽ sử dụng package serverless-http để làm việc đó (yaoming).

Xây dựng ứng dụng với Express bằng Typescript

Cấu trúc thư mục

Chúng ta sẽ vẫn dùng thư mục của bài viết trước. Chúng ta sẽ chia thư mục giống thế này.

src
----modules // các thư mục tính năng theo đối tượng
--------cats  // code của module `cats`
------------dto // các Data Transfer Objects của cats module
------------interfaces // các Interface của cats module
------------repositories // các repo của cats module
------------cats.controller.ts // file chứa nội dung router của cats module
----shared // các thư viện dùng chung cho cả app
----App.ts // class mô tả ứng dụng express
----handler.ts // file chứa hàm handler của serverless (từ bài trước)
----localServer.ts // file để thực hiện chạy ứng dụng theo cách `thông thường`, không qua `serverless-offline`
index.js // hỗ trợ chạy ứng dụng theo cách thông thường mà không cần build typescript

Những file khác vẫn giữa như bài viết trước.

Hiện thực các file chức năng

Mình sẽ không nêu các packages sẽ phải thêm, chúng ta thấy cần thư viện nào thì sẽ thêm thư viện đó (khoc2)

Main app và helper

src/App.js file này export ra một instance ứng dụng express. Class App mô tả một ứng dụng express,

  • constructor sẽ khởi tạo và gọi các hàm định nghĩa của ứng dụng
  • Phương thức private configureApp cấu hình ứng dụng
  • Phương thức private mountRoutes thực hiện mapping url với controller
import * as bodyParser from 'body-parser';
import * as express from 'express';
import * as logger from 'morgan';
import CatsController from './modules/cats/cats.controller';
import {Application, Request, Response, NextFunction, Router} from "express";

interface ErrorRequest {
  status?: number;
  message?: string;
}

class App {
  private express: Application;

  constructor() {
    this.express = express();
    this.configureApp();
    this.mountRoutes();
  }

  public getExpress(): Application {
    return this.express;
  }

  private configureApp(): void {
    this.express.use(logger('dev'));
    this.express.use((req: Request, res: Response, next: NextFunction) => {
      res.setHeader("Access-Control-Allow-Origin", "*");
    });
    this.express.use(bodyParser.json());
    this.express.use(bodyParser.urlencoded({extended: false}));
  }

  private mountRoutes(): void {
    const route = Router();
    route.get('/', (rep: Request, res: Response, next: NextFunction) => {
      res.json({
        message: 'Hello World!',
      });
    });
    this.express.use('/', route);

    //configure api routes
    this.express.use('/api/v1/cats', CatsController);

    // 404
    this.express.use((req: Request, res: Response, next: NextFunction) => {
      res.status(404).json({message: 'Not found'});
    });

    // 5xx
    this.express.use((err: ErrorRequest, req: Request, res: Response, next: NextFunction) => {
      res.status(err.status || 500).json({message: err.message || 'Internal Server Error'});
    });
  }
}

export default new App().getExpress();

src/shared/dynamoDbConnection.ts thực hiện việc kết nối và thư thi trên cơ sở dữ liệu Dynamodb

  • constructor thực hiện việc định nghĩa cấu hình kết nối, tạo ra đối tượng document client
  • public create thêm mới dữ liệu vào 1 bảng
  • public findById tìm một đối trượng trong 1 bảng theo id
import {DocumentClient} from "aws-sdk/lib/dynamodb/document_client";

class Connection {
  private dynamoDb: DocumentClient;

  constructor() {
    if (process.env.IS_OFFLINE === 'true') {
      this.dynamoDb = new DocumentClient({
        endpoint: process.env.DYNAMODB_ENDPOINT || 'http://localhost:8000',
        region: 'localhost',
      });
    } else {
      this.dynamoDb = new DocumentClient();
    }
  }

  public create<T>(params: DocumentClient.PutItemInput): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.dynamoDb.put(params, (err, data) => {
        if (err) {
          return reject(err);
        }
        resolve(params.Item as T);
      });
    });
  }

  public findById<T>(params: DocumentClient.GetItemInput): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.dynamoDb.get(params, (err, data) => {
        if (err) {
          return reject(err);
        }
        resolve(data.Item as T || null);
      });
    });
  }
}

export default new Connection();

Cats module

Các file trong phần này sẽ được tạo trong thư mục src/modules/cats


dto/create-cat.dto.ts mô tả class dữ liệu để tạo mới một con mèo, sử dụng package class-validator để định nghĩa và validate dữ liệu.

import {IsDefined, MaxLength, MinLength} from 'class-validator';

class CreateCatDto {
  @MinLength(3, {
    message: 'name is too short',
  })
  @MaxLength(50, {
    message: 'name is to long',
  })
  public name: string;

  @IsDefined({
    message: 'mood should be defined',
  })
  public mood: string;

  constructor(name: string, mood: string) {
    this.name = name;
    this.mood = mood;
  }
}

export default CreateCatDto;

interfaces/cat.interface.ts Mô tả interface một đối tượng m èo

interface ICat {
  id: string;
  name: string;
  mood: string;
}

export default ICat;

repositories/cats.repo.ts mô tả class CatsRepo - làm việc với bảng cats

  • public create tạo mới một con mèo
  • public findById tìm một con mèo theo Id
import { validate } from 'class-validator';
import * as uuid from 'uuid';
import CreateCatDto from '../dto/create-cat.dto';
import ICat from '../interfaces/cat.interface';
import connection from '../../../shared/dynamoDbConnection';

class CatsRepo {
  private tableName: string;

  constructor() {
    this.tableName = process.env.CATS_TABLE || 'cats'
  }

  public async create(name: string, mood: string): Promise<ICat> {
    const catDto = new CreateCatDto(name, mood);
    const errors = await validate(catDto, { validationError: { target: false } });
    if (errors.length > 0) {
      return Promise.reject({ status: 400, message: 'Cat data is not valid!' });
    }
    const params = {
      Item: {
        id: uuid.v4(),
        mood: catDto.mood,
        name: catDto.name,
      },
      TableName: this.tableName,
    };
    return connection.create<ICat>(params);
  }

  public async findById(id: string): Promise<ICat> {
    const params = {
      Key: {
        id,
      },
      TableName: this.tableName,
    };
    let cat = await connection.findById<ICat>(params);
    if (!cat) {
      return Promise.reject({ status: 404, message: 'No cat found with the given id.' });
    }
    return cat;
  }
}

export default new CatsRepo();

cats.controller.ts mô tả class CatsController - mapping url với phương thức tương ứng

  • constructor khởi tạo đối tượng route và mapping các phương thức
  • public create handle phương thức POST /api/v1/cats tạo mới một con mèo
  • public findById handle phương thức GET /api/v1/cats/:id lấy một con mèo theo id
  • private init mapping
import {NextFunction, Request, Response, Router} from 'express';
import CatsRepo from './repositories/cats.repo';

class CatsController {
  private route: Router;

  constructor() {
    this.route = Router();
    this.init();
  }

  public getRoute(): Router {
    return this.route;
  }

  public async create(req: Request, res: Response, next: NextFunction): Promise<void> {
    try {
      const { name, mood } = req.body;
      const cat = await CatsRepo.create(name, mood);
      res.status(200).json({
        data: cat,
        message: 'success',
        status: res.status,
      });
    } catch (err) {
      next(err);
    }
  }

  public async findById(req: Request, res: Response, next: NextFunction): Promise<void> {
    try {
      const id = req.params.id;
      const cat = await CatsRepo.findById(id);
      res.status(200).json({
        data: cat,
        message: 'success',
        status: res.status,
      });
    } catch (err) {
      next(err);
    }
  }

  private init(): void {
    this.route
      .post('/', this.create)
      .get('/:id', this.findById);
  }
}

export default new CatsController().getRoute();

Cấu hình ứng dụng chạy local

Vì được viết bằng express framework nên chúng ta có thể thực hiện chạy và test như cách thông thường mà không cần sự hỗ trợ của package serverless-offline. Chúng ta đã có một đối tượng App là một instance của express app, chúng ta sẽ tiến hành khởi tạo http server theo express instance này src/localServer.ts

require('dotenv').config();

import * as http from 'http';
import App from './App';

const port = normalizePort(process.env.PORT || 3000);
App.set('port', port);

const server = http.createServer(App);
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

function normalizePort(val: number | string): number | string | boolean {
  let port: number = (typeof val === 'string') ? parseInt(val, 10) : val;
  if (isNaN(port)) {
    return val;
  } else if (port >= 0) {
    return port;
  } else {
    return false;
  }
}

function onError(error: NodeJS.ErrnoException): void {
  if (error.syscall !== 'listen') { throw error; }
  const bind = (typeof port === 'string') ? 'Pipe ' + port : 'Port ' + port;
  switch (error.code) {
    case 'EACCES':
      console.error(`${bind} requires elevated privileges`);
      process.exit(1);
      break;
    case 'EADDRINUSE':
      console.error(`${bind} is already in use`);
      process.exit(1);
      break;
    default:
      throw error;
  }
}

function onListening(): void {
  const addr = server.address();
  const bind = (typeof addr === 'string') ? `pipe ${addr}` : `port ${addr.port}`;
  console.log(`Listening on ${bind}`);
}

Tới đây chúng ta đã có thể khởi chạy server bằng cách chạy các lệnh

tsc -p tsconfig.json -- để compile ts file sang file js

node src/localServer.js -- khởi động ứng dụng

Chúng ta sẽ có một http server chạy trên cổng process.env.PORT || 3000

Việc compile mất khá nhiều thời gian nên chúng ta sẽ dùng thêm một file index.js ở thư mục root của project, sử dụng package ts-node/register để tạo ra môi trường thực thi Typescript.

require('ts-node/register');
require('./src/localServer');

!!! Vì ứng dụng không chạy bằng serverless, nên chúng ta phải cài đặt dynamodb trên máy local. Có thể dùng docker hoặc cài đặt theo hướng dẫn từ AWS https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html

Cấu hình serverless handle function với instance của express

Chúng ta sẽ sửa lại file src/handler.ts, các trigger request của Lambda funtion sẽ được convert sang http request (trigger function phải là API Gateway request) bằng package serverless-http

import serverless = require('serverless-http');
import App from './App';
module.exports.handler = serverless(App);

Và thay đổi nội dung file serverless.yml, chỉ còn 1 function, toàn bộ các http request sẽ được xử lý bởi function này

service: serverless-typescript-express
provider:
  name: aws
  runtime: nodejs6.10
  stage: dev

plugins:
  - serverless-webpack
  - serverless-dynamodb-local
  - serverless-offline
custom:
  dynamodb:
    start:
      port: 8000
      inMemory: true
      migrate: true
      seed: true
      # Uncomment only if you already have a DynamoDB running locally
      noStart: true

functions:
  app:
    handler: handler.handler
    events:
     - http: ANY /
     - http: 'ANY {proxy+}'
resources:
  Resources:
    usersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: cats
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

Như vậy là hoàn thành ứng dụng serverless theo express style.

Kết luận

Bằng cấu trúc này chúng ta có thể chuyển một ứng dụng express cũ sang dạng serverless, hoặc phát triển một ứng dụng mới theo hướng serverless sử dụng hệ sinh thái Aws một cách dễ dàng.

Chúng ta sẽ gặp nhau ở các bài tiếp theo xoay quanh chủ đề serverless.