详情文案的轻量级表达式配置方案
背景###
在订单详情页中,常常有一些业务逻辑,根据不同的条件展示不同的文案。通常的写法是一堆嵌套的 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 完。
小结###
本文尝试使用轻量级表达式配置方案,来解决详情文案的多样化复合逻辑问题。 适用于 条件不太复杂并且相互独立的业务场景。
在实际编程实现的时候,不急于着手,而是先提炼出其中的共性和模型,并实现为简易框架,可以得到更好的解决方案。