Mock 平台的早期版本只支持"固定 JSON"。够用,但很快就撞墙:同一个接口,参数不同要返回不同的东西;身份证、银行卡、订单号不能瞎编;想验异常分支,得手动改配置。
后来把 mock 数据换成了 Groovy 脚本。这篇讲清楚三件事:脚本能访问什么、异常怎么模拟、以及它的边界到底在哪。
1. 静态 JSON 的四个天花板
先看几个具体场景,都是模板写不出来的:
参数分支 。getUser(uid) 传 1001 要返回 VIP,传别的返回普通用户。模板只能匹配一条规则,写不出"根据入参计算"。
合法但随机的数据。身份证要过校验位、银行卡要过 Luhn、订单号要能路由到指定分库。手写几个假数据凑合,跑两次就重复了。
要调别的服务拿数据。验"注册后下单",得先真的有这个用户。固定 JSON 造不出来。
异常分支 。想验上游的 catch (BizException),得让下游真的抛这个异常,而且要带对错误码。
这四个场景的共同点是:返回值不是一个常量,而是一段逻辑的结果。于是很自然地问:能不能让 mock 数据本身就是一段代码?
2. 为什么选 Groovy
选型时比较过几种:
| 方案 | 问题 |
|---|---|
| 自己定义表达式 DSL | 分支、循环、字符串拼接都得自己实现,最后变成一个四不像语言 |
| JavaScript 引擎(Nashorn / GraalJS) | JDK 15 之后 Nashorn 被移除;GraalJS 要额外依赖,冷启动慢 |
| Java 动态编译 | 一个脚本要写完整的类和方法签名,写起来比业务代码还啰嗦 |
| Groovy | 语法接近 Java,能直接用 JVM 上的类,GroovyShell.parse() 就有结果 |
Groovy 的关键优势是没有编译步骤 :脚本是一个字符串,parse 之后可以直接 run(),改完立刻生效。对"联调时随手改一条返回"来说,这个体验差别很大。
平台里只做了一件包装:
java
public static Object execute(String scriptContent, Map<String, Object> context) {
Script script = compile(scriptContent); // 带缓存
Binding binding = script.getBinding();
context.forEach(binding::setVariable); // request / mockData
binding.setVariable("utils", new ScriptUtils());
binding.setVariable("data", new TestDataUtils());
return script.run();
}
3. 最小可用:两个变量
脚本里默认能拿到这些:
| 变量 | 是什么 |
|---|---|
request |
这次调用的入参(解析后的 Map,形状见下) |
mockData |
当前这条 mock 数据的配置对象 |
utils |
小工具:uuid()、randomInt(min,max)、randomString(n)、timestamp()、dateFormat(pattern)、toJson()、fromJson()、sleep(ms) |
data |
造数据函数,见下一节 |
request 的形状不是固定的,取决于入参长什么样:
| 入参 | request 是什么 |
|---|---|
| 一个 POJO | 就是该对象展开的 Map,字段直接取 |
| 多个参数 / 基本类型 | request.args 是参数数组,按位置取 |
| 不是 JSON | request.raw 是原始字符串 |
所以取参数要带上兜底:
groovy
def uid = request.args ? request.args[0] : null
if (uid == '1001') {
return [status: 200, data: [userId: uid, level: 'GOLD']]
} else {
return [status: 200, data: [userId: uid, level: 'NORMAL']]
}
返回 Map、List、String 都行,平台会统一转成字符串;如果返回的是字符串,就原样作为响应体。
4. 造数据:data.*
手写假数据最烦的不是写,是看起来像真的但不合法:身份证校验位不对、银行卡 Luhn 不过、订单号路由不到指定库。所以把常用造数内置成函数。
本地生成(零网络,毫秒级):
| 函数 | 结果 |
|---|---|
data.idCard() / data.idCard("19900101", true) |
18 位身份证,校验位有效;可指定生日和性别 |
data.bankCard() / data.bankCard("1021000","11") |
银行卡号,Luhn 校验通过;可按银行、卡类型过滤 |
data.bankCardInfo() |
[bankCardNo, bin, bankName, actTypeName] |
data.creditCode() |
统一社会信用代码 |
data.name() |
中文姓名 |
data.orderNo("ORD", -1) |
订单号,支持前缀和日期偏移 |
data.nanoId(21) |
指定位数的短 ID |
data.orderDbNo(4, 3) |
分库订单号,哈希后落在 4 库中的第 3 库 |
调真实能力(要配置,走网络):
| 函数 | 用途 |
|---|---|
data.executeFlow(chainId, params) |
跑一条已注册的 LiteFlow 业务链,返回 [resp, step] |
data.dubboInvoke([...]) |
Dubbo 泛化调用,复用平台的接口测试能力 |
data.sqlExecute(ds, sql) |
在指定数据源执行 SQL,返回行列表 |
举个真实的用法:要验"下单时余额不足",得先有一个真实进件过的客户,再用他的客户号去下单。以前要手工造数据,现在脚本里组合两条业务链:
groovy
def cust = data.executeFlow("custRegisterChain", [
custName: data.name(),
idCard: data.idCard("19900101", true)
])
return data.executeFlow("orderCreateChain", [
custNo: cust.resp.custNo,
amount: 0
])
本地生成和远程调用的边界要清楚:前者不会失败,后者会。所以远程调用要放在脚本靠后,或者先判空------否则造数平台抖一下,你连正常返回都拿不到。
5. 模拟异常:三种写法,一个判定
这块是最容易踩坑的地方,因为"异常"在链路上有两层含义:脚本自己报错了,和脚本想返回一个业务异常。
5.1 三种写法
groovy
// 1) 直接抛:走"脚本执行失败"路径,平台按异常响应返回
throw new RuntimeException("模拟下游超时")
// 2) 返回异常对象:推荐,能带错误码
return new MockBizException("REPEAT_ORDER", "与原订单身份证有变动")
// 3) 返回异常结构:兼容 JSON 风格配置
return [type: "java.lang.RuntimeException", code: "XX7016", message: "余额不足"]
5.2 平台怎么判定
脚本跑完之后,结果会依次过三道判断:
| 判定 | 条件 | 结果 |
|---|---|---|
是不是 Throwable |
result instanceof Throwable |
标记为异常响应 |
| 是不是异常结构 | Map 或 JSON 字符串,且包含 type / code / message 任意一个字段 |
标记为异常响应 |
| 执行过程中抛了 | 捕获 ScriptExecutionException,取 cause |
标记为异常响应 |
注意第二条的隐含约束:你正常返回的数据里不要出现 code 字段 ,否则会被当成异常。比如 [code: 200, data: ...] 就会误判------所以成功响应建议用 status 或者直接返回业务对象的 JSON。
5.3 异常类型必须是对的
这一条比写法重要。上游的 catch (BizException e)、重试策略、事务回滚规则,全都依赖异常的真实类型 。你返回一个 RuntimeException,上游的 catch 根本不会命中,测了等于没测。
所以平台在异常载荷里带全限定名:
json
{"type": "com.example.BizException", "code": "E1001", "message": "余额不足"}
agent 拿到之后按类名加载、按构造器形态实例化,还原成真实异常。
问题是:脚本里写不出下游独有的异常类 ------平台后端的 classpath 里没有 com.example.BizException,new BizException(...) 直接编译失败。
两种解法:
解法一,脚本头部声明默认类型:
groovy
// mock-exception-type=com.example.BizException
return new MockBizException("E1001", "余额不足")
MockBizException 的双参构造会把这个默认类型带上,不用改脚本写法。
解法二,直接写真实类名,编译器报错时自动兼容:
groovy
return new BizException("E1001", "余额不足")
执行器捕获 MultipleCompilationErrorsException,从错误信息里抽出 unable to resolve class BizException,把 new BizException( 改写成 new MockBizException(,同时删掉对应的 import,再编译一次。
这不是"猜",是有边界的文本重写:只改构造调用,只删与被删类同名的 import,其它一概不动。好处是业务同学可以照着下游的写法抄,不用记平台的类。
6. 写不出来的时候
三个辅助手段,按顺序用:
先校验 。平台在保存/预览时会调 validate(),只编译不执行。语法错、引用了不存在的类,在这里就能看到,不用等到调用时才 500。
再预览 。previewScript() 会真的跑一遍,把返回值、执行耗时、异常都显示出来。填一组模拟入参,立刻能看到结果长什么样。
最后生成。按接口的方法签名推断出参数骨架:
groovy
def args = [
custNo: "", // String
amount: 0, // BigDecimal
extInfo: [:] // Map
]
return args
从空文件开始写 Groovy 是最劝退的一步。给个能跑的空壳,人只需要改里面的值。
7. 性能:编译一次,执行很多次
Groovy 的 parse 要跑一遍编译器,不便宜。如果每次请求都编译,mock 会把接口拖慢一个量级。所以按脚本内容做了缓存:
java
private static final ConcurrentHashMap<String, Script> scriptCache = new ConcurrentHashMap<>(64);
private static final int MAX_CACHE_SIZE = 256;
命中就直接 run()。超过 256 条时清掉一半(按遍历顺序近似淘汰),避免长期运行把类撑爆。
但执行本身是同步的。脚本在请求线程里跑,所以:
- 脚本里不要写死循环,也不要递归;
- 用
Thread.sleep(5000)模拟超时是可以的,但要清楚它占用的是当前请求线程------并发压上来会打满 tomcat 线程池; - 复杂的批量造数建议拆成多条 mock 配置,而不是一个脚本里循环调 100 次远程接口。
8. 安全边界:说清楚比藏起来好
需要坦白一点:GroovyShell 默认没有沙箱 。脚本里可以 System.exit(0),可以读文件,可以起线程。
这意味着两件事:
- 这个功能只对内网、只对可信的人开放,权限模型靠平台的登录+角色,不靠脚本沙箱;
- 如果哪天要对外开放,必须加
SecureASTCustomizer(限制可调用的类/方法)或者独立的低权限进程,不能只靠"约定不写危险代码"。
现在的取舍是:先让可信的人用得顺手,把边界写在文档里。真要沙箱化,成本主要在兼容性------很多造数函数要反射调用,白名单会很难维护。
9. 写脚本的几条经验
用了两年多,踩出来的几条:
- 脚本只做"组装",别在里面写业务逻辑。需要复杂逻辑,说明这个场景该用真实服务,不是 mock。
- 入参先判空 。
request.args在无参调用时是 null,直接下标取会 NPE,写def uid = request.args ? request.args[0] : null。 - 成功响应别带
code,会被异常判定吃掉(见 5.2)。 - 随机数据要可复现。排查问题时希望能拿到同一份数据,所以在脚本里固定种子或用固定值,别全随机。
- 远程造数放在最后,失败时至少能返回一个可用的兜底值。
- 一个脚本对应一个分支 。参数分支写在脚本里可以,但如果超过三层
if,拆成多条 mock 数据 + 规则匹配更好维护。
10. 小结
动态 mock 的价值不在于"能写代码",而在于把三类以前很麻烦的事变成一行:
- 参数不同 → 返回不同(分支)
- 数据要合法、要能路由 → 用
data.*生成 - 要验异常 → 返回带类型的异常载荷
代价也很明确:脚本是同步执行的、没有沙箱、写错了会影响响应时间。所以它适合"联调期临时接管",不适合当成业务逻辑的第二实现。
最后一句经验:脚本越短越好用。一个需要 50 行才能说清的 mock,通常说明被测的那个分支本身就该拆了。