脚本开发说明
本文面向需要扩展 TGateway 脚本能力的开发人员和交付工程师。它不是普通现场配置手册,而是说明脚本如何被编译、加载、注册、绑定和执行,以及每一种脚本类型应该怎么写。
开发准备与公共契约
源码入口
| 源码 | 作用 |
|---|---|
TGateway.Application/Expressions/ExpressionInfo.cs | 脚本类型枚举、脚本元数据、输入输出参数定义。 |
TGateway.Application/Expressions/DynamicBase.cs | 数据转换、内存变量、动态模型、动态 SQL、MQTT RPC、完整源码的运行时基类。 |
TGateway.Application/Expressions/CustomExpressionBase.cs | 自定义节点脚本基类和输出变化回调。 |
TGateway.ScriptCompiler/ExpressionCodeGenerator.cs | 把页面中填写的脚本包装成 C# 类并编译为 DLL。 |
TGateway.ExpressionsGenerator/ExpressionRegistrationGenerator.cs | 编译时扫描脚本类,通过 ModuleInitializer 注册到 ExpressionsData。 |
TGateway.Application/Expressions/ExpressionsData.cs | 保存已注册脚本委托,按名称查找脚本实例。 |
TGateway.Application/Expressions/ExpressionsHelper.cs | 运行时按脚本名称执行数据转换、动态模型、动态 SQL、MQTT RPC。 |
TGateway.Application/Controllers/GatewayScriptController.cs | WEB 脚本管理接口:创建、保存、编译、批量编译、删除、热加载。 |
TGateway.Application/Controllers/RuleEngine/GatewayCustomNodeController.cs | 自定义节点创建、保存、编译、热加载。 |
TGateway.Application/Task/RuleEngine/RuleEngineTask.cs | 规则流程启动、节点实例创建、输入输出传播和防抖触发。 |
TGateway.Application/Expressions/RuntimeDllLoader.cs | 启动时加载 PluginDlls、ScriptDlls、CustomNodeDlls。 |
总生命周期
- 在 WEB 中创建脚本或自定义节点。节点编辑器先保存当前草稿再编译;脚本编辑器的“编译并保存”在编译、加载成功后保存本次源码和参数。
- 点击编译时,Runtime 调用外部
TGateway.ScriptCompiler进程,按脚本类型包装源码和参数。 - 普通脚本与节点先编译到各自产物目录下的独立暂存目录,完成程序集初始化、类型身份和注册验证后,再发布正式文件及注册目录;失败保留原已发布结果,并清理本次暂存目录。
- 源生成器在 DLL 内生成
ModuleInitializer注册代码,装载期间暂存整批注册,验证通过后发布。普通脚本的正式文件位于ScriptDlls,名称由脚本类型与原始名称共同生成摘要;自定义节点位于CustomNodeDlls,使用稳定节点身份。 - 同一脚本或节点身份的编译、删除等管理变更协调执行,不同身份可以并行。业务读取已发布注册快照;服务启动时也会加载
PluginDlls、ScriptDlls、CustomNodeDlls。 - 功能配置按脚本名称绑定脚本,例如变量读表达式、内存变量表达式、数据转发实体脚本、历史表脚本、MQTT RPC 脚本、规则节点。
- 触发条件满足后执行脚本。脚本异常会影响对应功能,例如变量离线、转发批次失败、RPC 无响应或规则节点停止传播。
- 删除脚本时会删除数据库记录,并尝试删除对应 DLL;如果 DLL 正被占用,删除逻辑会尝试重命名为
.del。
文件发布、运行注册和数据库保存不能当作跨资源原子事务。脚本若提示“已编译发布但保存失败”,应保留编辑内容并处理存储错误,再核对已保存源码和当前运行结果。节点保存成功但编译失败时,已保存草稿和已发布版本也可能不同。
编辑器语言分析与 XML 文档
Studio 通过 TGateway.RoslynCompletion 类库提供补全、签名、悬停和诊断,分析请求与 Runtime 的正式编译分开。编辑器默认连接 https://roslyn.thingsgateway.cn,自建服务和当前源码 XML 注释的操作见代码编辑、补全与错误定位。
用户源码中的类型、方法、字段和属性可使用 summary、remarks、param、returns、value 提供说明。类库发布时保留与 DLL 同名的 XML 文档;Studio 的 Gateway 引用、工程引用和附加引用需要同时包含对应文件,才能显示程序集成员的说明。
按当前脚本类型的代码形态放置声明:方法体脚本只写语句和合法的局部声明,类成员脚本可声明辅助方法,FullSource 写完整源码。方法体和节点成员片段不提供文件级 using 自动修复,所需命名空间按支持的源码写法手动补充。正式编译仍以目标 Runtime 的程序集和参数为准。
类型总览
| 类型 | 页面类型值 | 基类 | 用户代码形态 | 主要绑定位置 |
|---|---|---|---|---|
| 数据转换 | DataTrans | ExpressionDatatrans | Execute 方法体 | 采集变量读表达式、写表达式。 |
| 内存变量计算 | MemoryVariableDatatrans | MemoryVariableExpressionDatatrans | Execute 方法体 | 内存变量读表达式。 |
| 动态模型-变量 | DynamicModel_VariableBasicData | DynamicModelBase<VariableBasicData> | GetList 方法体 | 数据转发目标的变量实体脚本。 |
| 动态模型-设备 | DynamicModel_DeviceBasicData | DynamicModelBase<DeviceBasicData> | GetList 方法体 | 数据转发目标的设备实体脚本。 |
| 动态模型-报警 | DynamicModel_AlarmVariable | DynamicModelBase<AlarmVariable> | GetList 方法体 | 数据转发目标的报警实体脚本。 |
| 动态模型-插件事件 | DynamicModel_PluginEventData | DynamicModelBase<PluginEventData> | GetList 方法体 | 数据转发目标的插件事件实体脚本。 |
| 动态 SQL-变量 | DynamicSQL_VariableBasicData | DynamicSQLBase<VariableBasicData> | 类成员源码 | 历史数据表脚本、实时数据表脚本。 |
| 时序历史 | TimeSeriesSQL_VariableBasicData | TimeSeriesSQLBase<VariableBasicData> | 类成员源码 | QuestDB、TDengine、IoTDB 历史脚本。 |
| 动态 SQL-设备 | DynamicSQL_DeviceBasicData | DynamicSQLBase<DeviceBasicData> | 类成员源码 | 框架支持;标准目标当前未发现直接消费入口,通常用于二开目标。 |
| 动态 SQL-报警 | DynamicSQL_AlarmVariable | DynamicSQLBase<AlarmVariable> | 类成员源码 | 历史报警表脚本。 |
| 动态 SQL-插件事件 | DynamicSQL_PluginEventData | DynamicSQLBase<PluginEventData> | 类成员源码 | 框架支持;标准目标当前未发现直接消费入口,通常用于二开目标。 |
| MQTT RPC | MQTTDynamicRPC | MQTTDynamicRPCBase | 类成员源码 | MQTT Client/Server 目标的 RPC 脚本。 |
| 完整源码 | FullSource | DynamicBase | 完整 C# 源码 | 高级扩展,供自定义代码按名称获取实例。 |
| 自定义节点 | CustomNode | CustomExpressionBase | InitAsync/ChangedAsync 方法体或完整节点类 | 规则引擎流程节点。 |
公共规则
脚本名称是运行时查找键。不要在生产环境频繁改名;变量、转发目标、MQTT RPC 或规则流程保存的是脚本名称。
页面脚本不是全部都写完整类。DataTrans、MemoryVariableDatatrans、四类动态模型写的是方法体;动态 SQL 和 MQTT RPC 写的是类成员;FullSource 写完整源码;自定义节点由节点页面根据输入输出参数生成属性,再把代码插入类中。
脚本默认可使用 System、System.Linq、System.Collections.Generic、TGateway.Application、TGateway、TUtility、TUtility.Extension、TUtility.Json.Extension、TUtility.Log。如果需要额外命名空间,把 using 写在脚本最前面,编译器会把它移到包装类外部。
不要依赖“没有 return 时自动补 return”的包装逻辑。源码中确实有自动补齐,但它只做字符串判断,遇到注释或复杂代码不可靠;正式脚本请显式 return。
带输入参数的脚本目前只对 DataTrans 和 MemoryVariableDatatrans 有运行时写入入口。参数会变成脚本类属性,变量配置中的参数值会在变量初始化时写入脚本实例。
动态加载依赖 RuntimeFeature.IsDynamicCodeSupported。AOT 或禁止动态代码的发布方式不能热加载脚本 DLL;这种环境应在发布前编译并验证扩展。
脚本实例通常会被复用。不要把单次执行的临时结果放到实例字段中,除非你明确需要跨执行保存状态并已考虑并发、重启和释放。
脚本里可以写日志,但高频变量脚本不要每次正常执行都写 Info 日志。建议只在异常、被过滤、格式不符合预期时写 Warning 或 Debug。
数据实体速查
| 实体 | 常用字段 | 说明 |
|---|---|---|
VariableBasicData | Id、DeviceName、Name、Value、RawValue、CollectTime、ChangeTime、IsOnline、DataType、Unit、RegisterAddress、CollectGroup、Remark1 到 Remark5 | 变量当前值或历史采样值。Value 是脚本转换后的工程值,RawValue 是原始值。 |
DeviceBasicData | Id、Name、ActiveTime、DeviceStatus、LastErrorMessage、PluginName、Description、Remark1 到 Remark5 | 设备状态变化或快照。 |
AlarmVariable | AlarmId、VariableId、DeviceName、Name、AlarmLevel、AlarmType、EventType、AlarmTime、EventTime、FinishTime、ConfirmTime、AlarmText、AlarmCode、AlarmLimit | 报警发生、恢复、确认等事件。 |
PluginEventData | DeviceName、ObjectValue | 插件自定义事件,事件正文是 JsonElement。 |
采集与内存变量脚本
数据转换脚本
用途
数据转换脚本把一个变量的原始值转换为工程值,也可在外部写入设备前把工程值转换回设备值。
运行时机
读表达式在 VariableRuntime.SetValue 中执行,顺序是:采集插件读到值、写入 RawValue、执行读表达式、写入 Value。读表达式失败时变量会被置为离线并记录转换失败信息。
写表达式在 CollectBase.InvokeWriteAsync 或 InvokeMethodAsync 中执行,顺序是:收到外部写入值、执行写表达式、把转换后的值交给采集插件写入设备。
方法形态
public override object Execute(object raw, Logger? logger)
{
// 用户脚本写在这里。
}
页面中只写方法体,可以直接使用 raw 和 logger。
Demo:模拟量比例换算
if (!TryConvertToDouble(raw, out var rawValue))
{
logger?.LogWarning($"AI value is not numeric: {raw}");
return raw;
}
var engineeringValue = rawValue * 0.1;
return Math.Round(engineeringValue, 2);
Demo:带参数的线性换算
在脚本输入参数中新增:
| 参数名 | 类型 | 初始值 | 说明 |
|---|---|---|---|
Scale | Double | 0.1 | 比例系数。 |
Offset | Double | 0 | 偏移量。 |
脚本内容:
if (!TryConvertToDouble(raw, out var rawValue))
{
return raw;
}
return Math.Round(rawValue * Scale + Offset, 3);
注意事项
返回值类型要和变量数据类型匹配。例如变量配置为 Double,不要返回无法转换的字符串。
读表达式不要直接访问外部网络或数据库。它在采集热路径执行,阻塞会拖慢变量刷新。
写表达式用于反写前转换,失败会阻止本次写入。反写脚本里要优先做输入校验,错误信息写清楚。
内存变量计算脚本
用途
内存变量脚本用于计算网关内部变量,不直接访问 PLC。它可以读取其它变量,生成派生量、状态量、汇总量或联锁判断结果。
运行时机
内存变量由 MemoryDriver 管理。变量没有读表达式时按写入值保存;有读表达式时,运行时会按内存变量触发方式执行计算。触发方式可以按周期执行,也可以尝试根据 Tag(device, variable) 记录的依赖变量变化触发。
方法形态
public override object Execute(object raw, Logger? logger)
{
// 用户脚本写在这里。
}
可用 Tag("设备名", "变量名") 获取其它变量运行时对象。Tag 同时会把依赖加入 Tags 集合,运行时据此建立变化触发关系。
Demo:计算两路温度平均值
var left = Tag("PLC_1", "Temp_Left").Value;
var right = Tag("PLC_1", "Temp_Right").Value;
if (!double.TryParse(Convert.ToString(left), out var leftValue) ||
!double.TryParse(Convert.ToString(right), out var rightValue))
{
logger?.LogWarning("Temperature source value is not numeric.");
return null;
}
return Math.Round((leftValue + rightValue) / 2.0, 1);
Demo:运行允许状态
var autoMode = Convert.ToBoolean(Tag("PLC_1", "AutoMode").Value);
var emergencyStop = Convert.ToBoolean(Tag("PLC_1", "EmergencyStop").Value);
var pressureOk = Convert.ToBoolean(Tag("PLC_1", "PressureOk").Value);
return autoMode && !emergencyStop && pressureOk;
注意事项
Tag 的设备名和变量名必须是运行时名称,不是描述、地址或外部映射名。
不要把 Tag 放在不一定执行的分支里,否则依赖变量集合可能不完整,变化触发关系也可能不完整。
依赖变量不存在时 Tag 会抛异常,脚本计算失败。上线前先用少量变量验证依赖名称。
如果脚本依赖时间、累计值或外部状态,即使有 Tag,也建议保留周期触发,避免只有变量变化才计算。
转发与存储脚本
动态模型脚本
用途
动态模型脚本在数据转发前重塑实体。它不负责连接外部系统,只负责把 VariableBasicData、DeviceBasicData、AlarmVariable 或 PluginEventData 变成目标系统需要的对象、匿名对象或字典。
转发基类会继续使用脚本输出做 Topic 分组、默认 JSON 序列化或内容模板替换。Topic 模板中的 ${属性名} 会从脚本输出对象上读取属性。
方法形态
public override IEnumerable<object> GetList(IEnumerable<T> datas, Logger? logger)
{
// 用户脚本写在这里。
}
页面中只写方法体,必须返回 IEnumerable<object> 或可赋给它的集合。
Demo:变量动态模型
类型选择 DynamicModel_VariableBasicData。
return datas.Select(data => new
{
device = data.DeviceName,
tag = data.Name,
value = data.Value,
raw = data.RawValue,
unit = data.Unit,
quality = data.IsOnline ? "Good" : "Bad",
ts = data.CollectTime
});
如果转发目标 Topic 写成 factory/${device}/${tag},运行时会按脚本输出的 device 和 tag 分组。
Demo:设备动态模型
类型选择 DynamicModel_DeviceBasicData。
return datas.Select(device => new
{
device = device.Name,
status = device.DeviceStatus.ToString(),
online = device.DeviceStatus.ToString() == "OnLine",
plugin = device.PluginName,
activeTime = device.ActiveTime,
message = device.LastErrorMessage
});
Demo:报警动态模型
类型选择 DynamicModel_AlarmVariable。
return datas.Select(alarm => new
{
id = alarm.AlarmId,
device = alarm.DeviceName,
tag = alarm.Name,
level = alarm.AlarmLevel,
alarmType = alarm.AlarmType?.ToString(),
eventType = alarm.EventType.ToString(),
text = alarm.AlarmText ?? alarm.AlarmCode,
eventTime = alarm.EventTime
});
Demo:插件事件动态模型
类型选择 DynamicModel_PluginEventData。
return datas.Select(item => new
{
device = item.DeviceName,
eventBody = item.ObjectValue,
receivedAt = DateTime.UtcNow
});
注意事项
返回对象的属性名会影响 Topic 模板和内容模板。改字段名前先检查转发目标中的 ${属性名}。
不要在动态模型里过滤掉大量数据,除非目标协议明确需要。范围过滤应该优先放在数据转发组中。
返回 Dictionary<string, object?> 也可以,但字段名要和模板保持一致。
动态模型脚本只改变上传内容,不改变运行时变量本身。
动态 SQL 脚本
用途
动态 SQL 脚本把数据库建表、删除和保存逻辑交给脚本。它用于项目要求固定表结构、宽表字段、特殊字段命名或特殊清理策略的场景。
方法形态
动态 SQL 不是方法体脚本,而是写入生成类中的成员源码。必须实现三个方法:
public override Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken);
public override Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken);
public override Task DBSavable(OrmClient db, IEnumerable<T> datas, Logger? logger, CancellationToken cancellationToken);
DBInit 在目标初始化表结构时执行;DBSavable 在批量写入时执行;DBDeletable 在保留天数清理时执行。
关系型动态 SQL 脚本只用于 SQL Server、MySQL、SQLite 和 PostgreSQL。QuestDB、TDengine、IoTDB 使用独立的“时序历史脚本”,不能绑定此处的关系型脚本。
时序库逻辑脚本
类型选择 TimeSeriesSQL_VariableBasicData,绑定到历史目标的“时序历史脚本”。WEB 编辑器填写生成类的成员,继承契约为 TimeSeriesSQLBase<VariableBasicData>,三个方法均接收目标持有的 TimeSeriesScriptContext db、日志对象与取消令牌。
| 方法 | 返回类型与职责 |
|---|---|
DBInit(db, logger, cancellationToken) | Task;创建脚本使用的表、超级表或路径。 |
DBSavable(db, datas, logger, cancellationToken) | Task;datas 为 IEnumerable<VariableBasicData>,保存当前批次。 |
DBDeletable(db, days, logger, cancellationToken) | Task<int>;按保留天数清理,返回删除数量。 |
上下文提供数据库类型、物理模型、目标名称、标准表名、保留天数、分片大小、时区和根路径等只读配置。使用 ExecuteAsync、ExecuteBatchAsync 执行 SQL,使用有最大行数限制的 QueryAsync 查询;通过 QuoteIdentifier 与 EncodeLiteral 处理标识符和值。传递取消令牌,并将失败向上传播,避免失败批次被误判为成功。不要在脚本中另外建立或释放目标持有的连接。
QuestDB 的脚本需要明确指定时间戳、分区与清理策略;TDengine 应按变量建立子表并正确设置 TAG,避免不同变量在同一时间戳下覆盖。IoTDB SQL 必须符合当前 Tree 或 Table 模型。绑定脚本后建表、写入和清理以脚本为准,不应假定标准表的 TTL/KEEP 或清理任务自动替脚本完成保留策略。
编译保存后核对已加载脚本,再保存目标绑定。使用独立数据库客户端验证数值、字符串及后续新样本;清空绑定后核对标准表恢复写入。操作步骤见历史数据存储。
Demo:历史变量宽表
类型选择 DynamicSQL_VariableBasicData,可绑定到历史数据转发目标的“历史表脚本”。
查看完整示例代码
[OrmTable("tg_his_variable_wide")]
private sealed class HisVariableWideRow
{
[OrmColumn(IsPrimaryKey = true)]
public string Id { get; set; } = string.Empty;
public string DeviceName { get; set; } = string.Empty;
public string VariableName { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string? Unit { get; set; }
public bool IsOnline { get; set; }
public DateTime CollectTime { get; set; }
}
public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<HisVariableWideRow>(cancellationToken).ConfigureAwait(false);
}
public override async Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
var before = DateTime.UtcNow.AddDays(-days);
return await db.Deletable<HisVariableWideRow>()
.Where(row => row.CollectTime < before)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
}
public override async Task DBSavable(OrmClient db, IEnumerable<VariableBasicData> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(data => new HisVariableWideRow
{
// 同一采集值的定时快照用记录时间区分,同一记录重试时保持主键不变。
Id = $"{data.Id}:{data.CreateTime:O}:{data.CollectTime:O}",
DeviceName = data.DeviceName,
VariableName = data.Name,
Value = data.Value?.ToString() ?? string.Empty,
Unit = data.Unit,
IsOnline = data.IsOnline,
CollectTime = data.CollectTime
}).ToList();
if (rows.Count == 0)
{
return;
}
await db.BulkCopy<HisVariableWideRow>()
.BulkMergeAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
本例按变量、记录时间和采集时间生成稳定主键,并按主键批量合并。定时快照可能共享同一个采集时间,不能只用变量 ID 和采集时间区分所有记录;失败补写也不要重新生成 GUID 或使用补写时的当前时间。Value 在本例保存为文本,空值转为空字符串,需要保留数值类型或 NULL 时应调整表模型与赋值。
Demo:实时变量表
类型选择 DynamicSQL_VariableBasicData,可绑定到实时数据转发目标的“实时表脚本”。
[OrmTable("tg_real_variable")]
private sealed class RealVariableRow
{
[OrmColumn(IsPrimaryKey = true)]
public long VariableId { get; set; }
public string DeviceName { get; set; } = string.Empty;
public string VariableName { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public bool IsOnline { get; set; }
public DateTime CollectTime { get; set; }
public DateTime UpdateTime { get; set; }
}
public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<RealVariableRow>(cancellationToken).ConfigureAwait(false);
}
public override Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
return Task.FromResult(0);
}
public override async Task DBSavable(OrmClient db, IEnumerable<VariableBasicData> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(data => new RealVariableRow
{
VariableId = data.Id,
DeviceName = data.DeviceName,
VariableName = data.Name,
Value = data.Value?.ToString() ?? string.Empty,
IsOnline = data.IsOnline,
CollectTime = data.CollectTime,
UpdateTime = DateTime.UtcNow
}).ToList();
if (rows.Count > 0)
{
await db.BulkCopy<RealVariableRow>()
.BulkMergeAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
}
Demo:设备状态表
类型选择 DynamicSQL_DeviceBasicData。当前标准目标主要使用变量和报警动态 SQL;设备动态 SQL 是框架支持能力,通常由二开数据转发目标调用。
[OrmTable("tg_device_state")]
private sealed class DeviceStateRow
{
[OrmColumn(IsPrimaryKey = true)]
public long DeviceId { get; set; }
public string DeviceName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string PluginName { get; set; } = string.Empty;
public string? LastErrorMessage { get; set; }
public DateTime ActiveTime { get; set; }
public DateTime UpdateTime { get; set; }
}
public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<DeviceStateRow>(cancellationToken).ConfigureAwait(false);
}
public override Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
return Task.FromResult(0);
}
public override async Task DBSavable(OrmClient db, IEnumerable<DeviceBasicData> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(data => new DeviceStateRow
{
DeviceId = data.Id,
DeviceName = data.Name,
Status = data.DeviceStatus.ToString(),
PluginName = data.PluginName,
LastErrorMessage = data.LastErrorMessage,
ActiveTime = data.ActiveTime,
UpdateTime = DateTime.UtcNow
}).ToList();
if (rows.Count > 0)
{
await db.BulkCopy<DeviceStateRow>()
.BulkMergeAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
}
Demo:历史报警表
类型选择 DynamicSQL_AlarmVariable,可绑定到历史报警转发目标的“历史报警表脚本”。
查看完整示例代码
[OrmTable("tg_alarm_history_wide")]
private sealed class AlarmHistoryRow
{
[OrmColumn(IsPrimaryKey = true)]
public string AlarmId { get; set; } = string.Empty;
public string DeviceName { get; set; } = string.Empty;
public string VariableName { get; set; } = string.Empty;
public int AlarmLevel { get; set; }
public string EventType { get; set; } = string.Empty;
public string? AlarmText { get; set; }
public DateTime EventTime { get; set; }
}
public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<AlarmHistoryRow>(cancellationToken).ConfigureAwait(false);
}
public override async Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
var before = DateTime.UtcNow.AddDays(-days);
return await db.Deletable<AlarmHistoryRow>()
.Where(row => row.EventTime < before)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
}
public override async Task DBSavable(OrmClient db, IEnumerable<AlarmVariable> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(alarm => new AlarmHistoryRow
{
AlarmId = $"{alarm.AlarmId}:{alarm.EventType}:{alarm.EventTime:O}",
DeviceName = alarm.DeviceName,
VariableName = alarm.Name,
AlarmLevel = alarm.AlarmLevel,
EventType = alarm.EventType.ToString(),
AlarmText = alarm.AlarmText,
EventTime = alarm.EventTime
}).ToList();
if (rows.Count > 0)
{
await db.BulkCopy<AlarmHistoryRow>()
.BulkInsertAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
}
Demo:插件事件表
类型选择 DynamicSQL_PluginEventData。当前标准目标未发现直接消费入口,通常由二开目标调用。
[OrmTable("tg_plugin_event")]
private sealed class PluginEventRow
{
[OrmColumn(IsPrimaryKey = true)]
public string Id { get; set; } = string.Empty;
public string DeviceName { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTime CreateTime { get; set; }
}
public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<PluginEventRow>(cancellationToken).ConfigureAwait(false);
}
public override async Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
var before = DateTime.UtcNow.AddDays(-days);
return await db.Deletable<PluginEventRow>()
.Where(row => row.CreateTime < before)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
}
public override async Task DBSavable(OrmClient db, IEnumerable<PluginEventData> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(item => new PluginEventRow
{
Id = Guid.NewGuid().ToString("N"),
DeviceName = item.DeviceName,
Payload = item.ObjectValue.GetRawText(),
CreateTime = DateTime.UtcNow
}).ToList();
if (rows.Count > 0)
{
await db.BulkCopy<PluginEventRow>()
.BulkInsertAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
}
注意事项
动态 SQL 方法签名必须和 DynamicSQLBase<T> 完全一致。旧模板或手写模板如果缺少 OrmClient db、Logger? logger、int days,会编译失败。
不要在 DBSavable 中逐条写数据库。优先批量写入或合并,否则高频历史数据会拖垮数据库。
DBDeletable 必须尊重保留天数。分表、宽表、实时表都要明确是否需要清理。
如果目标使用离线缓存,DBSavable 抛异常会让本批次失败并保留缓存;不要把数据库失败吞掉当成功。
RPC 与扩展脚本
MQTT RPC 脚本
用途
MQTT RPC 脚本用于自定义 MQTT 写入、查询或控制消息的解析和响应。标准 MQTT Client/Server 目标在没有脚本时默认解析:
{
"PLC_1": {
"StartCommand": true,
"SpeedSet": 1200
}
}
脚本可以把项目自定义 payload 转成这个结构,再调用 getRpcResult 执行网关内部写入。
方法形态
MQTT RPC 写的是完整方法,必须实现:
public override Task RPCInvokeAsync(
MqttArrivedMessage message,
Func<TopicArray, CancellationToken, Task> publish,
Func<Dictionary<string, Dictionary<string, JsonElement>>, ValueTask<Dictionary<string, Dictionary<string, IOperResult>>>> getRpcResult,
Logger? logger = null,
CancellationToken cancellationToken = default);
message 是收到的 MQTT 消息;publish 用于回复消息;getRpcResult 用于把“设备名 -> 变量名 -> 值”的写入请求交给网关执行。
Demo:兼容自定义写入格式
输入 payload:
{
"device": "PLC_1",
"values": {
"StartCommand": true,
"SpeedSet": 1200
}
}
脚本内容:
using System.Text;
private sealed class RpcRequest
{
public string Device { get; set; } = string.Empty;
public Dictionary<string, JsonElement> Values { get; set; } = new();
}
public override async Task RPCInvokeAsync(
MqttArrivedMessage message,
Func<TopicArray, CancellationToken, Task> publish,
Func<Dictionary<string, Dictionary<string, JsonElement>>, ValueTask<Dictionary<string, Dictionary<string, IOperResult>>>> getRpcResult,
Logger? logger = null,
CancellationToken cancellationToken = default)
{
var json = Encoding.UTF8.GetString(message.Payload);
var request = json.FromSystemTextJsonString<RpcRequest>();
if (request == null || string.IsNullOrWhiteSpace(request.Device))
{
logger?.LogWarning("Invalid MQTT RPC payload.");
return;
}
var rpcData = new Dictionary<string, Dictionary<string, JsonElement>>
{
[request.Device] = request.Values
};
var result = await getRpcResult(rpcData).ConfigureAwait(false);
using var response = new TopicArray
{
Topic = $"{message.TopicName}/Response",
Payload = new
{
success = result.Values.SelectMany(device => device.Values).All(item => item.IsSuccess),
detail = result
}.ToSystemTextJsonUtf8Bytes()
};
await publish(response, cancellationToken).ConfigureAwait(false);
}
注意事项
必须回复到项目约定的 Topic。默认逻辑使用 ${原Topic}/Response,自定义脚本可以改,但外部系统也要同步修改。
不要直接操作变量运行时绕过 getRpcResult,否则会绕开变量写权限、写表达式、采集插件写入流程和 RPC 日志。
脚本应捕获并记录无法解析的 payload。解析失败直接抛异常时,外部系统通常只能看到超时。
ThingsBoard 客户端插件有自己的 RPC 解析流程,当前源码未发现它使用 BigTextScriptRpc。
完整源码脚本
用途
完整源码脚本适合高级扩展:把完整 C# 类型编译进脚本 DLL,运行时通过 GetFullSource() 获取实例。它不等同于数据转换或动态模型,普通变量和转发目标不会自动调用它。
源码形态
FullSource 不做方法包装,页面内容必须是完整 C# 源码。源生成器通过继承 DynamicBase 识别完整源码脚本。
Demo:维护窗口判断工具
using System;
using System.Diagnostics.CodeAnalysis;
using TGateway.Application;
#nullable enable
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public sealed class MaintenanceWindowHelper : DynamicBase
{
public bool IsInWindow(DateTime utcNow, int startHourUtc, int endHourUtc)
{
var hour = utcNow.Hour;
if (startHourUtc <= endHourUtc)
{
return hour >= startHourUtc && hour < endHourUtc;
}
return hour >= startHourUtc || hour < endHourUtc;
}
}
调用侧示意:
var helper = "MaintenanceWindowHelper".GetFullSource() as MaintenanceWindowHelper;
var allow = helper?.IsInWindow(DateTime.UtcNow, 16, 18) == true;
注意事项
完整源码脚本必须自己保证类名、命名空间、依赖和访问级别。建议类名和脚本名称保持一致。
DynamicBase 当前不定义 Name 抽象属性;不要照搬带 public override string Name 的旧模板,否则会编译失败。
完整源码脚本更适合作为二开扩展,不建议现场交付人员直接维护。
自定义节点脚本
用途
自定义节点用于规则引擎。节点有输入、输出、输入输出参数,运行时把节点连接成有向图:上游输出变化后写入下游输入,再触发下游 ChangedAsync。
生命周期
- 规则流程启动时读取布局数据。
- 每个节点按
NodeTypeName从ExpressionsData创建实例。 - 运行时把页面配置的输入参数和输入输出参数写入实例。
- 设置
OutputsCommitted,接收节点提交的输出批次;同批状态输入先一起应用,再触发下游执行。 - 调用每个节点的
InitAsync。 - 没有上游的起始节点会执行一次
ChangedAsync。 - 上游输出变化时,目标节点通过
SmartChangedTriggerScheduler触发ChangedAsync。默认 10 ms 防抖,流程配置NoDebounce后取消防抖。 - 流程停止时释放节点实例;实现了
IDisposable的节点会被TryDispose调用。
页面生成属性的代码形态
如果在节点页面配置输入 Input、输入 Scale、输出 Output,脚本内容只需要写方法:
public override Task InitAsync()
{
Output = 0;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Output = Input * Scale;
return Task.CompletedTask;
}
Demo:泵运行小时累计节点
节点参数建议:
| 参数名 | 方向 | 类型 | 初始值 | 说明 |
|---|---|---|---|---|
Running | 输入 | Boolean | false | 泵运行状态。 |
Reset | 输入 | Boolean | false | 复位累计值。 |
Hours | 输出 | Double | 累计运行小时。 |
脚本内容:
private DateTime _lastTime = DateTime.UtcNow;
private double _hours;
public override Task InitAsync()
{
_lastTime = DateTime.UtcNow;
_hours = 0;
Hours = 0;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var now = DateTime.UtcNow;
if (Reset)
{
_hours = 0;
}
else if (Running)
{
_hours += (now - _lastTime).TotalHours;
}
_lastTime = now;
Hours = Math.Round(_hours, 3);
return Task.CompletedTask;
}
Demo:完整自定义节点类
嵌入式节点和外部 DLL 可直接继承 CustomExpressionBase:
using System.ComponentModel;
using TGateway.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;
}
}
注意事项
输出属性必须通过 SetOutput 写入。调度执行成功后自动提交输出批次,也可用 CommitOutputs 显式提交中间批次;失败时舍弃未提交部分。不要直接调用宿主的 OutputsCommitted,只改字段也不会触发下游节点。
订阅全局事件、创建定时器、打开网络连接的节点必须实现 IDisposable,在 Dispose 中取消订阅并释放资源。
ChangedAsync 可能被频繁触发。长耗时动作要考虑防抖、超时、取消和重复触发。
规则引擎会检测循环传播,同一传播链重复访问节点会停止传播。不要用节点环路实现高速循环控制。
排障与上线
排障
| 现象 | 检查项 |
|---|---|
| 编译失败 | 先看第一条红色错误;确认脚本类型和代码形态匹配,动态 SQL 和 MQTT RPC 是否写了 override 方法。 |
| 编译成功但列表找不到脚本 | 确认 DLL 已热加载;刷新“已加载脚本”;检查脚本名称、类型和分类。 |
| 变量表达式不生效 | 确认变量绑定的是脚本名称;读表达式类型必须是 DataTrans,内存变量读表达式必须是 MemoryVariableDatatrans。 |
| 写入没有到设备 | 检查写表达式是否抛异常;检查采集插件是否支持写入或 RPC;检查变量保护类型和权限。 |
| 内存变量不变化 | 检查触发方式、依赖变量名称、Tag 是否执行、依赖变量是否在线、脚本是否返回了可转换类型。 |
| 动态模型 Topic 报属性不存在 | Topic 模板 ${字段} 与脚本输出对象属性名不一致。 |
| 动态 SQL 未执行 | 确认目标属性中绑定了对应表脚本;历史变量/实时变量使用变量动态 SQL,历史报警使用报警动态 SQL。 |
| MQTT RPC 无响应 | 检查 RPC Topic 是否匹配;payload 是否能解析;脚本是否调用 publish;外部系统是否监听正确响应 Topic。 |
| 自定义节点不触发 | 检查节点输出是否通过 SetOutput 设置;连线端口是否对应参数;流程是否启用;防抖是否影响观察。 |
| AOT 环境脚本不可用 | 动态代码不支持时不会热加载脚本 DLL,需要改为非 AOT 部署或在发布流程中预编译并验证。 |
上线检查清单
| 检查项 | 要求 |
|---|---|
| 类型匹配 | 脚本类型、绑定位置、输入实体类型一致。 |
| 编译 | 单个编译成功,批量编译成功,重启后已加载脚本仍存在。 |
| 参数 | 输入参数名称、类型、初始值和变量配置中的参数值一致。 |
| 性能 | 高频脚本不访问慢速外部资源,不逐条写数据库,不正常路径刷 Info 日志。 |
| 异常 | 解析失败、变量不存在、数据库失败、RPC 失败都有明确日志。 |
| 数据 | 先用 3 到 5 个点验证输入、输出、时间、单位和在线状态,再扩大范围。 |
| 回退 | 保留旧脚本内容或导出配置,生产修改前确认可回退。 |