如何使用规则引擎使业务更简洁高效

2,786 阅读6分钟

一、前言

在介绍规则引擎之前,先引入两个业务案例。

1.1、案例解析

案例一:有若干题目供学生作答,根据学生作答的正确率,依据某规则给本次作答判定作答星级。

规则定义:

正确率(%)星级
0-200
20-501
50-802
80-1003

案例二:魔卡小程序中有一个闯关成功获得经验值的场景,并且根据经验值范围确定用户的段位。

规则定义:

经验值段位
0~50青铜1
50~300青铜2
300~550青铜3
550~800白银1
800~1050白银2
1050~1300白银3
1300~1550黄金1
1550~1800黄金2
1800~2050黄金3
2050~2300钻石1
2300~2550钻石2
2550~2800钻石3
2800~3050钻石4
3050~3550钻石5
3550~4050王者1
4050~4550王者2
4550~5050王者3
......
每获得500经验值可升一级王者N(N最大值为99)

规则说明:从青铜1到钻石5,其经验值范围是不规则的;从钻石5开始经验值每提升500,对应段位提升一级。

1.2、常规业务实现

这两种业务逻辑,实现起来很简单,无非是通过多个if else串联起来就能实现。

案例一实现方式:


    /**
     * 计算星级
     * @param accuracy
     * @return
     */
    public Integer calStarLevel (Integer accuracy) {
        // 正确率校验
        if (accuracy == null || accuracy < 0 || accuracy > 100) {
            return null;
        }
        if (accuracy >= 0 && accuracy < 20) {
            return 0;
        } else if (accuracy >= 20 && accuracy < 50) {
            return 1;
        } else if (accuracy >= 50 && accuracy < 80) {
            return 2;
        } else if (accuracy >= 80 && accuracy <= 100) {
            return 3;
        } else {
            return null;
        } 
    }

案例二实现方式:


    /**
     * 计算段位
     * @param experience
     * @return
     */
    public String calLevel (Integer experience) {
        // 正确率校验
        if (experience == null || experience < 0) {
            return null;
        }
        // 超过王者段位门槛,进入王者段位判断逻辑
        Integer levelWZExperienceThreshold = 3050;
        if (experience >= levelWZExperienceThreshold ) {
            Integer level = (experience - levelWZExperienceThreshold) / 500 + 1;
            return (level > 99) ? "WZ99" : "WZ" + level;
        }
        if (experience >= 0 && experience < 50) {
            return "QT1";
        } else if (experience >= 50 && experience < 300) {
            return "QT2";
        } else if (experience >= 300 && experience < 550) {
            return "QT3";
        } else if (experience >= 550 && experience < 800) {
            return "BY1";
        } else if (experience >= 800 && experience < 1050) {
            return "BY2";
        } else if (experience >= 1050 && experience < 1300) {
            return "BY3";
        } else if (experience >= 1300 && experience < 1550) {
            return "HJ1";
        } else if (experience >= 1550 && experience < 1800) {
            return "HJ2";
        } else if (experience >= 1800 && experience < 2050) {
            return "HJ3";
        } else if (experience >= 2050 && experience < 2300) {
            return "ZS1";
        } else if (experience >= 2300 && experience < 2550) {
            return "ZS2";
        } else if (experience >= 2550 && experience < 2800) {
            return "ZS3";
        } else if (experience >= 2800 && experience < 3050) {
            return "ZS4";
        } else if (experience >= 3050 && experience < 3550) {
            return "ZS5";
        } else {
            return null;
        }
    }

看到方案一和方案二的实现方式,没有人会认为这是好的代码实现。存在如下弊端:

1、if else太多,程序可读性不高。

2、规则和代码耦合,规则可能随时变化,写死在程序里,不够灵活。

3、规则发生变化,需要修改代码,重新发版上线,繁琐。

有没有比较简洁的实现方式,并且又能适应规则频繁的变化?规则引擎或许是一个好的解决方案!

二、规则引擎

2.1、种类

常见的规则引擎有:Aviator、Drools、EasyRules等,每一种规则引擎都有其优点和缺点,更细节的大家可以去研究,基于Aviator的高性能、轻量级等优点,本分享选用Aviator规则引擎展开。

三、规则引擎的应用

以下我们通过案例二来说明规则引擎的应用。 应用工作流: Untitled Diagram.png

3.1、规则翻译

在使用规则引擎之前,我们需要将规则文本翻译成程序能识别的格式,比如说json。

案例二:

[
    {
        "level": "QT1",
        "rule": "v>=0 && v<50"
    },
    {
        "level": "QT2",
        "rule": "v>=50 && v<300"
    },
    {
        "level": "QT3",
        "rule": "v>=300 && v<550"
    },
    {
        "level": "BY1",
        "rule": "v>=550 && v<800"
    },
    {
        "level": "BY2",
        "rule": "v>=800 && v<1050"
    },
    {
        "level": "BY3",
        "rule": "v>=1050 && v<1300"
    },
    {
        "level": "HJ1",
        "rule": "v>=1300 && v<1550"
    },
    {
        "level": "HJ2",
        "rule": "v>=1550 && v<1800"
    },
    {
        "level": "HJ3",
        "rule": "v>=1800 && v<2050"
    },
    {
        "level": "ZS1",
        "rule": "v>=2050 && v<2300"
    },
    {
        "level": "ZS2",
        "rule": "v>=2300 && v<2550"
    },
    {
        "level": "ZS3",
        "rule": "v>=2550 && v<2800"
    },
    {
        "level": "ZS4",
        "rule": "v>=2800 && v<3050"
    },
    {
        "level": "ZS5",
        "rule": "v>=3050 && v<3550"
    }
]

说明:

1、将规则翻译为一个json数组,每一个元素代表一种规则,

2、其中rule是一个逻辑表达式,变量v代表经验值,level表示命中该规则的结果。

3、语义要明确:元素之间两两互斥,不能存在重叠的规则。

3.2、规则配置和解析

为了适应规则快速变化的需求,我们翻译之后的规则需要配置在配置中心,配置更改,业务服务器可以实时的感知到。配置好后,我们需要对配置的规则进行解析和实时更改监听。

@Slf4j
@Service
public class ChallengeConfigLocalCacheService implements InitializingBean {

    @Autowired
    private KVConfig kvConfig;
    @Autowired
    private BizNotifyHolder bizNotifyHolder;

    private static final String challengeLevelExperienceMappingKey = "challenge_level_experience_mapping";


    // 可重入读写锁
    private ReadWriteLock lock = new ReentrantReadWriteLock();

    /**
     * 闯关段位和经验值映射关系配置
     */
    public List<ChallengeLevelExperienceMappingVO> challengeLevelExperienceMappingList = Lists.newArrayList();


 
    @Override
    public void afterPropertiesSet() throws Exception {
        loadChallengeLevelExperienceMapping(kvConfig.challengeLevelExperienceMapping);
    }

  
    @ApolloConfigChangeListener(value = {"application", "props"})
    public void watchConfigChange(ConfigChangeEvent changeEvent) {
        if (changeEvent.isChanged(challengeLevelExperienceMappingKey)) {
            String newValue = changeEvent.getChange(challengeLevelExperienceMappingKey).getNewValue();
            log.info("challenge_level_experience_mapping changed. new value = {}", newValue);
            loadChallengeLevelExperienceMapping(newValue);
        }
    }

    private void loadChallengeLevelExperienceMapping(String value) {
        try {
            lock.writeLock().lock();
            this.challengeLevelExperienceMappingList = JacksonUtil.json2List(value, ChallengeLevelExperienceMappingVO.class);
        } catch (Exception e) {
            BizNotifyContext context = new BizNotifyContext();
            context.setNotifyType(BizNotifyTypeEnum.ZYL.getCode());
            context.setTitle("加载配置challenge_level_experience_mapping异常");
            context.setDetail("challenge_level_experience_mapping:" + value);
            bizNotifyHolder.handle(context);
        } finally {
            lock.writeLock().unlock();
        }
    }

    
    public List<ChallengeLevelExperienceMappingVO> readChallengeLevelExperienceMapping() {
        try {
            lock.readLock().lock();
            return this.challengeLevelExperienceMappingList;
        } finally {
            lock.readLock().unlock();
        }
    }
}

其中loadChallengeLevelExperienceMapping方法从配置中心拉取配置并解析到本地。 readChallengeLevelExperienceMapping方法从本地读取配置。

3.3、规则匹配

读取本地规则,遍历规则列表,如果当前经验值命中当前规则,则返回当前段位。

   
    public String upgradeLevel(String oldLevel, String userId, Integer experience) {
        // 计算并触发段位提升
        List<ChallengeLevelExperienceMappingVO> levelExperienceMappingList = challengeConfigLocalCacheService.readChallengeLevelExperienceMapping();
        for (ChallengeLevelExperienceMappingVO item : levelExperienceMappingList) {
            Map<String, Object> env = new HashMap<>(1);
            env.put("v", experience);
            boolean hit = (Boolean) AviatorEvaluator.execute(item.getRule(), env, true);
            if (hit) {
                int ret = challengeUserResultMapper.updateLevel(userId, oldLevel, item.getLevel());
                if (ret > 0) {
                    return item.getLevel();
                }
            }
        }
        return null;
    }

综上:规则命中逻辑非常简洁,同时规则变更很灵活,不用每次更改都要上线。

四、Avaitor的原理和注意事项

4.1、原理简述

Aviator 的基本过程是将表达式直接翻译成对应的 java 字节码执行,整个过程最多扫两趟(开启执行优先模式,如果是编译优先模式下就一趟),这样就保证了它的性能超越绝大部分解释性的表达式引擎,测试也证明如此;其次,除了依赖 commons-beanutils 这个库之外(用于做反射)不依赖任何第三方库,因此整体非常轻量级,整个 jar 包大小哪怕发展到现在 5.0 这个大版本,也才 430K。同时, Aviator 内置的函数库非常“节制”,除了必须的字符串处理、数学函数和集合处理之外,类似文件 IO、网络等等你都是没法使用的,这样能保证运行期的安全,如果你需要这些高阶能力,可以通过开放的自定义函数来接入。因此总结它的特点是: • 高性能 • 轻量级 • 一些比较有特色的特点: • 支持运算符重载 • 原生支持大整数和 BigDecimal 类型及运算,并且通过运算符重载和一般数字类型保持一致的运算方式。 • 原生支持正则表达式类型及匹配运算符 =~ • 类 clojure 的 seq 库及 lambda 支持,可以灵活地处理各种集合 • 开放能力:包括自定义函数接入以及各种定制选项

4.2、引擎模式

image.png

1、AviatorEvaluator.EVAL,默认值,以运行时的性能优先,编译会花费更多时间做优化,目前会做一些常量折叠、公共变量提取的优化。

2、AviatorEvaluator.COMPILE,以编译的性能优先,不会做任何编译优化,牺牲一定的运行性能。

适用场景:

AviatorEvaluator.EVAL:适合长期运行的表达式。(表达式稳定不变)

AviatorEvaluator.COMPILE:适合需要频繁编译表达式的场景。(表达式变动频繁)

Aviator有两个非常重要的操作:compile(编译)、execute(执行)。

/**
* 编译
*/
public static Expression compile(final String expression) {
  return compile(expression, false);
}
 
/**
* 执行
*/
public static Object execute(final String expression, final Map<String, Object> env) {
  return execute(expression, env, false);
}

使用这两个方法,有两个问题:

1、每次都重新编译,如果你的脚本没有变化,这个开销是浪费的,非常影响性能。

2、编译每次都产生新的匿名类,这些类会占用 JVM 方法区(Perm 或者 metaspace),内存逐步占满,并最终触发full gc。

通过查看源码,发现compile和execute有一个重载方法: compile(final String expression, final boolean cached) execute(final String expression, final Map<String, Object> env, final boolean cached) 参数cached表示是否使用编译缓存。

/**
  * 编译
  */
  public static Expression compile(final String expression, final boolean cached) {
    return getInstance().compile(expression, cached);
  }
 
  /**
  * 执行
  */
  public static Object execute(final String expression, final Map<String, Object> env, final boolean cached) {
    return getInstance().execute(expression, env, cached);
  }

1、使用第二种方法并传入cached=ture。 2、在启动类中启用编译缓存:AviatorEvaluator.getInstance().setCachedExpressionByDefault(true);

  // 也可以在启动类中一次性设置:
  public class MokaServerApplication extends SpringBootServletInitializer {
 
    public static void main(String[] args) {
        AviatorEvaluator.getInstance().setCachedExpressionByDefault(true);
        SpringApplication.run(MokaServerApplication.class, args);
    }
}

Aviator工作原理: image.png

五、结语

以上两个案例,是规则引擎的两个非常简单的应用,这里只是作为一种解决问题的思路,我理解有规则的地方,就可以出现规则引擎的身影,也就是说它的适用场景非常广泛,希望它能出现在你的业务开发逻辑中。谢谢!