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

相关推荐
Livingbody28 分钟前
基于【ERNIE-4.5-VL-28B-A3B】模型的图片内容分析系统
后端
风吹落叶花飘荡1 小时前
2025 Next.js项目提前编译并在服务器
服务器·开发语言·javascript
yanlele2 小时前
我用爬虫抓取了 25 年 6 月掘金热门面试文章
前端·javascript·面试
你的人类朋友2 小时前
🍃Kubernetes(k8s)核心概念一览
前端·后端·自动化运维
烛阴2 小时前
WebSocket实时通信入门到实践
前端·javascript
草巾冒小子2 小时前
vue3实战:.ts文件中的interface定义与抛出、其他文件的调用方式
前端·javascript·vue.js
追逐时光者3 小时前
面试第一步,先准备一份简洁、优雅的简历模板!
后端·面试
DoraBigHead3 小时前
你写前端按钮,他们扛服务器压力:搞懂后端那些“黑话”!
前端·javascript·架构
慕木兮人可3 小时前
Docker部署MySQL镜像
spring boot·后端·mysql·docker·ecs服务器
发粪的屎壳郎3 小时前
ASP.NET Core 8 轻松配置Serilog日志
后端·asp.net·serilog