作为《程序员的底层思维》出版两年之后的再回顾,在上一篇《再谈软件设计中的抽象思维(上),从封装变化开始》中,我介绍了抽象设计的本质是发现变化点,结合问题域,提炼共性,沉淀领域知识。今天这篇我们通过实现一个通用的规则引擎,进一步讲解抽象思维在软件设计中的运用。
1. FizzBuzz游戏
FizzBuzz是一个在欧美非常流行的小游戏,类似于我们中国人酒桌上玩的“敲7游戏”,游戏规则是这样的,假设体育老师带着100名学生做一个报数游戏,从1开始按顺序报数,要求:
如果学生的序号是3的倍数,要说“Fizz”
如果学生的序号是5的倍数,就说“Buzz”
如果学生的序号同时是3和5的倍数,就说“FizzBuzz”
如果学生的序号是其他数字, 就说 原来数字
2. 战术性编程,快速实现
这个问题本身并不难, 战术龙卷风很快就能写出代码:
public class FizzBuzz {
public static String count(int n){
if (((n % 3) == 0) && ((n % 5) == 0))
return "FizzBuzz";
if ((n % 3) == 0)
return "Fizz";
if ((n % 5) == 0)
return "Buzz";
return String.valueOf(n);
}
}
如果你是用TDD的话,也可能会先写下下面的测试代码。不管是哪种情况,对于原始需求,写完功能+测试就算是完成工作了。
public class FizzBuzzTest {
@Test
public void num_given_1() {
//given
int input = 1;
//when
String result = FizzBuzz.count(input);
//then
Assertions.assertEquals("1", result);
}
@Test
public void fizz_given_3() {
//given
int input = 3;
//when
String result = FizzBuzz.count(input);
//then
Assertions.assertEquals("Fizz", result);
}
@Test
public void buzz_given_5() {
//given
int input = 5;
//when
String result = FizzBuzz.count(input);
//then
Assertions.assertEquals("Buzz", result);
}
@Test
public void fizz_buzz_given_15() {
//given
int input = 15;
//when
String result = FizzBuzz.count(input);
//then
Assertions.assertEquals("FizzBuzz", result);
}
}
这和很多的业务代码类似,初始场景简单,代码不复杂也很clean。但随着应用场景的变化,各种逻辑分支开始冲击原来的代码结构,最初的clean code就会慢慢地变得dirty,最后变成shit。
还是以我们的FizzBuzz为例,后续的需求演进可能是:
增加更多的花样:比如,如果学生的序号是7的倍数,就说“Whizz”
增加更多的规则:比如,如果是3的倍数,那么忽略其它规则
3. 分析变化点,抽象概念
为了让我们的程序更加通用,我们需要首先分析变化点,对变化的地方进行抽象,封装变化,从而让程序OCP。针对该问题,如下图所示,我们不难发现,变化点一个是绿色虚框内的部分,另一个是蓝色虚框内的部分。
实际上,这里我们已经能基本看出规则引擎的端倪了,因为根据Martin Fowler对规则引擎的定义,规则引擎的核心是Rule,而所谓的Rule 就是 if(Condition) then do(Action)。
至于上图右边那条“3和5的倍数”规则有些特殊,它既可以被看成是一条单独的Rule;也可以被看成是左边“3的倍数”和“5的倍数”两条Rule的组合(Composite);亦或是“3的倍数”和“5的倍数”两个condition谓词逻辑的and。对于我们FizzBuzz这个简单的问题而言,各种选项都可以。但是对于我们后续要实现的更加通用的规则引擎而言,我们会选择一种更加通用的方式去实现(具体,我们后文再说)。
根据上面的分析,我们不难写出一个相对通用简易的“规则引擎”来解决FizzBuzz问题,首先我们要对核心抽象概念进行接口定义。
Condition接口定义:
@FunctionalInterface
public interface Condition {
boolean evaluate(int n);
//谓词and逻辑,参考Predicate
default Condition and(Condition other) {
Objects.requireNonNull(other);
return (n) -> {
return this.evaluate(n) && other.evaluate(n);
};
}
//谓词or逻辑,参考Predicate
default Condition or(Condition other) {
Objects.requireNonNull(other);
return (n) -> {
return this.evaluate(n) || other.evaluate(n);
};
}
}
Action接口定义:
@FunctionalInterface
public interface Action {
String execute(int n);
}
Rule接口定义:
@FunctionalInterface
public interface Rule {
String apply(int n);
}
接下来,我们用这个简易规则引擎,重构之前的FizzBuzz,这里我们把上面提到的Composite Rule和Composite Condition两种方式都实现了一遍:
/**
* 计算倍数关系的谓词逻辑
*/
public class TimesCondition {
public static Condition times(int i){
return n -> n % i == 0;
}
}
/**
* 通过原子atom rule,以及atom rule之间的组合解决FizzBuzz问题
* 这里为了简单使用Rule的组合模式代替了RuleEngine实体
* 注意:这个SimpleRuleEngine只能解决输入为n,输出为String的FizzBuzz问题
* 完全不具备通用性
*/
public class SimpleRuleEngine {
public static Rule atom(Condition condition, Action action){
return n -> condition.evaluate(n) ? action.execute(n) : "";
}
public static Rule anyOf(Rule... rules){
return n -> stringStream(n, rules).filter(s -> !s.isEmpty()).findFirst().get();
}
public static Rule allOf(Rule... rules){
return n -> stringStream(n, rules).collect(Collectors.joining());
}
public static Stream<String> stringStream(int n, Rule[] rules){
return Arrays.stream(rules).map(r -> r.apply(n));
}
}
/**
* 用简易规则引擎重构后的FizzBuzz实现
*/
public class FizzBuzz {
public static String count(int i){
//Composite condition
Rule fizzBuzzRule = atom(times(3).and(times(5)), n -> "FizzBuzz");
Rule fizzRule = atom(times(3) , n -> "Fizz");
Rule buzzRule = atom(times(5), n -> "Buzz");
//Composite rule
Rule compositeFizzBuzzRule = allOf(fizzRule, buzzRule);
Rule defaultRule = atom(n -> true, n -> String.valueOf(n));
Rule rule = anyOf(compositeFizzBuzzRule, fizzRule, buzzRule, defaultRule);
return rule.apply(i);
}
}
针对FizzBuzz问题,不管其未来的需求如何演变,我们都可以通过编排上面定义的Rule以及Rule之间的组合来实现。不过正如上面SimpleRuleEngine类注释所言, 我们虽然使用了规则引擎这个概念,但是我们的这个“规则引擎”完全是FizzBuzz specific的,完全不具备通用性。如果我们想打造一个通用的规则引擎,还缺失什么?要如何做呢?
4. 通用规则引擎框架
上面的规则引擎不具备通用性,主要是因为Rule.apply这个函数的入参只能是int,返回值只能是String。作为一个通用规则引擎框架,我们肯定不能限制用户只能对int类型进行条件判断。
在进一步设计之前,我先来介绍一个框架上下文模式,我们使用框架,主要是复用框架的能力(function)。而对于框架而言,他最主要的职责是帮用户处理“数据”,对于用户数据,我们在框架中通常叫它们Context(上下文)。
如上图所示,我们可以将框架中的Context进一步分为Procedure Context(过程上下文)和Global Context(全局上下文):
所谓Procedure Context,是指用户每次调用框架所需要携带的数据。这个Context一般被设计为函数参数,在框架内部传递,当调用链结束,即被销毁。简单理解就是Context per request。例如,web容器框架中的每一个http请求都会有一个HttpServletRequest,就属于Procedure Context。
所谓Global Context,一般存储的是用户对框架的配置信息,它是全局共享的,在框架的整个生命周期都有效。比如,web容器中的ServletContext,一个容器只有一个是Global的。
这个框架上下文模式正是为了解决我们的int入参问题。即我们需要一个更通用的类型来表达Procedure Context,这里我们选择用Fact(事实)这个概念,来表示用户的输入数据。之所以叫Fact,是因为主流的流程引擎都是这么命名的,比如Drools。这就是我在上篇说的,抽象的难就在于有时候,我们要“创造”,“挖掘”合适的概念。像Fact这样的概念,其本身就是纯抽象的存在,不像苹果、香蕉,你还能看得见摸得着。实际上Context的概念也是一样,在软件领域,很多概念都是如此,你在这个“可见”的世界都找不到对应实体,它们只存在于我们的思维中,只能用抽象思维去处理。
按照我之前一直推荐的核心领域词汇表的做法,我们将通用规则引擎的核心概念整理如下:
英文名 | 中文名 | 含义 |
---|---|---|
RuleEngine | 规则引擎 | 规则引擎是有一组Rule组成,是框架的执行入口 |
fire | 触发 | 触发RuleEngine执行的函数 |
Rule | 规则 | 一条Rule是一个Condition和一组Action的组合 |
apply | 应用 | apple一个Rule,相当于 if(Condition) then do (Actions) |
fire | 触发 | 触发RuleEngine执行的函数 |
Condition | 条件 | 规则的判断条件,核心扩展点 |
evaluate | 评估 | Condition对应的函数 |
Action | 动作 | 当判断条件为true时,执行的动作 |
execute | 执行 | Action对应的函数 |
Fact | 事实数据 | 用户的输入数据,Procdure Context的承载体 |
RuleEngineConfig | 规则引擎配置 | Global Conext,比如maxAllowedRules:允许的最大规则数 |
结合上一节我们对规则引擎的基础实现,再加上新加入的Fact,RuleEngine等新概念,我们不难得出通用规则引擎的领域模型如下图所示
基于新的模型,我们将核心接口调整如下:
//Condition接口
@FunctionalInterface
public interface Condition {
boolean evaluate(Facts facts);
...
}
//Action接口
@FunctionalInterface
public interface Action {
void execute(Facts facts);
}
//Rule接口
public interface Rule {
boolean evaluate(Facts facts);
void execute(Facts facts);
boolean apply(Facts facts);
}
主要区别就在于将int类型,替换为更加通用的Facts,从而让我们的RuleEngine成为一个能支持任何场景的通用规则引擎。
另外,我们发现RuleEngine执行的是一组Rules,这些Rules之间的关系非常重要,因为RuleEngine如何执行这些Rules就取决于它们之间的关系。面对一组Rules,有两种类型的关系:逻辑关系和优先级关系,关于逻辑关系无外乎有以下三种:
“或”关系(And):Rules之间是互斥的,只要有一个满足,就执行短路操作,其它的Rule就不执行了。
“与”关系(Or):Rule之间存在逻辑与关系,即要么全部满足都执行,要么都不执行。
自然关系(Natural):Rule之间是平等的,RuleEngine会执行所有满足Condition的Rule。
这些关系之间,可能会组合成比较复杂的树形关系,比如下图所给的示例表示,rule1和右节点之间是Natural关系,所以如果rule1满足条件就会被执行,然后继续检查右节点。rule2和rule3任何一个满足条件则执行之,然后退出。如果都不满足,会继续查看rule4和rule5,如果rule4和rule5同时满足条件都执行,否则都不执行。
像这样的树形结构,特别合适使用组合模式(Composite Pattern),因为组合模式可以抹平整体和个体的差异,让处理这种分级递归树形结构变得简单。尽管有这样的灵活性,在实际使用中,还是不建议Rule嵌套太深,会把自己绕晕。
使用了组合模式之后,我们会将RuleEngine和Rule之间的关系调整为:
英文名 | 中文名 | 含义 |
---|---|---|
CompositRule | 组合规则 | 组合模式,表示一组Rules的组合 |
NaturalRules | 自然关系组合 | 顺序执行所有的Rules |
AnyRules | “或”关系组合 | 执行第一个满足条件的Rule |
AllRules | “与”关系组合 | 所有rules都满足条件,全部执行,否则都不执行 |
priority | 优先级 | 当有多个rules需要执行,指定rule的优先级 |
至此,一个相对通用的规则引擎就算设计完成了。完整的代码实现可以在https://github.com/alibaba/COLA/tree/master/cola-components/cola-component-ruleengine 查看。可以看到,整个设计过程,就是不断地分析变化点,抽象概念,沉淀领域知识,让系统更加通用的过程。不同的问题域,解决的问题不同,但这一套从变性入手,分析综合,以领域为核心,以概念为核心,抽象建模的方法论绝对是相通的。也是我们软件设计里最重要的核心能力之一。