1 背景
在我们的项目中,有时候在需要运行时获取一段脚本并执行其逻辑以灵活地实现业务需求。有人的第一想法就是脚本语言,我们当然可以在程序中内嵌一个Python解释器,然后在需要灵活变动的地方使用Python脚本实现我们的逻辑。但是这样做太“重量级”了,况且身为一个Java程序员,你不一定懂得Python的语法,又或者你忠于Java根本不愿把项目交托给其他语言。如果能够使用符合Java语法的脚本,而且不必嵌入任何解释器,那么这是不是一个完美的选择?
2 原理
Java本身具有足够的灵活性能让我们做到这一点,JVM可以在运行时动态编译Java源文件然后加载类,这是Java脚本动态获得生命力的基础。不过,动态编译的输入是一个完整的Java类的源文件,和javac工具一样,而我们要执行的仅仅是一段脚本。另外,这段Java脚本还需要有上下文环境,例如输入和输出,不然凭空写一段脚本没有任何意义,这就要费一番心思去设计了。要满足这些要求也不难,这里提供一个简单的实现方法,基本思路是生成一个临时类,将上下文变量声明为该类的成员,将脚本放在该类的excute()方法里,然后将这个临时类动态编译并执行excute()方法,即可使脚本生效。对于程序来说,动态编译的过程是透明的,其结果是准确获得了脚本的输出。
3 具体示例
3.1 需求描述
程序从数据库中取出了别名和姓名,而在界面上显示怎样的名字却不确定,因为这点会经常变动,有些场合显示别名和姓名的组合,有些场合仅显示姓名等等,为了适应这种灵活变化,需要将如何显示名称的逻辑作为一段脚本写在外部文件里,程序在运行时执行这段脚本得到名称然后显示。
3.2 解决过程
假设程序从数据库中取出了别名和姓名,分别存入变量
并从外部文件读取了脚本内容
从脚本里可以看到3个变量,分别是displayName、aliasName和originalName,这是我们默认提供给脚本的上下文变量环境,就像在Jsp页面里默认有request对象一样。
3.2.1 首先需要构造一个Java类的完整源代码
利用StringBuilder构造如下内容的字符串 String javaSource:
其中,excute()方法体的内容就是脚本内容,其余部分是固定的,将aliasName等3个变量声明为Temp类的成员变量,这样就为脚本代码构建了上下文环境(不然连编译都没法通过)。源代码内容构造完成之后,就要将它写入文件,文件名当然就是Temp.java了。
这样,就在当前目录产生了一个我们需要的Java源文件。
3.2.2 然后编译这个Java源文件和加载编译后的class
编译源文件:
注意,使用com.sun.tools.javac.Main类必须为项目添加tools.jar包的引用,这个jar包在JDK根目录下的lib文件夹里,自Java1.4开始包含。如果你的项目用手工编译,则必须添加类路径参数 “-cp tools.jar”。
加载编译后的class:
3.2.3 然后向临时变量注入上下文变量环境
虽然刚才构造的Java源代码里面已经有了aliasName和originalName两个成员变量提供给脚本代码使用,但是这两个变量并没有实际意义的值,实际的值应该是从数据库里取出来的值,我们要想办法将这个值传递给脚本。
用类反射的方法将我们之前从数据库里取出来的别名和姓名赋值给Temp的实例obj,这样当执行脚本的时候,脚本里面所引用的aliasName和originalName就是实际中应用的值了。
3.2.4 最后是执行脚本代码并获得显示名称
用类反射执行excute()方法实际上就是执行了外部脚本的逻辑,而外部脚本已将显示名称构造完成,所以取出临时变量的displayName属性即是我们需要的显示名称。这样,我们就实现了在运行时获取并执行外部Java脚本的需求,在一些场合下大大提高了应用程序的灵活性。当然,为了提高性能,不可能让程序每次都进行动态编译,可以将临时对象缓存起来,仅当脚本变化后才重新编译,不过这不属于本文的范畴,留待以后讨论。
4 改进
4.1 避免源文件冲突
我们可以通过构造唯一的路径或文件名来保证生成的Java源文件的唯一性,但是复杂的路径会增加后续编译和加载的复杂度,所以最好还是使用唯一的文件名。文件名可以用前缀+UUID的方式构造,也可以通过Java提供的临时文件来构造,临时文件能够保证唯一性。
4.2 编译参数增强
Main.compile()的参数是一个String数组,它支持的参数和javac工具支持的参数一致,只要将javac命令行的每一个单词依次作为数组的元素即可,例如
4.3 简化构造的源代码
如果脚本的上下文变量环境较多,那么构造源代码的过程就变得复杂低效易错,我们可以用继承的方式来消除这种复杂性,而且还可以用”多态”来代替一些类反射的使用,例如定义以下类:
有了这样一个抽象基类,我们构造的源代码只需要继承ScriptContext,然后覆盖excute()方法即可,而执行脚本的过程也可以直接调用ScriptContext.excute(),而不必通过类反射定位到excute()方法然后执行。在新的思路下我们刚才构造的源代码可能变成这样:
而执行脚本的过程也相应简化为: