按钮条件逻辑配置化的可选技术方案

问题

详情页的一些按钮逻辑,很容易因为产品的策略变更而变化,或因为来了新业务而新增条件判断,或因为不同业务的差异性而有所不同。如果通过代码来实现,通常要写一串if-elseif-elseif-else语句,且后续修改扩展比较容易出错,需要重新发布,灵活性差。 可采用配置化的方法来实现按钮逻辑,从而在需要修改的时候只要变更配置即可。按钮逻辑的代码形式一般是:

public Boolean getIsAllowBuyAgain() {
  if (ConditionA) {
    return BoolA;
  }
  if (ConditionB) {
    return BoolB;
  }
 
  if (CondtionC && !CondtionD && (ConditionE not in [v1,v2])) {
    return BoolC;
  }
  return BoolD;
}

本文讨论了三种可选方案: 重量级的Groovy脚本方案、轻量级的规则引擎方案、超轻量级的条件匹配表达式方案,重点讲解了条件匹配表达式方案。

这里的代码实现仅作为demo, 实际需要考虑健壮性及更多因素。 按钮逻辑实现采用了“组合模式”,解析配置采用了“策略模式”和“工厂模式”。

使用Groovy缓存脚本

优点:非常灵活通用,重量级配置方案

不足:耗时可能比较多,简单script脚本第一次执行比较慢, script脚本缓存后执行比较快, 可以考虑预热; 复杂的代码不易于配置,简单逻辑是可以使用Groovy配置的。

package button

import com.alibaba.fastjson.JSON
import org.junit.Test
import shared.conf.GlobalConfig
import shared.script.ScriptExecutor
import spock.lang.Specification
import spock.lang.Unroll
import zzz.study.patterns.composite.button.*

class ButtonConfigTest extends Specification {

    ScriptExecutor scriptExecutor = new ScriptExecutor()
    GlobalConfig config = new GlobalConfig()

    def setup() {
        scriptExecutor.globalConfig = config
        scriptExecutor.init()
    }

    @Test
    def "testComplexConfigByGroovy"() {
        when:
        Domain domain = new Domain()
        domain.state = 20
        domain.orderNo = 'E0001'
        domain.orderType = 0

        then:
        testCond(domain)
    }

    void testCond(domain) {
        Binding binding = new Binding()
        binding.setVariable("domain", domain)
        def someButtonLogicFromApollo = 'domain.orderType == 10 && domain.state != null && domain.state != 20'
        println "domain = " + JSON.toJSONString(domain)

        (0..100).each {
            long start = System.currentTimeMillis()
            println "someButtonLogicFromApollo ? " +
                    scriptExecutor.exec(someButtonLogicFromApollo, binding)
            long end = System.currentTimeMillis()
            println "costs: " + (end - start) + " ms"
        }

    }
}

class Domain {

    /** 订单编号 */
    String orderNo

    /** 订单状态 */
    Integer state

    /** 订单类型 */
    Integer orderType

}
package shared.script;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;

import groovy.lang.Binding;
import groovy.lang.Script;

import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

import shared.conf.GlobalConfig;

@Component("scriptExecutor")
public class ScriptExecutor {

  private static Logger logger = LoggerFactory.getLogger(ScriptExecutor.class);

  private LoadingCache<String, GenericObjectPool<Script>> scriptCache;

  @Resource
  private GlobalConfig globalConfig;

  @PostConstruct
  public void init() {
    scriptCache = CacheBuilder
        .newBuilder().build(new CacheLoader<String, GenericObjectPool<Script>>() {
          @Override
          public GenericObjectPool<Script> load(String script) {
            GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
            poolConfig.setMaxTotal(globalConfig.getCacheMaxTotal());
            poolConfig.setMaxWaitMillis(globalConfig.getMaxWaitMillis());
            return new GenericObjectPool<Script>(new ScriptPoolFactory(script), poolConfig);
          }
        });
    logger.info("success init scripts cache.");
  }

  public Object exec(String scriptPassed, Binding binding) {
    GenericObjectPool<Script> scriptPool = null;
    Script script = null;
    try {
      scriptPool = scriptCache.get(scriptPassed);
      script = scriptPool.borrowObject();
      script.setBinding(binding);
      Object value = script.run();
      script.setBinding(null);
      return value;
    } catch (Exception ex) {
      logger.error("exxec script error: " + ex.getMessage(), ex);
      return null;
    } finally {
      if (scriptPool != null && script != null) {
        scriptPool.returnObject(script);
      }
    }

  }

}

规则引擎方案

按钮条件逻辑和规则集合非常相似,可以考虑采用一款轻量级的规则引擎。通过配置平台来管理按钮逻辑规则。

可参阅 Java Drools5.1 规则流基础【示例】。当然,这里若选择 Java Drools 显然“重”了,可选用一款轻量级的Java开源规则引擎作为起点。

条件表达式

对于轻量级判断逻辑,采用条件表达匹配。条件表达匹配,实质是规则引擎的超轻量级实现。

优点: 超轻量级

不足: 可能不够灵活应对各种复杂场景。

思路

分析按钮方法的逻辑,可以看出它遵循一个套路:

ifMatchX-ReturnRx,  ifMatchY-ReturnRy, ifMatchZ-ReturnRz, Else-ReturnDefault.

ifMatchX-ReturnRx 可以抽象成对象 (left:(field, op, value), right:result) ,其中 field 的值从传入的参数对象 valueMap 获取。 MatchX 既可能是原子条件,也可能是组合条件(与逻辑)。

原子条件的运算符主要包含 等于 eq, 不等于 neq , 包含 in , 大于 gt ,小于 lt , 大于或等于 gte, 小于或等于 lte 。

代码实现

STEP1: 定义条件测试接口 ICondition

public interface ICondition {

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

  /**
   * 获取满足条件时要返回的值
   */
  Boolean getResult();

}

STEP2: 基本条件的测试实现

import java.util.Collection;
import java.util.Map;
import java.util.Objects;

import lombok.Data;

@Data
public class BaseCondition {

  protected String field;
  protected CondOp op;
  protected Object value;

  public BaseCondition() {}

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

  public boolean test(Map<String, Object> valueMap) {
    try {
      Object passedValue = valueMap.get(field);
      switch (this.getOp()) {
        case eq:
          return Objects.equals(value, passedValue);
        case neq:
          return !Objects.equals(value, passedValue);
        case lt:
          // 需要根据格式转换成相应的对象然后 compareTo
          return ((Comparable)passedValue).compareTo(value) < 0;
        case gt:
          return ((Comparable)passedValue).compareTo(value) > 0;
        case lte:
          return ((Comparable)passedValue).compareTo(value) <= 0;
        case gte:
          return ((Comparable)passedValue).compareTo(value) >= 0;
        case in:
          return ((Collection)value).contains(passedValue);
        default:
          return false;
      }
    } catch (Exception ex) {
      return false;
    }
  }
}

STEP3: 按钮逻辑是单个条件实现

package zzz.study.patterns.composite.button;

import com.alibaba.fastjson.JSON;

import java.util.Map;

import lombok.Data;

@Data
public class SingleCondition implements ICondition {

  private BaseCondition cond;
  private Boolean result;

  public SingleCondition() {
  }

  public SingleCondition(String field, CondOp condOp, Object value, boolean result) {
    this.cond = new BaseCondition(field, condOp, value);
    this.result = result;
  }

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

  /**
   * 单条件测试
   * 这里仅做一个demo,实际需考虑健壮性和更多因素
   */
  @Override
  public boolean satisfiedBy(Map<String, Object> valueMap) {
    return this.cond.test(valueMap);
  }

}

STEP4: 按钮逻辑是组合条件,必须所有条件 conditions 都满足才算测试通过,返回 Result ; 否则交由下一个条件逻辑配置处理。

package zzz.study.patterns.composite.button;

import com.alibaba.fastjson.JSON;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import lombok.Data;

@Data
public class MultiCondition implements ICondition {

  private List<BaseCondition> conditions;
  private Boolean result;

  public MultiCondition() {
    this.conditions = new ArrayList<>();
    this.result = false;
  }

  public MultiCondition(List<BaseCondition> conditions, Boolean result) {
    this.conditions = conditions;
    this.result = result;
  }

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

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

STEP5: 按钮逻辑配置的抽象:

package zzz.study.patterns.composite.button;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import lombok.Data;

@Data
public class ButtonCondition {

  private List<ICondition> buttonRules;

  private Boolean defaultResult;

  public ButtonCondition() {
    this.buttonRules = new ArrayList<>();
    this.defaultResult = false;
  }

  public ButtonCondition(List<ICondition> matches, Boolean defaultResult) {
    this.buttonRules = matches;
    this.defaultResult = defaultResult;
  }

  public static ButtonCondition getInstance(String configJson) {
    Map<String, Object> configMap = JSON.parseObject(configJson);
    Boolean result = ((JSONObject) configMap).getBoolean("defaultResult");
    JSONArray conditions = ((JSONObject) configMap).getJSONArray("buttonRules");
    List<ICondition> allConditions = new ArrayList<>();
    for (int i=0; i < conditions.size(); i++) {
      Map condition = (Map) conditions.get(i);
      if (condition.containsKey("cond")) {
        allConditions.add(JSONObject.parseObject(condition.toString(), SingleCondition.class));
      }
      else if (condition.containsKey("conditions")){
        allConditions.add(JSONObject.parseObject(condition.toString(), MultiCondition.class));
      }
    }
    return new ButtonCondition(allConditions, result);
  }

  public boolean satisfiedBy(Map<String, Object> valueMap) {
    // 这里是一个责任链模式,为简单起见,采用了列表遍历
    for (ICondition cond: buttonRules) {
      if (cond.satisfiedBy(valueMap)) {
        return cond.getResult();
      }
    }
    return defaultResult;
  }
}

STEP6: 按钮逻辑配置及测试

    @Test
    def "testConditions"() {
        expect:
        def singleCondJson = '{"cond":{"field": "activity_type", "op":"eq", "value": 13}, "result": true}'
        def singleButtonCondition = SingleCondition.getInstance(singleCondJson)
        def valueMap = ["activity_type": 13]
        singleButtonCondition.satisfiedBy(valueMap) == true
        singleButtonCondition.getResult() == true

        def multiCondJson = '{"conditions": [{"field": "activity_type", "op":"eq", "value": 13}, {"field": "feedback", "op":"gt", "value": 201}], "result": false}'
        def multiButtonCondition = MultiCondition.getInstance(multiCondJson)
        def valueMap2 = ["activity_type": 13, "feedback": 250]
        multiButtonCondition.satisfiedBy(valueMap2) == true
        multiButtonCondition.getResult() == false

        def buttonConfigJson = '{"buttonRules": [{"cond":{"field": "activity_type", "op":"eq", "value": 63}, "result": false}, {"cond":{"field": "order_type", "op":"eq", "value": 75}, "result": false}, ' +
                               '{"conditions": [{"field": "state", "op":"neq", "value": 10}, {"field": "order_type", "op":"eq", "value": 0}, {"field": "activity_type", "op":"neq", "value": 13}], "result": true}], "defaultResult": false}'
        def combinedCondition = ButtonCondition.getInstance(buttonConfigJson)
        def giftValueMap = ["activity_type": 63]
        def giftResult = combinedCondition.satisfiedBy(giftValueMap)
        assert giftResult == false

        def knowledgeValueMap = ["activity_type": 0, "order_type": 75]
        def knowledgeResult = combinedCondition.satisfiedBy(knowledgeValueMap)
        assert knowledgeResult == false

        def periodValueMap = ["state": 20, "order_type": 0, "activity_type": 0]
        def periodResult = combinedCondition.satisfiedBy(periodValueMap)
        assert periodResult == true

        def complexValueMap = ["state": 20, "order_type": 0, "activity_type": 13]
        def complexResult = combinedCondition.satisfiedBy(complexValueMap)
        assert complexResult == false
    }

    @Unroll
    @Test
    def "testBaseCondition"() {
        expect:
        new BaseCondition(field, op, value).test(valueMap) == result

        where:
        field      | op         | value      | valueMap          | result
        'feedback' | CondOp.eq  | 201        | ['feedback': 201] | true
        'feedback' | CondOp.in  | [201, 250] | ['feedback': 201] | true
        'feedback' | CondOp.gt  | 201        | ['feedback': 202] | true
        'feedback' | CondOp.gte | 201        | ['feedback': 202] | true
        'feedback' | CondOp.lt  | 201        | ['feedback': 250] | false
        'feedback' | CondOp.lte | 201        | ['feedback': 250] | false
    }

支持多种配置语法

以上支持了从JSON串解析按钮逻辑的条件配置。不过用JSON写逻辑表达式,还是有些不够自然,容易出错。如果能用更自然的表达语法就更好了,比如:activity_type=13 && state = 30 , result = true 。 这样需要支持多种配置语法。 可以使用策略模式和工厂模式。 凡是需要多种可替换实现的算法,通常都可以采用策略模式和工厂模式。

STEP1: 定义条件配置的解析策略接口:

package zzz.study.patterns.composite.button.strategy;

import zzz.study.patterns.composite.button.ButtonCondition;
import zzz.study.patterns.composite.button.MultiCondition;
import zzz.study.patterns.composite.button.SingleCondition;

public interface ConditionParserStrategy {

  SingleCondition parseSingle(String express);
  MultiCondition parseMulti(String express);
  ButtonCondition parse(String express);
}

STEP2: 实现从JSON的解析策略,实际上就是从 SingleCondition , MultiCondition, ButtionCondition 里抽出 getInstance 方法:

package zzz.study.patterns.composite.button.strategy;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import zzz.study.patterns.composite.button.ButtonCondition;
import zzz.study.patterns.composite.button.ICondition;
import zzz.study.patterns.composite.button.MultiCondition;
import zzz.study.patterns.composite.button.SingleCondition;

public class JSONStrategy implements ConditionParserStrategy {

  @Override
  public SingleCondition parseSingle(String condJson) {
    return JSON.parseObject(condJson, SingleCondition.class);
  }

  @Override
  public MultiCondition parseMulti(String condJson) {
    return JSON.parseObject(condJson, MultiCondition.class);
  }

  @Override
  public ButtonCondition parse(String condJson) {
    Map<String, Object> configMap = JSON.parseObject(condJson);
    Boolean result = ((JSONObject) configMap).getBoolean("defaultResult");
    JSONArray conditions = ((JSONObject) configMap).getJSONArray("buttonRules");
    List<ICondition> allConditions = new ArrayList<>();
    for (int i=0; i < conditions.size(); i++) {
      // ... see code above
    }
    return new ButtonCondition(allConditions, result);
  }
}

STEP3: 定义更自然语法的一种实现(暂时留空):

package zzz.study.patterns.composite.button.strategy;

import zzz.study.patterns.composite.button.ButtonCondition;
import zzz.study.patterns.composite.button.MultiCondition;
import zzz.study.patterns.composite.button.SingleCondition;

public class DomainStrategy implements ConditionParserStrategy {

  @Override
  public SingleCondition parseSingle(String domainStr) {
    return null;
  }

  @Override
  public MultiCondition parseMulti(String domainStr) {
    return null;
  }

  @Override
  public ButtonCondition parse(String domainStr) {
    return null;
  }
}

STEP4: 定义解析策略工厂

package zzz.study.patterns.composite.button.strategy;

public class ParserStrategyFactory {

  public ConditionParserStrategy getParser(String format) {
    if ("json".equals(format)) {
      return new JSONStrategy();
    }
    return new DomainStrategy();
  }

}

STEP5: 客户端使用,将之前的 XXXCondition.getInstance 方法换成如下:

ConditionParserStrategy parserStrategy = new ParserStrategyFactory().getParser("json")
def singleButtonCondition = parserStrategy.parseSingle(singleCondJson)
def multiButtonCondition = parserStrategy.parseMulti(multiCondJson)
def combinedCondition = parserStrategy.parse(buttonConfigJson)

实际应用中,策略类及工厂类都应该是单例Component。

按钮逻辑的修改

  • 新增

针对某个按钮新增逻辑,只要修改按钮逻辑配置即可。 这里需要注意, 新增按钮逻辑的配置可能需要新的字段,比如原来只要判断 order_type, 现在需要增加 activity_type ,这就要求传入的 valueMap 能够一次性把该传的东西都传进去,否则就要改代码了。 通常, valueMap 应该预先传入 (order_type, activity_type, buy_way, state, …)。

  • 修改

通常是是修改现有的运算符和值。比如原来的逻辑要求 order_type = 5 , 现在要改成 order_type = 5 or 10 , 这样原来的配置为 {“field”: “order_type”, “op”:”eq”, “value”: 5} 要改成 {“field”: “order_type”, “op”:”in”, “value”: [5,10]}

方案选用

个人建议:

  1. 非常简单的条件情形,比如不超过三个条件的按钮逻辑,适合用条件匹配表达式;
  2. 略微复杂的条件情形, 比如有好几个条件,适合用 groovy 脚本;
  3. 需要按照不同行业、不同业务定制化的按钮逻辑,可以考虑规则引擎。


相关文章

发表评论

Comment form

(*) 表示必填项

还没有评论。

跳到底部
返回顶部