用 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 服务反过来成了推动接口先行的理由。