1. 程式人生 > >詳情文案的輕量級表達式配置方案

詳情文案的輕量級表達式配置方案

apu 取值 hold option objects 定義條件 sem href 簡單

背景

在訂單詳情頁中,常常有一些業務邏輯,根據不同的條件展示不同的文案。通常的寫法是一堆嵌套的 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 完。

小結

本文嘗試使用輕量級表達式配置方案,來解決詳情文案的多樣化復合邏輯問題。 適用於 條件不太復雜並且相互獨立的業務場景。

在實際編程實現的時候,不急於著手,而是先提煉出其中的共性和模型,並實現為簡易框架,可以得到更好的解決方案。

詳情文案的輕量級表達式配置方案