拒绝代码臃肿,这套计算引擎设计方法值得一看!

导语 | 在庞大的数据系统中,往往会有大量的计算需求。传统的方式便是直接在代码写各种计算逻辑判断,这导致了代码非常臃肿,计算维护的成本变大。所以想着编写一套DSL,定义专用的语法去实现对数据的计算,并将其独立成为底层基础服务。

一、DSL 设计

(一)何为 DSL

领域特定语言(英语:domain-specific language、DSL)指的是专注于某个应用程序领域的计算机语言。不同于普通的跨领域通用计算机语言(GPL),领域特定语言只用在某些特定的领域。

简单来说,就是利用DSL,通过抽象构建模型,抽取公共的代码,以达到提高开发效率,减少重复的劳动的目的,比如经常使用的SQL。

同样的思路,我们要将复杂的逻辑判断与计算规则抽象化,构建计算DSL。

(二)如何通用化设计计算 DSL

值得庆幸的是,办公中经常使用的Excel就包含了许多计算规则。

让我直接举一个例子来说明,比如:要计算实际支出超出预算的金额,由于超出金额不可能为负数,所以逻辑条件为:如果实际支出大于预算,则结果为实际支出减预算,反之则取0。对应的Excel计算公式为:

代码语言:javascript
复制
IF (C2 > B2, IMSUB (C2, B2), 0)(C2 代表实际支出,B2 代表预算,IMSUB 代表减法)

有了一个这么专业的例子,那么对应我们的计算DSL就是:

代码语言:javascript
复制
IF ({budget} > {actual_expenses}, IMSUB ({budget}, {actual_expenses}), 0)({}用于标示具体字段,budget、actual_expenses 代表数据库中对应的预算、实际支出字段)

(三)DSL 设计的优势

  • 与Excel计算规则相似,减少用户学习成本。
  • 按照专业的规则来定义,使计算DSL更规范。
  • 由于规范的设计,更有利于后期扩展。

二、计算引擎的实现

(一)DSL 解析

对于这种有关键字并且无限嵌套的DSL,应该没有比堆栈更合适的方法来解析了。下面是具体例子的部分解析代码:

代码语言:javascript
复制
$dsl = 'IF({budget}>={actual_expenses},IMSUB({budget},{actual_expenses}),0,1)';
$stack               = []; // 堆栈$result              = []; // 结果$comparisonOperators = ['<', '>', '&', '|', '=']; // 比较运算符$placeholders        = [',', '(', ')']; // 占位符for ($index = 0; $index < strlen($dsl); $index++) {    $key = $dsl[$index];    switch ($key) {        // 解析变量        case '}' :            $variable = '';            while (true) {                $item = array_pop($stack); // 出栈                if ($item === '{') {                    break;                }                $variable = $item . $variable;            }            $result[] = $this->getVariable($variable); // 获取真实变量值            break;        // 解析方法        case  '(' :            $method = '';            while (true) {                $item = array_pop($stack); // 出栈                if (is_null($item)) {                    break;                }                $method = $item . $method;            }            $result[] = $method;            $result[] = $key;            break;        // 存储占位符,清空栈内变量(常量)        case in_array($key, $placeholders) :            $variable = '';            while (true) {                $item = array_pop($stack); // 出栈                if (is_null($item)) {                    break;                }                $variable = $item . $variable;            }            $variable != '' && $result[] = $variable;            $result[] = $key;            break;        // 解析比较运算符        case in_array($key, $comparisonOperators) :            if ($dsl[$index + 1] == '=') { // 兼容 >=、<=                $result[] = $key . '=';                $index++;            } else {                $result[] = $key;            }            break;        // 入栈        default :            $stack[] = $key;            break;    }}

(二)数据结构化

通过DSL解析可以得到“未赋值”的结构,再根据预先存储的数据模型对变量进行赋值,我们便可以得到如下结构:

这样一来,DSL就变成了机器所能识别的数据,将参数带入到指定的函数中便能得到计算结果。

(三)递归计算

从上图的结构中,我们可以分析出:每一个计算都包含了计算函数、占位符(开始符、分割符、结束符)以及函数对应的多个参数。其中参数可以是比较运算(IF函数第一个参数必为比较运算),也可以是另一个函数。这时候我们只需要使用递归的方式去不断往下运算便能得出结果。

代码语言:javascript
复制
/** * IF 函数核心计算逻辑 */public function calculate(){    // 计算比较结果    if ($this->getComparativeResult()) {        return $this->getResult($this->params[1]); // 返回真    } else {        return $this->getResult($this->params[2]); // 返回假    }}/** * 获取计算结果 */public function getResult($params){    // 如果是函数,则继续计算    if (is_array($params)) {        return (new Calculate($params))->calculate(); // 递归计算    }        // 非函数,直接返回结果    return $params;}

(四)架构梳理

首先对输入的DSL进行校验、解析并结构化数据;然后启动多个计算引擎同时并行处理;最终输出计算结果。

三、项目接入

(一)架构设计

整体架构分为五层,上层应用层提供给具体应用接入;通讯层负责对接收应用层的数据,及对支持应用层轮询获取计算结果;DSL解析层负责DSL校验、DSL解析以及数据结构化;处理完之后再到核心计算层,进行具体的计算执行;最后再将结果入库并将结果发送到消息队列中。

其中,DSL 解析层和核心计算层共同组成计算引擎

四、问题与思考

(一)计算提升效率缓慢

在完成项目接入后,为了提升计算效率,采用并行执行的方式来执行计算。期望的效果便是:随着并行的数量增加,效率也随之增加。

但事实总是事与愿违,即使扩大计算的并行数量也无法成倍提升计算效率,并且当并行数达到一定量时,效率提升越不明显

(二)计算依赖 

在经过仔细的问题排查之后,发现数据计算之间是有依赖关系的。让我们直接看下图的例子:当同时计算A、B、C三个字段时,不管如何并行执行,B的计算永远依赖A计算的结果;同理,C的计算也永远依赖A和B的计算结果。总而言之,就是说计算效率是有瓶颈的。

那么,如何能够用最少的资源达到整体计算的最佳效率呢?

五、解决方案:寻找最优解

(一)策略优先算法

对于每个计算字段来说,我们是知道具体依赖的程度的:

  • 对于A、D,只依赖常数,所以他们依赖程度为0。
  • 对于B、E,分别依赖A、B,那么他们的依赖应该分别在A、B的基础上+1,所以他们的依赖程度为1。
  • 对于C,同时依赖A、B,那么他的依赖程度应该为A+B+1=2。

所以,我们将每个字段排了优先级,对于同一优先级的字段并行计算,依次进行,便能以最少的资源达到整体计算的最佳效率

(二)计算速度不一致

在实际的计算中,每个字段计算的速度是不一样的。比如:在第一优先级中的A需要不断的累加才能得出结果,需要比同一优先级的D花费更长的时间。假如此时D已经计算完成,那么E其实已经不需要再依赖其他计算了,应该立即被执行。但由于第一优先级还未算完,所以只能继续等待。这样一来,对于计算结果的反馈非常的不友好。

(三)更进一步:动态策略优先算法

为了能快速的响应计算结果,我们需要在计算的同时,对计算完成的字段触发完成事件,对依赖该字段的其他优先级字段,重新分配优先级,当获得第一优先级时,立即执行

比如:在D计算完成后,去修改E的优先级,因为E只依赖D,而D已经计算完成,所以应该获得第一优先级,立即执行。

六、总结

(一)架构完善

在动态策略优先算法的思路下,我们在原先的结构中引入策略分配层。在DSL解析之后将数据传入到策略分配层中进行策略计算;然后,依次对各个优先级的字段进行计算任务调度;在计算完成后对事件进行处理,再依次进行任务调度;最终在完成整个计算后将数据入库。

其中,DSL解析层、策略分配层和核心计算层共同组成计算引擎

(二)下一步:引入监控

在完成了一系列的开发与项目接入工作之后。对于整个底层计算服务来说,并不是已经无懈可击了。

  • 在DSL的解析中需要实时监控解析结果,及时对错误进行拦截与记录,避免影响下层计算。
  • 在策略分配层中,也需要对每一次的策略计算、任务调度、事件处理进行监控,因为每一次错误都将影响整个模型的计算结果。
  • 在最终入库之前,还需要监控每个字段的计算结果是否符合预期。及时对错误结果进行修正。

 作者简介

林楨淵

腾讯 CDC 团队应用开发工程师

腾讯 CDC 团队应用开发工程师,毕业于广东工业大学,负责腾讯投资决策信息平台开发。致力于低代码开发平台(包括流程引擎、表单配置、计算引擎等等)的架构设计与持续优化。在投资领域开发有着丰富的落地经验。

推荐阅读

程序员如何把你关注的内容推送到你眼前?揭秘信息流推荐背后的系统设计

在Exception的影响下,如何才能写出更高质量的C++代码?

自动的内存管理系统实操手册——Java和Golang对比篇