nestjs微服务-系列1

23 阅读3分钟

nestjs微服务搭建

对于 nestjs 的微服务项目,官方文档并不是很好理解,而且比较简单,因此想从零到一搭建一个完整的 nestjs 微服务,通过 gRPC进行服务调用, 利用 consul进行微服务的注册与发现。

为了方便管理,本项目采用 Monorepo的方式组织代码。

整体介绍

项目结构

.
├── proto  # 存储proto文件
├── scripts # 存储服务脚本
├── consul
│   ├── config
│   │   └── consul.hcl  # consul配置文件
│   ├── data
│   └── docker-compose.yml
├── apps
│   ├── gateway # 网关服务
│   │   ├── src
│   │   │   ├── app.module.ts
│   │   │   └── main.ts
│   │   ├── test
│   │   │   ├── app.e2e-spec.ts
│   │   │   └── jest-e2e.json
│   │   └── tsconfig.app.json
│   ├── order-service  # 订单服务
│   │   ├── src
│   │   │   ├── app.module.ts
│   │   │   ├── main.ts
│   │   │   └── user
│   │   │       ├── user.controller.ts
│   │   │       ├── user.module.ts
│   │   │       └── user.service.ts
│   │   ├── test
│   │   │   ├── app.e2e-spec.ts
│   │   │   └── jest-e2e.json
│   │   └── tsconfig.app.json
│   └── user-service  # 用户服务
│       ├── src
│       │   ├── app.module.ts
│       │   ├── main.ts
│       │   └── user
│       │       ├── user.controller.ts
│       │       ├── user.module.ts
│       │       └── user.service.ts
│       ├── test
│       │   ├── app.e2e-spec.ts
│       │   └── jest-e2e.json
│       └── tsconfig.app.json
├── eslint.config.mjs
├── nest-cli.json
├── package-lock.json
├── package.json
├── README.md
├── tsconfig.build.json
└── tsconfig.json

调用流程

HTTP 网关(或 BFF 层)」作为统一入口,接收前端请求,然后它通过 gRPC 调用各个后端微服务(如user-service, order-service),拿到结果后统一封装返回给客户端。

浏览器 / App
     ↓ HTTP 请求
┌──────────────────┐
│ API Gateway (HTTP)│ ← NestJS,使用 Controller
└──────────────────┘
     ↓ 调用 gRPC 微服务
 ┌────────────┐    ┌──────────────┐
 │ user-svc   │    │ order-svc    │
 └────────────┘    └──────────────┘

初始化创建

首相创建项目基本结构,并下载相关依赖包。

nest new micro-service  # 创建一个项目
cd micro-service
nest generate app user-service --no-spec 		  # 创建 user 服务

修改项目中的 nest-cli.json文件:

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "apps",
  "root": ".",
  "compilerOptions": {
    "deleteOutDir": true,
    "webpack": true
  },
  "monorepo": true,
  "projects": {
    "gateway": {
      "type": "application",
      "root": "apps/gateway",
      "entryFile": "main",
      "sourceRoot": "apps/gateway/src",
      "compilerOptions": {
        "tsConfigPath": "apps/gateway/tsconfig.app.json"
      }
    }
  }
}
nest generate app order-service --no-spec 		# 创建 order 服务
nest generate app user-service --no-spec 		# 创建 order 服务 
# proto
mkdir proto scripts

执行上面命令,总共创建三个服务

  • gateway 服务:API网关层, 负责接收外部请求,可以统一校验Token、OAuth等身份认证信息,并根据用户角色或权限对访问进行控制。
  • user-service 服务:用户相关的服务
  • order-service 服务:订单相关的服务
  • proto
  • scripts

调整项目结构,在每个微服务下面创建一个 app.module.ts, 然后创建对应的 resource

    nest g res user --project user-service --no-spec
    nest g res order --project order-service --no-spec

最终项目结构应和上述给出的结构一致。

修改服务端口号,此项目分别为:

  • gateway: 3000
  • user-service: 3001
  • order-service: 3002

运行

分别执行下列命令启动项目:

npm run start:dev gateway
npm run start:dev user-service
npm run start:dev order-service

github.com/0x0bit/nest…

微服务改造

安装依赖包

npm i --save @nestjs/microservices  # nestjs微服务包
npm i --save @grpc/grpc-js @grpc/proto-loader # grpc相关的包
npm i --save @grpc/reflection # 让 gRPC 服务具有自描述能力,便于调试和动态客户端接入,类似 REST 中的 Swagger。
npm install -D ts-proto # 用于通过 proto 文件生成 ts 

增加proto文件

在增加 proto 文件之前,首先 在nest-cli.json进行配置,添加assets允许分发非 TypeScript 文件的属性,并watchAssets启用了对所有非 TypeScript 资源的监控。在本例中,希望.proto文件自动复制到该dist文件夹

{
  "compilerOptions": {
    "assets": ["**/*.proto"],
    "watchAssets": true
  }
}

user.proto

syntax = "proto3";

package user;

service UserService {
  rpc Create (User) returns (CreateOrUpdateResponse);
  rpc FindAll (GetUserRequest) returns (GetUserResponse);
  rpc FindOne (GetUserByIdRequest) returns (GetUserByIdResponse);
  rpc Update (UpdateUserRequest) returns (CreateOrUpdateResponse);
  rpc Remove (RemoveUserByIdRequest) returns (RemoveUserByIdResponse);
}

message CreateOrUpdateResponse {
  User user = 1;
}

message GetUserRequest {}
message GetUserResponse {
  repeated User users = 1;
}

message GetUserByIdRequest {
  int32 id = 1;
}
message GetUserByIdResponse {
  User user = 1;
}

message UpdateUserRequest {
  int32 id = 1;
  UpdateUserPayload payload = 2;
}

message UpdateUserPayload {
  optional string password = 1;
  optional string name = 2;
}

message RemoveUserByIdRequest {
  int32 id = 1;
}
message RemoveUserByIdResponse {
  User user = 1;
}

message User {
  int32 id = 1;
  string username = 2;
  string password = 3;
  string name = 4;
  int32 age = 5;
}

order.proto


syntax = "proto3";

package order;

service OrderService {
  rpc Create(Order) returns (CreateOrderResponse);
  rpc FindOrderByUser(GetOrderByUserIdRequest) returns (GetOrderByUserIdResponse);
}

message CreateOrderResponse {
  Order order = 1;
}

message GetOrderByUserIdRequest {
  int32 id = 1;
}
message GetOrderByUserIdResponse {
  repeated Order order = 1;
}

//枚举消息类型,使用enum关键词定义
enum OederStatus {
    UNKNOWN = 0; // proto3版本中,首成员必须为0,默认值,通常表示未知或未指定 
    DOING = 1;
    CACEL = 2;
    DONE = 3;
    REFUND = 4;
};

message Order {
  int32 id = 1;
  string Order = 2;
  OederStatus status = 3;
}

proto文件生成 ts 接口

  1. proto文件夹下创建 generated文件夹。
  2. 在项目scripts文件夹,增加 proto 转 ts 文件的脚本 gen-proto.ts

import { execSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';

const protoDir = path.resolve(__dirname, '../proto');
const outputDir = path.resolve(__dirname, '../proto/generated');
const grpcMetadataParams = true;

// 自动查找 proto 目录下所有 .proto 文件
const protoFiles = fs.readdirSync(protoDir)
  .filter(file => file.endsWith('.proto'))
  .map(file => path.join(protoDir, file))
  .join(' ');

// ts-proto 插件位置 
// ts-proto关于 Nest的扩展文档 https://github.com/stephenh/ts-proto/blob/main/NESTJS.markdown
const pluginPath = path.resolve(__dirname, '../node_modules/.bin/protoc-gen-ts_proto');

// ts-proto 的生成参数
const tsProtoOptions = [
  'nestJs=true',  // 生成符合 NestJS 风格的 interface 和服务定义(如 @nestjs/microservices 用法)
  'outputClientImpl=false', // 表示使用 grpc-web 风格的 client
  'outputEncodeMethods=false', // 不生成 encode 方法(如 MyMessage.encode())
  'outputJsonMethods=false', // 不生成 fromJSON, toJSON 方法
  'outputPartialMethods=false', // 允许从一个部分字段的对象快速构造出一个完整合法的 protobuf 消息对象,自动为缺失的字段填上默认值
  'outputTypeRegistry=false', // 不生成类型注册代码
  'addNestjsRestParameter=true', // 服务方法的最后一个参数将是一个任意类型的剩余参数
].join(',');

// 最终命令
const cmd = [
  'protoc',
  `--plugin=${pluginPath}`,
  `--ts_proto_opt=addGrpcMetadata=${grpcMetadataParams}`,  //是否生成grpc调用时设置metadata的形参
  `--ts_proto_out=${outputDir}`,
  `--ts_proto_opt=${tsProtoOptions}`,
  `--proto_path=${protoDir}`,
  protoFiles
].join(' ');

console.log('Executing:', cmd);
execSync(cmd, { stdio: 'inherit' });

fs.readdirSync(outputDir)
  .filter(f => f.endsWith('.ts') && !f.endsWith('.interface.ts'))
  .forEach(file => {
    const src = path.join(outputDir, file);
    const dest = path.join(outputDir, file.replace(/.ts$/, '.interface.ts'));
    fs.renameSync(src, dest);
    console.log(`✅ 重命名 ${file}${path.basename(dest)}`);
  });
  1. package.json文件中,增加 proto 文件转 ts 文件的运行命令,并执行命令生成 ts 文件。
{
  "scripts": {
     "gen:proto": "ts-node scripts/gen-proto.ts"
  }
}
  1. 运行生成 ts 文件
npm run gen:proto

git仓库源码

实现

main函数改造

项目入口文件 main.ts 的书写方式有两种,方式一如下:

import { NestFactory } from "@nestjs/core";
import { MicroserviceOptions, Transport } from "@nestjs/microservices";
import { AppModule } from "./app.module";
import { USER_PACKAGE_NAME } from "proto/generated/user.interface";
import { join } from "path";
import { ReflectionService } from "@grpc/reflection";

async function bootstrap() {
  const port = 3001;
  const app = await NestFactory.create(AppModule);
  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.GRPC,
    options: {
      url: `0.0.0.0:${port}`,
      package: USER_PACKAGE_NAME,
      protoPath: join(__dirname, '../../../proto/user.proto'),
      onLoadPackageDefinition: (pkg, server) => {
        new ReflectionService(pkg).addToServer(server);
      }
    }
  })
  await app.startAllMicroservices();
  await app.listen(port);
  console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();

方式二:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Transport } from '@nestjs/microservices';
import { ORDER_PACKAGE_NAME } from 'proto/generated/order.interface';
import { join } from 'path';
import { ReflectionService } from '@grpc/reflection';

async function bootstrap() {
  const port = 3002;
  const grpcUrl = `0.0.0.0:${port}`;
  const app = await NestFactory.createMicroservice(AppModule, {
    transport: Transport.GRPC,
    options: {
      url: grpcUrl, //表示 gRPC 服务器监听的地址和端口。
      package: ORDER_PACKAGE_NAME,
      protoPath: join(__dirname, '../../../proto/order.proto'),
      onLoadPackageDefinition: (pkg, server) => {
        new ReflectionService(pkg).addToServer(server);
      }
    }
  });
  console.log(`Application is running on: ${grpcUrl}`);
}
bootstrap();

主要区别在于方式一先是创建一个 http 服务,在挂载一个微服务,也就是同时支持 http 服务和微服务两种方式调用。而方式二只支持微服务调用。

proto中的调用方法逻辑实现

这里就是具体我们要做的业务逻辑实现了

User.controller.ts中,实现 user.proto 定义的 4 个增删改查的方法。下面只是例子,需要注意的是,实现的方法需要使用 GrpcMethod装饰器进行装饰,第一个参数是在 proto 中 service 后定义的这个值

第二个参数为方法名,如何 proto 定义的方法名称和实现的方法名称一样,可以不写。

export class UserController {
  constructor(private readonly userService: UserService) {}

  @GrpcMethod('UserService')
  Create(@Payload() createUserDto: User) {
    const user = this.userService.create(createUserDto);
    return { user };
  }

  @GrpcMethod('UserService''Update')
  Update(@Payload() updateUserDto: UpdateUserRequest) {
    const user = this.userService.update(
      updateUserDto.id,
      updateUserDto.payload!,
    );
    return { user };
  }

而其中的 User, UpdateUserRequest都是从 proto 生成的 ts 文件中进行引入的。 git仓库源码

gateway 调用

首先在 gateway 中,创建 user module.

nest g res user --project gateway --no-spec 

然后在 user.module.ts中配置需要调用的微服务

import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { ORDER_PACKAGE_NAME } from '../../../../proto/generated/order.interface';
import { USER_PACKAGE_NAME } from '../../../../proto/generated/user.interface';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [
    ClientsModule.register([
      {
        name: USER_PACKAGE_NAME,
        transport: Transport.GRPC,
        options: {
          url: '0.0.0.0:3001',
          package: USER_PACKAGE_NAME,
          protoPath: join(__dirname, '../../../proto/user.proto'),
        },
      },
      {
        name: ORDER_PACKAGE_NAME,
        transport: Transport.GRPC,
        options: {
          url: '0.0.0.0:3002',
          package: ORDER_PACKAGE_NAME,
          protoPath: join(__dirname, '../../../proto/order.proto'),
        },
      },
    ]),
  ],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

然后在 user.service.ts中进行服务初始化和调用

import { Metadata } from '@grpc/grpc-js';
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
import {
  ORDER_PACKAGE_NAME,
  ORDER_SERVICE_NAME,
  OrderServiceClient,
} from '../../../../proto/generated/order.interface';
import {
  USER_PACKAGE_NAME,
  USER_SERVICE_NAME,
  UserServiceClient,
} from '../../../../proto/generated/user.interface';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UserService implements OnModuleInit {
  private userService: UserServiceClient;
  private orderService: OrderServiceClient;

  constructor(
    @Inject(USER_PACKAGE_NAME) private readonly userClient: ClientGrpc,
    @Inject(ORDER_PACKAGE_NAME) private readonly orderClient: ClientGrpc,
  ) {}

  onModuleInit() {
    this.userService = this.userClient.getService<UserServiceClient>(USER_SERVICE_NAME);
    this.orderService = this.orderClient.getService<OrderServiceClient>(ORDER_SERVICE_NAME);
  }

  async create(createUserDto: CreateUserDto) {
    const user = this.userService.create(createUserDto);
    return await firstValueFrom(user);
  }

  async findAll() {
    const users = this.userService.findAll({});
    return await firstValueFrom(users);
  }

  async findOne(id: number) {
    const metadata = new Metadata();
    metadata.add('key', 'w3423');
    const user = this.userService.findOne({ id: +id }, metadata);
    return await firstValueFrom(user);
  }

  async update(id: number, updateUserDto: UpdateUserDto) {
    const user = this.userService.update({ id, payload: updateUserDto });
    return await firstValueFrom(user);
  }

  async remove(id: number) {
    const user = this.userService.remove({ id });
    return await firstValueFrom(user);
  }

  async getUserOrder(id: number) {
    const order = this.orderService.findOrderByUser({ id });
    return await firstValueFrom(order);
  }
}

需要注意的是,微服务返回的是一个 Observable,所以需要将返回的结果调用 firstValueFrom方法进行转换成对象。

git仓库源码

系列文章2:juejin.cn/post/752270…