一、为什么网关成了微服务的“调度台”

1.1 你每天要面对的真实痛点

咱们在实际开发里,一个应用如果拆成了好几个服务,比如商品服务、订单服务、用户服务,那你马上会遇到一个特别头疼的问题:客户端到底该去访问哪个地址?原来一个接口能搞定的事,现在得调好几个服务,拼数据、处理跨域、统一鉴权……如果每个服务都把自己的地址暴露给前端,那整个系统就像一盘散沙。更麻烦的是,某个服务要扩容,地址变了,前端也得跟着改。这时候,你特别希望有一个“总入口”,能先把所有请求接下来,再帮你去找到真正的服务,这样前端只需要记住一个地址就行了。这个“总入口”就是API网关。

1.2 Express能帮上什么忙

Express是Node.js世界里特别常见的轻量级Web框架,很多人觉得它只能写写接口,其实它完全能胜任一个“微型API网关”。Express的中间件机制特别灵活,你可以在请求进来之后、转发出去之前,任意插入各种处理逻辑,比如打日志、验身份、限流,然后再把请求转发给下游服务。所以,咱们完全可以用Express自己动手写一个网关,不需要一上来就上Kong或Nginx这种重型武器。接下来,我会从服务发现和请求转发这两个核心功能开始,手把手带你把一个Express网关搭起来。

二、服务发现:先解决“服务在哪”的问题

2.1 静态配置和动态发现怎么选

服务发现说白了,就是网关要知道“用户服务”现在跑在哪台机器、哪个端口上。最简单的方式是在配置文件里写死地址,比如“用户服务”的地址是127.0.0.1:3001。这种方式适合服务很少、不需要经常变动的场景。但一旦服务多了,或者要搞水平扩展,静态配置就非常难受。你每加一台机器,都得改配置、重启网关。动态发现的好处是,服务启动时主动去注册中心报个到,网关要找人时再问注册中心,这样服务增减都不用重启网关,非常灵活。

2.2 用Consul做服务发现的完整示例

这里我们用一个特别常见的注册中心Consul来演示。我们的技术栈统一是Node.js + Express,先安装axios用来发HTTP请求。

// 技术栈:Node.js + Express + axios
const axios = require('axios');

// Consul的HTTP API地址
const CONSUL_HOST = 'http://127.0.0.1:8500';

/**
 * 注册一个服务到Consul
 * @param {string} serviceName 服务名,比如 user-service
 * @param {string} serviceId 实例ID,比如 user-service-1
 * @param {number} port 服务端口
 */
async function registerService(serviceName, serviceId, port) {
  const payload = {
    ID: serviceId,          // 实例唯一ID
    Name: serviceName,      // 服务名
    Tags: ['express'],      // 标签,可以用来标记版本或环境
    Address: '127.0.0.1',   // 服务IP
    Port: port,             // 服务端口
    Check: {                // 健康检查,让Consul帮忙看着这个服务
      HTTP: `http://127.0.0.1:${port}/health`,
      Interval: '10s',      // 每10秒检查一次
      Timeout: '3s'
    }
  };

  // Consul的注册接口是PUT请求
  await axios.put(`${CONSUL_HOST}/v1/agent/service/register`, payload);
  console.log(`服务 ${serviceName} 注册成功`);
}

// 示例:启动时注册一个用户服务
registerService('user-service', 'user-service-1', 3001)
  .catch(err => {
    console.error('注册失败', err.message);
  });

2.3 把服务发现结果做成可缓存模块

每次转发都去问Consul太慢了,我们最好把查询结果缓存起来,并且定期刷新。下面写一个简单的服务发现缓存模块。

// 技术栈:Node.js
const axios = require('axios');

class ServiceRegistry {
  constructor() {
    this.cache = new Map(); // 缓存服务名到地址列表的映射
    this.refreshTime = 5000; // 5秒刷新一次
  }

  /**
   * 根据服务名获取所有健康的实例地址
   * @param {string} serviceName 服务名
   * @returns {Promise<Array>} 返回类似 [{host:'127.0.0.1', port: 3001}]
   */
  async getInstances(serviceName) {
    // 如果缓存里有,并且没过期,直接返回
    if (this.cache.has(serviceName)) {
      return this.cache.get(serviceName);
    }

    // 否则去Consul拉取健康实例
    const res = await axios.get(
      `http://127.0.0.1:8500/v1/health/service/${serviceName}?passing=true`
    );

    // 从返回数据中提取 host 和 port
    const instances = res.data.map(item => {
      return {
        host: item.Service.Address,
        port: item.Service.Port
      };
    });

    // 存入缓存
    this.cache.set(serviceName, instances);

    // 到点后自动把这条缓存删掉,下一次请求就会重新拉取
    setTimeout(() => {
      this.cache.delete(serviceName);
    }, this.refreshTime);

    return instances;
  }
}

// 使用示例
const registry = new ServiceRegistry();
registry.getInstances('user-service').then(instances => {
  console.log('用户服务实例有:', instances);
});

三、请求转发:把请求送到正确的服务手里

3.1 转发工具的选择

服务发现拿到了下游服务的地址,接下来就得把请求转发过去。Node.js里可以用原生http模块写代理,但太累。我推荐用http-proxy-middleware这个成熟库,它本来就是Express中间件,用起来特别顺手,既支持路径重写,也支持改请求头,而且能完美保留请求体。

3.2 基于http-proxy-middleware的转发示例

先安装依赖:

npm install express http-proxy-middleware axios

下面是一个完整的Express网关启动代码,注意看注释。

// 技术栈:Node.js + Express + http-proxy-middleware
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const axios = require('axios');

const app = express();
// 让Express能解析JSON请求体
app.use(express.json());

// 一个简单的服务发现函数,这里直接写死示例
async function getUserServiceUrl() {
  const res = await axios.get(
    'http://127.0.0.1:8500/v1/health/service/user-service?passing=true'
  );
  const instance = res.data[0].Service;
  return `http://${instance.Address}:${instance.Port}`;
}

// 中间件:动态把请求转发到user-service
app.use('/api/users', async (req, res, next) => {
  try {
    // 每次真实转发前,先问一下服务地址
    const target = await getUserServiceUrl();
    console.log(`本次转发目标:${target}`);

    // 创建代理中间件并立即转发
    const proxy = createProxyMiddleware({
      target,
      changeOrigin: true, // 把请求头里的Host改成目标服务的地址
      pathRewrite: {
        '^/api/users': '/users' // 把 /api/users 重写为 /users
      }
    });
    return proxy(req, res, next);
  } catch (err) {
    // 服务发现失败,直接返回错误
    res.status(502).json({ error: '无法定位用户服务' });
  }
});

// 健康检查接口
app.get('/health', (req, res) => {
  res.send('OK');
});

// 启动网关,监听8080端口
app.listen(8080, () => {
  console.log('API网关已启动:http://localhost:8080');
});

3.3 处理请求体丢失的问题

很多新手做转发时,会发现POST请求到了下游服务就变成空body了。原因很简单,Express默认已经把请求体解析了,你直接用代理转发时,原流已经被消费掉了。解决办法是在代理转发时,把解析出来的body重新放回去。http-proxy-middleware本身会处理原始流,但如果你在它之前用了express.json(),就要格外小心。推荐做法:先做代理,再对特定接口单独做body解析,或者用req.body重新构造请求。下面这个示例演示了怎么用http模块做手动转发,同时保留body。

// 技术栈:Node.js + Express + 原生http模块
const express = require('express');
const http = require('http');

const app = express();

// 这里不急着用 express.json()
app.post('/proxy/order', (req, res) => {
  let body = [];
  req.on('data', chunk => body.push(chunk));
  req.on('end', () => {
    const postData = Buffer.concat(body).toString('utf8');
    const options = {
      hostname: '127.0.0.1',
      port: 3002,
      path: '/order/create',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(postData)
      }
    };

    const proxyReq = http.request(options, proxyRes => {
      res.writeHead(proxyRes.statusCode, proxyRes.headers);
      proxyRes.pipe(res); // 把下游响应原样返回给客户端
    });

    proxyReq.end(postData);
  });
});

四、中间件技巧:让网关具备“大脑”和“手脚”

4.1 中间件执行顺序是命根子

Express中间件执行顺序是从上到下。如果你把日志中间件放在路由后面,那就等于漏掉了本应该记录的请求。同样的,身份校验必须放在转发之前。所以,你设计网关时,要把通用的逻辑放在最前面,再挂具体的路由和代理。

4.2 日志中间件(带请求耗时)

记录每个请求的URL、方法和处理耗时,是网关的重要功能。写一个简单的中间件。

// 技术栈:Node.js + Express
const express = require('express');
const app = express();

// 日志中间件
app.use((req, res, next) => {
  const start = Date.now(); // 记录开始时间
  res.on('finish', () => {  // 响应发送完成时触发
    const elapsed = Date.now() - start;
    console.log(`${req.method} ${req.originalUrl} - ${res.statusCode} - ${elapsed}ms`);
  });
  next();
});

// 一个普通接口
app.get('/ping', (req, res) => {
  res.json({ message: 'pong' });
});

4.3 身份校验中间件(JWT示例)

网关最常见的工作之一,就是把没登录的请求拦在外面。我们用jsonwebtoken库做一个简单的校验中间件。

// 技术栈:Node.js + Express + jsonwebtoken
const jwt = require('jsonwebtoken');
const SECRET_KEY = 'your-secret-key-here'; // 生产环境一定要换成环境变量

function authMiddleware(req, res, next) {
  // 从请求头里取token,格式一般是 Bearer xxxxxx
  const authHeader = req.headers.authorization;
  if (!authHeader) {
    return res.status(401).json({ error: '缺少token' });
  }

  const token = authHeader.split(' ')[1];
  try {
    // 验证token,如果过期或无效会直接抛错误
    const decoded = jwt.verify(token, SECRET_KEY);
    req.user = decoded; // 把解析出来的用户信息挂到req上
    next();
  } catch (err) {
    return res.status(401).json({ error: 'token无效或已过期' });
  }
}

// 把这个中间件用在网关路由上
app.use('/api/protected', authMiddleware);

4.4 统一错误处理中间件

如果网关里发生了未知错误,你肯定不想让客户端看到一堆堆栈信息。写一个统一的错误处理中间件,放在所有路由的最后面。

// 技术栈:Node.js + Express
app.use((err, req, res, next) => {
  console.error('网关内部错误:', err.message);
  res.status(500).json({
    code: 500,
    message: '服务暂时开小差,请稍后再试'
  });
});

注意:错误处理中间件必须有4个参数,即使你用不到next,也要保留这个占位。

4.5 把服务发现和转发揉进中间件

你可以把服务发现、负载均衡、转发这三件事封装成一个通用中间件。这样,新增一个路由就特别简单。

// 技术栈:Node.js + Express + http-proxy-middleware
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const ServiceRegistry = require('./ServiceRegistry'); // 之前写的缓存模块

const app = express();
const registry = new ServiceRegistry();

/**
 * 生成一个通用转发中间件
 * @param {string} serviceName 要转发的服务名
 * @param {string} pathPrefix 网关上的路径前缀
 */
function createGatewayRoute(serviceName, pathPrefix) {
  return async (req, res, next) => {
    try {
      const instances = await registry.getInstances(serviceName);
      if (instances.length === 0) {
        return res.status(503).json({ error: `服务 ${serviceName} 没有可用实例` });
      }
      // 简单轮询:每次都取第一个,你可以自己实现随机或加权
      const target = `http://${instances[0].host}:${instances[0].port}`;

      const proxy = createProxyMiddleware({
        target,
        changeOrigin: true,
        pathRewrite: {
          [`^${pathPrefix}`]: '' // 去掉前缀,按转发到下游对应路径
        }
      });
      return proxy(req, res, next);
    } catch (err) {
      return next(err);
    }
  };
}

// 一条路由就搞定用户服务
app.use('/api/users', createGatewayRoute('user-service', '/api/users'));
// 再来一条订单服务
app.use('/api/orders', createGatewayRoute('order-service', '/api/orders'));

app.listen(8080);

五、常见误区规避清单

5.1 误区:转发响应后还继续执行代码

有时候你会看到这样的写法:

res.redirect('/login');
res.send('end'); // 错误!会导致“Cannot set headers after they are sent”

在中间件里,如果调用了res.sendproxy之后,一定要用return结束函数,否则会报错,尤其在条件分支里。最安全的习惯是所有会结束请求的地方都加上return

5.2 误区:服务发现结果从不刷新

有些同学图省事,在网关启动时查一次服务地址,存到全局变量里再也不用。一旦服务发生扩容或宕机,网关完全不感知,结果就是转发到已挂掉的实例上。解决思路是像我前面那样设置一个缓存过期时间,或者监听Consul的变更事件。

5.3 误区:代理超时设置太“佛系”

如果不给代理设置超时时间,那么当下游服务卡住时,客户端会一直等,最后可能造成大量连接被占满。http-proxy-middleware支持设置proxyTimeouttimeout。建议加上类似下面的配置:

createProxyMiddleware({
  target,
  changeOrigin: true,
  proxyTimeout: 5000, // 代理请求后,等待下游响应5秒
  server: {
    timeout: 5000     // 整个请求的超时时间
  }
});

5.4 误区:忘记保留原始请求头和请求体

有些网关转发时,把Content-TypeAuthorization这些关键请求头丢了,或者POST body被吃掉了。用http-proxy-middleware时,默认会透传headers,但如果你在它前面用了express.json(),有些场景会有问题。我的建议是:要么先做代理再做body解析,要么像前面示例一样手动把body重新塞进去。这个坑非常隐蔽,一定要自测POST请求。

5.5 误区:中间件顺序乱安排

比如你把authMiddleware放在了实际路由的后面,那等于没加。又或者把express.json()放在错误处理中间件之后,那解析就完全没生效。记住一句话:通用的中间件放最前,路由放中间,错误处理放最后。日志和鉴权一定要在转发之前。

六、应用场景、优缺点与注意事项

6.1 适合用在哪些场景

自己用Express搭网关,很适合中小型微服务项目。比如你的团队不太大,服务数量在十个以内,没有专门的运维人员,用Kong、Nginx这些又觉得重。这时候Express网关非常灵活,你可以直接写JavaScript逻辑,比如根据用户角色动态转发到不同服务,或者照着某个特殊策略做灰度发布。还有,如果你的项目本身已经是Node.js技术栈,那维护成本最低。

6.2 优点和缺点

优点很明显:一是轻量,不需要额外安装一大堆依赖,一个Node进程就够了;二是灵活,中间件机制让你能轻松插入业务逻辑;三是生态好,Express有海量相关库,想加什么功能都有现成的。

缺点也不能忽视:一是性能比不上Nginx这种用C写的高性能网关,尤其是做HTTPS终止和静态资源处理的时候;二是高并发下Node单线程模型可能会成为瓶颈;三是功能不完善,比如限流、熔断、灰度发布都得自己实现。所以,如果你的流量特别大,或者需要强大的网关策略管理,还是要看专业网关。

6.3 值得注意的细节

生产环境里,一定要给服务发现加上健康检查,也就是看下游服务的/health接口返回是否正常,不要让网关去访问已经挂掉的实例。另外,网关本身的日志要收集好,方便排错。还有,把Express网关做成无状态服务,用PM2或者Docker多开几个实例,后面挂个负载均衡器,能扛住更多流量。

七、最后说两句

用Express做API网关,本质上就是充分利用中间件模型,把服务发现和请求转发这两个核心动作包装成一个个可复用的中间件。你不需要写太多复杂的代码,好的设计就能让整个系统变得清晰。这篇文章提到的那些坑,每一个都是实际项目中经常遇到的。从最简单的静态转发开始,慢慢加上鉴权、日志、缓存,再过渡到动态服务发现,你会发现Express网关其实挺能打的。当然,没有万能的方案,选择什么取决于你的项目规模。如果哪天服务多到需要专门的网关团队维护了,再考虑替换也不迟。