用 Express 搭团队 mock 服务:中间件组织、代理转发与热更新

先算一笔账。这个季度我们组三个前端,平均每人每个迭代有一到两天是"等接口"的状态:页面写完了,后端接口还在开发,联调排不上;或者接口通了,但你想测的那个分支场景——库存为零、优惠券过期、第三方支付回调超时——测试环境根本造不出数据。这些时间要么干耗,要么写点别的需求再切回来重新进入上下文,切换成本也是成本。三个人一个季度耗掉的时间加起来,够把本文这个 mock 服务从头搭三遍。

所以在动手之前,我先想清楚了这层东西要担什么责任:给前端一个不依赖后端进度、不依赖测试环境数据的接口供应层,前端说要什么响应,它就返回什么响应。至于它顺便还能干什么(聚合、转发、当调试代理),都是捎带的。

两年前我们用过 Mock.js,在浏览器里拦截 XHR 直接造假数据,写页面确实快。但它的拦截发生在浏览器内部,请求根本没出网络层,Network 面板里看不到真实请求,charles 抓不到包,移动端 H5 挂真机调试时更是完全失效;而且 mock 规则写在业务代码里,上线前得小心翼翼摘干净。这次要搭的是一个独立的 Node 服务,请求真实地走 HTTP 出去,浏览器、抓包工具、真机看到的都是货真价实的响应——mock 的位置从浏览器挪到网络对端,整个调试链路才是真实的。

Express 的中间件模型:这个服务的地基

选 Express 没什么悬念,Node 12 加 Express 4,团队里人人都能看懂改动。而且 mock 服务这个场景,恰好把 Express 的中间件模型用得淋漓尽致——整个服务就是一条请求处理流水线,每个中间件做一件事,做完调 next() 交给下一个:

1var express = require('express');
2var app = express();
3
4app.use(express.json());                 // 解析 JSON 请求体
5app.use(requestLogger);                  // 打印每条请求,联调时盯着看
6app.use(delaySimulator);                 // 全局延迟模拟(后面细说)
7app.use('/api', mockRouter);             // 命中 mock 规则的走这里
8app.use('/api', proxyToBackend);         // 没命中的透传给真实后端
9app.listen(3001);

这个顺序本身就是设计:请求先过日志和延迟模拟,然后尝试匹配 mock 规则,匹配不到不报 404,而是落到下一层代理中间件,透传给真实后端。这是整个服务最关键的一个决定——mock 服务不该是"全有或全无"的,一个迭代里通常只有两三个新接口需要 mock,其余几十个老接口都该走真实环境。前端只 mock 自己需要的,其他的无感透传。

日志中间件顺手写一个就行,几行代码,但联调时它是最常被盯着的输出:

1function requestLogger(req, res, next) {
2  var start = Date.now();
3  res.on('finish', function () {
4    console.log(
5      '[' + req.method + '] ' + req.originalUrl +
6      ' -> ' + res.statusCode + ' (' + (Date.now() - start) + 'ms)' +
7      (res.locals.mocked ? ' [mock]' : ' [proxy]')
8    );
9  });
10  next();
11}

响应上标一个 [mock] 还是 [proxy],一眼就能看出这条请求是假数据还是真后端——不标的话,过两天自己都记不清哪些接口 mock 了。

路由组织:mock 规则按业务域拆文件

规则全堆在一个文件里,超过十个接口就没法维护了。我按业务域拆目录,每个文件导出一组"路由定义",服务启动时扫描目录统一注册:

mock/
  ├── order.js      # 订单域
  ├── goods.js      # 商品域
  ├── coupon.js     # 优惠券域
  └── _util.js      # 造数据的公共函数

每个文件的格式约定得尽量薄,key 是 方法 空格 路径,value 是响应——可以是静态对象,也可以是拿到 req 的函数,需要动态逻辑时用函数:

1// mock/order.js
2module.exports = {
3  'GET /api/order/list': function (req) {
4    var page = parseInt(req.query.page, 10) || 1;
5    return {
6      code: 0,
7      data: {
8        total: 143,
9        list: makeOrderList(page === 8 ? 3 : 20), // 最后一页不满
10      },
11    };
12  },
13
14  // 静态响应直接写对象
15  'GET /api/order/statusEnum': {
16    code: 0,
17    data: ['待支付', '待发货', '已发货', '已完成', '已取消'],
18  },
19};

注册逻辑用 express.Router 挂载,路径匹配直接复用 Express 自己的能力,/api/order/:id 这种带参数的路径不用自己写解析。造假数据的部分我把 Mock.js 请了回来——它的数据模板语法('name|3-5': '@cword' 这类)用来生成中文姓名、手机号、时间序列还是很好用的,只是这次它跑在 Node 里当数据工厂,不再碰浏览器的 XHR。

透传:http-proxy-middleware 兜底

没被 mock 规则命中的请求,交给 http-proxy-middleware 转发到测试环境网关:

1var proxy = require('http-proxy-middleware');
2
3app.use('/api', proxy({
4  target: 'http://test-gateway.internal.com',
5  changeOrigin: true,
6  onProxyReq: function (proxyReq, req) {
7    // 透传登录态,测试环境的 cookie 校验才能过
8    if (req.headers.cookie) {
9      proxyReq.setHeader('cookie', req.headers.cookie);
10    }
11  },
12}));

changeOrigin 必须开,不然转发出去的 Host 头还是 localhost,网关的虚拟主机路由会认不出来。cookie 透传是为了登录态:我们的接口都在登录墙后面,mock 服务夹在中间,得把浏览器带来的 cookie 原样递给真实后端。这里踩过一个坑:POST 请求经过 express.json() 解析后,body 流已经被消费掉了,直接转发出去的请求体是空的,后端收到的 POST 全是空参数。解法要么把 body 重新序列化写回代理请求,要么让代理中间件挂在 body 解析之前。我选了后者——调整中间件顺序,代理分支完全不碰 body,让流原封不动地过去,代理转发的原则是能不动请求就不动请求。

热更新:改完 mock 文件不用重启

mock 数据是要频繁改的,改一次重启一次服务,这个体验没法接受。Node 的 require 有模块缓存,文件改了再 require 拿到的还是旧模块,所以热更新的核心就是删缓存:用 chokidar 监听 mock 目录,文件一变,把对应模块从 require.cache 里删掉,下次请求进来重新加载:

1var chokidar = require('chokidar');
2
3chokidar.watch('./mock').on('change', function (filePath) {
4  var resolved = require.resolve(path.resolve(filePath));
5  delete require.cache[resolved];
6  reloadMockRules();
7  console.log('[mock] 规则已重载: ' + filePath);
8});

配合这个机制,路由注册那层不能在启动时把规则"固化"进 Express 的路由表,而是保持一层间接:请求进来时才去当前生效的规则表里查。改完 order.js 保存,浏览器里重新发请求,新数据立刻生效,比 nodemon 整个进程重启快得多,也不会断掉正在进行的其他请求。

延迟与异常模拟:mock 服务比真实后端"好用"的地方

这部分是搭这层服务最超值的收获。测试环境永远是"正常返回",但前端代码里最容易出 bug 的恰恰是异常分支:loading 态写没写、超时有没有兜底、500 的时候页面会不会白屏。mock 服务里这些场景都能一个参数造出来。

我的实现是约定几个特殊的查询参数,任何被 mock 的接口都认:

1function delaySimulator(req, res, next) {
2  var delay = parseInt(req.query._delay, 10);
3  var status = parseInt(req.query._status, 10);
4
5  if (status) {
6    return setTimeout(function () {
7      res.status(status).json({ code: -1, message: '模拟的 ' + status + ' 响应' });
8    }, delay || 0);
9  }
10  if (delay) {
11    return setTimeout(next, delay);
12  }
13  next();
14}

页面请求的 URL 后面拼上 ?_delay=3000 就能看三秒慢网络下的 loading 表现,拼 ?_status=500 直接拿到一个服务端错误。QA 知道这个功能之后比我们用得还勤——以前她要造一个"支付超时"场景得求后端改代码或者拔网线,现在自己拼个参数就行。异常场景的测试成本降下来之后,异常分支的代码质量才真的有人管

拼参数适合单个接口的临时测试,但有些场景是"一组接口要同时变"——比如测"优惠券过期"的完整流程,券列表、下单校验、支付确认三个接口的返回要一致地反映"券已过期"这个状态,逐个拼参数既麻烦又容易漏。为此我又加了一层"场景"机制:mock 规则文件里同一个接口可以定义多套响应,用场景名索引,当前生效的场景由一个全局开关控制:

1// mock/coupon.js
2module.exports = {
3  'GET /api/coupon/list': {
4    default: { code: 0, data: { list: makeCoupons(5) } },
5    expired: { code: 0, data: { list: makeExpiredCoupons(3) } },
6    empty:   { code: 0, data: { list: [] } },
7  },
8};

切场景做了个最简陋的控制台:mock 服务顺便起了一个 /_scenes 页面,列出所有定义了多场景的接口,点一下切换,存在服务的内存变量里。没有权限、没有持久化,重启回到 default——它就是个开发工具,简陋是本分,但"QA 在浏览器里点两下就能把整个系统切到某个业务状态"这件事,以前想都不敢想。

服务自身的健壮性:一个 mock 函数抛错不能挂掉整个服务

mock 规则是全组人都会写的,质量没法保证。某天下午服务突然没响应了,查下来是有人写的 mock 函数里 req.query.ids.split(',') 没判空,一个不带参数的请求让函数抛错,Express 4 里同步抛错默认会走到错误处理,但我们当时没挂错误处理中间件,异步回调里的抛错更是直接把进程干崩了。补救分两层。第一层是给 mock 执行包上 try/catch,出错时返回一个带堆栈的 500,让写规则的人自己在响应里看到错误:

1function execMock(rule, req, res) {
2  try {
3    var result = typeof rule === 'function' ? rule(req) : rule;
4    res.locals.mocked = true;
5    res.json(result);
6  } catch (err) {
7    res.status(500).json({
8      code: -1,
9      message: '[mock 规则执行出错] ' + err.message,
10      stack: err.stack.split('\n').slice(0, 5),
11    });
12  }
13}

第二层是标准的 Express 四参数错误处理中间件挂在最末尾兜底,加上进程级的 uncaughtException 日志。服务部署在内网开发机上,用 pm2 托管,崩了自动拉起——开发工具可以简陋,但不能脆弱,一个人写错规则全组停工,这个工具就会被弃用

部署形态上也纠结过"每人本地跑一个"还是"内网共用一个"。共用实例的好处是 mock 规则和场景状态全组一致,QA 和前端看到的是同一份数据;坏处是一个人切了场景会影响别人。目前人少,共用加上群里吼一声"我要切过期券场景"还能运转,规则文件走 git 仓库,谁改了什么有记录。真到多人互相干扰的那天,再考虑按 cookie 或请求头隔离场景状态,方案想好了先不做。

录制回放:mock 数据的初稿不用手写

写 mock 规则最烦的部分是造数据,尤其是老接口——字段几十个,手敲一遍等于抄接口文档。既然代理层本来就能看到真实后端的响应,那让它顺手把响应记下来当 mock 初稿,是水到渠成的想法。我在代理中间件上加了个录制开关:

1app.use('/api', proxy({
2  target: 'http://test-gateway.internal.com',
3  changeOrigin: true,
4  onProxyRes: function (proxyRes, req) {
5    if (!RECORD_MODE) return;
6    var chunks = [];
7    proxyRes.on('data', function (c) { chunks.push(c); });
8    proxyRes.on('end', function () {
9      var body = Buffer.concat(chunks).toString('utf8');
10      saveRecording(req.method, req.originalUrl, body);
11    });
12  },
13}));

录制时有一个细节:如果后端响应是 gzip 压缩的,这里拿到的 chunk 是压缩后的字节流,直接 toString 是乱码,要么在代理配置里请求时去掉 accept-encoding 头让后端返回明文,要么自己解压。我选了前者,mock 场景不在乎那点传输体积。

开着录制模式把页面的正常流程点一遍,recordings/ 目录下就多出一堆按"方法加路径"命名的 JSON 文件,稍微改改(把敏感数据换掉、把动态部分换成 Mock.js 模板)就能挪进 mock 目录当规则用。从真实响应出发改 mock,比从接口文档出发写 mock,字段保真度高一个量级——毕竟文档会过时,响应不会骗人。当然这招只对已有的接口有效,新接口还是得老老实实照文档写,两条路互补。

录制文件我们没有直接当规则加载,中间保留了"人工挪一下"这个动作。试过全自动的"录制即规则",问题是录进来的数据带着当时测试环境的具体状态(某个真实用户的订单、当天的时间戳),不清洗直接用,跑几天就出现"mock 返回的优惠券三天前就过期了"这类幽灵问题,反而难查。

和 devServer.proxy 的分工

有同事问:Vue CLI 的 devServer.proxy 本来就能转发接口,为什么还要多起一个服务?两者不冲突,是串联关系。devServer.proxy 解决的是浏览器跨域问题,它只管"把 /api 转发到某个地址";这个地址以前直接填测试环境网关,现在填 mock 服务:

1// vue.config.js
2devServer: {
3  proxy: {
4    '/api': { target: 'http://localhost:3001' },
5  },
6},

链路变成:浏览器 → devServer → mock 服务 →(未命中时)测试环境。想完全绕开 mock 直连测试环境,改一行 target 就回去了,业务代码零改动。mock 服务独立于任何一个前端项目存在,我们三个项目共用同一个,规则仓库单独管理——这也是它比"写在 vue.config.js 里的 devServer.before 钩子"强的地方,mock 逻辑不再和某个项目的构建配置绑死。

独立服务还解决了一个 devServer 方案根本覆盖不了的场景:移动端真机联调。H5 页面在手机上打开时不经过电脑上的 devServer,之前想让手机访问 mock 数据只能改 charles 的映射规则,配置麻烦还经常忘了关。现在手机连内网 Wi-Fi,页面接口地址指到开发机 IP 的 3001 端口就直连 mock 服务了。跨域要在服务端处理一下——真机场景下页面域名和 mock 服务不同源,加一个 CORS 中间件放行:

1app.use(function (req, res, next) {
2  res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
3  res.header('Access-Control-Allow-Credentials', 'true');
4  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
5  if (req.method === 'OPTIONS') return res.sendStatus(204);
6  next();
7});

带 cookie 的请求 Allow-Origin 不能是 *,要回显具体的 origin,这是 CORS 规范里容易忘的一条。这个中间件只在 mock 服务上有,等于把"跨域放行"也变成了开发期专属能力,生产环境的同源策略不受任何影响。

mock 数据从哪来:跟接口文档对齐,别自由发挥

服务用了两周暴露出一个流程问题:mock 数据是前端按自己的理解写的,后端实际交付的接口和 mock 的字段有出入——一个字段 mock 里叫 orderStatus,后端给的是 status;mock 里是数字枚举,后端返回字符串。切真实接口的时候页面挂一片,等于联调的活一点没省,只是推迟了。

大家坐下来对了一次,定了条规矩:mock 规则只能照着评审过的接口文档写,文档没定稿的接口不允许 mock。我们的接口文档在 YApi 上维护,后端定义好返回结构后前端照抄字段建 mock。YApi 本身也带 mock 功能,讨论过要不要干脆用它,最后没用:它的 mock 是纯静态返回加简单的 Mock.js 模板,做不了带逻辑的动态响应(分页最后一页不满、按参数返回不同状态),也没有透传真实后端的能力。折中的做法是把 YApi 当唯一的字段事实来源,我们的 Express 服务当执行层——文档管"长什么样",mock 服务管"怎么活起来"。字段对不对得上,切换真实接口那天见分晓,这两周试下来,按文档写的接口切换时基本零修改。

要不要往 BFF 演进:想清楚边界再说

服务跑顺之后,组内讨论过一个更大的话题。社区这两年一直在讲 BFF(Backend for Frontend)——前端团队维护一个真正部署到生产的 Node 中间层,做接口聚合、字段裁剪、页面级数据编排。我们这个 mock 服务在形态上已经有点那个意思了:它站在前端和后端之间,能转发、能改写、能聚合。要不要顺势演进?

我的态度是明确地停在 mock 层。往 BFF 走,性质就变了:那是一个生产服务,要接入完整的鉴权链路、要有部署发布流程、要考虑可用性和降级、出了故障要有人值班——mock 服务挂了大不了前端切回直连,BFF 挂了是线上事故,这两者的责任重量完全不是一个量级。以我们组现在三个前端的人力,接一个生产级 Node 服务的运维责任,等于给自己上了一道没有收益兜底的枷锁。真实的聚合裁剪诉求目前也不强烈,中后台的接口粒度后端配合得还可以。

所以边界就画在这里:这个服务只跑在开发机和内网的一台开发服务器上,只服务于开发和联调阶段,永远不出现在生产链路里。哪天团队规模和业务复杂度真的撑起 BFF 了,再单独立项按生产标准做,而不是让这个 mock 服务"自然长大"——自然长大的服务,往往长成谁都不敢动的怪物。

服务上线(内网意义上的"上线")三周,这个迭代前端没有再出现整天等接口的情况:接口文档评审完,mock 规则十分钟写好,页面开发和后端开发真正并行。QA 提前拿着 mock 环境写用例、造异常场景,后端交付接口后切换基本零成本。开头算的那笔账,一个迭代就回本了——而且这笔账后端也认,评审时他们主动提出以后新接口先在 YApi 定字段再动手,这层 mock 服务反过来成了推动接口先行的理由。