groovy脚本执行与优化

1. 背景

Apache的Groovy是Java平台上设计的面向对象编程语言。这门动态语言拥有类似Python、Ruby和Smalltalk中的一些特性,可以作为Java平台的脚本语言使用,Groovy代码动态地编译成运行于Java虚拟机(JVM)上的Java字节码,并与其他Java代码和库进行互操作。由于其运行在JVM上的特性,Groovy可以使用其他Java语言编写的库。Groovy的语法与Java非常相似,大多数Java代码也符合Groovy的语法规则,尽管可能语义不同。

------来自wikipedia

在网上看到看到一个很有意思的比喻,Groovy之于Java,就好比狂草之于楷书。写好了一样赏心悦目,但是正式场合(企业级开发)还是严肃一点的多。

但Groovy的灵活性、Java良好的兼容(JVM)、本身的语法对于Java工程师来说学习成本不高使得其成为了一项被广泛使用的脚本语言。

 

2. 实现

单纯实现Groovy脚本执行很简单,一般有三种方式,GroovyClassLoader,GroovyShell,GroovyScirptEngine。它们之间的区别在于:

GroovyClassLoader 会动态地加载一个脚本并执行它,可使用Binding对象输入参数。GroovyClassLoader是一个定制的类装载器,负责解释加载Java类中用到的Groovy类。

 

GroovyShell允许在Java类中求任意Groovy表达式的值。可使用Binding对象输入参数给表达式,并最终通过GroovyShell返回Groovy表达式的计算结果,GroovyShell还支持一些沙盒环境等特性,多用于推求对立的脚本或表达式。

 

GroovyScirptEngine作为一个引擎,功能更全面,它本身提供一些脚本的缓存等机制。,如果换成相互关联的多个脚本,使用GroovyScriptEngine会更好些。GroovyScriptEngine从您指定的位置(文件系统,URL,数据库,等等)加载Groovy脚本,并且随着脚本变化而重新加载它们。同样,也允许传入参数值,并能返回脚本的值。

 

针对本次的使用场景,最终使用的是groovyshell,原因在于使用脚本的场景更多的是想依赖其灵活动态的特性,不想Java逻辑一变就需要重新发布。而本身脚本的逻辑不会特别复杂,更多的是对传入的参数进行简单的计算看是否符合期望。那么,不要对主流程甚至是JVM本身,应用本身造成影响是我们着重考虑的点。

 

2.1 最简单的实现

GroovyClassLoader groovyLoader = new GroovyClassLoader();
Class<Script> groovyClass groovyClass = (Class<Script>) groovyLoader.parseClass(scriptString);
Script groovyScript = groovyClass.newInstance();
Binding binding = new Binding();
Map variables = binding.getVariables();
variables.putAll(params);
groovyScript.setBinding(binding);
Object groovyResult = groovyScript.run();

可以看到跑起来很容易,但是这里面有很多坑,其它博客上或者是在我们技术团队都发生过真实的教训。

 

3. 优化

3.1 GroovyClassLoader

毕大师的文章也说到,我们不要采用一个全局的GroovyClassLoader,parseClass方法得到的Class<Script>对象会保存在PermGen,而Class对象被GC的条件之一是其ClassLoader先被GC,这就会导致PermGen的Class<Script>对象越来越多,最后被打满的情况。

 

所以这里很多文章都会推荐在需要的时候新new 一个GroovyClassLoader。对于GroovyClassLoader更详细的介绍可以阅读参考文档1。第二点是,完全相同的脚本多次执行,我们能否复用Class<Script>对象,这里推荐用LRU cache的比较多。对应到程序里,我们使用自定义配置的guava缓存,其中evict的设置(maxSize,超时的计算逻辑)需要使用者结合自己的使用场景来具体配置。

private Cache<String, Class<Script>> innerLruCache = CacheBuilder.newBuilder()
            .maximumSize(1000) //最大容量
            .expireAfterAccess(6, TimeUnit.HOURS) //缓存过期时长
            .concurrencyLevel(Runtime.getRuntime().availableProcessors())// 设置并发级别为cpu核心数
            .build();

String scriptKey = HashUtils.md5Hash(script.getBytes());
Class<Script> groovyClass = innerLruCache.getIfPresent(scriptKey);
if (groovyClass == null) {
  //缓存穿透,走2.1前3行的老逻辑
    //同时在每次更新缓存Class<Script>对象时候,采用了不同的groovyClassLoader
    innerLruCache.put(scriptKey, groovyClass);
}

3.2 自定义配置

在查看了很多资料后,发现如果替换GroovyClassLoader使用GroovyShell会获得更多安全性。而使用GroovyShell的时候本身对3.1GroovyClassLoader优化的思想不用变,因为GroovyShell的构造函数也会重新new 一个GroovyClassLoader

public GroovyShell(ClassLoader parent, Binding binding, final CompilerConfiguration config) {
    if (binding == null) {
        throw new IllegalArgumentException("Binding must not be null.");
    }
    if (config == null) {
        throw new IllegalArgumentException("Compiler configuration must not be null.");
    }
    final ClassLoader parentLoader = (parent!=null)?parent:GroovyShell.class.getClassLoader();
    this.loader = AccessController.doPrivileged(new PrivilegedAction<GroovyClassLoader>() {
        public GroovyClassLoader run() {
            return new GroovyClassLoader(parentLoader,config);
        }
    });
    this.context = binding;        
    this.config = config;
}

所以这个时候,2.1前三行的逻辑可以被替换为

//自定义配置
CompilerConfiguration config = new CompilerConfiguration();

//添加线程中断拦截器,可拦截循环体(for,while)、方法和闭包的首指令
config.addCompilationCustomizers(new ASTTransformationCustomizer(ThreadInterrupt.class));

//添加线程中断拦截器,可中断超时线程,当前定义超时时间为3s
Map<String, Object> timeoutArgs = ImmutableMap.of("value", 3);
config.addCompilationCustomizers(new ASTTransformationCustomizer(timeoutArgs, TimedInterrupt.class));

//沙盒环境
config.addCompilationCustomizers(new SandboxTransformer());
GroovyShell sh = new GroovyShell(config);
new NoSystemExitSandbox().register();
new NoRunTimeSandbox().register();

//确保在每次更新缓存Class<Script>对象时候,采用不同的groovyClassLoader
groovyScript = sh.parse(script);
groovyClass = (Class<Script>) groovyScript.getClass();

3.2.1 运行时元编程

可以看到采取自定义配置,我们能够通过Groovy运行时元编程来获得一些安全特性,eg:死循环的处理,中断超时等。虽然会损失一些性能,但是对于我的使用场景(安全>时效),是可以接受的。

关于运行时元编程:

它容许编译时生成代码。这种转换会影响程序的抽象语法树(AST,Abstract Syntax Tree),这也就是我们在 Groovy 中把它称为 AST 转换的原因。AST 转换能使我们实时了解编译过程,继而修改 AST,从而继续编译过程,生成常规的字节码。与运行时元编程相比,编译时元编程在类文件自身中(也就是说,在字节码内)就可以看到变化。这一点是非常重要的,比如说当你想让代码转换成为类抽象一部分时(实现接口,继承抽象类,等等),或者甚至当需要让类可从 Java (或其他的 JVM 语言)中调用时。

AST 转换可以为一个类添加一些方法。如果用运行时元编程来实现的话,新方法只能可见于 Groovy;而用编译时元编程来实现,新方法也可以在 Java 中显现出来。最后一点也同样重要,编译时元编程的性能要好过运行时元编程(因为不再需要初始化过程)。

------Groovy元编程(即参考文档2)

 

3.2.2 沙盒环境

引入沙盒环境实际上是在编译阶段对Groovy脚本做处理,特别是作为脚本执行其实我们只想让它使用合理的JVM资源,更不能执行像System.exit()这样的风险方法。

config.addCompilationCustomizers(new SandboxTransformer());就是允许Groovy代码进行沙盒转换的操作,沙盒具体执行哪些限制需要我们注册实现了GroovyInterceptor的类的实例。

举个例子,下面两个类分别禁止执行System.exit()方法以及Runtime这个类的所有方法。

class NoSystemExitSandbox extends GroovyInterceptor {
    @Override
    public Object onStaticCall(GroovyInterceptor.Invoker invoker, Class receiver, String method, Object... args) throws Throwable {
        if (receiver == System.class && method == "exit") {
            throw new SecurityException("No call on System.exit() please");
        }
        return super.onStaticCall(invoker, receiver, method, args);
    }
}

class NoRunTimeSandbox extends GroovyInterceptor {
    @Override
    public Object onStaticCall(GroovyInterceptor.Invoker invoker, Class receiver, String method, Object... args) throws Throwable {
        if (receiver == Runtime.class) {
            throw new SecurityException("No call on RunTime please");
        }
        return super.onStaticCall(invoker, receiver, method, args);
    }
}

3.2.3 编译检查

这个稍微trick一点,实质上是使用GroovyShell的parse方法,如果编译报错会抛出MultipleCompilationErrorsException,异常信息还是很全的,甚至告诉你哪一行哪里出了什么问题,同时搭配ErrorCollector,能够获得一些额外的编译报错的信息。

try{
  groovyScript = sh.parse(scirptString);
}catch (MultipleCompilationErrorsException exception) {
        ErrorCollector errorCollector = exception.getErrorCollector();
        LOGGER.info("here are {} errors when compiling the script", errorCollector.getErrorCount());
        //do something else
}

 

3.3 脚本执行&结果获取

在2.1最简单的实现一节,按照那种写法,如果run()方法真的有什么问题,比如恶意的sleep,或者确实因为某些不可预测的原因(死循环已经可以)导致要执行很长时间,那主线程就卡顿在这里,这是不能接受的。所以我们要引入线程池来做这件事情。关于线程池这里不做过多解释,具体可以根据需要定制出更符合场景的线程池。

/**
  * fixed thread pool
  * 线程数为cpu核心数
  * 最多等待1000个任务,超过使用默认丢弃策略,抛出异常
*/
private ThreadPoolExecutor threadPool = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),
        Runtime.getRuntime().availableProcessors(), 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));
//对2.1第8行代码的改造
Future<Object> future = threadPool.submit((Callable<Object>) groovyScript::run);
try{
    Object groovyResult = future.get(3, TimeUnit.SECONDS);
}catch (TimeoutException exception) {
    future.cancel(true);
    LOGGER.info("try cancel future task, is cancelled:{}", future.isCancelled());
    //do something else
}

这里要特别解释一下前面使用V get(long timeout, TimeUnit unit)

       throws InterruptedException, ExecutionException, TimeoutException;

是否和前面3.2.1运行时元编程的线程超时中断冲突。其实不会,比如再未引入运行时元编程时,我执行如下脚本

def eq (var1,var2){
  println('before sleep')
  sleep(4000)
  println('after sleep')
  return var1 == var2
};
eq(true,false); 

在3000ms过后,future.get()拿不到脚本执行的返回结果,抛出TimeoutException,但是主线程感知到了超时继续做别的处理,但是线程池中执行脚本的线程并没有真正被中断,主线程先打印出随后try cancel future task, is cancelled:{true},之后'after sleep'还是会被打印出来。所以实际上前面的运行时元编程线程超时中断是一个很好的补充机制。

 

关于future.cancel(true),查了一下这个方法是会调用interrupt()方法,但是只是设置了状态标志位,但并不是说线程就一定会被直接中断掉。

 

4.写在后面

接到这个需求的时候我是一脸懵的,因为groovy是一点都没接触过,最后用了4个人日做了这样一版东西,我深知不光要会用,能跑就行是最低要求而已,还要多了解一些原理性的东西。但是不免有一些纰漏和不足之处,欢迎大家多多指正。

文章的顺序基本上是我参考来自公司技术博客,各大技术博客,stackoverflow一点一点优化的过程,这个过程还是收获满满的,现在这段代码是跑在线上的,后面会继续观察它的表现,以及是否对JVM造成了影响。很多前辈都趟过雷,站在前人的经验教训上确实受益很多。

另外,为了避免冗长的代码影响大家思考,我将诸如异常处理等一些逻辑贴的比较少,但异常处理本身非常重要。

最后,希望能够对其他人起到一些帮助作用。

 

5. 参考文档

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值