构建具备服务发现和自动重连的 gRPC 客户端
基于上篇独立封装的 grpc 模块的基础之上
在现代微服务架构中,服务实例的动态性(例如因部署、扩容或故障而导致的 IP 和端口变更)是一个必须面对的挑战。如果客户端继续尝试连接旧的、已失效的服务地址,将导致请求失败,影响系统的可用性。
本文将深入探讨如何利用 NestJS、Consul 和 Cron 定时任务,构建一个智能的、具备自愈能力的 gRPC 客户端模块。这个模块能够:
- 在应用启动时,通过 Consul 动态发现 gRPC 服务的地址。
- 在运行时,通过 Cron 定时任务周期性地检查服务地址是否发生变化。
- 当检测到地址变更时,能够无感知地自动更新 gRPC 连接,确保业务调用的持续成功。
核心架构与设计思想
在深入代码之前,我先来解释整个系统的架构。它由四个核心部分协同工作,各司其职:
GrpcModule
(动态模块) : 这是我们模块的统一入口。它提供了一个registerClient
静态方法,允许我们在应用中方便地注册一个或多个需要连接的 gRPC 客户端,并传入各自的配置。GrpcClientManager
(智能管理器) : 这是整个架构的核心 。它不仅仅是一个 gRPC 客户端,更是一个"智能管理器"。每个注册的服务都会拥有一个独立的GrpcClientManager
实例。它封装了 gRPC 客户端的状态,并自我管理其健康检查的生命周期。createGrpcClientProvider
(工厂函数) : 这是连接 NestJS 依赖注入系统和我们自定义逻辑的桥梁。它是一个工厂提供者,负责在应用启动时,利用注入的服务发现服务 (例如ConsulService
)和SchedulerRegistry
,来创建和配置GrpcClientManager
的实例。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