DuyLee

Published

- 18 min read

NestJS Microservice: Xây dựng các microservice Auth, Order, Product với gRPC - Part 2/2

img of NestJS Microservice: Xây dựng các microservice Auth, Order, Product với gRPC - Part 2/2

Trong phần 1 của loạt bài, Mình đã hướng dẫn chi tiết cách xây dựng API Gateway cho hệ thống microservices sử dụng NestJS và gRPC, giúp quản lý luồng dữ liệu và tương tác giữa các dịch vụ một cách hiệu quả. các bạn có thể xem lại tại đây.

Mình sử dụng database Postgresql để lưu trữ dữ liệu nên các bạn cần cài Postgresql trước nhé!

Xây dựng service Authentication

Chúng ta sẽ bắt đầu bằng việc khởi tạo project Authentication Service bằng NestJS CLI

   nest new auth-svc -p npm

Copy folder proto ở project api-gateway vào folder của dự án

Cài đặt các packages

   npm i @nestjs/microservices @nestjs/typeorm @nestjs/jwt @nestjs/passport passport passport-jwt typeorm pg class-transformer class-validator bcryptjs
npm i -D @types/node @types/passport-jwt ts-proto

Setup cấu trúc dự án

   Nest g mo auth && Nest g co auth --no-spec 
mkdir src/auth/filter && mkdir src/auth/service && mkdir src/auth/strategy 
touch src/auth/filter/http-Exception.filter.ts 
touch src/auth/service/auth.service.ts 
touch src/auth/service/jwt.service.ts 
touch src/auth/strategy/jwt.strategy.ts 
touch src/auth/auth.dto.ts 
touch src/auth/auth.entity.ts

Thêm script vào package.json để generate ra file .pb.ts tương tự như chúng ta đã làm ở api-gateway

   "proto:auth": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto -I=./proto --ts_proto_out=src/auth/ proto/auth.proto --ts_proto_opt=nestJs=true --ts_proto_opt=fileSuffix=.pb",

Chạy lệnh sau:

   npm run proto:auth

Project của chúng ta sẽ như thế này

HTTP Exception Filter

Trong quá trình xây dựng hệ thống gRPC với NestJS, chúng ta sẽ sử dụng các đối tượng truyền dữ liệu (Data-Transfer-Objects - DTO) để xác thực payload của các yêu cầu. Khi sử dụng gói class-validator, nó sẽ tạo ra các ngoại lệ HTTP nếu payload không hợp lệ. Tuy nhiên, vì mục tiêu của chúng ta là giữ cho dịch vụ gRPC của mình hoạt động trơn tru mà không gửi ngoại lệ HTTP, chúng ta cần chuyển đổi các ngoại lệ này thành phản hồi gRPC chuẩn. Điều này giúp đảm bảo rằng các lỗi xác thực được xử lý một cách liền mạch, cung cấp các phản hồi dễ hiểu và không gây gián đoạn cho hệ thống microservices của chúng ta.

Thêm code vào src/auth/filter/http-exception.filter.ts

   import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { HttpArgumentsHost } from '@nestjs/common/interfaces';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx: HttpArgumentsHost = host.switchToHttp();
    const res: Response = ctx.getResponse<Response>();
    const req: Request = ctx.getRequest<Request>();
    const status: HttpStatus = exception.getStatus();

    if (status === HttpStatus.BAD_REQUEST) {
      const res: any = exception.getResponse();

      return { status, error: res.message };
    }

    res.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: req.url,
    });
  }
}

Auth DTO

Thêm code vào src/auth/auth.dto.ts

   import { IsEmail, IsString, MinLength } from '@nestjs/class-validator';
import { LoginRequest, RegisterRequest, ValidateRequest } from './auth.pb';

export class LoginRequestDto implements LoginRequest {
  @IsEmail()
  public readonly email: string;

  @IsString()
  public readonly password: string;
}

export class RegisterRequestDto implements RegisterRequest {
  @IsEmail()
  public readonly email: string;

  @IsString()
  @MinLength(8)
  public readonly password: string;
}

export class ValidateRequestDto implements ValidateRequest {
  @IsString()
  public readonly token: string;
}

Auth Entity

Thêm code vào src/auth/auth.entity.ts

   import { Exclude } from '@nestjs/class-transformer';
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Auth extends BaseEntity {
  @PrimaryGeneratedColumn()
  public id!: number;

  @Column({ type: 'varchar' })
  public email!: string;

  @Exclude()
  @Column({ type: 'varchar' })
  public password!: string;
}
JWT Service

Thêm code vào src/auth/service/jwt.service.ts

   import { Injectable } from '@nestjs/common';
import { JwtService as Jwt } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Auth } from '../auth.entity';
import * as bcrypt from 'bcryptjs';

@Injectable()
export class JwtService {
  @InjectRepository(Auth)
  private readonly repository: Repository<Auth>;

  private readonly jwt: Jwt;

  constructor(jwt: Jwt) {
    this.jwt = jwt;
  }

  // Decoding the JWT Token
  public async decode(token: string): Promise<unknown> {
    return this.jwt.decode(token, null);
  }

  // Get User by User ID we get from decode()
  public async validateUser(decoded: any): Promise<Auth> {
    return this.repository.findOne({ where: { id: decoded.id } });
  }

  // Generate JWT Token
  public generateToken(auth: Auth): string {
    return this.jwt.sign({ id: auth.id, email: auth.email });
  }

  // Validate User's password
  public isPasswordValid(password: string, userPassword: string): boolean {
    return bcrypt.compareSync(password, userPassword);
  }

  // Encode User's password
  public encodePassword(password: string): string {
    const salt: string = bcrypt.genSaltSync(10);

    return bcrypt.hashSync(password, salt);
  }

  // Validate JWT Token, throw forbidden error if JWT Token is invalid
  public async verify(token: string): Promise<any> {
    try {
      return this.jwt.verify(token);
    } catch (err) { }
  }
}
JWT Strategy

Thêm code vào src/auth/strategy/jwt.strategy.ts

   import { Injectable, Inject } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Auth } from '../auth.entity';
import { JwtService } from '../service/jwt.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  @Inject(JwtService)
  private readonly jwtService: JwtService;

  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: 'dev',
      ignoreExpiration: true,
    });
  }

  private validate(token: string): Promise<Auth | never> {
    return this.jwtService.validateUser(token);
  }
}
Auth Service

Thêm code vào src/auth/service/auth.service.ts

   import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from './jwt.service';
import {
  RegisterRequestDto,
  LoginRequestDto,
  ValidateRequestDto,
} from '../auth.dto';
import { Auth } from '../auth.entity';
import { LoginResponse, RegisterResponse, ValidateResponse } from '../auth.pb';

@Injectable()
export class AuthService {
  @InjectRepository(Auth)
  private readonly repository: Repository<Auth>;

  @Inject(JwtService)
  private readonly jwtService: JwtService;

  public async register({
    email,
    password,
  }: RegisterRequestDto): Promise<RegisterResponse> {
    let auth: Auth = await this.repository.findOne({ where: { email } });

    if (auth) {
      return { status: HttpStatus.CONFLICT, error: ['E-Mail already exists'] };
    }

    auth = new Auth();

    auth.email = email;
    auth.password = this.jwtService.encodePassword(password);

    await this.repository.save(auth);

    return { status: HttpStatus.CREATED, error: null };
  }

  public async login({
    email,
    password,
  }: LoginRequestDto): Promise<LoginResponse> {
    const auth: Auth = await this.repository.findOne({ where: { email } });

    if (!auth) {
      return {
        status: HttpStatus.NOT_FOUND,
        error: ['E-Mail not found'],
        token: null,
      };
    }

    const isPasswordValid: boolean = this.jwtService.isPasswordValid(
      password,
      auth.password,
    );

    if (!isPasswordValid) {
      return {
        status: HttpStatus.NOT_FOUND,
        error: ['Password wrong'],
        token: null,
      };
    }

    const token: string = this.jwtService.generateToken(auth);

    return { token, status: HttpStatus.OK, error: null };
  }

  public async validate({
    token,
  }: ValidateRequestDto): Promise<ValidateResponse> {
    const decoded: Auth = await this.jwtService.verify(token);

    if (!decoded) {
      return {
        status: HttpStatus.FORBIDDEN,
        error: ['Token is invalid'],
        userId: null,
      };
    }

    const auth: Auth = await this.jwtService.validateUser(decoded);

    if (!auth) {
      return {
        status: HttpStatus.CONFLICT,
        error: ['User not found'],
        userId: null,
      };
    }

    return { status: HttpStatus.OK, error: null, userId: decoded.id };
  }
}
Auth Controller

Thêm code vào src/auth/auth.controller.ts

   import { Controller, Inject } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import {
  LoginRequestDto,
  RegisterRequestDto,
  ValidateRequestDto,
} from './auth.dto';
import {
  AUTH_SERVICE_NAME,
  RegisterResponse,
  LoginResponse,
  ValidateResponse,
} from './auth.pb';
import { AuthService } from './service/auth.service';

@Controller()
export class AuthController {
  @Inject(AuthService)
  private readonly service: AuthService;

  @GrpcMethod(AUTH_SERVICE_NAME, 'Register')
  private register(payload: RegisterRequestDto): Promise<RegisterResponse> {
    return this.service.register(payload);
  }

  @GrpcMethod(AUTH_SERVICE_NAME, 'Login')
  private login(payload: LoginRequestDto): Promise<LoginResponse> {
    return this.service.login(payload);
  }

  @GrpcMethod(AUTH_SERVICE_NAME, 'Validate')
  private validate(payload: ValidateRequestDto): Promise<ValidateResponse> {
    return this.service.validate(payload);
  }
}
Auth Module

Thay đổi code ở src/auth/auth.module.ts

   import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { Auth } from './auth.entity';
import { AuthService } from './service/auth.service';
import { JwtService } from './service/jwt.service';
import { JwtStrategy } from './strategy/jwt.strategy';

@Module({
  imports: [
    JwtModule.register({
      secret: 'dev',
      signOptions: { expiresIn: '365d' },
    }),
    TypeOrmModule.forFeature([Auth]),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtService, JwtStrategy],
})
export class AuthModule { }
App Module

Thay đổi code ở src/app.module.ts và tạo database với tên micro_auth nha, các bạn có thể sử dụng env config để thay thế các giá trị của database

   import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      database: 'micro_auth',
      username: 'admin',
      password: 'admin',
      entities: ['dist/**/*.entity.{ts,js}'],
      synchronize: true, // never true in production!
    }),
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }
Main

Thay đổi code ở main.ts

   import { INestMicroservice, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';
import { protobufPackage } from './auth/auth.pb';
import { HttpExceptionFilter } from './auth/filter/http-Exception.filter';

async function bootstrap() {
  const app: INestMicroservice = await NestFactory.createMicroservice(
    AppModule,
    {
      transport: Transport.GRPC,
      options: {
        url: '0.0.0.0:50051',
        package: protobufPackage,
        protoPath: join('proto/auth.proto'),
      },
    },
  );

  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  await app.listen();
}

bootstrap();

Xây dựng service Product

Trước tiên cũng sẽ bắt đầu bằng việc khởi tạo project Product Service bằng NestJS CLI

   nest new product-svc -p npm
Cài đặt các package
   npm i @nestjs/microservices @grpc/grpc-js @grpc/proto-loader @nestjs/typeorm typeorm pg class-transformer class-validator 
npm i -D @types/node ts-proto

Setup cấu trúc dự án

Copy folder proto ở project api-gateway vào folder của dự án

   Nest g mo product && Nest g co product --no-spec && Nest g s product --no-spec 
mkdir src/product/entity 
touch src/product/product.dto.ts 
touch src/product/entity/product.entity.ts 
touch src/product/entity/stock-decrease-log.entity.ts

Thêm script vào package.json để generate ra file .pb.ts tương tự như Authentication service

   "proto:product": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto -I=./proto --ts_proto_out=src/product/ proto/product.proto --ts_proto_opt=nestJs=true --ts_proto_opt=fileSuffix=.pb",

Chạy lệnh sau:

   npm run proto:product

Project của chúng ta sẽ trông như sau

Product Entity

Thêm code vào src/product/entity/product.entity.ts

   import {
  BaseEntity,
  Column,
  Entity,
  OneToMany,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { StockDecreaseLog } from './stock-decrease-log.entity';

@Entity()
export class Product extends BaseEntity {
  @PrimaryGeneratedColumn()
  public id!: number;

  @Column({ type: 'varchar' })
  public name!: string;

  @Column({ type: 'varchar' })
  public sku!: string;

  @Column({ type: 'integer' })
  public stock!: number;

  @Column({ type: 'decimal', precision: 12, scale: 2 })
  public price!: number;

  /*
   * One-To-Many Relationships
   */

  @OneToMany(
    () => StockDecreaseLog,
    (stockDecreaseLog) => stockDecreaseLog.product,
  )
  public stockDecreaseLogs: StockDecreaseLog[];
}
StockDecreaseLog Entity

Để đảm bảo tính nhất quán và tránh các lỗi không mong muốn khi giảm hàng tồn kho, chúng ta cần tạo Entity StockDecreaseLog. Thực thể này sẽ ghi lại mọi thao tác giảm hàng tồn kho, liên kết với Order IDProduct ID tương ứng. Việc này giúp chúng ta theo dõi chi tiết từng hành động giảm hàng và ngăn chặn các trường hợp giảm hàng trùng lặp do cùng một đơn hàng. Bằng cách lưu trữ thông tin này, hệ thống có thể duy trì tính toàn vẹn của dữ liệu ngay cả khi xảy ra các lỗi không thể đoán trước, đảm bảo rằng mỗi yêu cầu giảm hàng chỉ được thực hiện một lần duy nhất.

Thêm code vào src/product/entity/stock-decrease-log.entity.ts

   import {
  BaseEntity,
  Column,
  Entity,
  PrimaryGeneratedColumn,
  ManyToOne,
} from 'typeorm';
import { Product } from './product.entity';

@Entity()
export class StockDecreaseLog extends BaseEntity {
  @PrimaryGeneratedColumn()
  public id!: number;

  @Column({ type: 'integer' })
  public quantity!: number;

  /*
   * Relation IDs
   */

  @Column({ type: 'integer' })
  public orderId!: number;

  /*
   * Many-To-One Relationships
   */

  @ManyToOne(() => Product, (product) => product.stockDecreaseLogs)
  public product: Product;
}
Product DTO

Hãy thay đổi code ở src/product/product.dto.ts

   import { IsNotEmpty, IsNumber, IsString } from '@nestjs/class-validator';
import {
  CreateProductRequest,
  DecreaseStockRequest,
  FindOneRequest,
} from './product.pb';

export class FindOneRequestDto implements FindOneRequest {
  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly id: number;
}

export class CreateProductRequestDto implements CreateProductRequest {
  @IsString()
  @IsNotEmpty()
  public readonly name: string;

  @IsString()
  @IsNotEmpty()
  public readonly sku: string;

  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly stock: number;

  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly price: number;
}

export class DecreaseStockRequestDto implements DecreaseStockRequest {
  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly id: number;

  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly orderId: number;

  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly quantity: number;
}
Product Service

Thay đổi code ở src/product/product.service.ts

   import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './entity/product.entity';
import {
  CreateProductRequestDto,
  DecreaseStockRequestDto,
  FindOneRequestDto,
} from './product.dto';
import {
  CreateProductResponse,
  DecreaseStockResponse,
  FindOneResponse,
} from './product.pb';
import { StockDecreaseLog } from './entity/stock-decrease-log.entity';

@Injectable()
export class ProductService {
  @InjectRepository(Product)
  private readonly repository: Repository<Product>;

  @InjectRepository(StockDecreaseLog)
  private readonly decreaseLogRepository: Repository<StockDecreaseLog>;

  public async findOne({ id }: FindOneRequestDto): Promise<FindOneResponse> {
    const product: Product = await this.repository.findOne({ where: { id } });

    if (!product) {
      return {
        data: null,
        error: ['Product not found'],
        status: HttpStatus.NOT_FOUND,
      };
    }

    return { data: product, error: null, status: HttpStatus.OK };
  }

  public async createProduct(
    payload: CreateProductRequestDto,
  ): Promise<CreateProductResponse> {
    const product: Product = new Product();

    product.name = payload.name;
    product.sku = payload.sku;
    product.stock = payload.stock;
    product.price = payload.price;

    await this.repository.save(product);

    return { id: product.id, error: null, status: HttpStatus.OK };
  }

  public async decreaseStock({
    id,
    orderId,
    quantity,
  }: DecreaseStockRequestDto): Promise<DecreaseStockResponse> {
    const product: Product = await this.repository.findOne({
      select: ['id', 'stock'],
      where: { id },
    });

    if (!product) {
      return { error: ['Product not found'], status: HttpStatus.NOT_FOUND };
    } else if (product.stock <= 0) {
      return { error: ['Stock too low'], status: HttpStatus.CONFLICT };
    }

    const isAlreadyDecreased: number = await this.decreaseLogRepository.count({
      where: { orderId },
    });

    if (isAlreadyDecreased) {
      // Idempotence
      return {
        error: ['Stock already decreased'],
        status: HttpStatus.CONFLICT,
      };
    }

    await this.repository.update(product.id, {
      stock: product.stock - quantity,
    });
    await this.decreaseLogRepository.insert({ product, orderId, quantity });

    return { error: null, status: HttpStatus.OK };
  }
}
Product Controller

Thay đổi file src/product/product.controller.ts

   import { Controller, Inject } from '@nestjs/common';
import {
  CreateProductRequestDto,
  DecreaseStockRequestDto,
  FindOneRequestDto,
} from './product.dto';
import {
  CreateProductResponse,
  DecreaseStockResponse,
  FindOneResponse,
  PRODUCT_SERVICE_NAME,
} from './product.pb';
import { ProductService } from './product.service';
import { GrpcMethod } from '@nestjs/microservices';

@Controller()
export class ProductController {
  @Inject(ProductService)
  private readonly service: ProductService;

  @GrpcMethod(PRODUCT_SERVICE_NAME, 'FindOne')
  private findOne(payload: FindOneRequestDto): Promise<FindOneResponse> {
    return this.service.findOne(payload);
  }

  @GrpcMethod(PRODUCT_SERVICE_NAME, 'CreateProduct')
  private createProduct(
    payload: CreateProductRequestDto,
  ): Promise<CreateProductResponse> {
    return this.service.createProduct(payload);
  }

  @GrpcMethod(PRODUCT_SERVICE_NAME, 'DecreaseStock')
  private decreaseStock(
    payload: DecreaseStockRequestDto,
  ): Promise<DecreaseStockResponse> {
    return this.service.decreaseStock(payload);
  }
}
Product Module

Thay đổi file src/product/product.module.ts

   import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductController } from './product.controller';
import { ProductService } from './product.service';
import { Product } from './entity/product.entity';
import { StockDecreaseLog } from './entity/stock-decrease-log.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Product, StockDecreaseLog])],
  controllers: [ProductController],
  providers: [ProductService],
})
export class ProductModule { }
App Module

Thay đổi nội dung src/app.module.ts và tạo database với tên micro_product

   import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ProductModule } from './product/product.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      database: 'micro_product',
      username: 'admin',
      password: 'admin',
      entities: ['dist/**/*.entity.{ts,js}'],
      synchronize: true, // never true in production!
    }),
    ProductModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }
Main

Thay đổi nội dung main.ts

   import { INestMicroservice, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';
import { protobufPackage } from './product/product.pb';

async function bootstrap() {
  const app: INestMicroservice = await NestFactory.createMicroservice(
    AppModule,
    {
      transport: Transport.GRPC,
      options: {
        url: '0.0.0.0:50053',
        package: protobufPackage,
        protoPath: join('proto/product.proto'),
      },
    },
  );

  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  await app.listen();
}

bootstrap();

Xây dựng service Order

Trước tiên cũng sẽ bắt đầu bằng việc khởi tạo project Order Service bằng NestJS CLI

   nest new order-svc -p npm
Cài đặt các package
   npm i @nestjs/microservices @grpc/grpc-js @grpc/proto-loader @nestjs/typeorm typeorm pg class-transformer class-validator 
npm i -D @types/node ts-proto
Setup cấu trúc dự án

Copy folder proto ở project api-gateway vào folder của dự án

   Nest g mo order && Nest g co order --no-spec && Nest g s order --no-spec 
mkdir src/order/proto 
touch src/order/order.dto.ts 
touch src/order/order.entity.ts

Thêm script vào package.json để generate ra file .pb.ts tương tự như Product service

   "proto:order": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto -I=./proto --ts_proto_out=src/order/proto/ proto/order.proto --ts_proto_opt=nestJs=true --ts_proto_opt=fileSuffix=.pb",
"proto:product": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto -I=./proto --ts_proto_out=src/order/proto/ proto/product.proto --ts_proto_opt=nestJs=true --ts_proto_opt=fileSuffix=.pb",

Chạy script:

   npm run proto:order
npm run proto:product

Service Order có sử dụng dữ liệu của service Product thông qua kết nối gRPC nên cần generate ra file product.pb.ts

Project của chúng ta sẽ như sau

Order Entity

Hãy thêm code vào src/order/order.entity.ts

   import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Order extends BaseEntity {
  @PrimaryGeneratedColumn()
  public id!: number;

  @Column({ type: 'decimal', precision: 12, scale: 2 })
  public price!: number;

  @Column({ type: 'integer' })
  public quantity!: number;

  /*
   * Relation IDs
   */

  @Column({ type: 'integer' })
  public productId!: number;

  @Column({ type: 'integer' })
  public userId!: number;
}
Order DTO

Hãy thêm code vào src/order/order.dto.ts

   import { IsNumber, Min } from '@nestjs/class-validator';
import { CreateOrderRequest } from './proto/order.pb';

export class CreateOrderRequestDto implements CreateOrderRequest {
  @IsNumber()
  public productId: number;

  @IsNumber()
  @Min(1)
  public quantity: number;

  @IsNumber()
  public userId: number;
}
Order Service

Order Service trong hệ thống của chúng ta có một vai trò đặc biệt, vì đây là lần đầu tiên chúng ta thực hiện cuộc gọi từ một microservice này đến một microservice khác. Cụ thể, Order Service sẽ tương tác với Product Microservice hai lần trong quá trình xử lý đơn hàng. Đầu tiên, nó sẽ kiểm tra tính khả dụng của sản phẩm bằng cách xác nhận rằng sản phẩm vẫn còn tồn kho. Sau khi đảm bảo sản phẩm tồn tại và có đủ hàng, Order Service sẽ tiếp tục gọi đến Product Microservice để giảm số lượng hàng tồn kho tương ứng với đơn đặt hàng. Bằng cách này, chúng ta không chỉ đảm bảo rằng đơn hàng được xử lý chính xác mà còn duy trì sự nhất quán trong việc quản lý tồn kho.

Thay đổi file src/order/order.service.ts

   import { HttpStatus, Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ClientGrpc } from '@nestjs/microservices';
import { Repository } from 'typeorm';
import { firstValueFrom } from 'rxjs';
import { Order } from './order.entity';
import {
  FindOneResponse,
  DecreaseStockResponse,
  ProductServiceClient,
  PRODUCT_SERVICE_NAME,
} from './proto/product.pb';
import { CreateOrderRequest, CreateOrderResponse } from './proto/order.pb';

@Injectable()
export class OrderService implements OnModuleInit {
  private productSvc: ProductServiceClient;

  @Inject(PRODUCT_SERVICE_NAME)
  private readonly client: ClientGrpc;

  @InjectRepository(Order)
  private readonly repository: Repository<Order>;

  public onModuleInit(): void {
    this.productSvc =
      this.client.getService<ProductServiceClient>(PRODUCT_SERVICE_NAME);
  }

  public async createOrder(
    data: CreateOrderRequest,
  ): Promise<CreateOrderResponse> {
    const product: FindOneResponse = await firstValueFrom(
      this.productSvc.findOne({ id: data.productId }),
    );

    if (product.status >= HttpStatus.NOT_FOUND) {
      return { id: null, error: ['Product not found'], status: product.status };
    } else if (product.data.stock < data.quantity) {
      return {
        id: null,
        error: ['Stock too less'],
        status: HttpStatus.CONFLICT,
      };
    }

    const order: Order = new Order();

    order.price = product.data.price;
    order.quantity = data.quantity;
    order.productId = product.data.id;
    order.userId = data.userId;

    await this.repository.save(order);

    const decreasedStockData: DecreaseStockResponse = await firstValueFrom(
      this.productSvc.decreaseStock({
        id: data.productId,
        orderId: order.id,
        quantity: data.quantity,
      }),
    );

    if (decreasedStockData.status === HttpStatus.CONFLICT) {
      // deleting order if decreaseStock fails
      await this.repository.delete(order.id);

      return {
        id: null,
        error: decreasedStockData.error,
        status: HttpStatus.CONFLICT,
      };
    }

    return { id: order.id, error: null, status: HttpStatus.OK };
  }
}
Order Controller

Thay đổi src/order/order.controller.ts

   import { Controller, Inject } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { OrderService } from './order.service';
import { ORDER_SERVICE_NAME, CreateOrderResponse } from './proto/order.pb';
import { CreateOrderRequestDto } from './order.dto';

@Controller('order')
export class OrderController {
  @Inject(OrderService)
  private readonly service: OrderService;

  @GrpcMethod(ORDER_SERVICE_NAME, 'CreateOrder')
  private async createOrder(
    data: CreateOrderRequestDto,
  ): Promise<CreateOrderResponse> {
    return this.service.createOrder(data);
  }
}
Order Module

Thay đổi src/order/order.module.ts

   import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrderController } from './order.controller';
import { Order } from './order.entity';
import { OrderService } from './order.service';
import { PRODUCT_SERVICE_NAME, PRODUCT_PACKAGE_NAME } from './proto/product.pb';

@Module({
  imports: [
    ClientsModule.register([
      {
        name: PRODUCT_SERVICE_NAME,
        transport: Transport.GRPC,
        options: {
          url: '0.0.0.0:50053',
          package: PRODUCT_PACKAGE_NAME,
          protoPath: 'proto/product.proto',
        },
      },
    ]),
    TypeOrmModule.forFeature([Order]),
  ],
  controllers: [OrderController],
  providers: [OrderService],
})
export class OrderModule { }
App Module

Thay đổi file src/app.module.ts và tạo database với tên micro_order

   import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { OrderModule } from './order/order.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      database: 'micro_order',
      username: 'admin',
      password: 'admin',
      entities: ['dist/**/*.entity.{ts,js}'],
      synchronize: true, // never true in production!
    }),
    OrderModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }
main.ts

Thay đổi main.ts

   import { INestMicroservice, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';
import { protobufPackage } from './order/proto/order.pb';

async function bootstrap() {
  const app: INestMicroservice = await NestFactory.createMicroservice(
    AppModule,
    {
      transport: Transport.GRPC,
      options: {
        url: '0.0.0.0:50052',
        package: protobufPackage,
        protoPath: join('proto/order.proto'),
      },
    },
  );

  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  await app.listen();
}

bootstrap();

Tất cả code đã hoàn thành mình giải thích 1 chút nhé.

Trong hệ thống microservices của chúng ta, mỗi dịch vụ đều có vai trò cụ thể và chạy trên các cổng khác nhau: Authentication service tại cổng 50051, Order service tại cổng 50052, và Product service tại cổng 50053. Mỗi dịch vụ thực hiện các nhiệm vụ riêng biệt và đều được quản lý thông qua API Gateway. Tuy nhiên, sự linh hoạt không dừng lại ở đó - các dịch vụ này cũng có khả năng giao tiếp trực tiếp với nhau. Chẳng hạn, Order service có thể gọi đến Product service để kiểm tra tồn kho hoặc cập nhật số lượng sản phẩm một cách hiệu quả và nhanh chóng thông qua giao thức gRPC.

Nhờ kiến trúc này, chúng ta có thể triển khai từng dịch vụ trên các máy chủ riêng biệt, dễ dàng mở rộng hệ thống khi cần mà không gây ra gián đoạn cho các dịch vụ khác. Điều này không chỉ tăng cường hiệu suất và khả năng mở rộng mà còn đảm bảo tính độc lập và tính liên tục của mỗi dịch vụ trong hệ thống microservices.

Kiểm tra và chạy thử

Chúng ta vào các service API Gateway, Authentication, Order và Product và run tất cả lên

   // API Gateway
pnpm run start:dev

// Authentication, product, Order
npm run start:dev

Tiếp theo chúng ta vào Postman và chạy thử API Register

Như vậy đã tạo được account, kiểm tra lại database micro_auth để xem record đã được thêm vào

Tương tự với các API: Login, Create product, Find product và Create order các bạn có thể tham khảo ở Postman collection mình đã tạo sẵn ^^

Trên đây là toàn bộ demo về xây dựng hệ thống Microservice API Gateway với NestJS và kết nối gRPC. Trong thực tế, còn nhiều yếu tố bổ sung như Cache, Message Queue, Scale service, Monitoring và Logging, Security…

Hy vọng các bạn sẽ yêu thích series này và nếu có cơ hội, mình sẽ viết một bài về tạo service gửi email và giao tiếp thông qua RabbitMQ (message queue).

Code tham khảo: https://github.com/duy-lee/nestjs-grpc-microservice