前段时间开发的项目,项目需求要求支持业务人员频繁业务需求变更,业务要求每次策略变更第一时间线上生效。结合项目业务需要,我们选择进行业务领域抽象,把业务变更的需求提炼成为脚本操作,每次业务人员对业务的操作变成为业务域的逻辑操作,针对业务流程上的不同需求变更就变成一条条脚本规则的动态变更。
因为团队主要开发语言是java,我们调研了QL Express 和 Groovy等脚本,最终选定Groovy脚本作为我们的脚本语言。我们使用Groovy支持业务人员频繁需求变更方案,首先对相关需求抽象出业务域,业务需求开发变成Groovy脚本,开发获取(转换)业务域数据接口。每次业务人员需求变更,我们修改业务脚本,线上获取到脚本变化,解析脚本语法树分析脚本依赖业务域,通过对应的业务域数据接口获取数据,然后加载数据执行对应脚本得到结果。
本文主要关注对Java调用Groovy脚本所做的优化,本文的优化重点并不是对Groovy脚本执行性能的极致优化,就像我们调研选取Groovy脚本支持我们的业务需求综合性能和易用性综合考量的结果。
Groovy调用优化
下面说的所有关于Groovy优化都是基于GroovyShell执行Groovy脚本的极限优化
1.因为我们的业务流程涉及大量脚本调用,Groovy作为脚本语言,每次Java调用业务变更需求的Groovy脚本,Groovy都要经过重新编译生成Class,并new一个ClassLoader去加载一个对象,导致每次调用Groovy脚本执行时间大部分花在脚本编译上,而且也会导致大量的编译脚本Class对账,运行一段时间后将perm暴涨。
2.高并发情况下,执行赋值binding对象后,真正执行run操作时,拿到的Binding对象可能是其它线程赋值的对象,会出现执行脚本结果混乱的情况。
针对以上存在的问题,我对Groovy脚本调用进行了优化解决以上问题。
1.首先我们通过给每个脚本生成一个md5,每次脚本首次执行,我们会把Groovy脚本生成的Script对象进行缓存,缓存设置一定的过期时间,保证下次同一个脚本执行直接调用Script就行。
2. 我们对每次Script执行通过锁保证每次执行的Binding不会出现多线程混乱的情况。
以上优化对应的代码如下:
public class GroovyUtil {
private static GroovyShell groovyShell;
static {
groovyShell = new GroovyShell();
}
public static Object execute(String ruleScript, Map<String, Object> varMap) {
String scriptMd5 = null;
try {
scriptMd5 = Md5Util.encryptForHex(ruleScript);
} catch (Exception e) {
}
Script script;
if (scriptMd5 == null) {
script = groovyShell.parse(ruleScript);
} else {
String finalScriptMd5 = scriptMd5;
script = GroovyCache.getValue(GroovyCache.GROOVY_SHELL_KEY_PREFIX + scriptMd5,
() -> Optional.ofNullable(groovyShell.parse(ruleScript, generateScriptName(finalScriptMd5))),
new TypeReference<Script>() {
});
if (script == null) {
script = groovyShell.parse(ruleScript, generateScriptName(finalScriptMd5));
}
}
// 此处锁住script,为了防止多线程并发执行Binding数据混乱
synchronized(script) {
Binding binding = new Binding(varMap);
script.setBinding(binding);
return script.run();
}
}
private static String generateScriptName(String scriptName) {
return "Script" + scriptName + ".groovy";
}
}
缓存类:
// 缓存类
public class GroovyCache {
private static Cache<String, Optional<Object>> localMemoryCache =
CacheBuilder.newBuilder().expireAfterWrite(24, TimeUnit.HOURS).build();
private static FLogger LOGGER = FLoggerFactory.getLogger(GroovyCache.class);
public static String GROOVY_SHELL_KEY_PREFIX = "GROOVY_SHELL#";
public static <T> T getValue(String key, Callable<Optional<Object>> load, TypeReference<T> typeReference) {
try {
Optional<Object> value = localMemoryCache.get(key, load);
if (value.isPresent()) {
return (T) value.get();
}
return null;
} catch (Exception ex) {
LOGGER.error("获取缓存异常,key:{} ", key, ex);
}
return null;
}
}
MD5Util
public class MD5Util {
private static final String hexDigits[] = {"0", "1", "2", "3", "4", "5",
"6", "7", "8", "9", "a", "b", "c", "d", "e", "f"};
/**
* 将1个字节(1 byte = 8 bit)转为 2个十六进制位
* 1个16进制位 = 4个二进制位 (即4 bit)
* 转换思路:最简单的办法就是先将byte转为10进制的int类型,然后将十进制数转十六进制
*/
private static String byteToHexString(byte b) {
// byte类型赋值给int变量时,java会自动将byte类型转int类型,从低位类型到高位类型自动转换
int n = b;
// 将十进制数转十六进制
if (n < 0)
n += 256;
int d1 = n / 16;
int d2 = n % 16;
// d1和d2通过访问数组变量的方式转成16进制字符串;比如 d1 为12 ,那么就转为"c"; 因为int类型不会有a,b,c,d,e,f等表示16进制的字符
return hexDigits[d1] + hexDigits[d2];
}
/**
* 将字节数组里每个字节转成2个16进制位的字符串后拼接起来
*/
private static String byteArrayToHexString(byte b[]) {
StringBuffer resultSb = new StringBuffer();
for (int i = 0; i < b.length; i++){
resultSb.append(byteToHexString(b[i]));
}
return resultSb.toString();
}
/**
* MD5算法,统一返回大写形式的摘要结果,默认固定长度是 128bit 即 32个16进制位
* String origin :需要进行MD5计算的字符串
* String charsetname :MD5算法的编码
*/
public static String MD5_32(String origin, String charsetname) {
String resultString = null;
try {
// 1,创建MessageDigest对象
MessageDigest md = MessageDigest.getInstance("MD5");
// 2,向MessageDigest传送要计算的数据;传入的数据需要转化为指定编码的字节数组
md.update(origin.getBytes( charsetname ));
// 3,计算摘要
byte[] bytesResult = md.digest();
// 第2步和第3步可以合并成下面一步
// byte[] bytesResult = md.digest(origin.getBytes(charsetname));
// 4,将字节数组转换为16进制位
resultString = byteArrayToHexString( bytesResult );
} catch (Exception e) {
e.printStackTrace();
}
// 统一返回大写形式的字符串摘要
return resultString.toUpperCase();
}
/**
* 获取 16位的MD5摘要,就是截取32位结果的中间部分
*/
public static String MD5_16(String origin, String charsetname) {
return MD5_32(origin, charsetname).substring(8,24);
}
public static void main(String[] args){
String origin = "1234567890";
// 默认MD5计算得到128 bit的摘要,即32个 16进制位
String result_32 = MD5_32(origin, "utf-8");
System.out.println(result_32); // E807F1FCF82D132F9BB018CA6738A19F
// 默认MD5计算得到即16个 16进制位
String result_16 = MD5_16(origin, "utf-8");
System.out.println(result_16); // F82D132F9BB018CA
}