业务复杂多变?Groovy魔法让你轻松应对

业务复杂多变?Groovy魔法让你轻松应对

Scroll Down

前言

最近,小年在项目中使用 Groovy 对业务能力进行了一些扩展,感觉比较有意思,而且效果也不错,所以来分享一下使用经验。

先来简单概述一下小年的项目需求背景:有一个支付业务场景需要接入多个运营商的支付能力,每个运营商在支付后都会返回支付结果,但是每个运营商的支付结果报文格式各不相同。

要实现起来并不难,只需要针对每个运营商的报文格式制定不同的解析规则,当然问题并没有这么 easy

  • 运营商的支付结果报文格式可能会变化
  • 后面可能还需要接入新的运营商

也就是说,如果运营商改了报文格式,我们的解析规则需要不断调整,并且有新增的运营商,还需要新增适配的解析规则。

项目使用的是 Java 语言开发,如果每次调整解析规则,意味着需要修改代码,测试,发布,回归等一系列繁琐的步骤。

http://pic.edisonz.cn/006APoFYly1g2gf1a6o7ig30a00a0hc9.gif

当然,办法总比困难多🤔

除了上面最直接的笨方法,还有两种可行方案:

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,实例化对象,反射调用指定方法。GroovyShellScriptEngineManager 底层核心也是调用了 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。

参考

复杂多变场景下的Groovy脚本引擎实战

基于Groovy的规则脚本引擎实战