详情文案的轻量级表达式配置方案

时间:2022-09-08 16:39:05

背景

在订单详情页中,常常有一些业务逻辑,根据不同的条件展示不同的文案。通常的写法是一堆嵌套的 if-else 语句,难以理解和维护。比如待开奖:

if (Objects.equals(PAID, orderState)) {
    if (Objects.equals(LOTTERY, activity) {
        Map<String, Object> extra = orderBO.getExtra();
        if (extra == null || extra.get("LOTTERY") == null) {
            return "待开奖";
        }
    }
}
if (Objects.equals(LOTTERY, activity)
    && Objects.equals(CONFIRM, orderState)
    && isGrouped(orderBO.getExtra())) {
    return "待开奖";
}
return OrderState.getState(orderState);

如何能够更好地表达这些业务呢 ?

"详情文案及按钮条件逻辑配置化的可选技术方案" 一文中,讨论了“Groovy脚本”、“规则引擎”及“条件表达式”三种方案。 本文主要谈谈条件表达式方案的实现。

问题域分析

经过初步分析可知,问题域涉及:

  • 规则:条件与结果。结果主要是字符串和布尔值,而条件则多种多样,涉及到不同业务领域。因此,要着重解决如何表达复合条件的问题。
  • 实例匹配。 以什么样的形式将实例传入。 如果以对象传入,那么就需要反射机制来获取字段,反而容易出错,因此,可以将实例转换为 Map 之后传入规则集合。

这里,使用简单表达式来表示规则。 这样,解决域可以建立为: 表达式 - 实例 Map ,表达式为: 条件 - 结果

这里的主要问题是:

  • 配置化地表达复合条件。
  • 创建易于编写的语法,能够安全可靠地转化为表达式对象。

设计方案

基本思路

仔细分析代码可知, 这些都可以凝练成 if cond then result 模式。 并且 or 可以拆解为单纯的 and 。比如上述代码可以拆解为:

state = PAID, activity = LOTTERY ,  extra is null => "待开奖"

state = PAID, activity = LOTTERY , extra.containsNot(LOTTERY) => “待开奖”

state = CONFIRM , activity = LOTTERY, extra.EXT_STATUS = "prize" => “待开奖”

这样,我们把问题的解决方案再一次化简:

  • 条件组合仅支持 and 表达式的组合,足够所需, 值仅支持 数字、字符串 和 列表。
  • 结果仅支持字符串和布尔。

条件表达式

支持如下操作符:

  • isnull / notnull : 是否为 null , 不为 null

  • eq ( = ): 等于,比如 state = PAID => 待发货;

  • neq ( != ): 不等于,比如 visible != 0 => 可见订单;

  • in (IN) : 包含于 ,比如 state in [TOPAY, PAID, TOSEND] => 未关闭订单;

  • contains / notcontains (HAS, NCT): 包含, 比如 extra contains BUYER_PHONE

取值: 从 Map 中获取。支持支持点分比如 extra.EXT_STATUS 。 还可以提供一些计算函数,基于这个值做进一步的计算。

配置语法与解析

有两种可选配置语法:

  • JSON 形式。 比如 {"conditions": [{"field": "activity", "op":"eq", "value":"LOTTERU"},{"field": "state", "op":"eq", "value":"CONFIRM"},{"field": "extra.EXT_STATUS", "op":"eq", "value":"prize"}] , "result":"待开奖"} , 这种形式比较正规,不过有点繁琐,容易因为配置的一点问题出错。

  • 简易形式。 比如 activity= LOTTERY && state = CONFIRM && extra.EXT_STATUS = "prize" , 写起来顺手,这样需要一套DSL 语法和解析代码, 解析会比较复杂一点。

经讨论后,使用 JSON 编写表达式比较繁琐,因此考虑使用简易形式。在简易形式中,规定:

  • 条件与结果用 => 分开;
  • 每个条件用 逗号 && 分开;
  • 每个表达式之间用 ; 区分。

测试用例

JSON 的语法配置:


class ExpressionJsonTest extends Specification {

    ExrepssionJsonParser expressionJsonParser = new ExrepssionJsonParser()

    @Test
    def "testOrderStateExpression"() {
        expect:
        SingleExpression singleExpression = expressionJsonParser.parseSingle(singleOrderStateExpression)
        singleExpression.getResult(["state":value]) == result

        where:
        singleOrderStateExpression  | value | result
        '{"cond": {"field": "state", "op":"eq", "value":"PAID"}, "result":"待发货"}' | "PAID" | '待发货'
    }

    @Test
    def "testOrderStateCombinedExpression"() {
        expect:
        String combinedOrderStateExpress = '''
            {"conditions": [{"field": "activity", "op":"eq", "value":"LOTTERY"},{"field": "state", "op":"eq", "value":"PAID"}, {"field": "extra", "op":"isnull"}], "result":"待开奖"} 
                '''
        CombinedExpression combinedExpression = expressionJsonParser.parseCombined(combinedOrderStateExpress.trim())
        combinedExpression.getResult(["state":"PAID", "activity":"LOTTERY"]) == "待开奖"

    }

    @Test
    def "testOrderStateCombinedExpression2"() {
        expect:
        String combinedOrderStateExpress = '''
            {"conditions": [{"field": "activity", "op":"eq", "value":"LOTTERY"},{"field": "state", "op":"eq", "value":"PAID"}, 
                      {"field": "extra", "op":"notcontains", "value":"LOTTERY"}], "result":"待开奖"} 
                '''
        CombinedExpression combinedExpression = expressionJsonParser.parseCombined(combinedOrderStateExpress.trim())
        combinedExpression.getResult(["state":"PAID", "activity":"LOTTERY", "extra":[:]]) == "待开奖"
    }

    @Test
    def "testOrderStateCombinedExpression3"() {
        expect:
        String combinedOrderStateExpress = '''
            {"conditions": [{"field": "activity", "op":"eq", "value":"LOTTERY"},{"field": "state", "op":"eq", "value":"CONFIRM"}, 
                      {"field": "extra.EXT_STATUS", "op":"eq",  "value":"prize"}], "result":"待开奖"} 
                '''
        CombinedExpression combinedExpression = expressionJsonParser.parseCombined(combinedOrderStateExpress.trim())
        combinedExpression.getResult(["state":"CONFIRM", "activity":"LOTTERY", "extra":['EXT_STATUS':'prize']]) == "待开奖"
    }

    @Test
    def "testWholeExpressions"() {
       expect:
       String wholeExpressionStr = '''
            [{"cond": {"field": "state", "op":"eq", "value":"PAID"}, "result":"待发货"},
             {"conditions": [{"field": "activity", "op":"eq", "value":"LOTTERY"},{"field": "state", "op":"eq", "value":"CONFIRM"}], "result":"待开奖"}]
                '''

       WholeExpressions wholeExpressions = expressionJsonParser.parseWhole(wholeExpressionStr)
       wholeExpressions.getResult(["state":"PAID"]) == "待发货"
       wholeExpressions.getResult(["state":"CONFIRM", "activity":"LOTTERY"]) == "待开奖"

    }
}

简易语法的测试用例:

class ExpressionSimpleTest extends Specification {

    ExpressionSimpleParser expressionSimpleParser = new ExpressionSimpleParser()

    @Test
    def "testOrderStateExpression"() {
        expect:
        SingleExpression singleExpression = expressionSimpleParser.parseSingle(singleOrderStateExpression)
        singleExpression.getResult(["state":value]) == result

        where:
        singleOrderStateExpression  | value | result
        'state = PAID => 待发货'  | "PAID" | '待发货'
    }

    @Test
    def "testOrderStateCombinedExpression"() {
        expect:
        String combinedOrderStateExpress = '''
           activity = LOTTERY && state = PAID && extra isnull => 待开奖
    '''
        CombinedExpression combinedExpression = expressionSimpleParser.parseCombined(combinedOrderStateExpress.trim())
        combinedExpression.getResult(["state":"PAID", "activity":"LOTTERY"]) == "待开奖"

    }

    @Test
    def "testOrderStateCombinedExpression2"() {
        expect:
        String combinedOrderStateExpress = '''
               activity = LOTTERY && state = PAID && extra NCT LOTTERY => 待开奖
        '''
        CombinedExpression combinedExpression = expressionSimpleParser.parseCombined(combinedOrderStateExpress.trim())
        combinedExpression.getResult(["state":"PAID", "activity":"LOTTERY", "extra":[:]]) == "待开奖"
    }

    @Test
    def "testOrderStateCombinedExpression3"() {
        expect:
        String combinedOrderStateExpress = '''
           activity = LOTTERY && state = CONFIRM && extra.EXT_STATUS = "prize" => 待开奖
        '''
        CombinedExpression combinedExpression = expressionSimpleParser.parseCombined(combinedOrderStateExpress.trim())
        combinedExpression.getResult(["state":"CONFIRM", "activity":"LOTTERY", "extra":['EXT_STATUS':'prize']]) == "待开奖"
    }

    @Test
    def "testWholeExpressions"() {
        expect:
        String wholeExpressionStr = '''
         activity = LOTTERY && state = PAID && extra NCT LOTTERY => 待开奖 ;
         state = PAID => 待发货 ; activity = LOTTERY && state = CONFIRM && extra.EXT_STATUS = "prize" => 待开奖 ;
         
        '''

        WholeExpressions wholeExpressions = expressionSimpleParser.parseWhole(wholeExpressionStr)
        wholeExpressions.getResult(["state":"PAID"]) == "待发货"
        wholeExpressions.getResult(["state":"PAID", "activity":"LOTTERY"]) == "待开奖"
        wholeExpressions.getResult(["state":"CONFIRM", "activity":"LOTTERY", "extra":['EXT_STATUS':'prize']]) == "待开奖"

    }
}


实现

STEP1: 定义条件测试接口 Condition 及表达式接口 Expression

public interface Condition {

  /**
   * 传入的 valueMap 是否满足条件对象
   * @param valueMap 值对象
   * 若 valueMap 满足条件对象,返回 true , 否则返回 false .
   */
  boolean satisfiedBy(Map<String, Object> valueMap);
}

public interface Expression {

  /**
   * 获取满足条件时要返回的值
   */
  String getResult(Map<String, Object> valueMap);

}


STEP2: 条件的实现

@Data
public class BaseCondition implements Condition {

  private String field;
  private Op op;
  private Object value;

  public BaseCondition() {}

  public BaseCondition(String field, Op op, Object value) {
    this.field = field;
    this.op = op;
    this.value = value;
  }

  public boolean satisfiedBy(Map<String, Object> valueMap) {
    try {
      if (valueMap == null || valueMap.size() == 0) {
        return false;
      }
      Object passedValue = MapUtil.readVal(valueMap, field);
      switch (this.getOp()) {
        case isnull:
          return passedValue == null;
        case notnull:
          return passedValue != null;
        case eq:
          return Objects.equals(value, passedValue);
        case neq:
          return !Objects.equals(value, passedValue);
        case in:
          if (value == null || !(value instanceof Collection)) {
            return false;
          }
          return ((Collection)value).contains(passedValue);
        case contains:
          if (passedValue == null || !(passedValue instanceof Map)) {
            return false;
          }
          return ((Map)passedValue).containsKey(value);
        case notcontains:
          if (passedValue == null || !(passedValue instanceof Map)) {
            return true;
          }
          return !((Map)passedValue).containsKey(value);
        default:
          return false;
      }
    } catch (Exception ex) {
      return false;
    }
  }
}

@Data
public class CombinedCondition implements Condition {

  private List<BaseCondition> conditions;

  public CombinedCondition() {
    this.conditions = new ArrayList<>();
  }

  public CombinedCondition(List<BaseCondition> conditions) {
    this.conditions = conditions;
  }

  @Override
  public boolean satisfiedBy(Map<String, Object> valueMap) {
    if (CollectionUtils.isEmpty(conditions)) {
      return true;
    }
    for (BaseCondition condition: conditions) {
       if (!condition.satisfiedBy(valueMap)) {
        return false;
      }
    }
    return true;
  }

}

public enum Op {

  isnull("isnull"),
  notnull("notnull"),
  eq("="),
  neq("!="),
  in("IN"),
  contains("HAS"),
  notcontains("NCT"),
  ;

  String symbo;

  Op(String symbo) {
    this.symbo = symbo;
  }

  public String getSymbo() {
    return symbo;
  }

  public static Op get(String name) {
    for (Op op: Op.values()) {
      if (Objects.equals(op.symbo, name)) {
        return op;
      }
    }
    return null;
  }

  public static Set<String> getAllOps() {
    return Arrays.stream(Op.values()).map(Op::getSymbo).collect(Collectors.toSet());
  }
}


STEP3: 表达式的实现

@Data
public class SingleExpression implements Expression {

  private BaseCondition cond;
  protected String result;

  public SingleExpression() {}

  public SingleExpression(BaseCondition cond, String result) {
    this.cond = cond;
    this.result = result;
  }

  public static SingleExpression getInstance(String configJson) {
    return JSON.parseObject(configJson, SingleExpression.class);
  }

  @Override
  public String getResult(Map<String, Object> valueMap) {
    return cond.satisfiedBy(valueMap) ? result : "";
  }
}

public class CombinedExpression implements Expression {

  private CombinedCondition conditions;
  private String result;

  public CombinedExpression() {}

  public CombinedExpression(CombinedCondition conditions, String result) {
    this.conditions = conditions;
    this.result = result;
  }

  @Override
  public String getResult(Map<String, Object> valueMap) {
    return conditions.satisfiedBy(valueMap) ? result : "";
  }

  public static CombinedExpression getInstance(String configJson) {
    try {
      JSONObject jsonObject = JSON.parseObject(configJson);
      String result = jsonObject.getString("result");
      JSONArray condArray = jsonObject.getJSONArray("conditions");
      List<BaseCondition> conditionList = new ArrayList<>();

      if (condArray != null || condArray.size() >0) {
        conditionList = condArray.stream().map(cond -> JSONObject.toJavaObject((JSONObject)cond, BaseCondition.class)).collect(Collectors.toList());
      }
      CombinedCondition combinedCondition = new CombinedCondition(conditionList);
      return new CombinedExpression(combinedCondition, result);
    } catch (Exception ex) {
      return null;
    }
  }
}

@Data
public class WholeExpressions implements Expression {

  private List<Expression> expressions;

  public WholeExpressions() {
    this.expressions = new ArrayList<>();
  }

  public WholeExpressions(List<Expression> expressions) {
    this.expressions = expressions;
  }

  public void addExpression(Expression expression) {
    this.expressions.add(expression);
  }

  public void addExpressions(List<Expression> expression) {
    this.expressions.addAll(expression);
  }

  public String getResult(Map<String,Object> valueMap) {
    for (Expression expression: expressions) {
      String result = expression.getResult(valueMap);
      if (StringUtils.isNotBlank(result)) {
        return result;
      }
    }
    return "";
  }

}


STEP4: 解析器的实现

public interface ExpressionParser {
  Expression parseSingle(String configJson);
  Expression parseCombined(String configJson);
  Expression parseWhole(String configJson);
}

/**
 * 解析 JSON 格式的表达式
 *
 * SingleExpression: 单条件的一个表达式
 * {"cond": {"field": "state", "op":"eq", "value":"PAID"}, "result":"待发货"}
 *
 * CombinedExpression: 多条件的一个表达式
 * {"conditions": [{"field": "activity", "op":"eq", "value":"LOTTERY"},{"field": "state", "op":"eq", "value":"PAID"}, {"field": "extra", "op":"isnull"}], "result":"待开奖"}
 *
 * WholeExpression: 多个表达式的集合
 * '''
 *   [{"cond": {"field": "state", "op":"eq", "value":"PAID"}, "result":"待发货"},
 *    {"conditions": [{"field": "activity", "op":"eq", "value":"LOTTERY"},{"field": "state", "op":"eq", "value":"CONFIRM"}], "result":"待开奖"}]
 * '''
 *
 */
public class ExrepssionJsonParser implements ExpressionParser {

  @Override
  public Expression parseSingle(String configJson) {
    return JSON.parseObject(configJson, SingleExpression.class);
  }

  @Override
  public Expression parseCombined(String configJson) {
    try {
      JSONObject jsonObject = JSON.parseObject(configJson);
      String result = jsonObject.getString("result");
      JSONArray condArray = jsonObject.getJSONArray("conditions");
      List<BaseCondition> conditionList = new ArrayList<>();

      if (condArray != null || condArray.size() >0) {
        conditionList = condArray.stream().map(cond -> JSONObject.toJavaObject((JSONObject)cond, BaseCondition.class)).collect(Collectors.toList());
      }
      CombinedCondition combinedCondition = new CombinedCondition(conditionList);
      return new CombinedExpression(combinedCondition, result);
    } catch (Exception ex) {
      return null;
    }
  }

  @Override
  public Expression parseWhole(String configJson) {
    JSONArray jsonArray = JSON.parseArray(configJson);
    List<Expression> expressions = new ArrayList<>();
    if (jsonArray != null && jsonArray.size() > 0) {
      expressions = jsonArray.stream().map(cond -> convertFrom((JSONObject)cond)).collect(Collectors.toList());
    }
    return new WholeExpressions(expressions);
  }

  private static Expression convertFrom(JSONObject expressionObj) {
    if (expressionObj.containsKey("cond")) {
      return JSONObject.toJavaObject(expressionObj, SingleExpression.class);
    }
    if (expressionObj.containsKey("conditions")) {
      return CombinedExpression.getInstance(expressionObj.toJSONString());
    }
    return null;
  }
}

/**
 * 解析简易格式格式的表达式
 *
 * 条件与结果用 => 分开; 每个表达式之间用 ; 区分。
 *
 * SingleExpression: 单条件的一个表达式
 * state = PAID => 待发货
 *
 * CombinedExpression: 多条件的一个表达式
 * activity = LOTTERY && state = PAID && extra = null => 待开奖
 *
 * WholeExpression: 多个表达式的集合
 *
 * state = PAID => 待发货 ; activity = LOTTERY && state = PAID => 待开奖
 *
 *
 */
public class ExpressionSimpleParser implements ExpressionParser {

  // 条件与结果之间的分隔符
  private static final String sep = "=>";

  // 复合条件之间之间的分隔符
  private static final String condSep = "&&";

  // 多个表达式之间的分隔符
  private static final String expSeq = ";";

  // 引号表示字符串
  private static final String quote = "\"";

  private static Pattern numberPattern = Pattern.compile("\\d+");

  private static Pattern listPattern = Pattern.compile("\\[(.*,?)+\\]");

  @Override
  public Expression parseSingle(String expStr) {
    check(expStr);
    String cond = expStr.split(sep)[0].trim();
    String result = expStr.split(sep)[1].trim();
    return new SingleExpression(parseCond(cond), result);
  }

  @Override
  public Expression parseCombined(String expStr) {
    check(expStr);
    String conds = expStr.split(sep)[0].trim();
    String result = expStr.split(sep)[1].trim();
    List<BaseCondition> conditions = Arrays.stream(conds.split(condSep)).filter(s -> StringUtils.isNotBlank(s)).map(this::parseCond).collect(Collectors.toList());
    return new CombinedExpression(new CombinedCondition(conditions), result);
  }

  @Override
  public Expression parseWhole(String expStr) {
    check(expStr);
    List<Expression> expressionList = Arrays.stream(expStr.split(expSeq)).filter(s -> StringUtils.isNotBlank(s)).map(this::parseExp).collect(Collectors.toList());
    return new WholeExpressions(expressionList);
  }

  private Expression parseExp(String expStr) {
    expStr = expStr.trim();
    return expStr.contains(condSep) ? parseCombined(expStr) : parseSingle(expStr);
  }

  private BaseCondition parseCond(String condStr) {
    condStr = condStr.trim();
    Set<String> allOps = Op.getAllOps();
    Optional<String> opHolder = allOps.stream().filter(condStr::contains).findFirst();
    if (!opHolder.isPresent()) {
      return null;
    }
    String op = opHolder.get();
    String[] fv = condStr.split(op);
    String field = fv[0].trim();
    String value = "";
    if (fv.length > 1) {
      value = condStr.split(op)[1].trim();
    }
    return new BaseCondition(field, Op.get(op), parseValue(value));
  }

  private Object parseValue(String value) {
    if (value.contains(quote)) {
      return value.replaceAll(quote, "");
    }
    if (numberPattern.matcher(value).matches()) {
      // 配置中通常不会用到长整型,因此这里直接转整型
      return Integer.parseInt(value);
    }
    if (listPattern.matcher(value).matches()) {
      String[] valueList = value.replace("[", "").replace("]","").split(",");
      List<Object> finalResult = Arrays.stream(valueList).map(this::parseValue).collect(Collectors.toList());
      return finalResult;
    }
    return value;
  }

  private void check(String expStr) {
    expStr = expStr.trim();
    if (StringUtils.isBlank(expStr) || !expStr.contains(sep)) {
      throw new IllegalArgumentException("expStr must contains => ");
    }
  }
}


STEP5: 配置集成

客户端使用,见 测试用例。 可以与 apollo 配置系统集成,也可以将条件表达式存放在 DB 中。

demo 完。

小结

本文尝试使用轻量级表达式配置方案,来解决详情文案的多样化复合逻辑问题。 适用于 条件不太复杂并且相互独立的业务场景。

在实际编程实现的时候,不急于着手,而是先提炼出其中的共性和模型,并实现为简易框架,可以得到更好的解决方案。