翻译 | 如何去设计一个规则引擎应用

2020-07-262522

原文自博客发布平台medium,作者为 Joseph Gefroh,传送门

遍历一次最小规则引擎的组件,并且去了解进行这样的设计的背后的想法。

有这么一种情况

你是一家企业中的一个忙碌的工程师,平时有非常多的事情需要你去建设但是能够使用的资源却很少。部门内部不断要求去处理那些只能交由工程师负责的更新工作、市场部门不断要求更改电子邮箱、运营需要更多的批处理工具、销售希望每两周测试一次新的定价方式。你和你的团队都被这些要求搞得不知所措。

那么你的选择是什么呢?

停止手上既定的工作安排来完成这些要求?连续不断的干扰是阻碍完成任何工作的最可怕的方式之一,况且谁知道这些要求是否真的有打断你的工作的价值呢?

忽略它们?这样你就能专心在计划的工作上了,但是此时会有许多外部的利益相关者对你的部门感到愤怒。除此之外,技术部门不就是应该对业务部门有所帮助的吗?仅仅因为你不能分散出一到两个小时而错失一些潜在的重要商机是否合理呢?

根据计划的时间表对要求进行分类?计划内的中断比突然的中断要来得好得多,但是仍然会限制别的部门的进度并且逼迫他们根据你的计划行事,协作性可能会不尽人意。

想象下是否有那么一种方式,能够阻拦那些需要你处理的请求并且提供工具给其他部门让在不需要你的协助下进行工作。他们就能够在内部自己协助来推进工作,而不需要工程师来协助执行。

如果真的有这么一种方式存在,你就能节约大量的时间并且能够专注于一些重要的工作上了。

那就开始进入规则引擎部分吧。

什么是规则引擎?

规则引擎是这么的一个系统,它会根据运行时配置的特定条件来执行一系列操作。这意味着一个有效可用的规则引擎能够在不需要工程师更改系统的业务逻辑的前提下工作。

工程师始终在不知不觉中构建基于一定规则的系统应用。在每次编写 if-else 语句的时候,你都在刻意地创造那些遵循系统的硬编码规则。

更进一步,你能够使这些系统变得可动态配置。我所说的动态,是指系统的行为不由工程师的流程编码决定,而是由数据配置所决定。

这被称为“codeless”的系统概念并不新鲜。像 Zapier、Hubspot 和 IFTTT 这些应用都在以相似的概念在运作,通过像 Boomi 或者 Drools 来进行技术上的实施。

凝结物的脆弱性

为什么需要规则引擎?

默认情况下,大多工程师都会关心他们自己写的代码中体现的规则的细节。这些代码都是基于特定业务使用场景的凝结产物。

代码完全按照所写的去执行,这是必然的。所以工程师们需要知道怎么正确地编写代码来让它正常地运行。

然而,这种与业务场景的耦合性会使得,当业务场景发生变化时,编写的代码也需要发生显著的变化。虽然这在大多情况下可行,但有时变化的速度会变得难以适应,特别是在一些小团队或者像初创团队的这种更动态变化的环境当中。

很容易看出,事情随后会怎么发展。业务逻辑的实现是为了满足“软件应该如何表现”这个要求的。这些要求可能来自于不同的来源 -- 业务要求、UX的改进、法规要求、技术驱动等。

例如:

  • 一封“欢迎”电邮必须在用户注册的两个小时候被发送
  • 如果一个支付账号连续3天超过 10 万待支付美金,则必须立即支付当中的所有金额
  • 作为公会成员的员工在 2019 年会以标准规则的两倍来累积假期时间,但在 2021 年却不同

所有这些规则也会因稳定性或者细节的变化而改变。

例如,基于用户分析所获得的数据的需求可能会每周更改一次,而基于财务法规的需求可能每隔几年才会更改一次。而只有在重新谈判合同时,基于公会的合同需求才会被改变。

当它确实需要被更改时,实现也必须随之改变。

##实现的演变

让我们来研究下“欢迎”电邮的假设需求实现,因为它需要随着时间变化。

一封“欢迎”电邮必须在用户注册的两个小时候被发送

第一阶段

可以依赖开发人员来完成这些最简单的事情。大多工程师会将业务逻辑归结为一个“如果这样,那样做”问题:如果这发生了,去执行这个动作。

大多数开发者会采用第一种(不幸的是这大部分情况下也是最后一种)方式:

非常简单,单行代码就能够实现这个需求:

def on_user_create
   WelcomeEmail.send(in: 2.hours)
end

第二阶段

随着时间流逝,业务部门经常要求更改过去那些看起来会一成不变的细节。

变化是永远存在的,并且软件(software)被称为 SOFTware 是有原因的 -- 它必须是可塑的、可适应场景的。

当我们发现 2 个小时可能太长了,想把它改为 15 分钟?如果我们这样做,工程师需要这样做来更改它。

def after_user_create
   WelcomeEmail.send(in: 15.minutes)
end

第三阶段

如果业务部门需要启动试验来观察什么样的注册流程能维持最佳的保留率和转换率,那么此值会经常发生改变。

如果这情况发生得足够多,开发者会希望代码变得更智能并且将细节移除出去,以便可以在运行时对其进行配置。

def after_user_create
   WelcomeEmail.send(in: Configuration.get('welcome_email_wait'))
end

Configuration.get 可以是访问数据库、读取配置文件、读取环境变量或者选取随机值。重要的因素是,信息现在能够在运行时被读取,而非通过硬编码得到。

第四阶段

每一个独立的需求往往会在第三阶段被解决。

然而,如果有一系列相互关联的需求,那些有观察力的工程师会将相关的模式结合在一起。

譬如,业务部门需要在上面的前提下多发送一个提示邮件。和之前的保持一致,工程师们有可能这样去编写:

def after_user_create
   WelcomeEmail.send(in: Configuration.get('welcome_email_wait'))
   TipsEmail.send(in: Configuration.get('tips_email_wait'))
end

再一次的,然而,它也要求工程师添加新的一种邮件类型。

考察演变

像我们上面观察到的,每个改变都是小而简,但随着时间变化的叠加,会变得非常恐怖。很容易看出,在成千上万的需求在并行的发生改变同时,承载着高交付压力,系统很快就会变得失去控制。

即使在这种简单的情况下,时间推移使得不相关的邮件与记录的生命周期紧紧地耦合在一起,这让系统变得越为复杂。

在上述的场景中,开发者将机制与业务用例进行了转换。这意味着开发者将正在做什么怎么做这些事为什么这么做强绑定在一起。他们将业务领域和技术领域结合在一起 -- 机制与用例相互绑定。

当以上述方式在描述提示邮件的时候,基本上我们必须重复实现。实现依赖于具体的细节,即何时执行什么事情,等待多长时间,以及实际的发送内容。这些细节发生了改变,会迫使实现也发生改变(又称为,工程师的额外工作)。

细节并不是麻烦所在

信条是所有细节都不是麻烦。

从技术角度来看,欢迎电邮是在用户注册 2 小时候还是 15 分钟后发送都不是问题。同样,是发送欢迎邮件还是提示邮件也是如此。

它们都是业务领域的事情,只是基础技术的用例而已。

实现本身应该保持不变,对于系统而言应该关心的,在创建 Whatsittoya 记录后发送一条 Foobar 通知和删除 Whatchamacallit 记录后发送一条 Fizzbuzz 通知所使用的代码段应该是相同的。

什么时候发送或者什么被发送都是指那些不被确认的细节。它们都是应该被抽象掉的细节,因为这些细节最有可能随着时间的推移被改变。

你不希望在每次需求的变更时都需要设计新的玩意。你会更希望将这份权力交由业务处理,除非有一些合同、安全或者监管的要求。

建立一个规则引擎

那么,既然我们现在已经述说了规则引擎的好处,那我们应该怎么去构建它呢?

这里有非常多的方法。但从概念上来说,最小规则引应当由如下的组件构成:

规则(rule)

规则是指业务策略。在规则引擎的技术领域当中,它相当于一组触发器、条件以及作用。

触发器(trigger)

触发器是确定引擎是否应该运行规则的一个抽象。在大多情况下,它是上下文相关的。

在简单的系统中,它可以是简单的字符串检查或者硬编码,例如规则上的值为 on_createPaymentAccount 的字符串,以指示规则只能在 PaymentAccount 记录被创建后才会被执行。

在那些更为复杂的系统中,它可能是一个完整的上下文检查,像用于检查用户是否登录或者当前工作的记录类型是什么。

条件(condition)

条件是确定规则是否在特定的情况以及特定的记录下执行。

从概念上来说,它与触发器有一些细微的重叠,但是也有足够的差异点,值得单独讨论。

触发器更多地用于确定是否应该去检查规则的条件,而条件更多是针对一个特定的实例的检查。小的系统可以将触发器折叠到条件当中,但在复杂的系统中,最好是将两者区分开来。

条件本身坑可能会对执行检查的实际代码进行某种引用,并将一些参数传递到代码当中。

例如,你能够将条件记录存储在数据库当中,记录存储着条件的名称以及一些附带的参数。然后,代码可以动态地初始化条件所标识的类,并且传递参数和需要检查的记录数据。

作用(effect)

作用是指规则触发并通过条件后所发生的的事情。它通常是一个函数或者是一个函数的执行。它可以像设置字段那么简单,也可以像启动工作流那么复杂。

像条件一样,它可能会对应用本身的实际代码进行某种引用。

引擎(engine)

最后,是引擎本身。它实际上会完成系统中的大部分工作。它会接收记录、加载规则列表、检查是否根据特定触发器和条件来执行规则,然后执行规则定下的作用。

其他关联的因素

除了上述组件外,在构建规则引擎时,你还可能会遇到其他需要关注的领域。例如规则的审核更改、跟踪应用规则的历史记录、对特定人员进行规则配置、基于事件的作用执行、规则 DSL 等等。它们都是和子系统相关联,但是也是在规则引擎的范围之外。

规则引擎的流程

规则引擎一般是怎么工作的呢?

  • 第一步:引擎触发
  • 第二步:获得规则
  • 第三步:检查条件
  • 第四步:执行应用

第一步:引擎触发

发生的第一步是引擎被触发。入口点可以是 Engine#run 函数。这个函数负责传递记录和关联的上下文(例如,在记录被创建或者被更新的时候,引擎被触发)

第二步:获得规则

引擎会获取符合当前上下文的规则列表。也许它会从数据库中实时读取,也有可能是在应用启动的时候就已经在配置文件中预加载了。

无论是以哪种方式,这些规则都将与条件和应用结果相影响,是接下来几个步骤中最重要的一部分。

###第三步:检查条件

条件是一个布尔检查,它会确定是否执行规则的应用结果。如果规则是多条件的,则应执行进一步的布尔逻辑来判断所有条件是否符合条件,以此执行下面的结果。(例如,全部或全非、任一、规定任一项)

###第四步:执行应用

如果条件通过,则是时候执行应用了。应用本身是任意定义的。你需要确定如何处理在应用失效的时候的情况。

一旦所有步骤完成了,这个规则就会被应用上,恭喜你已经完成了!

一个实际的例子

假设你现在需要在注册一个用户账号后,在达到一定量的用户访问后会被标记位“受欢迎”用户。从理论上来讲,你可以对其进行硬编码处理,但是在未来可能需要工程师来对其进行修改。良好的工程设计能够降低这个消耗并且将未来的影响降至最低。

如果你有可用于此的规则引擎,那么工程师将不再被需要了。

  • 设置一个访问的触发器
  • 设置一个访问数大于100的条件
  • 设置一个应用结果,将用户的某个标识字段设置为“受欢迎的”

然后,这个规则会在引擎中自动运行,并且在将来也能被修改。

示例代码

是否很难将其概念化?不用害怕,我创建了一个小小的 Github 仓库来说明这当中的一些概念。

JGefroh/rules-engine

注意,它并不能用于生产,只是上述概念的一个表述而已。实际的规则引擎会有更多可变的部分并且应该考虑其他实际的情况。


最后的免责声明:不正确实施的规则引擎以及不适当的上下文的规则引擎会使你的系统变得异常复杂,并导致系统组织敏捷性丧失,因此务必谨慎使用。

在正确的上下文指导下,规则引擎是非常有用的一个工具。如果实施得当,它们可以大大地减轻开发负担并且提高组织中其他部门额敏捷性。


译者的话

最近译者本身也在为一些动作驱动的需求所烦扰,看了这篇文章后,多少会引起一些启发,也查阅了其他关联的框架和应用。

在对于那些需要复杂条件完成的场景,譬如营销推送、用户标签、触达中,一定程度上规则引擎都可以避免因硬编码带来的需求更改困难,并且这些框架也可以使过程抽象化,大大缩短开发者的设计时间。

之后译者也会对像 drools 这样的框架进行一个了解,希望这篇文章能够帮助到有需要的人。

分享
点赞1
打赏
上一篇:Docker常用命令笔记(一)
下一篇:使用@ConditionalOnProperty注解,控制bean是否注入容器中