本页目录
自定义节点开发说明
本文面向需要扩展规则引擎节点的开发人员和交付工程师。自定义节点不是采集协议插件,也不是数据转发插件,而是规则流程里的“功能块”:上游输出变化后写入当前节点输入,当前节点执行 ChangedAsync,再把输出传给下游节点。
阅读路线
首次开发先验证一个最小例,再按参考补全参数、失败处理和释放流程。MCP 材料入口位于页末,可按需展开。
开发前准备
先判断是不是自定义节点
源码入口
| 源码 | 作用 |
|---|---|
TGateway.Application/Expressions/CustomExpressionBase.cs | 自定义节点基类,定义 Name、InitAsync、ChangedAsync、OutputsCommitted、SetOutput 和 CommitOutputs。 |
TGateway.Application/Expressions/CustomExpressionDefinition.cs | WEB 节点参数定义、参数方向、数据类型和生成属性规则。 |
TGateway.Application/Controllers/RuleEngine/GatewayCustomNodeController.cs | 自定义节点创建、保存、编译、批量编译、删除和热加载接口。 |
TGateway.Application/Entity/CustomNode.cs | 自定义节点数据库表,保存名称、分类、描述、代码和三类参数 JSON。 |
TGateway.ScriptCompiler/ExpressionCodeGenerator.cs | 把 WEB 节点代码包装成继承 CustomExpressionBase 的 C# 类。 |
TGateway.ExpressionsGenerator/SourceGenerator/ExpressionRegistrationGenerator.cs | 编译时扫描节点类,生成注册代码并写入 ExpressionsData。 |
TGateway.Application/Expressions/ExpressionsData.cs | 保存已注册节点信息和创建、读写、初始化、变化执行委托。 |
TGateway.Application/Task/RuleEngine/RuleEngineTask.cs | 规则流程运行时,负责创建节点实例、写入参数、初始化、输出传播、防抖和释放。 |
TGateway.Application/Script/EmbeddedNodes.*.cs、Script/Timers/ | 内置数学、逻辑、比较、数据流、定时器、计数器和统计节点参考实现。 |
TGateway.Application/Script/VariableNode.cs | 内置变量通知、报警通知、设备通知、变量 RPC、MQTT/邮件/Webhook 推送节点参考实现。 |
生命周期与参数契约
总生命周期
- 在 WEB “开发配置 → 自定义节点”中新建节点,维护名称、分类、描述、代码、输入参数、输出参数和输入输出参数。
- 点击“编译并保存”后,Runtime 先把节点写入
custom_node表。 - Runtime 调用
TGateway.ScriptCompiler,编译类型为CustomNode。 - 编译器使用节点稳定类型身份,把页面代码和参数属性包装成
CustomExpressionBase派生类;数据库节点身份为custom-node:<Id>,显示名称不再用作节点的唯一身份。 - 编译产物先写入
CustomNodeDlls下的独立暂存目录。完成装载、类型身份及整批注册验证后,才替换对应正式 DLL 并发布注册;失败保留原正式结果,最后清理暂存目录。 - 源生成器在 DLL 中生成
ModuleInitializer注册代码。装载期间先暂存本程序集的注册项,验证成功后整批发布到ExpressionsData。 - 规则流程启动前校验
DefinitionJson:稳定节点typeId、命名端口、参数契约及无环拓扑。画布布局只用于呈现,不决定执行。 - 每个节点按
typeId从当前节点目录绑定注册信息并创建实例。 - 运行时把定义中的输入、输入输出初值和节点参数写入实例,绑定当前流程的共享上下文与取消令牌。
- 运行时注入流程日志
Logger,设置批次接收回调,再调用所有节点的InitAsync。 - 没有上游连线的起始节点会执行一次
ChangedAsync。 - 节点在受管执行内写入输出,成功结束后提交批次;同批状态先一起应用到目标,再触发目标调度。事件按事件语义消费。
- 默认 10 ms 防抖;
NoDebounce取消合并等待,但仍有串行执行、队列容量及拒绝处理。 - 流程停止或重载时取消生命周期令牌,等待调度结束,调用
StopAsync并处理节点释放接口;新流程实例不继承旧内存状态。
节点编辑器保存的是草稿,编译发布的是运行版本。保存成功但编译失败时,重新打开仍可看到失败草稿;不能因此认为新逻辑已替换在线版本。已有流程持有的节点实例还应重新装载并验证。来自旧编辑版本的更新或编译请求会被拒绝,先读取当前节点再核对修改。
编辑器支持用户成员补全、XML 注释、参数提示和诊断行列定位,完整操作及可复制的 XML 示例见代码编辑、补全与错误定位。
基类契约
节点必须实现 Name、InitAsync、ChangedAsync。下面列出可用于扩展的主要契约;宿主内部回调不应由节点程序集直接访问。
| 成员 | 开发要求 |
|---|---|
Name / [RuleNode] | Name 是显示名称;完整类使用 [RuleNode("稳定唯一ASCII键")] 声明注册身份。WEB 节点由包装器按数据库身份生成,改显示名不改变类型身份。 |
InitAsync | 节点实例创建、参数写入、输出回调设置完成后调用一次。适合初始化输出、订阅事件、创建连接或启动定时器。 |
ChangedAsync | 起始节点启动时或上游输入变化后调用。适合读取输入、计算输出、执行写入或推送。 |
Logger | 流程专用日志。高频节点不要正常路径刷 Info 日志,只在异常、过滤、关键动作时记录。 |
OutputsCommitted | 宿主内部的批次接收入口,自定义节点程序集不能直接访问。完整类节点通过 SetOutput 修改输出;调度执行成功后自动提交,CommitOutputs 可显式提交中间批次。失败会舍弃尚未提交的输出,但不撤回已提交批次。 |
SmartChangedTriggerScheduler | 运行时防抖触发器。业务代码通常不直接调用它,由规则引擎负责。 |
StopAsync | 停止时的异步清理入口,WEB 成员代码也可覆盖;随后宿主仍处理释放接口。取消订阅、关闭通道与释放应可重复执行。 |
ExecutionCancellationToken | 本次节点执行总期限与流程取消的组合令牌。所有异步等待、网络和写入调用应传递它。 |
LifetimeCancellationToken | 长期事件订阅和定时任务使用的流程令牌,不绑定某一次已结束的执行。 |
EnqueueNotification | 广播或定时回调先固定载荷,再用此入口加入有界串行调度;回调内只做同步输出发布,不传入异步 lambda。 |
SkipExecution | 明确报告条件不满足,传稳定原因码和具体原因,调用后立即返回。不能在输出已提交或外部动作已开始后报告跳过。 |
SetWaitingReason | 报告空闲时等待条件,不改变最近执行结果;在 SkipExecution 前调用,同时传两个 null 清除说明。 |
Context | 同一流程实例的有界 JSON 共享状态,非跨流程或持久化存储。 |
EffectKind | 完整类声明纯计算、幂等或非幂等外部副作用;不能用画布参数修改该安全声明。 |
两种代码形态
WEB 页面节点
在自定义节点编辑器中只写类成员代码,不写完整类,不写 Name 属性,也不要重复声明页面参数。参数属性由参数定义自动生成。
例如参数定义包含小数输入 Input、Scale 和输出 Output,页面只填写以下成员。生成器负责稳定身份、参数属性和调用 SetOutput 的输出 setter:
/// <summary>初始化本实例的换算输出。</summary>
public override Task InitAsync()
{
Output = 0;
return Task.CompletedTask;
}
/// <summary>按当前输入与倍率计算,并交由宿主提交输出。</summary>
public override Task ChangedAsync()
{
Output = Input * Scale;
return Task.CompletedTask;
}完整类节点
完整类节点适合内置节点、外部 DLL 和需要声明副作用类别的节点。继承 CustomExpressionBase,声明唯一 [RuleNode],参数用特性标记,输出属性用 SetOutput。注册键保持稳定,不能用显示名称代替。
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using TGateway.Application;
/// <summary>把工程量与高限比较,输出当前超限状态。</summary>
[Category("比较运算")]
[RuleNode("manual.high-limit")]
public sealed class HighLimitNode : CustomExpressionBase
{
/// <summary>供节点库和流程面板显示的名称。</summary>
public override string Name => "高限判断";
/// <summary>待比较的工程量。</summary>
[ExpressionInput]
public double Input { get; set; }
/// <summary>采用与输入相同单位的高限。</summary>
[ExpressionInput]
public double Limit { get; set; } = 100;
/// <summary>保存最近设置的超限状态。</summary>
private bool _alarm;
/// <summary>由受管输出批次传播的比较结果。</summary>
[ExpressionOutput]
public bool Alarm
{
get => _alarm;
private set => SetOutput(ref _alarm, value);
}
/// <summary>为新实例建立明确的初始状态。</summary>
public override Task InitAsync()
{
Alarm = false;
return Task.CompletedTask;
}
/// <summary>每次执行重新计算严格大于关系。</summary>
public override Task ChangedAsync()
{
Alarm = Input > Limit;
return Task.CompletedTask;
}
}参数模型
| 参数方向 | 现场含义 | 生成属性 | 连线位置 | 注意事项 |
|---|---|---|---|---|
| 输入参数 | 上游传入或规则实例面板手填的值。 | [ExpressionInput] public set | 左侧命名端口,如 in:Input。 | 写入输入本身不发布输出,在受管执行中计算并设置输出。 |
| 输出参数 | 当前节点计算、过滤、写入或推送后的结果。 | [ExpressionOutput] private set | 右侧命名端口,如 out:Result。 | 页面直接赋输出属性;完整类用 SetOutput。 |
| 输入输出参数 | 接收上游并可向下游传播。 | [ExpressionInOut] public set | 同名左右端口,如 in:Value、out:Value。 | 区分宿主输入写入与节点主动发布,不能只修改字段。 |
| 节点参数 | 容量、策略及执行策略等配置。 | [ExpressionParameter] | 属性面板,无连线端口。 | 不把配置参数当成事件或业务输入。 |
数据类型
WEB 参数定义支持 Int16、Int32、Int64、UInt16、UInt32、UInt64、Byte、Float、Double、Decimal、Boolean、String、Object、DateTime。端口还具有事件语义、可空性、数组维数/元素及单位等契约;连线不能依靠运行时隐式转换来掩盖不匹配,应使用对应转换节点。
| 类型 | C# 类型 | 适合场景 | 初始值写法 |
|---|---|---|---|
Boolean | bool | 启停、联锁、报警状态、边沿信号。 | true 或 false。 |
Int32 / Int64 | int / long | 计数、编号、毫秒时间、枚举值。 | 1000。 |
Float / Double / Decimal | float / double / decimal | 模拟量、工程量、比例换算。 | 1.5,decimal 完整类中可写 1.5m。 |
String | string | 设备名、变量名、Topic、URL、模板、告警文本。 | WEB 中通常在规则实例属性面板填写。 |
DateTime | DateTime | 时间戳、窗口期、延时判断。 | DateTime.UtcNow 只适合完整类默认值;页面实例建议手填。 |
Object | object 或自定义类型 | VariableBasicData、AlarmVariable、字典、匿名对象、JSON 对象。 | 连接上游对象输出最稳;手填 JSON 时要确认目标类型能转换。 |
WEB 参数定义可填写状态参数的“初始值表达式”,例如小数 1m;事件端口不允许初值。输出和内部状态在 InitAsync 中初始化,流程实例输入在规则属性面板配置。三处作用不同,不能互相代替。
运行时传播规则
| 规则 | 说明 |
|---|---|
| 状态与事件分别传播 | 状态默认按已提交值去重;OutputOnlyOnChange=false 时每次写入都可传播。事件端口每次写入代表一次发生,相同载荷也保留事件语义。 |
| 空值受契约约束 | null 必须符合端口的可空声明,不能静默丢弃空值后继续使用旧输入;JsonElement 的 Null 与未初始化的 Undefined 也应区分。 |
| 端口按名称绑定 | 定义保存稳定 typeId 与端口键;参数排序不作为运行身份。重命名参数属于契约变化,必须同步修改使用流程并校验。 |
| 起始节点会执行一次 | 没有上游连线的节点在流程启动后执行一次 ChangedAsync,常量、周期源、事件源都可以作为起始节点。 |
| 默认 10 ms 防抖 | 多个上游短时间连续输出时,下游可能合并执行。需要逐次触发时在流程中开启“无限制触发”,但要评估 CPU 和外部系统压力。 |
| 环路在校验阶段拒绝 | 当前流程必须是有向无环图。需要状态记忆时使用保持、计数、上下文或独立内存变量。 |
| 输出属于受管作用域 | 初始化和调度执行中暂存输出;失败、取消、超时或显式跳过舍弃未提交部分。CommitOutputs 已发布的中间批次不会回滚。 |
| 外部通知先排队 | 事件和定时回调不能在已结束的执行外直接设置输出;先冻结值,再使用 EnqueueNotification。不要脱离 ChangedAsync 启动后台任务后写输出。 |
| 分支独立投递 | 一条连接拒绝不阻止其他目标接收;部分投递失败不回滚已接收分支。通过连接观察逐条检查。 |
| 停止时释放实例 | 使用流程令牌终止长期任务,在 StopAsync 和释放接口中解除事件订阅、关闭连接和释放定时器。 |
SkipExecution 只用于本次根本不执行的条件分支,普通 return 不会自动变成 Skipped。例如输入 Enabled 为假时,可先报告等待启用,再明确跳过;最近执行结果与当前空闲条件分别显示。
/// <summary>初始化等待条件,不在启动时产生业务输出。</summary>
public override Task InitAsync() => Task.CompletedTask;
/// <summary>仅在启用时计算,禁用时报告明确的未执行原因。</summary>
public override Task ChangedAsync()
{
if (!Enabled)
{
SetWaitingReason("WaitingForEnabled", "等待启用");
SkipExecution("Disabled", "当前节点未启用");
return Task.CompletedTask;
}
SetWaitingReason(null, null);
Output = Input * 2m;
return Task.CompletedTask;
}此 WEB 示例定义布尔输入 Enabled、小数输入 Input 与小数输出 Output。跳过不会撤回上次显示值,应以本次执行状态、原因与连接观察判断是否新发布。
基础节点与示例
内置节点类型清单
以下类型已从源码 EmbeddedNodes.*.cs 和 VariableNode.cs 核对。开发新节点时先看同类内置实现,避免重复造基础节点。
| 类型 | 内置节点 |
|---|---|
| 数学运算 | 加法、减法、乘法、除法、取模、幂运算、平方根、绝对值、四舍五入、数值钳制、线性缩放。 |
| 逻辑运算 | 逻辑与、逻辑或、逻辑非、逻辑异或、逻辑与非、逻辑或非。 |
| 比较运算 | 等于、不等于、大于、大于等于、小于、小于等于、范围内判断。 |
| 条件判断 | 条件选择、条件选择(字符串)、阈值触发、多路分支。 |
| 字符串处理 | 字符串拼接、字符串格式化、字符串截取、字符串替换、转大写、转小写、去除空格、包含判断、字符串长度。 |
| 类型转换 | 转整数、转浮点数、转字符串、转布尔值、JSON 数值提取、整数转十进制、JSON/布尔状态与事件互转。 |
| 定时器 | 延时、单次定时器、周期定时器。 |
| 计数器 | 计数器、加减计数器。 |
| 边沿检测 | 上升沿检测、下降沿检测、双边沿检测。 |
| 数据统计 | 平均值、移动平均、最大最小值、累加器。 |
| 变化检测 | 变化检测、变化率。 |
| 保持 | 值保持、采样保持、触发器。 |
| 常量 | 数值常量、字符串常量、布尔常量。 |
| 三角函数 | 正弦、余弦、正切、反正切2。 |
| 触发器 | 变量通知规则、告警通知规则、设备通知规则。 |
| 变量 RPC 节点 | 变量 RPC 节点。 |
| 数据推送 | MQTT 客户端上传、邮件推送、Webhook 推送。 |
| 数据流 | 状态合并、事件拆分、事件批处理、事件汇合、上下文写入/读取/删除。 |
| 控制流 | 执行错误捕获,忽略成功和显式跳过,只向错误分支传播失败或取消结果。 |
Demo 写法约定
下面的“页面参数”按自定义节点编辑器的三类参数填写。除“完整类 Demo”外,代码都粘贴到 WEB 自定义节点代码编辑区,不要额外写类名、命名空间或 Name 属性。
常量节点
查看常量节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Value | Double | 在规则流程节点属性面板填写的常量值。 |
| 输出 | Output | Double | 输出给下游的常量值。 |
public override Task InitAsync()
{
Output = Value;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Output = Value;
return Task.CompletedTask;
}数学运算节点
查看数学运算节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | RawValue | Double | 原始量程值。 |
| 输入 | RawMin | Double | 原始下限。 |
| 输入 | RawMax | Double | 原始上限。 |
| 输入 | EngMin | Double | 工程下限。 |
| 输入 | EngMax | Double | 工程上限。 |
| 输出 | Value | Double | 换算后的工程值。 |
| 输出 | Error | Boolean | 量程配置是否错误。 |
public override Task InitAsync()
{
Value = 0;
Error = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
if (Math.Abs(RawMax - RawMin) < 0.000001)
{
Error = true;
return Task.CompletedTask;
}
Error = false;
Value = Math.Round((RawValue - RawMin) * (EngMax - EngMin) / (RawMax - RawMin) + EngMin, 3);
return Task.CompletedTask;
}逻辑运算节点
查看逻辑运算节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | AutoMode | Boolean | 自动模式。 |
| 输入 | EmergencyStop | Boolean | 急停状态。 |
| 输入 | PressureOk | Boolean | 压力允许。 |
| 输入 | Fault | Boolean | 故障状态。 |
| 输出 | Allowed | Boolean | 是否允许启动。 |
public override Task InitAsync()
{
Allowed = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Allowed = AutoMode && !EmergencyStop && PressureOk && !Fault;
return Task.CompletedTask;
}比较和阈值节点
查看比较和阈值节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | PV | Double | 过程值。 |
| 输入 | HighLimit | Double | 高限值。 |
| 输入 | Hysteresis | Double | 回差,防止临界点抖动。 |
| 输出 | Alarm | Boolean | 当前是否报警。 |
| 输出 | RisingEdge | Boolean | 报警刚发生。 |
| 输出 | FallingEdge | Boolean | 报警刚恢复。 |
private bool _active;
public override Task InitAsync()
{
_active = false;
Alarm = false;
RisingEdge = false;
FallingEdge = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var previous = _active;
if (!_active && PV >= HighLimit)
{
_active = true;
}
else if (_active && PV <= HighLimit - Math.Abs(Hysteresis))
{
_active = false;
}
Alarm = _active;
RisingEdge = !previous && _active;
FallingEdge = previous && !_active;
return Task.CompletedTask;
}条件选择节点
查看条件选择节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | UseManual | Boolean | 是否使用手动给定值。 |
| 输入 | ManualValue | Double | 手动给定值。 |
| 输入 | AutoValue | Double | 自动计算值。 |
| 输出 | Output | Double | 最终输出值。 |
| 输出 | Source | String | 输出来源。 |
public override Task InitAsync()
{
Output = 0;
Source = "Auto";
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Output = UseManual ? ManualValue : AutoValue;
Source = UseManual ? "Manual" : "Auto";
return Task.CompletedTask;
}字符串处理节点
查看字符串处理节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | DeviceName | String | 设备名。 |
| 输入 | VariableName | String | 变量名。 |
| 输入 | ValueText | String | 当前值文本。 |
| 输入 | Unit | String | 单位。 |
| 输出 | Message | String | 拼好的提示文本。 |
public override Task InitAsync()
{
Message = string.Empty;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var unit = string.IsNullOrWhiteSpace(Unit) ? string.Empty : $" {Unit}";
Message = $"{DeviceName}.{VariableName} = {ValueText}{unit}";
return Task.CompletedTask;
}类型转换节点
查看类型转换节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | InputText | String | 外部文本值。 |
| 输出 | Value | Double | 解析后的数值。 |
| 输出 | Success | Boolean | 是否解析成功。 |
| 输出 | ErrorMessage | String | 错误信息。 |
public override Task InitAsync()
{
Value = 0;
Success = false;
ErrorMessage = string.Empty;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
if (double.TryParse(InputText, out var number))
{
Value = number;
Success = true;
ErrorMessage = string.Empty;
}
else
{
Success = false;
ErrorMessage = $"Cannot convert to number: {InputText}";
}
return Task.CompletedTask;
}定时器节点
查看定时器节点的参数与示例
这个 WEB 示例在本次受管执行内等待,完成后产生一次 Done 事件。将 Done 定义为事件输出、不填初值。总超时应大于 DelayMs;等待期间后续输入由调度器排队,不会抢占当前等待。
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Trigger | Boolean | 触发信号。 |
| 输入 | DelayMs | Int32 | 延时时间,毫秒。 |
| 输出 | Output | Boolean | 延时完成输出。 |
| 事件输出 | Done | Boolean | 每次延时完成产生 true 事件。 |
/// <summary>初始化状态输出,不产生完成事件。</summary>
public override Task InitAsync()
{
Output = false;
return Task.CompletedTask;
}
/// <summary>在当前执行的取消与期限约束内等待,再提交本次结果。</summary>
public override async Task ChangedAsync()
{
if (!Trigger)
{
SetWaitingReason("WaitingForTrigger", "等待触发信号");
SkipExecution("TriggerOff", "触发信号为假");
return;
}
if (DelayMs < 0)
throw new RuleBusinessRejectedException("InvalidDelay", "DelayMs must be non-negative.");
SetWaitingReason(null, null);
var delay = DelayMs;
await Task.Delay(delay, ExecutionCancellationToken).ConfigureAwait(false);
Output = true;
Done = true;
}计数器节点
查看计数器节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Increment | Boolean | 计数脉冲。 |
| 输入 | Reset | Boolean | 复位。 |
| 输入 | MaxValue | Int32 | 上限。 |
| 输出 | Count | Int32 | 当前计数。 |
| 输出 | Overflow | Boolean | 是否达到上限。 |
private bool _lastIncrement;
private int _count;
public override Task InitAsync()
{
_lastIncrement = false;
_count = 0;
Count = 0;
Overflow = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
if (Reset)
{
_count = 0;
}
else if (Increment && !_lastIncrement)
{
_count++;
}
_lastIncrement = Increment;
Overflow = MaxValue > 0 && _count >= MaxValue;
Count = Overflow && MaxValue > 0 ? MaxValue : _count;
return Task.CompletedTask;
}边沿检测节点
查看边沿检测节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Input | Boolean | 当前开关量。 |
| 输出 | Rising | Boolean | 上升沿。 |
| 输出 | Falling | Boolean | 下降沿。 |
| 输出 | Changed | Boolean | 任意变化。 |
private bool _previous;
public override Task InitAsync()
{
_previous = Input;
Rising = false;
Falling = false;
Changed = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Rising = Input && !_previous;
Falling = !Input && _previous;
Changed = Input != _previous;
_previous = Input;
return Task.CompletedTask;
}数据统计节点
查看数据统计节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Input | Double | 当前采样值。 |
| 输入 | WindowSize | Int32 | 滑动窗口大小。 |
| 输出 | Average | Double | 滑动平均值。 |
| 输出 | SampleCount | Int32 | 当前样本数量。 |
private readonly Queue<double> _samples = new();
public override Task InitAsync()
{
_samples.Clear();
Average = 0;
SampleCount = 0;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var size = Math.Max(1, WindowSize);
_samples.Enqueue(Input);
while (_samples.Count > size)
{
_samples.Dequeue();
}
SampleCount = _samples.Count;
Average = Math.Round(_samples.Average(), 3);
return Task.CompletedTask;
}变化检测节点
查看变化检测节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Input | Double | 当前值。 |
| 输入 | Threshold | Double | 变化判定阈值。 |
| 输出 | Changed | Boolean | 是否超过阈值。 |
| 输出 | Delta | Double | 本次变化量。 |
| 输出 | RatePerSecond | Double | 每秒变化率。 |
private double _lastValue;
private DateTime _lastTime;
public override Task InitAsync()
{
_lastValue = Input;
_lastTime = DateTime.UtcNow;
Changed = false;
Delta = 0;
RatePerSecond = 0;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var now = DateTime.UtcNow;
var seconds = Math.Max(0.001, (now - _lastTime).TotalSeconds);
var delta = Input - _lastValue;
Delta = Math.Round(delta, 3);
RatePerSecond = Math.Round(delta / seconds, 3);
Changed = Math.Abs(delta) >= Math.Abs(Threshold);
_lastValue = Input;
_lastTime = now;
return Task.CompletedTask;
}保持节点
查看保持节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Input | Double | 当前值。 |
| 输入 | Sample | Boolean | 采样信号。 |
| 输入 | Hold | Boolean | 保持信号。 |
| 输出 | Output | Double | 输出值。 |
private double _held;
public override Task InitAsync()
{
_held = Input;
Output = Input;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
if (Sample)
{
_held = Input;
}
Output = Hold ? _held : Input;
return Task.CompletedTask;
}三角函数节点
查看三角函数节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Angle | Double | 角度或弧度。 |
| 输入 | UseDegrees | Boolean | true 表示输入为角度。 |
| 输出 | SinValue | Double | 正弦值。 |
| 输出 | CosValue | Double | 余弦值。 |
public override Task InitAsync()
{
SinValue = 0;
CosValue = 1;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var radians = UseDegrees ? Angle * Math.PI / 180.0 : Angle;
SinValue = Math.Round(Math.Sin(radians), 6);
CosValue = Math.Round(Math.Cos(radians), 6);
return Task.CompletedTask;
}Object 结构化数据节点
查看Object 结构化数据节点的参数与示例
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | InputData | Object | 上游传入的对象,例如变量通知输出的 VariableBasicData。 |
| 输出 | Json | String | 序列化后的 JSON。 |
| 输出 | HasData | Boolean | 是否有有效数据。 |
public override Task InitAsync()
{
Json = string.Empty;
HasData = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
HasData = InputData != null;
Json = InputData == null ? string.Empty : InputData.ToSystemTextJsonString();
return Task.CompletedTask;
}事件触发示例
完整类 Demo:变量通知触发节点
这个完整类把在线变量变化变成流程输入。广播线程先筛选并固定快照,再入受管调度;状态 Value 去重,事件 Data 保留每次通知。数值接入比较节点前先按端口语义转换,再提取数值。
查看完整示例代码
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using TGateway.Application;
/// <summary>将匹配变量的冻结快照交给规则调度器消费。</summary>
[Category("触发器")]
[RuleNode("manual.variable-notification")]
public sealed class SimpleVariableNotifyNode : CustomExpressionBase, IDisposable
{
/// <summary>用于节点库显示,不参与类型身份匹配。</summary>
public override string Name => "变量变化触发";
/// <summary>限定通知设备,空字符串表示不限制。</summary>
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
/// <summary>限定变量名称,空字符串表示不限制。</summary>
[ExpressionInput]
public string VariableName { get; set; } = string.Empty;
/// <summary>保存与运行时对象解耦的值快照。</summary>
private JsonElement _value;
/// <summary>发布当前值状态,相同已提交值不重复传播。</summary>
[ExpressionOutput]
public JsonElement Value
{
get => _value;
private set => SetOutput(ref _value, value);
}
/// <summary>保存本次完整变量快照。</summary>
private JsonElement _data;
/// <summary>每次匹配通知都发布一次完整快照事件。</summary>
[ExpressionOutput(IsEvent = true)]
public JsonElement Data
{
get => _data;
private set => SetOutput(ref _data, value);
}
/// <summary>流程初始化时订阅,初始化阶段不生成虚假通知。</summary>
public override Task InitAsync()
{
GlobalData.VariableValueChangeEvent += OnVariableChanged;
return Task.CompletedTask;
}
/// <summary>在广播入口固定本次数据,不在广播线程传播规则输出。</summary>
private void OnVariableChanged(VariableRuntime runtime, VariableUpdateSource source)
{
if (!runtime.IsOnline ||
(!string.IsNullOrWhiteSpace(DeviceName) && runtime.DeviceName != DeviceName) ||
(!string.IsNullOrWhiteSpace(VariableName) && runtime.Name != VariableName))
return;
var data = runtime.AdaptVariableBasicData();
data.UpdateSource = source;
var snapshot = JsonSerializer.SerializeToElement(data);
var value = JsonSerializer.SerializeToElement(data.Value);
EnqueueNotification(() =>
{
Data = snapshot;
Value = value;
}, nameof(Data));
}
/// <summary>通知源没有需要在输入变化时重复执行的业务。</summary>
public override Task ChangedAsync() => Task.CompletedTask;
/// <summary>流程停止后解除全局订阅,防止保留旧实例。</summary>
public void Dispose()
{
GlobalData.VariableValueChangeEvent -= OnVariableChanged;
}
}完整类 Demo:报警通知触发节点
查看完整示例代码
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using TGateway.Application;
/// <summary>将指定级别与设备的报警快照发布为事件。</summary>
[Category("触发器")]
[RuleNode("manual.alarm-notification")]
public sealed class SimpleAlarmNotifyNode : CustomExpressionBase, IDisposable
{
/// <summary>节点库显示名称。</summary>
public override string Name => "报警变化触发";
/// <summary>最低允许报警级别。</summary>
[ExpressionInput]
public int MinLevel { get; set; } = 0;
/// <summary>限定设备名称,空字符串表示不限制。</summary>
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
/// <summary>保存与广播对象解耦的报警快照。</summary>
private JsonElement _alarm;
/// <summary>每次匹配报警发布一个 JSON 事件。</summary>
[ExpressionOutput(IsEvent = true)]
public JsonElement Alarm
{
get => _alarm;
private set => SetOutput(ref _alarm, value);
}
/// <summary>保存报警摘要。</summary>
private string _text = string.Empty;
/// <summary>发布最近报警的可读摘要状态。</summary>
[ExpressionOutput]
public string Text
{
get => _text;
private set => SetOutput(ref _text, value);
}
/// <summary>在流程初始化时建立报警订阅。</summary>
public override Task InitAsync()
{
GlobalData.AlarmChangedEvent += OnAlarmChanged;
return Task.CompletedTask;
}
/// <summary>冻结载荷后入队,避免广播对象后续修改影响当前事件。</summary>
private void OnAlarmChanged(AlarmVariable alarm)
{
if (alarm.AlarmLevel < MinLevel)
{
return;
}
if (!string.IsNullOrWhiteSpace(DeviceName) && alarm.DeviceName != DeviceName)
{
return;
}
var snapshot = JsonSerializer.SerializeToElement(alarm);
var text = $"{alarm.DeviceName}.{alarm.Name} {alarm.EventType} {alarm.AlarmText}";
EnqueueNotification(() =>
{
Alarm = snapshot;
Text = text;
}, nameof(Alarm));
}
/// <summary>只由报警订阅发布业务输出。</summary>
public override Task ChangedAsync() => Task.CompletedTask;
/// <summary>释放流程实例持有的全局事件订阅。</summary>
public void Dispose()
{
GlobalData.AlarmChangedEvent -= OnAlarmChanged;
}
}完整类 Demo:设备状态触发节点
查看完整示例代码
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using TGateway.Application;
/// <summary>将设备状态快照交给规则的事件接收端。</summary>
[Category("触发器")]
[RuleNode("manual.device-notification")]
public sealed class SimpleDeviceStatusNode : CustomExpressionBase, IDisposable
{
/// <summary>节点库显示名称。</summary>
public override string Name => "设备状态触发";
/// <summary>限定设备名称,空字符串表示不限制。</summary>
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
/// <summary>保存本次设备快照。</summary>
private JsonElement _device;
/// <summary>每次匹配状态变化发布一次 JSON 事件。</summary>
[ExpressionOutput(IsEvent = true)]
public JsonElement Device
{
get => _device;
private set => SetOutput(ref _device, value);
}
/// <summary>保存最近状态的文字表示。</summary>
private string _status = string.Empty;
/// <summary>发布最近设备状态。</summary>
[ExpressionOutput]
public string Status
{
get => _status;
private set => SetOutput(ref _status, value);
}
/// <summary>在当前流程实例中订阅设备变化。</summary>
public override Task InitAsync()
{
GlobalData.DeviceStatusChangeEvent += OnDeviceChanged;
return Task.CompletedTask;
}
/// <summary>广播入口只固定快照并提交通知。</summary>
private void OnDeviceChanged(DeviceRuntime runtime, DeviceBasicData data)
{
if (!string.IsNullOrWhiteSpace(DeviceName) && data.Name != DeviceName)
{
return;
}
var snapshot = JsonSerializer.SerializeToElement(data);
var status = data.DeviceStatus.ToString();
EnqueueNotification(() =>
{
Device = snapshot;
Status = status;
}, nameof(Device));
}
/// <summary>设备变化由订阅通知处理。</summary>
public override Task ChangedAsync() => Task.CompletedTask;
/// <summary>解除设备事件订阅,允许旧流程实例释放。</summary>
public void Dispose()
{
GlobalData.DeviceStatusChangeEvent -= OnDeviceChanged;
}
}写回、推送与定时示例
变量 RPC 节点
RPC 在现场语言里就是“外部系统或规则流程反写点位”。这个 Demo 用上升沿触发一次写入,避免 Trigger=true 时每次输入变化都重复写。
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Trigger | Boolean | 写入触发。 |
| 输入 | DeviceName | String | 设备名。 |
| 输入 | VariableName | String | 变量名。 |
| 输入 | WriteData | Object | 写入值。 |
| 输出 | Success | Boolean | 是否成功。 |
| 输出 | Message | String | 结果消息。 |
/// <summary>记录上一触发状态,只在上升沿执行写入。</summary>
private bool _lastTrigger;
/// <summary>变量写入不具备可靠的远端幂等确认,禁止不明结果自动重试。</summary>
protected override RuleNodeEffectKind EffectKind => RuleNodeEffectKind.NonIdempotentSideEffect;
/// <summary>初始化本实例的触发与业务结果。</summary>
public override Task InitAsync()
{
_lastTrigger = false;
Success = false;
Message = string.Empty;
return Task.CompletedTask;
}
/// <summary>通过正常变量写入链路处理一次上升沿,并保留引擎失败语义。</summary>
public override async Task ChangedAsync()
{
var rising = Trigger && !_lastTrigger;
_lastTrigger = Trigger;
if (!rising)
{
SetWaitingReason("WaitingForRisingEdge", "等待写入触发上升沿");
SkipExecution("NoRisingEdge", "本次输入没有写入触发上升沿");
return;
}
if (!GlobalData.TryGetVariableRuntime(DeviceName, VariableName, out var variable))
throw new RuleBusinessRejectedException("VariableNotFound", $"Variable not found: {DeviceName}.{VariableName}");
SetWaitingReason(null, null);
MarkSideEffectStarted();
var result = await variable.RpcAsync(
WriteData.ToSystemTextJsonElement(),
"rule",
ExecutionCancellationToken).ConfigureAwait(false);
if (!result.IsSuccess)
throw new RuleBusinessRejectedException("VariableRpcRejected", result.ErrorMessage ?? "Variable write was rejected.");
MarkSideEffectConfirmed();
Success = true;
Message = result.GetOperResult().ToString();
}数据推送节点
优先使用内置 MQTT、邮件和 Webhook 节点;完整配置见规则引擎。扩展新接收协议时,参考 VariableNode.cs 中的完整类,遵守以下执行契约。
| 阶段 | 开发要求 |
|---|---|
| 声明 | 通过 EffectKind 声明副作用。没有可靠幂等键的发送属于 NonIdempotentSideEffect,不能声明为纯计算。 |
| 前置检查 | 无效 URL、缺少凭据等抛出 RuleBusinessRejectedException;正常条件不满足可在动作开始前报告 Skipped。 |
| 发出动作 | 请求前调用 MarkSideEffectStarted,把 ExecutionCancellationToken 传入网络 API,并复用受控客户端。 |
| 确认结果 | 核对 HTTP 状态及厂商业务码;可确认完成后调用 MarkSideEffectConfirmed,再发布成功时间或结果。 |
| 失败与取消 | 不吞异常、不把 Success=false 后正常返回当作引擎失败。明确拒绝抛业务拒绝,取消继续上抛;发出动作后结果不明由引擎归类 UnknownEffect。 |
| 重试 | 总期限覆盖全部尝试和退避。仅明确瞬时故障可申请有限重试;非幂等动作发出后不能因超时自动重复发送。 |
| 释放 | 停止时关闭生产准入、等待在途工作结束,再释放连接。日志只保留诊断信息,隐藏 URL 中的令牌、密码与请求头凭据。 |
每个节点都有 ExecutionResult 事件输出,包含成功、显式跳过、拒绝、故障、超时、取消或结果未确认等状态。需要错误支路时接“执行错误捕获”,其忽略成功与显式跳过。业务结果与执行事件分别连接。
完整类 Demo:周期定时器
此示例使用 TUtility.TimerX,周期是节点配置参数,修改后保存并重新装载流程。定时回调只提交受管通知,Tick 是事件,连续发布 true 仍表示不同周期;Enabled=false 时不发布、不计数。需要动态周期或 Cron 时直接参考内置 Script/Timers/IntervalTimerNode.cs。
查看完整示例代码
#nullable enable
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using TGateway.Application;
using TUtility;
/// <summary>以固定周期产生事件,由宿主串行发布输出。</summary>
[Category("定时器")]
[RuleNode("manual.periodic-pulse")]
public sealed class PeriodicPulseNode : CustomExpressionBase, IDisposable
{
/// <summary>节点库显示名称。</summary>
public override string Name => "周期脉冲";
/// <summary>保存时固定的周期毫秒数,重新装载后生效。</summary>
[ExpressionParameter]
public int IntervalMs { get; set; } = 1000;
/// <summary>允许当前周期发布事件。</summary>
[ExpressionInput]
public bool Enabled { get; set; } = true;
/// <summary>保存最近一次周期事件载荷。</summary>
private bool _tick;
/// <summary>每次周期发布真事件,不需要先写假复位。</summary>
[ExpressionOutput(IsEvent = true)]
public bool Tick
{
get => _tick;
private set => SetOutput(ref _tick, value);
}
/// <summary>保存当前实例的周期次数。</summary>
private int _count;
/// <summary>发布当前实例已经完成的周期计数。</summary>
[ExpressionOutput]
public int Count
{
get => _count;
private set => SetOutput(ref _count, value);
}
/// <summary>由当前节点持有并释放的非重入定时任务。</summary>
private TimerX? _timer;
/// <summary>校验周期并建立当前流程实例的定时任务。</summary>
public override Task InitAsync()
{
if (IntervalMs < 1)
throw new RuleBusinessRejectedException("InvalidInterval", "IntervalMs must be positive.");
Count = 0;
ReportWaiting();
_timer = new TimerX(OnTimer, null, IntervalMs, IntervalMs);
return Task.CompletedTask;
}
/// <summary>启用输入变化时更新空闲等待说明。</summary>
public override Task ChangedAsync()
{
ReportWaiting();
return Task.CompletedTask;
}
/// <summary>定时线程不执行节点业务,只向流程队列提交固定通知。</summary>
private void OnTimer(object? state)
{
if (!LifetimeCancellationToken.IsCancellationRequested)
EnqueueNotification(PublishTick, nameof(Tick));
}
/// <summary>在受管串行执行内更新计数并发布本次周期事件。</summary>
private void PublishTick()
{
if (!Enabled) return;
Count++;
Tick = true;
}
/// <summary>空闲周期等待与执行结果分别报告。</summary>
private void ReportWaiting()
{
SetWaitingReason(Enabled ? "WaitingForInterval" : "WaitingForEnabled",
Enabled ? $"等待下次周期:{IntervalMs} ms" : "等待启用");
}
/// <summary>停止时移除定时任务,流程令牌同时阻止后续通知进入。</summary>
public void Dispose()
{
_timer?.Dispose();
_timer = null;
}
}排障与上线
开发注意事项
| 场景 | 要求 |
|---|---|
| 参数命名 | 使用 C# 属性合法名称,建议 PascalCase,例如 DeviceName、HighLimit。不要用中文、空格、连字符。 |
| 参数重命名 | 定义按稳定类型身份和端口名称引用。改名时同步修改流程;只调整排序不应改变端口绑定。 |
| 输出赋值 | 页面节点直接给输出属性赋值;完整类节点必须用 SetOutput 或等价回调。只改私有字段不会触发下游。 |
| 异步耗时 | ChangedAsync 中访问网络、数据库、MQTT、邮件服务时必须设置超时并处理异常,不要让规则流程无限等待。 |
| 事件订阅 | 订阅、定时器和连接属于当前流程实例。完整类实现释放接口,WEB 节点可覆盖 StopAsync;停止时解除订阅并等待在途工作结束。 |
| 高频触发 | 默认防抖能保护下游。开启“无限制触发”前先评估最坏频率、外部系统限流和 CPU 占用。 |
| Object 类型 | 结构化跨节点数据使用明确的 JSON 契约;自定义 CLR 类型还须满足当前节点目录、生成器和端口契约校验,不能依赖任意对象隐式转换。 |
| 日志 | 日志里不要输出密码、Token、证书内容。高频节点只记录异常和关键状态变化。 |
| 动态加载 | 加载 CustomNodeDlls 中的节点程序集要求 RuntimeFeature.IsDynamicCodeSupported 为 true。 |
排障
| 现象 | 检查链路 |
|---|---|
| 编译失败 | 先看第一条红色诊断;确认页面代码没有写完整类;确认参数名不是 C# 关键字;确认代码中用到的命名空间已 using 或写全名。 |
| 编译成功但规则面板没有节点 | 刷新规则页面;查看“已加载节点”;确认编译输出 DLL 位于 CustomNodeDlls;确认运行环境支持动态代码。 |
| 流程启动后节点不执行 | 确认流程已启用;确认节点没有上游时可作为起始节点;有上游时确认上游输出确实发生赋值。 |
| 下游收不到值 | 确认当前节点设置的是输出参数或输入输出参数;完整类是否使用 SetOutput;连线是否从右侧输出端口连到左侧输入端口。 |
| 下游输入为空或保留旧值 | 核对本次是否写入端口、是否显式跳过、值是否被去重以及可空/JSON 契约;查看连接最近活动,不能以旧显示值认定本次已投递。 |
| 参数值写入失败 | 检查规则实例属性是否能转换为参数类型;Object 参数检查 JSON 和 CustomType;数字类型检查小数/整数是否匹配。 |
| 重启后重复通知 | 检查事件节点、定时器节点、MQTT/邮件/Webhook 节点是否实现并正确释放 IDisposable。 |
| 流程校验提示环路 | 按诊断定位连线并拆除环路;用保持、计数、上下文或独立内存变量保存状态。 |
| RPC 写入失败 | 检查设备名、变量名、变量保护类型、写表达式、采集插件写入能力和设备在线状态。 |
| 推送节点阻塞 | 检查外部 URL、DNS、证书、超时设置、代理、防火墙;外部失败应输出错误而不是吞掉异常。 |
上线检查清单
| 检查项 | 要求 |
|---|---|
| 编译 | 单个编译成功,批量编译成功,服务重启后“已加载节点”仍能看到。 |
| 参数 | 每个参数有现场可理解的描述;输入、输出、输入输出方向正确。 |
| 流程 | 先用 3 到 5 个变量验证起始触发、输出传播、运行值显示和日志,再扩大范围。 |
| 异常 | 通讯失败、变量不存在、JSON 解析失败、外部推送失败都有明确输出或日志。 |
| 资源 | 事件、定时器、连接、通道、客户端在流程停止时能释放。 |
| 性能 | 高频流程不开无意义日志;外部请求有超时;批量推送或 RPC 有节流策略。 |
| 回退 | 生产修改前保留旧节点代码、旧参数表和规则流程导出文件。 |
相关文档
可选:通过 MCP 获取开发技能和随包工程
正常开发可直接阅读上面的准备、示例和契约,不需要先配置 AI 客户端。没有源码时,可通过以下材料入口取得公开契约、同版本发布引用及配套示例。
通过目标网关 MCP 获取 tgateway-development 技能,读取 references/custom-nodes.md、references/rule-engine.md 和 references/validation.md。从 examples/index.json 按需读取节点示例,从实例节点目录核对稳定 typeId、参数和端口,再创建节点及规则连线;不要求访问产品源码。
网页节点参数单独声明,代码填写节点成员,不粘贴完整 FullSource 类或顶层 using。除编译结果外,还要检查输入输出初值、触发传播、取消和事件退订,以及规则保存后的实例重载。技能同时覆盖规则 JSON 包的生成与校验,不能只交付孤立节点而省略用户需要的规则流程。