Nest.js、gRPC 和 Emp.js 构建高性能的微前端和微服务架构-Server端

722 阅读22分钟

image.png 系列文章传送门:

gRPC 概念

1.什么是gRPC

gRPC(gRPC Remote Procedure Call)是一种高性能的远程过程调用(RPC)框架,由Google开发并开源。它允许客户端和服务器之间通过定义服务接口和消息格式进行通信,实现跨语言和跨平台的远程调用。

image.png

gRPC使用Google的Protocol Buffers(protobuf)作为默认的接口定义语言和数据序列化机制。Protocol Buffers是一种轻量级、高效的二进制序列化格式,可以定义结构化数据的消息类型和字段,并生成相应的代码用于序列化、反序列化和访问数据。

以下是gRPC的一些关键特点和优势:

  1. 高性能:gRPC使用基于HTTP/2协议的双向流式传输,支持多路复用和连接复用,减少了网络开销。它还使用Protocol Buffers进行高效的序列化和反序列化,提供了快速且紧凑的数据传输。
  2. 跨语言和跨平台:gRPC支持多种编程语言,包括Java、C++、Go、Python、Node.js等,使得不同语言的应用程序能够无缝地进行通信和交互。此外,它还支持在不同平台之间进行通信,例如在移动设备和服务器之间进行跨平台的RPC调用。
  3. 自动生成代码:gRPC提供了强大的代码生成工具,可以根据定义的服务接口和消息格式自动生成客户端和服务器端的代码。这简化了开发过程,减少了手动编写和维护大量重复代码的工作量。
  4. 强类型约束:通过使用Protocol Buffers定义接口和消息格式,gRPC提供了强类型约束,确保了数据的一致性和可靠性。它在编译时进行类型检查,避免了在运行时出现类型不匹配的错误。
  5. 支持多种调用方式:gRPC支持多种调用方式,包括简单的请求-响应模式、流模式、客户端流和服务器流模式,以及双向流模式。这使得我们能够根据具体需求选择适合的调用方式,实现灵活和高效的通信方式。
  6. 可插拔的拦截器和中间件:gRPC提供了可插拔的拦截器和中间件机制,可以在请求和响应的处理过程中添加自定义的逻辑。这使得我们能够在不修改现有代码的情况下,实现各种功能,如身份验证、日志记录、错误处理等。

gRPC广泛应用于分布式系统、微服务架构和客户端-服务器应用程序的开发中,为不同服务提供了一种高效、可靠和可扩展的通信方式。它提供了简单易用的API和丰富的功能,使得我们 能够快速构建出高性能的分布式应用。

2.gRPC使用什么协议

从上面了解到gRPC 使用基于 HTTP/2 的传输协议进行通信。HTTP/2 是一种先进的网络协议,它在性能和效率方面相较于传统的 HTTP/1.1 有显著的改进。

以下是 gRPC 使用 HTTP/2 协议的一些关键特点:

  1. 双向流式传输:HTTP/2 支持双向流式传输,客户端和服务器可以同时发送多个请求和响应,而不需要按顺序等待。这种能力使得 gRPC 可以实现高效的请求和响应并发处理,提高了通信的效率。
  2. 多路复用:HTTP/2 使用二进制帧进行传输,这意味着多个请求和响应可以同时通过单个 TCP 连接进行传输。这种多路复用的机制减少了连接建立的开销,节省了网络资源,并提高了整体的性能。
  3. 首部压缩:HTTP/2 使用首部压缩算法,减少了请求和响应的头部信息的大小,降低了网络传输的开销。这对于 gRPC 的高效性很关键,因为 gRPC 的消息通常是小而频繁的。
  4. 服务端推送:HTTP/2 支持服务端推送(Server Push),服务器可以在客户端请求之前主动推送相关的资源。这对于 gRPC 可以提高请求-响应模式下的效率,减少了额外的请求延迟。
  5. 流量控制:HTTP/2 提供了流量控制机制,可以根据接收方的处理能力进行流量控制,防止过载和资源浪费。这对于 gRPC 可以确保在高负载情况下仍然保持稳定的性能。

通过使用 HTTP/2 协议,gRPC 实现了高效的、双向的、流式的通信机制,使得不同服务之间的远程调用更加高性能和可靠。同时,它还提供了与现有的 HTTP 生态系统的兼容性,使得 gRPC 可以方便地与其他基于 HTTP 的工具和框架集成。

3.gRPC的模式

gRPC 支持多种通信模式,以满足不同的应用需求。以下是 gRPC 支持的几种常见模式:

3.1 简单的请求-响应模式

这是最常见的 gRPC 模式,客户端发送一个请求到服务器端,并等待服务器返回一个响应。这种模式类似于传统的函数调用,适用于一次请求对应一次响应的场景。

rpc SayHello(HelloRequest) returns (HelloResponse){ }

3.2 服务器流模式

在这种模式下,客户端发送一个请求到服务器端,然后服务器端会以流的形式返回多个响应给客户端。客户端可以逐个接收并处理响应,直到服务器端发送完所有的响应。这种模式适用于需要一次请求获取多个连续响应的场景。

// 注意stream关键词
rpc SayHello(HelloRequest) returns (stream HelloResponse){ }

3.3 客户端流模式

在这种模式下,客户端将多个请求消息写入一个流,并发送给服务器端。服务器端接收并处理这些请求消息,并在处理完所有请求后发送一个单一的响应给客户端。这种模式适用于需要客户端发送多个请求并最终获取一个响应的场景。

// 注意stream关键词在
rpc SayHello(stream HelloRequest) returns (HelloResponse) { }

3.4 双向流模式(Bidirectional Streaming RPC)

在这种模式下,客户端和服务器端都可以同时发送和接收流式的请求和响应。客户端和服务器端之间建立一个双向的流,可以独立地发送和接收多个消息。这种模式适用于需要客户端和服务器端之间进行交互和通信的场景。

// 注意stream关键词
rpc SayHello(stream HelloRequest) returns (stream HelloResponse){ }

通过支持不同的通信模式,gRPC 能够适应各种复杂的应用场景,并提供灵活且高效的远程调用机制。我们可以根据具体需求选择合适的模式来实现所需的功能和性能。

4.ProtoBuf

4.1 ProtoBuf/Protoc/proto 分别是啥

ProtoBuf(Protocol Buffers)是一种由 Google 开发的轻量级、高效的数据序列化格式。它用于定义结构化数据的消息类型和字段,并生成相应的代码用于序列化、反序列化和访问数据。ProtoBuf 提供了跨平台、跨语言的数据交换格式,可用于不同系统和编程语言之间的数据通信。

Protoc(Protocol Compiler)是 ProtoBuf 的编译器,用于将定义的 .proto 文件转换为特定编程语言的源代码。它接受 .proto 文件作为输入,根据定义的消息类型和字段生成相应的代码文件,其中包括序列化、反序列化和访问数据所需的类、结构体或接口等。

.proto 文件是用于定义消息类型和字段的文件,它遵循 ProtoBuf 的语法规则。在 .proto 文件中,我们可以定义消息的结构、字段的数据类型、枚举类型等。通过编译器 Protoc,.proto 文件可以被转换为不同编程语言的源代码,使我们能够在自己选择的编程语言中使用定义的消息类型和字段。

使用 ProtoBuf 和 Protoc,我们可以根据自己的需求定义结构化数据的消息格式,并通过生成的代码实现数据的序列化、反序列化和访问。这使得数据的传输和存储更加高效、可靠,并能够实现跨语言和跨平台的数据交换。

4.2 Proto3 的新特性和语法改进

Proto3 是 Protocol Buffers 的第三个版本,相比于之前的版本(Proto2),它引入了一些新的特性和语法改进。下面是 Proto3 的一些主要特点:

  1. 简化的语法:Proto3 采用了更加简洁和直观的语法,去除了一些复杂的特性和语法元素。例如,Proto3 不再支持 required 字段,将所有字段都默认为可选字段。此外,不再需要使用 optional 关键字,只需直接定义字段即可。

  2. 默认值:Proto3 引入了字段的默认值的概念。在定义消息类型的字段时,可以为字段指定一个默认值,当消息中的字段未设置值时,将使用该默认值。这样可以简化消息的定义和使用,并减少了对可选字段的使用。

  3. 字段规则变更:Proto3 引入了一些新的字段规则,用于更好地支持扩展性和向后兼容性。其中包括:

    • repeated:用于定义重复出现的字段,类似于之前的 repeated 关键字,但不再需要使用 packed 关键字进行压缩,默认情况下字段是压缩的。
    • map:新增了 map 类型,用于定义键值对数据结构。可以将一个字段定义为 map 类型,指定键和值的数据类型,并在消息中使用类似于字典的语法进行操作。
  4. 删除字段:Proto3 禁止删除或重命名已定义的字段,以保持向后兼容性。这意味着在 Proto3 中,字段的标识符一旦定义,将无法更改或删除。如果需要废弃或替换字段,可以使用预留字段(reserved)来标记。

  5. 更严格的语法:Proto3 引入了更严格的语法检查,以避免潜在的错误和歧义。例如,Proto3 对标识符的命名规则进行了限制,要求字段名和消息名使用驼峰命名法,并遵循一定的命名约定。

Proto3 简化了 Protocol Buffers 的语法和使用方式,提供了更清晰、更精简的消息定义和交互方式。它注重向后兼容性,强调可扩展性和灵活性,使得使用 Protocol Buffers 进行数据序列化和通信更加方便和高效。

文件名开头都要加语法标识,如下:

syntax = "proto3";

4.3 Proto3 import 的作用

在 Protocol Buffers 中,import 语句用于引入其他 Proto 文件,并使其在当前 Proto 文件中可用。import 的作用是组织和模块化 Proto 文件的定义,使其可以分别存储和管理。

以下是 import 语句的一些用例:

  1. 分割 Proto 文件:当 Proto 文件变得复杂或包含多个消息类型时,可以使用 import 语句将其拆分为多个独立的 Proto 文件。每个 Proto 文件可以包含不同的消息定义,然后通过 import 引入其他 Proto 文件中的消息类型,以便在当前文件中使用这些类型。
  2. 重用消息定义:如果有一些公共的消息定义需要在多个 Proto 文件中使用,可以将这些消息定义放在一个独立的 Proto 文件中,并使用 import 语句在其他 Proto 文件中引入。这样可以避免重复定义相同的消息结构,实现消息定义的重用。
  3. 组织和管理 Proto 文件:通过使用 import 语句,可以将相关的消息定义组织在不同的 Proto 文件中,并在需要时按需引入。这样可以提高 Proto 文件的可读性和维护性,使代码结构更清晰和模块化。
// file1.proto
syntax = "proto3";

package mypackage;

import "file2.proto";

message Message1 {
  
}

// 使用来自 file2.proto 的 Message2
message Message2 {

}}
// file2.proto
syntax = "proto3";

package mypackage;

message Message2 {

}

Proto 类型学习可以参考这篇文章

Proto 项目

Proto 项目用于管理定义 .proto ,并使用 protoc 编译器将其输出为 TypeScript 代码,以供 Client 端和 Server 端使用。

1.安装

首先我们先要安装protocol编译器

brew install protobuf

查看是否安装成功 image.png

2.创建项目

现在创建一个项目由于管理proto 文件 image.png 如果您想使用 ts-proto 工具将 .proto 文件转换为对应的 .ts 文件。

yarn add -D ts-proto
or 
npm  i -g ts-proto // 全局安装

3.编写 proto 文件

其中有非标量 google.protobuf.Timestamp 用于生成 TS 的 Date 类型。

syntax = "proto3";

package userproto;

// 自带
import "google/protobuf/timestamp.proto";


service UserService {
    rpc getUsers(getBooksRequest) returns (getUsersResponse) {}
}

message getBooksRequest{

}

message getUsersResponse {
    repeated User users = 1;
}

message User {
    int32 id = 1;
    string name = 2;
    google.protobuf.Timestamp createdAt = 3;
}

4.编译

 "proto-ts": "protoc --plugin=./node_modules/ts-proto/protoc-gen-ts_proto.CMD --ts_proto_out=./ ./protos/*.proto --ts_proto_opt=outputEncodeMethods=false,outputJsonMethods=false,outputClientImpl=false"

该脚本的作用如下:

  1. protoc 命令用于运行 protoc 编译器。protoc 是 Protocol Buffers 的官方编译器,用于将 .proto 文件编译为各种编程语言的源代码。
  2. --plugin=./node_modules/ts-proto/protoc-gen-ts_proto.CMD 指定了 ts-proto 插件的路径,告诉 protoc 编译器要使用该插件进行代码生成。
  3. --ts_proto_out=./ 指定了生成的 TypeScript 代码的输出目录为当前目录(项目根目录)。
  4. ./protos/*.proto 指定了要编译的 .proto 文件的路径。这里使用通配符 *.proto 表示匹配 protos 目录下的所有 .proto 文件。
  5. --ts_proto_opt=outputEncodeMethods=false,outputJsonMethods=false,outputClientImpl=false 是 ts-proto 的选项设置,用于配置生成的 TypeScript 代码的行为。在这个脚本中,设置了输出编码方法、JSON 方法和客户端实现的选项为 false,意味着生成的代码中不会包含编码方法、JSON 方法和客户端实现的代码。

通过运行该脚本,protoc 编译器将读取指定的 .proto 文件,并使用 ts-proto 插件生成对应的 TypeScript 代码文件。生成的文件将放置在当前目录中。

// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
//   protoc-gen-ts_proto  v1.181.1
//   protoc               v5.27.1
// source: protos/user.proto

/* eslint-disable */

export const protobufPackage = "userproto";

export interface getBooksRequest {
}

export interface getUsersResponse {
  users: User[];
}

export interface User {
  id: number;
  name: string;
  createdAt: Date | undefined;
}

export interface UserService {
  getUsers(request: getBooksRequest): Promise<getUsersResponse>;
}

以上就是proto 项目,用于管理 proto 和编译生成 ts 文件。接下来我们讲Server 端

Server 端

1. 创建项目

首先我们创建一个 Server 端项目

nest new nest-server

要开始构建基于 gRPC 的微服务,首先安装所需的包:

yarn add @grpc/grpc-js @grpc/proto-loader

在 nest-cli.json 文件中,我们添加 assets 属性,允许我们分发非 TypeScript 文件,以及 watchAssets - 打开监视所有非 TypeScript 资源。在项目中,我们希望将 .proto 文件自动复制到 dist 文件夹。

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "server",
  "compilerOptions": {
    "assets": ["**/*.proto"],
    "watchAssets": true,
    "deleteOutDir": true,
    "typeCheck": true
  }
}

2. 拉取 Proto项目

Proto 是单独的一个Git 项目管理,需要我们要用脚本拉取 proto, 并放在指定的目录上。

脚本的主要作用是从 GitHub 上的 https://github.com/huazai128/protos-file 仓库中克隆 protos 目录到本地的 ./server 目录下,并删除不再需要的 ./protos-file 目录。

// proto.sh 
#!/bin/bash

PROTO_DIR="./server/protos" 

rm -rf "$PROTO_DIR"

cd ./server

git clone https://github.com/huazai128/protos-file.git

mv ./protos-file/protos ./

rm -rf ./protos-file

运行脚本 yarn proto,这样可以在不同项目中引用 proto,每个人都可以维护 proto,在开发群里面通知下更新就行了。还有其他的方法共享 proto,个人比较喜欢这种。

"proto": "sh ./scripts/proto.sh"

image.png

3.gRPC 微服务

Nest 微服务实现方式可以使用传递给 createMicroservice() 方法的选项对象的 transport 属性来选择 gRPC 传输机制,options 属性提供有关该服务的元数据。

 const app = await NestFactory.create<NestExpressApplication>(AppModule);
  // grpc 微服务
  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.GRPC,
    options: {
      package: ['userproto', 'authproto'],
      protoPath: [
        join(__dirname, '../protos/user.proto'),
        join(__dirname, '../protos/auth.proto'),
      ],
      url: '0.0.0.0:50052',
      onLoadPackageDefinition: (pkg, server) => {
        new ReflectionService(pkg).addToServer(server);
      },
    },
  });
  • transport: Transport.GRPC:指定使用 gRPC 作为传输协议。
  • package: ['userproto', 'authproto']:指定 gRPC 的包名
  • protoPath:指定 gRPC 的 proto 文件的路径。这里使用 join(__dirname, '../protos/user.proto') 和 join(__dirname, '../protos/auth.proto') 分别指定了 user.proto 和 auth.proto 的路径。
  • url: '0.0.0.0:50052':指定 gRPC 服务的地址和端口。
  • onLoadPackageDefinition:用于在加载包定义时执行自定义逻辑。这里使用 ReflectionService 将包定义添加到服务器。

gRPC 具体配置可以查看nest文档

4.gRPC DEMO

本段介绍下gRPC 几种模式应用,为后续开发提供技术支持。

4.1 GrpcMethod

@GrpcMethod() 是一个装饰器函数,用于在 Nest.js 中标记一个方法作为 gRPC 方法。它的作用是将一个普通的方法标记为处理特定 gRPC 服务和方法的端点。

// 官网代码
@GrpcMethod('HeroesService', 'FindOne')
findOne(data: HeroById, metadata: Metadata, call: ServerUnaryCall<any, any>): Hero {
const items = [
  { id: 1, name: 'John' },
  { id: 2, name: 'Doe' },
];
return items.find(({ id }) => id === data.id);
}

Client 端调用

    this.heroesService.findOne({id: 1})
4.2 GrpcStreamMethod

用于将方法标记为处理 gRPC 流式方法的端点。它用于处理接收和发送 gRPC 流式数据的场景。 使用 @GrpcStreamMethod() 装饰器可以实现以下功能:

  1. 映射 gRPC 服务和方法:通过在控制器方法上使用 @GrpcStreamMethod() 装饰器,可以将方法与特定的 gRPC 服务和方法进行关联。这样,当接收到相应的 gRPC 流式请求时,Nest.js 将自动将请求路由到被装饰的方法上。

  2. 处理 gRPC 流式数据:被 @GrpcStreamMethod() 装饰的方法接收两个参数,即输入流和输出流。您可以在方法内部使用这些流来处理接收到的数据并发送响应数据。

    • 输入流:表示从客户端接收到的 gRPC 流式数据。您可以通过监听输入流的事件(如 dataenderror)来处理接收到的数据。
    • 输出流:表示向客户端发送 gRPC 流式数据的流。您可以使用输出流的方法(如 write()end())来发送响应数据给客户端。
  // src/moduels/userproto  
  // 用于与 gRPC 通信进行流式数据传输。参数接收一个Observable(可观察对象)
  @GrpcStreamMethod('UserService', 'sync')
  async sync(data$: Observable<any>): Promise<any> {
    // 创建一个多播,用于处理流式数据的传输
    const hero$ = new Subject<any>();
    // 根据data$ 处理next 获取值
    const onNext = (heroById: any) => {
      const item = this.items.find(({ id }) => id === heroById.id);
      // 通过data$ 触发next 获取值,然后便利获取对象,hero$传递。
      hero$.next(item);
    };
    //  等data$ 完成触发complete, hero$也结束
    const onComplete = () => hero$.complete();
    data$.subscribe({
      next: onNext,
      complete: onComplete,
    });

    // 返回值就是一个表示数据流的 Observable 对象,用于向 gRPC 客户端发送流式响应数据。
    return hero$.asObservable(); // 返回的是hero$
  }

Client 端调用

    @Get('test1')
  steamVerfiy() {
    // ReplaySubject 是 RxJS 中的一个特殊类型的 Subject,它可以记录并重放一定数量的值给新的订阅者
    // 这段代码的目的是将使用 ReplaySubject 记录的值传递给 userService.sync() 方法进行同步操作,并将同步结果作为 Observable 进行返回。
    const ids$ = new ReplaySubject<any>()
    ids$.next({ id: 1 })
    ids$.next({ id: 4 })
    ids$.complete()
    const stream = this.userService.sync(ids$.asObservable())
    // 将流中的所有值收集到一个数组中
    return stream.pipe(toArray())
  }
4.3 GrpcStreamCall

当方法返回值定义为 stream 时,@GrpcStreamCall() 装饰器提供函数参数为 grpc.ServerDuplexStream,支持 .on('data', callback).write(message) 或 .cancel() 等标准方法。

  @GrpcStreamCall('UserService', 'streamReqCall')
  // eslint-disable-next-line @typescript-eslint/ban-types
  async streamReqCall(stream: any, callback: (p: any, p1: any) => any) {
    stream.on('data', (msg: any) => {
      console.log(msg, '----');
    });
    stream.on('end', () => {
      callback(null, {
        id: 2,
        itemTypes: [1],
      });
    });
  }

Client 端调用

@Get('test3')
    streamReqCall() {
    const upstream = new Subject<any>()
    upstream.next({
      id: 1,
      itemTypes: [1],
    })
    upstream.complete()

    const call$ = this.userService.streamReqCall(upstream)
    return call$
}

以上代码都在 src/moduels/userproto 目录下,这个 Module 主要用于验证和测试相关gRPC。运行后直接访问 API 就可以看到结果。

5.Mongoose 数据库

在Nest.js 项目中使用 Mongoose 和 Typegoose 进行 MongoDB 数据库的操作。

  1. 首先,确保已经安装了 Mongoose 模块。可以使用以下命令进行安装:
yarn add @nestjs/mongoose mongoose @typegoose/typegoose
  1. 在项目的适当位置创建一个 database目录,并在该文件中编写数据库连接的代码。
import { Module, Global } from '@nestjs/common';
import { databaseProviders } from './database.providers';

/**
 * 数据base 全局模块
 * @export
 * @class DatabaseModule
 */
@Global()
@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders],
})
export class DatabaseModule {}
// database.providers.ts 
import { dbUrl } from 'config';
import { connection, disconnect, connect, set } from 'mongoose';

export const DB_CONNECTION_TOKEN = 'DB_CONNECTION_TOKEN'; // 非基于类的提供器令牌

export const databaseProviders = [
  {
    provide: DB_CONNECTION_TOKEN,
    useFactory: async () => {
      let reconnectionTask: any = null;
      const RECONNECT_INTERVAL = 6000;
      set('strictQuery', true);

      function dbConnect() {
        return connect(dbUrl);
      }

      connection.on('disconnected', () => {
        reconnectionTask = setTimeout(dbConnect, RECONNECT_INTERVAL);
      });

      connection.on('open', () => {
        console.info('mongodb数据库连接成功');
        clearTimeout(reconnectionTask);
        reconnectionTask = null;
      });

      connection.on('error', (error) => {
        console.error('数据库连接异常', error);
        disconnect();
      });

      return dbConnect();
    },
    inject: [],
  },
];

6.Auth Module

Auth 模型用于用户登录注册验证。它定义了与认证相关的属性和行为,并提供了用于验证用户登录和注册的字段

在定义 Model 前,要定义些辅助函数和装饰器,用于在 Nest.js 中与 MongoDB 数据库进行交互。

import { Connection as MongodbConnection } from 'mongoose';
import { Inject, Provider } from '@nestjs/common';
import {
  REPOSITORY,
  DB_CONNECTION_TOKEN,
} from '@app/constants/system.constant';
import { getModelForClass } from '@typegoose/typegoose';

export interface TypeClass {
  new (...args: []);
}

// provider名称
export function getModelName(name: string): string {
  return name.toLocaleUpperCase() + REPOSITORY;
}

// mongodb 工厂提供者
export function getProviderByTypegoose(typegooseClass: TypeClass): Provider {
  return {
    provide: getModelName(typegooseClass.name),
    useFactory: (connection: MongodbConnection) => {
      return getModelForClass(typegooseClass, {
        existingConnection: connection,
      });
    },
    inject: [DB_CONNECTION_TOKEN],
  };
}

// Model 注入器
export function InjectModel(model: TypeClass) {
  return Inject(getModelName(model.name));
}

这些辅助函数和装饰器可以帮助在 Nest.js 中使用 Typegoose 创建和操作 MongoDB 模型。它们提供了依赖注入和模型转换的功能,使得在 Nest.js 中使用 MongoDB 数据库更加方便。

6.1 Auth Model

在 Auth 模型中使用了装饰器和插件:

  • AutoIncrementID:使用了 @typegoose/auto-increment 插件,用于为 userId 属性生成自增的唯一标识符。
  • paginate:使用了 mongoose-paginate-v2` 插件,用于实现分页功能。
  • @modelOptions():使用了 @typegoose/typegoose 中的 modelOptions 装饰器,用于指定模型的选项,例如设置 toObject 选项为 getters: true,以启用 getter 方法,以及设置 timestamps 选项来自动管理创建时间和更新时间。
// auth.model.ts
import { getProviderByTypegoose } from '@app/transformers/model.transform';
import { AutoIncrementID } from '@typegoose/auto-increment';
import { modelOptions, plugin, prop } from '@typegoose/typegoose';
import {
  IsDefined,
  IsNumberString,
  IsOptional,
  IsString,
  IsNumber,
  IsNotEmpty,
} from 'class-validator';
import * as paginate from 'mongoose-paginate-v2';

@plugin(AutoIncrementID, {
  field: 'userId',
  incrementBy: 1,
  startAt: 1000000000,
  trackerCollection: 'identitycounters',
  trackerModelName: 'identitycounter',
})
@plugin(paginate)
@modelOptions({
  schemaOptions: {
    toObject: { getters: true },
    timestamps: {
      createdAt: 'create_at',
      updatedAt: 'update_at',
    },
  },
})
export class Auth {
  @prop({ unique: true }) // 设置唯一索引
  userId: number;

  @IsNotEmpty({ message: '请输入您的账号' })
  @IsString()
  @IsDefined()
  @prop({ required: true })
  account: string;

  @IsString()
  @IsOptional()
  @prop({ default: '' })
  avatar: string;

  @IsString()
  @prop({ type: String, select: false })
  password: string;

  @IsNumber()
  @IsNumberString()
  @IsOptional()
  @prop({ type: [Number], default: [0] })
  role: number[];

  @prop({ default: Date.now, index: true, immutable: true })
  create_at?: Date;

  @prop({ default: Date.now })
  update_at?: Date;
}

export const AuthProvider = getProviderByTypegoose(Auth);

最后,通过 export const AuthProvider = getProviderByTypegoose(Auth); 导出了一个提供者对象,用于在 Nest.js 中进行依赖注入。

6.2 Auth Service

Auth Service 是 Auth 模型相关的服务类,用于处理用户认证等业务逻辑。

import { Injectable } from '@nestjs/common';
import { Auth } from './auth.model';
import { InjectModel } from '@app/transformers/model.transform';
import { MongooseID, MongooseModel } from '@app/interfaces/mongoose.interface';
import { decodeBase64, decodeMd5 } from '@app/utils/util';
import { LoginInfo } from '@app/interfaces/auth.interface';
import { LoginRequest, ValidateUserRequest } from '@app/protos/auth';

@Injectable()
export class AuthService {
  constructor(@InjectModel(Auth) private authModel: MongooseModel<Auth>) {}

  /**
   * 验证用户
   * @param {ValidateUserRequest} { userId }
   * @return {*}
   * @memberof AuthService
   */
  public async validateUser({ userId }: ValidateUserRequest) {
    return await this.getFindUserId(userId);
  }

  /**
   * 根据userId 查找用户信息
   * @param {number} userId
   * @return {*}
   * @memberof AuthService
   */
  public async getFindUserId(userId: number) {
    return await this.authModel.findOne({ userId: userId }).exec();
  }

  /**
   * 登录服务
   * @param {LoginRequest} auth
   * @return {*}  {Promise<LoginInfo>}
   * @memberof AuthService
   */
  public async login(auth: LoginRequest): Promise<LoginInfo> {
    const existAuth = await this.authModel.findOne(
      { account: auth.account },
      '+password',
    );
    const password = decodeMd5(decodeBase64(auth.password));
    if (existAuth?.password !== password) {
      throw '账号有误,请确认!';
    }
    return {
      account: existAuth.account,
      userId: existAuth.userId,
    };
  }

  /**
   * 根据ID查询用户
   * @param {MongooseID} id
   * @return {*}  {(Promise<Auth | null>)}
   * @memberof AuthService
   */
  public async findById(id: MongooseID): Promise<Auth | null> {
    const userInfo = await this.authModel.findById(id);
    return userInfo;
  }

  /**
   * 新建账号
   * @param {LoginRequest} auth
   * @return {*}
   * @memberof AuthService
   */
  public async createUser(auth: LoginRequest) {
    const newPassword = decodeMd5(decodeBase64(auth.password));
    const existedAuth = await this.authModel
      .findOne({ account: auth.account })
      .exec();
    if (existedAuth) {
      throw '账户已存在';
    }
    return await this.authModel.create({
      account: auth.account,
      password: newPassword,
    });
  }
}

这些方法提供了用户认证相关的功能,包括验证用户、登录服务、查询用户信息和创建新账号。通过使用 authModel 对象与数据库进行交互,AuthService 可以处理用户认证的业务逻辑。

6.3 Auth Controller
import { Controller } from '@nestjs/common';
import { GrpcMethod, MessagePattern } from '@nestjs/microservices';
import { AuthService } from './auth.service';
import {
  LoginResponse,
  LoginRequest,
  ValidateUserRequest,
} from '@app/protos/auth'; // proto 项目生成的 ts

@Controller('user')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  /**
   * grpc 登录
   * @param {LoginRequest} data
   * @return {*}  {Promise<LoginResponse>}
   * @memberof AuthController
   */
  @GrpcMethod('AuthService', 'login')
  async login(data: LoginRequest): Promise<LoginResponse> {
    const res = await this.authService.login(data);
    return res;
  }

  /**
   * grpc 验证用户
   * @param {ValidateUserRequest} data
   * @return {*}
   * @memberof AuthController
   */
  @GrpcMethod('AuthService', 'validateUser')
  async validateUser(data: ValidateUserRequest) {
    const res = await this.authService.validateUser(data);
    return res;
  }

  /**
   * 根据Id 获取用户信息
   * @param {ValidateUserRequest} data
   * @return {*}
   * @memberof AuthController
   */
  @GrpcMethod('AuthService', 'getUserById')
  async getUserById(data: ValidateUserRequest) {
    const res = await this.authService.getFindUserId(data.userId);
    return res;
  }

  /**
   * redis 微服务
   * @return {*}
   * @memberof AuthController
   */
  @MessagePattern({ cmd: 'getUserListRes' })
  async getUserList() {
    const userList = [{ id: 1, name: '测试der' }];
    return { userList };
  }
}

这些方法上的装饰器 @GrpcMethod 和 @MessagePattern 是 Nest.js 提供的装饰器,用于定义 gRPC 方法和消息模式的处理程序。这些装饰器帮助将请求映射到正确的处理方法。

6.4 Auth Module
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthProvider } from './auth.model';

@Module({
  imports: [],
  controllers: [AuthController],
  providers: [AuthProvider, AuthService],
})
export class AuthModule {}

目前整个流程已经完成,只需要 Client 端调用就可以了。我先看下 Client 端调用效果吧。

image.png image.png

结语

使用Nest.js、gRPC和Emp.js构建高性能的微前端和微服务架构的 Server 端可以带来许多优势。通过Nest.js,我们可以利用其模块化和依赖注入的特性来构建可扩展的服务器端应用程序。gRPC提供了高性能和强类型的跨服务通信机制,使得微服务之间的交互更加高效和可靠。

使用这些技术构建微前端和微服务架构的Server端,我们可以实现以下好处:

  1. 可扩展性: 通过将应用程序拆分为多个微服务和前端应用程序,我们可以根据需求独立扩展每个模块,从而实现更好的性能和可伸缩性。
  2. 灵活性: 微前端架构使得前端应用程序可以独立开发、测试和部署。每个前端应用程序可以使用适合其需求的不同框架、库或技术栈,同时保持整体应用程序的一致性。
  3. 可维护性: 使用模块化的架构和依赖注入,我们可以更容易地理解和维护代码。每个微服务和前端应用程序都可以被独立开发和测试,减少了代码库的复杂性。
  4. 性能优化: gRPC作为高性能的RPC框架,提供了快速且可靠的跨服务通信机制。这可以减少网络延迟和数据传输的开销,提高整体应用程序的性能。
  5. 团队协作: 将微服务和前端应用程序拆分为独立的模块,可以使不同团队可以独立工作,减少开发之间的依赖和冲突。

综上所述,使用Nest.js、gRPC和Emp.js构建高性能的微前端和微服务架构的Server端可以实现可扩展性、灵活性、可维护性、性能优化和团队协作等多个优势。

在写技术文章分享时,通过查找资料,获取了许多宝贵的知识,这些知识在日常开发中往往很容易被忽略。同时也为后续项目提供技术经验和架构支持。

项目代码地址: