当我们将一个单体应用拆分为多个独立的微服务后,紧接着会遇到两个棘手的问题:服务实例的网络地址如何被调用方知晓? 以及 外部客户端如何统一、安全地访问这些分散的服务? 这就引出了服务注册与发现,以及 API 网关层的设计。这两个组件是微服务架构从“服务拆分”走向“高可用治理”的关键基础设施。
23.4.1 服务注册与发现
为什么需要注册与发现
微服务运行在容器化环境中,服务实例经常会因为自动扩缩容、故障恢复或滚动更新而发生 IP 地址和端口号的变更。如果使用硬编码的 IP 列表或者静态负载均衡策略,一旦实例发生变动,就需要手动更新配置并重启服务,这在动态环境下是不可接受的。服务注册与发现的核心目标就是 让服务实例的位置信息能够动态维护,调用方始终能获取到当前可用的实例列表。
一个典型的注册与发现流程包含三个角色:
- 服务提供者 在启动时将自身信息(服务名、IP、端口、健康检查地址等)注册到注册中心。
- 注册中心 存储所有可用服务实例的列表,并通过心跳或主动探测检测实例健康状态,自动剔除异常实例。
- 服务消费者 从注册中心获取指定服务的可用实例列表,通常配合负载均衡策略选出其中一个实例发起请求。
Node.js 生态中的注册中心选型
市面上有很多成熟的注册中心,Node.js 服务可以通过对应的客户端库进行集成:
- Consul:Hashicorp 出品,功能完善,提供 HTTP/DNS 接口、健康检查、KV 配置存储。Node.js 客户端可使用
consulnpm 包。 - etcd:CoreOS 开发的分布式 KV 存储,强一致性,适合作为配置中心和注册中心。常用客户端为
etcd3。 - ZooKeeper:Apache 顶级项目,历史悠久,但较重。Node.js 可以使用
node-zookeeper-client。 - Kubernetes Service:如果应用已经容器化并运行在 K8s 中,可以直接使用 Kubernetes 的内置 Service 作为服务发现机制,无需额外部署注册中心。Service 提供的固定 ClusterIP 和 DNS 名称(如
my-service.namespace.svc.cluster.local)天然解决了发现需求。
对于中小规模项目,Consul 功能全面、生态良好,是 Node.js 微服务中很常见的选择。如果团队已经深度使用 Kubernetes,则优先利用 K8s Service,减少维护成本。
Node.js 服务注册示例
假设我们使用 Consul 作为注册中心,一个 Node.js 服务的注册过程大致如下:
const express = require('express');
const consul = require('consul')();
const app = express();
const PORT = process.env.PORT || 3000;
const SERVICE_NAME = 'user-service';
const SERVICE_ID = `${SERVICE_NAME}-${PORT}`;
app.get('/health', (req, res) => res.send('OK'));
app.listen(PORT, () => {
console.log(`服务启动在端口 ${PORT}`);
// 注册到 Consul
consul.agent.service.register({
name: SERVICE_NAME,
id: SERVICE_ID,
address: 'localhost', // 实际应为容器内可访问的地址
port: PORT,
check: {
http: `http://localhost:${PORT}/health`,
interval: '10s',
timeout: '5s',
deregistercriticalserviceafter: '30s', // 取消注册非健康实例
},
}, (err) => {
if (err) throw err;
console.log('成功注册到 Consul');
});
// 优雅退出时注销服务
process.on('SIGINT', () => {
consul.agent.service.deregister(SERVICE_ID, () => {
process.exit();
});
});
});
关键点:
- 注册时指定健康检查的 HTTP 端点,Consul 会定期请求该端点来确认实例存活。
- 设置
deregistercriticalserviceafter,当健康检查连续失败超过一段时间后,自动从注册中心移除实例。 - 进程退出时主动取消注册,避免短暂不可用的实例残留。
服务发现的两种模式
服务消费者获取实例列表的方式主要有两种:
- 客户端发现:消费者直接查询注册中心,获取实例列表,然后在本地执行负载均衡(例如使用
weighted或round-robin)。Node.js 中可以这么做:
const consul = require('consul')();
async function getServiceInstances(serviceName) {
const result = await consul.catalog.service.nodes(serviceName);
return result.map(node => `http://${node.ServiceAddress}:${node.ServicePort}`);
}
// 配合简单的随机负载均衡
async function callUserService() {
const instances = await getServiceInstances('user-service');
const url = instances[Math.floor(Math.random() * instances.length)];
// 使用 axios 或 fetch 请求 url
}
这种方式简单直观,但要求消费者必须知道注册中心的位置,且带有一层客户端依赖。
- 服务端发现:消费者通过一个中间层(通常是负载均衡器)调用服务,由该中间层查询注册中心并转发。例如部署一个 Nginx 或专用的 API 网关,消费者只面向网关请求,无需关心后端实例变化。在 Kubernetes 中,这由 kube-proxy + Service 天然实现。
在实际 Node.js 微服务项目中,如果不希望每个服务都直接访问 Consul,可以采用 服务端发现,将发现逻辑集中在网关层。
23.4.2 网关层设计
网关的定位与核心功能
API 网关是外部客户端访问微服务系统的统一入口。它像是一个“看门人”,负责将请求路由到正确的后端服务,同时在此处实现所有横向关注的公共逻辑,避免每个微服务重复造轮子。
网关应该承担的主要职责包括:
- 路由转发:根据请求路径、域名、Header 等规则分发到对应的微服务。
- 认证与鉴权:统一校验 JWT、Session 或 API Key,减轻微服务的鉴权压力。
- 限流与熔断:使用令牌桶或滑动窗口算法保护后端,防止流量尖峰。
- 请求聚合:将多个微服务的数据聚合为一个响应,减少客户端请求次数(BFF 模式)。
- 日志与监控:记录所有请求的访问日志、耗时、状态码,推送至集中监控系统。
- 跨域处理:统一处理 CORS 头,而非在每个微服务中单独配置。
- 协议转换:对外提供 RESTful API,对内可能转发 gRPC 或消息队列。
网关技术选型
非 Node.js 方案(稳定、性能极高):
- Nginx + OpenResty:利用 Lua 脚本扩展,可实现动态路由、限流、认证。配合 lua-resty-consul 等库可直接对接注册中心。
- Kong:基于 Nginx 的成熟 API 网关,插件市场丰富,支持 Rate Limiting、OAuth2、Prometheus 监控。
- APISIX:Apache 顶级项目,云原生架构,生态扩展性好,支持多种协议。
Node.js 方案(灵活性高,定制成本低):
- Express Gateway:以 Express 为核心的 API 网关,基于插件系统,可以快速配置路由、限流、认证。
- 自建网关:使用 Express 或 Fastify 搭配
http-proxy-middleware实现简单的反向代理,再逐步添加中间件扩展功能。
在团队以 Node.js 为主的全栈架构中,选择自建网关可以充分利用现有技术栈,方便定制个性化需求。但需要注意,网关作为所有流量的必经之路,性能必须足够高,且不能引入过多同步阻塞逻辑。
自建 Node.js 网关示例
下面是一个基于 Express 和 http-proxy-middleware 的动态路由网关,与 Consul 集成实现服务发现:
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const consul = require('consul')();
const app = express();
// 简单的内存缓存:服务名 -> 实例列表
const serviceCache = new Map();
async function getServiceUrl(serviceName) {
// 如果缓存有效,直接返回随机实例
const cached = serviceCache.get(serviceName);
if (cached && cached.expireAt > Date.now()) {
const instances = cached.instances;
return instances[Math.floor(Math.random() * instances.length)];
}
// 从 Consul 获取健康实例
const result = await consul.health.service({ service: serviceName, passing: true });
const instances = result.map(entry => `http://${entry.Service.Address}:${entry.Service.Port}`);
serviceCache.set(serviceName, { instances, expireAt: Date.now() + 10 * 1000 }); // 10s 缓存
if (instances.length === 0) throw new Error('无可用服务实例');
return instances[Math.floor(Math.random() * instances.length)];
}
// 动态路由中间件
app.use('/api/:serviceName', async (req, res, next) => {
try {
const targetUrl = await getServiceUrl(req.params.serviceName);
// 创建代理中间件并执行
const proxy = createProxyMiddleware({
target: targetUrl,
changeOrigin: true,
pathRewrite: { [`^/api/${req.params.serviceName}`]: '' },
});
proxy(req, res, next);
} catch (err) {
res.status(502).json({ error: '服务不可用' });
}
});
// 通用中间件:认证(示例简单地检查 Header)
app.use((req, res, next) => {
const token = req.headers.authorization;
if (!token || !token.startsWith('Bearer ')) {
return res.status(401).json({ error: '未授权' });
}
// 此处可验证 JWT 并解析用户信息
next();
});
// 请求日志
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.path} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
});
app.listen(8080, () => console.log('API 网关启动在 8080'));
这个简易网关实现了:
- 路径约定:前端通过
/api/user-service/users的方式访问用户服务。 - 动态服务发现:从 Consul 获取健康实例,并对转发目标进行简单随机负载均衡。
- 轻量缓存:避免每次请求都查询 Consul,缓存 10 秒。
- 集成认证和日志:所有通过网关的流量统一进行安全校验和访问记录。
网关层的高级考量
高可用与性能
- 网关本身需要多实例部署,前面再挂上负载均衡器(如 Nginx 或云平台的 SLB)。
- Node.js 网关应该避免在主线程执行 CPU 密集计算或同步阻塞操作,认证可异步调用内部服务验证 Token。
- 对于超高并发场景,可以考虑在网关前引入 OpenResty 或 Envoy 做前置转发,Node.js 仅做轻量的路由与聚合。
与注册中心的深度结合
网关是连接外部世界和内部服务群的枢纽,必须实时感知后端实例的变化。除了上述基于轮询缓存的简单实现,更优雅的做法是 订阅注册中心的服务变更事件,避免额外的轮询延迟。Consul 提供了 Watch 端点,可以将变更推送至网关:
const watcher = consul.watch({ method: consul.health.service, options: { service: 'user-service' } });
watcher.on('change', (nodes) => {
// 更新本地路由表
});
watcher.on('error', (err) => console.error('Watch 错误', err));
这样一来,后端实例上下线后几乎可以实时反映到网关路由表中,大幅缩短故障转移时间。
配置中心与动态路由
网关的路由规则(路径映射、限流参数等)不应该硬编码在代码里,而应该通过配置中心动态下发。例如,将路由规则存储在 Consul KV 或 etcd 中,网关定期拉取或监听变更,实现零停机更新路由策略。这种方式让网关变得更加灵活,可以随时上线新的微服务或更改限流策略,无需重启网关进程。
安全与限流
网关是所有流量的第一道防线,必须内置足够的安全策略:
- 利用
express-rate-limit或rate-limiter-flexible(支持 Redis 存储)实现全局限流和针对特定用户的限流。 - 对传入的 Token 或 Cookie 进行 JWT 验证,并把解析出的用户信息通过 Header 传递给下游微服务,如
req.headers['x-user-id']。 - 防止请求头注入,过滤敏感路径,配置 CORS 白名单。
23.4.3 注册、发现及网关的实战组合
在真实的 Node.js 微服务项目中,一个典型的组合是:
- 服务注册:每个 Node.js 服务在启动时向 Consul 注册,并暴露
/health端点。 - 服务发现:API 网关通过 Consul Watch 机制或定期拉取维护最新的服务路由表。
- 网关层:承担统一入口,进行 JWT 认证、限流、日志、跨域处理,并根据路由表将请求代理到对应的后端微服务。
- 负载均衡:由网关在多个实例间做客户端负载均衡(基于随机或轮询),或由 Kubernetes Service 自动完成。
这样的架构使得后端的每个微服务只需关注自身业务逻辑,而公共关注点全部沉淀在网关和注册中心中,极大简化了微服务治理的复杂度。
需要注意的是,随着微服务数量的增长,网关可能逐渐成为单点瓶颈。因此,生产环境中的网关必须支持水平扩展,并做好监控、降级和熔断预案,保障整体系统的稳定性。在 Node.js 技术栈中,我们完全可以用轻量的 Express 或 Fastify 构建出这样的网关,而不必依赖重型中间件,印证了 Node.js 在微服务中间层灵活、高效的优势。