乐趣区

关于敏捷开发:复杂多变场景下的Groovy脚本引擎实战

一、前言

因为之前在我的项目中应用了 Groovy 对业务能力进行一些扩大,成果比拟好,所以简略记录分享一下,这里你能够理解:

  • 为什么选用 Groovy 作为脚本引擎
  • 理解 Groovy 的基本原理和 Java 如何集成 Groovy
  • 在我的项目中应用脚本引擎时做的平安和性能优化
  • 理论应用的一些倡议

二、为什么应用脚本语言

2.1 脚本语言可解决的问题

互联网时代随着业务的飞速发展,不仅产品迭代、更新的速度越来越快,个性化需要也是越来越多,如:多维度(条件)的查问、业务流转规定等。方法通常有如下几个方面:

  • 最常见的形式是用代码枚举所有状况,即所有查问维度、所有可能的规定组合,依据运行时参数遍历查找;
  • 应用开源计划,例如 drools 规定引擎,此类引擎实用于业务基于规定流转,且比较复杂的零碎;
  • 应用动静脚本引擎,例如 Groovy,JSR223。注:JSR 即 Java 标准申请,是指向 JCP(Java Community Process)提出新增一个标准化技术规范的正式申请。任何人都能够提交 JST,以向 Java 平台削减新的 API 和服务。JSR 是 Java 界的一个重要规范。JSR223 提供了一种从 Java 外部执行脚本编写语言的不便、规范的形式,并提供从脚本外部拜访 Java 资源和类的性能,即为各脚本引擎提供了对立的接口、对立的拜访模式。JSR223 不仅内置反对 Groovy、Javascript、Aviator,而且提供 SPI 扩大,笔者曾通过 SPI 扩大实现过 Java 脚本引擎,将 Java 代码“脚本化”运行。

引入动静脚本引擎对业务进行形象能够满足定制化需要,大大晋升我的项目效率。例如,笔者当初开发的内容平台零碎中,上游的内容需求方依据不同的策略会要求内容平台圈选指定内容推送到指定的解决零碎,这些解决零碎解决完后,内容平台接管到处理结果再依据散发策略(规定)下发给举荐零碎。每次圈选内容都要写一堆对于此次圈选的查问逻辑,内容下发的策略也常常须要变更。所以想利用脚本引擎的动静解析执行,应用规定脚本将查问条件以及下发策略形象进去,晋升效率。

2.2 技术选型

对于脚本语言来说,最常见的就是 Groovy,JSR233 也内置了 Groovy。对于不同的脚本语言,选型时须要思考性能、稳定性、灵活性,综合思考后抉择 Groovy,有如下几点起因:

  • 学习曲线平缓,有丰盛的语法糖,对于 Java 开发者十分敌对;
  • 技术成熟,功能强大,易于应用保护,性能稳固,被业界看好;
  • 和 Java 兼容性强,能够无缝连接 Java 代码,能够调用 Java 所有的库。

2.3 业务革新

因为经营、产品同学对于内容的需要在一直的调整,内容平台圈选内容的能力须要可能反对各种查问维度的组合。内容平台起初开发了一个查问组合为(状态,入库工夫,起源方,内容类型),并定向散发到内容了解和打标的接口。然而这个接口曾经不能满足需要的变动,为此,最容易想到的设计就是枚举所有表字段(如公布工夫、作者名称等近 20 个),使其成为查问条件。然而这种设计的开发逻辑其实是很繁琐的,也容易造成慢查问;比方:筛选指定合作方和等级 S 的 up 主,且对没有内容了解记录的视频,调用内容了解接口,即对这部分视频进行内容了解。为了满足需要,须要从新开发,后果就是 write once, run only once,造成开发和发版资源的节约。

不论是 JDBC for Mysql,还是 JDBC for MongoDB 都是面向接口编程,即查问条件是被封装成接口的。基于面向接口的编程模式,查问条件 Query 接口的实现能够由脚本引擎动静生成,这样就能够满足任何查问场景。执行流程如下图 3.1。

上面给出脚本的代码 Demo:

/**
* 构建查问对象 Query
* 分页查问 mongodb
*/
public Query query(int page){
    String source = "Groovy";
    String articleType = 4; // (source,articleType) 组成联结索引,进步查问效率
    Query query = Query.query(where("source").is(source)); // 查问条件 1:source="Groovy"
    query.addCriteria(where("articleType").is(articleType)); // 查问条件 2:articleType=4
    Pageable pageable = new PageRequest(page, PAGESIZE);
    query.with(pageable);// 设置分页
    query.fields().include("authorId"); // 查问后果返回 authorId 字段
    query.fields().include("level"); // 查问后果返回 level 字段
    return query;
}
/**
* 过滤每一页查问后果
*/
public boolean filter(UpAuthor upAuthor){return !"S".equals(upAuthor.getLevel(); // 过滤掉 level != S 的作者
}
/**
* 对查问后果集逐条解决
*/
public void handle(UpAuthor upAuthor) {UpAthorService upAuthorService = SpringUtil.getBean("upAuthorService"); // 从 Spring 容器中获取执行 java bean
    if(upAuthorService == null){throw new RuntimeException("upAuthorService is null");
    }
    AnalysePlatService analysePlatService =  SpringUtil.getBean("analysePlatService"); // 从 Spring 容器中获取执行 java bean
        if(analysePlatService == null){throw new RuntimeException("analysePlatService is null");
    }
    List<Article> articleList = upAuthorService.getArticles(upAuthor);// 获取作者名下所有视频
    if(CollectionUtils.isEmpty(articleList)){return;}
    articleList.forEach(article->{if(article.getAnalysis() == null){analysePlatService.analyse(article.getArticleId()); // 提交视频给内容了解解决
        }  
    })
}

实践上,能够指定任意查问条件,编写任意业务逻辑,从而对于流程、规定常常变动的业务来说,解脱了开发和发版的时空解放,从而可能及时响应各方的业务变更需要。

三、Groovy 与 Java 集成

3.1 Groovy 基本原理

Groovy 的语法很简洁,即便不想学习其语法,也能够在 Groovy 脚本中应用 Java 代码,兼容率高达 90%,除了 lambda、数组语法,其余 Java 语法根本都能兼容。这里对语法不多做介绍,有趣味能够自行浏览 https://www.w3cschool.cn/groovy 进行学习。

3.2 在 Java 我的项目中集成 Groovy

3.2.1 ScriptEngineManager

依照 JSR223,应用标准接口 ScriptEngineManager 调用。

ScriptEngineManager factory = new ScriptEngineManager();
ScriptEngine engine = factory.getEngineByName("groovy");// 每次生成一个 engine 实例
Bindings binding = engine.createBindings();
binding.put("date", new Date()); // 入参
engine.eval("def getTime(){return date.getTime();}", binding);// 如果 script 文本来自文件, 请首先获取文件内容
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.2.2 GroovyShell

Groovy 官网提供 GroovyShell,执行 Groovy 脚本片段,GroovyShell 每一次执行时代码时会动静将代码编译成 Java Class,而后生成 Java 对象在 Java 虚拟机上执行,所以如果应用 GroovyShell 会造成 Class 太多,性能较差。

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);

3.2.3 GroovyClassLoader

Groovy 官网提供 GroovyClassLoader 类,反对从文件、url 或字符串中加载解析 Groovy Class,实例化对象,反射调用指定办法。

GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
  String helloScript = "package com.vivo.groovy.util" +  // 能够是纯 Java 代码
          "class Hello {" +
            "String say(String name) {" +
              "System.out.println(\"hello, \"+ name)" +
              "return name;"
            "}" +
          "}";
Class helloClass = groovyClassLoader.parseClass(helloScript);
GroovyObject object = (GroovyObject) helloClass.newInstance();
Object ret = object.invokeMethod("say", "vivo"); // 控制台输入 "hello, vivo"
System.out.println(ret.toString()); // 打印 vivo

3.3 性能优化

当 JVM 中运行的 Groovy 脚本存在大量并发时,如果依照默认的策略,每次运行都会从新编译脚本,调用类加载器进行类加载。一直从新编译脚本会减少 JVM 内存中的 CodeCache 和 Metaspace,引发内存泄露,最初导致 Metaspace 内存溢出;类加载过程中存在同步,多线程进行类加载会造成大量线程阻塞,那么效率问题就不言而喻了。

为了解决性能问题,最好的策略是对编译、加载后的 Groovy 脚本进行缓存,防止反复解决,能够通过计算脚本的 MD5 值来生成键值对进行缓存。上面咱们带着以上论断来探讨。

3.3.1 Class 对象的数量

3.3.1.1 GroovyClassLoader 加载脚本

下面提到的三种集成形式都是应用 GroovyClassLoader 显式地调用类加载办法 parseClass,即编译、加载 Groovy 脚本,天然地脱离了 Java 驰名的 ClassLoader 双亲委派模型。

GroovyClassLoader 次要负责运行时解决 Groovy 脚本,将其编译、加载为 Class 对象的工作。查看要害的 GroovyClassLoader.parseClass 办法,如下所示代码 3.1.1.1(出自 JDK 源码)。

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;
    }
}

零碎每执行一次脚本,都会生成一个脚本的 Class 对象,这个 Class 对象的名字由 “script” + System.currentTimeMillis()+Math.abs(text.hashCode() 组成,即便是雷同的脚本,也会当做新的代码进行编译、加载,会导致 Metaspace 的收缩,随着零碎一直地执行 Groovy 脚本,最终导致 Metaspace 溢出。

持续往下跟踪代码,GroovyClassLoader 编译 Groovy 脚本的工作次要集中在 doParseClass 办法中,如下所示代码 3.1.1.2(出自 JDK 源码):

private Class doParseClass(GroovyCodeSource codeSource) {validate(codeSource); // 简略校验一些参数是否为 null 
    Class answer;
    CompilationUnit unit = createCompilationUnit(config, codeSource.getCodeSource()); 
    SourceUnit su = null; 
    if (codeSource.getFile() == null) {su = unit.addSource(codeSource.getName(), codeSource.getScriptText()); 
    } else {su = unit.addSource(codeSource.getFile()); 
    } 
    ClassCollector collector = createCollector(unit, su); // 这里创立了 GroovyClassLoader$InnerLoader
    unit.setClassgenCallback(collector); 
    int goalPhase = Phases.CLASS_GENERATION; 
    if (config != null && config.getTargetDirectory() != null) goalPhase = Phases.OUTPUT; 
    unit.compile(goalPhase); // 编译 Groovy 源代码 
    answer = collector.generatedClass;   // 查找源文件中的 Main Class
    String mainClass = su.getAST().getMainClassName(); 
    for (Object o : collector.getLoadedClasses()) {Class clazz = (Class) o; 
        String clazzName = clazz.getName(); 
        definePackage(clazzName); 
        setClassCacheEntry(clazz); 
        if (clazzName.equals(mainClass)) answer = clazz; 
    } 
    return answer; 
}

持续来看一下 GroovyClassLoader 的 createCollector 办法,如下所示代码 3.1.1.3(出自 JDK 源码):

protected ClassCollector createCollector(CompilationUnit unit, SourceUnit su) {InnerLoader loader = AccessController.doPrivileged(new PrivilegedAction<InnerLoader>() {public InnerLoader run() {return new InnerLoader(GroovyClassLoader.this);  // InnerLoader extends GroovyClassLoader
        } 
    }); 
    return new ClassCollector(loader, unit, su); 
}   
public static class ClassCollector extends CompilationUnit.ClassgenCallback { 
    private final GroovyClassLoader cl; 
    // ... 
    protected ClassCollector(InnerLoader cl, CompilationUnit unit, SourceUnit su) { 
        this.cl = cl; 
        // ... 
    } 
    public GroovyClassLoader getDefiningClassLoader() {return cl;} 
    protected Class createClass(byte[] code, ClassNode classNode) {GroovyClassLoader cl = getDefiningClassLoader(); // GroovyClassLoader$InnerLoader
        Class theClass = cl.defineClass(classNode.getName(), code, 0, code.length, unit.getAST().getCodeSource()); // 通过 InnerLoader 加载该类
        this.loadedClasses.add(theClass); 
        // ... 
        return theClass; 
    } 
    // ... 
}

ClassCollector 的作用,就是在编译的过程中,将编译进去的字节码,通过 InnerLoader 进行加载。另外,每次编译 groovy 源代码的时候,都会新建一个 InnerLoader 的实例。那有了 GroovyClassLoader,为什么还须要 InnerLoader 呢?次要有两个起因:

加载同名的类

类加载器与类全名能力确立 Class 对象在 JVM 中的唯一性。因为一个 ClassLoader 对于同一个名字的类只能加载一次,如果都由 GroovyClassLoader 加载,那么当一个脚本里定义了 com.vivo.internet.Clazz 这个类之后,另外一个脚本再定义一个 com.vivo.internet.Clazz 类的话,GroovyClassLoader 就无奈加载了。

回收 Class 对象

因为当一个 Class 对象的 ClassLoader 被回收之后,这个 Class 对象才可能被回收,如果由 GroovyClassLoader 加载所有的类,那么只有当 GroovyClassLoader 被回收了,所有这些 Class 对象才可能被回收,而如果用 InnerLoader 的话,因为编译完源代码之后,曾经没有对它的内部援用,它就能够被回收,由它加载的 Class 对象,才可能被回收。上面具体探讨 Class 对象的回收。

3.3.1.2 JVM 回收 Class 对象

什么时候会触发 Metaspace 的垃圾回收?

  • Metaspace 在没有更多的内存空间的时候,比方加载新的类的时候;
  • JVM 外部又一个叫做 \_capacity\_until_GC 的变量,一旦 Metaspace 应用的空间超过这个变量的值,就会对 Metaspace 进行回收;
  • FGC 时会对 Metaspace 进行回收。

大家可能这里会有疑难:就算 Class 数量过多,只有 Metaspace 触发 GC,那应该就不会溢出了。为什么下面会给出 Metaspace 溢出的论断呢?这里引出下一个问题:JVM 回收 Class 对象的条件是什么?

  • 该类所有的实例都曾经被 GC,也就是 JVM 中不存在该 Class 的任何实例;
  • 加载该类的 ClassLoader 曾经被 GC;
  • java.lang.Class 对象没有在任何中央被援用。

条件 1 ,GroovyClassLoader 会把脚本编译成一个类,这个脚本类运行时用反射生成一个实例并调用它的入口函数执行(详见图 3.1),这个动作个别只会被执行一次,在利用外面不会有其余中央援用该类或它生成的实例,该条件至多是能够通过标准编程来满足。 条件 2 ,下面曾经剖析过,InnerClassLoader 用完后即可被回收,所以条件能够满足。 条件 3 ,因为脚本的 Class 对象始终被援用,条件无奈满足。

为了验证条件 3 是无奈满足的论断,持续查看 GroovyClassLoader 中的一段代码 3.1.2.1(出自 JDK 源码):

/**
* this cache contains the loaded classes or PARSING, if the class is currently parsed
*/
protected final Map<String, Class> classCache = new HashMap<String, Class>();
 
protected void setClassCacheEntry(Class cls) {synchronized (classCache) { // 同步块
        classCache.put(cls.getName(), cls);
    }
}

加载的 Class 对象,会缓存在 GroovyClassLoader 对象中,导致 Class 对象不可被回收。

3.3.2 高并发时线程阻塞

下面有两处同步代码块,详见代码 3.1.1.1 和代码 3.1.2.1。当高并发加载 Groovy 脚本时,会造成大量线程阻塞,肯定会产生性能瓶颈。

3.3.3 解决方案

  • 对于 parseClass 后生成的 Class 对象进行缓存,key 为 Groovy 脚本的 md5 值,并且在配置端批改配置后可进行缓存刷新。这样做的益处有两点:(1)解决 Metaspace 爆满的问题;(2)因为不须要在运行时编译加载,所以能够放慢脚本执行的速度。
  • GroovyClassLoader 的应用用参考 Tomcat 的 ClassLoader 体系,无限个 GroovyClassLoader 实例常驻内存,减少解决的吞吐量。
  • 脚本动态化:Groovy 脚本外面尽量都用 Java 动态类型,能够缩小 Groovy 动静类型查看等,进步编译和加载 Groovy 脚本的效率。

四、平安

4.1 被动平安

4.1.1 编码平安

Groovy 会主动引入 java.util,java.lang 包,不便用户调用,但同时也减少了零碎的危险。为了避免用户调用 System.exit 或 Runtime 等办法导致系统宕机,以及自定义的 Groovy 片段代码执行死循环或调用资源超时等问题,Groovy 提供了 SecureASTCustomizer 平安管理者和 SandboxTransformer 沙盒环境。

final SecureASTCustomizer secure = new SecureASTCustomizer();// 创立 SecureASTCustomizer
secure.setClosuresAllowed(true);// 禁止应用闭包
List<Integer> tokensBlacklist = new ArrayList<>();
tokensBlacklist.add(Types.**KEYWORD_WHILE**);// 增加关键字黑名单 while 和 goto
tokensBlacklist.add(Types.**KEYWORD_GOTO**);
secure.setTokensBlacklist(tokensBlacklist);
secure.setIndirectImportCheckEnabled(true);// 设置间接导入查看
List<String> list = new ArrayList<>();// 增加导入黑名单,用户不能导入 JSONObject
list.add("com.alibaba.fastjson.JSONObject");
secure.setImportsBlacklist(list);
List<Class<? extends Statement>> statementBlacklist = new ArrayList<>();// statement 黑名单,不能应用 while 循环块
statementBlacklist.add(WhileStatement.class);
secure.setStatementsBlacklist(statementBlacklist);
final CompilerConfiguration config = new CompilerConfiguration();// 自定义 CompilerConfiguration,设置 AST
config.addCompilationCustomizers(secure);
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(this.getClass().getClassLoader(), config);
​

4.1.2 流程平安

通过标准流程,减少脚本执行的可信度。

4.2 被动平安

尽管 SecureASTCustomizer 能够对脚本做肯定水平的平安限度,也能够标准流程进一步强化,然而对于脚本的编写依然存在较大的平安危险,很容易造成 cpu 暴涨、疯狂占用磁盘空间等重大影响零碎运行的问题。所以须要一些被动平安伎俩,比方采纳线程池隔离,对脚本执行进行无效的实时监控、统计和封装,或者是手动强杀执行脚本的线程。

五、总结

Groovy 是一种动静脚本语言,实用于业务变动多又快以及配置化的需要实现。Groovy 极易上手,其本质也是运行在 JVM 的 Java 代码。Java 程序员能够应用 Groovy 在进步开发效率,放慢响应需要变动,进步零碎稳定性等方面更进一步。

作者:vivo 互联网服务器团队 -Gao Xiang

退出移动版