正如上一篇博客所讲:“项目中之前的计算公式统一维护在base包(base包的公式会被订单、产品、api、财务、还款等系统依赖),由于公式会频繁地改动,造成base频繁升级,其他系统也得跟着发版”中存在的问题,把公式做成灵活可配,就是一件有意义的事情。
零、公式计算描述
此次需求,客户选择金融产品后,根据所选择的“自有”、“浦发”、“微众”三种类型资金来源,来执行试算操作,每执行一次试算,需要44个公式执行计算操作。
一、先看老方式:
最初的公式,都维护在代码中,虽然不够灵活,但是代码组织结构写的很棒:
1.接口统一定义所有公式的名称、方便各个子类在计算时,名称混乱;
2.父类实现一些不常变化的公式方法,定义计算所有公式时,每个公式的先后执行顺序;
3.根据业务标准(新车、旧车;资金来源;产品类型)等,定义第三层公式实现类(子类按照业务标准定义并继承)
4.对外暴露Adapter,外界通过这个类,来调用不同业务标准下的公式计算。
我还是很喜欢这块当时的设计,值得学习。(因代码不对外,只说一下思路)
二、再看新方式:
1.目的,实现公式改动时“后台可配,无需编码”。
2.整体思路:
3.几点实现细节
0)界面设计
如图,每种资金来源占用一个tab页,公式类型维护在枚举,公式内容及说明存放在mysql和redis中,每次更新通过“cache aside pattern”策略,同步更新redis和mysql,且记录每次公式的修改,便于历史记录查看,及错误追踪。
关于权限,在“菜单”入口处,设置只有“特定人员”可以看到并进入此页面。
1)aviator表达式引擎
使用aviator过程,遵循预先编译表达式的原则,从redis获取到公式集,根据公式id获取内容后,先编译,后执行:
/**
* Double类型,算式执行
* @param param 公式执行时的入参,线程安全
* @param express string类型的表达式内容
* @return
* @throws ExpressionRuntimeException
*/
public static Double doExecute(Integer key, String express,ConcurrentHashMap<String , Object> param) throws CompileExpressionErrorException,ExpressionRuntimeException {
//1.编译表达式
Expression expression = AviatorEvaluator.compile(express);
if(expression ==null){
throw new CompileExpressionErrorException(String.format("[%s]解析失败", ProdFormulaEnum.getNameByIndex(key)));
}
//2.执行计算,入参提前存放到ConcurrentHashMap中
Object result = expression.execute(param);
return (Double) result;
}
2)内置函数支持
对于公式中的三元运算、幂运算,参考第一篇中的讲解,注意,aviator不支持“[]”作为优先级的符号。
3)redis设计
起初AviatorUtil中,加入了本地缓存,代码如下:
/**
* 缓存编译后的表达式,无需再次编译
*/
ConcurrentHashMap<String , Expression> expressMap = new ConcurrentHashMap<String , Expression>();
/**
* 算式执行,返回类型为Double
* @param param
* @param express
* @return
* @throws ExpressionRuntimeException
*/
public Double doExecute(HashMap<String , Object> param, String express)throws CompileExpressionErrorException,ExpressionRuntimeException {
Expression expression = null;
//设计思路,编译过的表达式,无需再次编译
synchronized (lock) {
expression = expressMap.get(express);
if (expression == null) {
expression = AviatorEvaluator.compile(express);
expressMap.put(express, expression);
}
}
if(expression ==null){
throw new CompileExpressionErrorException(String.format("[%s]解析失败",express));
}
Object result = expression.execute(param);
return (Double) result;
}
页面配置修改后,清空此“expressMap”内容,但此项目线上部署多台机器,公式一旦修改,nginx也只能清空调一台机子中的缓存,于是,本地缓存编译结果方式,不予考虑。
最终,通过“cache aside pattern”,把修改后的公式优先同步到redis,每次试算拉取redis中最新的表达式内容,再执行试算。并发情况下,redis压力正在测试。
遇到问题:
1.调试不便
复杂公式计算失败,很难定位到问题,建议打开TRACE模式
public static Double doExecute2(Integer key, String express,HashMap<String , Object> param)throws CompileExpressionErrorException,ExpressionRuntimeException {
//如下所示
AviatorEvaluator.setOption(Options.TRACE_EVAL, true);
Expression expression = AviatorEvaluator.compile(express);
if(expression ==null){
throw new CompileExpressionErrorException(String.format("[%s]解析失败", ProdFormulaEnum.getNameByIndex(key)));
}
Object result = expression.execute(param);
return (Double) result;
}
每一个公式,每一步的执行都非常清晰:
[Aviator TRACE] <JavaType, 90000.0, Double> -sub <JavaType, 55.0, Double> => <Double, 89945.0>
[Aviator TRACE] <JavaType, 1, Integer> / <Long, 12> => <Long, 0>
[Aviator TRACE] <Long, 0> * <JavaType, 1, Integer> => <Long, 0>
[Aviator TRACE] <Double, 89945.0> * <Long, 0> => <Double, 0.0>
[Aviator TRACE] <JavaType, 1, Integer> / <Long, 12> => <Long, 0>
[Aviator TRACE] <Long, 0> * <JavaType, 1, Integer> => <Long, 0>
[Aviator TRACE] <Long, 1> + <Long, 0> => <Long, 1>
[Aviator TRACE] Func : math.pow(<Long, 1>,<JavaType, 3, Integer>)
[Aviator TRACE] <Double, 1.0> -sub <Long, 1> => <Double, 0.0>
[Aviator TRACE] <Double, 0.0> / <Double, 0.0> => <Double, NaN>
[Aviator TRACE] <JavaType, 90000.0, Double> -sub <JavaType, 55.0, Double> => <Double, 89945.0>
[Aviator TRACE] <JavaType, 1, Integer> / <Long, 12> => <Long, 0>
[Aviator TRACE] <Long, 0> * <JavaType, 1, Integer> => <Long, 0>
[Aviator TRACE] <Double, 89945.0> * <Long, 0> => <Double, 0.0>
[Aviator TRACE] <Double, NaN> + <Double, 0.0> => <Double, NaN>
[Aviator TRACE] Result : NaN
2.整型返回值
Long result = (Long) AviatorEvaluator.execute("1+2+3");
如上,Aviator的数值类型仅支持Long和Double, 任何整数都将转换成Long, 任何浮点数都将转换为Double, 包括用户传入的变量数值。适配整型结果,2中方案:
1)统一将结果处理为BigDecimal类型
AviatorEvaluator.setOption(Options.ALWAYS_PARSE_FLOATING_POINT_NUMBER_INTO_DECIMAL, true);
2)AviatorUtil,重写一个返回值为Long的execute方法,我采用此方式。
3.线程安全
疑惑“多个线程同时调用 AviatorEvaluator.execute() 会存在多线程安全问题吗”,看了下execute方法源码,且在issue中,作者说明了“AviatorEvaluator.execute() 本身是线程安全的。只要你的表达式执行逻辑是线程安全的,传入的 env 是线程安全的,那就没有问题”。
https://github.com/killme2008/aviator/issues/91(参考这个issue)
4.能够撑住多大试算并发量,在测试过程中不断试验。
三、总结
1.新老方式都不错,开关可以随意切换,算是个容灾策略。
2.未来试算的方向应该是走动态配置,过渡过程需要维护2套代码。
3.aviator的一个缺点,公式中如果有逻辑,且不能通过三元运算转义,这时,配置中的公式就受限。
欢迎大佬们,指正,互相交流。
That's all.
2019年3月16日17:27:20 (周六)