用 Groovy 做动态 Mock:参数分支、造真数据、模拟异常

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),可以读文件,可以起线程。

这意味着两件事:

  1. 这个功能只对内网、只对可信的人开放,权限模型靠平台的登录+角色,不靠脚本沙箱;
  2. 如果哪天要对外开放,必须加 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,通常说明被测的那个分支本身就该拆了。

相关推荐
yuniko-n1 小时前
【JUC】内存屏障
java·开发语言
泡茶喝茶写代码1 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 13 篇):放量突破策略
java·python·股票数据api·股票数据api接口·股票量化数据api·股票量化数据接口
文丑颜不良啊1 小时前
LeetCode626~650题目解析
java·leetcode
tianbin9112 小时前
批处理与微调辅助场景:Claude 中转并发、链路缓存与压测对比
java·开发语言·缓存
frjc2 小时前
Spring AI 里 ChatClient 和 ChatModel 有什么区别?什么时候该用哪个?
java·开发语言·spring·ai
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 圈子功能的表设计方案
java·开发语言·交友
文丑颜不良啊2 小时前
LeetCode601~625题目解析
java·leetcode
文丑颜不良啊3 小时前
LeetCode651~675题目解析
java·leetcode
frjc3 小时前
Spring WebFlux SSE 长连接,可以替换为 Java 21 虚拟线程吗?
java·人工智能·spring·ai·ai编程