SpiffWorkflow 是流程引擎,這裡的「流程」有點含糊,在談 SpiffWorkflow 前,先來說說它的流程是哪種流程。

流程

流程的英文可以是 process 或 workflow,或者有時候也常常用更簡化的形式「flow」表示,這些詞彙混用的狀況時常有之,我們也都概稱為流程,對於字面上的意思或許可以不深究,但對於應用面可就要計較一番了,對於流程,比較常見到的有:

  • UI flow,這是指用戶的操作流程,通常在 UX 領域可見,典型的應用是 Penpot、Figma 這類設計應用。
  • Data flow,這是指資料流程,目前比較常用於表示大數據的 ETL 流程。
  • Business process,這是指商業流程,也是用戶最能具體感受到的流程,包括人事、總務、採購、業務等各類申辦流程,這類產品又稱為 BPM(business process management)。

其中後兩者比較容易搞混,如果你想搭建的是申辦、簽核系統,那就應該找 business process 方案,如果你想搭建的是資料流處理系統,那就應該找 data flow 方案。

對於 data flow,比較著名的工具有 Apache Airflow,它是以 DAG 為概念的資料流程執行工具,它的設計模式決定了它的使用姿勢,它當然可以有分支也當然可以有人為決策,只是相較於專門的 business process 方案,要跑平行簽核、多重指派,在 Airflow 要實現這些得花更多力氣罷了,就像你不會在 PowerPoint 寫文件,也不會在 Word 寫程式一樣,不是不能,只是不適合。

而 SpiffWorkflow 就是專門針對 business process 的流程引擎,同類型的引擎或產品有這些:

上面這些是國內的,還有很多鼎新什麼的,就不幫它們打廣告了。這些國內的產品都是完整的 BPM 解決方案,有流程引擎、有表單設計、有人員組織、有統計圖表等等,也有面向開發端的 API。

國外的則有:

國外產品比較偏向終端用戶的大概有 Cammda、ProcessMaker、馳騁 BPM、Flowable,其它則是比較面向開發者,包括本文的主角 SpiffWorkflow。

BPMN

上面這些 BPM 的共同點是它們都以 BPMN 為基礎,BPMN 全稱 Business Process Model and Notation,是專門用來表示商業流程的一種圖,相較於一般的流程圖或 DAG,BPMN 有更豐富的符號來表示一段商業流程,下面是一張核彈發射流程的 BPMN:

Nuclear Strike Workflow

在 BPMN 的符號中:

  • 圓圈表示 event,這裡有 Start event 和 End event。
  • 菱形表示 gateway,簡單理解就是叉路,將軍可以決定要不要射,總統也可以決定要不要射。
  • 圓角方框表示 task,就是具體要做的事情。

雖然任何一款繪圖應用都可以畫出這樣的圖,但要讓程式使用,就必須用標準組織制定的機讀格式製作 BPMN,目前 BPMN 的機讀格式標準由 OMG 機構維護,本質上它是一種 XML 文件,前面那些國外的 BPM 大多可以讀入標準的 BPMN 檔案,而國內的 BPM 大多是玩自己的一套,這也沒什麼不好,只要能跑流程就好。

對於 BPMN 的認識,簡單帶過就好,因為下面 SpiffWorkflow 我們會跳過 BPMN 的部份 :p。

SpiffWorkflow

SpiffWorkflow 是 business process 的執行引擎,具體一點說,它本質上是狀態機與規則引擎的綜合體,流程制定好後,SpiffWorkflow 負責告訴我們當前該流程內所有工項的狀態,有的可能是 COMPLETED,有的可能是 READY,有的可能是 MAYBE、FUTURE 等等,隨著流程的推進,每個工項的狀態也會隨之變更,這也是 SpiffWorkflow 的主要價值所在,試想如果要自行判斷複雜流程的狀態,我們要寫多少規則才可能實現?

以上面的射核彈流程為例:

Nuclear Strike Workflow

SpiffWorkflow 可以讀入像這樣的 BPMN 檔案,並作為 workflow spec 之用,在 SpiffWorkflow 的設計裡:

  • 制定的流程稱為 workflow spec
  • 真正執行的流程稱為 workflow instance

一份 spec 之下會有許多的 instance,核彈一號的與核彈二號有各自的 workflow instance,也有各自的流程走向與結局,但它們的 spec 是相同的,都是將軍先決定,總統再決定。

制定 Workflow Spec

前面提到,SpiffWorkflow 可以讀入 BPMN 檔案並解析成 workflow spec,但這裡我們不這麼做,我們玩硬派的,手刻 spec,程式碼如下:

from SpiffWorkflow.specs.WorkflowSpec import WorkflowSpec
from SpiffWorkflow.specs.ExclusiveChoice import ExclusiveChoice
from SpiffWorkflow.specs.Simple import Simple
from SpiffWorkflow.specs.Cancel import Cancel

from SpiffWorkflow.operators import Equal, Attrib

def my_nuclear_strike(msg):
    print("Launched:", msg)

class NuclearStrikeWorkflowSpec(WorkflowSpec):
    def __init__(self):
        super().__init__()

        # The first step of our workflow is to let the general confirm
        # the nuclear strike.
        general_choice = ExclusiveChoice(wf_spec=self, name='general')
        self.start.connect(taskspec=general_choice)

        # The default choice of the general is to abort.
        cancel = Cancel(wf_spec=self, name='workflow_aborted')
        general_choice.connect(task_spec=cancel)

        # Otherwise, we will ask the president to confirm.
        president_choice = ExclusiveChoice(wf_spec=self, name='president')
        cond = Equal(Attrib(name='confirmation'), 'yes')
        general_choice.connect_if(condition=cond, task_spec=president_choice)

        # The default choice of the president is to abort.
        president_choice.connect(task_spec=cancel)

        # Otherwise, we will perform the nuclear strike.
        strike = Simple(wf_spec=self, name='nuclear_strike')
        president_choice.connect_if(condition=cond, task_spec=strike)

        # Now we connect our Python function to the Task named 'nuclear_strike'
        strike.completed_event.connect(callback=my_nuclear_strike)

        # As soon as all tasks are either "completed" or  "aborted", the
        # workflow implicitely ends.

可以看到,SpiffWorkflow.specs 模組下有許多用於定義 spec 的 class,這邊我們用到其中三個:

  • Simple:表示最單純的 task。
  • ExclusiveChoice:表示單選的 gateway。
  • Cancel:表示 end,即取消發射。

其中 ExclusiveChoice 預設是走向 Cancel,除非將軍與總統都把 confirmation 設為 yes,這部份用到 SpiffWorkflow.operators 的運算子 class EqualAttrib,整段流程不複雜,多看兩下應該可以參透,至於那 confirmation 要怎麼設、流程具體怎麼跑,讓我們看下去。

建立與操控 Workflow Instance

建立 workflow instance:

from SpiffWorkflow.workflow import Workflow
from SpiffWorkflow.task import Task, TaskState

workflow_instance = Workflow(workflow_spec=NuclearStrikeWorkflowSpec())

Instance 建立之後,就可以開始把玩了,在跑流程之前,先玩轉 instance 的一些方法。

Dump

workflow_instance.dump()

輸出如下:

358155a6-4776-46f2-aed2-cae4b18d6391/0: Task of Root State: COMPLETED Children: 1
  a18df1d8-c5a2-4bd0-832b-39b9d38a46f3/0: Task of Start State: READY Children: 1
    4d0eb169-15eb-42f5-a895-359590b5ce69/0: Task of general State: FUTURE Children: 2
      32726718-f032-417c-8325-bdc2db2f878d/0: Task of workflow_aborted State: LIKELY Children: 0
      41f2cb59-5d15-4735-b0af-fb42d75a1e0c/0: Task of president State: MAYBE Children: 2
        821401fd-a55d-45a4-8131-fa6af712b6d9/0: Task of workflow_aborted State: MAYBE Children: 0
        150966e3-05d2-4463-9209-d6506fa8f834/0: Task of nuclear_strike State: MAYBE Children: 0

dump() 即把流程的狀態以父子階層的格式秀出來,這裡 SpiffWorkflow 把 event、gateway、task 都以 task 概稱之,一筆 task 有以下欄位:

  • 最前面是它的 UUID,每個 spec 的 instance 的 task 的 UUID 都是獨立的。
  • 中間是 task 名。
  • 後面接 task 狀態,COMPLETED、READY 等等。
  • 最後為旗下子 task 的數量。

Get tasks

workflow_instance.get_tasks()

輸出如下:

[<Task object (Root) in state COMPLETED at 0x7f9405f4f9d0>,
 <Task object (Start) in state READY at 0x7f9405f4d270>,
 <Task object (general) in state FUTURE at 0x7f9405f4cdc0>,
 <Task object (workflow_aborted) in state LIKELY at 0x7f9405f4e350>,
 <Task object (president) in state MAYBE at 0x7f9405f4d2d0>,
 <Task object (workflow_aborted) in state MAYBE at 0x7f9405f4fa30>,
 <Task object (nuclear_strike) in state MAYBE at 0x7f9405f4e3b0>]

get_tasks() 也是展示當前狀態,以 Python 原生 Task list 的形式表現。


Get tasks from spec name

workflow_instance.get_tasks_from_spec_name(name='general')

輸出如下:

[<Task object (general) in state FUTURE at 0x7f9405f4cdc0>]

以 task 名稱取得 task 物件,注意,這裡拿到的是 task list。

拿到 Task 物件後,也有一些方法與屬性。

Task 方法與屬性

以將軍的發射決策 task 為例:

general_task: Task = workflow_instance.get_tasks_from_spec_name(name='general')[0]

ID

general_task.id

輸出如下:

UUID('4d0eb169-15eb-42f5-a895-359590b5ce69')

狀態

general_task.get_state_name()

輸出如下:

'FUTURE'

對於 task 狀態,SpiffWorkflow 有以下幾種:

  • MAYBELIKELY,這類屬於預期(PREDICTED)會發生的。
  • FUTUREWAITINGREADYSTARTED,這類屬於確定(DEFINITE)會發生。
  • COMPLETEDERRORCANCELLED,這類屬於結束(FINISHED)的。

隨著流程的推進,前面的決策會影響後面工項的狀態,這裡體現了 SpiffWorkflow 作為狀態機的特性。

雖然狀態這麼多,但最重要的是 READY,它表示接著要執行的 task,也是我們在使用 SpiffWorkflow 流程引擎時主要關注的狀態。

至於每種狀態的意義,除了望文生義外,也可以參見〈SpiffWorkflow Concepts〉。


資料

前面提到,將軍與總統都要做出決斷,把 confirmation 設為 yes 才可發射核彈,而這 confirmation 就會以 task data 的形式存在,做好決斷,推進流程,範例如下:

# 跑前面的流程

general_task.set_data(confirmation='yes') # 做決斷
workflow_instance.run_task_from_id(task_id=general_task.id) # 跑流程

這樣決斷一波下來,總統的決斷 task 就會變成 READY,反之,若是不設定 confirmation 為 yes,那就變成 cancel task 是 READY 囉,總統就變成 CANCELLED。

認識完 instance 與 task,來重頭跑一次流程吧!

跑流程

建立 workflow instance,檢視初始狀態:

workflow_instance = Workflow(workflow_spec=NuclearStrikeWorkflowSpec())
workflow_instance.dump()
d6ccb20e-69d9-4a14-9d01-4563a0addc2f/0: Task of Root State: COMPLETED Children: 1
  ebd7018d-d190-4a3b-a4c1-02877006b77e/0: Task of Start State: READY Children: 1
    b6917bf4-9efa-4f86-8025-2edbc5f766f8/0: Task of general State: FUTURE Children: 2
      02a4cb8d-994b-4892-8ef5-53af2791b2e2/0: Task of workflow_aborted State: LIKELY Children: 0
      c38a78d5-c24b-4123-a00c-772f76303134/0: Task of president State: MAYBE Children: 2
        1949bc90-309e-404f-94fd-332693c1aa91/0: Task of workflow_aborted State: MAYBE Children: 0
        8c518cb2-637d-4db6-85be-867039d06481/0: Task of nuclear_strike State: MAYBE Children: 0

那個 Root 和 Start task 是 SpiffWorkflow 預設的,來跑 Start task:

start_tasks: list[Task] = workflow_instance.get_tasks_from_spec_name(name='Start')
for task in start_tasks:
   if task.state == TaskState.READY:
       workflow_instance.run_task_from_id(task_id=task.id)
workflow_instance.dump()
d6ccb20e-69d9-4a14-9d01-4563a0addc2f/0: Task of Root State: COMPLETED Children: 1
 ebd7018d-d190-4a3b-a4c1-02877006b77e/0: Task of Start State: COMPLETED Children: 1
   b6917bf4-9efa-4f86-8025-2edbc5f766f8/0: Task of general State: READY Children: 2
     02a4cb8d-994b-4892-8ef5-53af2791b2e2/0: Task of workflow_aborted State: LIKELY Children: 0
     c38a78d5-c24b-4123-a00c-772f76303134/0: Task of president State: MAYBE Children: 2
       1949bc90-309e-404f-94fd-332693c1aa91/0: Task of workflow_aborted State: MAYBE Children: 0
       8c518cb2-637d-4db6-85be-867039d06481/0: Task of nuclear_strike State: MAYBE Children: 0

可以看到,Start task 變成 COMPLETED、general task 變成 READY。

接著跑 general task,讓他射:

general_tasks: list[Task] = workflow_instance.get_tasks_from_spec_name(name='general')
for task in general_tasks:
    if task.state == TaskState.READY:
        task.set_data(confirmation='yes')
        workflow_instance.run_task_from_id(task_id=task.id)
workflow_instance.dump()
d6ccb20e-69d9-4a14-9d01-4563a0addc2f/0: Task of Root State: COMPLETED Children: 1
  ebd7018d-d190-4a3b-a4c1-02877006b77e/0: Task of Start State: COMPLETED Children: 1
    b6917bf4-9efa-4f86-8025-2edbc5f766f8/0: Task of general State: COMPLETED Children: 1
      c38a78d5-c24b-4123-a00c-772f76303134/0: Task of president State: READY Children: 2
        1949bc90-309e-404f-94fd-332693c1aa91/0: Task of workflow_aborted State: LIKELY Children: 0
        5541d692-1c42-4872-a8d6-22fba2491761/0: Task of nuclear_strike State: MAYBE Children: 0

現在 president task 變成 READY,繼續讓總統射:

president_tasks: list[Task] = workflow_instance.get_tasks_from_spec_name(name='president')
for task in president_tasks:
    if task.state == TaskState.READY:
        task.set_data(confirmation='yes')
        workflow_instance.run_task_from_id(task_id=task.id)
workflow_instance.dump()
d6ccb20e-69d9-4a14-9d01-4563a0addc2f/0: Task of Root State: COMPLETED Children: 1
  ebd7018d-d190-4a3b-a4c1-02877006b77e/0: Task of Start State: COMPLETED Children: 1
    b6917bf4-9efa-4f86-8025-2edbc5f766f8/0: Task of general State: COMPLETED Children: 1
      c38a78d5-c24b-4123-a00c-772f76303134/0: Task of president State: COMPLETED Children: 1
        5541d692-1c42-4872-a8d6-22fba2491761/0: Task of nuclear_strike State: READY Children: 0

現在都確認了,核彈進入戰術位置,射:

strike_tasks: list[Task] = workflow_instance.get_tasks_from_spec_name(name='nuclear_strike')
for task in strike_tasks:
    if task.state == TaskState.READY:
        workflow_instance.run_task_from_id(task_id=task.id)
workflow_instance.dump()
Launched: <SpiffWorkflow.workflow.Workflow object at 0x7fb0b09e30d0>
d6ccb20e-69d9-4a14-9d01-4563a0addc2f/0: Task of Root State: COMPLETED Children: 1
  ebd7018d-d190-4a3b-a4c1-02877006b77e/0: Task of Start State: COMPLETED Children: 1
    b6917bf4-9efa-4f86-8025-2edbc5f766f8/0: Task of general State: COMPLETED Children: 1
      c38a78d5-c24b-4123-a00c-772f76303134/0: Task of president State: COMPLETED Children: 1
        5541d692-1c42-4872-a8d6-22fba2491761/0: Task of nuclear_strike State: COMPLETED Children: 0

在這個例子中,將軍、總統的決斷都是直接設定就跑了,當然在更實際的應用中,SpiffWorkflow 作為流程引擎,還需要外部的程式與前端配合,才能真正讓用戶去輸入他的決斷,這部份就取決於各自的應用看要怎麼發揮了。

另外 SpiffWorkflow 欠缺一些完整的商業 BPM 產品的特性,例如權限(哪個節點歸誰簽)、代簽、會簽、組織、組織主管等,所以它真的只是流程引擎,適合應用的場景是自有專案又需要內含稍微複雜的商業流程,而不適合拿它來搭建一套請假系統、出差申請系統、請購系統等等,這些往往都牽涉到完整的組織與主管,遇到這類需求還是找商業化的 BPM 產品吧。