Appearance
设计原理 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 事件清单与六语言映射表(官方口径 ·)
官方口径:集成层监听器按语义对齐;事件名、码值、挂载方式是各语言惯用形态, 不强制统一。集成方以本表为唯一官方映射,无需逐语言读源码。
语义 ↔ 事件名 ↔ 码值映射:
| 语义锚点 | Java | Rust | Go | Node | Python | PHP | 触发点 |
|---|---|---|---|---|---|---|---|
| 实例开始 | PROCESS_INSTANCE_START(1) | ProcessInstanceStart(1) | EventProcessStart(0) | ProcessStart(0) | PROCESS_START | INSTANCE_START | 启动流程 |
| 任务创建 | PROCESS_TASK_START(3) | ProcessTaskStart(3) | EventTaskCreate(1) | TaskCreate(3) | TASK_CREATE | TASK_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_REJECT | INSTANCE_END(2) | 到达 end(办结)/ 拒绝 |
| 抄送知会 | CC_CREATE(4) | CcCreate(4) | EventCCCreate(5) | CcCreate(5) | CC_CREATE | CC_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.listeners | Go、Node | 扩展结构体的监听器数组字段 |
event_listener 单回调 | Python | 引擎扩展点直接传函数(单回调形态) |
| 静态 Registry | PHP | 1.3.8 起 ProcessEventListenerRegistry 注册制 |
引擎侧兜底语义(P2,六语言统一):publisher 逐监听器调用, 单监听器异常(Java/PHP Exception/Throwable、Go panic、Node reject、Python 异常、 Rust panic)只记日志、不传播——不得影响引擎主流程,也不得中断后续监听器。 (集成层"全局兜底"铁律在此之上再加一层,两层互不替代。)
触发点补充:CC_CREATE 在 createCcInstance(逐行 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 已覆盖办结通知) |
集成层"事件 → 消息"的标准装配(七套集成层共用语义,各按自己栈的消息服务落库):
TaskCreate→ TODO 待办,接收人 = 任务处理人(actorIds,过滤:去空 / 仅纯数字 / 去重);InstanceEnd(finish 与 reject 都触发)→ NOTICE 通知,接收人 = 发起人(createUser);CC_CREATE→ NOTICE 抄送知会,接收人 = 事件直传的ccActorId(免反查 cc 表);六语言 6/6 已实现(Rust 于 P0 补齐);InstanceStart/TaskComplete→ 不产生消息。
三条铁律(集成层监听器必须满足):
- 开关:提供消息开关(默认开),关闭时监听器直接跳过(性能零损耗);
- 全局兜底:消息组装/落库的任何异常只记日志,绝不打断审批主流程(Go
defer recover/ Javacatch Throwable/ Rustcatch_unwind+Result / Python·Nodetry/except·try/catch); - 接收人过滤:去空、仅纯数字 ID、去重;过滤后为空则不发。
PHP 已补齐(2026-09-03 方案 A):
jeeflow-php1.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 RustCcCreate),映射表见 §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)对齐 JavaExecution.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_01 → task) |
com.mldong.jeeflow.interceptor.impl.OrgUserAssignmentHandlers$DeptLeaderAssignmentHandler | 当前任务操作人部门领导 |
…$DeptMainLeaderAssignmentHandler | 当前任务操作人部门分管领导 |
…$ApplicantDeptLeaderAssignmentHandler | 流程发起人部门领导 |
…$ApplicantDeptMainLeaderAssignmentHandler | 流程发起人部门分管领导 |
…$TaskRoleAssigneeHandler | 任务节点唯一编码关联角色(roleCode = 节点 id) |
OperatorAssignmentHandler/FormFieldAssigneeHandler是纯引擎语义,零外部依赖;- 组织维度 handler(部门领导/分管领导/角色)通过
OrgUserProviderSPI 取数据 (见 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 监听器注册进
ServiceContext(event_listeners,Java 需集成层显式put/register——SimpleContext 不自动发现 Spring bean);Go/Node/Python 经EngineExtensions(listeners数组 / 单event_listener回调)传入;PHP(1.3.8 起)经静态ProcessEventListenerRegistry::register(集成层 ServiceProviderboot()显式注册,无自动发现)。
7. 六语言实现对照
| 语言 | 拦截器 | 事件(触发语义 / 事件名) | Registry |
|---|---|---|---|
| Java | interceptor/FlowInterceptor.java | ProcessEventListener · PROCESS_INSTANCE_START / PROCESS_INSTANCE_END / PROCESS_TASK_START(无独立 finish/reject,办结与拒绝都用 INSTANCE_END;从不 fire PROCESS_TASK_END) | ServiceContext / Registry |
| Rust | jeeflow-core/src/interceptor.rs | ProcessEventListener(同步 on_event)· ProcessInstanceStart / ProcessInstanceEnd / ProcessTaskStart(语义同 Java) | ServiceContext(event_listeners) |
| Go | engine/interceptor.go | ProcessEventListener(Extensions.Listeners)· EventProcessStart / EventTaskCreate / EventTaskComplete / EventProcessFinish / EventProcessReject | engine/registry.go |
| Node.js | src/extensions.ts | ProcessEventListener(extensions.listeners 数组,引擎 await 每个)· EventType.ProcessStart/TaskCreate/TaskComplete/ProcessFinish/ProcessReject | src/registry.ts |
| Python | jeeflow/extensions.py | EngineExtensions.event_listener(单个 async 回调)· EventType.PROCESS_START/TASK_CREATE/TASK_COMPLETE/PROCESS_FINISH/PROCESS_REJECT | jeeflow/extensions.py |
| PHP | —(无拦截器,1.3.8 起有事件扩展点) | ProcessEventListener(静态 Registry)· ProcessEventTypeEnum:INSTANCE_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。