Skip to main content

自定义节点开发说明

本文面向需要扩展规则引擎节点的开发人员和交付工程师。自定义节点不是采集协议插件,也不是数据转发插件,而是规则流程里的“功能块”:上游输出变化后写入当前节点输入,当前节点执行 ChangedAsync,再把输出传给下游节点。

先判断是不是自定义节点

需求建议选型
需要把多个变量、报警、设备状态编排成联锁、告警、推送或控制流程自定义节点。
只是在变量读入或写出前做比例换算、枚举转换、字符串解析数据转换脚本,见 脚本开发说明
需要在内存变量里按表达式计算一个派生点内存变量脚本。
需要新接一种 PLC、仪表或协议采集采集插件,见 采集插件二开说明
需要把数据上传到新的平台、数据库或协议服务端业务插件,见 业务插件二开说明

源码入口

源码作用
ThingsGatewayRuntime.Application/Expressions/CustomExpressionBase.cs自定义节点基类,定义 NameInitAsyncChangedAsyncOutputChangedCallbackSetOutput
ThingsGatewayRuntime.Application/Expressions/CustomExpressionDefinition.csWEB 节点参数定义、参数方向、数据类型和生成属性规则。
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 推送节点参考实现。

总生命周期

  1. 在 WEB “开发配置 → 自定义节点”中新建节点,维护名称、分类、描述、代码、输入参数、输出参数和输入输出参数。
  2. 点击“编译并保存”后,Runtime 先把节点写入 custom_node 表。
  3. Runtime 调用 ThingsGatewayScriptCompiler,编译类型为 CustomNode
  4. 编译器按节点名称生成安全类名,把页面代码和参数属性包装成 CustomExpressionBase 派生类。
  5. 编译产物写入运行目录 CustomNodeDlls,文件名形如 <安全名称>AsyncExpression.dll
  6. 源生成器在 DLL 中生成 ModuleInitializer 注册代码,加载时把节点写入 ExpressionsData
  7. 规则流程启动时读取画布 JSON,rect 是节点,edge 是连线。
  8. 每个节点按 nodeTypeNameExpressionsData 创建实例。
  9. 运行时把画布中配置的输入参数和输入输出参数写入节点实例。
  10. 运行时注入流程日志 Logger,设置 OutputChangedCallback,再调用所有节点的 InitAsync
  11. 没有上游连线的起始节点会执行一次 ChangedAsync
  12. 节点输出变化后,运行时把值写入下游输入端口,并触发下游 SmartChangedTriggerScheduler
  13. 默认 10 ms 防抖;流程开启“无限制触发”后不做防抖。
  14. 流程停止、重启或删除运行上下文时,运行时对节点实例调用 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-0in-1写入输入参数本身不会触发输出传播,必须在 ChangedAsync 中设置输出。
输出参数当前节点计算、过滤、写入或推送后的结果。[ExpressionOutput] private set右侧输出端口 out-0out-1页面节点直接 Output = value;完整类节点用 SetOutput
输入输出参数既能从上游接收,又能被当前节点修改后继续输出。[ExpressionInOut] public set左右各一个端口 inout-in-0inout-out-0适合累计值、令牌、上下文对象;修改它会触发下游。

数据类型

后端模型支持这些类型:Int32Int64DoubleFloatDecimalBooleanStringDateTimeByteInt16UInt16UInt32UInt64Object。当前 WEB 参数定义面板主要暴露 Int32Int64FloatDoubleBooleanStringObject,其余类型适合通过接口、导入数据或完整类节点使用。

类型C# 类型适合场景初始值写法
Booleanbool启停、联锁、报警状态、边沿信号。truefalse
Int32 / Int64int / long计数、编号、毫秒时间、枚举值。1000
Float / Double / Decimalfloat / double / decimal模拟量、工程量、比例换算。1.5decimal 完整类中可写 1.5m
Stringstring设备名、变量名、Topic、URL、模板、告警文本。WEB 中通常在规则实例属性面板填写。
DateTimeDateTime时间戳、窗口期、延时判断。DateTime.UtcNow 只适合完整类默认值;页面实例建议手填。
Objectobject 或自定义类型VariableBasicDataAlarmVariable、字典、匿名对象、JSON 对象。连接上游对象输出最稳;手填 JSON 时要确认目标类型能转换。

当前 WEB 参数定义面板主要维护参数名、类型和说明;InitialValue 是接口和源码模型字段。页面没有专门初始值输入时,请在 InitAsync 中给输出和内部字段赋默认值,或在规则流程的节点属性面板填写实例参数。

运行时传播规则

规则说明
只有输出变化才传播输入有新值不等于下游会收到值。必须设置输出属性或输入输出属性。
null 值不会写入下游输入RuleEngineTask 当前只在 value != null 时写入下游属性,但仍会触发下游节点。需要传递“无数据”时建议输出对象包装状态,例如 { Valid = false }
端口按索引映射参数名运行时把 out-0 映射到第 1 个输出参数,把 in-0 映射到第 1 个输入参数。修改参数顺序后要检查旧流程连线。
起始节点会执行一次没有上游连线的节点在流程启动后执行一次 ChangedAsync,常量、周期源、事件源都可以作为起始节点。
默认 10 ms 防抖多个上游短时间连续输出时,下游可能合并执行。需要逐次触发时在流程中开启“无限制触发”,但要评估 CPU 和外部系统压力。
有环路会告警并停止重复传播规则引擎会检测循环依赖和单次传播链重复访问。不要用节点环路做高速控制循环。
停止时释放实例完整类节点实现 IDisposable 后,流程停止时会释放事件订阅、连接、定时器等资源。

内置节点类型清单

以下类型已从源码 EmbeddedNodes.csVariableNode.cs 核对。开发新节点时先看同类内置实现,避免重复造基础节点。

类型内置节点
数学运算加法、减法、乘法、除法、取模、幂运算、平方根、绝对值、四舍五入、数值钳制、线性缩放。
逻辑运算逻辑与、逻辑或、逻辑非、逻辑异或、逻辑与非、逻辑或非。
比较运算等于、不等于、大于、大于等于、小于、小于等于、范围内判断。
条件判断条件选择、条件选择(字符串)、阈值触发、多路分支。
字符串处理字符串拼接、字符串格式化、字符串截取、字符串替换、转大写、转小写、去除空格、包含判断、字符串长度。
类型转换转整数、转浮点数、转字符串、转布尔值。
定时器延时、单次定时器、周期定时器。
计数器计数器、加减计数器。
边沿检测上升沿检测、下降沿检测、双边沿检测。
数据统计平均值、移动平均、最大最小值、累加器。
变化检测变化检测、变化率。
保持值保持、采样保持、触发器。
常量数值常量、字符串常量、布尔常量。
三角函数正弦、余弦、正切、反正切2。
触发器变量通知规则、告警通知规则、设备通知规则。
变量 RPC 节点变量 RPC 节点。
数据推送MQTT 客户端上传、邮件推送、Webhook 推送。

Demo 写法约定

下面的“页面参数”按自定义节点编辑器的三类参数填写。除“完整类 Demo”外,代码都粘贴到 WEB 自定义节点代码编辑区,不要额外写类名、命名空间或 Name 属性。

常量节点

页面参数:

方向名称类型说明
输入ValueDouble在规则流程节点属性面板填写的常量值。
输出OutputDouble输出给下游的常量值。
public override Task InitAsync()
{
Output = Value;
return Task.CompletedTask;
}

public override Task ChangedAsync()
{
Output = Value;
return Task.CompletedTask;
}

数学运算节点

页面参数:

方向名称类型说明
输入RawValueDouble原始量程值。
输入RawMinDouble原始下限。
输入RawMaxDouble原始上限。
输入EngMinDouble工程下限。
输入EngMaxDouble工程上限。
输出ValueDouble换算后的工程值。
输出ErrorBoolean量程配置是否错误。
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;
}

逻辑运算节点

页面参数:

方向名称类型说明
输入AutoModeBoolean自动模式。
输入EmergencyStopBoolean急停状态。
输入PressureOkBoolean压力允许。
输入FaultBoolean故障状态。
输出AllowedBoolean是否允许启动。
public override Task InitAsync()
{
Allowed = false;
return Task.CompletedTask;
}

public override Task ChangedAsync()
{
Allowed = AutoMode && !EmergencyStop && PressureOk && !Fault;
return Task.CompletedTask;
}

比较和阈值节点

页面参数:

方向名称类型说明
输入PVDouble过程值。
输入HighLimitDouble高限值。
输入HysteresisDouble回差,防止临界点抖动。
输出AlarmBoolean当前是否报警。
输出RisingEdgeBoolean报警刚发生。
输出FallingEdgeBoolean报警刚恢复。
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;
}

条件选择节点

页面参数:

方向名称类型说明
输入UseManualBoolean是否使用手动给定值。
输入ManualValueDouble手动给定值。
输入AutoValueDouble自动计算值。
输出OutputDouble最终输出值。
输出SourceString输出来源。
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;
}

字符串处理节点

页面参数:

方向名称类型说明
输入DeviceNameString设备名。
输入VariableNameString变量名。
输入ValueTextString当前值文本。
输入UnitString单位。
输出MessageString拼好的提示文本。
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;
}

类型转换节点

页面参数:

方向名称类型说明
输入InputTextString外部文本值。
输出ValueDouble解析后的数值。
输出SuccessBoolean是否解析成功。
输出ErrorMessageString错误信息。
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

页面参数:

方向名称类型说明
输入TriggerBoolean触发信号。
输入DelayMsInt32延时时间,毫秒。
输出OutputBoolean延时完成输出。
输出DoneBoolean延时完成标志。
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)
{
}
}

计数器节点

页面参数:

方向名称类型说明
输入IncrementBoolean计数脉冲。
输入ResetBoolean复位。
输入MaxValueInt32上限。
输出CountInt32当前计数。
输出OverflowBoolean是否达到上限。
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;
}

边沿检测节点

页面参数:

方向名称类型说明
输入InputBoolean当前开关量。
输出RisingBoolean上升沿。
输出FallingBoolean下降沿。
输出ChangedBoolean任意变化。
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;
}

数据统计节点

页面参数:

方向名称类型说明
输入InputDouble当前采样值。
输入WindowSizeInt32滑动窗口大小。
输出AverageDouble滑动平均值。
输出SampleCountInt32当前样本数量。
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;
}

变化检测节点

页面参数:

方向名称类型说明
输入InputDouble当前值。
输入ThresholdDouble变化判定阈值。
输出ChangedBoolean是否超过阈值。
输出DeltaDouble本次变化量。
输出RatePerSecondDouble每秒变化率。
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;
}

保持节点

页面参数:

方向名称类型说明
输入InputDouble当前值。
输入SampleBoolean采样信号。
输入HoldBoolean保持信号。
输出OutputDouble输出值。
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;
}

三角函数节点

页面参数:

方向名称类型说明
输入AngleDouble角度或弧度。
输入UseDegreesBooleantrue 表示输入为角度。
输出SinValueDouble正弦值。
输出CosValueDouble余弦值。
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 结构化数据节点

页面参数:

方向名称类型说明
输入InputDataObject上游传入的对象,例如变量通知输出的 VariableBasicData
输出JsonString序列化后的 JSON。
输出HasDataBoolean是否有有效数据。
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 时每次输入变化都重复写。

页面参数:

方向名称类型说明
输入TriggerBoolean写入触发。
输入DeviceNameString设备名。
输入VariableNameString变量名。
输入WriteDataObject写入值。
输出SuccessBoolean是否成功。
输出MessageString结果消息。
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

页面参数:

方向名称类型说明
输入EnabledBoolean是否启用。
输入UrlStringWebhook 地址。
输入InputDataObject要推送的数据。
输出SuccessBoolean发送是否成功。
输出ErrorMessageString错误信息。
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,例如 DeviceNameHighLimit。不要用中文、空格、连字符。
参数重命名规则流程保存的是端口索引和节点属性。改名或调换顺序后,要重新检查旧流程连线和实例参数。
输出赋值页面节点直接给输出属性赋值;完整类节点必须用 SetOutput 或等价回调。只改私有字段不会触发下游。
异步耗时ChangedAsync 中访问网络、数据库、MQTT、邮件服务时必须设置超时并处理异常,不要让规则流程无限等待。
事件订阅订阅 GlobalData 事件、创建定时器、打开连接的节点必须完整类实现 IDisposable 并释放资源。
高频触发默认防抖能保护下游。开启“无限制触发”前先评估最坏频率、外部系统限流和 CPU 占用。
Object 类型优先通过上游连线传对象。手填 JSON 或自定义类型时,要确认 CustomType 能被运行时加载并转换。
日志日志里不要输出密码、Token、证书内容。高频节点只记录异常和关键状态变化。
AOT动态加载 CustomNodeDlls 依赖 RuntimeFeature.IsDynamicCodeSupported。AOT 或禁止动态代码环境不适合运行时热编译节点。

排障

现象检查链路
编译失败先看第一条红色诊断;确认页面代码没有写完整类;确认参数名不是 C# 关键字;确认代码中用到的命名空间已 using 或写全名。
编译成功但规则面板没有节点刷新规则页面;查看“已加载节点”;确认编译输出 DLL 位于 CustomNodeDlls;确认运行环境支持动态代码。
流程启动后节点不执行确认流程已启用;确认节点没有上游时可作为起始节点;有上游时确认上游输出确实发生赋值。
下游收不到值确认当前节点设置的是输出参数或输入输出参数;完整类是否使用 SetOutput;连线是否从右侧输出端口连到左侧输入端口。
下游被触发但输入为空当前源码对 null 输出不写入下游输入。用对象包装“空值状态”,或输出空字符串、false0 等明确值。
参数值写入失败检查规则实例属性是否能转换为参数类型;Object 参数检查 JSON 和 CustomType;数字类型检查小数/整数是否匹配。
重启后重复通知检查事件节点、定时器节点、MQTT/邮件/Webhook 节点是否实现并正确释放 IDisposable
流程出现循环告警检查画布是否有环路;不要用节点互相回写实现控制循环;必要时拆成状态变量或定时源触发。
RPC 写入失败检查设备名、变量名、变量保护类型、写表达式、采集插件写入能力和设备在线状态。
推送节点阻塞检查外部 URL、DNS、证书、超时设置、代理、防火墙;外部失败应输出错误而不是吞掉异常。

上线检查清单

检查项要求
编译单个编译成功,批量编译成功,服务重启后“已加载节点”仍能看到。
参数每个参数有现场可理解的描述;输入、输出、输入输出方向正确。
流程先用 3 到 5 个变量验证起始触发、输出传播、运行值显示和日志,再扩大范围。
异常通讯失败、变量不存在、JSON 解析失败、外部推送失败都有明确输出或日志。
资源事件、定时器、连接、通道、客户端在流程停止时能释放。
性能高频流程不开无意义日志;外部请求有超时;批量推送或 RPC 有节流策略。
回退生产修改前保留旧节点代码、旧参数表和规则流程导出文件。

相关文档