⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/yunyingyouyaogaiguizeSpringBoot--Groovy-rangnigaobielingchenfaban/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

规则天天变,别让发版当替罪羊

凌晨两点,运营群弹出一条消息:「优惠券规则要调整,A 用户群从 8 折改成 7.5 折,B 用户群增加满减门槛,5 点活动开始前必须上线。」

抬头看时间,离活动开始还剩三小时。改代码十分钟,提测半小时,灰度发版一小时——理论上来得及。但你刚 push 完代码,测试同事说有个边界条件没覆盖;改完再提,灰度中又冒出一个性能问题;回滚再上,活动已经开始了二十分钟。

做过运营 / 营销 / 风控系统的后端对这种场景应该不陌生。问题的本质不是「写代码慢」,是业务规则的变化频率和发版节奏不匹配——规则一周改三次,发版窗口一周只开一次。错配的代价就是每次紧急需求都在挤压发版的安全边界,事故概率随之上升。

解法不止一个,最轻的一种是 Groovy 动态脚本 + Spring Boot:把经常变的逻辑从代码里抽出来,放到数据库或配置中心,改完即时生效,不用走发版流程。

规则引擎 vs 动态脚本:先看复杂度,再看频率

遇到规则频繁变的场景,业内基本三条路:

方案 适合 不适合
硬编码 + 频繁发版 规则一年变两三次 规则一周变两三次(运营第一个崩溃,然后是你)
规则引擎(Drools / Easy Rules) 规则本身复杂,需要分支、优先级、冲突处理 规则简单但改得勤,引擎学习成本压垮整套方案
动态脚本(Groovy) 规则简单 + 改得勤 规则复杂到需要可视化编排 / 多人协作维护规则库

判断标准就两个维度:规则复杂度和变化频率:

  • 复杂度低 + 变化多 → 上 Groovy,灵活、轻量、生效快
  • 复杂度高 + 变化少 → 上规则引擎,模型重一次,后面享福
  • 复杂度高 + 变化多 → 规则引擎 + 动态脚本组合(阿里、京东的优惠券系统都是这个打法)
  • 复杂度低 + 变化少 → 老实写 Java,别折腾

文章接下来讲的实战,针对的是「复杂度低 + 变化多」这个最常见、也最痛的象限。

Groovy:JVM 上唯一能 0 成本调 Java 的脚本语言

JVM 上能跑动态脚本的不止 Groovy 一家。先把竞品摆出来:

语言 现状
JavaScript 走过 Nashorn——Java 8 引入,Java 15 移除。GraalVM 还能跑,但部署链路重
Python Jython 最后稳定版停在 2.7.x,三年没大更新,社区基本断更
Lua LuaJ 还在维护,但生态偏游戏行业,Java 互操作要绕
Kotlin Script 能跑,但启动比 Groovy 慢一倍,社区方案也少
Groovy Apache 旗下,2007 年 1.0,现在 5.x,社区活跃,生产案例多

Groovy 的真正优势是完全 JVM 原生:编译产物就是标准字节码,类型系统和 Java 互通,调 Spring Bean、用 Java 集合、抛 Java 异常都不需要任何桥接层。其他脚本语言比不了——Jython 调 Java 类需要走 PyObject 转换层,实际项目里的性能损耗通常很明显(具体数字看场景,但「不可忽略」是共识)。

业内不少团队拿 Groovy 做规则引擎、风控策略、报文解析这类「JVM 体系内的动态扩展」场景,理由都差不多——它和 Java 的互操作零成本,比上 Drools 这种重型规则引擎轻量得多。

官网:https://groovy.apache.org/

按工程价值排序,Groovy 的关键能力有三个:

  • 天然支持运行时编译:GroovyClassLoader 直接把字符串编译成 Class,立刻实例化调用——动态加载的核心能力。
  • 和 Java 互操作 0 成本:Java 调 Groovy、Groovy 调 Java 都不需要任何转换层。
  • 语法对 Java 用户友好:90% 的 Java 代码可以直接当 Groovy 执行(剩下 10% 主要是 Lambda 和泛型边界,后面会专门讲坑)。

底层原理一句话:字符串 → 编译成字节码 → 生成 Class → 实例化 → 反射调用。整个流程在 JVM 内部完成,不需要额外运行环境。

三种集成方式:生产只认 GroovyClassLoader

Java 集成 Groovy 有三种方式,只有最后一种能用到生产。前两种官方文档都明确写了「适合快速原型」。

GroovyShell:最方便,最不适合生产

适合一行表达式:

public static void main(String[] args) {
// 算一个用户的风险评分:信用分占 70%,活跃度占 30%
String script = "creditScore * 0.7 + activityScore * 0.3";
Binding binding = new Binding();
binding.setVariable("creditScore", 720);
binding.setVariable("activityScore", 85);
GroovyShell shell = new GroovyShell(binding);
Object score = shell.evaluate(script);
System.out.println(score); // 529.5
}

底层每次都新建 ClassLoader,没有复用,没有缓存。高频调用下 Metaspace 涨得最快。

ScriptEngineManager:通用,但一样慢

Java 标准脚本接口(JSR-223):

public static void main(String[] args) throws Exception {
ScriptEngineManager factory = new ScriptEngineManager();
ScriptEngine engine = factory.getEngineByName("groovy");
// 一条优惠券资格规则:订单金额 ≥ 200 且用户等级 ≥ 3
engine.eval("def isEligible(amount, level) { amount >= 200 && level >= 3 }");
Boolean ok = (Boolean) ((Invocable) engine).invokeFunction("isEligible", 350, 4);
System.out.println(ok); // true
}

优点是不绑定 Groovy,换 Kotlin Script 也能跑。但底层封装的还是 ClassLoader,性能没优化,生产不推荐。

GroovyClassLoader:官方推荐,生产实用

GroovyShell 和 ScriptEngineManager 底层都调它,直接用更灵活、也更方便加缓存:

GroovyClassLoader loader = new GroovyClassLoader();
// 把一段「分档折扣」规则当成 Groovy 类加载
String script =
"class DiscountRule {\n" +
" BigDecimal calc(BigDecimal amount, int level) {\n" +
" if (level >= 5) return amount * 0.10\n" +
" if (level >= 3) return amount * 0.05\n" +
" return BigDecimal.ZERO\n" +
" }\n" +
"}";
Class<?> clazz = loader.parseClass(script);
GroovyObject rule = (GroovyObject) clazz.newInstance();
BigDecimal discount = (BigDecimal) rule.invokeMethod("calc",
new Object[]{new BigDecimal("500"), 5});
System.out.println(discount); // 50.00

三步:加载脚本 → 生成实例 → 反射调用。生产里所有 Groovy 集成都应该走这条路。

Spring Boot 集成实战:MD5 缓存是关键

引入依赖

<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>3.0.19</version>
<type>pom</type>
</dependency>

Spring Boot 3.x + JDK 17 用 Groovy 4.x 也没问题,API 基本兼容(注意 4.x 之后包名从 org.codehaus.groovy 迁到 org.apache.groovy)。

核心组件:带缓存的脚本加载器

实际项目里不会把 GroovyClassLoader 散在各处,要封装成统一组件:

@Component
public class GroovyScriptLoader {

private static final Logger log = LoggerFactory.getLogger(GroovyScriptLoader.class);

// 脚本缓存:key = 脚本内容 MD5,value = 编译好的 Script Class
// 注意缓存的是 Class 不是 Script 实例——Script 有状态(Binding),并发共享会互相覆盖
private final Map<String, Class<? extends Script>> scriptClassCache = new ConcurrentHashMap<>();

private final GroovyClassLoader groovyClassLoader;

public GroovyScriptLoader() {
this.groovyClassLoader = new GroovyClassLoader(getClass().getClassLoader());
}

/**
* 执行脚本,带 MD5 缓存
*/
@SuppressWarnings("unchecked")
public Object executeScript(String scriptCode, Map<String, Object> params) {
String cacheKey = md5(scriptCode);
// 1. 缓存编译后的 Class(线程安全的不可变对象)
Class<? extends Script> scriptClass = scriptClassCache.computeIfAbsent(cacheKey, k -> {
Class<?> clazz = groovyClassLoader.parseClass(scriptCode);
groovyClassLoader.clearCache(); // 清 ClassLoader 内部缓存,内存友好
return (Class<? extends Script>) clazz;
});
// 2. 每次执行 new 一个新的 Script 实例,Binding 不共享
try {
Script script = scriptClass.getDeclaredConstructor().newInstance();
script.setBinding(new Binding(params != null ? params : new HashMap<>()));
return script.run();
} catch (Exception e) {
throw new RuntimeException("Groovy 脚本执行失败", e);
}
}

private String md5(String text) {
return DigestUtils.md5DigestAsHex(text.getBytes(StandardCharsets.UTF_8));
}
}

两个关键设计点:

  1. 缓存 Class,不缓存 Script 实例——Script 持有可变的 Binding 状态,多线程共享会互相覆盖(A 的请求看到 B 的参数)。Class 才是线程安全的不可变对象。这是生产里最容易踩的并发坑。
  2. MD5 作为缓存 key——同样脚本内容无论调多少次只编译一次,后续走缓存。没这一步,性能和 Metaspace 都会出问题(具体数据看后面踩坑那节)。

暴露 HTTP 接口

@RestController
@RequestMapping("/api/groovy")
public class GroovyController {

@Autowired
private GroovyScriptLoader scriptLoader;

@PostMapping("/execute")
public Object execute(@RequestBody ScriptRequest request) {
return scriptLoader.executeScript(request.getScriptCode(), request.getParams());
}
}

@Data
public class ScriptRequest {
@NotBlank
private String scriptCode;
private Map<String, Object> params;
}

测试一下

# 1. 优惠券资格判断(满 200 + 用户等级 ≥ 3)
curl -X POST http://localhost:8080/api/groovy/execute \
-H "Content-Type: application/json" \
-d '{
"scriptCode": "return amount >= 200 && level >= 3",
"params": {"amount": 350, "level": 4}
}'
# 返回: true

# 2. 分档折扣计算(按用户等级分三档)
curl -X POST http://localhost:8080/api/groovy/execute \
-H "Content-Type: application/json" \
-d '{
"scriptCode": "if (level >= 5) return amount * 0.10; if (level >= 3) return amount * 0.05; return 0",
"params": {"amount": 500, "level": 5}
}'
# 返回: 50.0

# 3. 报文字段提取(从运营商返回的 raw 报文里取 trans_id)
curl -X POST http://localhost:8080/api/groovy/execute \
-H "Content-Type: application/json" \
-d '{
"scriptCode": "def m = (raw =~ /trans_id=(\\d+)/); return m ? m[0][1] : null",
"params": {"raw": "status=ok&trans_id=10086&amount=200"}
}'
# 返回: "10086"

进阶用法:脚本要能用,也要能管

在脚本里调 Spring Bean

脚本不只算算数,更多时候要调 CouponService、RiskService 这类业务 Bean。做法是把 ApplicationContext 通过 Binding 传进去:

// 在 GroovyScriptLoader 里注入 ApplicationContext
@Autowired
private ApplicationContext applicationContext;

public Object executeScript(String scriptCode, Map<String, Object> params) {
if (params == null) params = new HashMap<>();
params.put("applicationContext", applicationContext);
// ... 后续逻辑不变
}

脚本里这样用:

def couponService = applicationContext.getBean('couponService')
def coupon = couponService.findActiveByUserId(params.userId)
return coupon != null && coupon.amount >= params.threshold

也可以封装 SpringContextUtil,脚本里用 SpringContextUtil.getBean(CouponService.class) 更干净。

接口实现模式:生产推荐写法

更进一步的做法是让 Groovy 脚本实现一个 Java 接口。这是生产推荐的写法——比裸的 parseScript + Map<String, Object> 强一个量级:类型安全、IDE 有提示、出问题易排查、Code Review 时能看出业务意图。

// 定义接口
public interface DiscountRule {
BigDecimal calc(Order order, User user);
}

Groovy 里实现:

class TieredDiscountRule implements DiscountRule {
@Override
BigDecimal calc(Order order, User user) {
if (user.level >= 5 && order.amount >= 1000) return order.amount * 0.15
if (user.level >= 3 && order.amount >= 500) return order.amount * 0.08
if (order.amount >= 200) return order.amount * 0.03
return BigDecimal.ZERO
}
}

调用时强转接口:

GroovyClassLoader loader = new GroovyClassLoader();
Class<?> clazz = loader.parseClass(scriptCode);
DiscountRule rule = (DiscountRule) clazz.newInstance();
loader.clearCache();
BigDecimal discount = rule.calc(order, user);

所有正式业务规则都建议走这种模式。裸脚本只用在临时调试或者快速验证。

脚本放哪里

两种常见选择:

位置 优点 缺点
数据库 最简单,配合 MD5 缓存性能也够 变更没有推送机制,依赖查询
配置中心(Nacos、Apollo) 支持订阅变更事件,改完立刻通知应用重载 引入新中间件,治理成本稍高

建议:脚本体量小、变更不频繁用数据库;脚本多、要求秒级生效用配置中心。

生产踩坑:五个问题,每个都能让你掉头发

下面这些都是踩过才懂的事,线上事故学费不便宜。

坑一:Metaspace 溢出(最常见,也最严重)

问题在 GroovyClassLoader.parseClass() 的实现——每次调用都生成新的 Class 对象,类名带时间戳和哈希值,即使脚本内容完全一样也被当成新 Class。这些 Class 放在 Metaspace 里不会被回收,积累多了就 OOM。

// 危险用法,不要这样写
public Object badPractice(String scriptCode, Map<String, Object> params) {
// 每次都 parseClass,每次都新 Class,Metaspace 一直涨
Script script = new GroovyClassLoader().parseClass(scriptCode);
// ...
}

真实案例:某项目上线后两周,Metaspace 从 80MB 涨到 800MB,最后触发 OOM。排查发现就是 parseClass() 没加缓存——日均 50 万次脚本执行 × 14 天 = 700 万个 Class 全部留在 Metaspace。

加 MD5 缓存之后,活跃 Class 数稳定在 200 以内,Metaspace 涨到 120MB 就稳住不再涨了。

解决方法:按脚本内容 MD5 缓存编译结果(前面实战里的 scriptCache),同时记得调 classLoader.clearCache() 清掉 ClassLoader 内部缓存——两边都清才到位,只清一边一样会泄漏。

生产再加一条 JVM 参数 -XX:MaxMetaspaceSize=256m,把元空间上限锁死。万一真泄漏,影响只在这个服务,不会把整台机器吃光。

坑二:Lambda 在 Groovy 里不一样

很多人觉得 Groovy 语法和 Java 差不多,直接把 Java 代码粘进去就用。大部分场景没问题,Lambda 是个例外。

// 多个支付渠道并行重试
Set<String> channels = new HashSet<>()
channels.add("alipay")
channels.add("wechat")
for (String channel : channels) {
executor.submit({ -> println "重试渠道: " + channel })
}

你以为输出 “重试渠道: alipay” 和 “重试渠道: wechat”,实际可能两行都是 wechat。

原因不是 Groovy 故意搞特殊。这是两套根本不同的语言设计:

  • Java Lambda:编译时做变量捕获检查,被捕获的变量必须是 final(或 effectively final),编译器会拒绝你修改循环变量的代码。
  • Groovy 闭包:直接持有变量的引用,不做 final 检查,给你灵活性的同时也让你自己处理「引用 vs 值」的差别。

Java 编译器帮你挡住的坑,Groovy 让你自己接住。生产里这种 bug 直接导致重试逻辑全打到同一个渠道——查起来还很难排,因为只在并发场景下才稳定复现。

解决方案:用 Groovy 原生的 each,或者手动把变量复制一份再传进去。

坑三:首次执行的「冷启动悬崖」

第一次加载某脚本时 Groovy 要做编译,耗时明显比后续长。压测里能看到明显的「悬崖」:

调用次序 P99
第一次(含编译) ~200ms
第二次起(走缓存) ~5ms

40 倍差距。如果脚本是按需懒加载的,每个新脚本第一次执行都有这个尖刺,对 P99 极其不友好——监控一直告警,排查半天发现是「冷启动」就是个常见笑话。

解决方法是预热:服务启动时把常用脚本提前加载一遍,触发 JIT:

@PostConstruct
public void warmUp() {
List<ScriptConfig> scripts = scriptConfigService.findAll();
for (ScriptConfig sc : scripts) {
try {
scriptLoader.executeScript(sc.getCode(), new HashMap<>());
} catch (Exception e) {
log.warn("Warm up script failed: {}", sc.getId(), e);
}
}
}

坑四:安全 = 生产里最容易被忽略的那条

Groovy 脚本可以执行任意 Java 代码。脚本内容如果来自用户输入,或者存在被外部篡改的风险,就是直接的 RCE 漏洞。脚本里出现 Runtime.getRuntime().exec("rm -rf /") 会发生什么不用细说。

生产环境至少做两件事:

  1. 严控脚本写入权限:只有审核过的人能修改脚本配置。绝对不能让前端传过来的字符串直接当脚本执行——这等同于给系统留了个 RCE 后门。
  2. 白名单:用 Groovy 的 SecureASTCustomizer 限制脚本只能调用指定类和方法。配置稍麻烦,但生产级方案必须做。

如果你的系统对外提供脚本提交接口(比如低代码平台、风控规则配置后台),这两条都不能省。

坑五:版本兼容

Groovy 版本 JDK 要求 包名
3.x JDK 8-17 org.codehaus.groovy
4.x JDK 8+ org.apache.groovy(迁移过)
5.x JDK 11+ org.apache.groovy

升级时对齐版本,别踩编译器兼容性的坑。尤其从 3.x 跳到 4.x,所有 import 要重写——这种改动要带上完整的回归测试再发。

小结:动态脚本解决变化,不解决治理

Groovy 动态加载这个方案,在合适场景下是性价比极高的工程工具——用很小的复杂度,换回「规则变更不用发版」这个能力。但它不是银弹。

适合的场景:

  • 营销规则:优惠券、积分、活动逻辑
  • 风控策略:阈值、黑名单、风险评分
  • 报文解析:对接多方接口且格式易变
  • 监控告警:告警条件和聚合规则

核心要点——这几条做不到,生产必出事:

要点 不做的代价
用 GroovyClassLoader,别用 GroovyShell / ScriptEngineManager 性能差 + 配置受限
MD5 缓存 + clearCache() 双管齐下 Metaspace 必炸
启动预热常用脚本 首次调用 P99 飙高
严控脚本写入权限 + SecureASTCustomizer 白名单 等于给系统留 RCE 后门
重要规则用「接口实现模式」 类型不安全 + 不好排查

最重要的一条——动态脚本只解决「变化」,不解决「治理」。

脚本数量上去之后,版本管理、回滚、审计、灰度这些规则引擎默认带的能力,你都得自己补:脚本改坏了怎么回滚?谁改的?什么时候生效的?灰度多少比例?规则之间冲突怎么处理?

这些问题在脚本数 < 10 时无所谓,到了 50+ 就成了运维灾难。

人少规则多的时候,Groovy 是甜品;规则复杂度真的上去了(多人协作、图形化编辑、规则间优先级和冲突处理),别硬撑——老老实实上 Drools 这类规则引擎,前期投入换长期可控。

文章目录
  1. 1. 规则天天变,别让发版当替罪羊
  2. 2. 规则引擎 vs 动态脚本:先看复杂度,再看频率
  3. 3. Groovy:JVM 上唯一能 0 成本调 Java 的脚本语言
  4. 4. 三种集成方式:生产只认 GroovyClassLoader
    1. 4.0.1. GroovyShell:最方便,最不适合生产
    2. 4.0.2. ScriptEngineManager:通用,但一样慢
    3. 4.0.3. GroovyClassLoader:官方推荐,生产实用
  • 5. Spring Boot 集成实战:MD5 缓存是关键
    1. 5.0.1. 引入依赖
    2. 5.0.2. 核心组件:带缓存的脚本加载器
    3. 5.0.3. 暴露 HTTP 接口
    4. 5.0.4. 测试一下
  • 6. 进阶用法:脚本要能用,也要能管
    1. 6.0.1. 在脚本里调 Spring Bean
    2. 6.0.2. 接口实现模式:生产推荐写法
    3. 6.0.3. 脚本放哪里
  • 7. 生产踩坑:五个问题,每个都能让你掉头发
    1. 7.0.1. 坑一:Metaspace 溢出(最常见,也最严重)
    2. 7.0.2. 坑二:Lambda 在 Groovy 里不一样
    3. 7.0.3. 坑三:首次执行的「冷启动悬崖」
    4. 7.0.4. 坑四:安全 = 生产里最容易被忽略的那条
    5. 7.0.5. 坑五:版本兼容
  • 8. 小结:动态脚本解决变化,不解决治理