当前位置: 首页 > 科技观察

Java是如何实现动态脚本的?

时间:2023-03-21 22:31:18 科技观察

在平台级Java系统中,动态脚本技术是不可或缺的一部分。本文分享了一个Java动态脚本的实现方案,给出了关键技术点,并对类重复、生命周期、安全问题等问题做了进一步探讨,欢迎同学们相互交流。前言繁星是一个数据服务平台。它的核心功能是:用户配置一段SQL,繁星输出对应的HSF/TR/SOA/Http访问接口。繁星引擎的流程图如下:一个查询请求,经过引擎的管道,经过各个Valve的处理,得到对应的结果数据。图中突出显示的两个阀门是本文的重点:前脚本和后脚本。温馨提示:动态脚本是指代码发布跳过公司内部发布平台,不能做到监控、灰度、回滚三招,容易造成上线失败。因此,业务系统强烈不推荐该技术。当然,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//动态编译的演示with命令行并加载java类------facade//提供单独的接口包,方便整个演示流程设计的顺利实施。我们先定义一个接口,比如Animal,然后用户在自己的代码中实现Animal接口。相当于用户提供的Cat实现类,这样系统加载用户的Java代码后,就可以方便的利用Java的多态特性访问相应的方法。这不仅方便了用户编写规范,也使平台易于使用。使用控制台命令行先回顾下如何使用命令行编译Java类并运行。首先为facade模块创建jar包,方便后续依赖:cd项目根目录mvninstall进入模块resources文件夹命令-javac(绝对路径因人而异):#进入所在目录cat.java位于cd/Users/fusu/d/group/fusu-share/dynamic-script/command-javac/src/main/resources#使用命令行工具javac编译,linux/mac上使用cp分隔符:windowuse;javac-cp.:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jarCat.java#Runjava-cp.:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jarCat#getresult#>我的CatMain使用Process调用javac进行编译有了上面的控制台命令行操作,很容易想到使用JavaProcess类调用命令行工具执行javac命令,然后使用URLClassLoader加载生成的类文件。该代码位于模块command-javac下的ProcessJavac.java文件中。核心代码如下://项目所在路径StringprojectPath=PathUtil.getAppHomePath();Processprocess=null;Stringcmd=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);系统。out.println(cmd);process=Runtime.getRuntime().exec(cmd);//打印程序输出readProcessOutput(process);intexitVal=process.waitFor();if(exitVal==0){System.out.println("javac执行成功!"+exitVal);}else{System.out.println("javac执行失败"+exitVal);return;}StringclassFilePath=String.format("%s/command-javac/src/main/resources/Cat.class",projectPath);StringurlFilePath=String.format("file:%s",classFilePath);URLurl=newURL(urlFilePath);URLClassLoaderclassLoader=newURLClassLoader(newURL[]{url});ClasscatClass=classLoader.loadClass("Cat");Objectobj=catClass.newInstance();if(objinstanceofAnimal){Animalanimal=(Animal)obj;animal.hello("Kitty");}//你会得到结果:Hello,Kitty!IamCatProgrammaticallycompilingandloading以上两种方式都有一个明显的缺点,就是需要依赖于Cat.java文件,而且必须生成Cat.class文件。在繁星平台上,自然希望这个过程在内存中完成,尽量减少IO操作,所以需要通过编程方式编译Java代码。该代码位于模块code-javac下的CodeJavac.java文件中。核心代码如下://类名StringclassName="Cat";//项目所在路径StringprojectPath=PathUtil.getAppHomePath();StringfacadeJarPath=String.format(.:%s/facade/target/facade-1.0.jar",projectPath);//需要编译的代码IterablecompilationUnits=newArrayList(){{add(newJavaSourceFromString(className,getJavaCode()));}};//编译选项,对应命令行参数Listoptions=newArrayList<>();options.add("-classpath");options.add(facadeJarPath);//使用系统编译JavaCompilerjavaCompiler=ToolProvider.getSystemJavaCompiler();StandardJavaFileManagerstandardJavaFileManager=javaCompiler.getStandardFileManager(null,null,null);ScriptFileManagerscriptFileManager=newScriptFileManager(standardJavaFileManager);//使用stringWriter收集错误。StringWritererrorStringWriter=newStringWriter();//开始编译booleanok=javaCompiler.getTask(errorStringWriter,scriptFileManager,diagnostic->{if(diagnostic.getKind()==Diagnostic.Kind.ERROR){errorStringWriter.append(diagnostic.toString());}},options,null,compilationUnits).call();if(!ok){StringerrorMessage=errorStringWriter.toString();//编译错误,直接抛出错误。thrownewRuntimeException("CompileError:{}"+errorMessage);}//获取编译后的二进制数据。finalMapallBuffers=scriptFileManager.getAllBuffers();finalbyte[]catBytes=allBuffers.get(className);//使用自定义ClassLoader加载类FsClassLoaderfsClassLoader=newFsClassLoader(className,catBytes);ClasscatClass=fsClassLoader.findClass(className);Objectobj=catClass.newInstance();if(objinstanceofAnimal){Animalanimal=(Animal)obj;animal.hello("Moss");}//会得到结果:Hello,Moss!我是猫。代码主要使用了系统编译器JavaCompiler。调用它的getTask方法相当于在命令行执行javac。getTask方法使用自定义的ScriptFileManager来收集二进制结果,并使用errorStringWriter来收集编译过程中可能出错的信息。最后,自定义类加载器FsClassLoader用于从二进制数据加载类Cat。深入讨论上面介绍了动态脚本的实现要点,但是还有很多问题需要讨论。作者抛出主要问题,简要论述。ClassLoader作用域问题JVM的类加载机制采用双亲委派模型。当类加载器收到加载请求时,它会委托其父加载器执行加载任务。因此,所有的加载任务都会交给顶层的类加载器。只有当父加载器无法处理时,子加载器才会自行执行加载任务。下图想必大家都很熟悉吧。JVM对一个类的唯一标识是(Classloader,类的全称),所以可能会出现接口Animal已经被加载,但是我们使用CustomClassLoader加载Cat时提示找不到Animal。这是因为Animal和Cat不是由同一个类加载器加载的。由于defineClass方法是protected的,如果要使用byte[]加载类,需要自定义一个classloader。如何指定这个Classloader的父加载器就比较讲究了。公司内部Java系统全部使用pandora,而pandora有自己的类加载器和线程加载器,所以我们以接口Animal的加载器animalClassLoader为标准,将线程ClassLoader设置为animalClassLoader,并设置自定义的parentClassLoader加载器指定为animalClassLoader。代码位于advance-discuss模块下,参考代码如下:data=data;}/*AdvanceDiscuss.java*///接口类加载器ClassLoaderanimalClassLoader=Animal.class.getClassLoader();//设置当前线程类加载器Thread.currentThread().setContextClassLoader(animalClassLoader);//...//使用自定义的ClassLoader来加载类FsClassLoaderfsClassLoader=newFsClassLoader(animalClassLoader,className,cat通过这些保证,就不会出现找不到类的问题。类重复的问题当我们只动态加载一个类时,自然我们不用担心所有类重名的问题,但是如果需要加载多个相同的类,就需要做特殊处理,可以使用正则表达式捕获用户的类名,然后添加随机strings来避免重名的问题。从上面我们知道JVM唯一标识一个类是(Classloader,全类名),所以只要能保证我们自定义的Classloader是不同的对象,也可以避免类重复的问题。类生命周期问题Java脚本动态垃圾回收的问题是必须要考虑的,否则随着越来越多的类被加载,系统的内存很快就会不够用。我们知道在JVM中,对象实例在没有被引用后会被GC(GarbageCollection)垃圾回收),Class作为JVM中的特殊对象,也会被GC(清除方法区中的Class信息和堆区的java.lang.Class对象,此时Class的生命周期结束)。该类将被回收,需要满足以下三个条件:NoInstance:该类的所有实例都已被GC。NoClassLoader:加载该类的ClassLoader实例已被GC。NoReference:该类的java.lang.Class没有被引用(XXX.class,使用静态变量/方法)。从以上三种情况可以推断,JVM自身的类加载器(Bootstrapclassloader,Extensionclassloader)加载的类在JVM的生命周期内永远不会被GC。自定义类加载器加载的Class是可以GC的,所以在编码的时候,一定要把自定义Classloader做成局部变量,这样才能自然回收。为了验证Class的GC情况,我们写一个简单的循环观察,在模块advance-discuss下的AdvanceDiscuss.java文件中:for(inti=0;i<1000000;i++){//编译加载andexecutecompileAndRun(i);//回收10000块if(i%10000==0){System.gc();}}//强制回收System.gc();System.out.println("restfor10s");线程.currentThread().sleep(10*1000);打开Java自带的jvisualvm程序(位于JAVA_HOME/bin/jvisualvm),可以看到JVM的情况。上图中可以看到加载类的变化图,堆大小呈锯齿状,说明动态加载的类可以有效回收。安全问题让用户编写脚本并在服务器上运行是一件非常危险的事情,因此如何保证脚本的安全是一个必须认真对待的问题。类白名单和黑名单机制在用户编写的Java代码中,我们需要指定允许用户使用的类的范围。想象一下,用户调用File来操作服务器上的文件,这是很不安全的。javassist库可以分析Class二进制文件。有了这个库,我们就可以很方便的获取到Class所依赖的类。代码位于advance-discuss模块下的JavassistUtil.java文件中,核心代码如下:);HashSetset=newHashSet<>();for(intix=1,size=constPool.getSize();ix