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

摘要: 原创出处 宅小年 「宅小年」欢迎转载,保留摘要,谢谢!


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

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

前言

最近,小年在项目中使用 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。

参考

文章目录
  1. 1. 前言
  2. 2. 什么是 Groovy?
  3. 3. 适用场景
    1. 3.1. 营销活动
    2. 3.2. 风控规则
  4. 4. 快速入门
  5. 5. 进阶指南
    1. 5.1. Groovy 的使用方式
      1. 5.1.1. 1、GroovyShell
      2. 5.1.2. 2、ScriptEngineManager
      3. 5.1.3. 3、GroovyClassLoader
    2. 5.2. 最佳实践
      1. 5.2.1. 1. 脚本加载的方式
      2. 5.2.2. 2. 规则设计
    3. 5.3. 踩坑指南
      1. 5.3.1. 内存泄露
  6. 6. 小结
  7. 7. 参考