自定义节点开发说明
本文面向需要扩展规则引擎节点的开发人员和交付工程师。自定义节点不是采集协议插件,也不是数据转发插件,而是规则流程里的“功能块”:上游输出变化后写入当前节点输入,当前节点执行 ChangedAsync,再把输出传给下游节点。
先判断是不是自定义节点
| 需求 | 建议选型 |
|---|---|
| 需要把多个变量、报警、设备状态编排成联锁、告警、推送或控制流程 | 自定义节点。 |
| 只是在变量读入或写出前做比例换算、枚举转换、字符串解析 | 数据转换脚本,见 脚本开发说明。 |
| 需要在内存变量里按表达式计算一个派生点 | 内存变量脚本。 |
| 需要新接一种 PLC、仪表或协议采集 | 采集插件,见 采集插件二开说明。 |
| 需要把数据上传到新的平台、数据库或协议服务端 | 业务插件,见 业务插件二开说明。 |
源码入口
| 源码 | 作用 |
|---|---|
ThingsGatewayRuntime.Application/Expressions/CustomExpressionBase.cs | 自定义节点基类,定义 Name、InitAsync、ChangedAsync、OutputChangedCallback 和 SetOutput。 |
ThingsGatewayRuntime.Application/Expressions/CustomExpressionDefinition.cs | WEB 节点参数定义、参数方向、数据类型和生成属性规则。 |
ThingsGatewayRuntime.Application/Controllers/RuleEngine/GatewayCustomNodeController.cs | 自定义节点创建、保存、编译、批量编译、删除和热加载接口。 |
ThingsGatewayRuntime.Application/Entity/CustomNode.cs | 自定义节点数据库表,保存名称、分类、描述、代码和三类参数 JSON。 |
ThingsGatewayScriptCompiler/ExpressionCodeGenerator.cs | 把 WEB 节点代码包装成继承 CustomExpressionBase 的 C# 类。 |
ThingsGatewayRuntime.ExpressionsGenerator/SourceGenerator/ExpressionRegistrationGenerator.cs | 编译时扫描节点类,生成注册代码并写入 ExpressionsData。 |
ThingsGatewayRuntime.Application/Expressions/ExpressionsData.cs | 保存已注册节点信息和创建、读写、初始化、变化执行委托。 |
ThingsGatewayRuntime.Application/Task/RuleEngine/RuleEngineTask.cs | 规则流程运行时,负责创建节点实例、写入参数、初始化、输出传播、防抖和释放。 |
ThingsGatewayRuntime.Application/Script/EmbeddedNodes.cs | 内置数学、逻辑、比较、定时器、计数器、统计等节点参考实现。 |
ThingsGatewayRuntime.Application/Script/VariableNode.cs | 内置变量通知、报警通知、设备通知、变量 RPC、MQTT/邮件/Webhook 推送节点参考实现。 |
总生命周期
- 在 WEB “开发配置 → 自定义节点”中新建节点,维护名称、分类、描述、代码、输入参数、输出参数和输入输出参数。
- 点击“编译并保存”后,Runtime 先把节点写入
custom_node表。 - Runtime 调用
ThingsGatewayScriptCompiler,编译类型为CustomNode。 - 编译器按节点名称生成安全类名,把页面代码和参数属性包装成
CustomExpressionBase派生类。 - 编译产物写入运行目录
CustomNodeDlls,文件名形如<安全名称>AsyncExpression.dll。 - 源生成器在 DLL 中生成
ModuleInitializer注册代码,加载时把节点写入ExpressionsData。 - 规则流程启动时读取画布 JSON,
rect是节点,edge是连线。 - 每个节点按
nodeTypeName从ExpressionsData创建实例。 - 运行时把画布中配置的输入参数和输入输出参数写入节点实例。
- 运行时注入流程日志
Logger,设置OutputChangedCallback,再调用所有节点的InitAsync。 - 没有上游连线的起始节点会执行一次
ChangedAsync。 - 节点输出变化后,运行时把值写入下游输入端口,并触发下游
SmartChangedTriggerScheduler。 - 默认 10 ms 防抖;流程开启“无限制触发”后不做防抖。
- 流程停止、重启或删除运行上下文时,运行时对节点实例调用
TryDispose。
基类契约
所有可运行的自定义节点最终都要满足这个契约:
public abstract class CustomExpressionBase
{
public abstract string Name { get; }
public Loggers? Logger { get; set; }
public abstract Task InitAsync();
public abstract Task ChangedAsync();
public Action<string, object?>? OutputChangedCallback;
protected void SetOutput<T>(ref T field, T value, string propertyName = null);
}
| 成员 | 开发要求 |
|---|---|
Name | 运行时注册和规则流程引用的节点名称。WEB 自定义节点由包装器自动生成,完整类节点必须手写。 |
InitAsync | 节点实例创建、参数写入、输出回调设置完成后调用一次。适合初始化输出、订阅事件、创建连接或启动定时器。 |
ChangedAsync | 起始节点启动时或上游输入变化后调用。适合读取输入、计算输出、执行写入或推送。 |
Logger | 流程专用日志。高频节点不要正常路径刷 Info 日志,只在异常、过滤、关键动作时记录。 |
OutputChangedCallback | 输出传播入口。WEB 生成的输出属性会自动调用;完整类节点应通过 SetOutput 修改输出。 |
SmartChangedTriggerScheduler | 运行时防抖触发器。业务代码通常不直接调用它,由规则引擎负责。 |
两种代码形态
WEB 页面节点
在自定义节点编辑器中只写类成员代码,不写完整类,不写 Name 属性,也不要重复声明页面参数。参数属性由参数定义自动生成。
如果参数定义中有输入 Input、输入 Scale、输出 Output,页面实际会包装成类似下面的类:
public class MyNodeCustomNode : CustomExpressionBase
{
public override string Name => "MyNode";
[ExpressionInput]
public double Input { get; set; }
[ExpressionInput]
public double Scale { get; set; }
[ExpressionOutput]
public double Output { get; private set; }
// 页面代码从这里插入。
}
页面里应写成:
public override Task InitAsync()
{
Output = 0;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Output = Input * Scale;
return Task.CompletedTask;
}
完整类节点
完整类节点适合内置节点、外部 DLL、需要实现 IDisposable 的事件/连接/定时器节点。完整类必须继承 CustomExpressionBase,参数用 [ExpressionInput]、[ExpressionOutput]、[ExpressionInOut] 标记,输出属性用 SetOutput。
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("比较运算")]
public sealed class HighLimitNode : CustomExpressionBase
{
public override string Name => "高限判断";
[ExpressionInput]
public double Input { get; set; }
[ExpressionInput]
public double Limit { get; set; } = 100;
private bool _alarm;
[ExpressionOutput]
public bool Alarm
{
get => _alarm;
private set => SetOutput(ref _alarm, value);
}
public override Task InitAsync()
{
Alarm = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Alarm = Input > Limit;
return Task.CompletedTask;
}
}
参数模型
| 参数方向 | 现场含义 | 生成属性 | 连线位置 | 注意事项 |
|---|---|---|---|---|
| 输入参数 | 上游传入或规则实例面板手填的值。 | [ExpressionInput] public set | 左侧输入端口 in-0、in-1。 | 写入输入参数本身不会触发输出传播,必须在 ChangedAsync 中设置输出。 |
| 输出参数 | 当前节点计算、过滤、写入或推送后的结果。 | [ExpressionOutput] private set | 右侧输出端口 out-0、out-1。 | 页面节点直接 Output = value;完整类节点用 SetOutput。 |
| 输入输出参数 | 既能从上游接收,又能被当前节点修改后继续输出。 | [ExpressionInOut] public set | 左右各一个端口 inout-in-0、inout-out-0。 | 适合累计值、令牌、上下文对象;修改它会触发下游。 |
数据类型
后端模型支持这些类型:Int32、Int64、Double、Float、Decimal、Boolean、String、DateTime、Byte、Int16、UInt16、UInt32、UInt64、Object。当前 WEB 参数定义面板主要暴露 Int32、Int64、Float、Double、Boolean、String、Object,其余类型适合通过接口、导入数据或完整类节点使用。
| 类型 | 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 参数定义面板主要维护参数名、类型和说明;InitialValue 是接口和源码模型字段。页面没有专门初始值输入时,请在 InitAsync 中给输出和内部字段赋默认值,或在规则流程的节点属性面板填写实例参数。
运行时传播规则
| 规则 | 说明 |
|---|---|
| 只有输出变化才传播 | 输入有新值不等于下游会收到值。必须设置输出属性或输入输出属性。 |
null 值不会写入下游输入 | RuleEngineTask 当前只在 value != null 时写入下游属性,但仍会触发下游节点。需要传递“无数据”时建议输出对象包装状态,例如 { Valid = false }。 |
| 端口按索引映射参数名 | 运行时把 out-0 映射到第 1 个输出参数,把 in-0 映射到第 1 个输入参数。修改参数顺序后要检查旧流程连线。 |
| 起始节点会执行一次 | 没有上游连线的节点在流程启动后执行一次 ChangedAsync,常量、周期源、事件源都可以作为起始节点。 |
| 默认 10 ms 防抖 | 多个上游短时间连续输出时,下游可能合并执行。需要逐次触发时在流程中开启“无限制触发”,但要评估 CPU 和外部系统压力。 |
| 有环路会告警并停止重复传播 | 规则引擎会检测循环依赖和单次传播链重复访问。不要用节点环路做高速控制循环。 |
| 停止时释放实例 | 完整类节点实现 IDisposable 后,流程停止时会释放事件订阅、连接、定时器等资源。 |
内置节点类型清单
以下类型已从源码 EmbeddedNodes.cs 和 VariableNode.cs 核对。开发新节点时先看同类内置实现,避免重复造基础节点。
| 类型 | 内置节点 |
|---|---|
| 数学运算 | 加法、减法、乘法、除法、取模、幂运算、平方根、绝对值、四舍五入、数值钳制、线性缩放。 |
| 逻辑运算 | 逻辑与、逻辑或、逻辑非、逻辑异或、逻辑与非、逻辑或非。 |
| 比较运算 | 等于、不等于、大于、大于等于、小于、小于等于、范围内判断。 |
| 条件判断 | 条件选择、条件选择(字符串)、阈值触发、多路分支。 |
| 字符串处理 | 字符串拼接、字符串格式化、字符串截取、字符串替换、转大写、转小写、去除空格、包含判断、字符串长度。 |
| 类型转换 | 转整数、转浮点数、转字符串、转布尔值。 |
| 定时器 | 延时、单次定时器、周期定时器。 |
| 计数器 | 计数器、加减计数器。 |
| 边沿检测 | 上升沿检测、下降沿检测、双边沿检测。 |
| 数据统计 | 平均值、移动平均、最大最小值、累加器。 |
| 变化检测 | 变化检测、变化率。 |
| 保持 | 值保持、采样保持、触发器。 |
| 常量 | 数值常量、字符串常量、布尔常量。 |
| 三角函数 | 正弦、余弦、正切、反正切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;
}
定时器节点
这个 Demo 是可粘贴到 WEB 的单次延时节点。长期周期定时器建议用后面的完整类写法实现 IDisposable。
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Trigger | Boolean | 触发信号。 |
| 输入 | DelayMs | Int32 | 延时时间,毫秒。 |
| 输出 | Output | Boolean | 延时完成输出。 |
| 输出 | Done | Boolean | 延时完成标志。 |
private System.Threading.CancellationTokenSource? _delayCts;
public override Task InitAsync()
{
Output = false;
Done = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
_delayCts?.Cancel();
_delayCts?.Dispose();
_delayCts = null;
if (!Trigger)
{
Output = false;
Done = false;
return Task.CompletedTask;
}
Done = false;
var cts = new System.Threading.CancellationTokenSource();
_delayCts = cts;
_ = DelayAsync(cts);
return Task.CompletedTask;
}
private async Task DelayAsync(System.Threading.CancellationTokenSource cts)
{
try
{
await Task.Delay(Math.Max(1, DelayMs), cts.Token).ConfigureAwait(false);
if (!cts.IsCancellationRequested)
{
Output = true;
Done = true;
}
}
catch (TaskCanceledException)
{
}
}
计数器节点
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | 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 结构化数据节点
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | 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:变量通知触发节点
这个类型要订阅全局事件,必须完整类实现 IDisposable。它适合把运行时变量变化变成规则流程的起点。
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("触发器")]
public sealed class SimpleVariableNotifyNode : CustomExpressionBase, IDisposable
{
public override string Name => "变量变化触发";
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
[ExpressionInput]
public string VariableName { get; set; } = string.Empty;
private object? _value;
[ExpressionOutput]
public object? Value
{
get => _value;
private set => SetOutput(ref _value, value);
}
private VariableBasicData? _data;
[ExpressionOutput]
public VariableBasicData? Data
{
get => _data;
private set => SetOutput(ref _data, value);
}
public override Task InitAsync()
{
GlobalData.VariableValueChangeEvent += OnVariableChanged;
return Task.CompletedTask;
}
private void OnVariableChanged(VariableRuntime runtime, VariableBasicData data)
{
if (!string.IsNullOrWhiteSpace(DeviceName) && data.DeviceName != DeviceName)
{
return;
}
if (!string.IsNullOrWhiteSpace(VariableName) && data.Name != VariableName)
{
return;
}
Data = data;
Value = data.Value;
}
public override Task ChangedAsync() => Task.CompletedTask;
public void Dispose()
{
GlobalData.VariableValueChangeEvent -= OnVariableChanged;
}
}
完整类 Demo:报警通知触发节点
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("触发器")]
public sealed class SimpleAlarmNotifyNode : CustomExpressionBase, IDisposable
{
public override string Name => "报警变化触发";
[ExpressionInput]
public int MinLevel { get; set; } = 0;
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
private AlarmVariable? _alarm;
[ExpressionOutput]
public AlarmVariable? Alarm
{
get => _alarm;
private set => SetOutput(ref _alarm, value);
}
private string _text = string.Empty;
[ExpressionOutput]
public string Text
{
get => _text;
private set => SetOutput(ref _text, value);
}
public override Task InitAsync()
{
GlobalData.AlarmChangedEvent += OnAlarmChanged;
return Task.CompletedTask;
}
private void OnAlarmChanged(AlarmVariable alarm)
{
if (alarm.AlarmLevel < MinLevel)
{
return;
}
if (!string.IsNullOrWhiteSpace(DeviceName) && alarm.DeviceName != DeviceName)
{
return;
}
Alarm = alarm;
Text = $"{alarm.DeviceName}.{alarm.Name} {alarm.EventType} {alarm.AlarmText}";
}
public override Task ChangedAsync() => Task.CompletedTask;
public void Dispose()
{
GlobalData.AlarmChangedEvent -= OnAlarmChanged;
}
}
完整类 Demo:设备状态触发节点
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("触发器")]
public sealed class SimpleDeviceStatusNode : CustomExpressionBase, IDisposable
{
public override string Name => "设备状态触发";
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
private DeviceBasicData? _device;
[ExpressionOutput]
public DeviceBasicData? Device
{
get => _device;
private set => SetOutput(ref _device, value);
}
private string _status = string.Empty;
[ExpressionOutput]
public string Status
{
get => _status;
private set => SetOutput(ref _status, value);
}
public override Task InitAsync()
{
GlobalData.DeviceStatusChangeEvent += OnDeviceChanged;
return Task.CompletedTask;
}
private void OnDeviceChanged(DeviceRuntime runtime, DeviceBasicData data)
{
if (!string.IsNullOrWhiteSpace(DeviceName) && data.Name != DeviceName)
{
return;
}
Device = data;
Status = data.DeviceStatus.ToString();
}
public override Task ChangedAsync() => Task.CompletedTask;
public void Dispose()
{
GlobalData.DeviceStatusChangeEvent -= OnDeviceChanged;
}
}
变量 RPC 节点
RPC 在现场语言里就是“外部系统或规则流程反写点位”。这个 Demo 用上升沿触发一次写入,避免 Trigger=true 时每次输入变化都重复写。
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Trigger | Boolean | 写入触发。 |
| 输入 | DeviceName | String | 设备名。 |
| 输入 | VariableName | String | 变量名。 |
| 输入 | WriteData | Object | 写入值。 |
| 输出 | Success | Boolean | 是否成功。 |
| 输出 | Message | String | 结果消息。 |
private bool _lastTrigger;
public override Task InitAsync()
{
_lastTrigger = false;
Success = false;
Message = string.Empty;
return Task.CompletedTask;
}
public override async Task ChangedAsync()
{
var rising = Trigger && !_lastTrigger;
_lastTrigger = Trigger;
if (!rising)
{
return;
}
if (!GlobalData.TryGetVariableRuntime(DeviceName, VariableName, out var variable))
{
Success = false;
Message = $"Variable not found: {DeviceName}.{VariableName}";
return;
}
var result = (await variable.RpcAsync(
WriteData.ToSystemTextJsonElement(),
"rule",
System.Threading.CancellationToken.None).ConfigureAwait(false)).GetOperResult();
Success = result.IsSuccess;
Message = result.ToString();
}
数据推送节点
源码里的数据推送节点包括 MQTT 客户端上传、邮件推送和 Webhook 推送。二开时要重点处理连接复用、超时、失败输出、密码日志脱敏和资源释放。下面给出最小 Webhook 页面节点;MQTT 和邮件这类需要持久连接或通道对象的推送,建议参考 VariableNode.cs 写完整类并实现 IDisposable。
页面参数:
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 输入 | Enabled | Boolean | 是否启用。 |
| 输入 | Url | String | Webhook 地址。 |
| 输入 | InputData | Object | 要推送的数据。 |
| 输出 | Success | Boolean | 发送是否成功。 |
| 输出 | ErrorMessage | String | 错误信息。 |
private static readonly System.Net.Http.HttpClient HttpClient = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
public override Task InitAsync()
{
Success = false;
ErrorMessage = string.Empty;
return Task.CompletedTask;
}
public override async Task ChangedAsync()
{
if (!Enabled || string.IsNullOrWhiteSpace(Url) || InputData == null)
{
return;
}
try
{
var json = InputData.ToSystemTextJsonString();
using var content = new System.Net.Http.StringContent(
json,
System.Text.Encoding.UTF8,
"application/json");
using var response = await HttpClient.PostAsync(Url, content).ConfigureAwait(false);
Success = response.IsSuccessStatusCode;
ErrorMessage = Success ? string.Empty : $"{(int)response.StatusCode} {response.ReasonPhrase}";
}
catch (Exception ex)
{
Success = false;
ErrorMessage = ex.Message;
Logger?.LogWarning(ex, "Webhook push failed");
}
}
完整类 Demo:周期定时器
周期源节点会在没有上游输入时自己产生输出。它必须释放 Timer,否则流程重启后可能出现重复触发。
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("定时器")]
public sealed class PeriodicPulseNode : CustomExpressionBase, IDisposable
{
public override string Name => "周期脉冲";
[ExpressionInput]
public int IntervalMs { get; set; } = 1000;
[ExpressionInput]
public bool Enabled { get; set; } = true;
private bool _tick;
[ExpressionOutput]
public bool Tick
{
get => _tick;
private set => SetOutput(ref _tick, value);
}
private int _count;
[ExpressionOutput]
public int Count
{
get => _count;
private set => SetOutput(ref _count, value);
}
private System.Threading.Timer? _timer;
public override Task InitAsync()
{
_timer = new System.Threading.Timer(OnTimer, null, 0, Math.Max(1, IntervalMs));
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
_timer?.Change(0, Math.Max(1, IntervalMs));
return Task.CompletedTask;
}
private void OnTimer(object? state)
{
if (!Enabled)
{
return;
}
Tick = !Tick;
Count++;
}
public void Dispose()
{
_timer?.Dispose();
}
}
开发注意事项
| 场景 | 要求 |
|---|---|
| 参数命名 | 使用 C# 属性合法名称,建议 PascalCase,例如 DeviceName、HighLimit。不要用中文、空格、连字符。 |
| 参数重命名 | 规则流程保存的是端口索引和节点属性。改名或调换顺序后,要重新检查旧流程连线和实例参数。 |
| 输出赋值 | 页面节点直接给输出属性赋值;完整类节点必须用 SetOutput 或等价回调。只改私有字段不会触发下游。 |
| 异步耗时 | ChangedAsync 中访问网络、数据库、MQTT、邮件服务时必须设置超时并处理异常,不要让规则流程无限等待。 |
| 事件订阅 | 订阅 GlobalData 事件、创建定时器、打开连接的节点必须完整类实现 IDisposable 并释放资源。 |
| 高频触发 | 默认防抖能保护下游。开启“无限制触发”前先评估最坏频率、外部系统限流和 CPU 占用。 |
| Object 类型 | 优先通过上游连线传对象。手填 JSON 或自定义类型时,要确认 CustomType 能被运行时加载并转换。 |
| 日志 | 日志里不要输出密码、Token、证书内容。高频节点只记录异常和关键状态变化。 |
| AOT | 动态加载 CustomNodeDlls 依赖 RuntimeFeature.IsDynamicCodeSupported。AOT 或禁止动态代码环境不适合运行时热编译节点。 |
排障
| 现象 | 检查链路 |
|---|---|
| 编译失败 | 先看第一条红色诊断;确认页面代码没有写完整类;确认参数名不是 C# 关键字;确认代码中用到的命名空间已 using 或写全名。 |
| 编译成功但规则面板没有节点 | 刷新规则页面;查看“已加载节点”;确认编译输出 DLL 位于 CustomNodeDlls;确认运行环境支持动态代码。 |
| 流程启动后节点不执行 | 确认流程已启用;确认节点没有上游时可作为起始节点;有上游时确认上游输出确实发生赋值。 |
| 下游收不到值 | 确认当前节点设置的是输出参数或输入输出参数;完整类是否使用 SetOutput;连线是否从右侧输出端口连到左侧输入端口。 |
| 下游被触发但输入为空 | 当前源码对 null 输出不写入下游输入。用对象包装“空值状态”,或输出空字符串、false、0 等明确值。 |
| 参数值写入失败 | 检查规则实例属性是否能转换为参数类型;Object 参数检查 JSON 和 CustomType;数字类型检查小数/整数是否匹配。 |
| 重启后重复通知 | 检查事件节点、定时器节点、MQTT/邮件/Webhook 节点是否实现并正确释放 IDisposable。 |
| 流程出现循环告警 | 检查画布是否有环路;不要用节点互相回写实现控制循环;必要时拆成状态变量或定时源触发。 |
| RPC 写入失败 | 检查设备名、变量名、变量保护类型、写表达式、采集插件写入能力和设备在线状态。 |
| 推送节点阻塞 | 检查外部 URL、DNS、证书、超时设置、代理、防火墙;外部失败应输出错误而不是吞掉异常。 |
上线检查清单
| 检查项 | 要求 |
|---|---|
| 编译 | 单个编译成功,批量编译成功,服务重启后“已加载节点”仍能看到。 |
| 参数 | 每个参数有现场可理解的描述;输入、输出、输入输出方向正确。 |
| 流程 | 先用 3 到 5 个变量验证起始触发、输出传播、运行值显示和日志,再扩大范围。 |
| 异常 | 通讯失败、变量不存在、JSON 解析失败、外部推送失败都有明确输出或日志。 |
| 资源 | 事件、定时器、连接、通道、客户端在流程停止时能释放。 |
| 性能 | 高频流程不开无意义日志;外部请求有超时;批量推送或 RPC 有节流策略。 |
| 回退 | 生产修改前保留旧节点代码、旧参数表和规则流程导出文件。 |