Skip to content

设计原理 04 · 扩展机制——拦截器、事件与 HandlerRegistry

系列:jeeflow 工作流引擎设计原理

1. 要解决什么问题

引擎核心必须零框架依赖,但业务方总需要"在流程跑起来的时候干点自己的事":

  • 审批通过后发通知(企业微信/钉钉/短信)
  • 特定节点完成时改业务单据状态
  • 参与者不是固定的人,而是按业务规则算出来的
  • 决策分支不是表达式能表达的,要写代码判断

这些问题不解决,引擎就只能 demo 不能上线。解决方式就是三类扩展点:拦截器、事件、处理器注册表

2. 三类扩展点总览

扩展点时机用途
FlowInterceptor节点执行前后横切逻辑(日志/统计/熔断)
ProcessEventListener流程生命周期异步通知/集成(钉钉、MQ)
HandlerRegistry运行时按名解析动态参与者、动态决策

3. 拦截器(FlowInterceptor)

3.1 设计

interface FlowInterceptor:
    preHandle(node, instance): boolean    # 节点执行前;false 中断该节点
    postHandle(node, instance): void      # 节点执行后(无论成败)
    order(): int                          # 排序,越小越先

3.2 为什么 pre 返回 boolean?

拦截器需要能阻止节点执行——典型场景:审批节点要求前置条件满足才创建任务(如"该客户已实名认证才进入人工审核")。preHandle 返回 false,引擎跳过该节点的任务创建,流程停在原地。

3.3 执行顺序

所有拦截器按 order 升序执行 preHandle(任一 false 即中断)
节点执行
所有拦截器按 order 降序执行 postHandle(对称的洋葱模型)

对称顺序保证"先注册的后收尾",与中间件惯例一致。

3.4 与事件的区别

拦截器事件
能阻止执行✅(pre 返回 false)
关注点节点级横切流程级通知
顺序敏感✅(order)❌(广播)

经验法则:要"拦"用拦截器;要"通知"用事件;两者不要混。

4. 事件监听(ProcessEventListener)

4.1 事件清单与六语言映射表(官方口径 ·)

官方口径:集成层监听器按语义对齐;事件名、码值、挂载方式是各语言惯用形态, 不强制统一。集成方以本表为唯一官方映射,无需逐语言读源码。

语义 ↔ 事件名 ↔ 码值映射

语义锚点JavaRustGoNodePythonPHP触发点
实例开始PROCESS_INSTANCE_START(1)ProcessInstanceStart(1)EventProcessStart(0)ProcessStart(0)PROCESS_STARTINSTANCE_START启动流程
任务创建PROCESS_TASK_START(3)ProcessTaskStart(3)EventTaskCreate(1)TaskCreate(3)TASK_CREATETASK_START任务落库后逐任务
任务完成—(从不独立 fire,语义并入实例结束)—(同左)EventTaskComplete(4)TaskComplete(4)TASK_COMPLETE—(同 Java)完成任务
实例结束(办结+拒绝)PROCESS_INSTANCE_END(2)ProcessInstanceEnd(2)EventProcessFinish(2) / EventProcessReject(3)ProcessFinish(1) / ProcessReject(2)PROCESS_FINISH / PROCESS_REJECTINSTANCE_END(2)到达 end(办结)/ 拒绝
抄送知会CC_CREATE(4)CcCreate(4)EventCCCreate(5)CcCreate(5)CC_CREATECC_CREATE(4)创建抄送实例后逐抄送人

实例结束两派(语义等价,按语义锚点接即可): 合并派(1 个事件,办结/拒绝都 fire)= Java / Rust / PHP 的 InstanceEnd拆分派(2 个事件)= Go / Node / Python 的 Finish + Reject。 集成层做"办结通知"时:合并派接 1 个事件;拆分派两接都要接,漏接一个就是静默缺口。 码值各语言枚举独立(4/5/"字符串"并存是历史事实:4 号位死码复用派 Java/PHP/Rust、 4 号位活码占用后追加 5 派 Go/Node、Python 字符串枚举),无强制统一必要

挂载方式(四形态,均为官方)

形态语言说明
ServiceContext 装配Java、Rust引擎上下文注册 ProcessEventListener
Extensions.listenersGo、Node扩展结构体的监听器数组字段
event_listener 单回调Python引擎扩展点直接传函数(单回调形态)
静态 RegistryPHP1.3.8 起 ProcessEventListenerRegistry 注册制

引擎侧兜底语义(P2,六语言统一):publisher 逐监听器调用, 单监听器异常(Java/PHP Exception/Throwable、Go panic、Node reject、Python 异常、 Rust panic)只记日志、不传播——不得影响引擎主流程,也不得中断后续监听器。 (集成层"全局兜底"铁律在此之上再加一层,两层互不替代。)

触发点补充CC_CREATEcreateCcInstance(逐行 INSERT)之后 逐抄送人 fire,ccActorId 直传事件体(监听器免反查 cc 表);发起(f_ccActors)/ 办理(tf_ccActors)/ 手动补抄送(facade createCCInstance)三路同语义。

4.2 监听器怎么写

java
// 伪代码(六语言同构:Java/Rust/Go/Node/Python/PHP)
listener = (event) -> {
    switch (event.type) {
        case TASK_CREATE:
            sendTodo(event.taskId)          // 任务落库 → 处理人待办
        case PROCESS_INSTANCE_END:          // 办结 / 拒绝都触发
            notifyCreator(event.instanceId) // 发起人办结通知
        case CC_CREATE:
            notifyCc(event.ccActorId)       // 抄送知会 → 抄送人(六语言 6/6)
    }
}

4.3 为什么事件不带"业务上下文"?

事件只带 instanceId/taskId,业务方需要更多数据时自己查仓储。刻意不带完整快照:事件是"通知发生了",不是"传输数据"——避免事件体膨胀、避免监听器拿到过期数据。

4.4 触发时机与"事件 → 消息"职责边界

本节是契约:引擎 fire 什么事件、在什么时机 fire、谁来把事件变成可观测的副作用(站内信等),六语言必须对齐。

引擎只 fire 事件,不保证任何副作用落库。 事件是引擎对外的唯一通知通道;把「事件 → 业务副作用」(典型:wf 流程站内信 TODO/NOTICE)的字段组装 + 持久化集成层职责,引擎核心不含、不依赖这条链路。

触发时机(对齐 Java 参考实现的 CreateTaskHandler / EndProcessHandler,是可观测的既定行为,六语言必须一致):

语义触发点引擎保证
TaskCreate任务落库之后逐任务 fire(会签 → 每个子任务各 fire 一次;分支/退回重办 → 新任务 fire)任务行已写入 wf_task,监听器可按 taskId 反查到该任务
InstanceEnd实例到达 end 节点,办结(finish)与拒绝(reject)两条路径都 fire实例已落最终态(FINISHED / REJECT)
InstanceStart启动流程实例已创建
TaskComplete任务完成(Java / Rust 引擎从不 fire 独立的任务结束事件,见 §7;语义上 InstanceEnd 已覆盖办结通知)

集成层"事件 → 消息"的标准装配(七套集成层共用语义,各按自己栈的消息服务落库):

  • TaskCreateTODO 待办,接收人 = 任务处理人(actorIds,过滤:去空 / 仅纯数字 / 去重);
  • InstanceEnd(finish 与 reject 都触发)→ NOTICE 通知,接收人 = 发起人(createUser);
  • CC_CREATENOTICE 抄送知会,接收人 = 事件直传的 ccActorId(免反查 cc 表);六语言 6/6 已实现(Rust 于 P0 补齐);
  • InstanceStart / TaskComplete → 不产生消息。

三条铁律(集成层监听器必须满足):

  1. 开关:提供消息开关(默认开),关闭时监听器直接跳过(性能零损耗);
  2. 全局兜底:消息组装/落库的任何异常只记日志,绝不打断审批主流程(Go defer recover / Java catch Throwable / Rust catch_unwind+Result / Python·Node try/except·try/catch);
  3. 接收人过滤:去空、仅纯数字 ID、去重;过滤后为空则不发。

PHP 已补齐(2026-09-03 方案 A)jeeflow-php 1.3.8 起具备事件机制—— ProcessEventListener 接口 + 静态 ProcessEventListenerRegistry + ProcessPublisher(每监听器 try/catch Throwable 记日志不传播), 事件体只带 id(instanceId/taskId/ccActorId)。集成层 WfMessageProcessEventListener(mldong-laravel-jeeflow,base+1)走"事件 → 消息"监听器路径, 落地 TaskCreate→TODO / InstanceEnd→NOTICE(办结+拒绝同文)/ CC_CREATE→NOTICE 三类,满足三条铁律。 CC_CREATE 六语言 6/6 已实现(各栈批次 + P0 Rust CcCreate),映射表见 §4.1。

5. HandlerRegistry(自写 IoC)

5.1 为什么需要它

流程定义里写的是字符串assignmentHandler: "deptLeaderHandler"),运行时引擎需要把这个字符串变成可调用的处理器。这就是注册表:按名字找到实例

流程定义 JSON                    Registry
┌──────────────────┐           ┌──────────────────┐
│ assignmentHandler:│  ──名字──▶│ deptLeaderHandler│──▶ 返回 [userId]
│ "deptLeaderHandler"│          │  (已注册实例)      │
└──────────────────┘           └──────────────────┘

5.2 设计

interface IAssignmentHandler:
    assign(node, instance, operator): string[]   # 返回参与者列表
                                                 # operator: 当前任务操作人

interface IDecisionHandler:
    decide(node, instance, vars): string      # 返回目标边 ID

class HandlerRegistry:
    register(name, handler)                   # 启动时注册
    resolve(name): handler                    # 运行时按名解析

operator 参数(v1.6.0,集成反馈 16)对齐 Java Execution.getOperator():区分 「当前任务操作人」(如"当前人部门领导")与「流程发起人」(instance.operator), 语义与 boot4 内置 handler 完全一致。

5.2.1 内置通用 handler(v1.6.0,开箱即用)

jeeflow 内置 7 个通用参与者处理器,注册名与 Java 类全限定名一致——同一份流程 JSON 在四语言引擎上无需改动

注册名(流程定义里写的 assignmentHandler 值)语义
com.mldong.jeeflow.interceptor.impl.OperatorAssignmentHandler流程发起人(instance.operator;兜底 "apply.operator"
com.mldong.jeeflow.interceptor.impl.FormFieldAssigneeHandler按表单字段值分配:节点名精确匹配变量字段;task_01 类编号后缀自动去掉再匹配(task_01task
com.mldong.jeeflow.interceptor.impl.OrgUserAssignmentHandlers$DeptLeaderAssignmentHandler当前任务操作人部门领导
…$DeptMainLeaderAssignmentHandler当前任务操作人部门分管领导
…$ApplicantDeptLeaderAssignmentHandler流程发起人部门领导
…$ApplicantDeptMainLeaderAssignmentHandler流程发起人部门分管领导
…$TaskRoleAssigneeHandler任务节点唯一编码关联角色(roleCode = 节点 id)
  • OperatorAssignmentHandler / FormFieldAssigneeHandler纯引擎语义,零外部依赖;
  • 组织维度 handler(部门领导/分管领导/角色)通过 OrgUserProvider SPI 取数据 (见 SPI 设计 05)——业务方只实现数据接口,不写 handler, 消灭各集成方重复实现(boot4 原本 8 个 handler 只剩数据 SPI)。

各语言注册方式(四版同构):

python
# Python 示例(Go/Node 同名函数)
from jeeflow import HandlerRegistry, register_builtin_assignments
registry = HandlerRegistry()
register_builtin_assignments(registry, user_prov, org_prov)   # 组织维度依赖注入
engine.set_extensions(EngineExtensions(registry=registry))
java
// Java 示例:无需注册——引擎按类全限定名 Class.forName 懒加载
// 流程定义里写全限定类名即可

5.3 解析优先级(参与者)

resolveActors(node):
  1. assignee 非空          → 固定参与者(含 "applicant" → 发起人)
  2. Registry 按名解析       → 动态参与者(推荐)
  3. 扩展注入的 AssignmentHandler(兼容旧 API)

设计要点assignee 优先于 assignmentHandler——静态配置优先于动态逻辑,减少误解析。

5.4 决策解析优先级

evaluateDecision(node):
  1. Registry 的 decisionHandler
  2. 扩展注入的 DecisionHandler
  3. 出边 expr 表达式求值

5.5 为什么不用真正的 Spring?

  • 引擎核心零依赖:不能引入 Spring/Guice
  • 引擎不需要"对象生命周期管理"——处理器是无状态的,一个 Map 就够了
  • 集成方若用 Spring,只需在启动时把 Bean 注册进 Registry(适配 3 行代码)
java
// Spring 集成示例(demo 之外的使用方)
@Configuration
class JeeflowConfig {
    @Bean
    void register(JeeflowEngine engine, DeptLeaderHandler handler) {
        engine.getRegistry().register("deptLeaderHandler", handler);
    }
}

6. 扩展点挂载位置(引擎内部)

executeNode(node):
  preHandle(拦截器)            ←── 拦截器
  ├── createTask → resolveActors ←── Registry / 扩展
  │     └─ 任务落库后 → fire TASK_CREATE   ←── 事件监听器(逐任务,会签各一次)
  ├── evaluateDecision           ←── Registry / 扩展 / 表达式
  postHandle(拦截器)

启动流程时       → fire PROCESS_START(InstanceStart)
到达 end 节点时  → fire PROCESS_FINISH / PROCESS_REJECT
                   (Java/Rust 统一 fire PROCESS_INSTANCE_END,办结与拒绝都触发)

挂载方式因语言而异:Java/Rust 监听器注册进 ServiceContextevent_listeners,Java 需集成层显式 put/register——SimpleContext 不自动发现 Spring bean);Go/Node/Python 经 EngineExtensionslisteners 数组 / 单 event_listener 回调)传入;PHP(1.3.8 起)经静态 ProcessEventListenerRegistry::register(集成层 ServiceProvider boot() 显式注册,无自动发现)。

7. 六语言实现对照

语言拦截器事件(触发语义 / 事件名)Registry
Javainterceptor/FlowInterceptor.javaProcessEventListener · PROCESS_INSTANCE_START / PROCESS_INSTANCE_END / PROCESS_TASK_START独立 finish/reject,办结与拒绝都用 INSTANCE_END从不 fire PROCESS_TASK_ENDServiceContext / Registry
Rustjeeflow-core/src/interceptor.rsProcessEventListener(同步 on_event)· ProcessInstanceStart / ProcessInstanceEnd / ProcessTaskStart(语义同 Java)ServiceContext(event_listeners
Goengine/interceptor.goProcessEventListenerExtensions.Listeners)· EventProcessStart / EventTaskCreate / EventTaskComplete / EventProcessFinish / EventProcessRejectengine/registry.go
Node.jssrc/extensions.tsProcessEventListenerextensions.listeners 数组,引擎 await 每个)· EventType.ProcessStart/TaskCreate/TaskComplete/ProcessFinish/ProcessRejectsrc/registry.ts
Pythonjeeflow/extensions.pyEngineExtensions.event_listener单个 async 回调)· EventType.PROCESS_START/TASK_CREATE/TASK_COMPLETE/PROCESS_FINISH/PROCESS_REJECTjeeflow/extensions.py
PHP—(无拦截器,1.3.8 起有事件扩展点)ProcessEventListener(静态 Registry)· ProcessEventTypeEnumINSTANCE_START / INSTANCE_END / TASK_START / CC_CREATE(仅 PHP 已实现 首批次)ProcessEventListenerRegistry(静态,ServiceProvider boot() 显式注册)

命名差异:Go 拦截器方法名 PascalCase(PreHandle),Node/Python 用 camelCase/snake_case,语义一致。 事件名不统一(Java/Rust 用 InstanceEnd 合并办结+拒绝;Go/Node/Python 拆 Finish/Reject;各语言任务创建事件名不同),但触发语义TaskCreate / InstanceEnd / InstanceStart)六语言一致——集成层按语义对齐,见 §4.4。


jeeflow · 轻量级多语言工作流引擎