前言
最近,小年在项目中使用 Groovy 对业务能力进行了一些扩展,感觉比较有意思,而且效果也不错,所以来分享一下使用经验。
先来简单概述一下小年的项目需求背景:有一个支付业务场景需要接入多个运营商的支付能力,每个运营商在支付后都会返回支付结果,但是每个运营商的支付结果报文格式各不相同。
要实现起来并不难,只需要针对每个运营商的报文格式制定不同的解析规则,当然问题并没有这么 easy
- 运营商的支付结果报文格式可能会变化
- 后面可能还需要接入新的运营商
也就是说,如果运营商改了报文格式,我们的解析规则需要不断调整,并且有新增的运营商,还需要新增适配的解析规则。
项目使用的是 Java 语言开发,如果每次调整解析规则,意味着需要修改代码,测试,发布,回归等一系列繁琐的步骤。
当然,办法总比困难多🤔
除了上面最直接的笨方法,还有两种可行方案:
1、规则引擎,比如开源 Drools、Easy Rules 等
2、动态脚本,比如 Groovy
规则引擎本身的目的就是为了解决业务的复杂性和多变性,但是小年的系统业务流程本身并不复杂,引入规则引擎就有点过于重,反而增加了系统复杂度,所以这个方案就被 Pass 掉了。
最终选用的是 Groovy 动态脚本的方案。可能有的同学第一次听说 Groovy ,包括小年自己也是。
什么是 Groovy?
Groovy 是一种基于 Java 平台的动态编程语言。它结合了静态类型语言和动态类型语言的特性,是一种面向对象的脚本语言,设计目标是提供更简洁、更具表达力的语法,以及更易于使用的 API。
这里我们只需要记住两个重点:
- 动态脚本语言,它允许在运行时动态添加、修改和删除类和方法;
- 与 Java 兼容,语法甚至更加简单;
原理并不复杂,JVM 类加载器动态将 Groovy 代码编译成 Java Class,然后生成 Java 对象在 JVM 上执行。
其实这跟 Nginx + Lua 的组合很相似(静态编译语言 + 动态脚本语言),一套动静组合实现一些需要灵活变动的需求或者规则。
适用场景
营销活动
营销活动这类的场景,可以说是最适合不过。营销活动的套路相信大家也都深有体会,千人千面、大数据杀熟这些套路。
运营同学需要经常性地调整营销策略和规则,不同的场景需要配置不同的规则,所以这时可以利用 Groovy 脚本来快速动态调整规则,快速高效的满足运营产品的需求。
风控规则
风控领域的规则引擎极其适合采用 Groovy 这一技术实现。在对抗黑产的过程中,策略制定人员每天都会产生新的拦截规则。如果每次都要发版,一些被发现的紧急问题或者被薅羊毛的漏洞没办法紧急修复。
因此,通过利用 Groovy 脚本引擎的动态解析执行功能,我们可以将拦截规则抽象成规则脚本,实现快速部署,从而显著提升工作效率。这种方法使得策略人员能够迅速响应风险事件,灵活调整规则,确保对黑产的打击始终保持高效和及时。
快速入门
1、引入依赖
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>3.0.17</version>
<type>pom</type>
</dependency>
2、新建一个 Hello.groovy
文件,声明一个 say()
方法
class Hello {
String say(String name) {
return name + "World!"
}
}
3、在 Java 类中用 GroovyClassLoader
加载 Hello.groovy
文件生成 Class ,然后生成实例,最后通过反射调用方法即可。
public class QuickStart {
public static void main(String[] args) throws Exception {
// 文件路径
String filePath = "src/main/java/com/zhang/awesome/groovy/Hello.groovy";
File groovyFile = new File(filePath);
GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
// 加载class
Class groovyClass = groovyClassLoader.parseClass(groovyFile);
// 生成实例
GroovyObject groovyObject = (GroovyObject) groovyClass.newInstance();
// 反射调用say方法
Object result = groovyObject.invokeMethod("say", "Hello");
System.out.println("return: " + result.toString());
}
}
是不是非常简单?只需要简单的3个步骤,就可以实现Groovy动态脚本能力,并且 Groovy 语法基本与 Java 是兼容的,所以写起来也是很方便。
进阶指南
Groovy 的使用方式
在 Java 中使用 Groovy 有三种方式:
- GroovyShell
- ScriptEngineManager
- GroovyClassLoader
1、GroovyShell
public static void main(String[] args) {
final String script = "Runtime.getRuntime().availableProcessors()";
Binding intBinding = new Binding();
GroovyShell shell = new GroovyShell(intBinding);
final Object eval = shell.evaluate(script);
System.out.println(eval);
}
2、ScriptEngineManager
public static void main(String[] args) throws ScriptException, NoSuchMethodException {
ScriptEngineManager factory = new ScriptEngineManager();
// 每次生成一个engine实例
ScriptEngine engine = factory.getEngineByName("groovy");
Bindings binding = engine.createBindings();
// 入参
binding.put("date", new Date());
// 如果script文本来自文件,请首先获取文件内容
engine.eval("def getTime(){return date.getTime();}", binding);
engine.eval("def sayHello(name,age){return 'Hello,I am ' + name + ',age' + age;}");
// 反射到方法
Long time = (Long) ((Invocable) engine).invokeFunction("getTime", null);
System.out.println(time);
String message = (String) ((Invocable) engine).invokeFunction("sayHello", "zhangsan", 12);
System.out.println(message);
}
3、GroovyClassLoader
public static void groovyClassLoader() throws InstantiationException, IllegalAccessException {
GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
// 可以是纯Java代码
String helloScript = "package com.vivo.groovy.util" +
"class Hello {" +
"String say(String name) {" +
"System.out.println(\"hello, \" + name)" +
" return name;" +
"}" +
"}";
Class helloClass = groovyClassLoader.parseClass(helloScript);
GroovyObject object = (GroovyObject) helloClass.newInstance();
// 控制台输出"hello, vivo"
Object ret = object.invokeMethod("say", "vivo");
// 打印vivo
System.out.println(ret.toString());
}
Groovy 官方提供 **GroovyClassLoader
**类,支持从文件、URL或字符串中加载解析 Groovy Class
,实例化对象,反射调用指定方法。GroovyShell
、ScriptEngineManager
底层核心也是调用了 GroovyClassLoader
,并且还会存在性能问题。所以一般场景来说还是比较推荐使用 GroovyClassLoader
最佳实践
当然,使用 Groovy 的动态脚本能力并不难,但如果要真正运用整合到项目中,是需要一定的设计模式和方法技巧,小年总结一下主要有这么两点
1. 脚本加载的方式
脚本变更后,如何实时生效?
既然是动态,我们最希望的当然是:调整和修改脚本的代码后,能够实时生效。也就是说应用能够感知脚本的变更,并且能重新Reload。
GroovyClassLoader
支持字符串、文件、URL的方式加载脚本。实现的方式很多,比如可以字符串的话,可以放在配置中心、数据库;文件的话可以放在资源服务器中再通过接口或者配置触发reload等。小年在项目中的使用方式是通过数据库的形式,把脚本代码都放在一个表的字段里,每次都从数据库中读取最新。
2. 规则设计
我们需要尽可能把最频繁变动的部分抽取到脚本中,而并不是把所有代码都丢到脚本来实现。简单来说就是要抽象出最小细粒度的动态规则,这部分才是脚本里面的内容。
举个例子,比如说在营销场景下,针对不同用户的属性(年龄、等级)可以获得不同的抽奖次数,我们在项目中定义一个接口:IRewardRule
public interface IRewardRule {
Integer getRewardCount(User user);
}
而获取用户抽奖次数的规则逻辑则是放在 Groovy 脚本中实现 RewardRule.groovy
class RewardRule implements IRewardRule {
@Override
Integer getRewardCount(User user) {
if (user.getAge() <= 10) {
return 5
}else if (user.getAge() <= 20 && user.getAge() > 10) {
return 3
}else {
return 1
}
}
}
在业务调用的时候我们可以直接用 GroovyClassLoader
生成对应的类,调用 getRewardCount
方法
GroovyClassLoader classLoader = new GroovyClassLoader();
Class<?> groovyClazz = classLoader.parseClass(script);
Object instance = groovyClazz.newInstance();
classLoader.clearCache();
IRewardRule rewardRule = clazz.cast(instance);
rewardRule.getRewardCount(user)
具体的Demo可以参考:https://github.com/Zhang-BigSmart/awesome-groovy
踩坑指南
内存泄露
GroovyClassLoader
类加载器每次调用 parseClass
方法执行 Groovy 脚本,都会重新编译脚本,调用类加载器进行类加载。我们知道类对象信息是放在 JVM 的 Metaspace 区域中,重复不断地执行 Groovy 脚本意味着会创建大量的类,容易导致 Metaspace 内存溢出,造成内存泄露。
简单了解一下 Groovy 脚本的加载流程, GroovyClassLoader
执行核心方法 parseClass
方法:
public Class parseClass(String text) throws CompilationFailedException {
return parseClass(text, "script" + System.currentTimeMillis() +
Math.abs(text.hashCode()) + ".groovy");
}
public Class parseClass(GroovyCodeSource codeSource, boolean shouldCacheSource) throws CompilationFailedException {
synchronized (sourceCache) {
Class answer = sourceCache.get(codeSource.getName());
if (answer != null) return answer;
answer = doParseClass(codeSource);
if (shouldCacheSource) sourceCache.put(codeSource.getName(), answer);
return answer;
}
}
可以看到每次调用 parseClass
方法,都会生成一个 Class 对象,而对象名是 script + System.currentTimeMillis()+Math.abs(text.hashCode()
组成,也就是说,即使是相同内容的脚本,都会被认为是新的代码,进行新的编译和加载。而你的业务逻辑不断重复执行就会一直生成新的类,最终导致 Metaspace
溢出。
而且 GroovyClassLoader 还会缓存类的信息,像上面的 sourceCache.put(codeSource.getName(), answer)
之外,还有 classCache
也会缓存类的对象,所以导致 Class 对象不可被回收
protected void setClassCacheEntry(Class cls) {
synchronized (classCache) {
classCache.put(cls.getName(), cls);
}
}
要解决这个性能问题,我们通常是对加载后的 Groovy 脚本进行缓存,避免重复编译加载,可以通过计算脚本的MD5值来生成键值对进行缓存。
通过应用层自己维护一个cache,从而解决 Metaspace 内存溢出的问题。当然,这里还有一个小细节点,在初始化的时候加上同步锁,可以避免并发的问题。
private final static Map<String, Object> SCRIPT_CACHE = new ConcurrentHashMap<>();
public synchronized <T> T initialize(String cacheKey, String script, Class<T> clazz) {
if (SCRIPT_CACHE.containsKey(cacheKey)) {
return clazz.cast(SCRIPT_CACHE.get(cacheKey));
}
GroovyClassLoader classLoader = new GroovyClassLoader();
try {
Class<?> groovyClazz = classLoader.parseClass(script);
if (clazz != null) {
Object instance = groovyClazz.newInstance();
// 清除GroovyClassLoader的缓存
classLoader.clearCache();
// 应用缓存
SCRIPT_CACHE.put(cacheKey, instance);
return clazz.cast(instance);
}
} catch (Exception e) {
log.error("initialize exception", e);
}
return null;
}
小结
对于 Java 开发者来说,Groovy 是一门非常容易上手的一门语言。 Groovy 动态加载的能力,非常适用于业务变化多而快的需求,提高开发效率,更快相应需求的变化,提供系统稳定性。
如果你正好也碰到一些需要频繁变动规则的需求,不妨可以考虑一下 Groovy。