参数决定流程往哪走,事件决定流程在走的过程中还能做什么。本文从参数容器、五类来源、事件标记、执行类型四处做机制级拆解,并说明发送前事件为何能反向影响流向。
一个 BPM 引擎,如果只会「按图流转」,那它只是一个执行器。真正让引擎嵌入业务系统的,是它对外暴露的可编程接口。
该平台的引擎在两个地方对外开口:
开口 | 作用 | 一句话概括 |
|---|---|---|
参数 | 控制流程往哪走、谁来办 | 输入:外部世界把信息告诉引擎 |
事件 | 在流程的关键动作前后插入代码 | 输出:引擎把控制权交回业务方 |
参数与事件是一对:参数决定流程「怎么走」,事件决定流程在走的过程中「能做别的什么」。 本文按这个线索,把两块拆开讲清楚。
先明确参数是干什么用的。剥开细节,参数只服务两件事:
这两件事,恰好是流程引擎在运行时唯一需要外部输入的两个决策点。其他的(表单字段、审批意见)都是过程数据,不直接影响流转。
参数的形式很简单:参数键 = 参数值。
QJTS=2QJTS 是参数键2 是参数值在该平台里,这个结构由 AtPara 承载。看它的解析逻辑(DA/AtPara.cs):
public AtPara(string para)
{
if (para == null || DataType.IsNullOrEmpty(para) == true || para.Length <= 3)
return;
if (para.Contains("@") == false)
{
try
{
this._HisHT = BP.Tools.Json.ToHashtable(para);
return;
}
catch { para = "@" + para; }
}
string[] strs = para.Split('@');
foreach (string str in strs)
{
// ...
string[] mystr = str.Split('=');
if (mystr.Length == 2)
this.SetVal(mystr[0], mystr[1]);
// ...
}
}有两个细节值得注意:
@:@QJTS=2@SQL=...。一行字符串里可以塞多个参数。@ 时自动尝试 JSON:AtPara 能直接吃 JSON 并转成 Hashtable。这是它兼容老接口(@ 串)和新接口(JSON)的地方。HisHT 是 Hashtable,GetValStrByKey / GetValInt64ByKey 等按类型取值——一个参数容器,多种取值方式。该平台把流程参数按来源分成五类。这个分类的价值在于:不同类型的参数,注入时机不同、生命周期不同、作用范围也不同。
# | 类型 | 来源 | 典型内容 |
|---|---|---|---|
1 | 系统参数 | 发起或处理流程时,通过 API 压入 | 流程编号、WorkID、节点 ID |
2 | 表单参数 | 用户在表单里输入的数据 | 主表字段名 → 字段值 |
3 | 登录人员参数 | 当前登录者的信息 | 账号、名称、部门、组织 |
4 | 外部传递参数 | URL / HTTP 传进来 | QueryString 上的键值 |
5 | 系统全局配置参数 | 全局配置文件 | AppSettings 里的项 |
发起或处理流程时,调用方通过 API 把参数压进引擎。对应 FlowEventBase.DoIt 里的处理:
if (atPara != null)
{
AtPara ap = new AtPara(atPara);
foreach (string s in ap.HisHT.Keys)
{
try { r.Add(s, ap.GetValStrByKey(s)); }
catch { r[s] = ap.GetValStrByKey(s); }
}
}注意 try/catch:已存在就覆盖、不存在就新增。这个写法在处理「同名参数来自不同来源」时很有用——后注入的覆盖先注入的。
表单参数不需要单独传,主表数据本身就是参数。事件分发时,实体行被直接摊进参数表:
Row r = en.Row; // 实体数据行
if (r.ContainsKey("FK_MapData") == true)
r["FK_MapData"] = en.ClassID;
else
r.Add("FK_MapData", en.ClassID);也就是说,用户在表单里填的每个字段,都自动成为一个键值对,可以在条件、接收人规则、事件里按字段名直接引用。
当前登录者的账号、名称、部门编号、组织编号、组织名称等。这类参数不出现在调用方传参里,而是引擎运行时按当前用户补齐。
FlowEventBase 暴露了 WorkID、OID、FID、Title、FK_Mapdata、Nos 等一组属性,事件代码里可以直接取。人员相关的表达式(如 @WebUser.No、@WebUser.DeptNo)能在条件与接收人规则里直接用。
BS 系统下,引擎会把当前请求的 QueryString 全量并入参数表(FlowEventBase.DoIt):
if (BP.Difference.SystemConfig.isBSsystem == true)
{
/*如果是bs系统, 就加入外部url的变量.*/
foreach (string key in HttpContextHelper.RequestParamKeys)
{
if (key == null) continue;
switch (key)
{
case "WorkID":
case "NodeID":
case "FrmID":
case "IsReadonly":
case "__VIEWSTATE":
case "__EVENTVALIDATION":
case "OID":
continue; // 这些是引擎自己的参数,跳过
default: break;
}
string val = HttpContextHelper.RequestParams(key);
if (r.ContainsKey(key) == true)
r[key] = val;
else
r.Add(key, val);
}
}这里有个细节:白名单式的排除。WorkID、NodeID、OID 这类引擎内部参数被显式跳过,避免外部请求覆盖引擎状态——是安全考量,也是稳定性考量。
所以从 URL 上带一个 ?QJTS=2 进来,这个 QJTS 就自动成为流程参数,能被条件分支和接收人规则读到。
放在全局配置文件里,比如连接串、路径、Token、语言、默认密码等。看 SystemConfig:
public static NameValueCollection AppSettings
{
get
{
// ...
_app_setting = System.Configuration.ConfigurationManager.AppSettings;
// ...
}
}读取方式统一:
string s = BP.Difference.SystemConfig.AppSettings["TokenModel"];
string str = BP.Difference.SystemConfig.AppSettings["UserDefaultPass"];这类参数不随流程实例变化,是整个系统级的存在——典型如 HostURL、MobileURL、DataUserDirPath、该引擎AppPath。
参数来源 | 进入方式 | 汇入位置 |
|---|---|---|
系统参数 | 发起 / 处理时 API 压入 | 参数表 |
表单参数 | 主表字段自动摊入 | 参数表 |
登录人员参数 | 运行时按当前用户补齐 | 参数表 |
外部传递参数 | URL / HTTP 全量并入 | 参数表 |
系统全局配置 | AppSettings 读取 | 参数表 |
五类参数最终在同一个参数表里汇合,然后被两个消费方使用:方向判断和接收人计算。
参数本身只是数据。它真正发挥作用是通过两个消费方:
所以接下来必须讲事件。
事件是对流程的操作点击所发生的动作。
用户在界面上的每一个动作,都会在引擎里触发一个事件标记:
动作 | 事件 |
|---|---|
发送前 | SendWhen |
发送成功后 | SendSuccess |
发送失败 | SendError |
工作到达 | WorkArrive |
退回前 / 退回后 | ReturnBefore / ReturnAfter |
撤销前 / 撤销后 | UndoneBefore / UndoneAfter |
移交后 | ShitAfter |
加签后 / 加签答复后 | AskerAfter / AskerReAfter |
流程删除前 / 删除后 | BeforeFlowDel / AfterFlowDel |
流程结束前 / 结束后 | FlowOverBefore / FlowOverAfter |
完整的清单在 ORM 组件/Sys/EvenListNode.cs 里,逐条带注释。
事件机制的实现方式很朴素:流程引擎在执行过程中,在一些关键位置留出「钩子」,到点就调用配置好的代码片段。
看 FlowEventBase.DoIt 的 switch 分发:
switch (eventType)
{
case EventListNode.SendWhen: // 节点事件 - 发送前
this.ItIsStopFlow = false;
string str = this.SendWhen();
if (this.ItIsStopFlow == true)
return "@Info=" + str + "@IsStopFlow=1";
if (this.JumpToNodeID == 0 && this.JumpToEmps == null)
return str;
return "@Info=" + str + "@ToNodeID=" + this.JumpToNodeID
+ "@ToEmps=" + this.JumpToEmps;
case EventListNode.SendSuccess:
return this.SendSuccess();
case EventListNode.ReturnBefore:
return this.ReturnBefore();
// ...
}关键点在返回值。发送前事件的返回是可以反向影响流程的:
@IsStopFlow=1 → 停止发送;@ToNodeID= / @ToEmps= → 改变流向、改变接收人。这意味着发送前事件不只是「通知」,而是「参与决策」——它可以直接把流程导向别的节点、或者干脆拦住不让发。
这是本次要讲的重点。同一个事件,可以用三种不同方式实现——对应三类不同的人、三种不同的复杂度。
在界面上配置,填一段 SQL、一个 URL、一个类名。
存储表是 Sys_FrmEvent,定义见 ORM 组件/Sys/FrmEvent.cs:
Map map = new Map("Sys_FrmEvent", "外部自定义事件(表单,从表,流程,节点)");
//0=表单事件,1=流程,2=节点事件.
map.AddTBInt(FrmEventAttr.EventSource, 0, "事件类型", true, true);
map.AddTBString(FrmEventAttr.EventID, null, "事件标记", ...);
map.AddTBString(FrmEventAttr.DoDoc, null, "执行内容", ...);
map.AddTBString(FrmEventAttr.FK_DBSrc, "local", "数据源", ...);
map.AddTBString(FrmEventAttr.EventDoType, null, "事件执行类型", ...);配置模式的核心优势:改事件不用重新编译。
字段说明:
字段 | 含义 |
|---|---|
EventSource | 0=表单事件,1=流程事件,2=节点事件 |
EventID | 事件标记(SendWhen、SendSuccess…) |
EventDoType | 执行类型(见下) |
DoDoc | 执行内容 |
FK_DBSrc | 数据源,默认 local |
继承 FlowEventBase,重写需要的方法。
FlowEventBase 定义了一大批 virtual 方法,每个对应一个事件点:
public virtual string FrmLoadAfter() { ... }
public virtual string SendWhen() { ... }
public virtual string SendSuccess() { ... }
public virtual string SendError() { ... }
public virtual string ReturnBefore() { return null; }
public virtual string ReturnAfter() { return null; }
public virtual string WorkArrive() { ... }
public virtual string FlowOverBefore() { ... }
public virtual string FlowOverAfter() { ... }
// ...同时它把常用上下文都准备好了,子类直接取:
public Node HisNode { get; } // 当前节点
public GenerWorkFlow HisGenerWorkFlow { get; } // 流程实例
public Entity HisEn { get; } // 数据实体
public Row SysPara { get; } // 参数表 ← 参数与事件在此汇合
public string JumpToEmps { get; }
public int JumpToNodeID { get; }
public bool ItIsStopFlow { get; set; }注意 SysPara 与 ItIsStopFlow / JumpToNodeID / JumpToEmps——前者读参数,后三者写决策。这就是「参数进、决策出」的完整闭环。
再配合 GetValStr / GetValInt / GetValBoolen / GetValDecimal 系列方法,子类里读参数很直接。
不改引擎源码,把逻辑挂在外部。OverrideEvent.cs 就是这类模式的落点,里面是一堆静态方法:
public static string DoIt(string eventMark, WorkNode wn, string paras,
string checkNote, int returnToNodeID,
string returnToEmps, string returnMsg)
public static string SendWhen(WorkNode wn)
public static string SendSuccess(WorkNode wn)
public static string ReturnAfter(WorkNode wn, int toNodeID, string toEmp, string info)
public static string FlowOverAfter(WorkNode wn)同时它还承担了邮件、钉钉、企业微信、CCApp、短信等推送的分流:
public static void SendToEmail(SMS sms)
public static void SendToCCApp(SMS sms)
public static void SendToDingDing(SMS sms)
public static void SendToWeiXin(SMS sms)
public static void MessageIsEnableSelf(SMS sms)外挂模式的价值:把「引擎行为的扩展」与「引擎源码」解耦。 升级引擎时,外部代码不动。
维度 | 配置模式 | 内置事件实体类 | 外挂模式 |
|---|---|---|---|
落地位置 | Sys_FrmEvent 表 | 继承 FlowEventBase | 静态方法(如 OverrideEvent) |
改完要不要编译 | 不用 | 要 | 要 |
适合谁 | 实施 / 业务配置人员 | 二次开发人员 | 集成 / 平台维护人员 |
适合什么 | SQL、URL、简单调用 | 复杂业务逻辑 | 全局行为、外部推送分流 |
主要字段/入口 | EventDoType + DoDoc | 重写 virtual 方法 | OverrideEvent.DoIt 等 |
EventDoType配置模式下,EventDoType 决定了这段配置怎么执行。完整枚举在 ORM 组件/Sys/EnumLib.cs:
public enum EventDoType
{
Disable = 0, // 禁用
SP = 1, // 执行存储过程
SQL = 2, // 运行 SQL
URLOfSelf = 3, // 自定义 URL
WSOfSelf = 4, // 自定义 WS
SpecClass = 5, // 执行 dll 文件的类与方法
EventBase = 6, // 基类
BuessUnit = 7, // 执行的业务单元
WebApi = 8, // 自定义 WebApi
SFProc = 9 // SFProduces
}这份枚举很说明问题:它把「事件能干什么」铺成了一个从简单到复杂的谱系——
SP(存储过程)、SQL;URLOfSelf、WSOfSelf、WebApi;SpecClass(反射调用 dll 里的类与方法)、EventBase(基类)、BuessUnit、SFProc。同一张配置表,可以表达从「执行一条 SQL」到「调用一个外部 WebApi」的全部跨度。 这是配置模式能覆盖大多数场景的原因。
这是本次要讲的最后一点,也是最重要的一点。
事件是流程引擎与外部系统的接口。
流程引擎本身只管流转。它要跟 ERP、MES、OA、财务系统、消息平台打交道,靠的就是事件:
参数来源 | 进入方式 | 汇入位置 |
|---|---|---|
系统参数 | 发起 / 处理时 API 压入 | 参数表 |
表单参数 | 主表字段自动摊入 | 参数表 |
登录人员参数 | 运行时按当前用户补齐 | 参数表 |
外部传递参数 | URL / HTTP 全量并入 | 参数表 |
系统全局配置 | AppSettings 读取 | 参数表 |
SendSuccess);WorkArrive → OverrideEvent.SendToDingDing);SendWhen + IsStopFlow)。OverrideEvent 里那一组 SendToEmail / SendToCCApp / SendToDingDing / SendToWeiXin,本身就是「事件作为外部交互媒介」最直接的证据——引擎不知道钉钉和企业微信的区别,它只发事件,谁来接是外挂层的事。
最后回答一个自然的问题:参数和事件,在运行时是什么关系?
答案是:参数是事件的输入,事件是参数的消费者与生产者。
参数来源 | 进入方式 | 汇入位置 |
|---|---|---|
系统参数 | 发起 / 处理时 API 压入 | 参数表 |
表单参数 | 主表字段自动摊入 | 参数表 |
登录人员参数 | 运行时按当前用户补齐 | 参数表 |
外部传递参数 | URL / HTTP 全量并入 | 参数表 |
系统全局配置 | AppSettings 读取 | 参数表 |
SysPara;DeliveryWay),或在事件里自定义;@ToNodeID、@ToEmps、@IsStopFlow)反过来影响流程。顺带提一句发送前事件的返回格式,它是参数与事件共用一套语法的体现:
@Info=xxx@ToNodeID=102@ToEmps=zhangsan返回的也是参数格式。引擎用同一套 AtPara 解析它。输入是参数,输出也是参数,中间是事件——这就是这个设计的对称性。
另外还有一个双轨机制值得注意:FrmEvents(Sys_FrmEvent 表)与 FlowEventBase(代码继承)是两条并列的路径。FrmEvents.DoEventNode 的注释里写着一句话:
不再使用节点事件 2019-08-27 zl 原调用点有两处:(1)FrmEvent.cs 中的 DoEventNode();(2)Flow.cs 中的 DoFlowEventEntity() 方法中。
这段注释反映了一次架构演进:配置表驱动的事件与代码继承的事件,在历史上有过并行,后来调用点发生了变化。理解这一点对升级和排障很重要——不能想当然认为「配了 Sys_FrmEvent 就一定会在每个事件点被调用」,具体以版本为准。
回到最初的例子 QJTS=2。参数键叫什么、值是什么,引擎不关心——它只做键值匹配。但工程上要注意:
WorkID、NodeID、OID、FK_MapData 等),否则会被引擎的参数处理逻辑覆盖或跳过;Entity.Row → AtPara → URL 参数,后处理的会覆盖先处理的;把两块合起来看,该平台在引擎上留的两个开口,设计思路是一致的:
参数:五类来源(系统 / 表单 / 人员 / 外部 / 全局)汇成一张参数表,服务于「走哪条路、谁来办」两个决策点。
事件:二十多个事件标记覆盖从表单保存、节点发送、退回撤销到流程结束的全过程;三种表达方式(配置 / 实体类 / 外挂)对应三类人的投入成本;十种执行类型覆盖从一条 SQL 到一次 WebApi 调用的跨度。
而把两者连起来的,是同一套 @key=value 参数语法——输入用它,输出也用它。
引擎只负责流转,业务通过参数与事件接进来。 这条边界划得越清楚,引擎就越不容易被业务细节污染,也就越经得起长期的版本迭代。这也解释了为什么这套核心结构能沿用至今:接口是稳的,变化留给参数和事件。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。