Nest 使用笔记
第二十二章:Etcd / Nacos——配置中心与服务发现
打开 learnhub 的 EtcdService,在真实代码里讲透配置中心为什么不只是 .env、Etcd 的 KV get/put + 优雅降级、watch 热更新、Etcd 和 @nestjs/config 怎么分层共存;再补上 learnhub 作为单体没做的另一半——Nacos 服务发现和 @nestjs/microservices。每段真实代码都能在 learnhub 里指到对应文件。
- Etcd
- Nacos
- 微服务
第一章我们用 .env + @nestjs/config 管配置,那个方案到这一章开始不够用了:.env 是静态的,改一个值要改文件、重启进程;微服务一多,同一个配置散在 N 个仓库里,改一次得改 N 处。这一章打开 learnhub 的 EtcdService,在真实代码里讲透四件事:配置中心为什么不只是 .env、Etcd 的 KV 读写 + 优雅降级、watch 配置热更新、Etcd 和 @nestjs/config 怎么分层共存;再补上 learnhub 作为单体没做的另一半——Nacos 服务发现和 @nestjs/microservices。learnhub 实际只把 Etcd 当配置中心的 KV 用(get/put + 一个 admin 接口),这一章在它真实代码的基础上,把生产里该补的 watch / delete / 服务发现讲清楚。
先搞懂:配置中心 / 服务发现是什么 / 为什么需要 / 企业级怎么用
配置中心是什么:一个集中存放所有服务配置、并支持变更实时推送的中间件。业务代码不把配置写死在 .env 或代码里,而是运行时从配置中心读;管理员在配置中心改一个值,所有订阅了这个配置的实例几秒内收到变更、热更新——不用改代码、不用重启。
服务发现(注册中心)是什么:让服务实例启动时把自己注册上去(“我是 user-service,IP 10.0.0.5,端口 3001”),调用方查”user-service 当前有哪些可用实例”拿到一批地址,再负载均衡选一个调用。实例挂了靠心跳/租约过期自动摘除。
为什么需要它(每个痛点都对应一个真实场景):
- 配置散在各服务
.env里:改一个数据库地址要改 N 个仓库、N 次重启。配置中心改一处,全部实例 watch 到变更热更新。 - 服务实例动态增删:扩容、缩容、容器重启、节点宕机,调用方写死的 IP 列表很快就过期。注册中心实时维护”谁还活着”。
- 运行期想改阈值/开关:大促时把限流阈值从 1000 调到 5000、灰度时把一个 feature flag 从 off 切到 on——总不能为此发一版。配置中心推一下就生效。
判断要不要上:单体项目(learnhub 就是)用 @nestjs/config + .env 完全够;只有拆了微服务、需要集中配置 + 实时服务发现时才上 Etcd/Nacos。
Etcd 是什么:一个强一致的分布式 KV 存储,用 Raft 共识算法保证多副本之间数据一致。k8s 自身就用 Etcd 存集群状态。它的数据模型极简:key 是任意字符串(约定用 / 分层,如 /services/user/inst1),value 是字节。强一致(CP)的代价是写入要走 Quorum、吞吐不如 Redis;但配置量本来就小,这点代价换来的是”任何一个实例读到的配置一定是一致的”。
企业级怎么用(每条后面都会落到代码):
- 配置热更新:业务启动时
watch关心的一批 key,变更回调里刷新内存值——learnhub 没做,第五步补; - 读写带降级:配置中心不是 100% 可用,读失败要 fallback 到本地缓存或
.env,绝不能让一次配置读抛错把请求打挂——learnhub 的get/puttry/catch 就是这个意思; - 服务注册靠 lease + 心跳:实例启动 put 一条带 TTL 的 key、定时续租;挂了不发心跳,TTL 到了 key 自动消失,调用方
watch或get --prefix感知——learnhub 没做,第七步讲; - 选型上服务发现常用 AP(Nacos、Consul,可用性优先),强一致协调用 CP(Etcd,一致性优先、k8s 原生)。
这一章你会做出什么
- 打开 learnhub 的
src/modules/etcd/,在真实代码里吃透:@Global()模块、Etcd3客户端的构造(懒连接)、get/put的 try/catch 优雅降级。 - 把 learnhub 文件注释里提到、但没实现的
watch热更新和delete补清楚——并说清”为什么 learnhub 没做”。 - 讲透 Etcd 和
@nestjs/config怎么分层共存:.env管启动参数、Etcd 管运行期可变配置。 - 学到 learnhub 作为单体用不上的另一半:Nacos 的服务注册发现、
@nestjs/microservices的 transports——这是真正上了微服务规模才需要的基础设施。
前置:第一章和第四章的 ConfigModule / @nestjs/config 知识(这一章会和它对照);第二十章 RabbitMQ 的 amqp.service.ts(这一章拿它做优雅降级对照)。Etcd 本体用 Docker 起,第二步给命令。
第一步:为什么不只用 .env——配置中心要解决的真问题
先看 learnhub 现在的配置层长什么样。第一章里 main.ts 启动时,ConfigModule.forRoot({ load: [configuration] }) 会执行一个工厂函数,把扁平的 process.env 组织成嵌套对象,业务里用 ConfigService.get('mysql.host') 取值:
// learnhub/src/config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
mysql: {
host: process.env.MYSQL_HOST || '127.0.0.1',
port: parseInt(process.env.MYSQL_PORT || '3306', 10),
username: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'learnhub',
},
jwt: {
secret: process.env.JWT_SECRET || 'learnhub_jwt_secret_change_me',
accessExpires: process.env.JWT_ACCESS_EXPIRES || '30m',
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
},
// ...redis / mongo / elasticsearch / minio 同款
});
这套方案的边界在哪:
- 静态:
.env在进程启动时读一次,之后ConfigService.get()拿的就是启动时那份快照。改.env必须重启进程才生效。 - 每部署一份:开发、测试、生产各一份
.env,跨部署不共享。生产改个阈值,得改文件、提交、走 CI、重启 pod。 - 无审计、无版本:谁改的、什么时候改的、改之前是什么值——git 里有,但运行中的实例不知道,也没法在运行时回滚到上一个值。
- 跨服务不共享:N 个微服务各自一份
.env,同一个”开关”要在 N 个仓库里改 N 次。
配置中心解决的就是这些:一个集中存配置的地方,改一处所有实例 watch 到变更热更新,带版本和审计。落到 learnhub 的具体场景,能想到这些用 .env 做起来很别扭的事:
- 运行期切 feature flag:新功能上线想先灰度 10% 流量,配置中心改
feature.new_ui.enabled = true,几个实例同时收到、马上生效。.env方案得发版。 - 运行期调阈值:限流的 QPS 阈值、缓存的 TTL、重试次数——线上发现设大了,配置中心改一下立即生效,不用半夜发版。
- 轮换密钥:第三方 API key 要轮换,新 key 写进配置中心、应用 watch 到后平滑切到新 key,老 key 留着过渡。
.env方案要重启、有中断窗口。
注意:不是所有配置都该进配置中心。启动参数(MySQL 地址、Redis 地址、Etcd 本身的地址)——这些在应用能连上配置中心之前就要用到,显然不能放进 Etcd 自己(鸡生蛋问题)。第六步会讲清楚两层怎么分。
第二步:用 Docker 起 Etcd——强一致 KV 存储
先把它跑起来有个体感。Etcd 用 Docker 起一个单节点(生产是 3 / 5 节点集群,靠 Raft 容错):
docker run -d --name etcd-test -p 2379:2379 \
-e ETCD_ROOT_PASSWORD=123456 \
bitnami/etcd
bitnami/etcd 镜像默认要设 root 密码(学习用)。用 etcdctl 操作(KV 存储,k8s 也用它):
# 写
docker exec -it etcd-test etcdctl --user root:123456 put /config/feature/new_ui '{"enabled":true}'
# 读
docker exec -it etcd-test etcdctl --user root:123456 get /config/feature/new_ui
# 按前缀批量读
docker exec -it etcd-test etcdctl --user root:123456 get --prefix /config
# 删
docker exec -it etcd-test etcdctl --user root:123456 del /config/feature/new_ui
# 监听某个 key 的变更(开另一个终端改这个 key,这边会收到事件)
docker exec -it etcd-test etcdctl --user root:123456 watch /config/feature/new_ui
key 用 / 分层是 Etcd 社区的约定(/config/... 管配置、/services/... 管服务注册),不是强制——但前缀分层的 get --prefix / watch --prefix 非常好用,约定俗成能省很多事。
为什么用 Etcd 而不是 Redis 做配置中心:Redis 是缓存数据库、追求吞吐、最终一致;Etcd 是协调服务、追求强一致(Raft Quorum 写入)。配置量小但要求”所有实例看到的配置一定一致”——Etcd 的强一致正好。另外 Redis 的 keyspace notification 只能通知已存在 key 的变更、不能 watch 一个还不存在的 key 的创建事件,而配置常是动态新增的,这点 Redis 不够用。
第三步:learnhub 的 EtcdModule——@Global 全局可用
learnhub 把 Etcd 封装成一个全局模块:
// learnhub/src/modules/etcd/etcd.module.ts
@Global()
@Module({
controllers: [EtcdController],
providers: [EtcdService],
exports: [EtcdService],
})
export class EtcdModule {}
@Global() 的意思是:这个模块 export 出去的 provider(EtcdService)在整个应用的任何模块里都能直接注入,不用每个用到的模块再 imports: [EtcdModule]。配置中心、日志、Redis 这种全应用都要用的基础设施,做成 @Global() 最省事——和 learnhub 的 RedisModule / MinioModule / SearchModule 是一个套路。
注意 learnhub 这里没用动态模块(没有 forRoot / forRootAsync),连接参数直接在 EtcdService 的构造函数里读 process.env——下一步会看到为什么这样写够用、以及它和第八章 TypeOrmModule.forRootAsync 的差距在哪。这个简化是 learnhub 作为教学项目的取舍:动态模块的样板代码会盖住这一章真正要讲的”配置中心”本身。生产代码可以照着第六章的 JwtModule.registerAsync / 第八章的 TypeOrmModule.forRootAsync 把它改成 EtcdModule.forRootAsync({ inject: [ConfigService], useFactory }),让连接参数走 ConfigService 而不是直接读 process.env。
第四步:learnhub 的 EtcdService——get/put + 优雅降级(本章核心)
这是这一章和”写个 etcd demo”差距最大的一节。先看 learnhub 的真实代码:
// learnhub/src/modules/etcd/etcd.service.ts
@Injectable()
export class EtcdService {
private readonly logger = new Logger(EtcdService.name);
private readonly client: Etcd3;
constructor() {
const hosts = (process.env.ETCD_HOSTS || 'http://127.0.0.1:2379').split(',');
this.client = new Etcd3({
hosts,
auth:
process.env.ETCD_USER && process.env.ETCD_PASSWORD
? { username: process.env.ETCD_USER, password: process.env.ETCD_PASSWORD }
: undefined,
});
}
async get(key: string): Promise<string | null> {
try {
return (await this.client.get(key).string()) ?? null;
} catch (e) {
this.logger.warn(`Etcd get(${key}) 失败:${(e as Error).message}`);
return null;
}
}
async put(key: string, value: string): Promise<void> {
try {
await this.client.put(key).value(value);
} catch (e) {
this.logger.warn(`Etcd put(${key}) 失败:${(e as Error).message}`);
}
}
}
几个看起来不起眼、但全是生产考量的点:
第一,客户端在构造函数里建、不在 onModuleInit 里连。new Etcd3({...}) 只是构造客户端对象,不发起网络连接——真正的连接是第一次 get/put 时懒建立的。所以这个 service 没有 implements OnModuleInit、没有 onModuleInit 方法:构造完 client 就算”就绪”,连接按需建立。对比第二十章的 AmqpService,那个真在 onModuleInit 里 await connect()——因为发布前必须有一个就绪的 channel,连接失败要立刻 warn 出来。两种策略对应两种需求:amqp 要 eagerly 建好通道才能发;etcd 的每次 get/put 自带网络往返、可以按需连。第四章讲过 OnModuleInit 这个生命周期钩子,这里不展开。
第二,get 和 put 都用 try/catch 包住、失败不抛。get 失败返回 null、put 失败吞掉只 warn。这是配置中心的铁律:配置中心挂了不能把业务请求拖死。设想一个接口里 const threshold = await this.etcd.get('rate_limit'),如果 etcd 暂时抖动抛个错出去,全局异常过滤器会把这个 500 直接返给用户——但这个用户跟 etcd 一点关系都没有,他只是想发个帖。try/catch + warn + 返回 null,把”配置读失败”降级成”用 null 当值”,业务层再决定怎么处理 null(用默认值、用 .env 里的值、用上次缓存的值)。
第三,get 返回 string | null。null 有两种含义:key 不存在、或读失败。这种”二义性”在生产代码里其实是个隐患——理想做法是分成”缓存里取 + 不存在返默认值”和”读失败抛但被外层兜”,或者干脆让 service 内部维护一份内存缓存、读永远命中缓存(第五步的 watch 模式就是这个思路)。learnhub 这里为了简洁合二为一,能接受。
思考:如果配置存在 Etcd、代码每次请求都 etcd.get(key) 现读,Etcd 抖了一下会怎样?——这次 get 返回 null,调用方拿到 null。如果调用方没做”null 时 fallback 到默认值”,这次请求就把 null 当配置用了(比如把限流阈值当 0,所有请求被拒)。所以配置读永远不能让请求崩,更稳的写法是:服务启动时把关心的 key 全 get 一遍灌进内存缓存,运行期 get 永远命中内存(O(1)、永不抛),由 watch 异步刷新缓存。Etcd 挂了只是缓存停止刷新,不会让任何一次请求读到错值。learnhub 现在是”每次现读”的简单写法,教学够用;生产里换成”缓存 + watch”才稳妥。
注意:Etcd / Nacos 这种基础设施自己一旦上线,就成了系统的关键路径——它挂了、你又没 fallback,整个应用就动不了。优雅降级不是可选项、是必须项。learnhub 的 try/catch 是最基础的一层;生产里还要加上面说的”内存缓存 + .env fallback”,让应用在 Etcd 完全不可用时也能用”上次已知的好值”继续跑。
第四,构造函数里直接读 process.env。这违反了第一章和第十六章推崇的”配置走 ConfigService”原则——process.env 没类型、没默认值管理、没法被 ConfigModule 的校验覆盖。learnhub 这里偷懒了,更稳的写法是 EtcdModule.forRootAsync({ inject: [ConfigService], useFactory: (config) => new Etcd3({ hosts: config.get('etcd.hosts') }) }),把 etcd.hosts 也加进 configuration.ts。第三步已经提到这个改进点。
第五步:watch——配置变更热更新(learnhub 注释提到、没实现)
打开 learnhub 的 etcd.service.ts,文件头有一行注释:
// learnhub/src/modules/etcd/etcd.service.ts (文件头注释)
/**
* 阶段9 · Etcd 配置中心客户端【节142/143】
* get/put 配置项;Etcd 不可用时降级(返回 null / 告警),不阻断业务。
* 生产里通常再配合 watch 监听配置变更热更新(节142)。
*/
“生产里通常再配合 watch”——learnhub 自己点出了没实现的那一半。这一节把 watch(和 delete)补清楚。下面这段不是 learnhub 现有代码,是按它的 get/put 风格补全的生产形态:
// 不是 learnhub 现有代码 —— 在 EtcdService 上扩展 watch + delete + 内存缓存
private cache = new Map<string, string>(); // 内存缓存:watch 刷新,读永远命中
private watchers = new Map<string, Watcher>(); // 避免对同一 key 重复 watch
/** 启动时调一次:把关心的 key 灌进缓存,并 watch 它的变更 */
async watch(key: string, onChange?: (value: string | null) => void): Promise<void> {
if (this.watchers.has(key)) return; // 已订阅,不重复
const initial = await this.get(key); // 先灌初值进缓存
if (initial !== null) this.cache.set(key, initial);
const watcher = await this.client.watch(key).watch(); // 订阅变更
watcher.on('put', ({ value }) => {
this.cache.set(key, value.toString()); // 刷新缓存
onChange?.(value.toString()); // 业务回调(如重连数据库、切 feature flag)
});
watcher.on('delete', () => {
this.cache.delete(key);
onChange?.(null);
});
this.watchers.set(key, watcher);
}
/** 读永远走内存缓存,永不抛、永不走网络 */
getCached(key: string): string | null {
return this.cache.get(key) ?? null;
}
async delete(key: string): Promise<void> {
try {
await this.client.delete(key);
} catch (e) {
this.logger.warn(`Etcd delete(${key}) 失败:${(e as Error).message}`);
}
}
几个关键点:
watch是长连接订阅:客户端和 Etcd 维持一个 watch 流,key 一变就推put/delete事件。这是”配置热更新”的底层机制——业务在回调里做该做的事(重连池、切 flag、刷新内存)。getCached永远命中内存:把”读配置”从一次网络往返降成一次Map.get,而且永不抛——Etcd 挂了只是缓存停刷新,读还是好的。这是上一节”思考”里说的生产写法。watch避免重复订阅:对同一个 key 多次调watch要记得去重,否则会建出 N 个 watch 流、回调触发 N 次。learnhub 没这个需求(它没 watch),生产代码里要防。
注意:watch 的回调可能对同一个事件触发多次。Etcd 的 watch 协议是 at-least-once——网络分区时客户端会从某个 revision 重新拉、已经处理过的事件可能再推一遍。所以回调必须幂等:刷新一个内存值天然幂等(覆盖写)、切 flag 天然幂等;但”收到事件就 +1 计数”这种就不行,会多计。设计回调时永远假设”它会被重复调用”。
注意:watcher 是有成本的资源——每个 watch 是一条长连接、占内存。生产里要么用 watch --prefix 一次订阅一整个前缀(推荐,配合分层 key 设计 /config/app1/*),要么对每个 key 显式 watch 并在模块销毁时清理。learnhub 作为单体没这个压力,微服务规模下要管。
delete 就是个普通的删除操作,没什么特别——放进来是因为 learnhub 的 EtcdService 只有 get/put、没有 delete,配置中心完整 CRUD 该有。
第六步:Etcd 和 @nestjs/config 怎么分层共存
到这里有个问题必须回答:learnhub 既有 @nestjs/config 的 .env、又有 Etcd,配置到底放哪?答案是按”什么时候读、改了要不要重启”分层:
| 配置类型 | 例子 | 放哪 | 为什么 |
|---|---|---|---|
| 启动参数(bootstrap) | MySQL 地址、Redis 地址、Etcd 自身地址、JWT secret | .env + @nestjs/config | 应用连 Etcd 之前就要用,显然不能存 Etcd |
| 运行期可变业务配置 | feature flag、限流阈值、缓存 TTL、灰度比例 | Etcd | 改了要立即生效、不重启 |
| 几乎不变的常量 | 分页默认 size、密码盐值 | .env 或代码常量 | 改它要发版无所谓 |
简单的判断标准:“这个值会不会在不去重启的前提下被改?” 会 → Etcd;不会 → .env。
一个反例帮助理解:MySQL 地址放进 Etcd 行不行?——不行。应用启动时 TypeORM 要连库,这时 Etcd 客户端自己都还没建立(更别说 Etcd 可能本身就和这个 MySQL 同一套网络),互相依赖、启动顺序解不开。所以一切”建立配置中心连接之前就要用的”参数,都必须留在 .env。这就是为什么 learnhub 的 configuration.ts 里 mysql / redis / mongo / minio / elasticsearch 全在 .env——它们都是 bootstrap 期参数。
learnhub 现在的 .env 一层是完整的(configuration.ts 覆盖了所有 bootstrap 参数);Etcd 那一层是占位的(只有 get/put 的 admin 接口、没有实际业务在用 Etcd 读运行期配置)。这是合理的——learnhub 是单体、没真正需要热更新的配置。真要落地一个,最自然的切入点是把限流阈值或缓存 TTL 放进 Etcd、用第五步的 watch + 缓存模式接管。
第七步:Nacos——learnhub 没做的另一半(服务发现)
到这里讲的都还是”配置中心”。Etcd 在 learnhub 里就只被当配置中心用——一个 KV 存储读写配置项。但这一章标题里还有个 “Nacos”,以及 Etcd/Nacos 的另一半身份:服务发现。这一节讲清这另一半,以及为什么 learnhub 不需要它。
learnhub 不需要服务发现的原因:learnhub 是个单体应用(一个进程提供 HTTP API + WebSocket + 定时任务),第二十章的 RabbitMQ 也是直接用 amqplib 连一个固定地址的 broker——没有”多个 user-service 实例、调用方要找其中一个”的场景。服务发现是为微服务舰队设计的:N 个 user-service 实例分布在 N 台机器,order-service 想调 user-service 时,去注册中心查”现在有哪些 user-service 实例活着”,拿到一批 IP:port,再负载均衡选一个。
Nacos 比 Etcd 多了什么:Etcd 是个纯 KV 存储,做服务发现要自己用 lease + watch 凑出来(put 一个带 TTL 的 key、定时续租、调用方 get --prefix 或 watch 感知)。Nacos 是阿里开源、内置服务发现 + 配置中心两套 API、带 Web 控制台、客户端自动心跳——开箱即用。
Etcd 做服务注册的原理(理解这个就理解了所有注册中心):
# 1. user-service 实例启动时注册:put 一个带 10 秒租约的 key
etcdctl put /services/user/inst1 '{"ip":"10.0.0.5","port":3001}' --lease=10
# 2. 实例定时续租(每 5 秒一次),证明自己还活着
etcdctl lease keep-alive <lease-id>
# 3. 实例挂了、不再续租 → 10 秒后租约过期、key 自动消失
# 4. order-service 调用前查存活实例
etcdctl get --prefix /services/user
# 5. 或订阅变更、维护本地实例列表
etcdctl watch --prefix /services/user
lease + keep-alive 就是”心跳”的底层机制——实例活着就续租、挂了租约过期自动摘除、调用方永远能查到当前活着的实例。learnhub 没做这一层(它是单体、只有一个实例、不需要互相找)。
Nacos 的注册发现写法(下面的代码不是 learnhub 的,是 nacos npm 包的典型用法,展示生产微服务里这一层怎么写):
// 不是 learnhub 代码 —— 生产微服务里 Nacos 服务注册 + 发现的典型写法
import { NacosNamingService } from 'nacos';
// 服务提供方:启动时注册自己
const naming = new NacosNamingService({ serverList: '127.0.0.1:8848' });
await naming.registerInstance('user-service', {
ip: '10.0.0.5',
port: 3001,
weight: 1,
healthy: true,
});
// 服务调用方:查询存活实例 + 负载均衡选一个
const instances = await naming.getAllInstances('user-service');
const target = instances[Math.floor(Math.random() * instances.length)];
// 然后用 target.ip:target.port 发 HTTP 调用
Nacos 客户端自动发心跳(不用自己写 keep-alive)、实例挂了自动摘除、还能配健康检查(主动探活)。这是它比”用 Etcd 凑服务发现”省心的地方。Nacos 还有内置 Web 控制台,能在浏览器里看服务列表、实例健康状态、配置——这是它比 Etcd(只有命令行)友好的地方。
Etcd vs Nacos 选型:
| 维度 | Etcd | Nacos |
|---|---|---|
| 模型 | 纯 KV 存储 | 注册 + 配置 两套内置 API |
| 控制台 | 无(靠 etcdctl) | 内置 Web 控制台 |
| 一致性 | CP(Raft 强一致) | 可选 AP / CP(默认 AP) |
| 心跳 | 自己写 lease + keep-alive | 客户端自动 |
| 配置定位 | 任意 key(约定前缀 /config/...) | dataId + group + namespace |
| 典型用户 | k8s、CoreDNS | 阿里系、Spring Cloud |
| 适合 | 已上 k8s、追求极简、要强一致 | 要可视化、Java 生态对接、AP 优先 |
判断:上 k8s 选 Etcd(k8s 本身就用它,一套搞定);要可视化、对接 Spring Cloud 选 Nacos;纯 Node 微服务、且没上 k8s,两个都行,看团队熟悉度。
第八步:@nestjs/microservices——比 raw amqplib 更”微服务”的一层
第二十章 learnhub 的 AmqpService 是用 amqplib 直连 RabbitMQ、手动 sendToQueue 发消息——那叫”用消息队列”,不叫”微服务框架”。Nest 自己有个 @nestjs/microservices 包,提供一套微服务传输抽象,是真正”微服务形态”的 Nest 应用该用的层。
它支持的 transports(传输方式):TCP、Redis、NATS、Kafka、RabbitMQ、gRPC、MQTT……换成大白话:你的服务之间通信走什么协议,是 nestjs/microservices 帮你抽象掉的细节。写出来是这样:
// 不是 learnhub 代码 —— @nestjs/microservices 的典型形态
// main.ts 里不用 NestFactory.create,而用 createMicroservice
const app = await NestFactory.createMicroservice(AppModule, {
transport: Transport.TCP, // 或 NATS / Kafka / RABBITMQ / GRPC
options: { host: '0.0.0.0', port: 4001 },
});
await app.listen();
// controller 不再用 @Get / @Post,而用 @MessagePattern(消息驱动)
@Controller()
export class UserController {
@MessagePattern({ cmd: 'get_user' })
getUser(data: { id: number }) {
return this.userService.findOne(data.id);
}
}
这层 learnhub 故意没用——它是单体,第二十章的 amqplib 直连已经够完成”发领域事件给通知服务”这个唯一需求。但你要拆微服务,@nestjs/microservices + 注册中心(Nacos / Etcd)+ 配置中心(Etcd / Nacos)才是完整的一套:传输层由 nestjs/microservices 管、实例发现由注册中心管、运行期配置由配置中心管。learnhub 教你第一块(消息驱动)、这一章教你后两块(配置中心 + 服务发现),合起来就是微服务三大基础设施的认知。
注意:上了 @nestjs/microservices 不是免费的——它从 HTTP 换到了自有协议(TCP / NATS / etc.),原本 Swagger、HTTP 中间件、浏览器直接调这些都不再适用。单体能做的事就不要为了”显得微服务”而拆。learnhub 选 amqplib 直连、保留 HTTP 形态,是有意的取舍。
第九步:用 learnhub 的配置接口读写 Etcd
learnhub 给 Etcd 配了一个 admin 接口,串起来试一下:
// learnhub/src/modules/etcd/etcd.controller.ts
@ApiTags('配置中心')
@ApiBearerAuth()
@Controller({ path: 'config', version: '1' })
export class EtcdController {
constructor(private readonly etcd: EtcdService) {}
@Get(':key')
@RequirePermission('user:read')
async get(@Param('key') key: string) {
return { key, value: await this.etcd.get(key) };
}
@Put(':key')
@RequirePermission('user:update')
async put(@Param('key') key: string, @Body() body: { value: string }) {
await this.etcd.put(key, body.value);
return { key, value: body.value };
}
}
跑起来(先 docker run 起 Etcd、再登录拿 token):
# 起 Etcd
docker run -d --name etcd-test -p 2379:2379 -e ETCD_ROOT_PASSWORD=123456 bitnami/etcd
export ETCD_HOSTS=http://127.0.0.1:2379
export ETCD_USER=root
export ETCD_PASSWORD=123456
# 登录拿 token(第十一章讲过)
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123456"}'
# 写一个配置项
curl -X PUT http://localhost:3000/api/v1/config/feature/new_ui \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{"value":"{\"enabled\":true}"}'
# 读出来
curl -H "Authorization: Bearer eyJ..." http://localhost:3000/api/v1/config/feature/new_ui
# {"key":"feature/new_ui","value":"{\"enabled\":true}"}
注意几件事:
- 路由是
/api/v1/config/:key,前缀/api来自第一章main.ts的setGlobalPrefix('api')、版本号v1来自@Controller({ version: '1' })。 - 需要
user:read/user:update权限——第十二章 RBAC 那套,配置项属于敏感操作、不能让普通用户随便改。 - 写进去的 value 是字符串(Etcd 本身只存 bytes),结构化数据要自己
JSON.stringify/JSON.parse。learnhub 没在这里做 JSON 包装,调用方自己处理。
Etcd 没起来也能调这个接口——get 返回 null、put warn 一下、HTTP 还是 200。这就是第四步”优雅降级”的体感:配置中心挂了,admin 接口照样能响应(只是说”读不到”),不会抛 500。
这一章的成果
- 打开 learnhub 的
src/modules/etcd/,吃透了**@Global()模块**、Etcd3客户端的构造(懒连接、不在onModuleInit)、get/put的 try/catch 优雅降级——并理解为什么每个点都要那么写。 - 读懂 learnhub 文件注释里”生产里通常再配合 watch”那句没说完的话:补上了
watch配置热更新 +delete+ 内存缓存让读永不抛,以及 watch 回调必须幂等这条铁律。 - 讲清了 Etcd 和
@nestjs/config的分层共存:.env管 bootstrap 期参数(MySQL/Redis/Etcd 本身的地址)、Etcd 管运行期可变业务配置(feature flag、阈值)。 - 学到了 learnhub 作为单体用不上的另一半:Nacos 的服务注册 + 发现 + 健康检查、Etcd/Nacos 的 AP vs CP 选型、以及
@nestjs/microservices的 transports——这是真正微服务规模才需要的基础设施。
常见问题
- 配置中心和
@nestjs/config的.env有啥区别:.env启动时读一次、静态、改了要重启;配置中心能 watch 实时推送、改一处全部实例生效。单体用.env够,微服务集中管配置用配置中心。 - 为什么不用 Redis 做配置中心:Redis 无法 watch 一个”还不存在”的 key 的创建事件,配置常动态新增;而且 Redis 追求吞吐、最终一致,配置要求强一致。Etcd 的 Raft Quorum 写入保证一致。
- Etcd 还是 Nacos:上 k8s 选 Etcd(k8s 自身就用它);要 Web 控制台、对接 Spring Cloud、AP 优先选 Nacos。
- learnhub 为什么没做 watch / 服务发现:它是单体、没真正需要热更新的配置、也没多实例互相调用。learnhub 把 Etcd 当配置中心 KV 用、配一个 admin 接口,是教学取舍;生产微服务要补上 watch 热更新和注册中心。
- Etcd 挂了应用会怎样:learnhub 的
get返回 null、putwarn,应用不崩。但调用方要处理 null(fallback 到默认值或.env),否则会把 null 当配置用。生产里推荐”内存缓存 + watch 刷新”,读永不抛。 - watch 回调为什么可能触发多次:Etcd 的 watch 协议是 at-least-once,网络分区时客户端会从某个 revision 重拉、可能重推已处理的事件。回调必须幂等。
- Etcd 自己成了关键路径怎么办:上集群(3 / 5 节点 Raft 容错)+ 应用侧优雅降级(缓存 +
.envfallback),让 Etcd 完全不可用时应用仍能用”上次已知的好值”跑。
下一章是整门课的收尾——用 Docker Compose 把 Nest + MySQL + Redis + Nginx + 全部依赖编排到一起,docker compose up 一条命令拉起整套 learnhub,容器间用名字互访,并把前面每一章学到的东西在这个最终编排里对上号。