Chapter 11 SimpleFiles 驱动专题

SimpleFiles 驱动的做法很朴素:把每个测点映射成磁盘上的一个文本文件。测点的地址就是文件路径,文件内容就是测点的值。

它最初是为了"测试"和"喂狗"——让整套逻辑在没有硬件的机器上也能跑测试。但用久了会发现它在两个场景里特别顺手:

  • 测点即文件:写业务逻辑时不必等 PLC 到位,用文件先跑通;也可以反过来,把上位机算出来的结果落成文件,让别的程序(甚至人)去读。
  • 测点即配置:有些"参数"本来就该能被人编辑、能被版本管理,放在文件里比放在数据库里方便快捷。

用法三步,和别的驱动没有区别:

  1. 配通道:XML 里写一个 driver="SimpleFiles" 的 <Channel>,可以指定基础目录(见 11.1);
  2. 配测点:<Tag address="sub/state.txt" type="INT32">,地址就是文件路径(见 11.2);
  3. 注册 builder.AddSimpleFilesSupport()(见 11.3)。

配套包:StdUnit.Tags.SimpleFiles。

本系列文档只讲"怎么用、为什么这样设计、容易踩什么"。字段默认值、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) 拼出来
省略 BaseDiraddress 原样使用(此时它就是一个完整路径)

这里有一个必须知道的事实: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值类型读取(文件内容变成值)写入(值变成文件内容)
BITboolbool.TryParseTrue / False
BYTEbytebyte.TryParse数字文本
INT16 / UINT16short / ushortTryParse数字文本
INT32 / UINT32int / uintTryParse数字文本
FLOAT / DOUBLEfloat / doubleTryParse数字文本
STRstring原样返回文件内容原样写入

自定义类型(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 排错清单

现象先看这里
启动就报 DirectoryNotFoundExceptionBaseDir 或地址里的子目录不存在。库不会创建目录,请先建好
读到的字符串末尾多了个换行STR 原样返回文件内容。文件是别的程序写的(多半带换行),要么让它别写换行,要么在 ParseValue 里自己裁剪
文件明明存在,值却没变该测点是 R1W:只有第一轮读,之后不再读了
文件不存在,什么都没发生autoCreateFile 没开(默认关)。读是静默跳过,写是静默 no-op
数字解析失败内容是给人看的还是给程序读的?float/double 的文本格式与语言环境有关;跨程序共享的文件建议用 STR 自己解析
报"未注册相应的 TagCbntBuilder"SimpleFiles 只支持直连 <Tag>,不要用 <TagCbnt>
想让文件创建后立刻有值开启 autoCreateFile 后,创建发生在这一轮,读到值要等下一轮