PingCode Flow技術架構揭祕

PingCode研發中心發表於2022-05-07
作者:PingCode 研發 VP@徐子巖

本文是PingCode Flow系列文章的第四篇,不出意外的話,也是最後一篇。從去年五月份PingCode Flow正式上線,就在腦海中構思好了四篇文件的順序和大致內容,從介紹研發自動化的現狀、痛點和解決方案,到展示如何使用PingCode Flow來實現研發的自動化。而這最後一篇,則是希望能夠從純技術角度展示PingCode Flow內部是如何工作的,如何在每天將近4000次規則執行的壓力下,保障99%的規則都能在1秒內完成,同時支援順序、並行、判斷、迴圈等複雜的執行邏輯。

同時,我們也希望在這篇文章中分享一下我們是如何分析和思考,並最終得出現在的架構。因此本文不會直接展示最終的設計結果,而是會闡述我們為什麼要如此的設計,優缺點的權衡,以及開發過程中的重構。

PingCode Flow系列文章傳送門

PingCode Flow的本質是什麼

在前幾篇文章中我們提到,PingCode Flow是一款研發自動化工具。所謂的自動化,就是指在某個事件發生後,按照預定義的流程去完成一系列的操作。所以,本質上來講,PingCode Flow是一個TAP(Trigger Action Platform)系統。它由一個觸發器和多個動作組成一個有序的執行規則,然後按照這個規則順序執行。

image.png

因此,PingCode Flow在技術架構設計的時候,就是要確保這樣的流程能夠順暢的執行。

資料結構:怎麼定義一個規則

確定了產品的核心目標後,第一件事就是要明確資料是如何定義的。按照上面的圖示,在一個團隊中使用者可能會定義多個規則,而每個規則都包含了一個觸發器和多個後續的動作。基於這個簡單的需求,可以將規則的資料結構定義如下。

image.png

這樣,一個規則就包含了一個觸發器以及它所包含的動作。而動作的序號決定了他們執行的先後順序。這樣的設計看來基本滿足了目前的產品需求。
但是我們知道,現在的PingCode Flow不僅僅支援上述的單線順序執行流程,還支援條件、並行、判斷、迴圈等負責的執行流程。而且不止於此,我們需要將上述這些流程自由組合,譬如並行內部有判斷,判斷裡面有迴圈,迴圈中還有並行……通過這樣幾乎無限的組合,讓一個規則實現幾乎任意的流轉。

image.png

因此,上述簡單的資料結構就完全不能滿足需求。而如何設計一個能夠支援各種場景的規則,是擺在我們PingCode Flow團隊面前的第一個難題。
如果我們以「一個規則就是一系列動作的集合」這個方式去考慮,那麼很難設計出相對通用的資料結構。因為規則內的動作是由使用者決定的,不可能窮舉出所有可能的結構出來。但是可以嘗試換一種思路來考慮這個問題,也就是說,我們不再把規則看做是觸發器和動作的有序列表,而是將他們定義為一個連結串列。那麼一個規則就是

  • 觸發器及下一個動作
  • 當前動作及下一個動作
    的集合。
    假如我們再將「觸發器」和「動作」合併為「步驟」。那麼一個規則就是
  • 第一個步驟
  • 當前步驟的下一個步驟
    這樣,我們對於規則和步驟的定義就可以統一為
    
    image.png
    
    即規則並不關心它內部的動作都是什麼以及先後順序,它只關心第一個動作是什麼。而每一個動作也僅僅關心下一個動作是什麼。
    對於並行、迴圈、判斷等複雜的流程,我們只需要擴充套件對應動作的資料結構,就可以實現不同的排列組合。譬如對於並行,它的資料結構是這樣的。
    
    image.png
    
    「並行」內部的每個分支的第一個步驟ID儲存在一個陣列中,表示這個分支要執行的第一個步驟是什麼。「並行」本身不關心每個分支裡面具體的流程是什麼樣的。它只關心當所有分支都執行完畢後,下一個步驟是什麼。
    基於這樣的結構,對於上述這個複雜的規則

image.png

我們的資料大致是這樣的。對於 步驟1 ,它只設定了下一個步驟是 步驟2
image.png

對於 步驟2 ,它內部有兩個分支 步驟3 步驟4 ,下一個步驟是整個分支全部執行完畢的 步驟10 。因此它的資料是這樣的。
image.png

對於 步驟4 ,它是一個迴圈步驟。進入迴圈體的第一個步驟是 步驟6 ,而它完成迴圈後就會結束當前的分支操作。因此它的資料是這樣的。
image.png
 下一個步驟ID 為空,表示當前分支結束。

執行邏輯:如何支援各種型別的步驟

當我們確定了資料結構之後,規則內步驟的執行方式也隨之確定了。和結構類似,當一個規則被啟動後(我們先不考慮規則是如何被觸發的),它會首先找到第一個動作的ID。熟悉PingCode Flow的讀者們都知道,我們系統內預置了很多的動作,譬如設定工作項負責人、建立頁面、變更測試用例狀態等等。那麼這些動作是怎麼被執行起來的呢。
首先,每一個動作都會有一個全域性唯一的名稱。當規則執行到這個步驟的時候,我們會通過步驟的ID找到它的動作名。通過動作的名稱定位程式碼中對應的實際執行邏輯。

image.png

譬如「設定工作項負責人」這個動作,它的聯結器名稱是 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呼叫啟動
    
    image.png
    
    由上圖可以看出,對於一個規則來說,它並不需要關心自己是由什麼途徑觸發的。它只需要知道在某個時刻,有一個規則需要執行。因此,我們為這個執行規則的部分單獨分離了一個模組,即「Flow Engine」,它的職責很簡單,就是「啟動某個規則」。
    而對於負責接收規則啟動請求的模組,它們有一個通用的職責,就是按自己的需求去通知Flow Engine啟動規則。上圖中五個觸發規則的模組,各自的職責如下:
    
    image.png
    
    通過這樣的拆分,就可以將規則的執行和規則的觸發完全隔離開。在PingCode Flow開發初期,我們僅支援從PingCode其它子產品觸發的規則。但是隨著產品功能的不斷增強,我們陸續實現了第三方產品(GitHub、GitLab、Jenkins等)的接入,即時規則(手動觸發)和定時規則(定時觸發)。而這些新的觸發方式完全不影響之前其它的模組,因此最大限度的保證了產品的質量。

部署方式:讓所有節點都支援橫向擴充套件

企業級SaaS產品的核心訴求就是資料的安全性和服務的穩定性。對於穩定性,一方面要求我們的產出物(即程式碼)質量很高,另一方是要求我們的服務在各個環節都能支援橫向擴充套件,不會出現因請求量和執行量增加的情況下導致的系統效能問題和穩定性問題。單一職責的模組劃分讓我們在設計PingCode Flow部署方式的時候有了更好的選擇,更容易的達成穩定性的要求。
具體來說,之前介紹的五個接收模組和規則的執行模組(Flow Engine),本身的業務邏輯都是無狀態的,因此都可以支援獨立的橫向擴充套件。

image.png

上圖中的箭頭,表示呼叫關係由各個觸發模組發起,按需啟動Flow Engine的規則。我們最初是設計是使用基礎框架的RPC功能來實現。即當有一個事件發生時,譬如使用者修改了工作項的狀態,那麼「PingCode子產品」這個觸發模組會通過RPC(HTTP或TCP請求)同步呼叫Flow Engine的介面,啟動相應的規則。
但是PingCode Flow和其它的PingCode子產品有所不同。PingCode Flow的執行頻率和實行時間是基於客戶定義的規則,由PingCode系統內以及各種外部系統的操作和事件驅動的,一旦啟動請求量和執行量會非常大。因此就要求位於後端的Flow Engine有足夠的彈性,能夠平穩的執行每一條規則,緩衝短時間的大量操作。因此,直接使用RPC的方案最終在架構評審會中被否定了。
既然架構目標是需要PingCode Flow系統能夠在高峰期保護後端的Engine模組,因此,我們決定在呼叫層和實際執行層之間使用了訊息佇列。
image.png



通過訊息佇列,所有規則的執行請求會被加入佇列中,然後由多個偵聽佇列的Flow Engine例項進行讀取和處理。這樣的好處是,首先,一旦出現短時間執行量過大的情況,執行請求會被緩衝在訊息佇列中,不會對Flow Engine造成衝擊。其次,呼叫方和執行方完全通過資料進行互動,二者之間徹底的解耦。最後,在操作量有波動的時候,我們可以將新的Flow Engine接入訊息佇列來完成擴容,無需額外的配置IP、埠號、請求轉發和負載均衡等資訊。
最終,我們PingCode Flow的整體架構如下所示。

image.png


寫在最後:合理的架構是不斷演進出來的

在與我們PingCode的客戶進行溝通的時候,經常會被問及的一個問題是,研發團隊是如何得出一個好的架構設計的?既滿足了未來擴充套件的需要,同時避免過度的設計。對此,我個人的觀點是,世界上就沒有所謂好的設計,只有合理的設計。而一個合理的設計不是出自於某個架構師的空想,而要基於現有的業務需求和可預見的場景,逐步發現的。
尤其是在敏捷開發的大場景下,我們每一個迭代都是為了完成能夠體現客戶價值的一個一個使用者故事。因此,架構設計也不是一蹴而就,而是要在每一迭代中不斷的思考、設計、實踐、反饋和修改,最終得到一個當前看來最為合理的答案。

相關文章