一、流程图上没画人,单子却走对了
用流程设计器画一张最简单的请假审批流:开始 → 部门领导审批 → 结束,三个节点两条线。
画完之后你检查一遍这张图:节点上没有任何人的名字。没有"张三",没有"李四",连"审批人"三个字都是你自己写的节点显示名。从图上看,这张流程不知道该把单子交给谁。
然后你发起了一笔请假。几秒后,你部门领导的待办列表里,多了这条单子。
它怎么知道该去找领导的?
你可能会说:节点名不是写着"部门领导审批"吗------但节点显示名只是个标签,引擎不会拿它去组织架构里搜人;就算你把节点改成"甲乙丙",流程照样得走对。真正的答案是:处理人从来不是画流程时定死的,是引擎在建任务那一刻,按一条固定的优先级链现场算出来的。这条链的最后一级,站着引擎自带的 7 个"找人"处理器------你在节点上写的,只是一个类名字符串。
这篇就把这条链拆开:处理人的三级优先级、一个类名怎么变成一个活人、7 个内置处理器各自怎么找人、以及为什么业务方一行找人代码都不用写。
二、先排队,再找人:处理人的三级优先级
引擎走到一个任务节点、要创建任务时,做的第一件事不是建任务,而是解析参与者 (actor)。这段逻辑在 CreateTaskHandler.resolveActors 里,优先级写得非常直白------先到先得,上一级找到了就不再往下看:
java
// jeeflow-core/src/.../handler/impl/CreateTaskHandler.java(节选,1.8.21)
private List<String> resolveActors(TaskModel taskModel, ProcessModel model, Execution execution) {
List<String> actors = new ArrayList<>();
FlowData args = execution.getArgs();
// 1. 动态指定下一节点处理人优先(v1.0.1:对齐 boot2/boot3 tf_nextNodeOperator)
Object nextNodeOperator = args.get(FlowConst.NEXT_NODE_OPERATOR);
if (nextNodeOperator != null && !nextNodeOperator.toString().isEmpty()) { /* 拆逗号入列 */ return actors; }
// 2. 固定指派 assignee------token 即变量 key,能替换就换,换不了就是字面量
if (taskModel.getAssignee() != null && !taskModel.getAssignee().isEmpty()) {
for (String raw : assignee.split(",")) {
String token = raw.trim();
// mldong 契约特殊值:applicant → 流程发起人
if (token.contains("applicant")) {
token = token.replace("applicant", execution.getProcessInstance().getOperator());
}
Object v = args.get(token); // token 是变量 key?命中就替换成变量值
if (v != null) { /* 变量值入列 */ }
else if (!actors.contains(token)) { actors.add(token); } // 没命中,字面量就是 userId
}
}
// 3. 动态指派处理器 assignmentHandler(assignee 为空时才生效)
if (actors.isEmpty()) {
String handlerClass = taskModel.getAssignmentHandler();
if (handlerClass != null && !handlerClass.isEmpty()) {
AssignmentHandler handler = (AssignmentHandler)
Class.forName(handlerClass.trim()).getDeclaredConstructor().newInstance();
String result = handler.assign(execution);
/* 结果按逗号拆开入列 */
}
}
return actors;
}
三级,一级比一级"动态":
第 1 级:tf_nextNodeOperator------上一步提交时动态指定。 提交任务时可以在参数里直接点名"下一节点给谁办"。这是唯一能越过所有配置 的方式:流程运行中的人比流程设计时的配置更清楚这单该给谁,所以它排第一。变量名里的 tf_ 前缀是 mldong 框架的历史契约(task forward),对齐 boot2/boot3 的同名机制。
第 2 级:assignee------流程图上写死的固定值。 支持逗号分隔多人,而且每个 token 有一次"变身"机会:token 先当变量 key 去流程变量里查,查到就用变量值,查不到就把它本身当 userId 。所以 assignee: "leader1" 是写死一个人,assignee: "deptManager" 是引用一个流程变量,assignee: "applicant" 则是引擎级特殊值------替换成流程发起人(这就是第 6 篇讲过的 applicant 契约在处理人侧的样子,退回发起人重办之所以闭环,靠的也是它)。
第 3 级:assignmentHandler------按类名调处理器动态算。 注意代码里那个 if (actors.isEmpty())------assignee 配了值,assignmentHandler 就整个被跳过。这是故意的设计,spec 里写明了理由:"静态配置优先于动态逻辑,减少误解析"。写死的值永远比动态逻辑优先级高,配置的人意图才不会被意外覆盖。
三级走完还没完------任务建好、落库之前,还有一道 FlowInterceptor 拦截器可以改名单。引擎自带的 SurrogateInterceptor(委托代理)就在这儿干活:查一下"这个处理人是不是委托了别人",命中就把代理人换进去。所以完整的图景是这样:

这条优先级链理清了,剩下的问题就一个:第 3 级里那个 Class.forName(...) 到底在干嘛------一个写在流程 JSON 里的类名字符串,怎么就变成了坐在工位上的部门领导?
三、一个类名,怎么变成一个活人
先看它在流程定义里长什么样。这是引擎测试资产里一条真实流程(11-assignment-handler.json)的节点:
json
{
"id": "task3",
"type": "snaker:task",
"text": { "value": "部门领导" },
"properties": {
"form": "dept-form",
"assignmentHandler": "com.mldong.jeeflow.interceptor.impl.OrgUserAssignmentHandlers$DeptLeaderAssignmentHandler",
"taskType": 0, "performType": 0
}
}
节点上和人有关的只有一个 assignmentHandler 属性,值是一个Java 全限定类名。没有参数、没有配置块、没有任何人的名字。
引擎拿到这个字符串后做的事,CreateTaskHandler 里就一行:
java
AssignmentHandler handler = (AssignmentHandler)
Class.forName(handlerClass.trim()).getDeclaredConstructor().newInstance();
String result = handler.assign(execution);
反射:加载类、实例化、调 assign。就这么土。这个"土办法"是刻意的------引擎核心零第三方依赖,不引 Spring 不引 Guice,一个类名字符串加一次反射就是全部的"依赖注入"。
处理器的接口也简单到过分:
java
// jeeflow-core/src/.../interceptor/AssignmentHandler.java
public interface AssignmentHandler {
String assign(Execution execution);
}
一个入参,一个字符串返回值(多人用逗号拼)。所有的"找人"逻辑都被压进这一个方法。它能用到的原材料,全在 Execution 执行上下文里:
| Execution 里的东西 | 是什么 | 找人时干什么用 |
|---|---|---|
execution.getOperator() |
当前操作人------上一步点"提交"的那个人 | "我的领导审"类 handler 的起点 |
execution.getProcessInstance().getOperator() |
流程发起人------这单是谁发起的 | "发起人的领导审"类 handler 的起点 |
execution.getArgs() |
流程变量(表单数据) | 表单字段取人、变量替换 |
execution.getNodeModel() |
当前任务节点模型 | 拿节点编码去匹配字段/角色 |
注意前两行的区别------当前操作人 和流程发起人是两个不同的人,而且正是"部门领导审批"这种最常见需求的全部秘密:请假人(发起人)提交后,第一站的"当前操作人"就是发起人本人,引擎拿他的部门找领导;领导审批提交后,"当前操作人"变成领导本人。同一个 handler,站在不同节点上,基准人不同,找到的人就不同。

还有一个容易忽略的细节:assign 返回的是 String 不是集合,多人用逗号拼接。这个看似随意的设计其实是对 PHP/Go 等动态语言的友好------一个字符串在任何语言间传递都不会有类型争议。六语言引擎在这套机制上能对齐得七七八八(第五节细说),从接口签名的克制开始就定了调。
四、引擎自带的 7 个答案
接口只有一个方法,难的部分其实是"引擎要不要帮你写几个现成的实现"。jeeflow 的答案是:要,而且一口气内置 7 个 。先上全家福(中文名是引擎 HandlerRegistry 里登记的官方叫法,顺序即官方排序):
| # | 注册名(类名简写) | 官方中文名 | 找人逻辑 | 数据从哪来 |
|---|---|---|---|---|
| 1 | OperatorAssignmentHandler |
流程发起人 | 返回 instance.operator |
纯引擎语义,零依赖 |
| 2 | FormFieldAssigneeHandler |
根据表单字段值分配参与者 | 节点编码匹配表单字段的值 | 纯引擎语义,零依赖 |
| 3 | DeptLeaderAssignmentHandler |
当前用户所属部门经理 | 当前操作人 → 其部门 → 部门领导 | IUserProvider + IOrgUserProvider |
| 4 | DeptMainLeaderAssignmentHandler |
当前用户所属部门分管领导 | 同上,取分管领导 | 同上 |
| 5 | ApplicantDeptLeaderAssignmentHandler |
发起人所属部门经理 | 发起人 → 其部门 → 部门领导 | 同上 |
| 6 | ApplicantDeptMainLeaderAssignmentHandler |
发起人所属部门分管领导 | 同上,取分管领导 | 同上 |
| 7 | TaskRoleAssigneeHandler |
根据任务节点唯一编码关联角色分配参与者 | 节点编码当作角色编码,查该角色下的人 | IOrgUserProvider |
三个代表挨个说。
代表一:OperatorAssignmentHandler,最简单的,也是最容易被名字骗的。 它的类名叫"操作人处理器",实现却是:
java
// jeeflow-core/src/.../interceptor/impl/OperatorAssignmentHandler.java
public class OperatorAssignmentHandler implements AssignmentHandler {
@Override
public String assign(Execution execution) {
if (execution.getProcessInstance() != null
&& execution.getProcessInstance().getOperator() != null
&& !execution.getProcessInstance().getOperator().isEmpty()) {
return execution.getProcessInstance().getOperator(); // 注意:是发起人
}
return "apply.operator";
}
}
它返回的不是 execution.getOperator()(当前操作人),而是 instance.getOperator()(流程发起人 )。类名里的 "Operator" 沿用的是实例字段名 operator------实例的 operator 字段存的是发起人,所以这个 handler 的真实语义是"让发起人再审一次",典型用途是"申请不通过退回申请人确认"这类节点。类名和语义之间隔着一层窗户纸,源码注释里写得明明白白,但只看类名配置的人容易踩坑------这也是为什么把它放在第一个讲。
代表二:FormFieldAssigneeHandler,让表单决定谁审批。 场景:报销单上有个"指定审批人"字段,填报人选了谁,单子就给谁审。它的找人逻辑是一个三级字段匹配:
java
// jeeflow-core/src/.../interceptor/impl/FormFieldAssigneeHandler.java(节选)
private Object findFieldValue(FlowData args, String taskName) {
// issues/48 E20:表单字段变量为 f_ 前缀(f_approver),先匹配前缀再回落裸名(兼容存量)
if (args.containsKey("f_" + taskName)) return args.get("f_" + taskName);
if (args.containsKey(taskName)) return args.get(taskName);
Matcher matcher = NUMBER_SUFFIX_PATTERN.matcher(taskName);
if (matcher.matches() && args.containsKey(matcher.group(1))) {
return args.get(matcher.group(1)); // task_01 → 匹配 task 字段
}
return null;
}
节点编码叫 approver,就先找流程变量里的 f_approver(mldong 契约里表单字段统一带 f_ 前缀),找不到再找裸名 approver,还找不到就把 _01 这种编号后缀摘掉再试一次。字段值支持逗号分隔字符串或集合,全算参与者。流程图上一行配置不写死任何人,人选在表单里------这是七个 handler 里唯一"人在提交时才确定"的。
代表三:DeptLeaderAssignmentHandler,两个 SPI 串成一条链。 这是"部门领导审批"的真身,也是内置 handler 里最能让你看懂分层设计的:
java
// jeeflow-core/src/.../interceptor/impl/OrgUserAssignmentHandlers.java(节选)
public static class DeptLeaderAssignmentHandler implements AssignmentHandler {
@Override
public String assign(Execution execution) {
return byDept(deptIdOf(execution.getOperator(), execution), false);
}
}
private static String deptIdOf(String userId, Execution execution) {
IUserProvider userProvider = ServiceContext.find(IUserProvider.class);
if (userProvider == null) return null;
IUserProvider.UserInfo u = userProvider.getUser(userId);
return u != null ? u.getDeptId() : null; // 第一步:人 → 部门
}
private static String byDept(String deptId, boolean main) {
IOrgUserProvider org = ServiceContext.find(IOrgUserProvider.class);
if (org == null) return null;
List<String> ids = main ? org.findDeptMainLeaders(deptId) : org.findDeptLeaders(deptId);
return ids == null || ids.isEmpty() ? null : String.join(",", ids); // 第二步:部门 → 领导们
}
两步:当前操作人 --IUserProvider.getUser()--> 部门 ID --IOrgUserProvider.findDeptLeaders()--> 领导列表。引擎对组织架构的全部认知 只有这两个接口------它不知道你们公司部门表长什么样、领导字段叫 leaderIds 还是 managers,它只知道"给我一个 userId 我要能问到他的部门,给我一个部门 ID 我要能问到它的领导"。这个设计的意义,下一节专门讲。
7 个 handler 不是纸面上的清单,引擎测试资产里有一条专门的流程逐个验证它们(11-assignment-handler.json:task1 表单字段取人 → task2 发起人审批 → task3 部门领导 → task4 角色取人,一条串联合笼),配一套内存版组织数据(D01 部门 → 领导 leader1、leader2,分管 boss1,角色 task4 → roleA、roleB)跑全链路断言。

五、业务方为什么不写 handler:把逻辑留下,把数据交出去
上一节那两步 SPI 串联,单看是"设计干净",放到历史里看才知道是"血泪换来的"。这个故事的起点是一份集成反馈(issues/16,2026-08)。
boot4(mldong 的 JDK 21 分支)作为集成方接入 jeeflow 时,在内置 wf 模块里实现了 8 个参与者处理类。其中两个是纯引擎语义(发起人、表单字段),剩下六个全是组织维度:当前人部门领导、当前人分管领导、发起人部门领导、发起人分管领导、角色取人......问题在于------这些 handler 是"通用业务语义",任何一个要审批流的集成方都躲不开,但 jeeflow 当时的 SPI 只有"取单个用户信息"和"搜索用户",没有"按部门取领导""按角色取人"的数据接口。于是每一个集成方都得把这一套 handler 重新抄写一遍,抄的还全是自己家的部门表查询。
jeeflow 的解法是把这件事拦腰切成两半:
- 逻辑归引擎 :那 7 个 handler(含两个纯语义的)引擎直接内置,v1.6.0(2026-08-04)四语言同批落地; - 数据归业务方 :新增
IOrgUserProviderSPI,就三个方法------findDeptLeaders(deptId)/findDeptMainLeaders(deptId)/findByRole(roleCode),加上已有的IUserProvider.getUser(userId),业务方实现这四个方法,组织数据就接进来了。
效果在 boot4 的代码量上立竿见影:原本 8 个 handler 类,现在只剩两个数据接口实现。换个角度说,"部门领导审批"这个需求的全部定制点,被压缩成一条 SQL------"查出某部门的领导 ID 列表"。逻辑有且只有一份,数据各家接各家的,这就是"把逻辑留下,把数据交出去"。
这套设计还有个容易被忽视的收尾:设计器怎么知道下拉框里该列哪 7 个选项? 引擎 v1.4.0 起带了一个 HandlerRegistry(处理器注册中心),构造时就把这 7 个内置 handler 连同官方中文名、排序一起登记成元数据:
java
// jeeflow-core/src/.../metadata/HandlerRegistry.java(节选)
public HandlerRegistry() {
registerBuiltins(); // 构造即注册内置通用 handler 元数据------集成方零注册即得字典
}
private void registerBuiltins() {
Class<?> type = AssignmentHandler.class;
register(type, "com.mldong.jeeflow.interceptor.impl.OperatorAssignmentHandler", "流程发起人", -9999, null);
register(type, "...OrgUserAssignmentHandlers$ApplicantDeptLeaderAssignmentHandler", "发起人所属部门经理", 10, null);
register(type, "...OrgUserAssignmentHandlers$ApplicantDeptMainLeaderAssignmentHandler", "发起人所属部门分管领导", 20, null);
register(type, "...OrgUserAssignmentHandlers$DeptLeaderAssignmentHandler", "当前用户所属部门经理", 30, null);
/* ...表单字段 50、角色 60... */
}
前端流程设计器的"处理人处理器"下拉字典直接以它为数据源------字典里的值和运行时按名解析的键天然同一个来源,不存在"设计器里能选、引擎里跑不通"的值漂移。集成方要扩展自己的 handler,除了实现接口,也在这儿补一条元数据,设计器里立刻可见。

六、六语言:同一份流程 JSON,七个同样的名字
这套机制最硬的约束其实是一条跨语言契约:注册名就是 Java 全限定类名 ------包括 OrgUserAssignmentHandlers$DeptLeaderAssignmentHandler 里这个 Java 内部类的 $ 语法。Go/Python/Node/Rust/PHP 的引擎在注册和解析内置 handler 时,键一律原样使用这个 Java 字符串。代价是其他语言的注册表里存着一段"外语";好处是同一份流程 JSON 不用改一个字,可以在任何一门语言的引擎上跑出同样的找人结果,前端设计器也只需要认识一种写法。
逐语言核对(2026-09-04,源码 grep)的结果:机制六语言全有,接口签名各从其俗(Java/PHP/Rust 收 Execution 返回逗号串,Go/Python/Node 收 (node, instance, operator) 返回列表------语义相同,只是"上下文打包传"还是"拆开传"的口味差),IUserProvider + IOrgUserProvider 两个数据 SPI 六语言齐装满员。差异有三处,如实列出:
| 维度 | Java | Go | Python | Node | PHP | Rust |
|---|---|---|---|---|---|---|
| 内置 7 handler | 7/7 | 7/7 | 7/7 | 7/7 | 2/7 | 7/7 |
| 表单字段匹配 | f_ 前缀→裸名→_N 后缀 |
同 Java | 同 Java | 同 Java | 同 Java | 少 _N 后缀一步 |
| assignee 与 handler 同时配置时 | assignee 先 | handler 先 | assignee 先 | handler 先 | assignee 先 | assignee 先 |
applicant 匹配 |
子串替换 | 全等 | 子串 | 子串 | 子串 | 全等 |
三处差异的分量不一样,值得分别说一句:
PHP 只内置了 2 个(发起人部门领导、表单字段)------更要紧的是它的元数据字典仍登记着全部 7 个:设计器下拉里能看到 7 个选项,真正配了没实现的那 5 个,运行时会解析失败。这是六语言里唯一的"字典与实现不一致",补齐前,PHP 栈的流程设计得避开那 5 个选项(或由集成层自行实现注册)。
优先级两派 :spec 的口径是"assignee 静态配置优先于 assignmentHandler 动态逻辑",Java/Python/PHP/Rust 照此实现;Go/Node 的实现里却是注册表 handler 先查。它只在一个节点同时配置了 assignee 和 assignmentHandler 时才咬人------这种配置本身就是坏味道(语义含糊),但一旦发生,六语言会给出两种不同的处理人。跨语言迁移流程定义前,值得用一条 grep 自查。
applicant 子串 vs 全等 :Java/Python/Node/PHP 用"包含 applicant 就替换"(assignee: "applicantLeader" 也会被替换成"发起人ID+Leader"),Go/Rust 要求 token 全等。写 applicant 这个精确值的场景两者一致;但变量命名里碰巧含 applicant 字样的,子串派会悄悄替换。与其记规则,不如约定只用裸 applicant 或逗号列表里的独立段。
这三处都已挂到 issues 台账,后续批次对齐。除却这些边角,"节点上一个类名,引擎现场找出人"这件事,在六门语言上的行为是一致的------下次你在流程设计器里画完一张没有任何人名的流程图,可以放心点提交:找人的活,引擎内置的 7 个处理器比你急。
参考资料
- jeeflow 源码(2026-09-04 核对):
jeeflow-java1.8.21(CreateTaskHandler.resolveActors/interceptor/AssignmentHandler/interceptor/impl/{OperatorAssignmentHandler,FormFieldAssigneeHandler,OrgUserAssignmentHandlers}/metadata/HandlerRegistry/spi/{IUserProvider,IOrgUserProvider}/AssignmentHandlerTest+flows/11-assignment-handler.json) - 跨语言核对:jeeflow-go1.8.24(engine/engine_impl.goresolveActors/engine/builtin.go)/jeeflow-python1.8.24(jeeflow/engine.py/jeeflow/builtin.py)/jeeflow-node1.8.24(src/engine.ts/src/builtin.ts)/jeeflow-php1.3.8(src/Handler/CreateTaskHandler.php/src/Handler/Builtin/)/jeeflow-rust1.0.7(src/engine.rsresolve_assignee/src/interceptor.rs) - issues/16《boot4 集成 jeeflow 参与者解析 SPI 扩展与通用 handler 内置》(v1.6.0,2026-08-04 四语言落地) - jeeflow-doc 规范:concepts/04-extensions §5 HandlerRegistry、concepts/05-spi-design - 系列前篇:第 6 篇《"applicant" 契约:退回发起人的闭环设计》、第 14 篇《流程实例如何沿着箭头走到结束:Rust 工作流引擎的执行内核》 - 仓库:github.com/mldong/jeef... · 文档站 · 开源演示站 · 集成演示站