您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > JAVA

Java 如何实现动态脚本?

时间:2020-06-16 10:26:09  来源:  作者:

作者: 赋苏 阿里技术

Java 如何实现动态脚本?

 

阿里妹导读:在平台级的 JAVA 系统中,动态脚本技术是不可或缺的一环。本文分享了一种 Java 动态脚本实现方案,给出了其中的关键技术点,并就类重名问题、生命周期、安全问题等做出进一步讨论,欢迎同学们共同交流。

文末福利:Java 学习路线。

前言

繁星是一个数据服务平台,其核心功能是:用户配置一段 SQL,繁星产出对应的 HSF/TR/SOA/Http 取数接口。

繁星引擎流程图如下:

Java 如何实现动态脚本?

 

一次查询请求经过引擎的管道,被各个阀门处理后就得到了相应的结果数据。图中高亮的两个阀门就是本文讨论的重点:前置脚本与后置脚本。

温馨提示:动态脚本就意味着代码发布跳过了公司内部发布平台,做不到监控、灰度、回滚三板斧,容易引发线上故障,因此业务系统中强烈不推荐使用该技术。

当然 Java 动态脚本技术一般使用场景也比较少,主要在平台性质的系统中可能用到,比如 leetcode 平台,D2 平台,繁星数据服务平台等。本文权当技术探索和交流。

功能描述

JavaScript 熟悉的同学知道,eval() 函数,例如:

eval('console.log(2+3)')

就会在控制台中打出 5。

这里我们要做的和 eval 类似,就是希望输入一段 Java 代码,服务器按照代码中的逻辑执行。在繁星中前置脚本的功能就是可以对用户的输入参数进行自定义的处理,后置脚本的功能就是可以对数据库中查询到的结果做进一步加工。

为什么是 Java 脚本?

Groovy

要实现动态脚本的需求,首先可能会想到 Groovy,但是使用 Groovy 有几大缺点:

  • Groovy 虽然也是运行在 JVM,但是语法和 Java 有一些差异,对于只会 Java 的同学来说有一定学习成本。
  • 动态类型,缺乏约束。有时候太过于灵活自由也是缺点,尤其是对于平台说来。
  • 需要额外引入 Groovy 的引擎 jar 包,大小 6.2M,属实不小,对于有代码强迫症的我来说这会是一个重要考虑因素。

Java

采用 Java 来实现动态脚本的功能有以下优点:

  • 学习成本低,在阿里最主要的语言就是 Java,会 Java 几乎是每个工程师必备的技能,因此上手难度几乎为零。
  • Java 可以规定接口约束,从而使得用户写的前后置脚本整齐划一,方便管理和治理。
  • 可以实时编译和错误提示,方便用户及时订正问题。

实现方式

代码工程说明

本文的代码工程:

https://kbtdatacenter-read.oss-cn-zhangjiakou.aliyuncs.com/fusu-share/dynamic-script.zip

​​​​​​--dynamic-script------advance-discuss //深度讨论脚本动态化技术中的一些细节------code-javac //使用代码执行编译加载运行任务------command-javac //演示用命令行的方式动态编译和加载java类------facade //提供单独的接口包,方便整个演示过程流畅进行

实现方案设计

我们首先定义好一个接口,例如 Animal,然后用户在自己的代码中实现 Animal 接口。相当于用户提供的是 Animal 的实现类 Cat,这样系统加载了用户的 Java 代码后,可以很方便的利用 Java 多态特性,访问到对应的方法。这样既方便了用户书写规范,同时平台使用起来也简单。

使用控制台命令行

首先回顾如何使用命令行来编译 Java 类,并且运行。

首先对 facade 模块打一个 jar 包,方便后续依赖:

​​​​​​cd 项目根目录mvn install

进入到模块 command-javac 的 resources 文件夹下(绝对路径因人而异):

​​​​​​# 进入到Cat.java所在的目录cd /Users/fusu/d/group/fusu-share/dynamic-script/command-javac/src/main/resources# 使用命令行工具javac编译,linux/mac 上cp分隔符使用 : windown使用 ;javac -cp .:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jar Cat.java# 运行java -cp .:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jar Cat# 得到结果# > I'm Cat Main

使用 Process 调用 javac 编译

有了上面的控制台命令行操作,很容易想到用 Java 的 Process 类调用命令行工具执行 javac 命令,然后使用 URLClassLoader 来加载生成的 class 文件。代码位于模块 command-javac 下的 ProcessJavac.java 文件中,核心代码如下:

//项目所在路径String projectPath = PathUtil.getAppHomePath();Process process = null;String cmd = String.format("javac -cp .:%s/facade/target/facade-1.0.jar -d %s/command-javac/src/main/resources %s/command-javac/src/main/resources/Cat.java", projectPath, projectPath, projectPath);System.out.println(cmd);process = Runtime.getRuntime().exec(cmd);// 打印程序输出readProcessOutput(process);int exitVal = process.waitFor();if (exitVal == 0) { System.out.println("javac执行成功!" + exitVal);} else { System.out.println("javac执行失败" + exitVal); return;}String classFilePath = String.format("%s/command-javac/src/main/resources/Cat.class", projectPath);String urlFilePath = String.format("file:%s", classFilePath);URL url = new URL(urlFilePath);URLClassLoader classLoader = new URLClassLoader(new URL[]{url});Class<?> catClass = classLoader.loadClass("Cat");Object obj = catClass.newInstance();if (obj instanceof Animal) { Animal animal = (Animal) obj; animal.hello("Kitty");}//会得到结果: Hello,Kitty! 我是Cat。

用编程方式编译和加载

上面两种方式都有一个明显的缺点,就是需要依赖于 Cat.java 文件,以及必须产生 Cat.class 文件。在繁星平台中,自然希望这个过程都在内存中完成,尽量减少 IO 操作,因此使用编程方式来编译 Java 代码就显得很有必要了。代码位于模块 code-javac 下的 CodeJavac.java 文件中,核心代码如下:

​​​​​​​//类名String className = "Cat";//项目所在路径String projectPath = PathUtil.getAppHomePath();String facadeJarPath = String.format(".:%s/facade/target/facade-1.0.jar", projectPath);//需要进行编译的代码Iterable<? extends JavaFileObject> compilationUnits = new ArrayList<JavaFileObject>() {{ add(new JavaSourceFromString(className, getJavaCode()));}};//编译的选项,对应于命令行参数List<String> options = new ArrayList<>();options.add("-classpath");options.add(facadeJarPath);//使用系统的编译器JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();StandardJavaFileManager standardJavaFileManager = javaCompiler.getStandardFileManager(null, null, null);ScriptFileManager scriptFileManager = new ScriptFileManager(standardJavaFileManager);//使用stringWriter来收集错误。StringWriter errorStringWriter = new StringWriter();//开始进行编译boolean ok = javaCompiler.getTask(errorStringWriter, scriptFileManager, diagnostic -> { if (diagnostic.getKind() == Diagnostic.Kind.ERROR) { errorStringWriter.append(diagnostic.toString()); }}, options, null, compilationUnits).call();if (!ok) { String errorMessage = errorStringWriter.toString(); //编译出错,直接抛错。 throw new RuntimeException("Compile Error:{}" + errorMessage);}//获取到编译后的二进制数据。final Map<String, byte[]> allBuffers = scriptFileManager.getAllBuffers();final byte[] catBytes = allBuffers.get(className);//使用自定义的ClassLoader加载类FsClassLoader fsClassLoader = new FsClassLoader(className, catBytes);Class<?> catClass = fsClassLoader.findClass(className);Object obj = catClass.newInstance();if (obj instanceof Animal) { Animal animal = (Animal) obj; animal.hello("Moss");}//会得到结果: Hello,Moss! 我是Cat。

代码中主要使用到了系统编译器 JavaCompiler,调用它的 getTask 方法就相当于命令行中执行 javac,getTask 方法中使用自定义的 ScriptFileManager 来搜集二进制结果,以及使用 errorStringWriter 来搜集编译过程中可能出错的信息。最后借助一个自定义类加载器 FsClassLoader 来从二进制数据中加载出类 Cat。

深入讨论

上文介绍了动态脚本的实现关键点,但是还有诸多问题需要讨论,笔者把主要的几个问题抛出来,简单讨论一下。

ClassLoader 范围问题

JVM 的类加载机制采用双亲委派模式,类加载器收到加载请求时,会委派自己的父加载器去执行加载任务,因此所有的加载任务都会传递到顶层的类加载器,只有当父加载器无法处理时,子加载器才自己去执行加载任务。下面这幅图相信大家已经很熟悉了。

Java 如何实现动态脚本?

 

JVM 对于一个类的唯一标识是 (Classloader,类全名),因此可能出现这种情况,接口 Animal 已经加载了,但是我们用 CustomClassLoader 去加载 Cat 时,提示说 Animal 找不到。这就是因为 Animal 和 Cat 不是被同一个 Classloader 加载的。

由于 defineClass 方法是 protected 的,因此要用 byte[] 来加载 class 就需要自定义一个 classloader,如何指定这个 Classloader 的父加载器就比较有讲究了。

公司内部的 Java 系统都是采用的 pandora,pandora 有自己的类加载器以及线程加载器,因此我们以接口 Animal 的加载器 animalClassLoader 为标准,将线程 ClassLoader 设置为 animalClassLoader,同时将自定义的 ClassLoader 的父加载器指定为 animalClassLoader。代码位于模块 advance-discuss 下,参考代码如下:

​​​​​​​/*FsClassLoader.java*/public FsClassLoader(ClassLoader parentClassLoader, String name, byte[] data) { super(parentClassLoader); this.fullyName = name; this.data = data;}/*AdvanceDiscuss.java*///接口的类加载器ClassLoader animalClassLoader = Animal.class.getClassLoader();//设置当前的线程类加载器Thread.currentThread().setContextClassLoader(animalClassLoader);//...//使用自定义的ClassLoader加载类FsClassLoader fsClassLoader = new FsClassLoader(animalClassLoader, className, catBytes);

通过这些保障,就不会出现找不到类的问题了。

类重名问题

当我们只动态加载一个类时,自然不用担心类全名重复的问题,但是如果需要加载多个相同类时,就有必要进行特殊处理了,可以利用正则表达式捕获用户的类名,然后增加随机字符串的方式来规避重名问题。

从上文中,我们知道 JVM 对于一个类的唯一标识是(Classloader,类全名),因此只要能保证我们自定义的 Classloader 是不同的对象,也能够避免类重名的问题。

Class 生命周期问题

Java 脚本动态化必须考虑垃圾回收的问题,否则随着 Class 被加载的越来越多,系统的内存很快就不够用了。我们知道在 JVM 中,对象实例在没有被引用后会被 GC (Garbage Collection 垃圾回收),Class 作为 JVM 中一个特殊的对象,也会被 GC(清空方法区中 Class 的信息和堆区中的 java.lang.Class 对象。这时 Class 的生命周期就结束了)。

Class 要被回收,需要满足以下三个条件:

  • NoInstance:该类所有的实例都已经被 GC。
  • NoClassLoader:加载该类的 ClassLoader 实例已经被 GC。
  • NoReference:该类的 java.lang.Class 没有被引用 (XXX.class,使用了静态变量/方法)。

从上面三个条件可以推出,JVM 自带的类加载器(Bootstrap 类加载器、Extension 类加载器)所加载的类,在 JVM 的生命周期中始终不会被 GC。自定义的类加载器所加载的 Class 是可以被 GC 的,因此在编码时,自定义的 Classloader 一定做成局部变量,让其自然被回收。

为了验证 Class 的 GC 情况,我们写一个简单的循环来观察,模块 advance-discuss 下的 AdvanceDiscuss.java 文件中:

​​​​​​​for (int i = 0; i < 1000000; i++) { //编译加载并且执行 compileAndRun(i); //10000个回收一下 if (i % 10000 == 0) { System.gc(); }}//强制进行回收System.gc();System.out.println("休息10s");Thread.currentThread().sleep(10 * 1000);

打开 Java 自带的 jvisualvm 程序(位于 JAVA_HOME/bin/jvisualvm),可以可视化的观看到 JVM 的情况。

Java 如何实现动态脚本?

 

在上图中可以看到加载类的变化图以及堆大小呈锯齿状,说明动态加载类能够被有效的被回收。

安全问题

让用户写脚本,并且在服务器上运行,光是想想就知道是一件非常危险的事情,因此如何保证脚本的安全,是必须严肃对待的一个问题。

类的白名单及黑名单机制

在用户写的 Java 代码中,我们需要规定用户允许使用的类范围,试想用户调用 File 来操作服务器上的文件,这是非常不安全的。javassist 库可以对 Class 二进制文件进行分析,借助该库我们可以很容易地得到 Class 所依赖的类。代码位于模块 advance-discuss 下的 JavassistUtil.java 文件中,以下是核心代码:

​​​​​​​public static Set<String> getDependencies(InputStream is) throws Exception { ClassFile cf = new ClassFile(new DataInputStream(is)); ConstPool constPool = cf.getConstPool(); HashSet<String> set = new HashSet<>(); for (int ix = 1, size = constPool.getSize(); ix < size; ix++) { int descriptorIndex; if (constPool.getTag(ix) == ConstPool.CONST_Class) { set.add(constPool.getClassInfo(ix)); } else if (constPool.getTag(ix) == ConstPool.CONST_NameAndType) { descriptorIndex = constPool.getNameAndTypeDescriptor(ix); String desc = constPool.getUtf8Info(descriptorIndex); for (int p = 0; p < desc.length(); p++) { if (desc.charAt(p) == 'L') { set.add(desc.substring(++p, p = desc.indexOf(';', p)).replace('/', '.')); } } } } return set;}

拿到依赖后,就可以首先使用白名单来过滤,以下这些包或类只涉及简单的数据操作和处理,是被允许的:

​​java.lang,java.util,com.alibaba.fastjson,java.text,[Ljava.lang (java.lang下的数组,例如 `String[]`)[D (double[])[F (float[])[I (int[])[J (long[])[C (char[])[B (byte[])[Z (boolean[])

但是有个别的包下的类也比较危险,需要过滤掉,这时候就需要用黑名单再做一次筛选,这些包或类是不被允许的:

​​​​​​java.lang.Threadjava.lang.reflect

线程隔离

有可能用户的代码中包含死循环,或者执行时间特别长,对于这种有问题的逻辑在编译时是无法感知的,因此还需要使用单独的线程来执行用户的代码,当出现超时或者内存占用过大的情况就直接 kill。

缓存问题

上面讨论的都是从编译到执行的完整过程,但是有时候用户的代码没有变更,我们去执行时就没有必要再次去编译了,因此可以设计一个缓存策略,当用户代码没有发生变更时,就使用懒加载策略,当用户的代码发生了变更就释放之前加载好的 Class,重新加载新的代码。

及时加载问题

当系统重启时,相当于所有的类都被释放了需要重新加载,对于一些比较重要的脚本,可能短暂的懒加载时间也是难以接受的,对于这种就需要单独搜集,在系统启动的时候根据系统一起加载进内存,这样就可以当健康检查通过时,保证类已经加载好了,从而有效缩短响应时间。

后记

由于篇幅问题,缓存问题、及时加载问题只做了简单的讨论。当然 Java 动态脚本技术还涉及到很多其他细节,需要在使用过程中不断总结。也欢迎大家一起交流~



Tags:Java   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
文章目录 如何理解面向对象编程? JDK 和 JRE 有什么区别? 如何理解Java中封装,继承、多态特性? 如何理解Java中的字节码对象? 你是如何理解Java中的泛型的? 说说泛型应用...【详细内容】
2021-12-24  Tags: Java  点击:(5)  评论:(0)  加入收藏
文章目录1、Quartz1.1 引入依赖<dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz</artifactId> <version>2.3.2</version></dependency>...【详细内容】
2021-12-22  Tags: Java  点击:(11)  评论:(0)  加入收藏
1 前言ObjectiveSQL 是一个Java ORM 框架,它不仅是Active Record 模式在Java 中的应用,同时还针对复杂SQL 编程提供近乎完美的解决方案,使得Java 代码与SQL 语句有机的结合,改变...【详细内容】
2021-12-13  Tags: Java  点击:(13)  评论:(0)  加入收藏
本系列为 Netty 学习笔记,本篇介绍总结Java NIO 网络编程。Netty 作为一个异步的、事件驱动的网络应用程序框架,也是基于NIO的客户、服务器端的编程框架。其对 Java NIO 底层...【详细内容】
2021-12-07  Tags: Java  点击:(16)  评论:(0)  加入收藏
流为什么动不动就说 io 流? 这个“流”是什么意思呢?流这个词,也常常出现在电竞选手的领域。大家都说,哦,这个队伍经常上去卖人头来取得局面优势的这种打法,叫献祭流。而到了 Java...【详细内容】
2021-11-15  Tags: Java  点击:(32)  评论:(0)  加入收藏
1. 字符串有整型的相互转换String a = String.valueOf(2); //integer to numeric stringint i = Integer.parseInt(a); //numeric string to an int 2. 向文件末尾添加内容B...【详细内容】
2021-10-13  Tags: Java  点击:(91)  评论:(0)  加入收藏
负载均衡是将客户端请求访问,通过提前约定好的规则转发给各个server。其中有好几个种经典的算法,下面我们用Java实现这几种算法。 轮询算法轮询算法按顺序把每个新的连接请求...【详细内容】
2021-09-27  Tags: Java  点击:(51)  评论:(0)  加入收藏
1 背景近日在给公司同事分享Arthas 工具使用时候,被它强悍的功能震撼到了就好奇研究了下它的原理及底层实现,其实它是通过Java agent 来实现的,也就深入地学习了一下Java agent...【详细内容】
2021-09-09  Tags: Java  点击:(66)  评论:(0)  加入收藏
近日浏览网上一些图片提取文字的网站,觉得甚是有趣,花费半日也做了个在线图片识别程序,完成了两个技术方案的选择,一是 tesseract + Python flask的方案实现,二是 tesseract + Sp...【详细内容】
2021-09-07  Tags: Java  点击:(81)  评论:(0)  加入收藏
Java String的判空方法是Java开发中的一个很基础的方法,下面列举了一些常用的方法。 方法一:效率高,也是最常用的方法。if(s == null || s.length() <= 0) 方法二:也是常看到的...【详细内容】
2021-09-03  Tags: Java  点击:(120)  评论:(0)  加入收藏
▌简易百科推荐
一、Redis使用过程中一些小的注意点1、不要把Redis当成数据库来使用二、Arrays.asList常见失误需求:把数组转成list集合去处理。方法:Arrays.asList 或者 Java8的stream流式处...【详细内容】
2021-12-27  CF07    Tags:Java   点击:(3)  评论:(0)  加入收藏
文章目录 如何理解面向对象编程? JDK 和 JRE 有什么区别? 如何理解Java中封装,继承、多态特性? 如何理解Java中的字节码对象? 你是如何理解Java中的泛型的? 说说泛型应用...【详细内容】
2021-12-24  Java架构师之路    Tags:JAVA   点击:(5)  评论:(0)  加入收藏
大家好!我是老码农,一个喜欢技术、爱分享的同学,从今天开始和大家持续分享JVM调优方面的经验。JVM调优是个大话题,涉及的知识点很庞大 Java内存模型 垃圾回收机制 各种工具使用 ...【详细内容】
2021-12-23  小码匠和老码农    Tags:JVM调优   点击:(11)  评论:(0)  加入收藏
前言JDBC访问Postgresql的jsonb类型字段当然可以使用Postgresql jdbc驱动中提供的PGobject,但是这样在需要兼容多种数据库的系统开发中显得不那么通用,需要特殊处理。本文介绍...【详细内容】
2021-12-23  dingle    Tags:JDBC   点击:(12)  评论:(0)  加入收藏
Java与Lua相互调用案例比较少,因此项目使用需要做详细的性能测试,本内容只做粗略测试。目前已完成初版Lua-Java调用框架开发,后期有时间准备把框架进行抽象,并开源出来,感兴趣的...【详细内容】
2021-12-23  JAVA小白    Tags:Java   点击:(10)  评论:(0)  加入收藏
Java从版本5开始,在 java.util.concurrent.locks包内给我们提供了除了synchronized关键字以外的几个新的锁功能的实现,ReentrantLock就是其中的一个。但是这并不意味着我们可...【详细内容】
2021-12-17  小西学JAVA    Tags:JAVA并发   点击:(10)  评论:(0)  加入收藏
一、概述final是Java关键字中最常见之一,表示“最终的,不可更改”之意,在Java中也正是这个意思。有final修饰的内容,就会变得与众不同,它们会变成终极存在,其内容成为固定的存在。...【详细内容】
2021-12-15  唯一浩哥    Tags:Java基础   点击:(14)  评论:(0)  加入收藏
1、问题描述关于java中的日志管理logback,去年写过关于logback介绍的文章,这次项目中又优化了下,记录下,希望能帮到需要的朋友。2、解决方案这次其实是碰到了一个问题,一般的情况...【详细内容】
2021-12-15  软件老王    Tags:logback   点击:(17)  评论:(0)  加入收藏
本篇文章我们以AtomicInteger为例子,主要讲解下CAS(Compare And Swap)功能是如何在AtomicInteger中使用的,以及提供CAS功能的Unsafe对象。我们先从一个例子开始吧。假设现在我们...【详细内容】
2021-12-14  小西学JAVA    Tags:JAVA   点击:(21)  评论:(0)  加入收藏
一、概述观察者模式,又可以称之为发布-订阅模式,观察者,顾名思义,就是一个监听者,类似监听器的存在,一旦被观察/监听的目标发生的情况,就会被监听者发现,这么想来目标发生情况到观察...【详细内容】
2021-12-13  唯一浩哥    Tags:Java   点击:(16)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条