詳情文案的輕量級表達式配置方案
背景
在訂單詳情頁中,常常有一些業務邏輯,根據不同的條件展示不同的文案。通常的寫法是一堆嵌套的 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 完。
小結
本文嘗試使用輕量級表達式配置方案,來解決詳情文案的多樣化復合邏輯問題。 適用於 條件不太復雜並且相互獨立的業務場景。
在實際編程實現的時候,不急於著手,而是先提煉出其中的共性和模型,並實現為簡易框架,可以得到更好的解決方案。
詳情文案的輕量級表達式配置方案