XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

工作流 ​

本章按使用顺序讲工作流子系统:定义流程、发布、启动实例、处理用户任务、等待外部事件、查询与控制实例,最后讲表达式、故障与补偿、自定义活动与存储。读者是要在应用里实现审批流、编排长时间运行业务的开发者。

完整 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)数据库租约行多实例、需要持久化
csharp
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 天不处理视为超时。

csharp
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 的常量格式:

json
{
  "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" }
  ]
}
csharp
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 的),等于初值的也写出,读回来的值不变。

节点、转移与变量 ​

元素字段
WorkflowNodeId(^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$,定义内唯一)、ActivityType、DisplayName、Properties(字符串值支持 {{ 表达式 }} 模板)、RetryPolicy、Timeout(挂起后多久超时)、ContinueOnError、OutputMappings(变量名 → 表达式)
WorkflowTransitionId、SourceNodeId、TargetNodeId、Condition(表达式,空为无条件)、Priority(小者先)、IsDefault
WorkflowVariableDefinitionName、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(见下文「列表排序」)。

csharp
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 为空或没给次序键(排序项里已有的跳过)项数上限
WorkflowDefinitionQueryName(名字)、Title(显示名,没有取名字)、Version、Status、CreatedAt、PublishedAt名字升序、版本降序名字升序、版本降序(数据库实现最后再按 ID)WorkflowDefinitionQuery.MaxSorts = 6
WorkflowInstanceQueryCreatedAt、EndedAt、Status、DefinitionName、Title(实例标题)创建时间降序创建时间降序、写入先后降序;排序项里有创建时间时写入先后随它的方向WorkflowInstanceQuery.MaxSorts = 5
csharp
// 实例列表:状态升序(生命周期),同状态的定义名升序,再同的创建时间降序
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 的全局定义)。各租户共用的流程模板要在每个租户里各建一份,由应用的租户初始化(种子)负责。

csharp
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。

启动实例 ​

csharp
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,信号按它匹配,重用策略按它查重
ReusePolicyAllowDuplicate(缺省)、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 书签,到期由轮询启动一个实例并推算下一次;停用、归档、删除定义时删除书签。文法与调度相同,见调度与后台作业。
csharp
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(表达式求值)

其余属性:

属性缺省说明
CompletionPolicyAnyAny 任一人通过即通过;All 会签;Sequential 依次逐个审批
ApprovalThreshold全部All 下的通过阈值:人数(如 2)或百分比(如 "60%",按指派人数向上取整);剩余人数不可能达到时以 rejected 完成
ExcludeStarterfalse从指派人里去掉发起人
EmptyAssigneeStrategyFault没有指派人时:Fault 节点故障,AutoApprove、AutoReject,Fallback 改用 FallbackAssigneeRules
Title、FormKey节点显示名、空模板,写进任务视图
FormData、Priority空表单数据、优先级
DueAfterSeconds / DueAt空截止时长或截止时刻,DueAt 优先;只写进任务视图,不自动完成任务
ReminderAfterSeconds空每隔该时长发布一次 workflow.task.reminder,直到任务结束
CcUserIds / CcRules空抄送人,写进任务事件
AllowedOutcomesapproved、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),审批记录从这里读。

csharp
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 并按类型登记,同类型重复登记是编程错误:

csharp
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
TimerDelay、延迟启动到期由轮询恢复
SignalWaitSignal、EventGatewaySignalAsync
SubWorkflowSubWorkflow、ForEach子实例结束时回调父实例
Retry节点重试到期由轮询恢复
NodeTimeout节点 Timeout到期由轮询恢复
ExternalExternalTask外部工作者完成;锁到期由轮询解锁
Reminder用户任务提醒引擎直接处理
InstanceTimeout定义 Timeout引擎直接处理
DefinitionTimerStart.Cron引擎直接处理,InstanceId 是定义 ID

ResumeAsync(bookmarkId, inputs, expectedKey) 消费一个书签并恢复节点;实例不在运行中,或 expectedKey 与书签的 Key 不同时返回 WORKFLOW_CONFLICT_003,轮询器遇到这类书签把到期时间后推 NotResumableBackoff(缺省 5 分钟)。SignalAsync(name, payload, correlationId) 消费当前分区里匹配的全部信号书签(书签的 CorrelationId 为空时接受任意关联),再按信号启动规则启动实例,返回消费数加启动数。

csharp
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 外部任务的方式工作:

csharp
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。

csharp
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 项、每个字段一次;空或没给时创建时间降序
csharp
// 审批中心「我发起的」:只取顶层,带标题搜索
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):

活动出边主要属性
StartAllMatchedSignalName、Cron、TimeZone
EndNoneOutcome:先调用全部 IWorkflowOutcomeHandler,再结束实例
TerminateNoneReason
SetVariableAllMatchedValues(字面量对象)、Expressions(变量名 → 表达式)
DecisionExclusive—
ParallelAll—
JoinAllMatchedMode:WaitAll(缺省,入边全部到达才执行)/ WaitAny(首个到达执行)
DelayAllMatchedDuration(秒或表达式)、Until(时刻表达式)、Cron + TimeZone
UserTaskAllMatched见处理用户任务
PublishEventAllMatchedEventName、Payload:发布 workflow.custom 消息
WaitSignalAllMatchedSignalName、AcceptAnyCorrelation
EventGatewayAllMatchedSignals、TimeoutSeconds、AcceptAnyCorrelation:先到的信号胜出,结果标签是信号名或 timeout,输出 signal、payload
SubWorkflowAllMatchedDefinitionName、DefinitionVersion、Variables、VariableExpressions、WaitForCompletion、ResultVariable、FailOnChildFault
ForEachAllMatchedItems(求值为数组)、ItemVariable、Parallel、FailFast,子流程属性同 SubWorkflow
LogAllMatchedMessage、Level
FaultNoneMessage:让实例故障
CompensateAllMatchedNodeIds:对指定的已完成节点(缺省为之前全部已完成节点)逆序补偿后继续
HttpAllMatchedUrl、Method、Headers、Body、ContentType、TimeoutSeconds、ResultVariable(缺省 http)、FailOnErrorStatus(缺省 true);输出 { status, headers, body },JSON 响应解析为对象
ExternalTaskAllMatchedTopic、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)时按顺序处理:

  1. 有重试策略且未用尽:节点记为 Faulted,插入 Retry 书签,第 n 次尝试失败后等 FirstDelay × BackoffFactor^(n−1),实例保持运行。WorkflowRetryPolicy.MaxAttempts 含首次(至少 1,1 为不重试),FirstDelay 缺省 10 秒,BackoffFactor 至少 1、缺省 2。
  2. ContinueOnError:节点记为 Faulted,变量 lastError 写入这次错误的故障结构(code、message、messageKey、arguments、inner),以 outcome = "error" 流出。表达式按 lastError.code 判断(message 是按进程默认区域渲染的文本,只适合判空);展示用 WorkflowJson.DeserializeFault(instance.Variables[WorkflowEngine.LastErrorVariable])?.Localize()。
  3. 否则实例 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),是作用域服务:

csharp
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.customPublishEvent 活动发出,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.InMemoryWorkflow.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秒30Http 活动的缺省超时
json
{
  "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 的仓储读写,见持久化。

相关 ​

Released under The MIT License