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

相关推荐
Nejosi_念旧1 小时前
解读 Go 中的 constraints包
后端·golang·go
风无雨1 小时前
GO 启动 简单服务
开发语言·后端·golang
小明的小名叫小明1 小时前
Go从入门到精通(19)-协程(goroutine)与通道(channel)
后端·golang
斯普信专业组1 小时前
Go语言包管理完全指南:从基础到最佳实践
开发语言·后端·golang
一只叫煤球的猫3 小时前
【🤣离谱整活】我写了一篇程序员掉进 Java 异世界的短篇小说
java·后端·程序员
你的人类朋友4 小时前
🫏光速入门cURL
前端·后端·程序员
aramae6 小时前
C++ -- STL -- vector
开发语言·c++·笔记·后端·visual studio
lifallen6 小时前
Paimon 原子提交实现
java·大数据·数据结构·数据库·后端·算法
舒一笑7 小时前
PandaCoder重大产品更新-引入Jenkinsfile文件支持
后端·程序员·intellij idea
PetterHillWater7 小时前
AI编程之CodeBuddy的小试
后端·aigc