Chapter 11 SimpleFiles 驱动专题
SimpleFiles 驱动的做法很朴素:把每个测点映射成磁盘上的一个文本文件。测点的地址就是文件路径,文件内容就是测点的值。
它最初是为了"测试"和"喂狗"——让整套逻辑在没有硬件的机器上也能跑测试。但用久了会发现它在两个场景里特别顺手:
- 测点即文件:写业务逻辑时不必等 PLC 到位,用文件先跑通;也可以反过来,把上位机算出来的结果落成文件,让别的程序(甚至人)去读。
- 测点即配置:有些"参数"本来就该能被人编辑、能被版本管理,放在文件里比放在数据库里方便快捷。
用法三步,和别的驱动没有区别:
- 配通道:XML 里写一个
driver="SimpleFiles"的<Channel>,可以指定基础目录(见 11.1); - 配测点:
<Tag address="sub/state.txt" type="INT32">,地址就是文件路径(见 11.2); - 注册
builder.AddSimpleFilesSupport()(见 11.3)。
本系列文档只讲"怎么用、为什么这样设计、容易踩什么"。字段默认值、API 清单请查 AI 参考手册 的 §5.7;两者冲突时以手册为准。
11.1 通道配置
<Project xmlns:sf="tags:simplefiles"
xsi:noNamespaceSchemaLocation="Schemas/tagsproject.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Channel name="file" driver="SimpleFiles">
<sf:BaseDir>D:/temp/tags</sf:BaseDir>
</Channel>
</Project>
BaseDir 是唯一可配的子元素,可以省略:
| 配置 | 测点地址如何变成路径 |
|---|---|
写了 BaseDir | 用 Path.Combine(BaseDir, address) 拼出来 |
省略 BaseDir | address 原样使用(此时它就是一个完整路径) |
这里有一个必须知道的事实:BaseDir 之外不做任何路径规整,既不判断地址是不是绝对路径,也不转换分隔符。所以:
- 相对路径请一律写正斜杠(
sub/state.txt)。Windows 上反斜杠也能用,但那是 Windows 的宽容,写到 Linux 上会变成一个名字里带反斜杠的文件; BaseDir与地址里的子目录必须已经存在,库不会替你创建目录(见 11.6 的排错表)。
这个通道还是无状态的:连接、断开、释放三个动作都是空实现(它不持有任何句柄)。所以多个通道指向同一个目录的代价几乎为零——不过"一个通道只属于一个入口"这条约束依旧成立,理由与别的驱动相同(两套扫描周期交错读写同一批文件会互相干扰)。
11.2 测点怎么写
每个 <Tag> 对应一个文件,address 是路径,type 决定解析方式:
<TagGrp name="仿真" isEntry="true" channel="file" scanInterval="50">
<Tag name="启动" address="in/start.txt" type="BIT"></Tag>
<Tag name="产量" address="in/count.txt" type="INT32"></Tag>
<Tag name="工艺配方" address="config/recipe.json" type="STR"></Tag>
<Tag name="输出" address="out/result.txt" type="DOUBLE" access="R1W" autoCreateFile="true"></Tag>
</TagGrp>
type 与 C# 类型的对应关系,以及内容怎么读怎么写:
type | 值类型 | 读取(文件内容变成值) | 写入(值变成文件内容) |
|---|---|---|---|
BIT | bool | bool.TryParse | True / False |
BYTE | byte | byte.TryParse | 数字文本 |
INT16 / UINT16 | short / ushort | TryParse | 数字文本 |
INT32 / UINT32 | int / uint | TryParse | 数字文本 |
FLOAT / DOUBLE | float / double | TryParse | 数字文本 |
STR | string | 原样返回文件内容 | 原样写入 |
自定义类型(JSON、或自己实现的类型)见 11.5。
几个容易踩的点:
- 内容不做裁剪。
STR读到的是文件的全部文本,末尾的换行也会一起读进来;写入时也不会自动补换行。用别的程序编辑这些文件时要注意这一点。 - 数值按当前区域性解析与格式化(
ToString()与TryParse都没指定InvariantCulture)。同一台机器上读写是对称的,但如果这些文件会被别处的程序或别的语言环境读,小数点符号可能对不上——对这类文件建议用STR自己解析。 - 不写
type的文件不能用:SimpleFiles 是直连测点驱动,测点种类必须明确。 <TagCbnt>不支持。SimpleFiles 只注册了直连测点构建器,用组合会报"未注册相应的 TagCbntBuilder"。文件本来就是一次读写一个,组合在这里也没有收益。
autoCreateFile
默认 false。文件不存在时:
| 场景 | autoCreateFile="false"(默认) | autoCreateFile="true" |
|---|---|---|
| 读 | 直接返回,值、时间戳、通知都不动(静默跳过) | 先按默认值创建文件,本轮仍不读值,下一轮才读到 |
| 写 | 直接返回,不写文件(静默 no-op) | 先创建默认内容,随后写入当前值 |
创建出来的默认内容由 FormatValue(default) 决定,也就是数字是 0、字符串是空内容。注意"读"那一列:开启自动创建后,首次读到值要等到下一轮轮询,因为这一轮只是把文件建出来。
1.0 起旧属性名
AutoCreateFile已移除。它当初是驼峰命名统一时的历史包袱,现在仍写旧名会在加载期抛TagsProjectConfigurationException,消息里直接给出改用autoCreateFile的提示。
11.3 注册与运行
builder.AddSimpleFilesSupport(); // = Channel + DirectTagBuilder + XSD provider
builder.EnableXmlSchemaValidation(); // 可选:让 <sf:BaseDir> 有智能提示与加载期校验
只想要一部分时可以分别调用 AddSimpleFilesChannel() 与 AddSimpleFilesDirectTagBuilder()。
运行方式与其它驱动完全一致:
await ctrl.StartPollAsync(dir, root: null, hook: (proj, sp, ct) =>
{
// 需要时在这里补逻辑组件
return Task.CompletedTask;
});
11.4 它和其它驱动不一样的地方
| 其它驱动 | SimpleFiles | |
|---|---|---|
| 地址 | 协议地址或 NodeId | 文件路径 |
| 连接的代价 | 建连、断连要处理超时与重试 | 无连接概念,通道是无状态的 |
| 批量 | 组合能合并多次 IO | 一个测点一个文件,没有组合 |
| 出错形态 | 多半是通信故障 | 多半是文件被占、权限不足、内容格式不对 |
R1W
R1W 的含义是"只读一次,读取成功之后变成只写模式"。软件系统里有很多参数"只在启动时需要读取一次",此后反复访问文件纯属浪费。R1W 虽然是为设备测点准备的,但用在文件访问场景里格外合适:
- 与
autoCreateFile搭配:文件不存在时也能自动落盘,第一次读取仍然成立。 - 两侧的刷写时机是确定的:如果测点是在轮询线程内被改的(例如逻辑组件里赋值),本轮结束前就会被刷写到文件;如果是通过"外部意图"改的,意图会在下一轮开头执行,紧接着的那次刷写就把它落地,不需要你额外做什么。
- 外部改了文件,可以重新读一次:把该测点的
IsScanned手动改回false,下一轮就会重新从文件读,读完又会被自动标记为true。手动改或者订阅事件后自动改都行;如果你用了StdUnit.Tags.BlazorLib.Core,/stdunit.tags/monitor页面上有现成的按钮可以点。
IsDirty
也不必担心它会反复刷写文件系统。每个测点都有 IsDirty 属性,只有被改过的测点才会被刷写到磁盘,写完就自动把脏标记清掉。
反过来,"读"不会把测点标脏(读路径直接写缓存字段,不走属性 setter),所以不会出现"读一次就写回一次"的无谓写入,R1W 的"只读一次"也不会因为随后有写入而退化。
11.5 自定义测点类型
内置的 type 覆盖了常见标量;当文件内容是 JSON、或者你想自己决定文本与值之间如何转换时,需要自己注册一个测点构建器。两种做法的注册方式相同:用 predicate 挑出属于你的那种 type,用 configure 决定怎么建测点。type 是在 XML 里自己起的名字,只要注册时的条件与它一致即可。
其一:直接用 JSON
测点值的类型由开发者自己指定,序列化交给 System.Text.Json。
<Tag name="配方" address="config/recipe.json" type="MyJson"></Tag>
注册代码:
builder.AddSimpleFilesChannel()
.AddSimpleFilesDirectTagBuilder(
configure: b => b.WithJsonTagFactory<MyJson>(),
predicate: b => b.TagDescriptor.TagKind == "MyJson");
内置的 JsonDirectTag<T> 会把文件内容反序列化成 T,写入时再序列化回去。文件内容为 JSON 字面量 null 时,值就是 null(这是合法值,不是错误)。
AOT / 裁剪宿主还要多一步:这些宿主下
System.Text.Json默认关闭反射序列化,必须登记一份源生成元数据(b.AddJsonSerializerContext(MyJsonContext.Default))。本地 JIT 运行不需要。见 AOT 支持。
其二:自己写一个测点类型
继承 SimpleFilesDirectTagBase<T> 并重写两个方法:ParseValue 负责"文本变值",FormatValue 负责"值变文本",两者要成对实现。
public class VersionTag : SimpleFilesDirectTagBase<Version>
{
public VersionTag(TagDescriptor descriptor, SimpleFilesTagChannel? thisChannel, TagContainer container)
: base(descriptor, thisChannel, container) { }
protected override Version ParseValue(string text) => Version.Parse(text);
protected override string FormatValue(Version? value) => value?.ToString() ?? string.Empty;
}
配置时由开发者为测点值指定一个自定义的类型:
<Tag name="版本" address="config/version.txt" type="MyVersion"></Tag>
注册代码:
builder.AddSimpleFilesDirectTagBuilder(
configure: b => b.WithFactory<Version>(
(descriptor, thisChannel, container) => new VersionTag(descriptor, thisChannel, container)),
predicate: b => b.TagDescriptor.TagKind == "MyVersion");
三点要注意:
- 自动创建文件时是以
default(T)调用FormatValue的,所以它必须能处理"空值";若T是引用类型,null是合法输入。 - 用
WithFactory注册时不必自己归一化地址,框架已经按BaseDir处理好了NormalizedAddress。 - 注册时框架会检查通道类型,不是
SimpleFilesTagChannel会直接报配置错误,不会到运行期才炸。
11.6 排错清单
| 现象 | 先看这里 |
|---|---|
启动就报 DirectoryNotFoundException | BaseDir 或地址里的子目录不存在。库不会创建目录,请先建好 |
| 读到的字符串末尾多了个换行 | STR 原样返回文件内容。文件是别的程序写的(多半带换行),要么让它别写换行,要么在 ParseValue 里自己裁剪 |
| 文件明明存在,值却没变 | 该测点是 R1W:只有第一轮读,之后不再读了 |
| 文件不存在,什么都没发生 | autoCreateFile 没开(默认关)。读是静默跳过,写是静默 no-op |
| 数字解析失败 | 内容是给人看的还是给程序读的?float/double 的文本格式与语言环境有关;跨程序共享的文件建议用 STR 自己解析 |
| 报"未注册相应的 TagCbntBuilder" | SimpleFiles 只支持直连 <Tag>,不要用 <TagCbnt> |
| 想让文件创建后立刻有值 | 开启 autoCreateFile 后,创建发生在这一轮,读到值要等下一轮 |