关于技术架构:PingCode-Flow技术架构揭秘

22次阅读

共计 7207 个字符,预计需要花费 19 分钟才能阅读完成。

作者:PingCode 研发 VP@徐子岩

本文是 PingCode Flow 系列文章的第四篇,不出意外的话,也是最初一篇。从去年五月份 PingCode Flow 正式上线,就在脑海中构思好了四篇文档的程序和大抵内容,从介绍研发自动化的现状、痛点和解决方案,到展现如何应用 PingCode Flow 来实现研发的自动化。而这最初一篇,则是心愿可能从纯技术角度展现 PingCode Flow 外部是如何工作的,如何在每天将近 4000 次规定执行的压力下,保障 99% 的规定都能在 1 秒内实现,同时反对程序、并行、判断、循环等简单的运行逻辑。

同时,咱们也心愿在这篇文章中分享一下咱们是如何剖析和思考,并最终得出当初的架构。因而本文不会间接展现最终的设计后果,而是会论述咱们为什么要如此的设计,优缺点的衡量,以及开发过程中的重构。

PingCode Flow 系列文章传送门

  •  研发自动化,你筹备好了么
  •  5 分钟带你玩转 PingCode Flow
  •  咱们是如何应用 PingCode Flow 实现研发自动化治理的

PingCode Flow 的实质是什么

在前几篇文章中咱们提到,PingCode Flow 是一款研发自动化工具。所谓的自动化,就是指在某个事件产生后,依照预约义的流程去实现一系列的操作。所以,实质上来讲,PingCode Flow 是一个 TAP(Trigger Action Platform)零碎。它由一个触发器和多个动作组成一个有序的执行规定,而后依照这个规定程序执行。



因而,PingCode Flow 在技术架构设计的时候,就是要确保这样的流程可能顺畅的运行。

数据结构:怎么定义一个规定

确定了产品的外围指标后,第一件事就是要明确数据是如何定义的。依照下面的图示,在一个团队中用户可能会定义多个规定,而每个规定都蕴含了一个触发器和多个后续的动作。基于这个简略的需要,能够将规定的数据结构定义如下。



这样,一个规定就蕴含了一个触发器以及它所蕴含的动作。而动作的序号决定了他们执行的先后顺序。这样的设计看来根本满足了目前的产品需要。
然而咱们晓得,当初的 PingCode Flow 不仅仅反对上述的复线程序执行流程,还反对条件、并行、判断、循环等负责的执行流程。而且不止于此,咱们须要将上述这些流程自由组合,譬如并行外部有判断,判断外面有循环,循环中还有并行……通过这样简直有限的组合,让一个规定实现简直任意的流转。



因而,上述简略的数据结构就齐全不能满足需要。而如何设计一个可能反对各种场景的规定,是摆在咱们 PingCode Flow 团队背后的第一个难题。
如果咱们以「一个规定就是一系列动作的汇合」这个形式去思考,那么很难设计出绝对通用的数据结构。因为规定内的动作是由用户决定的,不可能穷举出所有可能的构造进去。然而能够尝试换一种思路来思考这个问题,也就是说,咱们不再把规定看做是触发器和动作的有序列表,而是将他们定义为一个链表。那么一个规定就是

  • 触发器及下一个动作
  • 以后动作及下一个动作
    的汇合。
    如果咱们再将「触发器」和「动作」合并为「步骤」。那么一个规定就是
  • 第一个步骤
  • 以后步骤的下一个步骤
    这样,咱们对于规定和步骤的定义就能够对立为
    
    
    
    即规定并不关怀它外部的动作都是什么以及先后顺序,它只关怀第一个动作是什么。而每一个动作也仅仅关怀下一个动作是什么。
    对于并行、循环、判断等简单的流程,咱们只须要扩大对应动作的数据结构,就能够实现不同的排列组合。譬如对于并行,它的数据结构是这样的。
    
    
    
    「并行」外部的每个分支的第一个步骤 ID 保留在一个数组中,示意这个分支要执行的第一个步骤是什么。「并行」自身不关怀每个分支外面具体的流程是什么样的。它只关怀当所有分支都执行结束后,下一个步骤是什么。
    基于这样的构造,对于上述这个简单的规定



咱们的数据大抵是这样的。对于 步骤 1 ,它只设置了下一个步骤是 步骤 2

对于 步骤 2 ,它外部有两个分支 步骤 3 步骤 4 ,下一个步骤是整个分支全副执行结束的 步骤 10。因而它的数据是这样的。

对于 步骤 4 ,它是一个循环步骤。进入循环体的第一个步骤是 步骤 6 ,而它实现循环后就会完结以后的分支操作。因而它的数据是这样的。

 下一个步骤 ID 为空,示意以后分支完结。

执行逻辑:如何反对各种类型的步骤

当咱们确定了数据结构之后,规定内步骤的执行形式也随之确定了。和构造相似,当一个规定被启动后(咱们先不思考规定是如何被触发的),它会首先找到第一个动作的 ID。相熟 PingCode Flow 的读者们都晓得,咱们零碎内预置了很多的动作,譬如设置工作项负责人、创立页面、变更测试用例状态等等。那么这些动作是怎么被执行起来的呢。
首先,每一个动作都会有一个全局惟一的名称。当规定执行到这个步骤的时候,咱们会通过步骤的 ID 找到它的动作名。通过动作的名称定位代码中对应的理论执行逻辑。



譬如「设置工作项负责人」这个动作,它的连接器名称是 project,动作名称是 set_assignee。代码大抵如下。


@action({
    name: "set_assignee",
    displayName: "设置工作项负责人",
    description: "设置以后一个或多个工作项的负责人。",
    isEnabled: Is.yes,
    allowCreation: Is.yes,
    allowDeletion: Is.yes
})
export class AgileActionSetWorkItemsAssignee extends AgileWorkItemsAction<AgileActionSetAssigneeDynamicPropertiesSchema, AgileActionSetAssigneeDirectives, AgileActionSetAssigneeRuleStepEntity> {constructor() {super(AgileActionSetAssigneeRuleStepEntity, undefined, /* ... */);
    }

    protected onGetDirectivesMetadata(): DirectivesMetadata<Omit<AgileActionSetAssigneeDirectives, keyof AgileWorkItemsActionDirectives>> {/* ... */};

    protected onGetDynamicPropertiesMetadata(): PropertiesMetadata<Omit<AgileActionSetAssigneeDynamicPropertiesSchema, keyof AgileWorkItemsDynamicPropertiesSchema>> {/* ... */};

    protected async onExecute(context: ExecuteContextWrapper<AgileActionSetAssigneeDynamicPropertiesSchema, AgileActionSetAssigneeDirectives, AgileActionSetAssigneeRuleStepEntity>): Promise<RuleStepResult<AgileActionSetAssigneeDynamicPropertiesSchema>> {/* ... */}
}

其中最次要的代码是 onExecute,它将会在执行这个步骤时候被调用。当操作执行结束后,会将数据库中保留的的 下一个步骤 ID 返回,规定执行引擎会去调用后续的步骤。这就是一个最简略的动作步骤,由零碎调用,执行具体的操作,而后返回下一个步骤的 ID。
除了一般的动作之外,PingCode Flow 还反对条件、并行、判断、循环等简单的流程管制。和方才提到的动作一样,都是通过重写 onExecute 这个办法来实现的。以「条件」为例,它须要在判断为真的时候继续执行后续的步骤,为假则进行以后步骤。那么它的 onExecute 就是这样的。

export abstract class Condition<D extends Directives, T extends RuleStepsConditionEntity<D>> extends Element<EmptyPropertiesSchema, D, T> {constructor(ruleStepCtor: new (...args: any) => T, contracts: ElementContract[]) {/* ... */}

    protected abstract predicate(context: ExecuteContextWrapper<EmptyPropertiesSchema, D, T>): Promise<boolean>;

    protected async onExecute(context: ExecuteContextWrapper<EmptyPropertiesSchema, D, T>): Promise<RuleStepResult<EmptyPropertiesSchema>> {if (await this.predicate(context)) {
            return {
                properties: undefined,
                nextStepId: context.getRuleStepEntity().next_step_id};
        }
        else {
            return {
                properties: undefined,
                nextStepId: undefined
            };
        }
    }

    public getDynamicPropertiesMetadata(): PropertiesMetadata<EmptyPropertiesSchema> {return {};
    }

}

咱们定义了一个形象办法 predicate,用来给派生类实现具体的判断逻辑。onExecute 办法会调用这个 predicate。如果后果为 TRUE,那么将会返回数据库外面定义的下一个步骤的 ID,规定将会继续执行;如果后果为 FALSE,那么它会返回 undefined,示意没有后续的步骤了,执行流程到此结束。
而对于「判断」、「并行」、「循环」等类型的步骤,它外部可能蕴含了非常复杂的流程,也能够通过现有的数据结构和执行流程做到解耦,让每个步骤只须要专一本人的工作。
以「并行」为例,咱们晓得它的数据结构蕴含了

  • 每个分支的首个步骤 ID
  • 所有分支完结后的下一个步骤 ID
    因而,「并行」步骤的执行逻辑就是同时启动每个分支的首个步骤。而后等所有分支的操作都完结了,再返回下一个步骤的 ID。
    

    @control({
      name: "parallel",
      displayName: "并行(Parallel)",
      description: "并行执行步骤。",
      isEnabled: Is.yes,
      allowCreation: Is.yes,
      allowDeletion: Is.yes
    })
    export class ControlParallel extends ControlAction<EmptyPropertiesSchema, RuleStepsControlParallelEntity> {constructor() {/* ... */}
    
      public getDynamicPropertiesMetadata(): PropertiesMetadata<EmptyPropertiesSchema> {/* ... */}
    
      protected async onExecute(context: ExecuteContextWrapper<EmptyPropertiesSchema, EmptyDirectives, RuleStepsControlParallelEntity>): Promise<RuleStepResult<EmptyPropertiesSchema>> {const entity = context.getRuleStepEntity();
          const contexts = await Promise.all(_.map(entity.parallel_next_step_ids, id => new Promise<ExecuteContext>((resolve, reject) => {const ctx = context.getRawContext(true);
              Executor.create().execute(entity._id, id, ctx)
                  .then(() => {return resolve(ctx);
                  })
                  .catch(error => {return reject(error);
                  });
          })));
          context
              .mergeProperties(contexts)
              .mergeTargets(contexts, false);
          return {
              properties: undefined,
              nextStepId: entity.next_step_id
          };
      }
    
    }

留神在 onExecute 办法中,咱们将数据库中定义的分支步骤 ID 数组 parallel_next_step_ids 转化为异步操作 Executor.create().execute,让他们在各自的上下文中执行。而后等所有分支的操作都执行结束,也就是 await Promise.all 后,再返回下一个步骤的 ID。这样,对于「并行」自身则齐全不必关怀每个分支内的执行逻辑是什么样的。而当规定执行到某个分支内的时候,也齐全不会意识到本人是处在某个「并行」的「分支」中。

模块拆分:规定是如何被调度起来的

方才咱们介绍了规定和步骤的数据是怎么保留的,以及一个规定内的步骤是怎么执行的。然而规定是如何被触发启动的呢?目前 PingCode Flow 反对自动化、手动和即时三种规定,同时自动化规定又能够分为如下三种启动场景:

  • 由 PingCode 其它子产品调用启动
  • 由第三方子产品调用启动
  • 由自定义的 Webhook 调用启动
    
    
    
    由上图能够看出,对于一个规定来说,它并不需要关怀本人是由什么路径触发的。它只须要晓得在某个时刻,有一个规定须要执行。因而,咱们为这个执行规定的局部独自拆散了一个模块,即「Flow Engine」,它的职责很简略,就是「启动某个规定」。
    而对于负责接管规定启动申请的模块,它们有一个通用的职责,就是按本人的需要去告诉 Flow Engine 启动规定。上图中五个触发规定的模块,各自的职责如下:
    

    
    通过这样的拆分,就能够将规定的执行和规定的触发齐全隔离开。在 PingCode Flow 开发初期,咱们仅反对从 PingCode 其它子产品触发的规定。然而随着产品性能的一直加强,咱们陆续实现了第三方产品(GitHub、GitLab、Jenkins 等)的接入,即时规定(手动触发)和定时规定(定时触发)。而这些新的触发形式齐全不影响之前其它的模块,因而最大限度的保障了产品的品质。

部署形式:让所有节点都反对横向扩大

企业级 SaaS 产品的外围诉求就是数据的安全性和服务的稳定性。对于稳定性,一方面要求咱们的产出物(即代码)品质很高,另一方是要求咱们的服务在各个环节都能反对横向扩大,不会呈现因申请量和执行量减少的状况下导致的零碎性能问题和稳定性问题。繁多职责的模块划分让咱们在设计 PingCode Flow 部署形式的时候有了更好的抉择,更容易的达成稳定性的要求。
具体来说,之前介绍的五个接管模块和规定的执行模块(Flow Engine),自身的业务逻辑都是无状态的,因而都能够反对独立的横向扩大。



上图中的箭头,示意调用关系由各个触发模块发动,按需启动 Flow Engine 的规定。咱们最后是设计是应用根底框架的 RPC 性能来实现。即当有一个事件产生时,譬如用户批改了工作项的状态,那么「PingCode 子产品」这个触发模块会通过 RPC(HTTP 或 TCP 申请)同步调用 Flow Engine 的接口,启动相应的规定。
然而 PingCode Flow 和其它的 PingCode 子产品有所不同。PingCode Flow 的执行频率和履行工夫是基于客户定义的规定,由 PingCode 零碎内以及各种内部零碎的操作和事件驱动的,一旦启动申请量和执行量会十分大。因而就要求位于后端的 Flow Engine 有足够的弹性,可能安稳的执行每一条规定,缓冲短时间的大量操作。因而,间接应用 RPC 的计划最终在架构评审会中被否定了。
既然架构指标是须要 PingCode Flow 零碎可能在高峰期爱护后端的 Engine 模块,因而,咱们决定在调用层和理论执行层之间应用了音讯队列。




通过音讯队列,所有规定的执行申请会被退出队列中,而后由多个侦听队列的 Flow Engine 实例进行读取和解决。这样的益处是,首先,一旦呈现短时间执行量过大的状况,执行申请会被缓冲在音讯队列中,不会对 Flow Engine 造成冲击。其次,调用方和执行方齐全通过数据进行交互,二者之间彻底的解耦。最初,在操作量有稳定的时候,咱们能够将新的 Flow Engine 接入音讯队列来实现扩容,无需额定的配置 IP、端口号、申请转发和负载平衡等信息。
最终,咱们 PingCode Flow 的整体架构如下所示。




写在最初:正当的架构是一直演进进去的

在与咱们 PingCode 的客户进行沟通的时候,常常会被问及的一个问题是,研发团队是如何得出一个好的架构设计的?既满足了将来扩大的须要,同时防止适度的设计。对此,我集体的观点是,世界上就没有所谓好的设计,只有正当的设计。而一个正当的设计不是出自于某个架构师的空想,而要基于现有的业务需要和可预感的场景,逐渐发现的。
尤其是在麻利开发的大场景下,咱们每一个迭代都是为了实现可能体现客户价值的一个一个用户故事。因而,架构设计也不是欲速不达,而是要在每一迭代中一直的思考、设计、实际、反馈和批改,最终失去一个以后看来最为正当的答案。

正文完
 0