nestjs 学习17:封装一个微服务注册与配置中心的动态模块

0 阅读7分钟

微服务架构的系统都会有配置中心和注册中心。

为什么呢?

比如说配置中心:

系统中会有很多微服务,它们会有一些配置信息,比如环境变量、数据库连接信息等。

这些配置信息散落在各个服务中,以配置文件的形式存在。

这样你修改同样的配置需要去各个服务下改下配置文件,然后重启服务。

就很麻烦。

image.png

如果有一个服务专门用来集中管理配置信息呢?

这样每个微服务都从这里拿配置,可以统一的修改,并且配置更改后也会通知各个微服务。

这个集中管理配置信息的服务就叫配置中心。

image.png

再就是注册中心:

微服务之间会相互依赖,共同完成业务逻辑的处理。

如果某个微服务挂掉了,那所有依赖它的服务就都不能工作了。

为了避免这种情况,我们会通过集群部署的方式,每种微服务部署若干个节点,并且还可能动态增加一些节点。

那么问题来了:

微服务 A 依赖了微服务 B,写代码的时候 B 只有 3 个节点,但跑起来以后,某个节点挂掉了,并且还新增了几个微服务 B 的节点。

这时候微服务 A 怎么知道微服务 B 有哪些节点可用呢?

image.png

答案也是需要一个单独的服务来管理,这个服务就是注册中心:

image.png

微服务在启动的时候,向注册中心注册,销毁的时候向注册中心注销,并且定时发心跳包来汇报自己的状态。

在查找其他微服务的时候,去注册中心查一下这个服务的所有节点信息,然后再选一个来用,这个叫做服务发现。

这样微服务就可以动态的增删节点而不影响其他微服务了。

微服务架构的后端系统中,都会有这两种服务。

但是,虽然这是两种服务,功能确实很类似,完全可以在一个服务里实现。

可以做配置中心、注册中心的中间件还是挺多的,比如 nacos、apollo、etcd 等。

它们其实是一个 key-value 的存储服务。

下面以 etcd 来学习微服务注册中心和配置中心。

配置中心的实现比较简单,就是直接 put、get、del 对应的 key。

使用 etcd 官方提供的 npm 包 etcd3:

const { Etcd3 } = require('etcd3');
const client = new Etcd3({
    hosts: 'http://localhost:2379',
    auth: {
        username: 'root',
        password: 'guang'
    }
});
 
// 保存配置
async function saveConfig(key, value) {
    await client.put(key).value(value);
}

// 读取配置
async function getConfig(key) {
    return await client.get(key).string();
}

// 删除配置
async function deleteConfig(key) {
    await client.delete().key(key);
}

(async function main() { 
    await saveConfig('config-key', 'config-value'); 
    const configValue = await getConfig('config-key'); 
    console.log('Config value:', configValue); 
})();

你可以在这里存各种数据库连接信息、环境变量等各种配置。

然后是注册中心:

服务注册:

// 服务注册
async function registerService(serviceName, instanceId, metadata) {
    const key = `/services/${serviceName}/${instanceId}`;
    const lease = client.lease(10); // 10秒租约

    // 1. 注册服务
    await lease.put(key).value(JSON.stringify(metadata));

    // 2. 自动续租(心跳)→ 这才是真正的心跳机制
    lease.on('keepAlive', () => {
        console.log('续租成功 → 服务正常');
    });

    // 3. 租约丢失才重新注册(网络断了/服务挂了)
    lease.on('lost', async () => {
        console.log('租约过期,重新注册...');
        await registerService(serviceName, instanceId, metadata);
    });
}

注册的时候我们按照 /services/服务名/实例id 的格式来指定 key。

也就是一个微服务可以有多个实例。

设置了租约 10s,这个就是过期时间的意思,然后过期会自动删除。

我们可以监听 lost 事件,在过期后自动续租。

当不再续租的时候,就代表这个服务挂掉了。

为什么要有租约?

租约 = 服务的有效期。

你的服务注册到 etcd 后,不是永久存在的,必须在有效期内 “续命”,否则会被自动删除。

作用:自动清理宕机 / 掉线的服务实例,保证注册中心里的服务都是活着的

但是,10 秒后租约一定会过期,然后触发 lost 事件,然后重新注册,循环往复,这是低效且不稳定的。

所以,必须加上心跳检测。

由服务每隔几秒给etcd发送请求,告诉 etcd 我还活着,自动给租约续命。

一般是要按 1/3 频率发心跳,也就是10 / 3 = 3.3 秒发送一次心跳检测。

然后是服务发现:

// 服务发现
async function discoverService(serviceName) {
    const instances = await client.getAll().prefix(`/services/${serviceName}`).strings();
    return Object.entries(instances).map(([key, value]) => JSON.parse(value));
}

服务发现就是查询 /services/服务名 下的所有实例,返回它的信息。

// 监听服务变更
async function watchService(serviceName, callback) {
    const watcher = await client.watch().prefix(`/services/${serviceName}`).create();
    watcher .on('put', async event => {
        console.log('新的服务节点添加:', event.key.toString());
        callback(await discoverService(serviceName));
    }).on('delete', async event => {
        console.log('服务节点删除:', event.key.toString());
        callback(await discoverService(serviceName));
    });
}

通过 watch 监听 /services/服务名下所有实例的变动,包括添加节点、删除节点等,返回现在的可用节点。

测试下:

(async function main() {
    const serviceName = 'my_service';
    
    await registerService(serviceName, 'instance_1', { host: 'localhost', port:3000 });
    await registerService(serviceName, 'instance_2', { host: 'localhost', port:3002 });

    const instances = await discoverService(serviceName);
    console.log('所有服务节点:', instances);

    watchService(serviceName, updatedInstances => {
        console.log('服务节点有变动:', updatedInstances);
    });
})();

那 Nest 里怎么集成它呢?

其实和 Redis 差不多。

集成 Redis 的时候我们就是写了一个 provider 创建连接,然后注入到 service 里调用它的方法。

首先加一个 etcd 的 provider:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { Etcd3 } from 'etcd3';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: 'ETCD_CLIENT',
      useFactory() {
        const client = new Etcd3({
            hosts: 'http://localhost:2379',
            auth: {
                username: 'root',
                password: 'guang'
            }
        });
        return client;
      }
    }
  ],
})
export class AppModule {}

在 AppController 里注入下:

image.png

这样 etcd 就集成好了,很简单。

然后我们封装成一个模块, 创建一个 module 和 service。

在 EtcdModule 添加 etcd 的 provider:

import { Module } from '@nestjs/common';
import { EtcdService } from './etcd.service';
import { Etcd3 } from 'etcd3';

@Module({
  providers: [
    EtcdService,
    {
      provide: 'ETCD_CLIENT',
      useFactory() {
        const client = new Etcd3({
            hosts: 'http://localhost:2379',
            auth: {
                username: 'root',
                password: 'guang'
            }
        });
        return client;
      }
    }
  ],
  exports: [
    EtcdService
  ]
})
export class EtcdModule {}

然后在 EtcdService 添加一些方法:

import { Inject, Injectable } from '@nestjs/common';
import { Etcd3 } from 'etcd3';

@Injectable()
export class EtcdService {

    @Inject('ETCD_CLIENT')
    private client: Etcd3;

    // 保存配置
    async saveConfig(key, value) {
        await this.client.put(key).value(value);
    }

    // 读取配置
    async getConfig(key) {
        return await this.client.get(key).string();
    }

    // 删除配置
    async deleteConfig(key) {
        await this.client.delete().key(key);
    }
   
    // 服务注册
    async registerService(serviceName, instanceId, metadata) {
        const key = `/services/${serviceName}/${instanceId}`;
        const lease = this.client.lease(10);
        await lease.put(key).value(JSON.stringify(metadata));
        lease.on('lost', async () => {
            console.log('租约过期,重新注册...');
            await this.registerService(serviceName, instanceId, metadata);
        });
    }

    // 服务发现
    async discoverService(serviceName) {
        const instances = await this.client.getAll().prefix(`/services/${serviceName}`).strings();
        return Object.entries(instances).map(([key, value]) => JSON.parse(value));
    }

    // 监听服务变更
    async watchService(serviceName, callback) {
        const watcher = await this.client.watch().prefix(`/services/${serviceName}`).create();
        watcher.on('put', async event => {
            console.log('新的服务节点添加:', event.key.toString());
            callback(await this.discoverService(serviceName));
        }).on('delete', async event => {
            console.log('服务节点删除:', event.key.toString());
            callback(await this.discoverService(serviceName));
        });
    }

}

然后再创建个模块,引入它试一下:

image.png

image.png

没啥问题。

不过现在 EtcdModule 是普通的模块,我们改成动态模块:

import { DynamicModule, Module, ModuleMetadata, Type } from '@nestjs/common';
import { EtcdService } from './etcd.service';
import { Etcd3, IOptions } from 'etcd3';

export const ETCD_CLIENT_TOKEN = 'ETCD_CLIENT';

export const ETCD_CLIENT_OPTIONS_TOKEN = 'ETCD_CLIENT_OPTIONS';

@Module({})
export class EtcdModule {

  static forRoot(options?: IOptions): DynamicModule {
    return {
      module: EtcdModule,
      providers: [
        EtcdService,
        {
          provide: ETCD_CLIENT_TOKEN,
          useFactory(options: IOptions) {
            const client = new Etcd3(options);
            return client;
          },
          inject: [ETCD_CLIENT_OPTIONS_TOKEN]
        },
        {
          provide: ETCD_CLIENT_OPTIONS_TOKEN,
          useValue: options
        }
      ],
      exports: [
        EtcdService
      ]
    };
  }
}

把 EtcdModule 改成动态模块的方式,加一个 forRoot 方法。

把传入的 options 作为一个 provider,然后再创建 etcd client 作为一个 provider。

然后 AaaModule 引入 EtcdModule 的方式也改下:

image.png

现在 etcd 的参数是动态传入的了,这就是动态模块的好处。

当然,一般动态模块都有 forRootAsync,我们也加一下:

export interface EtcdModuleAsyncOptions  {
  useFactory?: (...args: any[]) => Promise<IOptions> | IOptions;
  inject?: any[];
}

static forRootAsync(options: EtcdModuleAsyncOptions): DynamicModule {
    return {
      module: EtcdModule,
      providers: [
        EtcdService,
        {
          provide: ETCD_CLIENT_TOKEN,
          useFactory(options: IOptions) {
            const client = new Etcd3(options);
            return client;
          },
          inject: [ETCD_CLIENT_OPTIONS_TOKEN]
        },
        {
          provide: ETCD_CLIENT_OPTIONS_TOKEN,
          useFactory: options.useFactory,
          inject: options.inject || []
        }
      ],
      exports: [
        EtcdService
      ]
    };
}

和 forRoot 的区别就是现在的 options 的 provider 是通过 useFactory 的方式创建的,之前是直接传入useValue。

现在就可以这样传入 options 了:

EtcdModule.forRootAsync({
  async useFactory() {
      await 111;
      return {
          hosts: 'http://localhost:2379',
          auth: {
              username: 'root',
              password: 'guang'
          }
      }
  }
})

这有什么好处吗?

好处肯定是options更加灵活,可以是异步的。

总结:

  • 首先创建了一个etcd的provider,然后直接在controller里面注入;

  • 接着,把etcd的逻辑封装成一个静态模块,创建module和service,把service服务exports出去,然后在其他模块引入这个模块,就可以使用了。

  • 我们又把这个普通模块封装了成了一个动态模块,通过forRoot来完成,然后在appModule中传入配置信息,这样配置信息就不是写在模块里面了,而是由用户传入,这样更加灵活。

  • 如果配置信息一开始不是固定的,比如要从其他服务获取,那就需要动态生成配置信息了,可以采用forRootAsync。