从耦合到可插拔的架构演进
本文将继续升级前面编写的 gRPC 客户端模块,记录其如何从一个与服务发现(Consul)紧密耦合的设计,通过应用依赖倒置原则和一系列高级优化,最终演进为一个健壮、灵活、可插拔的通用模块。
第一阶段:初始架构与痛点分析
我们的起点是一个功能完备的 gRPC 客户端模块,它能与 Consul 配合,动态发现并连接到后端的 gRPC 服务,同时具备基于定时任务的健康检查和自动重连能力。
其核心逻辑分布在几个文件中:
grpc.module.ts
: 通过registerClient
动态注册一个或多个 gRPC 客户端。grpc-client.manger.ts
: 封装了客户端实例,并负责定时从 Consul 拉取最新服务地址,实现动态重连。grpc.provides.ts
: 创建 gRPC 客户端实例的工厂函数。
尽管功能可用,但这个初始架构存在一个核心问题:高度耦合。
代码耦合点分析
1. 模块间的强依赖
在 grpc.module.ts
中,ConsulModule
被直接导入,这使得 GrpcModule
的使用者必须同时安装和配置 Consul 相关的依赖,即使他们可能想用别的服务发现机制。
typescript
// 旧版 grpc.module.ts 中
// ...
return {
module: GrpcModule,
global: true,
// 直接导入了 ConsulModule,产生了强依赖
imports: [ConsulModule, ScheduleModule.forRoot()],
providers: [...clientProviders],
exports: [...clientProviders],
};
耦合带来的痛点
这种设计导致了几个显著的问题:
- 缺乏灵活性 :如果未来希望将服务发现从 Consul 更换为 Etcd、Zookeeper 或其他机制(甚至是简单的配置文件),必须深入修改
GrpcModule
的内部代码。 - 难以测试 :对 gRPC 模块进行单元测试时,必须引入一个真实的或模拟的
ConsulService
,增加了测试的复杂性和维护成本。 - 违反依赖倒置原则 :这是最根本的架构问题。高层模块 (
GrpcModule
) 直接依赖于底层实现细节 (ConsulModule
),而非依赖于抽象。这使得系统僵化,难以适应变化。
第二阶段:依赖倒置与解耦
为了解决上述问题,我们引入了软件设计中的核心原则------依赖倒置原则 (DIP) 。我们的目标是让 GrpcModule
不再关心服务发现的具体实现,而是依赖于一个通用的"服务发现"抽象契约。
第一步:定义抽象层
首先定义了一个 IServiceDiscovery
接口,它规定了所有服务发现机制都必须提供一个 resolveAddress
方法。同时,我们为这个接口创建了一个唯一的注入令牌 SERVICE_DISCOVERY_TOKEN
。
typescript
/**
* 服务发现注入令牌
*/
export const SERVICE_DISCOVERY_TOKEN = 'ServiceDiscovery';
/**
* @interface IServiceDiscovery
* @description 服务发现的通用接口。
* 任何服务发现机制(如 Consul, Etcd, Zookeeper)都应实现此接口。
*/
export interface IServiceDiscovery {
/**
* 根据服务名称解析其可访问的 URL 地址。
* @param serviceName - 要解析的服务名称。
* @returns Promise<{ address: string; port: string | number } | null>, 如果找不到则返回 null。
*/
resolveAddress(serviceName: string): Promise<{ address: string; port: string | number } | null>;
}
这个接口成为了高层模块与底层模块之间的"防火墙"。
第二步:重构 gRPC 模块以依赖抽象
接下来,移除了 GrpcModule
对 ConsulService
的所有直接引用,转而注入 IServiceDiscovery
。
grpc.provides.ts
的改造:
typescript
// 新版 grpc.provides.ts
// ...
import { IServiceDiscovery, SERVICE_DISCOVERY_TOKEN } from './interfaces/service-discovery.interface';
// ...
export function createGrpcClientProvider(options: GrpcClientOptions): Provider {
return {
provide: options.injectToken,
useFactory: async (
// 注入抽象接口,而非具体实现
discoveryService: IServiceDiscovery,
schedulerRegistry: SchedulerRegistry,
) => {
// ...
// 通过抽象接口解析地址
const initialUrl = await discoveryService.resolveAddress(options.serviceName);
// ...
return new GrpcClientManger(
// ...
discoveryService, // 传入抽象接口
// ...
);
},
// 注入时使用 Token
inject: [SERVICE_DISCOVERY_TOKEN, SchedulerRegistry],
};
}
同样,GrpcClientManger
和 GrpcModule
自身也移除了所有对 Consul 的直接依赖。
typescript
// 新版 grpc-client.manger.ts
export class GrpcClientManger<T extends ClientGrpc> {
// ......其他代码
constructor(
initialClient: T,
initialUrl: string,
private readonly options: GrpcClientOptions,
private readonly discoveryService: IServiceDiscovery,
private readonly schedulerRegistry: SchedulerRegistry,
) {
this.client = initialClient;
this.currentUrl = initialUrl;
this.logger = new Logger(`${GrpcClientManger.name}-${options.serviceName}`);
// 启动健康检查任务
this.startHealthCheck();
}
// ......其他代码
private async reconnectIfNeeded(): Promise<void> {
const { serviceName } = this.options;
this.logger.debug(`Running ${serviceName} scheduled health check...`);
try {
const serviceAddress = await this.discoveryService.resolveAddress(serviceName);
// ......其他代码
}
}
typescript
// 新版 grpc.module.ts
@Module({})
export class GrpcModule {
// ......其他代码
return {
module: GrpcModule,
global: true,
// 移除原先引用的 ConsulModule
imports: [cheduleModule.forRoot()],
// ......其他代码
};
}
}
第三步:改造 Consul 模块作为"适配器"
解耦后,我们需要一个 IServiceDiscovery
的具体实现。我们现有的 ConsulService
去 implements IServiceDiscovery
,即使后续更换成其他的服务注册中心,也只需要实现IServiceDiscovery
定义的 resolveAddress
方法就行,然后改造 ConsulModule
我们在 ConsulModule
中增加了一个别名 Provider (useExisting
),这是一种非常高效的策略,因为它不会创建新的实例,而是复用已有的 ConsulService
实例, 然后调整代码,更改如下
typescript
import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
import { CONSUL_OPTIONS } from './consul.constants';
import { ConsulService } from './consul.service';
import { ConsulModuleOptions } from './interfaces/consul-module.interface';
import { SERVICE_DISCOVERY_TOKEN } from '@app/grpc/interfaces/service-discovery.interface';
function createConsulProviders(
optionsOrFactory:
| ConsulModuleOptions
| (() => Promise<ConsulModuleOptions> | ConsulModuleOptions),
): Provider[] {
const providers: Provider[] = [
ConsulService,
// 关键:告诉 NestJS,当有地方需要 IServiceDiscovery 时,
// 请使用已经存在的 ConsulService 实例。 {
provide: SERVICE_DISCOVERY_TOKEN,
useExisting: ConsulService,
},
];
typeof optionsOrFactory === 'function'
? providers.unshift({ provide: CONSUL_OPTIONS, useFactory: optionsOrFactory })
: providers.unshift({ provide: CONSUL_OPTIONS, useValue: optionsOrFactory });
return providers;
}
@Global()
@Module({})
export class ConsulModule {
/** 同步注册 */
static forRoot(options: ConsulModuleOptions): DynamicModule {
const providers = createConsulProviders(options);
return {
module: ConsulModule,
providers,
exports: [ConsulService, SERVICE_DISCOVERY_TOKEN],
};
}
/** 异步注册(从 ConfigService、ENV 等拿配置) */
static forRootAsync(
optionsFactory: () => Promise<ConsulModuleOptions> | ConsulModuleOptions,
): DynamicModule {
const providers = createConsulProviders(optionsFactory);
return {
module: ConsulModule,
providers,
exports: [ConsulService, SERVICE_DISCOVERY_TOKEN],
};
}
}
通过这三步,我们成功地将 gRPC 核心逻辑与 Consul 解耦。现在 GrpcModule
可以在不知道 Consul 存在的情况下正常工作,实现了真正的"可插拔"。
github更改commit