流程实例如何沿着箭头走到结束:Rust 工作流引擎的执行内核

一、点了"同意",然后呢?

报销审批页,领导点了"同意"。界面上,这条待办消失了,隔壁同事的待办里多了个新任务。

但在这两次刷新之间,后端到底发生了什么?流程是从"开始"节点一路自动跑到"结束"吗?还是引擎其实根本没跑完,只是停在了下一个该由人处理的节点上?

这篇回答的就是这个问题。不是讲流程定义怎么写(那是 JSON 的事),也不是讲 DDD 模型怎么设计(那是聚合根的事),而是讲运行时------一次提交进来之后,引擎是怎么沿着流程图的箭头,把实例往前推一步的。

用 Rust 引擎的 jeeflow-core 来讲。理由很实在:Rust 的实现最"裸",没有框架帮你兜底,递归、状态、持久化的每一步都得自己写出来,所以"引擎到底怎么走的"这件事,在它身上看得最清楚。

先立一个反直觉的结论,后面全篇都是为它服务的:

一次提交不会把流程从头跑到尾。引擎会沿着箭头一路走,走到第一个"要等人"的节点就停下来。所谓"走到结束",是多次提交接力跑完的,不是一口气冲完的。


二、流程图进了内存,是一张图

引擎拿到流程定义(那份 LogicFlow JSON)之后,先把它解析成一个内存里的图。Rust 里就是 ProcessModel 这个结构,核心就两堆东西:

rust 复制代码
// jeeflow-core/src/parser.rs
pub struct ProcessModel {
    pub name: String,
    pub display_name: String,
    pub model_type: String,
    pub nodes: Vec<NodeModel>,   // 所有节点
    pub edges: Vec<EdgeModel>,   // 所有箭头(边)
    // ...
}

NodeModel 有八种类型,一眼能看出流程图里那些方框分别是什么:

rust 复制代码
pub enum NodeType {
    Start,      // 开始
    Task,       // 任务(要人处理)
    Decision,   // 分支(菱形)
    Fork,       // 并行拆分
    Join,       // 并行汇聚
    End,        // 结束
    Custom,     // 自定义
    SubProcess, // 子流程
}

引擎要"沿箭头走",靠的就是两个图查询方法------这就是"箭头"在代码里的全部体现:

rust 复制代码
// 从某节点出发,找出所有指向外部的箭头
pub fn get_output_edges(&self, node_id: &str) -> Vec<&EdgeModel> {
    self.edges.iter().filter(|e| e.source_node_id == node_id).collect()
}

// 顺着一条箭头,找到它指向的下一个节点
pub fn get_target_node(&self, edge: &EdgeModel) -> Option<&NodeModel> {
    self.get_node(&edge.target_node_id)
}

"沿着箭头走到结束"这句话,翻译成代码就是:反复问"我现在这个节点,有哪些出箭头?每条箭头指向谁?",然后去走指向的那个节点。 就这么朴素。复杂的是------走到不同类型节点时,引擎的应对完全不一样。这就引出全篇的主角。

后面几节反复出现的报销流程长这样(03-decision-expr.json,15 个共享流程之一):


三、引擎的心脏:一个递归的 execute_node

整个执行内核,就一个递归函数。它接收"执行上下文 + 当前节点",根据节点类型决定下一步,然后递归调用自己去处理下一个节点

rust 复制代码
// jeeflow-core/src/engine.rs
fn execute_node(&self, exec: &mut Execution, node: &NodeModel) -> JeeflowResult<()> {
    exec.current_node = Some(node.clone());

    match node.node_type {
        NodeType::Start => { /* ... */ }
        NodeType::Task | NodeType::Custom => { /* ... */ }
        NodeType::Decision => { /* ... */ }
        NodeType::Fork => { /* ... */ }
        NodeType::Join => { /* ... */ }
        NodeType::End => { /* ... */ }
        NodeType::SubProcess => { /* ... */ }
    }
    Ok(())
}

一个 match 收掉了六种节点。逐个看,你会发现"沿箭头走"这件事在每种节点上是不同的节奏:

左半边就是上面那段代码,右半边是六种节点在递归里的待遇------蓝色"走"的节点都是过路客 (继续递归),橙色"停"的 Task 是唯一的例外:建完任务就返回,等下一次提交。

开始节点------没有任何停留,直接沿所有出箭头继续走:

rust 复制代码
NodeType::Start => {
    let next_nodes: Vec<NodeModel> = exec.process_model.get_output_edges(&node.id)
        .iter()
        .filter_map(|e| exec.process_model.get_target_node(e).cloned())
        .collect();
    for next in next_nodes {
        self.execute_node(exec, &next)?;   // 递归
    }
}

任务节点 ------这是关键分界。引擎走到这里,不递归了 ,它只干一件事:建一个待办任务,然后

rust 复制代码
NodeType::Task | NodeType::Custom => {
    // 解析这个节点的审批人,建任务。注意:没有 self.execute_node(...)
    self.create_task_with_assignment(exec, node)?;
}

看清楚了------任务节点这一支,代码里没有对 execute_node 的递归调用 。引擎走到任务节点,建完任务就返回了,实例状态停在"进行中",等着有人来办这个任务。这就是开头那个反直觉结论的出处:引擎不会替你点"同意"。

结束节点------递归的终点,在这里给实例盖章:

rust 复制代码
NodeType::End => {
    // 读 reject 变量,决定是"通过"还是"驳回"
    let has_reject_var = exec.args.get_str("reject").map(|v| v == "true").unwrap_or(false);
    if has_reject_var {
        exec.process_instance.reject();
    } else {
        exec.process_instance.finish();
    }
    exec.instance_finished = true;
    self.repo().update_instance(&exec.process_instance)?;   // 落库
    // 发 ProcessInstanceEnd 事件
}

所以一次 execute_node 从开始节点出发的完整轨迹是:开始 → 沿箭头 → 遇到第一个任务节点 → 建任务 → 停。 它压根没机会走到结束节点------因为中间的每个任务节点都会截断递归。

那怎么走到结束?往下看。


四、走到结束,是多次接力

一次提交跑不到头,那"走到结束"到底怎么发生的?答案是接力 :每次有人办完一个任务,引擎就从这个任务所在的节点重新出发,再沿箭头走一段,再停在下一个任务节点。

这是 execute_task_async 的核心(一次任务提交进来走的方法)。摘掉权限、会签那些分支,主干是这样:

rust 复制代码
// jeeflow-core/src/engine.rs · execute_task_async 主干
// 1. 从库里把聚合根"水合"出来:实例 + 它名下的所有任务
let mut instance = self.repo().find_instance_by_id(task.process_instance_id)?...;
instance.tasks = self.repo().find_history_tasks(instance.instance_id)?;

// 2. 在内存里完成这个任务(改状态、并变量)
instance.complete_task(task_id, operator, &full_args)...?;

// 3. 找到这个任务对应的节点,从它继续走
let node = model.get_node(&task.task_name).cloned();
let mut exec = Execution::new(instance, model, define, operator, full_args);

// 4. 沿该节点的所有出箭头,递归往下走(又是那个 execute_node)
if let Some(node) = node {
    let next_nodes: Vec<NodeModel> = exec.process_model.get_output_edges(&node.id)
        .iter()
        .filter_map(|e| exec.process_model.get_target_node(e).cloned())
        .collect();
    for next in next_nodes {
        self.execute_node(&mut exec, &next)?;
    }
}

// 5. 这一步新产生的任务 + 实例状态,统一落库
self.persist_tasks(&exec.process_instance, &mut exec.new_tasks)?;
self.repo().update_instance(&exec.process_instance)?;

注意第 4 步:它从"被完成的那个任务节点"出发,再走一遍那个递归 execute_node。所以整条流程的生命周期,就是这个"办一个任务 → 从该节点再走一段 → 停在下一任务"的循环 ,一直循环到某次 execute_node 真的走到了 End 分支、把实例 finish() 掉为止。

拿第二节的报销流程(03-decision-expr.json)走一遍,假设提交的金额是 1500(走 amount > 1000 那条边):

  • 发起时startAndExecute):引擎从 start 走到 apply(一个 assignee=applicant 的任务节点),建了个"发起申请"任务,停。但因为是发起,facade 层会立刻自动把它办掉(下面讲 applicant 约定),于是接着走到 task1,建"填写报销单"任务给领导,停。第一次调用,实例停在 task1。
  • 领导提交报销单execute_task_async 从 task1 出发,走到 decision1,算表达式选一条边,走到 task2 或 task3,建任务给经理/总监,停。第二次调用,实例停在 task2 或 task3。
  • 经理/总监点"同意" :从 task2/task3 出发,走到 end,实例 finish(),状态变 20(已完成)。第三次调用,流程走完。

三次提交,三次接力,实例才真的"沿着箭头走到了结束"。所谓"一条流程跑完",在引擎视角就是这三段递归的拼接。

每一行是一次 API 调用里递归走过的路径:蓝线是"走",橙色 pill 是"停在任务节点等人"。三次调用,蓝线一段一段接起来,才从 start 铺到 end------实例状态从 10(进行中)一路保持,直到最后一次真的走到 End 分支才变 20(已完成)。

这里顺带讲一个容易被忽略的约定:applicant 。流程里第一个任务节点(apply)的 assignee 写的是字面量 applicant,意思是"这个人就是发起人本人"。引擎解析审批人时认得出它:

rust 复制代码
// resolve_assignee 里的一支
if assignee == "applicant" {
    return vec![exec.process_instance.operator.clone()];  // 审批人 = 发起人
}

所以发起时,apply 节点的"审批人"就是点"发起"的那个人,facade 的 startAndExecute 会紧接着把这个节点自动办掉------不然流程会永远卡在自己手里。这也是"发起"和"普通任务"在体验上不一样(发起后立刻看到下一步待办)的原因。


五、分支:决策节点怎么选路

走到结束是接力,那接力的方向是谁定的?------分支节点(Decision)。它决定了"沿哪条箭头走"。

一个决策节点通常有多条出箭头,每条箭头带一个条件表达式。引擎的做法很直白:挨个试,谁先成立走谁

rust 复制代码
// execute_node 的 Decision 分支(简化)
NodeType::Decision => {
    let edges = exec.process_model.get_output_edges(&node.id);
    let mut found: Option<EdgeModel> = None;
    for edge in edges {
        let edge_expr = edge.expr();
        let matches = if let Some(ref expr) = edge_expr {
            self.evaluate_expression(expr, exec)   // 算这条箭头的条件
        } else {
            false
        };
        if matches {
            found = Some(edge.clone());
            break;                                 // 第一条成立的边,立刻走
        }
    }
    if let Some(edge) = found {
        if let Some(next) = exec.process_model.get_target_node(&edge) {
            let next = next.clone();
            self.execute_node(exec, &next)?;       // 只走这一条
        }
    }
}

对照第二节的图查询:决策节点也是 get_output_edges 拿所有出箭头,但只递归进第一条表达式为真的那条------其他箭头这次不走。这就是一张菱形分支图在代码里的样子。

表达式怎么算?引擎默认内置一个极简求值器,能处理变量引用 + 比较运算符;更复杂的表达式可以插一个 IExpressionEvaluator SPI 进去接管:

rust 复制代码
// 变量引用:${amount} 和 #amount 两种写法都会从变量里取值
fn resolve_var_refs(&self, expr: &str, exec: &Execution) -> String {
    // ${var} → 取 exec.args 里的值,取不到补 "0"
    // #var   → 先查会签门变量,再查 args
    // ...
}

// 比较:先当数字比,比不了再当字符串比
fn compare_values(&self, left: &str, right: &str) -> Option<std::cmp::Ordering> {
    if let (Ok(l), Ok(r)) = (left.parse::<f64>(), right.parse::<f64>()) {
        return l.partial_cmp(&r);
    }
    Some(left.cmp(right))
}

回到那个报销例子:decision1 有两条出箭头,amount > 1000 指向"经理审批",amount <= 1000 指向"总监审批"。领导提交的报销单里 amount 进了实例变量,决策节点算 amount > 1000 为真,就走上面那条边去建"经理审批"任务;为假就走下面那条。金额决定方向,方向决定下一个谁收到待办------分支的全部意义就在这。


六、换个镜头:Rust 视角下的 DDD

上面讲的是"怎么走"。这一节换到 Rust 的实现细节,看为什么这套走法在 Rust 里是这样写的。三件事,恰好是工作流场景下 DDD 最该有的样子,而 Rust 把它们逼得更彻底。

1. 状态机 = 带数字的枚举,非法状态在类型层面就不存在

实例的状态不是散落的 int,是一个枚举,每个变体直接带上了库里那个状态码:

rust 复制代码
pub enum InstanceState {
    Doing = 10, Finished = 20, Withdraw = 30,
    Interrupt = 40, Reject = 45, Pending = 50, Abandon = 99,
}

状态不靠外部塞数字改,而是聚合根上的命令方法驱动:

rust 复制代码
impl ProcessInstance {
    pub fn finish(&mut self)  { self.state = InstanceState::Finished.code(); }
    pub fn reject(&mut self)  { self.state = InstanceState::Reject.code(); }
    pub fn withdraw(&mut self) { /* 把名下 DOING 任务都撤回,实例转 WITHDRAW */ }
    pub fn complete_task(&mut self, task_id: i64, operator: &str, args: &FlowData) -> Result<(), String> {
        // 找不到任务、或状态非法 → 返回 Err,而不是抛异常
        let task = self.tasks.iter_mut().find(|t| t.task_id == task_id)
            .ok_or_else(|| format!("Task {} not found ...", task_id))?;
        // 把本次提交的 f_ 字段并进实例变量
        for (k, v) in args.iter() { if k.starts_with("f_") { self.variables.insert(k.clone(), v.clone()); } }
        task.finish(operator, args)?;
        Ok(())
    }
}

这就是"充血模型":状态变更是对象自己的方法,规则(哪些能变、怎么变)收在聚合根内部,Result 代替异常把"非法操作"变成可处理的返回值。Rust 比 Java 多给了一层------枚举本身就不存在 InstanceState::Finished 之外的值,你没法把实例置成一个"没定义过的状态"。

2. 所有权:整条执行链在内存里跑,只在边界碰库

回看第三、四节的代码会发现一个一致的结构:

库访问全部挤在左右两端:左边把聚合根水合进内存,右边把结果统一落库;中间整个蓝色虚线区------complete_task、递归 execute_node、建一堆新任务------全在内存里对 ProcessInstance 这个对象操作,一次库都没碰。这正是 DDD 里"聚合根在事务边界内自洽演化"的意思:先把领域逻辑在内存里算完,再一次性持久化。

而 Rust 在这里多交了一份"学费",也是它最诚实地暴露聚合边界的地方。为了绕过借用检查器,代码里出现了好几处刻意的 clone()

rust 复制代码
// execute_node 的 Start 分支
let next_nodes: Vec<NodeModel> = exec.process_model.get_output_edges(&node.id)
    .iter()
    .filter_map(|e| exec.process_model.get_target_node(e).cloned())  // ← clone
    .collect();
for next in next_nodes {
    self.execute_node(exec, &next)?;
}

源码里那行注释写得很直白:// Clone next nodes to avoid borrow conflict。因为 exec 既要被 execute_node 可变借用(往里写新任务、改状态),又要从中不可变借用(读图、读节点),两条借用同时存在,Rust 不允许------于是把"下一步要走的节点"先 clone 出来,断开对 exec 的借用,再递归。所有权规则在这里当了一次"架构审查员":它逼你把"我到底在改谁、在读谁"这件事在类型层面想清楚,聚合的边界因此切得很干净。别的语言不会逼你,但这恰恰是 DDD 建模最容易糊的地方。

3. Execution:穿过整条递归链的值对象

递归要一路带状态往下走,这些状态装在一个结构里,每层递归都收到它:

rust 复制代码
pub struct Execution {
    pub process_instance: ProcessInstance,  // 聚合根(在内存里被改)
    pub process_model: ProcessModel,        // 流程图
    pub current_node: Option<NodeModel>,    // 当前走到哪
    pub new_tasks: Vec<ProcessTask>,        // 这一步新产生的任务
    pub instance_finished: bool,            // 这一步是否走到了 End
    pub gate_vars: FlowData,                // 会签门变量
    // ...
}

一次提交内所有"要落库的东西"都先攒在 exec.new_tasks 里,不在中途逐个插库------这也是"边界才落库"的体现:递归只是把状态算全,落库是回到边界后统一做的。


结语:箭头会走,但流程要等人

把这几节收拢,"流程实例如何沿着箭头走到结束"的完整答案其实是三句话:

  1. 箭头走法是递归的 :一个 execute_node,靠 get_output_edges + get_target_node 反复问"下一站在哪",一个 match 处理六种节点。
  2. 走法节奏是不均匀的 :开始、分支、并行、结束这些节点是"过路"的,一路递归不停;但任务节点是"站",引擎建完任务就停------它不会替你点同意
  3. "走到结束"是接力 :每次有人办完一个任务,引擎就从该节点重新出发再走一段,如此反复,直到某次递归真的走到 End 分支、把实例 finish()

而 Rust 让这套走法额外清楚了一层:状态是带码的枚举、非法状态不可表示;执行链在内存里跑、只在边界落库;所有权规则顺手把聚合边界切干净。同一个执行内核,六种语言各自交出自己的写法,但"沿箭头走 + 到任务就停 + 靠接力走到头"这套行为,一个不差。

下次再在审批页点"同意",你可以对那两次刷新之间的瞬间更有底------那不是流程在后台狂奔,而是引擎沿着箭头稳稳地走到了下一个该等人处理的地方,停下来,等下一次接力。


参考资料

相关推荐
许彰午24 分钟前
22-DataCenter报文序列化
java·低代码·架构·状态模式
zabr2 小时前
Agent少问你,不代表控制更少
架构·aigc·ai编程
闲云野鹤在人间5 小时前
OpenStack架构介绍和安装流程
linux·网络·架构·openstack
这个DBA有点耶5 小时前
同城双活落地的三座山:网络延迟、脑裂预防、反向同步
数据库·架构·dba
我是大AI5 小时前
实战解析:基于多源交叉验证的AI幻觉治理架构与GEO行业解决方
人工智能·架构
yangdaxiageo5 小时前
AI搜索广告的商业化底座:GEO技术架构的三层模型详解
人工智能·架构
代码方舟6 小时前
零信任架构实战:基于天远车辆估值构建自动化二手车评估网关
运维·人工智能·架构·自动化
xiaohaiAIgeo6 小时前
【2026年】实验室IoT三层部署架构详解
人工智能·物联网·架构·科普知识
国科安芯7 小时前
星载CAN总线通信网络中抗辐射MCU的通信可靠性设计分析
网络·人工智能·分布式·单片机·嵌入式硬件·架构