一种基于 JEP 和可配置公式实现用户自定义字段的解决方案

背景

随着 IT 技术的普及和发展,用户的信息化水平越来越高,软件产品除了满足用户的基本需求之外,还必须越来越照顾到用户的个性化需求,为用户提供深层次的个性化服务。以一个包含报表展示功能的产品为例,默认呈现给所有用户完全相同的报表,即同一个报表的字段内容和标签对所有用户完全相同。而在实际中,我们常常会遇到不同的用户由于其业务需求的不同,对于同一张报表,除基本数据字段之外,还要求额外增加符合该用户特定业务含义的字段,我们称之为用户自定义字段(Custom Metric)。这类需求在财务报表,数据分析报表中是比较常见。对于用户自定义字段,不同的用户给定不同的计算公式,甚至对于同一个用户的同一个字段,其计算公式也可能会随着时间推移而改变。一种直观的方法就是将所有用户有可能用到的字段都存储起来,然后再对不同的用户实现不同的字段,这样不仅会造成存储空间的浪费,而且后期的维护成本也十分高昂。本文将介绍一种基于 JEP 和可配置公式的解决方案,在不增加额外存储空间的情况下,灵活快速的解决用户的该类需求,并且具有良好的维护性和扩展性。

JEP 介绍

考虑到很多人对 JEP 还比较陌生,在介绍整个实现方案之前,有必要先让您对 JEP 有一个初步的了解。

JEP(Java Math Expression Parser)是一个第三方的 Java 工具包,提供了一套用于解析和计算数学表达式的类库,其核心功能就是计算公式的解析和结果的计算。在 JEP2.4.1 版本之前为符合 GPLv3 协议的开源免费包,你可以在 sourceforge 网站上下载和使用。使用 JEP 提供的 API,可以根据用户给定的公式来即时计算结果。JEP 支持用户自定义变量、常量和函数。在 JEP 中,已经预先包含大量的可使用的通用数学函数和常量,可满足日常的绝大部分数学计算需求。其官方网站是 http://www.singularsys.com/jep/,大家可以在该网站上下载试用版本和相关文档。

JEP 具有如下的特性:

  • 文件小巧(jar archive 文件大小在 300k 以下)
  • 快速求值
  • 精度高,计算中使用 BigDecimal
  • 包含常见的数学函数和运算符
  • 支持布尔型表达式
  • 良好的可扩展和可配置性
  • 支持字符串,向量和复杂数值
  • 支持隐式乘法
  • 允许声明的或者未声明的变量
  • 兼容 JAVA1.5
  • 支持 Unicode 字符
  • 大量的开发文档供参考
  • 包含 JAVACC 语法分析生成器可自动生成 main class

JEP 对一个表达式的计算分为两个步骤。JEP 首先会对表达式进行解析,解析后会生成一个树形结构,接下来会基于这个树形结构进行快速求值。其工作流程图如下:

图 1. JEP 工作流程图
一种基于 JEP 和可配置公式实现用户自定义字段的解决方案

从上图可以看出,JEP 的工作过程十分简单。下面举一个简单的例子进行进一步说明,让您对 JEP 有一个更加直观的了解。

清单 1. JEP 简单示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Jep jep = new Jep();
   try {
       int x = 10;
      //1. 设置变量的值
      jep.addVariable("x", x);
      //2. 载入并解析公式
      jep.parse("x+1");
      //3. 计算结果
      Object result = jep.evaluate();
      //4. 输出显示
      System.out.println("x + 1 = " + result + " (When x="+x+")");
  }  catch (Exception e) {
      System.out.println("An error occured: " + e.getMessage());
  }
输出结果:x + 1 = 11.0 (When x=10)

经过以上的介绍,想必您已经对 JEP 有一个初步的认识,那么接下来就可以开始进入本文的主题了。

整体设计说明

本方案的整体设计如下图所示:

图 2. 整体设计图
一种基于 JEP 和可配置公式实现用户自定义字段的解决方案

在本方案中,JEP 提供了一套用于对数学表达式的解析和计算类库,可以对用户配置的计算公式进行解析,并快速计算求值。通过将计算公式设计拥有按用户隔离、配置化管理以及运行时载入三个特性,我们便可以对同一字段针对不同的用户配置不同的计算公式。对于计算后的用户的自定义字段,我们可以根据不同的业务需求,可以直接在 UI 上展示,或者存储如数据,或者作为中间结果供其他用途。

JEP

JEP 为整个功能设计的核心,主要对公式进行读取和解析,并为计算中遇到的变量赋值,并且计算结果。

可配置化公式

对于计算公式,考虑到灵活性和可扩展性,我们将各个用户的自定义公式保存在配置文件中,其具备如下特性:

  1. 按用户隔离

每一个用户都使用独立文件存放计算公式,用户之间不会相互干扰,实现用户公式的个性化配置。

  1. 配置化管理

提供修改功能,保持程序的灵活性和可扩展性。在新增自定义字段或者改变计算公式时,无需修改代码,只需要重新对计算公式进行配置即可。

  1. 运行时载入

修改配置后,无需重启应用,也可将配置的公式载入运行时系统中。

在系统启动时会读取配置文件,在系统运行过程中,提供对用户的自定义公式的再配置功能并重新加载,在无需重启服务器的情况下让新配置的公式生效。

另外,由于计算公式不同而带来的字段业务含义不同,如用户自定义字段需要显示在 UI 上,为了使显示内容更加友好,我们可以为用户自定义字段提供可配置化的标签,最终使用户的自定义字段的标签与内容相匹配。下面的示例中也包含了这部分的实现。

详细代码实现

下面将以一个实际的例子并结合代码的方式,来具体说明该方案。

案例需求

假设有一个消费者到一家快餐店,由于又渴又饿让他食欲大增,他告诉商家他需要 5 杯可乐和 10 个汉堡。庆幸的是,正好这时快餐店在搞活动,他在享用大餐的同时,也可以节省一点费用。另外,对于供应商来说,他也会关心在这笔交易中的利益。于是有了如下两个表格:

表 1. 交易清单

对于这一笔生意,不同的用户关心的内容不同,于是有如下的需求表:

表 2. 角色需求表

如何来解决不同角色关心不同内容的需求呢?下面通过展示具体得代码来说明实现过程。

代码实现

首先我们列出整体的代码结构图,让您有一个整体的认识,两部分主程序和配置文件。如下图:

图 3. 主程序代码结构图
一种基于 JEP 和可配置公式实现用户自定义字段的解决方案
图 4. 配置文件结构图
一种基于 JEP 和可配置公式实现用户自定义字段的解决方案

定义产品交易类 ProductDeal.java,包含如下字段及相应的 getter 和 setter 方法:

清单 2. 数据对象定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ProductDeal {
/** 标识 */
 private String productId;
/** 名称 */
 private String productName;
/** 售价 */
 private Double unitPrice;
/** 单位减价 */
 private Double unitPriceOff;
/** 销量 */
 private Integer volume;
/** 商家经营成本 */
 private Double unitOperationCost;
/** 供应商价格 */
 private Double unitSupplierPrice;
/** 供应商成本 */
 private Double unitSupplierCost;
/** 自定义字段 A*/
 private Double customMetricA;
/** 自定义字段 B*/
 private Double customMetricB;
/**getter/setter 方法略 */
      ...
      ...
}

本案例中设计到三个类用户,分别为他们创建用户账号,见如下清单。

清单 3. 用户账号管理类 UserAccount.java
1
2
3
4
5
6
7
8
public class UserAccount {
 /** 消费者 */
  public static String USER_CUSTOMER= "CUSTOMER";
 /** 商家 */
  public static String USER_SELLER= "SELLER";
 /** 供应商 */
  public static String USER_SUPPLIER= "SUPPLIER";
 }

对于本例中的两个自定义字段 customMetricA 和 customMetricB,每个字段都对应一个公式名称。

清单 4. 配置文件 metricFormulaConfig.properties
1
2
customMetricA=formulaA
customMetricB=formulaB

对于同一个公式名称,不同的用户可以在其单独的配置文件中配置计算表达式,以实现其个性化需求。

清单 5. 消费者的公式配置文件 config/customer/formula.properties
1
2
formulaA= (unitPrice-unitPriceOff) * volume
formulaB= unitPriceOff * volume
清单 6. 消费者对应的字段标签配置 config/customer/label.properties
1
2
customMetricA= 消费者支出
customMetricB= 消费者节省
清单 7. 商家的公式配置文件 config/seller/formula.properties
1
2
formulaA=(unitPrice-unitPriceOff) * volume
formulaB=customMetricA - (unitOperationCost + unitSupplierPrice) * volume

注意:对于商家的自定义字段的计算,customMetricB 的计算需要使用到 customMetricA 的数值,所以在计算的时候需要考虑计算顺序,确保 customMetricB 在 customMetricA 之前计算。该功能在实际应用中可以避免重复计算。

清单 8. 商家对应的字段标签配置 config/seller/label.properties
1
2
customMetricA= 商家收入
customMetricB= 商家利润
清单 9. 供应商的公式配置文件 config/supplier/formula.properties
1
2
formulaA=customMetricB - unitSupplierCost * volume
formulaB=unitSupplierPrice * volume
清单 10. 供应商对应的字段标签配置 config/supplier/label.properties
1
2
customMetricA= 供应商利润
customMetricB= 供应商收入

注意:对于供应商的自定义字段的计算,customMetricA 的计算需要使用到 customMetricB 的数值,所以在计算的时候需要考虑计算顺序,确保 customMetricB 在 customMetricA 之前计算。

创建并配置好这些配置文件之后,需要将这些配置文件中的内容载入,于是我们需要用到配置管理工具类。其分别有三个方法,用于载入前述的三类配置文件。 目前的这些配置信息是以 properties 文件的形式存储。当然,在实际应用中,采用数据库或者 xml 文件的形式进行存储,也可以达到同样的效果,只需要相应的修改配置管理类的实现即可。

清单 11. 配置管理类 ConfigurationUtil.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
 * 载入自定义字段和公式名称的映射信息
 */
 public static void loadMetricFormulaMapping(){ …… }
 
/**
 * 载入用户的自定义公式信息
 */
 public static void loadFormulas(String user){ …… }
 
/**
 * 载入用户自定义字段标签信息
 */
 public static void loadLabels(String user){ …… }

JEP 在计算之前需要提前设置好所有用于计算的变量的值,在 JEP 计算完成之后,将计算结果存储起来供进一步使用。JEP 对这些变量的值的来源并不关心,一般来讲,变量的值会来自于 Java VO 对象,也可以来源于 ResultSet 对象。为了统一的获取变量的值和存放计算结果,在这里创建了一个 IValueable.java 接口类,其实现非常简单:

清单 12. 数据转换接口 IValuable.java
1
2
3
4
5
6
7
8
9
10
public interface IValuable{
 /**
   * 通过字段名称获取字段值
   */
public Double getValue(String fieldName) throws Exception;
 /**
   * 设置字段名称和计算结果
   */
  public void setValue(String fieldName,Double result) throws Exception;
 }

下面给出一个基于 Java VO 的实现,其中利用 Apache 的 BeanUtils 来根据字段名称获取字段的值:

清单 13. IValuable 接口的 Java VO 实现类 ObjectValueBean.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ObjectValueBean  implements IValuable{
 
  public Object object;
  public Map<String,Double> resultMap =  new HashMap<String,Double>();
     
  public ObjectValueBean(Object object) {
      this.object = object;
  }
      
  public Object getObject() {
     return object;
  }
   
  public void setObject(Object object) {
     this.object = object;
  }
   
 @Override
  public Double getValue(String fieldName)  throws Exception{
     //TODO Auto-generated method stub
     if (resultMap.containsKey(fieldName))
         return resultMap.get(fieldName);
     else
        return new Double(BeanUtils.getProperty(object,fieldName));
     }
      
 @Override
  public void setValue(String fieldName, Double result) throws Exception {
       // TODO Auto-generated method stub
       BeanUtils.setProperty(object, fieldName, result);
       resultMap.put(fieldName, result);
     }
 }

类似的我们也可以实现以 ResultSet 为数据源的 IValuable 接口实现类。

JEP 部分是整个实现的核心,在用户公式给定之后,JEP 要根据用户信息读入用户的公式,对公式进行校验(确保计算表达式配置正确,无字段的循环引用等),并根据字段之间的依赖关系设定好字段的计算顺序,对给定数据进行计算和处理。

清单 14. Jep 工具类 JepUtil.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
   /**
     * 初始化
     */
  public void init(String user)  throws Exception
     {
 setUser(user);
 formulaEvaluatorMap=  new HashMap<String,JepFormulaEvaluator>();
 calculationOrder =  new LinkedHashSet<String>();
         initCalculatorOrder();
     }
/**
  * 初始化计算顺序,当存在字段的循环引用时会抛出异常
  */
  private void initCalculatorOrder() throws Exception
     {
  if (ConfigurationUtil.getMetricFormulaMap()== null
 || ConfigurationUtil.getMetricFormulaMap().keySet().isEmpty()
 || ConfigurationUtil.getFormulaMap(user)== null
 || ConfigurationUtil.getFormulaMap(user).keySet().isEmpty())
  return ;
         
  List<String> order =  new ArrayList<String>();
  for (Object fieldName:ConfigurationUtil.getMetricFormulaMap().keySet()) {
       this.addFormulaFields((String)fieldName, order,(String)fieldName);
  }
     calculationOrder.addAll(order);
}
      
   /**
     * 对每一行记录进行处理,包括如下步骤
     * a) 公式解析
     * b) 变量赋值
     * c) 计算
     * d) 存储计算结果
     */
public void processRow(IValuable valuable) throws Exception {
 for (String fieldName : calculationOrder){            
  if(ConfigurationUtil.getMetricFormulaMap().keySet().contains(fieldName)){
 String formulaKey = (String)ConfigurationUtil.getMetricFormulaMap().get(fieldName);
    JepFormulaEvaluator jep = formulaEvaluatorMap.get(formulaKey);
    jep.addVariables(valuable);
    Double result = jep.evaluate();
    valuable.setValue(fieldName, result);
  }
}
}

公式的解析、赋值和计算的工作,最终由 JepFormulaEvaluator.java 来实现。在 JepFormulaEvaluator 类中,构造函数需要传入 formula 表达式,在设置变量值时需要传入 IValuable 对象实例作为数据来源。

清单 15. Jep 公式计算类 JepFormulaEvaluator.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/** 构造函数 */
public JepFormulaEvaluator(String formula){
   this .formula = formula;
}
 
/**
 * 解析公式表达式
 */
public boolean parse()  throws ParseException {
     if (formula ==  null ) {
       return false ;
     }
    node = jep.parse(formula);
    return true ;
}
 
/**
 * 变量赋值
 */
public void addVariables(IValuable valuable)  throws Exception{
      Set<String> children =  this .findChildren();
      for (String child : children) {
          jep.addVariable(child, valuable.getValue(child));
      }
}
 
/**
 * 计算结果
 */
public Double evaluate()  throws EvaluationException {
   result = (Double)jep.evaluate();
   return result;
}

在编写完上述代码之后,我们就可以应用它来解决我们在一开始提出的案例需求,见下面 CustomMetricExample.java 类的实现。请注意,我们定义了一个 postAction() 方法,此处我们将结果直接打印在控制台,在实际应用中可以将结果显示在 UI 上,也可以保存到数据库,另外也可以作为中间结果供其他用途,也只需要编写相应代码就可实现。

清单 16. 案例实现 CustomMetricExample.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
public class CustomMetricExample {
     JepUtil jepUtil =  new JepUtil();
     List<IValuable> valueList;
     
    /**
     * 载入测试数据
     */
     public void loadData() {
         List<IValuable> valueList =  new ArrayList<IValuable>();
         ProductDeal productDeal1 =  new ProductDeal();
         productDeal1.setProductId("001");
         productDeal1.setProductName("可乐");
         productDeal1.setUnitPrice(3.2);
         productDeal1.setUnitPriceOff(0.2);
         productDeal1.setUnitOperationCost(0.2);
         productDeal1.setUnitSupplierPrice(2.5);
         productDeal1.setUnitSupplierCost(2.0);
         productDeal1.setVolume(5);
         
         valueList.add( new ObjectValueBean(productDeal1));
         
         ProductDeal productDeal2 =  new ProductDeal();
         productDeal2.setProductId("002");
         productDeal2.setProductName("汉堡");
         productDeal2.setUnitPrice(10.0);
         productDeal2.setUnitPriceOff(2.0);
         productDeal2.setUnitOperationCost(1.0);
         productDeal2.setUnitSupplierPrice(6.0);
         productDeal2.setUnitSupplierCost(5.0);
         productDeal2.setVolume(10);
         
         valueList.add( new ObjectValueBean(productDeal2));
         this.valueList = valueList;
     }
     
    /**
     * 计算用户的自定义字段
     */
  public void calculateCustomMetricForUser(String user)  throws Exception {
     jepUtil.init(user);
     for (IValuable valuable : valueList) {
          jepUtil.processRow(valuable);
     }
     this.postAction(user);
 }
     
    /**
     * 后处理操作
     * 可以直接显示在 UI 上,也可以存储到数据库中,或作为中间结果供其他用途
     */
  private void postAction(String user) {
 //Display on UI
  for(IValuable valuable : valueList) {
     ProductDeal productDeal = (ProductDeal)((ObjectValueBean)valuable).getObject();
     System.out.println(user +" "+ productDeal.getProductName());
     System.out.println("customMetricA" + " ==> "+ ConfigurationUtil.getLabel(
     user,"customMetricA")+"=" + productDeal.getCustomMetricA());
     System.out.println("customMetricB" + " ==> "+ ConfigurationUtil.getLabel(
     user,"customMetricB")+"=" + productDeal.getCustomMetricB());
  }
   System.out.println("--------------------------------------------------");
}
     
 public static void main(String[] args)  throws Exception {        
    CustomMetricExample customMetricExample  =  new CustomMetricExample();
    //1.载入原始数据
        customMetricExample.loadData();
    //2.载入公共配置信息(字段和公式名的映射)
        ConfigurationUtil.loadCommonConfiguration();
    //3.载入用户配置信息(公式和标签)
        ConfigurationUtil.loadUserConfiguration(UserAccount.USER_CUSTOMER);
        ConfigurationUtil.loadUserConfiguration(UserAccount.USER_SELLER);
        ConfigurationUtil.loadUserConfiguration(UserAccount.USER_SUPPLIER);
    //4.应用
      System.out.println("我是消费者,我关心支出和节省金额");
      customMetricExample.calculateCustomMetricForUser(UserAccount.USER_CUSTOMER);
      System.out.println("我是消费者,我关心收入和利润");
      customMetricExample.calculateCustomMetricForUser(UserAccount.USER_SELLER);
      System.out.println("我是供应商,我关心收入和利润");
      customMetricExample.calculateCustomMetricForUser(UserAccount.USER_SUPPLIER);
    }
 }

在执行 CustomMetricExample.java 的 main 方法后,我们得到如下的输出结果:

清单 17. 输出结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
我是消费者,我关心支出和节省金额
 CUSTOMER 可乐
       customMetricA ==> 消费者支出 =15.0
       customMetricB ==> 消费者节省 =1.0
 CUSTOMER 汉堡
       customMetricA ==> 消费者支出 =80.0
       customMetricB ==> 消费者节省 =20.0
 --------------------------------------------------
我是商家,我关心收入和利润
 SELLER 可乐
       customMetricA ==> 商家收入 =15.0
       customMetricB ==> 商家利润 =1.5
 SELLER 汉堡
       customMetricA ==> 商家收入 =80.0
       customMetricB ==> 商家利润 =10.0
 --------------------------------------------------
我是供应商,我关心收入和利润
 SUPPLIER 可乐
       customMetricA ==> 供应商利润 =2.5
       customMetricB ==> 供应商收入 =12.5
 SUPPLIER 汉堡
       customMetricA ==> 供应商利润 =10.0
       customMetricB ==> 供应商收入 =60.0
 --------------------------------------------------

通过以上的实现可以看出,该实现充分考虑到需求的可变性,用户提出的关于自定义字段业务含义改变时,我们只需要重新配置其计算公式和对应的字段标签即可,非常的方便。

总结

本文对第三方 Java 类库 JEP 做了一个简单入门介绍,在此基础上介绍了一种基于 JEP 和配置化管理公式来解决用户自定义字段的通用方案,实现过程简洁清晰,并且具备良好的可维护性和扩展性,具有很强的应用价值。

下载资源

相关主题

  • JEP2.4.1:符合 GPLv3 协议的开源免费版(JEP2.4.1), 可以从 sourceforge 网站上下载。
  • JEP 的官方网站:更多内容可通过访问 JEP 的官方网站获取。目前 JEP 的最新版本为 3.4,本文使用的是官方试用版 jep-java-3.4-trial.zip。另外 JEP 还提供 .NET 版本,是基于 JEP Java Release 3 的基础上移植产生。

https://www.ibm.com/developerworks/cn/java/j-lo-jep/index.html