NestJS 别用 Express 了!Fastify + Nacos 打造配置实时推送
本文基于 NestJS 11 + Fastify 5 + Nacos v3 API。项目代码见文末仓库链接。
一、为什么微前端需要一个 BFF
前端产出的 HTML、JS、CSS 都是静态文件。部署到服务器后,没有运行时读取环境变量或配置文件的能力(不像 Node.js 后端)。
但微前端的子应用列表本身就是动态的——哪些子应用可用、它们的入口地址是什么,这些信息可能随环境变化(开发/测试/预发/生产),甚至需要在不停服的情况下热更新。
常见的三种配置注入方式:
| 方案 | 原理 | 一次构建到处部署 | 缺点 |
|---|---|---|---|
| 构建时注入(Vite .env) | import.meta.env.VITE_*编译期替换 | 否 | 每个环境需重新构建 |
| 部署时注入(entrypoint.sh) | 容器启动时写入 config.json | 是 | 更新需重启容器 |
| 运行时加载(/api/config) | 启动时 fetch 后端接口 | 是 | 需要额外的后端服务 |
我们选择了第三种——在微前端应用和配置中心之间加一层 BFF(Backend For Frontend),同时解决两个问题:读取配置,实时推送变更。
┌──────────────┐ │ Nacos │ 配置中心 │ (配置源) │ └──────┬───────┘ │ HTTP v3 API (polling) ┌──────▼───────┐ │ BFF (NestJS) │ 中间层 │ port 3000 │ └──┬────────┬──┘ │ │ GET /api/config GET /api/config/stream (SSE) │ │ ┌──▼────────▼──┐ │ Nginx │ 反向代理 │ port 80 │ └──────┬───────┘ │ ┌──────▼───────┐ │ 浏览器 │ │ (qiankun / │ │ wujie 主应用)│ └──────────────┘二、为什么选 NestJS + Fastify 而不是 Express
2.1 Fastify 适配器
NestJS 默认使用 Express,但它支持切换 HTTP 适配器。一行改动:
// packages/bff/src/main.tsimport{FastifyAdapter,NestFastifyApplication}from'@nestjs/platform-fastify'constapp=awaitNestFactory.create<NestFastifyApplication>(AppModule,newFastifyAdapter(),// 替换掉默认的 Express)awaitapp.listen(3000,'0.0.0.0')2.2 为什么不用 Express
| 维度 | Express | Fastify |
|---|---|---|
| 吞吐量 | 基准线 | 2-3x Express |
| TypeScript 支持 | 需要@types/express | 原生 TypeScript,类型完整 |
| 插件系统 | 中间件 | 插件(封装更好,可组合) |
| 序列化 | 手动 JSON.stringify | 内置 fast-json-stringify(schema-based,更快) |
| 响应流操作 | 需要通过底层 Node res 对象 | res.raw直接暴露底层流 |
在这个项目里,最关键的理由是写 SSE 时需要直接操作底层响应流。Fastify 的res.raw就是底层的 Node.jsServerResponse,读写响应头、写数据都很直接:
// 写 SSE 响应头res.raw.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive',})res.raw.write('\n')res.raw.flushHeaders()Express 也能做到,但 Fastify 的封装更薄,踩坑更少。
另外,NestJS 的 Controller 可以直接注入 Fastify 的类型:
importtype{FastifyReply,FastifyRequest}from'fastify'@Get('config/stream')stream(@Query('dataId')dataId:string,@Req()req:FastifyRequest,@Res()res:FastifyReply){// ...}依赖注入 + 完整类型推断 + 操作底层流的便利性,这三者结合在一起是选 Fastify 的核心理由。
三、自研 Nacos 接入
Nacos 官方提供了 Java SDK 和 Go SDK,但没有维护良好的 Node.js SDK(社区有但不稳定)。所以我们直接调 REST API。
3.1 认证与 Token 管理
// packages/bff/src/nacos/nacos.service.tsasynclogin():Promise<string>{constres=awaitfetch(`http://${this.nacosAddr}/nacos/v3/auth/user/login`,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:`username=${encodeURIComponent(this.nacosUsername)}&password=${encodeURIComponent(this.nacosPassword)}`,})if(!res.ok)thrownewError(`Nacos login failed:${res.status}`)constbody=awaitres.json()this.accessToken=body.accessToken// token 过期前 5 分钟自动刷新this.tokenExpiresAt=Date.now()+(body.tokenTtl||18000)*1000-300_000returnthis.accessToken}asyncensureToken():Promise<string>{if(!this.accessToken||Date.now()>=this.tokenExpiresAt){awaitthis.login()}returnthis.accessToken!}关键设计:
tokenExpiresAt比 Nacos 返回的 TTL 提前 5 分钟,留足余量ensureToken()在每次请求前检查,过期自动刷新- 如果因为网络抖动导致登录失败,请求会直接抛错——让调用方重试
3.2 获取配置与缓存
asyncgetConfig(dataId:string):Promise<{config:unknown;rawContent:string}>{consttoken=awaitthis.ensureToken()consturl=`http://${this.nacosAddr}/nacos/v3/client/cs/config?dataId=${encodeURIComponent(dataId)}&groupName=DEFAULT_GROUP`constres=awaitfetch(url,{headers:{accessToken:token}})if(!res.ok)thrownewError(`Nacos config fetch failed:${res.status}`)constbody=awaitres.json()if(body.code!==0)thrownewError(`Nacos error:${body.message}`)constraw=body.data.contentconstconfig=JSON.parse(raw)this.cache.set(dataId,{data:config,ts:Date.now()})return{config,rawContent:raw}}缓存策略:
- 按
dataId分组的Map<string, { data, timestamp }> - 默认 TTL 60 秒,可通过
CACHE_TTL_MS环境变量调整 - on-demand 接口(
GET /api/config)优先走缓存 - 轮询检测变更时 bypass 缓存,直接拉最新数据
3.3 变更检测
privatestartPolling(){setInterval(async()=>{for(constdataIdofthis.trackedDataIds){try{const{config,rawContent}=awaitthis.getConfig(dataId)constnewHash=String(rawContent.length)+':'+rawContent.slice(0,50)constprevHash=this.hashes.get(dataId)if(prevHash!==newHash){this.hashes.set(dataId,newHash)this.onChange?.(dataId,config)}}catch{// 单次轮询失败不影响后续}}},Number(process.env.POLL_INTERVAL_MS)||10_000)}变更检测用的是一个轻量 hash:内容长度 + 前 50 个字符。对于 JSON 配置文件(通常几百字节),这个粒度足以区分任何实质变更。轮询间隔默认 10 秒。
当检测到变更时,通过onChange回调通知订阅方——这个订阅方就是 SSE 服务。
四、SSE 实时推送
4.1 客户端管理
// packages/bff/src/sse/sse.service.ts@Injectable()exportclassSseService{privateclientsByDataId=newMap<string,Set<FastifyReply>>()add(dataId:string,client:FastifyReply){letgroup=this.clientsByDataId.get(dataId)if(!group){group=newSet()this.clientsByDataId.set(dataId,group)}group.add(client)}remove(dataId:string,client:FastifyReply){constgroup=this.clientsByDataId.get(dataId)if(!group)returngroup.delete(client)if(group.size===0)this.clientsByDataId.delete(dataId)}broadcast(dataId:string,data:unknown){constgroup=this.clientsByDataId.get(dataId)if(!group||group.size===0)returnconstpayload=JSON.stringify(data)constmessage=`event: config-update\ndata:${payload}\n\n`for(constclientofgroup){try{client.raw.write(message)}catch{group.delete(client)// 写入失败 → 清理死连接}}}}设计要点:
- 按
dataId分组——qiankun 主应用和 wujie 主应用订阅的是不同的配置,广播时只推给关心该配置的客户端 - 广播时用
try/catch——如果客户端已断开但还没触发 close 事件,write()会抛错,直接清理 - 空组自动删除——所有客户端断开后,释放内存
4.2 SSE Endpoint
// packages/bff/src/config-stream/config-stream.controller.ts@Controller('api')exportclassConfigStreamControllerimplementsOnModuleInit{constructor(privatereadonlysse:SseService,privatereadonlynacos:NacosService,){}onModuleInit(){// 关键:将 Nacos 的变更检测和 SSE 广播连接起来this.nacos.onChange=(dataId,data)=>{this.sse.broadcast(dataId,data)}}@Get('config/stream')stream(@Query('dataId')dataId:string,@Req()req:FastifyRequest,@Res()res:FastifyReply){constid=dataId||'qiankun-main-config'res.raw.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive',})res.raw.write('\n')res.raw.flushHeaders()this.sse.add(id,res)req.raw.on('close',()=>{this.sse.remove(id,res)})}}onModuleInit里的那一行是整个系统的关键连线:
NacosService.onChange ──→ SseService.broadcast ──→ 所有订阅的浏览器当 Nacos 上的配置被修改,10 秒内(轮询间隔),所有打开了 SSE 连接的浏览器都会收到推送,不用手动刷新页面。
4.3 On-Demand 接口
顺带实现一个普通的 REST 接口,用于浏览器首次加载:
// packages/bff/src/config-api/config-api.controller.ts@Controller('api')exportclassConfigApiController{@Get('config')asyncgetConfig(@Headers('host')host:string){constdataId=this.dataIdFromHost(host)constcached=this.nacos.getFromCache(dataId)if(cached)returncachedconst{config}=awaitthis.nacos.getConfig(dataId)returnconfig}privatedataIdFromHost(host:string):string{if(host.startsWith('qiankun'))return'qiankun-main-config'if(host.startsWith('wujie'))return'wujie-main-config'return'qiankun-main-config'}}这里通过 Host 头区分请求来源——qiankun 主应用和 wujie 主应用请求的配置是不同的 Nacos dataId。一个 BFF 同时服务两套微前端容器。
五、Nginx 层的角色
两套微前端容器(qiankun-main / wujie-main)的 Nginx 配置一模一样:
# packages/qiankun-main/nginx.conf server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri /index.html; # SPA fallback } location /api/ { proxy_pass http://bff:3000; # API 反向代理到 BFF proxy_http_version 1.1; } }为什么要通过 Nginx 反向代理而不是前端直接请求 BFF:
- 同域:前端页面和
/api在同一个域名下,避免跨域问题 - 解耦:前端不关心 BFF 的实际地址(
bff:3000是 Docker 内网地址) - 统一入口:Nginx 可以统一做日志、限流、缓存等
六、完整数据流
把前面的所有组件串起来,一条配置变更的完整旅程:
1. 运维在 Nacos 控制台修改配置 (e.g. 新增一个子应用) │ 2. NacosService 轮询检测到变更 (10s 内) ├─ hash 对比:上次 ≠ 这次 ├─ 更新缓存 └─ 触发 onChange(dataId, newConfig) │ 3. SseService.broadcast(dataId, newConfig) ├─ 找到所有订阅该 dataId 的 SSE 客户端 ├─ 遍历写入 event: config-update\ndata: {json}\n\n └─ 通过 Fastify res.raw.write() 推送到浏览器 │ 4. 浏览器 EventSource 收到 config-update 事件 ├─ 解析 JSON → window.__APP_CONFIG__ 更新 ├─ 触发 CustomEvent('config-changed') │ ├─ Navbar 组件重渲染 → 新子应用出现在导航栏 ├─ App.tsx 重渲染 → 新路由注册 └─ qiankun-main 额外:registerMicroApps(新增子应用) │ 5. 用户看到新子应用入口 —— 全程无刷新从配置变更到用户可见,端到端延迟 = 轮询间隔(10s)+ 网络传输(<100ms)。
七、前端如何消费
前端主应用启动时的流程(以 wujie-main 为例):
// packages/wujie-main/src/main.tsxasyncfunctionbootstrap(){// 1. 首次加载:fetch /api/configawaitloadConfig()// 2. 渲染应用(此时 window.__APP_CONFIG__ 已就绪)createRoot(document.getElementById('root')!).render(<App/>)// 3. 订阅 SSE,接收后续变更subscribeConfig()}// packages/wujie-main/src/config/loader.tsexportasyncfunctionloadConfig(){constres=awaitfetch('/api/config')constconfig=awaitres.json()window.__APP_CONFIG__=config}exportfunctionsubscribeConfig(){constes=newEventSource('/api/config/stream?dataId=wujie-main-config')es.addEventListener('config-update',(event)=>{constconfig=JSON.parse(event.data)window.__APP_CONFIG__=config window.dispatchEvent(newCustomEvent('config-changed'))})}首次加载走 REST 接口(简单直接),后续变更走 SSE(实时推送)。前端代码不关心配置是从 Nacos 来的还是从文件来的——它只关心window.__APP_CONFIG__里有正确的 JSON。
项目源码
完整代码见:https://gitee.com/bytesifter/front-example
├── packages/ │ ├── qiankun-main/ ← qiankun 主应用 │ ├── wujie-main/ ← wujie 主应用 │ ├── exp1-react/ ← React 子应用 │ ├── exp2-vue/ ← Vue 子应用 │ └── bff/ ← NestJS + Fastify BFF 服务 └── articles/ ← 本文及相关文章更多关于 qiankun 和 wujie 如何消费配置、如何做框架选型,请阅读前一篇文章:《qiankun Vite 8 踩坑》。
