Ứng dụng web sử dụng DynamoDB, Typescript, Express, Generic repository pattern…(Phần 04) – BaseRepository

Đây là bài viết thuộc phần 4 của series. Như đã nhắc tới tại đoạn kết của Phần 03, trong bài viết này chúng ta sẽ tạo ra lớp Generic Repository. Kết hợp lớp này với các Entity, chúng ta có thể tạo ra các Repository để thao tác với các bảng trong cơ sở dữ liệu.

BaseRepository

Đây là lớp sẽ có tất cả các phương thức chung nhất để thao tác với csdl (thường là CRUD).

Lớp này sẽ implement các phương thức được định nghĩa bởi các interface: IRead và IWrite.

Đọc ghi dữ liệu với DynamoDB, chúng ta phải làm việc với Partition Key rất nhiều, nên các phương thức đọc ghi đều có liên quan tới Key này.

IRead

Mô tả các phương thức đọc dữ liệu.Với cơ sở dữ liệu DynamoDB, các phương thức đọc dữ liệu sẽ có chút đặc biệt hơn so với những csdl khác. DynamoDB có phương thức scan để đọc dữ liệu, nhưng mình không khuyên sử dụng phương thức này, nên mình không mô tả phương thức này trong bài viết.

Tạo file src/repositories/interfaces/IRead.ts 

import { DocumentClient } from 'aws-sdk/clients/dynamodb';

export interface IRead<T, K> {
  query(queryParams: Omit<DocumentClient.QueryInput, 'TableName'>): Promise<T[]>;
  findOne(key: K): Promise<T>;
}

 

Interface IRead là một generic interface , interface này nhận vào 2 kiểu dữ liệu là kiểu T và kiểu K. Trong đó:

  • T là kiểu dữ liệu của entity. Ví dụ: Đối tượng Movie đã tạo ở phần 02
  • K là kiểu dữ liệu của Partition Key. Ví dụ: Với bảng Movies thì kiểu của key sẽ là {title: string, year: number}

Interface này mô tả 2 phương thức:

  • findOne: Nhận vào key và trả lại một đối tượng Entity. Đây là một async function
  • query: Nhận vào đối tượng query của DynamoDB, đối tượng này được không cần thuộc tính TableName, thuộc tính này được đặt chung khi tạo ra lớp Repository. Phương thức này sẽ trả lại là một mảng các đối tượng Entity

IWrite

Mô tả các phương thức thêm, cập nhật dữ liệu trong csdl.

Tạo file src/repositories/interfaces/IWrite.ts 

import { DocumentClient } from 'aws-sdk/clients/dynamodb';

export interface IWrite<T, K> {
  create(item: Partial<T>): Promise<T>;
  update(key: K, item: Omit<Partial<T>, keyof K>): Promise<T>;
  delete(key: K, options?: Omit<DocumentClient.DeleteItemInput, 'Key' | 'TableName'>): Promise<boolean>;
}

Interface này có 3 phương thức:

  • create: Thêm dữ liệu vào csdl. Partial<T> có nghĩa là bạn có thể truyền vào một đối tượng có kiểu giống với T (Entity) nhưng các thuộc tính đều là tùy chọn
  • update: Cập nhật dữ liệu trong csdl. Tham số truyền vào là key của đối tượng cần cập nhật, và dữ liệu cập nhật. Chúng ta không thể cập nhật giá trị key của dữ liệu
  • delete: Xóa dữ liệu theo key. Bạn có thể truyền thêm tùy chọn cho action này, các tùy chọn là tùy chọn của DynamoDB khi gọi hàm delete.

Exception

Phần này là tùy chọn.

Các Repository có phương thức tìm kiếm theo key (findOne), nếu dữ liệu không tồn tại thì chúng ta phải xử lý việc thông báo lỗi. Việc xử lý lỗi nên được tập trung hóa ở một nơi, vậy ở trong Repository chúng ta chỉ cần throw ra một lỗi xác định trong trường hợp không tìm thấy dữ liệu.

Với Typescript thì cú pháp async/await được ưu tiên hơn cả, việc xử lý lỗi cùng dễ dàng hơn rất nhiều với cú pháp này. Chúng ta chỉ cần 1 block try/catch là có thể bắt được mọi lỗi trong quá trình thực thi.

Tạo file src/repositories/errors/BaseException.ts (Thự ra file này phải được tạo ở ngoài domain repository, vì đối tượng này sẽ được dùng chung trong cả dự án)

export abstract class BaseException extends Error {
  protected constructor(message: string) {
    super(message);

    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);

    // Set the prototype explicitly.
    Object.setPrototypeOf(this, BaseException.prototype);
  }
}

(Lưu ý: Typescript wiki

Tạo file src/repositories/errors/EntityNotFoundException.ts , đối tượng này sẽ được throw khi dữ liệu không được tìm thấy:

import { BaseException } from './BaseException';

export class EntityNotFoundException extends BaseException {
  constructor(entityName: string) {
    super(`${entityName} not found!`);
  }
}

Khi xử lý lỗi, chúng ta dễ dàng handle được các trường hợp lỗi, kiểu như:

app.use(function (err, req, res, next) {
  if (err instanceof BaseException) {
    // Lỗi được throw trong chương trình
    if (err instanceof EntityNotFoundException) {
      return res.status(404).json({message: err.message});
    }
    // Mặc định trả lại lỗi 400
    return res.status(400).json({ message: err.message });
  }
  // Lỗi không xác định
  res.status(500).json({message: 'Something broke!'})
});

BaseRepository

Lớp BaseRepository sẽ implement lại 2 interface ở trên.

File src/repositories/base/BaseRepository.ts :

import { DocumentClient } from 'aws-sdk/clients/dynamodb';
import { EntityNotFoundException } from '../errors/EntityNotFoundException';
import { IRead } from '../interfaces/IRead';
import { IWrite } from '../interfaces/IWrite';

type EntityConstructor<T> = new (raw: any) => T;

export class BaseRepository<T, K> implements IWrite<T, K>, IRead<T, K> {
  constructor(
    protected readonly docClient: DocumentClient,
    protected readonly tableName: string,
    private readonly entityClass: EntityConstructor<T>,
  ) {
  }

  public getDefaultParam(): { TableName: string } {
    return {
      TableName: this.tableName,
    }
  }

  public async create(item: Partial<T>): Promise<T> {
    // Create default partition key
    const putParams: DocumentClient.PutItemInput = {
      ...this.getDefaultParam(),
      Item: item,
    };

    await this.docClient.put(putParams).promise();

    return new this.entityClass(putParams.Item);
  }

  public async delete(
    key: K,
    options: Omit<DocumentClient.DeleteItemInput, 'Key' | 'TableName'> = {},
  ): Promise<boolean> {
    const deleteParams: DocumentClient.DeleteItemInput = {
      ...this.getDefaultParam(),
      ...options,
      Key: key,
    };
    const result = await this.docClient.delete(deleteParams).promise();
    return !!result;
  }


  public async findOne(key: K): Promise<T> {
    const getParams: DocumentClient.GetItemInput = {
      ...this.getDefaultParam(),
      Key: key,
    };
    const result = await this.docClient.get(getParams).promise();
    if (!result.Item) {
      throw new EntityNotFoundException(this.tableName);
    }
    return new this.entityClass(result.Item);
  }

  public async update(key: K, item: Omit<Partial<T>, keyof K>): Promise<T> {
    const updateParams: DocumentClient.PutItemInput = {
      ...this.getDefaultParam(),
      Item: {
        ...item,
        ...key,
      },
    };
    await this.docClient.put(updateParams).promise();
    return new this.entityClass(updateParams.Item);
  }

  public async query(query: Omit<DocumentClient.QueryInput, 'TableName'>): Promise<T[]> {
    const queryParams: DocumentClient.QueryInput = {
      ...query,
      ...this.getDefaultParam(),
    };
    const result = await this.docClient.query(queryParams).promise();

    return (result.Items || []).map((raw) => {
      return new this.entityClass(raw);
    });
  }
}

Chúng ta sẽ đi qua từng phần của lớp này:

  • Implement IRead và IWrite: Một repository thì ít nhất sẽ có các phương thức đọc và ghi được mô tả trong IReadIWrite. Đây là Base repository, nên nó không đại diện để đọc hay ghi vào bảng nào trong cơ sở dữ liệu
  • constructor: Hàm khởi tạo nhận các tham số truyền vào để khởi tạo một Repository:
    • docClient:DocumentClient => Đối tượng để thao tác với csdl
    • tableName: string => Tên bảng sẽ thao tác
    • entityClass: EntityConstructor<T> =>   Entity class type, thay vì truyền vào giá trị thì bạn truyền vào “kiểu”, giá trị này sẽ dùng để convert các json object thành các Entity class, để tận dụng được những hàm chúng ta đã viết trong Entity class như getActors
  • getDefaultParam: Khi làm việc với DynamoDB chúng ta luôn phải truyền vào TableName cho mọi query tới csdl, phương thức này sẽ trả lại object có thuộc tính TableName, và giá trị của thuộc tính là this.tableName – giá trị được truyền vào lúc khởi tạo
  • create: Chèn 1 record vào csdl. Phương thức này gọi tới phương thức put của DocumentClient. Nếu đối tượng có Partition key là một unique string kiểu như uuid-v4 , thì thường giá trị key sẽ được tạo ở đây. Phương thức trả lại một entity instance
  • delete: Xóa một bản ghi theo key. Giá trị trả lại thuộc kiểu boolean
  • findOne: Tìm kiếm một bản ghi theo key. Nếu không tìm thấy bản ghi, throw ra lỗi EntityNotFoundException. Trả lại một entity instance
  • update: Cập nhật thông tin một bản ghi theo key
  • query: Lấy các bản ghi theo điều kiện. Thực hiện query dựa trên các Global Secondary Index (GSI) key. Hàm này luôn trả lại một array các instance của entity, array rỗng khi không có bản ghi nào phù hợp

Kết thúc phần 04

Tổng kết phần này: Chúng ta đã xây dựng những lớp quan trọng nhất của dự án.

Ở phần sau, chúng ta sẽ kết hợp lớp BaseRepository với Movie Entity để có thể thao tác với dữ liệu trong bảng Movies của DynamoDB.

Đây là những gì được thêm vào, thay đổi so với Phần 03Commit

Đây là project sau khi kết thúc phần 04: Github