再谈软件设计中的抽象思维(下),从FizzBuzz到规则引擎

作为《程序员的底层思维》出版两年之后的再回顾,在上一篇《再谈软件设计中的抽象思维(上),从封装变化开始》中,我介绍了抽象设计的本质是发现变化点,结合问题域,提炼共性,沉淀领域知识。今天这篇我们通过实现一个通用的规则引擎,进一步讲解抽象思维在软件设计中的运用。

1. FizzBuzz游戏

FizzBuzz是一个在欧美非常流行的小游戏,类似于我们中国人酒桌上玩的“敲7游戏”,游戏规则是这样的,假设体育老师带着100名学生做一个报数游戏,从1开始按顺序报数,要求:

  1. 如果学生的序号是3的倍数,要说“Fizz

  2. 如果学生的序号是5的倍数,就说“Buzz

  3. 如果学生的序号同时是3和5的倍数,就说“FizzBuzz

  4. 如果学生的序号是其他数字, 就说 原来数字

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为例,后续的需求演进可能是:

  1. 增加更多的花样:比如,如果学生的序号是7的倍数,就说“Whizz”

  2. 增加更多的规则:比如,如果是3的倍数,那么忽略其它规则

3. 分析变化点,抽象概念

为了让我们的程序更加通用,我们需要首先分析变化点,对变化的地方进行抽象,封装变化,从而让程序OCP。针对该问题,如下图所示,我们不难发现,变化点一个是绿色虚框内的部分,另一个是蓝色虚框内的部分。

9473f79e3b67e1f412ccd093cb956156.jpeg

关于前半部分的判断条件我们可以抽象成Condition这个概念,后半部分的执行动作我们可以用抽象成Action这个概念。而一整条“如果…..就…..”我们称之为Rule。

实际上,这里我们已经能基本看出规则引擎的端倪了,因为根据Martin Fowler对规则引擎的定义,规则引擎的核心是Rule,而所谓的Rule 就是 if(Condition) then do(Action)。

e83ad27d174b5465391cd63eb4ead6aa.jpeg

至于上图右边那条“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(上下文)。

07a6189091311962c6ff95e7bb588154.jpeg

如上图所示,我们可以将框架中的Context进一步分为Procedure Context(过程上下文)和Global Context(全局上下文):
  1. 所谓Procedure Context,是指用户每次调用框架所需要携带的数据。这个Context一般被设计为函数参数,在框架内部传递,当调用链结束,即被销毁。简单理解就是Context per request。例如,web容器框架中的每一个http请求都会有一个HttpServletRequest,就属于Procedure Context。

  2. 所谓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等新概念,我们不难得出通用规则引擎的领域模型如下图所示

9082f67d0e565b2894baa1704d5ee839.jpeg

基于新的模型,我们将核心接口调整如下:

//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,有两种类型的关系:逻辑关系和优先级关系,关于逻辑关系无外乎有以下三种:

  1. “或”关系(And):Rules之间是互斥的,只要有一个满足,就执行短路操作,其它的Rule就不执行了。

  2. “与”关系(Or):Rule之间存在逻辑与关系,即要么全部满足都执行,要么都不执行。

  3. 自然关系(Natural):Rule之间是平等的,RuleEngine会执行所有满足Condition的Rule。

    这些关系之间,可能会组合成比较复杂的树形关系,比如下图所给的示例表示,rule1和右节点之间是Natural关系,所以如果rule1满足条件就会被执行,然后继续检查右节点。rule2和rule3任何一个满足条件则执行之,然后退出。如果都不满足,会继续查看rule4和rule5,如果rule4和rule5同时满足条件都执行,否则都不执行。

    b89f9986fe4c7efa0cccc339bb012fe2.jpeg

像这样的树形结构,特别合适使用组合模式(Composite Pattern),因为组合模式可以抹平整体和个体的差异,让处理这种分级递归树形结构变得简单。尽管有这样的灵活性,在实际使用中,还是不建议Rule嵌套太深,会把自己绕晕。

使用了组合模式之后,我们会将RuleEngine和Rule之间的关系调整为:

0f5f0cf12ba8b839e397a9c8e94a1592.jpeg
在前面的树形结构例子中,不知道你有没有这样的疑问,rule2和rule3之间是OR的关系,如果rule2和rule3都满足条件,是选择执行rule2还是rule3呢?前面我们只定义了rules之间的逻辑关系,并没有定义优先级关系。为此,我们需要一个新概念priority,来指定rule之间执行的优先级,如下图所示:
ed791d046ac65d54fc233ea226baded7.jpeg
rule3的优先级更高,会优先评估(evaluate)和执行(execute)rule3。同样,右节点的优先级更高,会优先评估和执行右节点。这些新概念,同样需要被添加到领域词汇表:
英文名中文名含义
CompositRule组合规则组合模式,表示一组Rules的组合
NaturalRules自然关系组合顺序执行所有的Rules
AnyRules“或”关系组合执行第一个满足条件的Rule
AllRules“与”关系组合所有rules都满足条件,全部执行,否则都不执行
priority优先级当有多个rules需要执行,指定rule的优先级

至此,一个相对通用的规则引擎就算设计完成了。完整的代码实现可以在https://github.com/alibaba/COLA/tree/master/cola-components/cola-component-ruleengine 查看。可以看到,整个设计过程,就是不断地分析变化点,抽象概念,沉淀领域知识,让系统更加通用的过程。不同的问题域,解决的问题不同,但这一套从变性入手,分析综合,以领域为核心,以概念为核心,抽象建模的方法论绝对是相通的。也是我们软件设计里最重要的核心能力之一。

  • 14
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值