基於javascript引擎封裝實現算術表達式計算工具類


JAVA可動態計算表達式的框架非常多,比如:spEL、Aviator、MVEL、EasyRules、jsEL等,這些框架的編碼上手程度、功能側重點及執行性能各有優劣,網上也有大把的學習資料及示例代碼,我這里也不在贅述了,本文要介紹的是直接借助於JDK中自帶的ScriptEngineManager,使用javascript Engine來動態計算表達式,編碼簡單及執行性能接近原生JAVA,完全滿足目前我公司的產品系統需求(通過配置計算公式模板,然后將實際的值帶入公式中,最后計算獲得結果),當然在實際的單元測試中發現,由於本質是使用的javascript 語法進行表達式計算,若有小數,則會出現精度不准確的情況(網上也有人反饋及給出了相應的解決方案),為了解決該問題,同時又不增加開發人員的使用復雜度,故我對計算過程進行了封裝,計算方法內部會自動識別出表達式中的變量及數字部份,然后所有參與計算的值均通過乘以10000轉換為整數后進行計算,計算的結果再除以10000以還原真實的結果,具體封裝的工具類代碼如下:

public class JsExpressionCalcUtils {

        private static ScriptEngine getJsEngine() {
            ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
            return scriptEngineManager.getEngineByName("javascript");
        }

        /**
         * 普通計算,若有小數計算則可能會出現精度丟失問題,整數計算無問題
         * @param jsExpr
         * @param targetMap
         * @return
         * @throws ScriptException
         */
        public static Double calculate(String jsExpr, Map<String, ? extends Number> targetMap) throws ScriptException {
            ScriptEngine jsEngine = getJsEngine();
            SimpleBindings bindings=new SimpleBindings();
            bindings.putAll(targetMap);
            return (Double) jsEngine.eval(jsExpr, bindings);
        }

        /**
         * 精確計算,支持小數或整數的混合運算,不會存在精度問題
         * @param jsExpr
         * @param targetMap
         * @return
         * @throws ScriptException
         */
        public static Double exactCalculate(String jsExpr, Map<String, ? extends Number> targetMap) throws ScriptException {
            String[] numVars = jsExpr.split("[()*\\-+/]");
            numVars = Arrays.stream(numVars).filter(StringUtils::isNotEmpty).toArray(String[]::new);

            double fixedValue = 10000D;
            StringBuilder stringBuilder = new StringBuilder();
            for (String item : numVars) {
                Number numValue = targetMap.get(item);
                if (numValue == null) {
                    if (NumberUtils.isNumber(item)) {
                        jsExpr = jsExpr.replaceFirst("\\b" + item + "\\b", String.valueOf(Double.parseDouble(item) * fixedValue));
                        continue;
                    }
                    numValue = 0;
                }
                stringBuilder.append(String.format(",%s=%s",item, numValue.doubleValue() * fixedValue));
            }

            ScriptEngine jsEngine = getJsEngine();
            String calcJsExpr = String.format("var %s;%s;", stringBuilder.substring(1), jsExpr);
            double result = (double) jsEngine.eval(calcJsExpr);
            System.out.println("calcJsExpr:" + calcJsExpr +",result:" + result);
            return result / fixedValue;
        }

    }

如上代碼所示,calculate方法是原生的js表達式計算,若有小數則可能會有精度問題,而exactCalculate方法是我進行封裝轉換為整數進行計算后再還原的方法,無論整數或小數進行計算都無精度問題,具體見如下單元測試的結果:

    @Test
    public void testJsExpr() throws ScriptException {
        Map<String,Double> numMap=new HashMap<>();
        numMap.put("a",0.3D);
        numMap.put("b",0.1D);
        numMap.put("c",0.2D);

        //0.3-(0.1+0.2) 應該為 0.0,實際呢?
        String expr="a-(b+c)";
        Double result1= JsExpressionCalcUtils.calculate(expr,numMap);
        System.out.println("result1:" + result1);

        Double result2= JsExpressionCalcUtils.exactCalculate(expr,numMap);
        System.out.println("result2:" + result2);

    }

result1:-5.551115123125783E-17 ---這不符合預期結果

calcJsExpr:var a=3000.0,b=1000.0,c=2000.0;a-(b+c);,result:0.0
result2:0.0 ---符合預期結果

2021-01-19補充,經過實際多場景測試,發現上述JS表達式(取整再運算)並未達到實際效果,在除法運算時仍會產生小數導致不准確,故轉而采用spEL表達式並進行改良后,以確保計算准確,代碼如下:

    private static Field typedValueField = null;
    private static Field typedValueDescriptorField = null;

    static {
        typedValueField = ReflectionUtils.findField(TypedValue.class, "value");
        typedValueDescriptorField = ReflectionUtils.findField(TypedValue.class, "typeDescriptor");
        Assert.state(typedValueField != null && typedValueDescriptorField != null, "not found TypedValue field[value,typeDescriptor] !");
        typedValueField.setAccessible(true);
        typedValueDescriptorField.setAccessible(true);
    }



    /**
     * 基於spring Expression【精確計算】算術表達式並獲得結果,運算過程凡涉及數字均轉換為BigDecimal類型,整個過程均以BigDecimal的高精度進行運算,確保精度正常(推薦使用)
     *
     * @param exprString
     * @param targetMap
     * @return
     */
    public static Double exactCalculate(String exprString, Map<String, ?> targetMap) {
        return exactCalculate(exprString, targetMap, false, null);
    }

    /**
     * 基於spring Expression【精確計算】算術表達式並獲得結果,運算過程凡涉及數字均轉換為BigDecimal類型,整個過程均以BigDecimal的高精度進行運算,確保精度正常(推薦使用)
     *
     * @param exprString
     * @param targetMap
     * @return
     */
    public static Double exactCalculate(String exprString, Map<String, ?> targetMap, boolean ignoreNonexistentKeys, Object defaultIfNull) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();
        context.setOperatorOverloader(NumberOperatorOverloader.DEFAULT);
        context.addPropertyAccessor(MapPropertyAccessor.DEFAULT.setOptions(ignoreNonexistentKeys, defaultIfNull));
        //這里將目標入參MAP作為spring表達式的根對象,則表達式中可以直接使用屬性即可
        context.setRootObject(targetMap);
        try {
            SpelExpression spExpression = (SpelExpression) parser.parseExpression(exprString);
            if (spExpression == null) {
                throw new ApplicationException(Constants.DEFAULT_ERROR_CODE, "解析spring表達式失敗:" + exprString);
            }

            if (spExpression.getAST() != null) {
                numberLiteralToBigDecimal(spExpression.getAST());
            }

            BigDecimal result = spExpression.getValue(context, BigDecimal.class);
            return result.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
        } catch (Exception e) {
            LOGGER.error("算術表達式語法執行錯誤:{},表達式:{},目標入參:{}", e.getMessage(), exprString, JsonUtils.deserializer(targetMap));
            throw new ApplicationException(Constants.DEFAULT_ERROR_CODE, "算術表達式語法執行錯誤,原因:" + e.getMessage());
        }
    }

    /**
     * 內部輔助方法:將表達式解析后的樹中包含有數值字面量的統一轉換為BigDecimal,確保精度不丟失
     *
     * @param spelNode
     */
    private static void numberLiteralToBigDecimal(SpelNode spelNode) {
        if (spelNode == null) {
            return;
        }

        if (spelNode instanceof Literal) {
            TypedValue typedValue = ((Literal) spelNode).getLiteralValue();
            if (typedValue != null && typedValue.getValue() instanceof Number) {
                try {
                    //將表達式中數字字面量的值轉換為BigDecimal,以便參與運算時精度不會丟失
                    typedValueField.set(typedValue, NumberUtils.createBigDecimal(typedValue.getValue().toString()));
                    typedValueDescriptorField.set(typedValue, null);
                } catch (IllegalAccessException e) {
                    throw new RuntimeException(e);
                }
            }
        }

        if (spelNode.getChildCount() > 0) {
            for (int i = 0; i < spelNode.getChildCount(); i++) {
                numberLiteralToBigDecimal(spelNode.getChild(i));
            }
        }
    }


    /**
     * 數字類型運算符重載操作類(目前實際並沒有生效)
     */
    private static class NumberOperatorOverloader implements OperatorOverloader {
        private static final List<Operation> OVERRIDE_OPERATIONS = Arrays.asList(Operation.ADD, Operation.SUBTRACT, Operation.DIVIDE, Operation.MULTIPLY);

        public static NumberOperatorOverloader DEFAULT = new NumberOperatorOverloader();

        @Override
        public boolean overridesOperation(Operation operation, Object o, Object o1) throws EvaluationException {
            return OVERRIDE_OPERATIONS.contains(operation) && o instanceof Number && o1 instanceof Number;
        }

        @Override
        public Object operate(Operation operation, Object o, Object o1) throws EvaluationException {
            BigDecimal leftNumber = NumberUtils.createBigDecimal(o.toString());
            BigDecimal rightNumber = NumberUtils.createBigDecimal(o1.toString());
            switch (operation) {
                case ADD: {
                    return leftNumber.add(rightNumber);
                }
                case SUBTRACT: {
                    return leftNumber.subtract(rightNumber);
                }
                case DIVIDE: {
                    return leftNumber.divide(rightNumber, RoundingMode.HALF_UP);
                }
                case MULTIPLY: {
                    return leftNumber.multiply(rightNumber);
                }
                default: {
                    return BigDecimal.ZERO;
                }
            }
        }
    }

    /**
     * MAP屬性訪問器,確保spring表達式中可以直接指明key,而無需使用['key']這種模式
     */
    private static class MapPropertyAccessor implements PropertyAccessor {

        public static final MapPropertyAccessor DEFAULT = new MapPropertyAccessor();

        private boolean ignoreNonexistentKeys = false;
        private Object defaultIfNull = null;

        /**
         * 設置選項
         *
         * @param ignoreNonexistentKeys 忽略不存在KEY
         * @param defaultIfNull         如果沒空時的默認值
         */
        public MapPropertyAccessor setOptions(boolean ignoreNonexistentKeys, Object defaultIfNull) {
            this.ignoreNonexistentKeys = ignoreNonexistentKeys;
            this.defaultIfNull = defaultIfNull;

            if (this.defaultIfNull != null && this.defaultIfNull instanceof Number) {
                //如果指定的默認值不為空,且為數字類型,則直接轉換為BigDecimal類型
                this.defaultIfNull = NumberUtils.createBigDecimal(this.defaultIfNull.toString());
            }

            return this;
        }

        @Override
        public Class<?>[] getSpecificTargetClasses() {
            return new Class[]{Map.class};
        }

        @Override
        public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException {
            return (target instanceof Map && (((Map<?, ?>) target).containsKey(name) || ignoreNonexistentKeys));
        }

        @Override
        public TypedValue read(EvaluationContext context, Object target, String name) throws AccessException {
            Assert.state(target instanceof Map, "參數不是Map類型");
            Map<?, ?> map = (Map<?, ?>) target;
            if (!map.containsKey(name) && !ignoreNonexistentKeys) {
                throw new AccessException("Map中未包含該key: " + name);
            }

            Object value = map.get(name);
            if (value == null) {
                value = defaultIfNull;
            } else if (value instanceof Number) {
                //為保證精度不丟失,若為數字類型就轉換為BigDecimal類型
                value = NumberUtils.createBigDecimal(value.toString());
            }

            return new TypedValue(value);
        }

        @Override
        public boolean canWrite(EvaluationContext context, Object target, String name) throws AccessException {
            return false;
        }

        @Override
        public void write(EvaluationContext context, Object target, String name, Object newValue) throws AccessException {

        }
    }

核心就是將所有的涉及數字類型轉為BigDecimal進行運算,sp EL內部操作符會對BigDecimal進行專門的運算處理,從而確保精度正常。

順便說一下,.NET(C#)語言也是支持執行javascript表達式的哦,當然也可以實現上述的求值表達式工具類,實現思路相同,有興趣的.NET開發人員可以試試;


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM