nestjs微服务-系列4

构建具备服务发现和自动重连的 gRPC 客户端

基于上篇独立封装的 grpc 模块的基础之上

在现代微服务架构中,服务实例的动态性(例如因部署、扩容或故障而导致的 IP 和端口变更)是一个必须面对的挑战。如果客户端继续尝试连接旧的、已失效的服务地址,将导致请求失败,影响系统的可用性。

本文将深入探讨如何利用 NestJS、Consul 和 Cron 定时任务,构建一个智能的、具备自愈能力的 gRPC 客户端模块。这个模块能够:

  1. 在应用启动时,通过 Consul 动态发现 gRPC 服务的地址。
  2. 在运行时,通过 Cron 定时任务周期性地检查服务地址是否发生变化。
  3. 当检测到地址变更时,能够无感知地自动更新 gRPC 连接,确保业务调用的持续成功。

核心架构与设计思想

在深入代码之前,我先来解释整个系统的架构。它由四个核心部分协同工作,各司其职:

  1. GrpcModule (动态模块) : 这是我们模块的统一入口。它提供了一个 registerClient 静态方法,允许我们在应用中方便地注册一个或多个需要连接的 gRPC 客户端,并传入各自的配置。
  2. GrpcClientManager (智能管理器) : 这是整个架构的核心 。它不仅仅是一个 gRPC 客户端,更是一个"智能管理器"。每个注册的服务都会拥有一个独立的 GrpcClientManager 实例。它封装了 gRPC 客户端的状态,并自我管理其健康检查的生命周期。
  3. createGrpcClientProvider (工厂函数) : 这是连接 NestJS 依赖注入系统和我们自定义逻辑的桥梁。它是一个工厂提供者,负责在应用启动时,利用注入的服务发现服务 (例如 ConsulService)和 SchedulerRegistry,来创建和配置 GrpcClientManager 的实例。
  4. GrpcClientOptions (配置接口) : 定义了注册一个 gRPC 客户端所需的所有配置项,如服务名、注入令牌、proto 文件路径,以及最重要的------cron 表达式。

分步实现

下面,我们将逐一分析每个文件的代码,理解它们是如何协同工作的。

第一步:定义配置

libs/grpc/src/interfaces/grpc-client-options.interface.ts

typescript 复制代码
import { CronExpression } from '@nestjs/schedule';

export interface GrpcClientOptions {
  // 用于在 NestJS 依赖注入容器中唯一标识客户端的 token
  injectToken: string | symbol;
  // 在服务注册中心注册的服务名称
  serviceName: string;
  // .proto 文件中定义的包名 (package name)
  packageName: string;
  // .proto 文件相对于项目根目录的路径
  protoPath: string;
  /**
   * 定义此客户端的健康检查频率 (Cron 表达式)。
   * 如果提供此参数,模块将为此客户端创建一个独立的定时任务,
   * 监控其在服务注册中心中的地址变化并自动重连。
   */
  cron?: CronExpression | string;
}

这个接口中最关键的属性是可选的 cron。它赋予了每个 gRPC 客户端独立配置健康检查频率的能力。

第二步:核心实现 - GrpcClientManager

libs/grpc/src/grpc-client.manger.ts

typescript 复制代码
import { Logger } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { GrpcClientOptions } from './interfaces/grpc-client-options.interface';
import { ConsulService } from '@libs/consul';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { createGrpcClientProxy } from './grpc.provides';

/**
 * @class GrpcClientManger
 * @description 包装了 gRPC 客户端,允许在运行时动态更新内部的客户端实例。
 */
export class GrpcClientManger<T extends ClientGrpc> {
  private client: T;
  private currentUrl: string;
  private readonly logger: Logger;

  constructor(
    initialClient: T,
    initialUrl: string,
    private readonly options: GrpcClientOptions,
    private readonly consulService: ConsulService,
    private readonly schedulerRegistry: SchedulerRegistry,
  ) {
    this.client = initialClient;
    this.currentUrl = initialUrl;
    this.logger = new Logger(`${GrpcClientManger.name}-${options.serviceName}`);
    // 启动健康检查任务
    this.startHealthCheck();
  }

  /**
   * 业务代码通过此方法获取可用的 gRPC 客户端。
   * @returns T
   */
  public getClient(): T {
    return this.client;
  }

  /**
   * 获取当前连接的 URL 地址。
   * @returns string
   */
  public getCurrentUrl(): string {
    return this.currentUrl;
  }

  /**
   * 新增方法:直接获取 gRPC 服务实例。
   * @param serviceName
   */
  public getService<TService extends object>(serviceName: string): TService {
    if (!this.client) {
      const errorMessage =
        'gRPC client is not initialized, cannot get service.';
      this.logger.error(errorMessage);
      throw new Error(errorMessage);
    }
    return this.client.getService<TService>(serviceName);
  }

  /**
   * 更新内部的 gRPC 客户端实例和 URL。
   * @param newClient 新的 gRPC 客户端实例
   * @param newUrl 新的 URL 地址
   */
  public updateClient(newClient: T, newUrl: string): void {
    this.logger.log(
      `Updating client connection from ${this.currentUrl} to ${newUrl}`,
    );
    this.client = newClient;
    this.currentUrl = newUrl;
  }

  /**
   * 启动此客户端实例独立的健康检查任务
   */
  private startHealthCheck(): void {
    const { serviceName, cron: cronExpression } = this.options;
    if (!cronExpression) {
      this.logger.log(
        'No cron expression provided. Skipping scheduled health checks.',
      );
      return;
    }
    const jobName = `grpc-health-check:${serviceName}`;
    try {
      const existingJob = this.schedulerRegistry.getCronJob(jobName);
      // 如果任务确实存在,则替换它
      if (existingJob) {
        this.logger.warn(
          `Cron job "${jobName}" already exists. It will be replaced.`,
        );
        this.schedulerRegistry.deleteCronJob(jobName);
      }
    } catch {
      /* empty */
    }
    const job = new CronJob(cronExpression, async () => {
      await this.reconnectIfNeeded();
    });
    this.schedulerRegistry.addCronJob(jobName, job);
    job.start();
    this.logger.log(
      `${serviceName} Health check scheduled with cron: "${cronExpression}"`,
    );
  }

  private async reconnectIfNeeded(): Promise<void> {
    const { serviceName } = this.options;
    this.logger.debug(`Running ${serviceName} scheduled health check...`);
    try {
      const serviceAddress =
        await this.consulService.resolveAddress(serviceName);
      if (!serviceAddress) {
        this.logger.warn(
          `Could not resolve address for service: ${serviceName}.`,
        );
        return;
      }
      const { address, port } = serviceAddress;
      const newUrl = `${address}:${port}`;
      if (this.currentUrl === newUrl) {
        return; // 地址未变,无需操作
      }
      this.logger.log(
        `Address changed from ${this.currentUrl} to ${newUrl}. Reconnecting...`,
      );

      const newClient = createGrpcClientProxy(this.options, newUrl) as T;
      this.updateClient(newClient, newUrl);
    } catch (error) {
      this.logger.error(
        `Failed to perform health check for service "${serviceName}":`,
        error,
      );
    }
  }
}

关键点解析:

  • 构造函数 : 它接收所有必要的依赖(一个实现了服务发现逻辑的服务,如 ConsulService,以及 SchedulerRegistry)和初始状态。最重要的是,它在实例化后立即调用 this.startHealthCheck(),实现了"自我启动"。
  • startHealthCheck() : 这是定时任务的设置器。它会根据配置中的 cron 表达式,利用 SchedulerRegistry 创建一个独一无二的 CronJob
  • reconnectIfNeeded() : 这是定时任务实际执行的逻辑。它查询服务注册中心,比较新旧地址,如果发现不一致,就调用辅助函数 createGrpcClientProxy 创建一个新的客户端实例,并通过 updateClient 方法替换掉旧的实例。整个过程对上层业务代码完全透明。

第三步:组装零件 - 工厂提供者

工厂提供者是 NestJS 实现复杂依赖注入的利器。我们的 createGrpcClientProvider 函数就是这样一个工厂,它负责在应用启动时,创建出功能完备的 GrpcClientManager

libs/grpc/src/grpc.provides.ts

typescript 复制代码
import { Logger, Provider } from '@nestjs/common';
import { GrpcClientOptions } from './interfaces/grpc-client-options.interface';
import { ConsulService } from '@libs/consul';
import {
  ClientGrpc,
  ClientProxyFactory,
  Transport,
} from '@nestjs/microservices';
import { join } from 'path';
import * as process from 'node:process';
import { GrpcClientManger } from './grpc-client.manger';
import { SchedulerRegistry } from '@nestjs/schedule';

/**
 * 创建 gRPC 客户端代理的辅助函数
 * @param options - 客户端配置
 * @param url - 要连接的 URL
 * @returns ClientGrpc
 */
export function createGrpcClientProxy(
  options: GrpcClientOptions,
  url: string,
): ClientGrpc {
  const { packageName, protoPath } = options;
  return ClientProxyFactory.create({
    transport: Transport.GRPC,
    options: {
      package: packageName,
      protoPath: join(process.cwd(), protoPath),
      url: url,
    },
  });
}

/**
 * 创建 gRPC 客户端提供者的工厂函数
 * @param options - 客户端配置
 * @returns Provider
 */
export function createGrpcClientProvider(options: GrpcClientOptions): Provider {
  return {
    // 提供者的注入令牌
    provide: options.injectToken,
    useFactory: async (
      consulService: ConsulService,
      schedulerRegistry: SchedulerRegistry,
    ) => {
      const logger = new Logger('createGrpcClientProvider');
      const { serviceName } = options;
      logger.log(
        `[gRPC Client] Initializing provider for service: ${serviceName}`,
      );

      // 1. 动态解析服务地址,并在失败时抛出明确错误
      const serviceAddress = await consulService.resolveAddress(serviceName);
      if (!serviceAddress) {
        throw new Error(
          `[gRPC Client] Could not resolve initial address for service: "${serviceName}". Please ensure it is running and registered in Consul.`,
        );
      }
      const { address, port } = serviceAddress;
      const initialUrl = `${address}:${port}`;
      logger.log(
        `[gRPC Client] Service '${serviceName}' initially found at ${initialUrl}`,
      );

      // 2. 使用 ClientProxyFactory 创建 gRPC 客户端,并添加类型断言
      const initialClient = createGrpcClientProxy(options, initialUrl);

      // 3. 创建客户端状态管理器
      return new GrpcClientManger(
        initialClient,
        initialUrl,
        options,
        consulService,
        schedulerRegistry,
      );
    },
    // 注入创建 Manager 所需的依赖
    inject: [ConsulService, SchedulerRegistry],
  };
}

第四步:封装一切 - 动态模块

最后,动态模块将所有部分整合在一起,对外提供一个简洁的 API。

libs/grpc/src/grpc.module.ts

typescript 复制代码
import { DynamicModule, Module, Provider } from '@nestjs/common';
import { GrpcClientOptions } from './interfaces/grpc-client-options.interface';
import { ConsulModule } from '@libs/consul';
import { createGrpcClientProvider } from './grpc.provides';
import { ScheduleModule } from '@nestjs/schedule';

@Module({})
export class GrpcModule {
  /**
   * 注册一个或多个 gRPC 客户端
   * @param options - 客户端配置数组
   * @returns DynamicModule
   */
  static registerClient(
    options: GrpcClientOptions | GrpcClientOptions[],
  ): DynamicModule {
    const clientOptions = Array.isArray(options) ? options : [options];
    // 根据配置创建所有的 gRPC 客户端提供者
    const clientProviders = clientOptions.flatMap(
      (option): Provider => createGrpcClientProvider(option),
    );

    return {
      module: GrpcModule,
      global: true,
      imports: [ConsulModule, ScheduleModule.forRoot()],
      // 注册动态创建的提供者
      providers: [...clientProviders],
      // 导出这些提供者,以便其他模块可以注入它们
      exports: [...clientProviders],
    };
  }
}

关键点解析:

  • registerClient: 这个静态方法是模块的入口。它接收配置,调用 createGrpcClientProvider 将配置转换为 NestJS 的提供者(Provider)。
  • imports: 必须导入服务发现模块 (如你已封装的 ConsulModule)和 ScheduleModule.forRoot(),以确保工厂函数能够注入它们的服务。
  • global: true: 将模块设置为全局可以让我们在任何其他模块中直接注入通过 injectToken 注册的客户端,而无需在每个模块的 imports 数组中都包含 GrpcModule

使用

更新 gateway 中的 app.module.ts

typescript 复制代码
import { ConsulModule } from '@libs/consul';
import { Module } from '@nestjs/common';
import { UserModule } from './user/user.module';
import { GrpcModule } from '@app/grpc';
import {
  USER_PACKAGE_NAME,
  USER_SERVICE_NAME,
} from '../../../proto/generated/user.interface';
import {
  ORDER_PACKAGE_NAME,
  ORDER_SERVICE_NAME,
} from '../../../proto/generated/order.interface';
import { CronExpression } from '@nestjs/schedule';

@Module({
  imports: [
    // 初始化consul
    ConsulModule.forRoot({
      consulHost: 'http://127.0.0.1:8500',
      token: '123456',
    }),
    // 注如需要依赖的微服务
    GrpcModule.registerClient([
      {
        injectToken: USER_PACKAGE_NAME,
        serviceName: USER_SERVICE_NAME,
        packageName: USER_PACKAGE_NAME,
        protoPath: 'proto/user.proto',
        // 如果不传递,则不会创建对应的健康检查机制
        cron: CronExpression.EVERY_10_SECONDS,
      },
      {
        injectToken: ORDER_PACKAGE_NAME,
        serviceName: ORDER_SERVICE_NAME,
        packageName: ORDER_PACKAGE_NAME,
        protoPath: 'proto/order.proto',
        cron: '*/30 * * * * *',
      },
    ]),
    UserModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

更新 gateway 中的 user.services.ts

现在,在任何你调用 gRPC 服务的地方,直接注入 GrpcClientManager 即可:,服务将直接通过 GrpcClientManger中提供的 getService方法获取对应的服务

typescript 复制代码
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';
import { GrpcClientManger } from '@app/grpc/grpc-client.manger';

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

  constructor(
    @Inject(USER_PACKAGE_NAME)
    private readonly userClientManager: GrpcClientManger<ClientGrpc>,
    @Inject(ORDER_PACKAGE_NAME)
    private readonly orderClientManager: GrpcClientManger<ClientGrpc>,
  ) {}

  onModuleInit() {
    this.userService = this.userClientManager.getService<UserServiceClient>(USER_SERVICE_NAME);
    this.orderService = this.orderClientManager.getService(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);
  }
}

到此,一个构建具备服务发现和自动重连的 gRPC 客户端就封装完成了。

github更新commit

相关推荐
珍宝商店32 分钟前
优雅的 async/await 错误处理模式指南
开发语言·前端·javascript
数据知道42 分钟前
Go基础:Go语言能用到的常用时间处理
开发语言·后端·golang·go语言
宋辰月1 小时前
echarts项目积累
前端·javascript·echarts
不爱编程的小九九1 小时前
小九源码-springboot048-基于spring boot心理健康服务系统
java·spring boot·后端
龙茶清欢1 小时前
Spring Boot 应用启动组件加载顺序与优先级详解
java·spring boot·后端·微服务
醉方休2 小时前
TensorFlow.js高级功能
javascript·人工智能·tensorflow
炒香菇的书呆子2 小时前
基于Amazon S3设置AWS Transfer Family Web 应用程序
javascript·aws
235162 小时前
【LeetCode】3. 无重复字符的最长子串
java·后端·算法·leetcode·职场和发展
!chen2 小时前
学习 React 前掌握 JavaScript 核心概念
javascript·学习·react.js
可观测性用观测云2 小时前
解锁DQL高级玩法——对日志关键信息提取和分析
后端