nestjs微服务-系列5

从耦合到可插拔的架构演进

本文将继续升级前面编写的 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],
};
耦合带来的痛点

这种设计导致了几个显著的问题:

  1. 缺乏灵活性 :如果未来希望将服务发现从 Consul 更换为 Etcd、Zookeeper 或其他机制(甚至是简单的配置文件),必须深入修改 GrpcModule 的内部代码。
  2. 难以测试 :对 gRPC 模块进行单元测试时,必须引入一个真实的或模拟的 ConsulService,增加了测试的复杂性和维护成本。
  3. 违反依赖倒置原则 :这是最根本的架构问题。高层模块 (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 模块以依赖抽象

接下来,移除了 GrpcModuleConsulService 的所有直接引用,转而注入 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],
  };
}

同样,GrpcClientMangerGrpcModule 自身也移除了所有对 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 的具体实现。我们现有的 ConsulServiceimplements 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

相关推荐
程序员爱钓鱼5 分钟前
Go语言实战案例:使用channel实现生产者消费者模型
后端·go·trae
程序员爱钓鱼10 分钟前
Go语言实战案例:使用select监听多个channel
后端·go·trae
SoniaChen3324 分钟前
Rust基础-part6-数组与切片-字符串
后端·rust·web3
杨DaB2 小时前
【SpringMVC】拦截器,实现小型登录验证
java·开发语言·后端·servlet·mvc
努力的小雨9 小时前
还在为调试提示词头疼?一个案例教你轻松上手!
后端
魔都吴所谓9 小时前
【go】语言的匿名变量如何定义与使用
开发语言·后端·golang
陈佬昔没带相机10 小时前
围观前后端对接的 TypeScript 最佳实践,我们缺什么?
前端·后端·api
Livingbody11 小时前
大模型微调数据集加载和分析
后端
Livingbody11 小时前
第一次免费使用A800显卡80GB显存微调Ernie大模型
后端
Goboy12 小时前
Java 使用 FileOutputStream 写 Excel 文件不落盘?
后端·面试·架构