工作流
本章按使用顺序讲工作流子系统:定义流程、发布、启动实例、处理用户任务、等待外部事件、查询与控制实例,最后讲表达式、故障与补偿、自定义活动与存储。读者是要在应用里实现审批流、编排长时间运行业务的开发者。
完整 API 见 Workflow.Abstractions 包。
模型
工作流定义是一张有向图:节点(WorkflowNode)执行某种活动,转移(WorkflowTransition)连接节点并可带条件,变量(WorkflowVariableDefinition)声明实例的输入。实例(WorkflowInstance)是定义的一次执行,状态全部落在存储里;每执行一个节点留下一条节点实例(WorkflowNodeInstance)。需要等待的节点(人工审批、定时、信号、子流程、外部任务、重试、超时)以书签(WorkflowBookmark)挂起,书签被消费时从存储恢复执行;用持久化存储时,实例能跨越进程重启长时间等待。
值模型是 JSON(RFC-0021《工作流:持久化执行》):变量、节点属性、输入、输出、节点状态与书签载荷都是 JsonObject,存储以 JSON 文本持久化,序列化走源生成,Native AOT 下可用。时间一律是 UTC 的 DateTimeOffset。
引擎在抽象包里(ADR-0016)。它在实例锁内按「波次」推进:从队列逐个执行节点,直到队列空或实例不再运行,然后一次写回实例、节点实例与书签。活动在引擎这次操作所在的调用里执行(同一作用域、同一事务),看到的主体、租户与追踪都是这次调用的。Provider 实现三个存储、事务、实例锁和到期书签的轮询驱动。
事务
在一次调用里使用引擎、定义目录、任务服务或外部任务服务时,它们加入调用方的事务(ADR-0077):存储写入、活动与结果处理器的写入、事件都随业务一起提交或回滚。"写申请并发起流程"、"授予并完成实例"这样的组合是原子的:业务失败、调用回滚,实例、节点实例、书签都不留。
- 事件在事务里经作用域的
IMessagePublisher发出。装配了发件箱时随事务提交、回滚即丢弃;没有发件箱时直接发布,可能早于提交、回滚不撤回——要求一致的应用要装配发件箱,见消息与事件。 - 引擎取得的实例锁持有到调用的服务作用域结束(事务提交或回滚之后)才释放,别的节点拿到锁时读到的一定是提交后的状态。同一调用里再操作同一个实例直接复用这把锁。
- 只读调用(RFC-0005「只读工作单元」,如
get/list操作)里只能查询;写入在取锁、执行活动之前返回PERSISTENCE_UNEXPECTED_001(操作实现的缺陷,HTTP 500)。内存实现与数据库实现相同(ADR-0099):数据库实现看工作单元,内存实现看调用的操作契约,没装持久化也一样拒绝。 - 在调用之外使用(后台任务、托管服务、轮询器、定时器)时,引擎自己开一次经完整管道的调用(来源协议
workflow,目标是操作名,如workflow.start),在那次调用的事务里执行;租户取所在作用域的调用,没有即平台。应用不在服务状态时返回WORKFLOW_UNAVAILABLE_001。 - 活动失败按重试 / 继续 / 实例故障处理,它在失败之前做的写入留在同一事务里、不单独回滚:会写入的活动与结果处理器先校验、后写入。
Workflow.InMemory没有事务:写入立即生效,调用回滚不撤销。
分区
工作流的数据分成平台分区与每个租户一个分区(ADR-0077)。一次调用只读写自己的分区:分区是当前调用的租户,没有租户的调用(平台)是平台分区。平台与租户互不可见:租户看不到平台和别的租户的定义、实例与任务,平台也看不到任何租户的。看不到的一律按不存在处理(定义 WORKFLOW_NOTFOUND_001、实例 _002、书签 _003)。平台要管理某个租户的流程,以那个租户的身份发起调用。用 Workflow.Database 时租户的记录随租户分库,见多租户。
应用直接使用的四个服务都是作用域服务:
| 服务 | 用途 |
|---|---|
IWorkflowDefinitionCatalog | 定义的生命周期:草稿、发布、新版本、停用、归档、删除 |
IWorkflowEngine | 启动、恢复书签、信号,以及挂起、恢复、取消、终止、重试、改变量、跳转、迁移、清理 |
IWorkflowTaskService | 用户任务:待办查询、完成、转办、加签、认领与释放、退回、催办 |
IWorkflowExternalTaskService | 外部任务:外部工作者按主题领取、完成、失败、延长锁 |
启用
| 实现 | 存储 | 实例锁 | 适用 |
|---|---|---|---|
Workflow.InMemory(默认实现,Profiles.Full 已包含) | 有界内存表 | 进程内信号量 | 单实例、开发、测试;重启后实例丢失 |
| Workflow.Database | 应用的关系库(persistence.relational@1) | 数据库租约行 | 多实例、需要持久化 |
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Workflow.InMemory;
var builder = WebApplication.CreateBuilder(args);
builder.AddXiHan(xihan => xihan.UseFeature<InMemoryWorkflowFeature>());
var app = builder.Build();
app.UseXiHan();
app.Run();特性启动时起到期书签的轮询(缺省每秒一次),定时、超时、重试、提醒、定时启动都靠它推进。每个周期先看应用是否在服务状态:启动中与排空中管道不接受调用,这时的周期什么也不做、不记告警,进入服务状态后照常轮询与清理;周期已经开始、之后才进入排空的,下一个失败的分区调用就让这个周期停下,剩下的分区不再调用、不告警。数据库实现的轮询领导权也只在服务状态拿与续:排空中的节点在下一个周期就释放,另一个在服务的实例随即接手,不等本节点停完。
定义流程
用构造器
WorkflowDefinitionBuilder 以流式 API 生成定义。下面是一个请假审批:主管审批,超过 3 天的再由人事三人会签、60% 通过即可,主管 3 天不处理视为超时。
using XiHan.Framework.Workflow.Abstractions.Activities;
using XiHan.Framework.Workflow.Abstractions.Assignees;
using XiHan.Framework.Workflow.Abstractions.Definitions;
using XiHan.Framework.Workflow.Abstractions.Tasks;
public static class LeaveWorkflow
{
public static WorkflowDefinition Build() => WorkflowDefinitionBuilder.Create("hr.leave", "请假审批")
.WithCategory("hr")
.WithTimeout(TimeSpan.FromDays(7), WorkflowTimeoutAction.Terminate)
.AddVariable("applicant", required: true)
.AddVariable("days", required: true)
.AddVariable("approvers", required: true, description: "主管,逗号分隔")
.AddStart()
.AddUserTask("manager", "主管审批", node => node
.WithAssigneeRule(AssigneeRuleTypes.Expression, "approvers")
.WithProperty(UserTaskActivity.TitleProperty, "{{ applicant }} 请假 {{ days }} 天")
.WithProperty(UserTaskActivity.DueAfterSecondsProperty, 86400)
.WithTimeout(TimeSpan.FromDays(3)))
.AddDecision("route")
.AddUserTask("hr", "人事会签", node => node
.WithAssigneeRule(AssigneeRuleTypes.User, "hr-1", "hr-2", "hr-3")
.WithProperty(UserTaskActivity.CompletionPolicyProperty, "All")
.WithProperty(UserTaskActivity.ApprovalThresholdProperty, "60%"))
.AddEnd("approved", outcome: UserTaskOutcomes.Approved)
.AddEnd("rejected", outcome: UserTaskOutcomes.Rejected)
.AddTransition("start", "manager")
.AddTransition("manager", "route")
// 决策节点按优先级(小者先)取第一条成立的出边
.AddTransition("route", "hr", "outcome == 'approved' && days > 3")
.AddTransition("route", "approved", "outcome == 'approved'", priority: 1)
.AddDefaultTransition("route", "rejected")
.AddTransition("hr", "approved", "outcome == 'approved'")
.AddDefaultTransition("hr", "rejected")
.Build();
}构造器为 Start、End、Decision、Parallel、Join、Delay、UserTask、EventGateway、Http、ExternalTask、Compensate 提供专用方法,其余活动用 AddNode(id, activityType, displayName, configure) 加 WithProperty 设置属性。节点上的通用设置:WithRetry(maxAttempts, firstDelay, backoffFactor)、WithTimeout、WithContinueOnError、WithOutputMapping(variable, expression)。定义级设置:WithCompensation(取消时补偿)、WithCompensateOnFault(故障时也补偿)、WithTimeout(timeout, action)(实例级超时,Fault 或 Terminate)、WithCategory、WithDescription、WithProperties。
用 JSON
流程设计器交来的是 JSON。WorkflowJson.Serialize 与 WorkflowJson.DeserializeDefinition 做往返,属性名是 camelCase,枚举是字符串,时长是 TimeSpan 的常量格式:
{
"id": "draft",
"name": "hr.expense",
"version": 1,
"displayName": "报销",
"variables": [{ "name": "amount", "required": true }],
"nodes": [
{ "id": "start", "activityType": "Start" },
{
"id": "approve",
"activityType": "UserTask",
"displayName": "财务审批",
"properties": {
"AssigneeRules": [{ "type": "User", "values": ["finance-1"] }],
"Title": "报销 {{ amount }} 元"
},
"timeout": "2.00:00:00"
},
{
"id": "notify",
"activityType": "Http",
"properties": { "Url": "https://erp.localhost/expense", "Method": "POST", "Body": "{\"amount\": {{ amount }}}" },
"retryPolicy": { "maxAttempts": 3, "firstDelay": "00:00:10", "backoffFactor": 2 }
},
{ "id": "end", "activityType": "End" }
],
"transitions": [
{ "id": "t1", "sourceNodeId": "start", "targetNodeId": "approve" },
{ "id": "t2", "sourceNodeId": "approve", "targetNodeId": "notify", "condition": "outcome == 'approved'" },
{ "id": "t3", "sourceNodeId": "approve", "targetNodeId": "end", "isDefault": true },
{ "id": "t4", "sourceNodeId": "notify", "targetNodeId": "end" }
]
}using XiHan.Framework.Workflow.Abstractions.Serialization;
var parsed = WorkflowJson.DeserializeDefinition(File.ReadAllText("expense.json"));
// 不是合法 JSON 或名字不合文法时返回 WORKFLOW_VALIDATION_001
var definition = parsed.ValueOrThrow();id、version、status 在 CreateAsync 时由目录重新分配,JSON 里的值只是占位。节点没有 properties 或写成 null 时按空属性处理。
必填的成员只有:定义的 id、name,节点的 id、activityType,转移的 id、sourceNodeId、targetNodeId,变量的 name;没写报 workflow.definition.member_missing。其余成员都可省略,取模型的初值(ADR-0107):version 1、status Draft、nodes / transitions / variables 空列表、节点的 properties 空对象、重试策略的 maxAttempts 1、firstDelay 10 秒、backoffFactor 2,其余为空、false 或 0;写成 null 与不写相同。WorkflowJson.Serialize 写出全部成员(只省略值为 null 的),等于初值的也写出,读回来的值不变。
节点、转移与变量
| 元素 | 字段 |
|---|---|
WorkflowNode | Id(^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$,定义内唯一)、ActivityType、DisplayName、Properties(字符串值支持 {{ 表达式 }} 模板)、RetryPolicy、Timeout(挂起后多久超时)、ContinueOnError、OutputMappings(变量名 → 表达式) |
WorkflowTransition | Id、SourceNodeId、TargetNodeId、Condition(表达式,空为无条件)、Priority(小者先)、IsDefault |
WorkflowVariableDefinition | Name、Required、DefaultValue、Description |
定义名 WorkflowName 用主题文法 ^[a-z0-9][a-z0-9._-]{0,127}$,一个名字下有多个版本。活动类型 ActivityType 的文法是 ^[A-Za-z][A-Za-z0-9._-]{0,63}$,内置活动用 PascalCase 名。
出边与结果标签
节点以结果标签完成时(用户任务的 approved / rejected / timeout、事件网关的信号名、ContinueOnError 的 error、活动自定的标签),标签写进节点输出,也写进实例变量 outcome;当前节点与后继决策节点的出边条件都按它选边。没有标签的节点不覆盖 outcome。
出边选择由活动登记时的 ActivityOutgoing 决定:
| 模式 | 行为 | 使用者 |
|---|---|---|
AllMatched(缺省) | 取全部无条件或条件成立的出边;一条都没有时取默认出边 | 大多数活动 |
Exclusive | 按优先级取第一条无条件或条件成立的出边;没有时取默认出边 | Decision |
All | 取全部出边 | Parallel |
None | 不流出 | End、Terminate、Fault |
AllMatched 与 Exclusive 一条也选不到时实例故障。默认出边只作兜底,有条件成立时不走它。条件引用了未知变量是表达式错误,实例故障,不会悄悄当成假。
校验
WorkflowDefinitionValidator 在发布与启动前执行,失败返回 WORKFLOW_VALIDATION_001,details.errors 列出全部问题(每条的路径、消息键与参数见 设计期诊断):名字与版本合法;至少一个节点;节点 ID 唯一;活动类型都已登记;恰好一个 Start 且没有入边;转移两端存在;条件与输出映射的表达式语法合法;每个节点最多一条默认出边;Join 的 Mode、UserTask 的 CompletionPolicy、EmptyAssigneeStrategy 与 ApprovalThreshold 取值合法;End 的 Outcome 不是空串;每个 UserTask 至少一个指派来源且规则类型已登记;Start.Cron 是合法 cron;EventGateway 至少一个信号;Http 的 Url 非空、Method 合法;所有节点从 Start 可达。
发布与版本
目录按版本管理定义。同名定义的每个版本是一条独立记录,状态是 Draft、Published、Disabled 或 Archived:
| 方法 | 允许的状态 | 作用 |
|---|---|---|
CreateAsync(definition) | — | 新建草稿:新 ID,租户取当前调用的租户(写了别的租户 → WORKFLOW_NOTFOUND_001),版本号 = 该分区里同名最大版本 + 1 |
UpdateAsync(definition) | Draft | 整体替换草稿,名字、版本、租户不变 |
PublishAsync(ids) | Draft | 先校验再发布;带 Start.Cron 的定义同时排一个定时启动书签 |
NewVersionAsync(name) | — | 以当前分区里的最新版本为底复制出一个草稿 |
DisableAsync(ids) | Published | 停用,删除定时启动书签 |
ArchiveAsync(ids) | 任意 | 归档,删除定时启动书签 |
DeleteAsync(ids) | Draft | 删除草稿 |
FindAsync(id) / FindPublishedAsync(name, version) | — | 查询;version 为空取最新已发布版本,只在当前分区里找 |
QueryAsync(WorkflowDefinitionQuery) | — | 定义列表:摘要的一页与总数(WorkflowDefinitionPage),见下文「定义列表」 |
ActivityTypes() | — | 已登记活动的描述符(类型、显示名、分类、输入属性),供设计器列出可用节点;显示名、分类显示名(CategoryName)、说明与输入说明按调用方的区域渲染 |
状态不允许时返回 WORKFLOW_CONFLICT_002。按标识的操作都是批量、逐项结果,单条写法是 WorkflowDefinitionCatalogExtensions 里的扩展方法。Category 最多 128 个字符(WorkflowDefinition.MaxCategoryLength),超长构造即抛 ArgumentException,从 JSON 读入时 WorkflowJson.DeserializeDefinition 返回 WORKFLOW_VALIDATION_001。
定义列表
QueryAsync(WorkflowDefinitionQuery) 列当前分区的定义,返回这一页的摘要与同条件的总数(ADR-0096)。摘要 WorkflowDefinitionSummary 只有列表要的字段:Id、Name、Version、DisplayName、Description、Category、Status、TenantId、CreatedAt、PublishedAt,以及显示名或名字 Title;节点、转移、变量与扩展属性不在里面,要整份定义按 Id 调 FindAsync。
| 条件 | 说明 |
|---|---|
Keyword | 关键字:不区分大小写地包含在名字、显示名或说明里即匹配,规则见下文「关键字」 |
Name | 定义名 |
Category | 分类,精确匹配 |
Status | 状态 |
CreatedFrom / CreatedTo | 创建时间区间:下界含、上界不含(ADR-0102) |
Sorts | 排序项(字段加方向),有序、可多个,见下文「列表排序」;空或没给时名字升序、版本降序 |
Skip / Limit | 缺省 0 与 100,Limit 最大 1000(WorkflowDefinitionQuery.MaxLimit) |
条件同时给出时取交集。过滤、排序、分页与计数都由存储执行,数据库实现是 SQL。分页越界(Skip 为负、Limit 不在 1~1000)不在构造时抛出,QueryAsync 返回 WORKFLOW_VALIDATION_002(HTTP 400,消息键 workflow.definition.query_page_invalid),不进存储;排序字段越界同样是 400(见下文「列表排序」)。
var page = (await catalog.QueryAsync(new WorkflowDefinitionQuery
{
Keyword = "报销",
Category = "finance",
Status = WorkflowDefinitionStatus.Published,
CreatedFrom = monthStart,
CreatedTo = monthStart.AddMonths(1),
// 状态升序(生命周期),同状态的创建时间降序
Sorts = [new(WorkflowDefinitionSortField.Status), new(WorkflowDefinitionSortField.CreatedAt, Descending: true)],
Skip = 0,
Limit = 20,
}, cancellationToken)).ValueOrThrow();
// page.Items:这一页的摘要;page.Total:同条件的总数列表排序
定义查询与实例查询各有一个有序的排序项列表 Sorts(WorkflowDefinitionSort / WorkflowInstanceSort:白名单里的字段 Field 加方向 Descending,缺省升序,ADR-0102),可以多个字段(ADR-0104):第一项是主排序字段,第一项相同的按第二项,依此类推。排序在存储里执行(数据库实现是 SQL 的 ORDER BY),全部排序项之后接固定的次序键,同值的行在各页之间不重不漏。
| 查询 | 排序项的字段 | Sorts 为空或没给 | 次序键(排序项里已有的跳过) | 项数上限 |
|---|---|---|---|---|
WorkflowDefinitionQuery | Name(名字)、Title(显示名,没有取名字)、Version、Status、CreatedAt、PublishedAt | 名字升序、版本降序 | 名字升序、版本降序(数据库实现最后再按 ID) | WorkflowDefinitionQuery.MaxSorts = 6 |
WorkflowInstanceQuery | CreatedAt、EndedAt、Status、DefinitionName、Title(实例标题) | 创建时间降序 | 创建时间降序、写入先后降序;排序项里有创建时间时写入先后随它的方向 | WorkflowInstanceQuery.MaxSorts = 5 |
// 实例列表:状态升序(生命周期),同状态的定义名升序,再同的创建时间降序
var query = new WorkflowInstanceQuery
{
Sorts =
[
new(WorkflowInstanceSortField.Status),
new(WorkflowInstanceSortField.DefinitionName),
new(WorkflowInstanceSortField.CreatedAt, Descending: true),
],
Skip = page * 20,
Limit = 20,
};- 空列表与没给一样是缺省排序;每一项的方向缺省升序,实例列表要最新的在前得写
new(WorkflowInstanceSortField.CreatedAt, Descending: true)。 - 排序项不合法时
QueryAsync/ListInstancesAsync返回WORKFLOW_VALIDATION_002(HTTP 400),不进存储;按这个顺序检查、报第一个:项数超过上限(消息键workflow.definition.query_sorts_too_many/workflow.instance.query_sorts_too_many,参数count、max),某项为空或字段不在白名单——请求里给了越界的数字(…query_sort_invalid,参数sort),同一字段出现两次、不论方向(…query_sort_duplicate,参数sort)。CountInstancesAsync不看排序。请求里给不认识的字段名(如{"field": "starter"})在反序列化时就被拒绝。 - 文本(名字、标题、定义名)按数据库列的排序规则比较,内存实现按序数:大小写、标点与中文的先后可能随库不同。
- 状态按生命周期的先后(ADR-0103):定义
Draft→Published→Disabled→Archived;实例Running→Suspended→Completed→Canceled→Faulted→Terminated。升序是这个顺序、降序反过来,同状态按后面的排序项与次序键;状态变了(发布、停用、挂起、恢复、取消、终止、重试……)位置随之变化。 - 没发布过的定义
PublishedAt为空、没结束的实例EndedAt为空:空值排在哪头照数据库(PostgreSQL、Oracle 当作最大,SQLite、MySQL、SQL Server 当作最小),内存实现当作最小。 - 标题按前 256 个字符比较(
WorkflowSortKeys.Title)。
关键字
列表查询的 Keyword 都按同一套规则匹配(WorkflowKeyword):去掉首尾空白,为空或只有空白不过滤;关键字与被搜的文本先折叠成不变文化的小写,再判断包含,任一被搜字段包含即匹配,不跨字段。关键字里的 %、_、[、\ 按字面匹配,与全角的 %、_、[、\ 是不同的字符;内存实现与各数据库 Provider 的结果一致——按字面由持久化保证(持久化里规约的字符串匹配,ADR-0098),小写折叠抹平各库大小写规则的差异。数据库的排序规则另有放宽(如 MySQL 缺省排序规则不区分重音)时以排序规则为准。SqlSugar 在兼容库(达梦、人大金仓、openGauss、TiDB 等)上维持原来的翻译,那里关键字里的 %、_ 会当通配,与应用自己实体的查询相同;这是已定的,这些库在真实库上验证之后由持久化加进名单,工作流不另做绕法(ADR-0101)。
三处查询各自匹配的字段(ADR-0099):
| 查询 | 匹配的字段 |
|---|---|
定义列表 WorkflowDefinitionQuery | 名字、显示名、说明 |
实例列表 WorkflowInstanceQuery | 实例标题、关联 ID |
待办 UserTaskQuery | 任务标题、实例标题、定义名、关联 ID(所属实例的) |
关联 ID 是按包含匹配的,搜索框里输单据号的一段就能找到;要按一组单据号精确查进度,用实例查询的 CorrelationIds。
定义的租户
定义属于一个分区(TenantId 是分区的租户,平台为空)。每个分区是一个名字空间:同名定义在不同分区里各自从版本 1 编号,互不占号。调用只看得到、改得动自己分区的定义,按名字解析(启动、FindPublishedAsync、子流程与 ForEach)也只在自己的分区里找。看不到的定义一律按不存在处理,返回 WORKFLOW_NOTFOUND_001,不暴露别的分区有没有这个定义。
没有全局定义:平台建的定义对租户不可见(ADR-0077 取消了 ADR-0066 的全局定义)。各租户共用的流程模板要在每个租户里各建一份,由应用的租户初始化(种子)负责。
using XiHan.Framework.Primitives;
using XiHan.Framework.Workflow.Abstractions.Definitions;
public sealed class LeaveSetup(IWorkflowDefinitionCatalog catalog)
{
public async ValueTask<Result<WorkflowDefinition>> PublishAsync(CancellationToken cancellationToken)
{
var created = await catalog.CreateAsync(LeaveWorkflow.Build(), cancellationToken);
if (!created.TryGetValue(out var draft))
{
return created;
}
return await catalog.PublishAsync(draft.Id, cancellationToken);
}
}发布新版本不会改变旧版本的状态。按名字启动时取最新的已发布版本,已经在跑的实例留在原来的版本上,需要时用 MigrateAsync 迁到新版本。只想让新实例停用旧版本时,对旧版本调用 DisableAsync。
启动实例
using System.Text.Json.Nodes;
using XiHan.Framework.Primitives;
using XiHan.Framework.Workflow.Abstractions.Engine;
using XiHan.Framework.Workflow.Abstractions.Instances;
public sealed class LeaveApplications(IWorkflowEngine engine)
{
public ValueTask<Result<WorkflowInstance>> ApplyAsync(string applicant, int days, IReadOnlyList<string> approvers, string requestNo, CancellationToken cancellationToken)
=> engine.StartAsync(new WorkflowStartRequest
{
Name = "hr.leave",
Variables = new JsonObject
{
["applicant"] = applicant,
["days"] = days,
["approvers"] = string.Join(",", approvers),
},
StarterId = applicant,
CorrelationId = requestNo,
// 同一申请单号已有未结束的实例时拒绝,防止重复提交
ReusePolicy = WorkflowReusePolicy.RejectActive,
}, cancellationToken);
}WorkflowStartRequest 的字段:
| 字段 | 说明 |
|---|---|
Name + Version / DefinitionId | 找定义;Version 为空取最新已发布版本,只在调用方的分区里找。定义不存在、不是 Published 或在别的分区 → WORKFLOW_NOTFOUND_001 |
Variables | 初始变量;声明了的变量缺省时补默认值,缺必填 → WORKFLOW_VALIDATION_002 |
StarterId | 发起人,Starter 指派规则与 ExcludeStarter 用它 |
CorrelationId | 业务关联 ID,信号按它匹配,重用策略按它查重 |
ReusePolicy | AllowDuplicate(缺省)、RejectActive(已有未结束实例 → WORKFLOW_CONFLICT_005)、ReturnActive(返回已有实例);没有 CorrelationId 时忽略。查重只看同一分区的实例;查重与写入在同一把实例锁里,并发的同键启动只建一个实例;等锁超时是 WORKFLOW_TIMEOUT_001 |
StartAt | 延迟启动:开始节点以定时书签挂起到该时刻 |
DisplayName、InstanceId | 实例显示名与指定的实例 ID |
实例属于调用方的分区(租户取当前调用,平台为空)。返回的是第一个波次结束后的实例:流程一路走到 End 时状态已是 Completed,停在用户任务上时是 Running。定义有实例级超时时,启动时就排好 InstanceTimeout 书签,到期按 TimeoutAction 让实例故障或终止。
信号启动与定时启动
Start 节点的两个属性让定义自己启动:
SignalName:SignalAsync(name, payload, correlationId)在恢复匹配的信号书签之后,为每个Start.SignalName等于该名字的已发布定义(调用方分区的)启动一个实例,载荷写进变量signal,关联 ID 是信号的关联 ID。Cron(可带TimeZone):发布时建一个定义级DefinitionTimer书签,到期由轮询启动一个实例并推算下一次;停用、归档、删除定义时删除书签。文法与调度相同,见调度与后台作业。
using XiHan.Framework.Workflow.Abstractions.Activities;
using XiHan.Framework.Workflow.Abstractions.Definitions;
var onOrder = WorkflowDefinitionBuilder.Create("orders.fulfil")
.AddNode("start", ActivityTypes.Start, configure: node => node.WithProperty(StartActivity.SignalNameProperty, "order.created"))
.AddExternalTask("ship", "warehouse.ship")
.AddEnd()
.AddTransition("start", "ship").AddTransition("ship", "end")
.Build();
var nightly = WorkflowDefinitionBuilder.Create("finance.reconcile")
.AddNode("start", ActivityTypes.Start, configure: node => node
.WithProperty(StartActivity.CronProperty, "0 0 2 * * *")
.WithProperty(StartActivity.TimeZoneProperty, "Asia/Shanghai"))
.AddNode("log", ActivityTypes.Log, configure: node => node.WithProperty(LogActivity.MessageProperty, "reconcile started"))
.AddEnd()
.AddTransition("start", "log").AddTransition("log", "end")
.Build();处理用户任务
UserTask 节点挂起时为每个指派人建一个用户任务书签,任务 ID 就是书签 ID。指派来源有三个,合并后去重保序:
| 属性 | 说明 |
|---|---|
Assignees | 指派人列表 |
AssigneesExpression | 求值为列表或逗号分隔字符串的表达式 |
AssigneeRules | 规则列表 [{ "type": ..., "values": [...] }];内置类型 User(原样)、Starter(发起人)、Expression(表达式求值) |
其余属性:
| 属性 | 缺省 | 说明 |
|---|---|---|
CompletionPolicy | Any | Any 任一人通过即通过;All 会签;Sequential 依次逐个审批 |
ApprovalThreshold | 全部 | All 下的通过阈值:人数(如 2)或百分比(如 "60%",按指派人数向上取整);剩余人数不可能达到时以 rejected 完成 |
ExcludeStarter | false | 从指派人里去掉发起人 |
EmptyAssigneeStrategy | Fault | 没有指派人时:Fault 节点故障,AutoApprove、AutoReject,Fallback 改用 FallbackAssigneeRules |
Title、FormKey | 节点显示名、空 | 模板,写进任务视图 |
FormData、Priority | 空 | 表单数据、优先级 |
DueAfterSeconds / DueAt | 空 | 截止时长或截止时刻,DueAt 优先;只写进任务视图,不自动完成任务 |
ReminderAfterSeconds | 空 | 每隔该时长发布一次 workflow.task.reminder,直到任务结束 |
CcUserIds / CcRules | 空 | 抄送人,写进任务事件 |
AllowedOutcomes | approved、rejected | 允许的完成结果 |
节点的 Timeout 另插一个超时书签,到期以 outcome = "timeout" 完成节点。Sequential 一次只有当前指派人的一个任务。All 下每个人完成时合并其变量并保留其余人的任务,达到阈值才完成节点;任一人给出非 approved 的结果立即以该结果完成节点。
IWorkflowTaskService 的操作:
| 方法 | 作用 | 主要错误 |
|---|---|---|
PendingAsync(assigneeId) / PendingByInstanceAsync(instanceId) | 某人或某实例的待办 | — |
QueryAsync(UserTaskQuery) | 指派人或实例必填其一,可按定义名、节点、创建时间与关键字 Keyword(任务标题、实例标题、定义名或关联 ID 任一包含即匹配,规则见上文「关键字」,ADR-0096、ADR-0099)过滤,按创建时间升序或降序(Descending)分页;返回 UserTaskPage(Items 这一页、Total 总数) | WORKFLOW_VALIDATION_002(缺条件;Skip 为负或 Limit 不在 1 到 1000) |
CompleteAsync(completions) | 完成任务,可带结果、意见与变量 | WORKFLOW_DENIED_001(不是指派人)、WORKFLOW_VALIDATION_002(结果不允许)、WORKFLOW_NOTFOUND_003 |
TransferAsync(transfers) | 转办给另一个人 | WORKFLOW_CONFLICT_004(目标已是指派人) |
AddAssigneesAsync(additions) | 加签:Any / All 立即加任务,Sequential 排到队尾 | WORKFLOW_CONFLICT_004 |
ClaimAsync(claims) / ReleaseAsync(releases) | Any 策略下认领(撤下其他人的任务)与释放 | WORKFLOW_CONFLICT_004(已被认领)、WORKFLOW_VALIDATION_002(不是 Any) |
ReturnAsync(returns) | 退回:目标缺省为本节点之前最近完成的用户任务节点,没有则退回 Start 之后的首个节点;以跳转实现 | WORKFLOW_VALIDATION_002 |
UrgeAsync(urges) | 催办:发布 workflow.task.urged,不改状态 | — |
任务标题是节点 Title 模板渲染的结果(缺省节点名),实例标题是启动时给的显示名、没给取定义的显示名、再没有取定义名。查询只返回当前分区、实例处于运行中的任务:实例挂起或故障时它的任务退出结果,恢复后回来。过滤、排序与分页都由存储执行(数据库实现是 SQL),Total 是同条件的总数;Limit 缺省 100、最大 1000(UserTaskQuery.MaxLimit,与持久化的查询条数上限一致),越界在查询时返回校验错误,HTTP 是 400。节点实例的 State 记录 assignees、approved、rejected、claimedBy 与 history(每条含 actorId、outcome、comment、at),审批记录从这里读。
using XiHan.Framework.Primitives;
using XiHan.Framework.Workflow.Abstractions.Instances;
using XiHan.Framework.Workflow.Abstractions.Tasks;
public sealed class Inbox(IWorkflowTaskService tasks)
{
// 第 page 页(从 0 起),最新的在前;Total 用来算页数
public ValueTask<Result<UserTaskPage>> ListAsync(string userId, int page, CancellationToken cancellationToken)
=> tasks.QueryAsync(new UserTaskQuery { AssigneeId = userId, Descending = true, Skip = page * 20, Limit = 20 }, cancellationToken);
// 待办搜索框:任务标题、流程标题、定义名或单据号(关联 ID)包含关键字即命中
public ValueTask<Result<UserTaskPage>> SearchAsync(string userId, string keyword, int page, CancellationToken cancellationToken)
=> tasks.QueryAsync(new UserTaskQuery { AssigneeId = userId, Keyword = keyword, Descending = true, Skip = page * 20, Limit = 20 }, cancellationToken);
public ValueTask<Result<WorkflowInstance>> ApproveAsync(string taskId, string userId, string? comment, CancellationToken cancellationToken)
=> tasks.CompleteAsync(new UserTaskCompletion(taskId, userId, UserTaskOutcomes.Approved, comment), cancellationToken);
public ValueTask<Result> HandOverAsync(string taskId, string userId, string colleague, CancellationToken cancellationToken)
=> tasks.TransferAsync(new UserTaskTransfer(taskId, userId, colleague, "出差,转同事处理"), cancellationToken);
public ValueTask<Result<WorkflowInstance>> SendBackAsync(string taskId, string userId, CancellationToken cancellationToken)
=> tasks.ReturnAsync(new UserTaskReturn(taskId, userId, Comment: "材料不全"), cancellationToken);
}自定义指派规则
组织架构、角色这类规则由应用实现 IAssigneeProvider 并按类型登记,同类型重复登记是编程错误:
using XiHan.Framework.Primitives;
using XiHan.Framework.Workflow.Abstractions;
using XiHan.Framework.Workflow.Abstractions.Assignees;
public interface IOrgChart
{
ValueTask<Result<IReadOnlyList<string>>> ManagersOfAsync(IReadOnlyList<string> departments, CancellationToken cancellationToken);
}
public sealed class DepartmentManagerProvider(IOrgChart org) : IAssigneeProvider
{
public string RuleType => "DepartmentManager";
public Result Validate(AssigneeRule rule) => Result.Success();
// 失败原样返回:不能用空列表掩盖组织数据不可用
public ValueTask<Result<IReadOnlyList<string>>> ResolveAsync(AssigneeResolution resolution, CancellationToken cancellationToken)
=> org.ManagersOfAsync(resolution.Rule.Values, cancellationToken);
}builder.Services.AddAssigneeProvider<DepartmentManagerProvider>() 登记后,节点里写 WithAssigneeRule("DepartmentManager", "finance")。规则类型未登记时校验与执行都返回 WORKFLOW_NOTFOUND_004;Provider 返回失败时节点故障。
等待与恢复
书签的种类(BookmarkKinds):
| 种类 | 由谁建 | 怎样恢复 |
|---|---|---|
UserTask | 用户任务 | IWorkflowTaskService.CompleteAsync |
Timer | Delay、延迟启动 | 到期由轮询恢复 |
Signal | WaitSignal、EventGateway | SignalAsync |
SubWorkflow | SubWorkflow、ForEach | 子实例结束时回调父实例 |
Retry | 节点重试 | 到期由轮询恢复 |
NodeTimeout | 节点 Timeout | 到期由轮询恢复 |
External | ExternalTask | 外部工作者完成;锁到期由轮询解锁 |
Reminder | 用户任务提醒 | 引擎直接处理 |
InstanceTimeout | 定义 Timeout | 引擎直接处理 |
DefinitionTimer | Start.Cron | 引擎直接处理,InstanceId 是定义 ID |
ResumeAsync(bookmarkId, inputs, expectedKey) 消费一个书签并恢复节点;实例不在运行中,或 expectedKey 与书签的 Key 不同时返回 WORKFLOW_CONFLICT_003,轮询器遇到这类书签把到期时间后推 NotResumableBackoff(缺省 5 分钟)。SignalAsync(name, payload, correlationId) 消费当前分区里匹配的全部信号书签(书签的 CorrelationId 为空时接受任意关联),再按信号启动规则启动实例,返回消费数加启动数。
using System.Text.Json.Nodes;
using XiHan.Framework.Workflow.Abstractions.Engine;
public sealed class PaymentCallbacks(IWorkflowEngine engine)
{
// 支付网关回调:唤醒在等 payment.received 信号的、关联 ID 为该订单号的实例
public async ValueTask<int> PaidAsync(string orderNo, decimal amount, CancellationToken cancellationToken)
=> (await engine.SignalAsync("payment.received", new JsonObject { ["amount"] = amount }, orderNo, cancellationToken)).ValueOrThrow();
}外部任务
ExternalTask 活动把一段工作交给外部工作者(另一个进程、另一种语言写的服务),按 Camunda 外部任务的方式工作:
using System.Text.Json.Nodes;
using XiHan.Framework.Workflow.Abstractions.ExternalTasks;
public sealed class ShippingWorker(IWorkflowExternalTaskService external)
{
public async ValueTask RunOnceAsync(CancellationToken cancellationToken)
{
// 领取并加锁 5 分钟:锁内只有本工作者能完成或失败它
var fetched = (await external.FetchAsync("warehouse.ship", "worker-1", TimeSpan.FromMinutes(5), 10, cancellationToken)).ValueOrThrow();
foreach (var task in fetched)
{
var order = task.Payload?["order"]?.GetValue<string>();
if (order is { Length: > 0 })
{
await external.CompleteAsync(new ExternalTaskCompletion(task.Id, "worker-1", new JsonObject { ["shipped"] = true }), cancellationToken);
}
else
{
// 带 RetryAfter:解锁并推迟,任务的 Attempt 加一;不带则节点以该错误故障,按节点重试策略处理
await external.FailAsync(new ExternalTaskFailure(task.Id, "worker-1", "order missing", TimeSpan.FromMinutes(1)), cancellationToken);
}
}
}
}领取只返回当前分区里该主题未锁定(或锁已过期)且已到可领取时刻的任务,按创建时间最多 maxTasks 条。maxTasks 取 1 到 IWorkflowExternalTaskService.MaxTasksPerFetch(100),越界返回 WORKFLOW_VALIDATION_002;条数与分区、可领取时刻一起下推到存储查询。候选在实例锁内重读确认,被别的工作者抢先或实例已不在运行中的跳过,所以一次领到的可能少于 maxTasks,空手而归不代表主题下没有任务。ExtendLockAsync 延长锁。没有持有未过期的锁时完成、失败、延长都返回 WORKFLOW_DENIED_002。锁到期的任务由轮询自动解锁,别的工作者可以重新领取。
查询实例
实例与节点实例的查询用引擎(作用域服务)的 FindInstanceAsync、ListInstancesAsync、CountInstancesAsync、NodeInstancesAsync、BookmarksAsync。它们只查调用方的分区:租户的调用只查得到本租户的实例,平台的调用只查得到平台的实例;按 ID 找别的分区的实例为无值或 WORKFLOW_NOTFOUND_002。
using XiHan.Framework.Workflow.Abstractions.Engine;
using XiHan.Framework.Workflow.Abstractions.Instances;
public sealed class LeaveReports(IWorkflowEngine engine)
{
public async ValueTask<(IReadOnlyList<WorkflowInstance> Items, long Total)> PendingAsync(int page, CancellationToken cancellationToken)
{
// 分区取自当前调用,查询里没有租户条件
var query = new WorkflowInstanceQuery
{
DefinitionName = "hr.leave",
Statuses = [WorkflowInstanceStatus.Running, WorkflowInstanceStatus.Suspended],
Skip = page * 20,
Limit = 20,
};
var items = (await engine.ListInstancesAsync(query, cancellationToken)).ValueOrThrow();
var total = (await engine.CountInstancesAsync(query, cancellationToken)).ValueOrThrow();
return (items, total);
}
// 节点实例按开始时间升序,就是流程走过的轨迹
public async ValueTask<IReadOnlyList<WorkflowNodeInstance>> TrailAsync(string instanceId, CancellationToken cancellationToken)
=> (await engine.NodeInstancesAsync(instanceId, cancellationToken)).ValueOrThrow();
// 实例在等什么:用户任务、定时、信号、子流程、超时……(实例详情的「待恢复」)
public async ValueTask<IReadOnlyList<WorkflowBookmarkSummary>> WaitingAsync(string instanceId, CancellationToken cancellationToken)
=> (await engine.BookmarksAsync(instanceId, cancellationToken)).ValueOrThrow();
}WorkflowInstanceQuery 按状态(Status 或 Statuses)、定义名、关联 ID 集合、父实例、只取顶层、发起人、关键字、创建时间范围、结束时间上界过滤,同时给出的取交集,按排序项 Sorts 排序(可多个,缺省创建时间降序,见上文「列表排序」);过滤、排序、分页与计数都由存储执行(数据库实现是 SQL)。Skip 缺省 0,Limit 缺省 100、最大 1000(WorkflowInstanceQuery.MaxLimit)。分页越界(Skip 为负、Limit 不在 1~1000)不在构造时抛出,ListInstancesAsync 返回 WORKFLOW_VALIDATION_002(HTTP 400),不进存储;CountInstancesAsync 不看分页(ADR-0082)。
| 条件 | 说明 |
|---|---|
CorrelationIds | 一组关联 ID,实例的关联 ID 是其中之一即匹配;重复的只算一个。null 不过滤,空集合不匹配任何实例(列表为空、计数为 0,不进存储)。最多 1000 个(WorkflowInstanceQuery.MaxCorrelationIds),不能有空白的 ID,否则列表与计数都返回 WORKFLOW_VALIDATION_002(消息键 workflow.instance.query_correlations_invalid) |
TopLevelOnly | 只取顶层实例(ParentInstanceId 为空)。子流程继承父实例的发起人与关联 ID,「我发起的」与按单据查进度都要加它 |
Keyword | 关键字:不区分大小写地包含在实例标题 Title(启动时给的 DisplayName,没给取定义的显示名,再没有取定义名)或关联 ID 里即匹配(ADR-0099),规则见上文「关键字」 |
Sorts | 排序项(ADR-0102、ADR-0104):字段 CreatedAt、EndedAt、Status、DefinitionName、Title 加方向,有序、最多 5 项、每个字段一次;空或没给时创建时间降序 |
// 审批中心「我发起的」:只取顶层,带标题搜索
var mine = new WorkflowInstanceQuery { StarterId = userId, TopLevelOnly = true, Keyword = keyword, Skip = page * 20, Limit = 20 };
// 实例列表点「结束时间」列升序
var finished = new WorkflowInstanceQuery { Sorts = [new(WorkflowInstanceSortField.EndedAt)], Skip = page * 20, Limit = 20 };
var items = (await engine.ListInstancesAsync(mine, cancellationToken)).ValueOrThrow();
var total = (await engine.CountInstancesAsync(mine, cancellationToken)).ValueOrThrow();
// 一页单据的审批进度:一次查询,按关联 ID 分组
var progress = (await engine.ListInstancesAsync(new WorkflowInstanceQuery
{
DefinitionName = "permission-request",
CorrelationIds = requests.Select(static r => r.Id.ToString()).ToList(),
TopLevelOnly = true,
Limit = WorkflowInstanceQuery.MaxLimit,
}, cancellationToken)).ValueOrThrow().ToLookup(static instance => instance.CorrelationId);实例的 Status 是 Running、Suspended、Completed、Canceled、Faulted 或 Terminated,故障信息在 Fault(错误码、消息键与参数,展示见 故障原因的展示)、FaultNodeId、FaultNodeInstanceId,End 节点给出的结果在 Outcome。
控制实例
| 方法 | 允许的状态 | 作用 |
|---|---|---|
SuspendAsync(ids, reason) | Running | 挂起,到期书签暂不推进 |
ResumeInstancesAsync(ids) | Suspended | 恢复;不重新驱动书签,到期书签由轮询接续 |
CancelAsync(ids, reason) | Running、Suspended、Faulted | 取消:清掉未结束节点与全部书签,EnableCompensation 时补偿,级联到未结束的子流程 |
TerminateAsync(ids, reason) | Running、Suspended、Faulted | 终止:同取消,但不补偿 |
RetryAsync(ids) | Faulted 且有故障节点 | 清故障,从故障节点再执行一次(Attempt + 1) |
SetVariablesAsync(updates) | Running、Suspended | 合并变量、删除指定键 |
JumpAsync(jumps) | Running、Suspended、Faulted | 取消全部未结束节点与书签,从目标节点重新执行 |
MigrateAsync(migrations) | Running、Suspended | 迁到同名的另一个已发布版本;未结束节点在目标版本里必须存在且活动类型相同 |
PurgeAsync(request) | 已结束 | 删除早于给定时刻结束的实例及其节点实例与书签,可按状态与定义名过滤,一次最多 Limit 条(1~1000,缺省 1000;越界返回 WORKFLOW_VALIDATION_002,不开事务) |
调用只操作得了自己分区的实例:别的分区的实例按不存在处理,返回 WORKFLOW_NOTFOUND_002,PurgeAsync 只清自己的分区;按书签 ID 的 ResumeAsync 遇到别的分区的书签返回 WORKFLOW_NOTFOUND_003。用户任务、外部任务与信号同样只在自己的分区里匹配。状态不允许时返回 WORKFLOW_CONFLICT_001;已经结束的实例上取消与终止幂等返回。跳转目标不存在、迁移目标不兼容返回 WORKFLOW_VALIDATION_002;死汇合、波次上限这类引擎级故障没有故障节点,不能重试。所有控制操作都是批量、逐项结果,单条写法在 WorkflowEngineExtensions 里。XiHan:Workflow:RetentionDays 大于 0 时,轮询器每 HousekeepingIntervalSeconds 逐个分区自动清理一次。
内置活动
AddWorkflowEngine 登记 19 个内置活动(ActivityTypes):
| 活动 | 出边 | 主要属性 |
|---|---|---|
Start | AllMatched | SignalName、Cron、TimeZone |
End | None | Outcome:先调用全部 IWorkflowOutcomeHandler,再结束实例 |
Terminate | None | Reason |
SetVariable | AllMatched | Values(字面量对象)、Expressions(变量名 → 表达式) |
Decision | Exclusive | — |
Parallel | All | — |
Join | AllMatched | Mode:WaitAll(缺省,入边全部到达才执行)/ WaitAny(首个到达执行) |
Delay | AllMatched | Duration(秒或表达式)、Until(时刻表达式)、Cron + TimeZone |
UserTask | AllMatched | 见处理用户任务 |
PublishEvent | AllMatched | EventName、Payload:发布 workflow.custom 消息 |
WaitSignal | AllMatched | SignalName、AcceptAnyCorrelation |
EventGateway | AllMatched | Signals、TimeoutSeconds、AcceptAnyCorrelation:先到的信号胜出,结果标签是信号名或 timeout,输出 signal、payload |
SubWorkflow | AllMatched | DefinitionName、DefinitionVersion、Variables、VariableExpressions、WaitForCompletion、ResultVariable、FailOnChildFault |
ForEach | AllMatched | Items(求值为数组)、ItemVariable、Parallel、FailFast,子流程属性同 SubWorkflow |
Log | AllMatched | Message、Level |
Fault | None | Message:让实例故障 |
Compensate | AllMatched | NodeIds:对指定的已完成节点(缺省为之前全部已完成节点)逆序补偿后继续 |
Http | AllMatched | Url、Method、Headers、Body、ContentType、TimeoutSeconds、ResultVariable(缺省 http)、FailOnErrorStatus(缺省 true);输出 { status, headers, body },JSON 响应解析为对象 |
ExternalTask | AllMatched | Topic、Payload;锁的时长由领取方在 FetchAsync 里给出 |
Http 经单例 WorkflowHttpClient 发请求,超时缺省 XiHan:Workflow:HttpTimeoutSeconds(30 秒)。需要代理或认证处理器时,在装配前注册自己的实例 new WorkflowHttpClient(httpClient) 替换它(HttpClient.Timeout 设成无限,超时由活动给出)。框架不提供 Script 活动:表达式语言与 SetVariable 覆盖了常见场景,脚本引擎属于第三方依赖。
地址由流程定义给出,不可信:缺省的客户端过出站地址策略,只能连公网地址,重定向的目标同样检查。目标被拒绝时节点故障 WORKFLOW_UNEXPECTED_001,消息键 workflow.http.address_denied,参数 host 是请求里的主机名。流程要调内网服务时,把服务写进 XiHan:Security:Outbound:AllowedHosts。注册自己的 WorkflowHttpClient 之后,由它决定能连哪里。
超时由活动自己计时、按令牌归因(RFC-0021,ADR-0097):TimeoutSeconds 按引擎的 TimeProvider 落成定时取消,与这次调用的取消令牌链接后交给客户端,时限从发出请求到读完响应体。
| 情形 | 结果 |
|---|---|
| 活动的超时令牌已触发(不论调用方是否也已取消,不论客户端抛出取消还是处理器接住取消后交回了响应) | 节点故障 WORKFLOW_UNEXPECTED_001,消息键 workflow.http.timed_out(没有参数),响应不写进变量 |
| 只有调用方取消 | 抛出 OperationCanceledException:整个操作随调用回滚,不记成节点故障;交回的响应同样不写进变量 |
两个都没触发,客户端抛出取消(它自己的超时)或 TimeoutException | 同超时 |
HttpRequestException | 同码故障,details.reason 只写异常类型的全名,完整异常进日志(故障会存进实例、经接口返回,连接异常的消息常带目标主机与端口,ADR-0115) |
测试里注入 FakeTimeProvider(在装配工作流特性之前登记为 TimeProvider),推进时钟即让请求超时,不用真实等待。
子流程的子实例在父实例的分区里,继承关联 ID 与发起人,深度加一,超过 MaxSubWorkflowDepth(缺省 16)时返回 WORKFLOW_VALIDATION_002;子实例结束时以 childInstanceId、childStatus、childVariables、childFault(子实例 Fault 的 JSON,没有故障为空)回调父实例;子实例没有正常完成而父节点因此失败时,父节点的故障是 workflow.child.failed(参数 id、status),子实例的故障是它的内层原因。
表达式
条件、模板、输出映射、表达式指派都用同一个无依赖的小语言(IWorkflowExpressionEvaluator):
| 类别 | 内容 |
|---|---|
| 运算符 | ||、&&、==、!=、<、<=、>、>=、+、-、*、/、%、一元 ! 与 - |
| 访问 | 成员 .x、索引 [i] |
| 字面量 | 数字(decimal)、单双引号字符串、true、false、null |
| 函数(不区分大小写) | len、contains、startsWith、endsWith、upper、lower、trim、isNullOrEmpty、abs、min、max、round、toNumber、toString、now、date |
+ 任一侧是字符串即拼接;== 跨数字类型宽松比较,字符串按序数比较。标识符是实例变量,输出映射里另有 outputs(节点输出)。节点属性里的字符串支持 {{ 表达式 }} 模板,引号内的 }} 不算结束。
空值的语义(ADR-0059):Evaluate 与 ActivityContext.EvaluateProperty 返回 Result<Maybe<JsonNode>>。结果为 null,包括字面量 null、缺少的成员或键、值为 null 的变量、没有这个属性,都是成功的无值,不是失败。下游的处理:
EvaluateCondition遇到无值失败,因为无值不是布尔。Render把无值渲染成空串。- 写回变量、输出与子流程变量时,无值写成 JSON 的
null。
表达式最长 4000 个字符,嵌套最多 64 层(括号、函数实参、索引、一元运算符都算,a + b + c 这样的链不算)。超限在发布时进校验结果,运行时是 WORKFLOW_VALIDATION_003(workflow.expression.too_long、workflow.expression.too_deep)。
未知的根变量是错误,不是无值:amount > 100 在没有 amount 变量时返回 WORKFLOW_VALIDATION_003,实例故障;判断可选变量用在启动时给默认值,或者把它放进对象里按成员访问。已解析的表达式按文本缓存,上限 ExpressionCacheSize(缺省 10000),满了整体清空重来。
故障、重试与补偿
节点失败(活动返回失败的 Result,或抛出异常并被翻译为 WORKFLOW_UNEXPECTED_001)时按顺序处理:
- 有重试策略且未用尽:节点记为
Faulted,插入Retry书签,第 n 次尝试失败后等FirstDelay × BackoffFactor^(n−1),实例保持运行。WorkflowRetryPolicy.MaxAttempts含首次(至少 1,1 为不重试),FirstDelay缺省 10 秒,BackoffFactor至少 1、缺省 2。 ContinueOnError:节点记为Faulted,变量lastError写入这次错误的故障结构(code、message、messageKey、arguments、inner),以outcome = "error"流出。表达式按lastError.code判断(message是按进程默认区域渲染的文本,只适合判空);展示用WorkflowJson.DeserializeFault(instance.Variables[WorkflowEngine.LastErrorVariable])?.Localize()。- 否则实例
Faulted,记录Fault、FaultNodeId、FaultNodeInstanceId;CompensateOnFault时执行补偿。之后可以RetryAsync从故障节点重来,或JumpAsync改走别的节点。
补偿按完成顺序的逆序调用已完成节点的 ICompensableActivity.CompensateAsync,节点状态改为 Compensated。三个入口:EnableCompensation 的定义被取消时、CompensateOnFault 的定义故障时、流程走到 Compensate 节点时(BPMN 的补偿抛出事件)。
一个波次执行的节点数超过 MaxNodeExecutionsPerBurst(缺省 1000)时实例故障,防止条件写错导致的死循环;波次结束时仍有凑不齐的汇合而又没有书签,实例同样故障。
故障原因的展示
实例与节点实例上的 Fault(WorkflowFault)以及故障事件里的 Fault 存错误码 Code、消息键 MessageKey、参数 Arguments(值按不变文化转成字符串)与内层原因 Inner,面向人的文案不存进库(RFC-0010,ADR-0105)。Message 是写入时按进程默认区域渲染的文本,只供日志用,不要直接展示给用户。
| 场景 | 写法 |
|---|---|
| 在一次调用里展示(接口返回的视图、时间线) | instance.Fault?.Localize():按当前消息作用域渲染,调用链里就是调用方的区域;有内层原因时以 workflow.fault.caused_by 接上 |
| 把故障作为错误返回给调用方 | instance.Fault.ToError():带键、参数与内层的错误,宿主在 Egress 按调用方的区域翻译 |
| 在调用之外按收件人的区域渲染(通知、邮件) | using (MessageScope.Use(null, recipientLocale)) { text = fault.Localize(); } |
引擎与内置活动的失败原因都是消息键:节点超时 workflow.node.timed_out、活动属性不合法按种类各有键(workflow.activity.property_*)、指派规则、表达式的结果不是布尔、HTTP 超时、Fault 节点 workflow.activity.fault_raised、没有开始节点等。活动抛出时 reason 只写异常类型的全名,完整异常进日志(ADR-0115);要让调用方看到说明,活动返回失败,不靠抛异常。应用或第三方给的文本——HTTP 的原因短语、外部任务工作者报的错误、Fault 节点作者写的消息——原样放在参数里,外层的键照常本地化。表达式在运行期出错(未知变量、除以零、类型不符等)时,故障的消息键就是表达式诊断的键(workflow.expression.*,参数带表达式本身)。子流程没有正常完成时,父节点的故障按子实例的状态各一个键(workflow.child.faulted、canceled、terminated),状态名不进文案,参数 status 是枚举名。没有消息键的故障,Localize() 返回 Message 原文。
设计期诊断
发布时的校验结果给设计器逐条展示(ADR-0106)。WORKFLOW_VALIDATION_001 的消息键是 workflow.definition.invalid,只带问题条数 count;details.errors 是 SchemaViolation 的列表,每条:
| 成员 | 内容 |
|---|---|
Path | 定义 JSON 里的位置:$.nodes[2]、$.nodes[2].activityType、$.nodes[2].properties.Cron、$.nodes[2].outputMappings.total、$.transitions[0].condition、$.transitions[0].sourceNodeId;定义级的问题(没有节点、没有开始节点)是 $.nodes |
MessageKey、Arguments | 这条问题的消息键与参数:结构问题 workflow.definition.*(参数 node、transition 等),属性问题沿用 workflow.activity.property_*,表达式问题是 workflow.expression.*,指派规则问题是 Provider 给的错误 |
Reason | 按进程默认区域渲染的文本,日志用 |
接法:
- 经 HTTP:宿主把
details.errors写成 Problem Details 的字段级errors,键是去掉$.的路径(如nodes[2].properties.Cron),值是按调用方区域翻译好的文案数组;detail是汇总("工作流定义有 3 处问题。")。设计器按键里的下标定位节点或转移。 - 进程内:
validator.Diagnose(definition)返回同一组问题;在调用里用issue.MessageKey.L(issue.Arguments)即得调用方语言。
表达式的语法与求值诊断每种一个键(workflow.expression.unexpected_character、token_expected、unexpected_end、unknown_variable、unknown_function、argument_count 等),参数都带 expression,位置从 1 起;错误码仍是 WORKFLOW_VALIDATION_003。WorkflowJson.DeserializeDefinition 先按与模型同一份规则核对文档,再构造模型:模型会拒绝的值(缺少成员、名字与活动类型的文法、定义 ID、版本、分类长度、超时、节点与转移 ID、变量名、重试策略)是 workflow.definition.invalid 加 details.errors 的违规列表,形状与发布时的校验相同(路径如 $.nodes[0].id、$.nodes[0].retryPolicy.maxAttempts,键如 workflow.definition.node_id_invalid、member_missing),经 HTTP 同样是字段级 errors。JSON 为 null 是 workflow.definition.json_null,语法错误与值的类型不符是 json_malformed(参数 path、line、position)。没写的成员取模型的初值,与直接构造模型一致(ADR-0107):version 是 1,重试策略的 maxAttempts 是 1、firstDelay 是 10 秒、backoffFactor 是 2;写成 null 与不写相同。只有必填成员没写才报缺少成员(member_missing),见上文 用 JSON。
状态的展示名
状态在模型、查询结果、事件与回调输入里都是枚举值,程序按枚举判断。给人看时用 WorkflowStatusMessages:KeyOf(status) 给消息键(workflow.instance_status.*、workflow.node_status.*、workflow.definition_status.*),status.Localize() 按当前区域渲染。状态不允许操作的错误(WORKFLOW_CONFLICT_001 / _002)按状态各一句(workflow.instance.is_running … is_terminated、workflow.definition.is_draft … is_archived),参数 status 是枚举名。
自定义活动
活动实现 IActivity(需要书签恢复的再实现 IResumableActivity,需要补偿的实现 ICompensableActivity),是作用域服务:
using System.Text.Json.Nodes;
using XiHan.Framework.Primitives;
using XiHan.Framework.Workflow.Abstractions.Activities;
public interface ISmsSender
{
ValueTask<Result> SendAsync(string phone, string text, CancellationToken cancellationToken);
}
public sealed class SmsActivity(ISmsSender sms) : IActivity
{
public const string TypeName = "Sms";
public async ValueTask<Result<ActivityOutcome>> ExecuteAsync(ActivityContext context, CancellationToken cancellationToken)
{
// RenderProperty 渲染 {{ }} 模板,缺少的变量渲染成空串
var phone = context.RenderProperty("Phone");
var text = context.RenderProperty("Text");
if (!phone.TryGetValue(out var to) || !text.TryGetValue(out var body))
{
return Result.Failure<ActivityOutcome>((phone.Error ?? text.Error)!);
}
var sent = await sms.SendAsync(to, body, cancellationToken);
if (sent.IsFailure)
{
// 失败即节点故障,按节点的重试策略处理
return Result.Failure<ActivityOutcome>(sent.Error!);
}
return Result.Success(ActivityOutcome.Complete(new JsonObject { ["smsSent"] = true }));
}
}builder.Services.AddActivity<SmsActivity>(SmsActivity.TypeName) 登记后,定义里写 AddNode("notify", SmsActivity.TypeName, "短信通知", node => node.WithProperty("Phone", "{{ phone }}").WithProperty("Text", "申请已 {{ outcome }}"))。AddActivity 的第三个参数是 ActivityOutgoing,第四个是给设计器的 ActivityDescriptor。活动的返回:
ActivityOutcome | 含义 |
|---|---|
Complete(outputs, label) | 完成,输出合并进实例变量,label 是结果标签 |
Suspend(bookmarks) / SuspendWithOutputs(outputs, bookmarks) | 以书签挂起 |
EndInstance(outputs, label) | 结束整个实例 |
TerminateInstance(reason) | 终止实例 |
CompleteWithChildren / SuspendWithChildren | 同时启动子实例 |
ActivityContext 提供 Instance、Node、NodeInstance、Variables、State(节点私有状态)、Services、Invocation(本次执行的调用上下文)与 Clock。End 节点带 Outcome 时,引擎先调用全部 IWorkflowOutcomeHandler(AddWorkflowOutcomeHandler<T>() 登记)再结束实例,审批结果回写业务表适合放在这里。
事件
装配了消息能力时,引擎发布 CloudEvents 信封:Source 为 /workflow,Type 是主题,Data 是 WorkflowEventData 的 JSON,扩展属性带 tenant 与 correlation。没有发布者时跳过,发布失败只记日志。
| 主题前缀 | 主题 |
|---|---|
workflow.instance.* | started、completed、faulted、canceled、terminated、suspended、resumed、jumped、migrated、timedout |
workflow.node.* | executing、executed、faulted(WillRetry 标出是否会重试) |
workflow.task.* | created、completed、transferred、claimed、released、returned、urged、reminder |
workflow.external.* | created、completed、failed |
workflow.custom | PublishEvent 活动发出,Subject 是事件名 |
待办通知、催办短信、审批结果同步都订阅这些主题来做,见消息与事件。
消息代理是 Kafka 且集群关了自动建主题(auto.create.topics.enable=false)时,这 25 个主题(带 TopicPrefix)要预先建好,没人订阅的也要建,否则发布失败、只记日志,见 Messaging.Kafka。
存储与实例锁
Provider 实现五个契约:IWorkflowDefinitionStore、IWorkflowInstanceStore、IWorkflowBookmarkStore、IWorkflowTransaction、IWorkflowInstanceLock。三个存储是作用域服务,每个方法的第一个参数是分区(tenantId,空为平台),只读写这个分区,写入的记录的 TenantId 必须等于它(不等抛 ArgumentException);都按批量写入、不做乐观并发:写入由实例锁串行化。IWorkflowTransaction 回答所在作用域能不能加入调用方事务(IsActive)、加入的事务能不能写(EnsureWritable)。IWorkflowInstanceLock.AcquireAsync(instanceId) 等待超过 LockTimeout(缺省 10 秒)返回 WORKFLOW_TIMEOUT_001,锁不可重入。存储不可达 → WORKFLOW_UNAVAILABLE_001,内存实现容量已满 → WORKFLOW_UNAVAILABLE_002。
| 方面 | Workflow.InMemory | Workflow.Database |
|---|---|---|
| 存储 | 有界内存表,按分区存放,保存与返回深拷贝;定义 1000、实例 10000、节点实例 100000、书签 100000 | 查询列加整份模型的 JSON 文档,经仓储读写;租户的记录实现 IMultiTenant、随租户分库,平台的记录在平台表 |
| 事务 | 无:写入立即生效,调用回滚不撤销;只读调用里拒绝写入(看操作契约) | 在调用方的工作单元里读写,随业务提交或回滚;只读工作单元里拒绝写入 |
| 实例锁 | 每个实例一个进程内信号量 | 租约行:插入即拿到,过期按并发戳接管,持有期间每三分之一租约续约(LockLeaseSeconds 缺省 60) |
| 轮询 | 本进程轮询,逐个分区 | 只在持有 workflow.poller 租约的节点上轮询,逐个分区(平台与租户目录里启用的租户) |
| 重启 | 实例全部丢失 | 实例与书签保留,重启后接着推进 |
| 多实例 | 不支持 | 多个节点共用一个库 |
配置
引擎选项在 XiHan:Workflow(WorkflowOptions),内存与数据库实现读同一节:
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
MaxNodeExecutionsPerBurst | 整数 | 1000 | 一个波次最多执行的节点数 |
MaxSubWorkflowDepth | 整数 | 16 | 子流程嵌套深度上限 |
LockTimeoutSeconds | 秒 | 10 | 等实例锁的上限 |
NotResumableBackoffSeconds | 秒 | 300 | 不可恢复的到期书签后推多久 |
ExpressionCacheSize | 整数 | 10000 | 表达式缓存条目上限 |
RetentionDays | 天 | 0 | 已结束实例的保留期,0 为不自动清理 |
HousekeepingIntervalSeconds | 秒 | 3600 | 自动清理的周期 |
HttpTimeoutSeconds | 秒 | 30 | Http 活动的缺省超时 |
{
"XiHan": {
"Workflow": {
"LockTimeoutSeconds": 10,
"RetentionDays": 90,
"InMemory": { "PollIntervalSeconds": 1, "BatchSize": 100 }
}
}
}配置值写错即启动失败,不按缺省处理:解析不了的值(如把数字写成文字、写成 NaN)抛 ArgumentException,能解析但超出取值范围的值(如 LockTimeoutSeconds 为 0)抛 ArgumentOutOfRangeException,消息都带出完整的配置键与原值。Provider 自己的键见 Workflow.InMemory 与 Workflow.Database。
错误码
| 错误码 | 含义 |
|---|---|
WORKFLOW_VALIDATION_001 | 定义不合法,details.errors 列出全部问题 |
WORKFLOW_VALIDATION_002 | 输入不合法:缺必填变量、子流程深度超限、任务结果不允许、跳转目标不存在、迁移目标不兼容、任务查询缺条件或分页越界、实例查询的分页、关联 ID 集合或清理的条数越界、定义查询的分页越界、定义与实例查询的排序项不合法(字段不在白名单、重复、超过上限) |
WORKFLOW_VALIDATION_003 | 表达式语法不合法或求值失败 |
WORKFLOW_NOTFOUND_001 | 定义不存在、没有已发布版本或在别的分区 |
WORKFLOW_NOTFOUND_002 | 实例不存在或在别的分区 |
WORKFLOW_NOTFOUND_003 | 书签(用户任务、外部任务)不存在或在别的分区 |
WORKFLOW_NOTFOUND_004 | 活动类型或指派规则类型未登记 |
WORKFLOW_CONFLICT_001 | 实例状态不允许该操作 |
WORKFLOW_CONFLICT_002 | 定义状态不允许该操作 |
WORKFLOW_CONFLICT_003 | 书签不可恢复:实例不在运行中、键不匹配 |
WORKFLOW_CONFLICT_004 | 指派人已在任务上,或任务已被认领 |
WORKFLOW_CONFLICT_005 | 同一关联 ID 下已有未结束的实例(RejectActive) |
WORKFLOW_DENIED_001 | 操作者不是任务的指派人 |
WORKFLOW_DENIED_002 | 工作者没有持有外部任务的锁,或锁已过期 |
WORKFLOW_TIMEOUT_001 | 等实例锁超时 |
WORKFLOW_UNAVAILABLE_001 | 工作流存储不可达;或引擎在调用之外被调用时应用不在服务状态 |
WORKFLOW_UNAVAILABLE_002 | 工作流存储容量已满 |
WORKFLOW_UNEXPECTED_001 | 活动抛出未处理异常,或引擎内部故障(波次上限、死汇合) |
PERSISTENCE_UNEXPECTED_001 | 只读调用里调用了写入操作(持久化的错误码,操作实现的缺陷,HTTP 500) |
消息模板在消息目录 workflow 里,十种区域都有译文。
与其他能力的关系
Delay的Cron与Start的定时启动复用调度子系统的CronExpression,文法见调度与后台作业;工作流自己的到期书签由轮询器推进,不占用调度器的作业。- 引擎在调用里加入调用方的事务,在调用之外自己经完整管道开调用,租户、授权、审计与追踪的行为与执行管道一致;分区与分库见多租户。
- 数据库实现经
persistence.relational@1的仓储读写,见持久化。
相关
- Workflow.Abstractions
- Workflow.InMemory
- Workflow.Database
- 测试与一致性套件
- 规范:RFC-0021《工作流:持久化执行》;ADR-0016、ADR-0020、ADR-0059、ADR-0077
