From 5f7ef929f2b32c5ee05ae0e02fa15b3d933b35b0 Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Wed, 28 Oct 2026 21:48:35 +0800 Subject: [PATCH 1/9] Stream Hub MVP --- .github/workflows/dotnet.yml | 10 +- AGENTS.md | 6 +- README.md | 5 +- StdUnit.Tags.sln | 18 + ...06\346\236\266\345\267\256\345\274\202.md" | 4 +- .../Commands/CommandDto.cs | 24 + .../Commands/CommandsEndpoint.cs | 75 +++ .../Commands/TagsStreamCommands.cs | 298 ++++++++++ .../Core/EndpointConstants.cs | 35 ++ .../Core/ITagsStreamHub.cs | 25 + .../Core/Index/EntryStream.cs | 35 ++ .../Core/Index/EntryValuesCollector.cs | 166 ++++++ .../Core/Index/ProjectIndex.cs | 239 ++++++++ .../Core/Index/TagNodes.cs | 42 ++ .../Core/TagsStreamHub.cs | 559 ++++++++++++++++++ .../Core/TagsStreamJsonContext.cs | 22 + .../Core/TagsStreamOptions.cs | 39 ++ src/StdUnit.Tags.WebStream/Sse/SseFrames.cs | 54 ++ .../Sse/SseFramesPiper.cs | 93 +++ src/StdUnit.Tags.WebStream/Sse/StreamDto.cs | 65 ++ .../Sse/StreamEndpoint.cs | 61 ++ .../StdUnit.Tags.WebStream.csproj | 25 + src/StdUnit.Tags.WebStream/Tree/TreeDto.cs | 124 ++++ .../Tree/TreeEndpoint.cs | 24 + src/StdUnit.Tags.WebStream/Ui/TagsStreamUi.cs | 93 +++ src/StdUnit.Tags.WebStream/Ui/index.html | 422 +++++++++++++ src/StdUnit.Tags.WebStream/_Extensions.cs | 97 +++ src/StdUnit.Tags.WebStream/paket.references | 1 + src/publish-packages.ps1 | 3 +- .../AotSmokeTest.WebStream.csproj | 19 + tests/AotSmokeTest.WebStream/Program.cs | 276 +++++++++ .../StdUnit.Tags.Tests.NetCoreOnly.csproj | 15 +- .../WebStream/EntryValuesCollectorTests.cs | 117 ++++ .../WebStream/ProjectIndexTests.cs | 166 ++++++ .../WebStream/SseFramesTests.cs | 85 +++ .../StreamEndpointParseEntriesTests.cs | 42 ++ .../WebStream/StreamTestContext.cs | 143 +++++ .../WebStream/TagsStreamCommandsTests.cs | 242 ++++++++ .../WebStream/TagsStreamHubTests.cs | 257 ++++++++ .../WebStream/TagsStreamMappingTests.cs | 178 ++++++ .../WebStream/TagsStreamRegistrationTests.cs | 45 ++ 41 files changed, 4237 insertions(+), 12 deletions(-) create mode 100644 src/StdUnit.Tags.WebStream/Commands/CommandDto.cs create mode 100644 src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs create mode 100644 src/StdUnit.Tags.WebStream/Commands/TagsStreamCommands.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/ITagsStreamHub.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/Index/EntryStream.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/Index/EntryValuesCollector.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/Index/ProjectIndex.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/Index/TagNodes.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/TagsStreamJsonContext.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/TagsStreamOptions.cs create mode 100644 src/StdUnit.Tags.WebStream/Sse/SseFrames.cs create mode 100644 src/StdUnit.Tags.WebStream/Sse/SseFramesPiper.cs create mode 100644 src/StdUnit.Tags.WebStream/Sse/StreamDto.cs create mode 100644 src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs create mode 100644 src/StdUnit.Tags.WebStream/StdUnit.Tags.WebStream.csproj create mode 100644 src/StdUnit.Tags.WebStream/Tree/TreeDto.cs create mode 100644 src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs create mode 100644 src/StdUnit.Tags.WebStream/Ui/TagsStreamUi.cs create mode 100644 src/StdUnit.Tags.WebStream/Ui/index.html create mode 100644 src/StdUnit.Tags.WebStream/_Extensions.cs create mode 100644 src/StdUnit.Tags.WebStream/paket.references create mode 100644 tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj create mode 100644 tests/AotSmokeTest.WebStream/Program.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/EntryValuesCollectorTests.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/ProjectIndexTests.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/SseFramesTests.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamEndpointParseEntriesTests.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamTestContext.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamCommandsTests.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 7bf29e2..300db0c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -125,9 +125,10 @@ jobs: aot-smoke: # 目的:以 NativeAOT 发布成原生可执行文件,并运行。 - # 两个测试: + # 三个测试: # * `AotSmokeTest.LogicetPlugins`:无 的正常加载 + TryAddLogicet + AOT 下配了 必须快速失败; - # * `AotSmokeTest.SimpleFiles`:基本类型测点 + JSON 测点(源生成元数据)读写 + AOT 下缺元数据必须快速失败。 + # * `AotSmokeTest.SimpleFiles`:基本类型测点 + JSON 测点(源生成元数据)读写 + AOT 下缺元数据必须快速失败; + # * `AotSmokeTest.WebStream`:起 Kestrel,结构树的**多态 JSON 序列化**([JsonDerivedType])+ SSE 从基线到增量的全链路。 # 为什么在 Linux 跑:NativeAOT 在 ubuntu runner 上只需 clang + zlib1g-dev,比占 Windows runner 便宜,更重要的是不用排队; runs-on: ubuntu-latest @@ -161,3 +162,8 @@ jobs: run: | dotnet publish tests/AotSmokeTest.SimpleFiles/AotSmokeTest.SimpleFiles.csproj -c Release -r linux-x64 -o ./aot-smoke-out --nologo ./aot-smoke-out/AotSmokeTest.SimpleFiles + + - name: Smoke test (WebStream, NativeAOT) + run: | + dotnet publish tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj -c Release -r linux-x64 -o ./aot-smoke-out --nologo + ./aot-smoke-out/AotSmokeTest.WebStream diff --git a/AGENTS.md b/AGENTS.md index 3e3cdb8..f8f51f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,12 +25,12 @@ dotnet test --no-build --collect:"XPlat Code Coverage" --results-directory ./Tes CI(`.github/workflows/dotnet.yml`)有**三个 job**: - **`build`(ubuntu-latest,权威)**:整解 `dotnet build StdUnit.Tags.sln -c Release`(含 net472 的**编译**校验——引用程序集由 SDK 隐式引入的 `Microsoft.NETFramework.ReferenceAssemblies` 提供)+ `dotnet test -f net8.0`(带覆盖率上报 codecov)。 - **`net472-on-mono`(ubuntu-latest)**:`apt-get install -y mono-complete` 自己装 Mono,跑 `dotnet test -f net472`,让 net472 的**运行**测试也自动化、又不占 Windows runner 的排队时间。**mono ≠ .NET Framework**,它绿只说明"大概率没退化",(权威结果仍归发版工作流 `release-nuget.yml` / `release-baget.yml`,windows-latest 全 TFM)+ 本机 Windows。当前是**硬性把关**(未设 `continue-on-error`);万一出现 mono 专有的伪失败,可在 job 上加 `continue-on-error: true` 降级为补充信号。mono job 红时先看是不是"mono 与真框架的差异",别急着改产品代码。 -- **`aot-smoke`(ubuntu-latest)**:两个示例各自 `dotnet publish ... -r linux-x64`(需 `clang` + `zlib1g-dev`)后**运行原生产物**:`AotSmokeTest.LogicetPlugins` 验证"无 `` 的正常加载 + `TryAddLogicet` + AOT 下配了 `` 必须快速失败",`AotSmokeTest.SimpleFiles` 验证"基本类型测点 + JSON 测点(源生成元数据)读写 + AOT 下缺元数据必须快速失败"。改了 AOT 相关代码(`IsAotCompatible`、`[DynamicallyAccessedMembers]`、`Compat/*`、插件加载路径)时以它为准;publish 输出里的 `IL2104`/`IL3053` 来自第三方 `McMaster.NETCore.Plugins`,属已知噪声。 +- **`aot-smoke`(ubuntu-latest)**:三个示例各自 `dotnet publish ... -r linux-x64`(需 `clang` + `zlib1g-dev`)后**运行原生产物**:`AotSmokeTest.LogicetPlugins` 验证"无 `` 的正常加载 + `TryAddLogicet` + AOT 下配了 `` 必须快速失败",`AotSmokeTest.SimpleFiles` 验证"基本类型测点 + JSON 测点(源生成元数据)读写 + AOT 下缺元数据必须快速失败",`AotSmokeTest.WebStream` 验证"起 Kestrel + 结构树的多态 JSON 序列化(`[JsonDerivedType]` 源生成)+ SSE 从全量基线到增量"。改了 AOT 相关代码(`IsAotCompatible`、`[DynamicallyAccessedMembers]`、`Compat/*`、插件加载路径、WebStream 的 DTO/序列化)时以它为准;publish 输出里的 `IL2104`/`IL3053` 来自第三方 `McMaster.NETCore.Plugins`,属已知噪声。 - 两条 Linux job 统一遵循 **"`ubuntu-latest` + 依赖自装"**,不钉镜像版本:本仓库自己装依赖(mono / clang+zlib),就没有理由把 job 的寿命绑在镜像标签上——`ubuntu-22.04` 已被官方公告 2026-09-17 起弃用、2027-04-17 退役(`actions/runner-images#14254`,期间还有 brownout 会直接判红),`ubuntu-latest` 也已宣布 2026-11 起为 26.04。 - 参考实测(2026-10,本机 WSL):mono 上的结果与 Windows 真 .NET Framework 一致。 - 改了 `Compat/*` 或 `#if NETFRAMEWORK` 分支时,仍建议在本机 Windows 上跑一次 `-f net472`(或在 PR 里看 `net472-on-mono`)。 `samples/WpfDemo`(net8.0-windows + WPF)**已从解决方案移除**,目录保留、需要时在本机 Windows 上单独 `dotnet build samples/WpfDemo/WpfDemo.csproj`(只要它还留在解决方案里,Linux 上的整解构建就会失败)。 -`tests/AotSmokeTest.LogicetPlugins` 与 `tests/AotSmokeTest.SimpleFiles`(都是 net8.0 控制台 + `PublishAot=true`)**在解决方案内**(整解构建顺带编译它们),但**不要**改成 net8.0-windows 或其他不可跨平台的形态——CI 的 `aot-smoke` job 要在 Linux 上 publish 并运行它们。 +`tests/AotSmokeTest.LogicetPlugins`、`tests/AotSmokeTest.SimpleFiles` 与 `tests/AotSmokeTest.WebStream`(都是 net8.0 + `PublishAot=true`)**在解决方案内**(整解构建顺带编译它们),但**不要**改成 net8.0-windows 或其他不可跨平台的形态——CI 的 `aot-smoke` job 要在 Linux 上 publish 并运行它们。 - SDK 固定为 **8.0.102**(`global.json`,`rollForward: minor`)。 - 依赖用 **Paket** 管理。**不要 `dotnet add package`**:请改 `paket.dependencies` 后运行 `dotnet paket install`(会更新 `paket.lock`)。 @@ -73,7 +73,7 @@ CI(`.github/workflows/dotnet.yml`)有**三个 job**: - ⚠ 位置敏感的坑:`PublishAot=true` 会被 SDK 写进 `runtimeconfig.json`,使 `RuntimeFeature.IsDynamicCodeSupported` 在 **IL+JIT** 下也是 `false`;宿主据此判定"不支持动态加载",因此 `` 在 AOT(以及 `PublishAot=true` 的 IL 构建)下 **必须快速失败**(`TagsProjectNotSupportedException`),**不要**让它被 `LogicetLoader` 的通用 `catch` 吞掉——配了却静默不跑业务逻辑是硬红线。 - `tests/AotSmokeTest.LogicetPlugins` / `tests/AotSmokeTest.SimpleFiles` 是这两条链路的端到端验证(CI 的 `aot-smoke` job)。 + `tests/AotSmokeTest.LogicetPlugins` / `tests/AotSmokeTest.SimpleFiles` / `tests/AotSmokeTest.WebStream` 是这些链路的端到端验证(CI 的 `aot-smoke` job)。 - net472 缺 init-only setter 时,在 `paket.references` 里加 `IsExternalInit`(已有先例:Core / StdUnit.Tags / ComScanner / OpcUaClient / SimpleFiles)。 - **net472 的引用程序集没有可空标注**,`string.IsNullOrEmpty` 之类的 BCL 后置条件看不到,会出现 net472 专属的 CS8601/CS8603/CS8604 假阳性。改用语言级判空(`x is null || x.Length == 0`),**不要用 `NoWarn` 压掉**。 - 不要用 `| Select-Object -First n` 过滤 `dotnet paket`/`dotnet build` 的长输出:会在第 n 行处终止上游进程。先 `Out-File` 再 grep。 diff --git a/README.md b/README.md index 0cba6c5..7856eac 100644 --- a/README.md +++ b/README.md @@ -100,12 +100,13 @@ dotnet new tags.driver -n your-project.name -D YourDriver - `StdUnit.Tags.R3Extensions`: `Cysharp/R3`扩展,只依赖于`StdUnit.Tags.Core` - `StdUnit.Tags.BlazorLib.Core`: Blazor 类库,包含核心功能抽象,以及一个极简的监控页面。 - `StdUnit.Tags.McpServer`: 这是一个把`StdUnit.Tags`暴露成 [Model Context Protocol Server](https://modelcontextprotocol.io/) 的类库。 +- `StdUnit.Tags.WebStream`: 只读观测流(ASP.NET Core)。结构树走一次性 `GET`,测点值按"入口 + 轮次"以 SSE(`text/event-stream`)推送;不依赖 Blazor / SignalR,可在 NativeAOT 宿主下使用。自带一个极简监控页(`GET /stdunit.tags/ui`,单文件 HTML + 内嵌 JS,无需 npm 工程),并附带两个写命令(`POST /stdunit.tags/value` 改值、`POST /stdunit.tags/rescan` 标记未扫描)。端点只允许改**路由前缀**(`app.MapTagsStream("/internal/tags")`),相对路径是线协议的一部分;**不内置任何鉴权**,暴露到非受信网络前请对返回的路由组加策略,例如 `app.MapTagsStream().RequireAuthorization()`。 > 备注:源码目录中还有一个 `StdUnit.Tags.BlazorLib`(不是 `StdUnit.Tags.BlazorLib.Core`),放的是常用硬件设备的编辑器和设计器实现。这些只是我把脑子里的片段随手写下来的实验性代码——因为将来可能做成类库,里面不少东西被标成了`public`——目前它只是实验场,不对外发布。 相关测试统一放在 `tests/` 下的两个测试项目中: - `StdUnit.Tags.Tests`: 上述子项目的自动化测试(多目标 `net8.0` + `net472`)。 -- `StdUnit.Tags.Tests.NetCoreOnly`: **仅 `net8.0`** 的测试项目,专门存放无法面向 net472 的测试(Blazor / ASP.NET Core 等);目前所有测试都放在 `StdUnit.Tags.Tests` 里,所以这个项目还是空项,占位预留。 +- `StdUnit.Tags.Tests.NetCoreOnly`: **仅 `net8.0`** 的测试项目,专门存放无法面向 net472 的测试(Blazor / ASP.NET Core 等);目前承载 `StdUnit.Tags.WebStream` 的测试。 这里的自动化测试会在 `main`/`dev` 的推送、PR、发布新版时自动执行,并把结果传到 [codecov.io](https://app.codecov.io/gh/StdUnit/StdUnit.Tags)。 @@ -124,6 +125,7 @@ dotnet new tags.driver -n your-project.name -D YourDriver | R3扩展 | `StdUnit.Tags.R3Extensions` | ✅ 支持 | | McpServer扩展 | `StdUnit.Tags.McpServer` | ✅ 支持 | | BlazorLibCore扩展 | `StdUnit.Tags.BlazorLib.Core` | ✅ 支持 | +| 只读观测流 | `StdUnit.Tags.WebStream` | 🧪 实验 | 上表列出的包中,标记为“实验”的,`..` 里的 `` 跳变可能包含破坏性更新;标记为“支持”的,只有 `` 跳变才可能包含破坏性更新。核心包(`StdUnit.Tags`、`StdUnit.Tags.Core`)不属于驱动/扩展、因此不在此表内,按语义化版本处理——同样只有 `` 跳变才可能包含破坏性更新。 @@ -153,6 +155,7 @@ AI 可以直接参考 [StdUnit.Tags —— 给 AI 的参考手册](https://tags. | `StdUnit.Tags.S7` / `ModbusTcp` / `Hjzk` / `ZLan` / `OpcUaClient` / `ComScanner` / `SimpleFiles` | `net8.0` + `net472` | | `StdUnit.Tags.McpServer` | `net8.0` + `net472`(经 `ModelContextProtocol` 核心包的 `netstandard2.0` 资产) | | `StdUnit.Tags.BlazorLib` / `BlazorLib.Core` | 仅 `net8.0` | +| `StdUnit.Tags.WebStream` | 仅 `net8.0`(ASP.NET Core;依赖 `Microsoft.AspNetCore.App` 框架引用) | | `StdUnit.Tags.SchemaGenerator` | `netstandard2.0`(源生成器) | 两个框架的能力差异在 `Logicet` 插件上(共两处):见 [netfx 的插件化限制](#netfx-的插件化限制) 与 [AOT 的插件化限制](#aot-的插件化限制)。 diff --git a/StdUnit.Tags.sln b/StdUnit.Tags.sln index adcdb5e..db05b97 100644 --- a/StdUnit.Tags.sln +++ b/StdUnit.Tags.sln @@ -59,6 +59,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotSmokeTest.SimpleFiles", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StdUnit.Tags.Tests", "tests\StdUnit.Tags.Tests\StdUnit.Tags.Tests.csproj", "{3B44DA2B-D44B-14EE-9973-19C90BA6A579}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2215F920-F9B4-4801-88A1-5F3E3E65B9CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StdUnit.Tags.WebStream", "src\StdUnit.Tags.WebStream\StdUnit.Tags.WebStream.csproj", "{1B85FF35-7A81-4CB7-924A-441EC3DA9218}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B9E1E3A0-6B7E-4A15-AB8C-CB5E3FFB29C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotSmokeTest.WebStream", "tests\AotSmokeTest.WebStream\AotSmokeTest.WebStream.csproj", "{87CBE561-3796-48DE-B121-E7534B49FE45}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -153,6 +161,14 @@ Global {3B44DA2B-D44B-14EE-9973-19C90BA6A579}.Debug|Any CPU.Build.0 = Debug|Any CPU {3B44DA2B-D44B-14EE-9973-19C90BA6A579}.Release|Any CPU.ActiveCfg = Release|Any CPU {3B44DA2B-D44B-14EE-9973-19C90BA6A579}.Release|Any CPU.Build.0 = Release|Any CPU + {1B85FF35-7A81-4CB7-924A-441EC3DA9218}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B85FF35-7A81-4CB7-924A-441EC3DA9218}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B85FF35-7A81-4CB7-924A-441EC3DA9218}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B85FF35-7A81-4CB7-924A-441EC3DA9218}.Release|Any CPU.Build.0 = Release|Any CPU + {87CBE561-3796-48DE-B121-E7534B49FE45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87CBE561-3796-48DE-B121-E7534B49FE45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87CBE561-3796-48DE-B121-E7534B49FE45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87CBE561-3796-48DE-B121-E7534B49FE45}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -163,6 +179,8 @@ Global {CFD0A797-F315-4012-A3A7-5FDC703B0A6A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {C05B9E35-3DED-4DE5-BCCA-32F655B3DC2A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {D9097C66-2929-4F6E-B6A1-C93182F0DFBC} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {1B85FF35-7A81-4CB7-924A-441EC3DA9218} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {87CBE561-3796-48DE-B121-E7534B49FE45} = {B9E1E3A0-6B7E-4A15-AB8C-CB5E3FFB29C8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6FF52024-3F01-4AC2-B991-CEE89ACF9FFC} diff --git "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" index 2acec61..8daf8da 100644 --- "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" +++ "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" @@ -37,6 +37,7 @@ MCP 测试已在 `McpServer` 支持 net472 后合并回主测试项目(走 `Mo | `StdUnit.Tags.RxExtensions` | NotPlanned | 底层 `System.Reactive` 目前对 AOT 支持不好 | | `StdUnit.Tags.McpServer` | Ready | | | `StdUnit.Tags.BlazorLib.Core` | NotPlanned | Blazor 团队已明确表述不支持 Server 模式的 AOT | +| `StdUnit.Tags.WebStream` | Ready | 只用 Minimal API + SSE + JSON 源生成;**不依赖** SignalR / Session / MVC(这三者都不能 AOT)。宿主是否 `PublishAot` 由宿主自己决定 | | **驱动** | | | | `StdUnit.Tags.S7` | Ready | | | `StdUnit.Tags.ModbusTcp` | Ready | | @@ -49,4 +50,5 @@ MCP 测试已在 `McpServer` 支持 net472 后合并回主测试项目(走 `Mo > 备注: CI中提供了端到端的AOT冒烟示例(位于 `tests/`): > - `tests/AotSmokeTest.LogicetPlugins`(Core + StdUnit.Tags + ModbusTcp) -> - `tests/AotSmokeTest.SimpleFiles`(Core + StdUnit.Tags + SimpleFiles)。 \ No newline at end of file +> - `tests/AotSmokeTest.SimpleFiles`(Core + StdUnit.Tags + SimpleFiles) +> - `tests/AotSmokeTest.WebStream`(WebStream:起 Kestrel,验证多态 JSON 序列化与 SSE 全链路) \ No newline at end of file diff --git a/src/StdUnit.Tags.WebStream/Commands/CommandDto.cs b/src/StdUnit.Tags.WebStream/Commands/CommandDto.cs new file mode 100644 index 0000000..99a0b01 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Commands/CommandDto.cs @@ -0,0 +1,24 @@ +namespace StdUnit.Tags.WebStream; + +/// +/// POST /stdunit.tags/value 的请求体:把某个测点的值改成 。 +/// +/// 测点路径(与 一致)。 +/// +/// 值的文本形式;服务端按 的 TagKind 精确解析 +/// (不做跨类型转换)。布尔类(BIT/DI/DO)接受 true/false/1/0。 +/// +public sealed record WriteTagValueRequest(string? Path, string? Raw); + +/// +/// POST /stdunit.tags/rescan 的请求体:把某个测点标记为"未扫描"(R1W 测点会强制重读)。 +/// +/// 测点路径(与 一致)。 +public sealed record RescanTagRequest(string? Path); + +/// +/// 命令执行结果。 为 null 表示成功。 +/// +/// 是否成功。 +/// 失败原因(成功时为 null,序列化时省略)。 +public sealed record CommandResult(bool Ok, string? Error); diff --git a/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs b/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs new file mode 100644 index 0000000..6e2073d --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 写命令端点:改测点值(POST /stdunit.tags/value)、标记未扫描(POST /stdunit.tags/rescan)。 +/// +internal static class CommandsEndpoint +{ + public static async Task WriteValueAsync(HttpContext context) + { + var request = await ReadBodyAsync(context, TagsStreamJsonContext.Default.WriteTagValueRequest) + .ConfigureAwait(false); + if (request is null) + { + await WriteResultAsync(context, CommandOutcome.BadRequest("请求体不是合法的 JSON")).ConfigureAwait(false); + return; + } + + var ctrl = context.RequestServices.GetRequiredService(); + var options = context.RequestServices.GetRequiredService(); + var outcome = await TagsStreamCommands + .WriteValueAsync(ctrl, request, options.CommandTimeout) + .ConfigureAwait(false); + + await WriteResultAsync(context, outcome).ConfigureAwait(false); + } + + public static async Task RescanAsync(HttpContext context) + { + var request = await ReadBodyAsync(context, TagsStreamJsonContext.Default.RescanTagRequest) + .ConfigureAwait(false); + if (request is null) + { + await WriteResultAsync(context, CommandOutcome.BadRequest("请求体不是合法的 JSON")).ConfigureAwait(false); + return; + } + + var ctrl = context.RequestServices.GetRequiredService(); + var options = context.RequestServices.GetRequiredService(); + var outcome = await TagsStreamCommands.RescanAsync(ctrl, request, options.CommandTimeout).ConfigureAwait(false); + + await WriteResultAsync(context, outcome).ConfigureAwait(false); + } + + private static async Task WriteResultAsync(HttpContext context, CommandOutcome outcome) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes( + new CommandResult(outcome.Error is null, outcome.Error), + TagsStreamJsonContext.Default.CommandResult); + + context.Response.StatusCode = outcome.StatusCode; + context.Response.ContentType = "application/json; charset=utf-8"; + context.Response.ContentLength = bytes.Length; + await context.Response.Body.WriteAsync(bytes, context.RequestAborted).ConfigureAwait(false); + } + + private static async Task ReadBodyAsync(HttpContext context, JsonTypeInfo typeInfo) + where T : class + { + try + { + return await JsonSerializer + .DeserializeAsync(context.Request.Body, typeInfo, context.RequestAborted) + .ConfigureAwait(false); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/StdUnit.Tags.WebStream/Commands/TagsStreamCommands.cs b/src/StdUnit.Tags.WebStream/Commands/TagsStreamCommands.cs new file mode 100644 index 0000000..542404e --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Commands/TagsStreamCommands.cs @@ -0,0 +1,298 @@ +using System.Globalization; +using Microsoft.AspNetCore.Http; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 写命令的结果:HTTP 状态码 + 错误文本。 +/// +internal readonly record struct CommandOutcome(int StatusCode, string? Error) +{ + public static CommandOutcome Ok { get; } = new(StatusCodes.Status200OK, null); + + public static CommandOutcome BadRequest(string error) => new(StatusCodes.Status400BadRequest, error); + + public static CommandOutcome Conflict(string error) => new(StatusCodes.Status409Conflict, error); + + public static CommandOutcome Timeout(string error) => new(StatusCodes.Status504GatewayTimeout, error); + + public static CommandOutcome Failure(string error) => new(StatusCodes.Status500InternalServerError, error); +} + +/// +/// 写命令:改测点值、标记未扫描。
+/// 所有变更都通过 投递到对应入口的轮询线程上执行—— +/// 绝不在请求线程上直接改测点(那会与轮询并发读写同一份状态)。 +///
+internal static class TagsStreamCommands +{ + /// 改测点值。 + public static async Task WriteValueAsync( + ITagsProjectCtrl ctrl, + WriteTagValueRequest request, + TimeSpan timeout + ) + { + if (!TryResolve(ctrl, request.Path, out var target, out var failure)) + { + return failure; + } + + if (target.Tag.IsReadOnly()) + { + return CommandOutcome.Conflict($"测点只读(access=RO),不能写入:{target.Tag.TagName()}"); + } + + if (!TryParseValue(target.Tag, request.Raw, out var value, out var parseError)) + { + return CommandOutcome.BadRequest(parseError!); + } + + return await ExecuteAsync(target, () => target.Tag.Value = value, timeout).ConfigureAwait(false); + } + + /// 标记未扫描(R1W 测点会因此强制重读一次)。 + public static Task RescanAsync( + ITagsProjectCtrl ctrl, + RescanTagRequest request, + TimeSpan timeout) + { + if (!TryResolve(ctrl, request.Path, out var target, out var failure)) + { + return Task.FromResult(failure); + } + + return ExecuteAsync(target, () => ResetScanned(target.Tag), timeout); + } + + /// + /// 与 Blazor 组件里的语义一致:子测点属于组合测点时,标记组合未扫描(冒泡),否则标记测点本身。 + /// + internal static void ResetScanned(ITag tag) + { + var container = tag.Parent; + if (container is not null && container.IsTagCbnt) + { + container.Map( + static cbnt => + { + cbnt.IsScanned = false; + return 0; + }, + static _ => 0); + return; + } + + tag.IsScanned = false; + } + + private static async Task ExecuteAsync(Target target, Action mutate, TimeSpan timeout) + { + Task completion; + try + { + var accepted = target.Project.WriteIntent( + target.Entry.TagName(), + (_, _) => + { + mutate(); + return ValueTask.CompletedTask; + }, + out completion); + + if (!accepted) + { + // 队列满:WriteIntent 已把 completion 置为异常,这里只取它的消息 + await ObserveAsync(completion).ConfigureAwait(false); + return CommandOutcome.Conflict("该入口的写入意图队列已满,请稍后重试"); + } + } + catch (Exception ex) + { + return CommandOutcome.Failure(ex.Message); + } + + var finished = await Task.WhenAny(completion, Task.Delay(timeout)).ConfigureAwait(false); + if (!ReferenceEquals(finished, completion)) + { + return CommandOutcome.Timeout( + $"等待 {(int)timeout.TotalSeconds}s 仍未被执行:入口可能被禁用或不轮询(意图会在它恢复轮询后执行)"); + } + + try + { + await completion.ConfigureAwait(false); + return CommandOutcome.Ok; + } + catch (Exception ex) + { + return CommandOutcome.Failure(ex.Message); + } + } + + private static async Task ObserveAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // 只是为了观察异常,具体消息在调用点统一给出 + } + } + + private static bool TryResolve( ITagsProjectCtrl ctrl, string? path, out Target target, out CommandOutcome failure) + { + target = default; + failure = default; + + if (string.IsNullOrWhiteSpace(path)) + { + failure = CommandOutcome.BadRequest("path 不能为空"); + return false; + } + + var project = ctrl.Project; + if (project is null) + { + failure = CommandOutcome.Conflict("项目未运行"); + return false; + } + + ITag tag; + try + { + if (project.Tags.Descendant(path) is not TagUnion.TagUnit unit) + { + failure = CommandOutcome.BadRequest($"不是叶测点,无法对其操作:{path}"); + return false; + } + + tag = unit.Value; + } + catch (Exception ex) when (ex is KeyNotFoundException or InvalidOperationException or ArgumentException) + { + failure = CommandOutcome.BadRequest($"测点不存在:{path}"); + return false; + } + + var entry = tag.SearchEntry(); + if (entry is null) + { + failure = CommandOutcome.Conflict($"测点不在任何入口内(不会被轮询):{path}"); + return false; + } + + target = new Target(project, tag, entry); + return true; + } + + private static bool TryParseValue(ITag tag, string? raw, out object value, out string? error) + { + value = null!; + error = null; + + if (raw is null) + { + error = "raw 不能为空"; + return false; + } + + var kind = tag.TagKind(); + try + { + switch (kind) + { + case BuiltinTagKinds.BYTE: + value = byte.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.INT16: + value = short.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.UINT16: + value = ushort.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.INT32: + value = int.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.UINT32: + value = uint.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.INT64: + value = long.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.UINT64: + value = ulong.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.FLOAT: + value = float.Parse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.DOUBLE: + value = double.Parse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture); + return true; + case BuiltinTagKinds.BIT: + case BuiltinTagKinds.DI: + case BuiltinTagKinds.DO: + if (!TryParseBool(raw, out var flag)) + { + error = $"无法解析为布尔值(接受 true/false/1/0):{raw}"; + return false; + } + + value = flag; + return true; + case BuiltinTagKinds.STR: + value = raw; + return true; + default: + // 未登记的类型(如驱动自定义的 JSON 测点)不接受文本写入 + error = $"不支持用文本写入的类型:{kind}(当前值类型:{tag.Value?.GetType().Name ?? "null"})"; + return false; + } + } + catch (Exception ex) when (ex is FormatException or OverflowException) + { + error = $"无法解析为 {kind}:{raw}"; + return false; + } + } + + private static bool TryParseBool(string raw, out bool value) + { + if (bool.TryParse(raw, out value)) + { + return true; + } + + if (raw == "1") + { + value = true; + return true; + } + + if (raw == "0") + { + value = false; + return true; + } + + return false; + } + + private readonly struct Target + { + public Target(ITagsProject project, ITag tag, ITagGrp entry) + { + this.Project = project; + this.Tag = tag; + this.Entry = entry; + } + + public ITagsProject Project { get; } + + public ITag Tag { get; } + + public ITagGrp Entry { get; } + } +} diff --git a/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs b/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs new file mode 100644 index 0000000..0a9a3be --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs @@ -0,0 +1,35 @@ +namespace StdUnit.Tags.WebStream; + +/// +/// 端点常量。
+/// +/// /{prefix} +/// /ui # 内置监控页(GET) +/// /tree # 结构树(GET) +/// /stream # 值流(GET,SSE) +/// /value # 改测点值(POST) +/// /rescan # 标记未扫描(POST) +/// +///
+public static class EndpointConstants +{ + /// + /// 默认前缀:所有端点都挂在这个前缀下(/stdunit.tags/tree、/stdunit.tags/stream …)。 + /// + public const string DefaultPrefix = "/stdunit.tags"; + + /// 内置监控页(GET)。 + internal const string UiPath = "/ui"; + + /// 结构树(GET)。 + internal const string TreePath = "/tree"; + + /// 值流(GET,SSE)。 + internal const string StreamPath = "/stream"; + + /// 改测点值(POST)。 + internal const string WriteValuePath = "/value"; + + /// 标记未扫描(POST)。 + internal const string RescanPath = "/rescan"; +} diff --git a/src/StdUnit.Tags.WebStream/Core/ITagsStreamHub.cs b/src/StdUnit.Tags.WebStream/Core/ITagsStreamHub.cs new file mode 100644 index 0000000..0cb6c45 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/ITagsStreamHub.cs @@ -0,0 +1,25 @@ +using StdUnit.Tags.WebStream.Internal; + +namespace StdUnit.Tags.WebStream; + +internal interface ITagsStreamHub +{ + /// 当前 epoch(每次项目启动递增)。 + long Epoch { get; } + + /// 取当前结构树。项目未运行时返回 = false 的空树。 + TreeDto GetTree(); + + /// + /// 建立一个FramesPiper。 为 null 表示订阅全部入口。
+ /// 返回的订阅者已经入队了当前状态与各入口的全量值基线(在同一把锁内完成,不会漏帧), + ///
+ SseFramesPiper Subscribe(IReadOnlySet? entries); + + void RemovePiper(SseFramesPiper piper); + + /// + /// 重同步帧:当前状态 + 该订阅者关心的各入口全量值。 + /// + IReadOnlyList> BuildSyncFrames(SseFramesPiper piper); +} diff --git a/src/StdUnit.Tags.WebStream/Core/Index/EntryStream.cs b/src/StdUnit.Tags.WebStream/Core/Index/EntryStream.cs new file mode 100644 index 0000000..b771757 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/Index/EntryStream.cs @@ -0,0 +1,35 @@ +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 一个入口的观测视图:叶子测点集合、容器集合、值收集器。 +/// +internal sealed class EntryStream +{ + private long _seq; + + public EntryStream(string key, string path, ITagGrp entry, IReadOnlyList leaves, IReadOnlyList containers) + { + this.Key = key; + this.Path = path; + this.Entry = entry; + this.Collector = new EntryValuesCollector(leaves, containers); + } + + /// + /// 入口键,等于 entry.TagName()。
+ /// ?entries= 与 values.entry 用的就是它。 + ///
+ public string Key { get; } + + /// 入口在树中的路径。 + public string Path { get; } + + public ITagGrp Entry { get; } + + public EntryValuesCollector Collector { get; } + + /// + /// 该入口内单调递增的帧序号(每次项目启动从 0 开始)。 + /// + public long IncrementSeq() => Interlocked.Increment(ref this._seq); +} diff --git a/src/StdUnit.Tags.WebStream/Core/Index/EntryValuesCollector.cs b/src/StdUnit.Tags.WebStream/Core/Index/EntryValuesCollector.cs new file mode 100644 index 0000000..384100a --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/Index/EntryValuesCollector.cs @@ -0,0 +1,166 @@ +using System.Globalization; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 一个入口的值收集器:保存上一次的值用于 diff,并能给出当前全量值(订阅基线与重同步)。
+/// 所有成员线程安全:diff 在轮询线程上跑,全量构建在发送循环/请求线程上跑。 +///
+internal sealed class EntryValuesCollector +{ + private readonly object _gate = new(); + private readonly IReadOnlyList _leaves; + private readonly IReadOnlyList _containers; + private readonly Dictionary _previousLeafValues = new(ReferenceEqualityComparer.Instance); + /// 上一次推送的容器使能标志,键是容器身份()。 + private readonly Dictionary _prevContainerIsEnabledFlags = new(ReferenceEqualityComparer.Instance); + + public EntryValuesCollector(IReadOnlyList leaves, IReadOnlyList containers) + { + this._leaves = leaves; + this._containers = containers; + + // 预先记下"构建时刻"的值:这样第一轮只报告真正变化过的点, + // 而不是把一整套默认值当成"全都变了"再推一遍。 + foreach (var leaf in leaves) + { + var tag = leaf.Tag; + this._previousLeafValues[tag] = new Previous(tag.Value, tag.IsDirty, tag.IsScanned); + } + foreach (var container in containers) + { + this._prevContainerIsEnabledFlags[container.Identity] = container.IsEnabled; + } + } + + /// + /// 计算自上次调用以来的变化。只在轮询线程上调用;无变化时返回 null。
+ /// O(该入口子树的节点数):叶测点一趟、容器一趟,都是只读扫描,只有真正变化的元素才会生成 DTO。 + ///
+ public EntryDelta? Diff() + { + lock (this._gate) + { + var values = new List(); + foreach (var leaf in this._leaves) + { + var tag = leaf.Tag; + var value = tag.Value; + var isDirty = tag.IsDirty; + var isScanned = tag.IsScanned; + + if (this._previousLeafValues.TryGetValue(tag, out var previous) + && Equals(previous.Value, value) + && previous.IsDirty == isDirty + && previous.IsScanned == isScanned) + { + continue; + } + + this._previousLeafValues[tag] = new Previous(value, isDirty, isScanned); + values.Add(CreateValueDto(leaf)); + } + + var containerFlags = new List(); + foreach (var container in this._containers) + { + var isEnabled = container.IsEnabled; + if (this._prevContainerIsEnabledFlags.TryGetValue(container.Identity, out var previous) && previous == isEnabled) + { + continue; + } + + this._prevContainerIsEnabledFlags[container.Identity] = isEnabled; + containerFlags.Add(new ContainerFlagDto(container.Path, isEnabled)); + } + + return values.Count == 0 && containerFlags.Count == 0 + ? null + : new EntryDelta(values, containerFlags); + } + } + + /// + /// 该入口当前的全部值(订阅基线 / 重同步)。不能在轮询线程上调用(O(n) 且要分配字符串)。 + /// + public List GetAll() + { + lock (this._gate) + { + var values = new List(this._leaves.Count); + foreach (var leaf in this._leaves) + { + values.Add(CreateValueDto(leaf)); + } + return values; + } + } + + private static TagValueDto CreateValueDto(LeafTag leaf) + { + var tag = leaf.Tag; + var (value, timestamp) = ReadStable(tag); + return new TagValueDto( + leaf.Path, + tag.TagKind(), + Convert.ToString(value, CultureInfo.InvariantCulture), + timestamp, + tag.IsDirty, + tag.IsScanned); + } + + /// + /// 读一对 (值, 时间戳)。 是 8 字节结构,32 位宿主上可能有撕裂读, + /// 所以读两次时间戳,不一致就重试(最多几次),仍不一致则接受最后一次的结果。 + /// + private static (object? Value, DateTime Timestamp) ReadStable(ITag tag) + { + for (var i = 0; i < 3; i++) + { + var before = tag.Timestamp; + var value = tag.Value; + var after = tag.Timestamp; + if (before == after) + { + return (value, after); + } + } + + return (tag.Value, tag.Timestamp); + } + + private readonly struct Previous + { + public Previous(object? value, bool isDirty, bool isScanned) + { + this.Value = value; + this.IsDirty = isDirty; + this.IsScanned = isScanned; + } + + public object? Value { get; } + + public bool IsDirty { get; } + + public bool IsScanned { get; } + } +} + + + +/// +/// 一次轮次里发生变化的内容。
+/// 两个列表都可能为空(表示该维度没有变化),但都不为 null——调用方用 Count 判断。 +///
+internal sealed class EntryDelta +{ + public EntryDelta(List values, List containerFlags) + { + this.Values = values; + this.ContainerFlags = containerFlags; + } + + public List Values { get; } + + public List ContainerFlags { get; } +} diff --git a/src/StdUnit.Tags.WebStream/Core/Index/ProjectIndex.cs b/src/StdUnit.Tags.WebStream/Core/Index/ProjectIndex.cs new file mode 100644 index 0000000..3b0b093 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/Index/ProjectIndex.cs @@ -0,0 +1,239 @@ +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 一个 epoch 内的项目索引:扁平的结构树 + 每个入口的观测视图。
+/// 构建一次(项目启动时),之后只读。 +///
+internal sealed class ProjectIndex +{ + private ProjectIndex(long epoch, TreeDto tree, List entries) + { + this.Epoch = epoch; + this.Tree = tree; + this.Entries = entries; + + var byKey = new Dictionary(StringComparer.Ordinal); + foreach (var entry in entries) + { + byKey[entry.Key] = entry; + } + this.ByKey = byKey; + } + + public long Epoch { get; } + + public TreeDto Tree { get; } + + public IReadOnlyList Entries { get; } + + public IReadOnlyDictionary ByKey { get; } + + /// + /// 从项目中构建索引。O(节点数),一次性:在项目启动时(轮询开始之前)调用。
+ /// 只遍历一次:结构树与"每个入口的叶子/容器"在同一趟 DFS 里同时产出 + /// (入口子树两两不相交,所以把"当前入口的收集器"往下传就够了,不需要第二趟)。 + ///
+ public static ProjectIndex Build(long epoch, ITagsProject project) + { + var root = project.Tags; + var entryGroups = root.ScanEntries(); + + var visitor = new IndexVisitor(entryGroups); + new TagUnion.TagGrp(root).Accept(visitor); + + var entries = new List(entryGroups.Count); + foreach (var entry in entryGroups) + { + if (!visitor.Collected.TryGetValue(entry, out var accumulator)) + { + continue; + } + + entries.Add(new EntryStream( + entry.TagName(), + accumulator.Path, + entry, + accumulator.Leaves, + accumulator.Containers + )); + } + + var tree = new TreeDto(epoch, true, root.TagName(), visitor.Nodes); + return new ProjectIndex(epoch, tree, entries); + } + + /// + /// 一趟深度优先遍历的访问者:产出结构树节点,并在进入入口子树时把它的叶子与容器累积下来。
+ /// 不继承 :基类的 Process 之后不会再通知子类"子树已结束", + /// 而这里必须知道何时退出子树(恢复父路径 / 深度 / 当前入口),所以自己维护进出栈的上下文 + /// ()。遍历顺序与基类一致:先序、文档顺序。 + ///
+ private sealed class IndexVisitor : ITagUnionVisitor + { + private readonly HashSet _entrySet; + + private string? _parentPath; + private int _depth; + + /// "当前所在的入口累加器";不在任何入口子树内时为 null(那部分节点只有结构、没有值)。 + private EntryAccumulator? _entry; + + public IndexVisitor(IList entryGroups) + { + // 只有 ScanEntries 认定的"最外层入口"才算入口(嵌套的 isEntry 不生效,见 NestedEntryValidator)。 + this._entrySet = new HashSet(entryGroups, ReferenceEqualityComparer.Instance); + } + + public List Nodes { get; } = new(); + + public Dictionary Collected { get; } = new(ReferenceEqualityComparer.Instance); + + /// + public void Visit(TagUnion.TagGrp grp) + { + var node = grp.Value; + var scope = this.Enter(node.TagName()); + + var isEntry = this._entrySet.Contains(node); + if (isEntry) + { + this._entry = new EntryAccumulator(scope.Path); + this.Collected[node] = this._entry; + } + + this.Nodes.Add(new TagGrpNodeDto( + Path: scope.Path, + Parent: scope.ParentPath, + Depth: scope.Depth, + Name: node.TagName(), + ChannelName: node.SearchChannel()?.ChannelName(), + AccessMode: node.SearchAccessMode().ToString(), + EntryKey: isEntry ? node.TagName() : null, + IsEntry: node.Descriptor.IsEntry, + IsEnabled: node.IsEnabled, + ScanIntervalMs: node.SearchScanInterval())); + + this._entry?.Containers.Add(new ContainerNode(scope.Path, node, null)); + + foreach (var child in node.Children.Values) + { + child.Accept(this); + } + + this.Leave(scope); + } + + /// + public void Visit(TagUnion.TagCbnt cbnt) + { + var node = cbnt.Value; + var scope = this.Enter(node.TagName()); + + this.Nodes.Add(new TagCbntNodeDto( + Path: scope.Path, + Parent: scope.ParentPath, + Depth: scope.Depth, + Name: node.TagName(), + ChannelName: node.SearchChannel()?.ChannelName(), + AccessMode: node.SearchAccessMode().ToString(), + IsEnabled: node.IsEnabled, + StartAddress: node.StartAddress)); + + this._entry?.Containers.Add(new ContainerNode(scope.Path, null, node)); + + // 组合的子节点只可能是叶测点,直接走 VisitTag,省掉一层 TagUnit 包装。 + foreach (var child in node.Children.Values) + { + this.VisitTag(child); + } + + this.Leave(scope); + } + + /// + public void Visit(TagUnion.TagUnit tag) => this.VisitTag(tag.Value); + + private void VisitTag(ITag tag) + { + var path = Combine(this._parentPath, tag.TagName()); + + this.Nodes.Add(new TagLeafNodeDto( + Path: path, + Parent: this._parentPath, + Depth: this._depth, + Name: tag.TagName(), + ChannelName: tag.SearchChannel()?.ChannelName(), + AccessMode: tag.SearchAccessMode().ToString(), + TagKind: tag.TagKind(), + Address: tag.NormalizedAddress(), + TagSize: tag.TagSize(), + IsReadOnly: tag.IsReadOnly())); + + this._entry?.Leaves.Add(new LeafTag(path, tag)); + } + + /// 进入一个容器节点:算出它自己的路径/深度,并把上下文切到"它的子树"。 + private Scope Enter(string name) + { + var scope = new Scope(Combine(this._parentPath, name), this._parentPath, this._depth, this._entry); + this._parentPath = scope.Path; + this._depth = scope.Depth + 1; + return scope; + } + + /// 离开容器节点:恢复进入前的父路径 / 深度 / 当前入口。 + private void Leave(Scope scope) + { + this._parentPath = scope.ParentPath; + this._depth = scope.Depth; + this._entry = scope.PreviousEntry; + } + + private static string Combine(string? parentPath, string name) + { + if (parentPath is null) + { + // 根节点不占路径段:其 Path 为空串,子节点直接用名字。 + return string.Empty; + } + + return parentPath.Length == 0 ? name : parentPath + "/" + name; + } + + /// 一次 的上下文快照:本节点的路径/父路径/深度,以及进入前的入口累加器。 + private readonly struct Scope + { + public Scope(string path, string? parentPath, int depth, EntryAccumulator? previousEntry) + { + this.Path = path; + this.ParentPath = parentPath; + this.Depth = depth; + this.PreviousEntry = previousEntry; + } + + /// 本节点的路径。 + public string Path { get; } + + /// 本节点的父路径(也就是进入前的当前路径)。 + public string? ParentPath { get; } + + /// 本节点的深度。 + public int Depth { get; } + + /// 进入本节点之前的入口累加器,离开时恢复。 + public EntryAccumulator? PreviousEntry { get; } + } + } + + /// 单趟遍历时,为每个入口顺带累积它的叶子与容器。 + private sealed class EntryAccumulator + { + public EntryAccumulator(string path) => this.Path = path; + + public string Path { get; } + + public List Leaves { get; } = new(); + + public List Containers { get; } = new(); + } +} diff --git a/src/StdUnit.Tags.WebStream/Core/Index/TagNodes.cs b/src/StdUnit.Tags.WebStream/Core/Index/TagNodes.cs new file mode 100644 index 0000000..e7a33d2 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/Index/TagNodes.cs @@ -0,0 +1,42 @@ +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 一个叶测点()及其在树中的路径。 +/// +internal readonly struct LeafTag +{ + public LeafTag(string path, ITag tag) + { + this.Path = path; + this.Tag = tag; + } + + public string Path { get; } + + public ITag Tag { get; } +} + + +/// +/// 一个容器( 或 )及其在树中的路径。 +/// +internal readonly struct ContainerNode +{ + public ContainerNode(string path, ITagGrp? grp, ITagCbnt? cbnt) + { + this.Path = path; + this.Grp = grp; + this.Cbnt = cbnt; + } + + public string Path { get; } + + public ITagGrp? Grp { get; } + + public ITagCbnt? Cbnt { get; } + + /// 用于字典键(引用身份,避免依赖实现是否重写 Equals)。 + public object Identity => (object?)this.Grp ?? this.Cbnt!; + + public bool IsEnabled => this.Grp?.IsEnabled ?? this.Cbnt!.IsEnabled; +} diff --git a/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs b/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs new file mode 100644 index 0000000..d363131 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs @@ -0,0 +1,559 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StdUnit.Tags.WebStream.Internal; + +namespace StdUnit.Tags.WebStream; + +/// +/// 只读观测流的核心实现(对外只暴露 ):
+/// - 跟踪 的启停,维护每次启动的 epoch 与结构索引;
+/// - 为每个入口用 挂一个"轮次完成"回调,做值 diff;
+/// - 把结果序列化成 SSE 帧,扇出给各订阅者(每帧只序列化一次)。
+/// 同时是 (随宿主启停)与订阅者集合的持有者:订阅相关的成员只出现在内部的 +/// 上,只服务同一程序集里的端点与 。 +/// +/// 线程模型:轮次回调在轮询线程上执行,只做 O(改动的点) 的字典写入 + 一次 TryWrite, +/// 不做序列化、不做 IO、不阻塞;序列化与扇出在单独的发送循环里,每个订阅者另有自己的写循环与有界队列。 +/// 任何一环满了都只丢帧并触发重同步,不会反压到轮询。 +/// +///
+internal sealed class TagsStreamHub : ITagsStreamHub, IHostedService, IDisposable +{ + private readonly ITagsProjectCtrl _ctrl; + private readonly TagsStreamOptions _options; + private readonly ILogger? _logger; + + /// 订阅者集合与状态发布。_transitionGate → _gate 是唯一允许的加锁顺序。 + private readonly object _gate = new(); + + /// 启动/停止的状态迁移串行化(避免事件与对账线程同时迁移)。 + private readonly object _transitionGate = new(); + + private readonly HashSet _pipers = new(); + private readonly Channel _pending; + + private volatile ProjectIndex? _index; + private volatile ITagsProject? _project; + private volatile StateDto _state = new(0, false, null); + + private long _epoch; + private int _pipersCount; + private int _overflow; + private int _started; + private CancellationTokenSource? _cts; + private Task? _pumpTask; + private Task? _heartbeatTask; + + /// + /// c'tor + /// + /// 测点项目控制器(需由 AddTagsProjectServices 注册) + /// 配置 + /// 日志 + public TagsStreamHub(ITagsProjectCtrl ctrl, TagsStreamOptions options, ILogger? logger = null) + { + this._ctrl = ctrl ?? throw new ArgumentNullException(nameof(ctrl)); + this._options = options ?? throw new ArgumentNullException(nameof(options)); + this._logger = logger; + + if (options.ReconcileInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(options), options.ReconcileInterval, "ReconcileInterval 必须大于 0"); + } + if (options.HeartbeatInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(options), options.HeartbeatInterval, "HeartbeatInterval 必须大于 0"); + } + if (options.PiperQueueCapacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), options.PiperQueueCapacity, "SubscriberQueueCapacity 必须大于 0"); + } + if (options.PendingQueueCapacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), options.PendingQueueCapacity, "PendingQueueCapacity 必须大于 0"); + } + + this._pending = Channel.CreateBounded(new BoundedChannelOptions(options.PendingQueueCapacity) + { + FullMode = BoundedChannelFullMode.Wait, // 配合 TryWrite:满时返回 false,而不是静默丢弃 + SingleReader = true, + SingleWriter = false, + }); + } + + /// + public long Epoch => this._state.Epoch; + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref this._started, 1) != 0) + { + return Task.CompletedTask; + } + + var cts = new CancellationTokenSource(); + this._cts = cts; + this._ctrl.StartedOrStopped += this.OnStartedOrStopped; + this._pumpTask = Task.Run(() => this.PumpAsync(cts.Token), CancellationToken.None); + this._heartbeatTask = Task.Run(() => this.HeartbeatAsync(cts.Token), CancellationToken.None); + + // 订阅之前项目可能就已经在跑(hub 启动得晚):先对一次账。 + this.SyncWithController(); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + this._ctrl.StartedOrStopped -= this.OnStartedOrStopped; + + var cts = Interlocked.Exchange(ref this._cts, null); + if (cts is not null) + { + cts.Cancel(); + } + + var tasks = new[] { this._pumpTask, this._heartbeatTask }; + foreach (var task in tasks) + { + if (task is null) + { + continue; + } + + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // 正常停机 + } + } + + cts?.Dispose(); + this._pumpTask = null; + this._heartbeatTask = null; + } + + /// + public TreeDto GetTree() + { + var index = this._index; + var state = this._state; + return index is not null && state.IsStarted + ? index.Tree + : new TreeDto(state.Epoch, false, string.Empty, Array.Empty()); + } + + + /// + public SseFramesPiper Subscribe(IReadOnlySet? entries) + { + var piper = new SseFramesPiper(this, entries, this._options.PiperQueueCapacity); + + lock (this._gate) + { + piper.Enqueue(SseFrames.State(this._state)); + + var index = this._index; + if (index is not null && this._state.IsStarted) + { + foreach (var stream in index.Entries) + { + if (piper.ContainsEntry(stream.Key)) + { + piper.Enqueue(this.BuildFullFrameLocked(index, stream)); + } + } + } + + this._pipers.Add(piper); + Interlocked.Increment(ref this._pipersCount); + } + + return piper; + } + + public void RemovePiper(SseFramesPiper piper) + { + lock (this._gate) + { + if (this._pipers.Remove(piper)) + { + Interlocked.Decrement(ref this._pipersCount); + } + } + } + + public IReadOnlyList> BuildSyncFrames(SseFramesPiper piper) + { + lock (this._gate) + { + var frames = new List> { + SseFrames.State(this._state) + }; + + var index = this._index; + if (index is not null && this._state.IsStarted) + { + foreach (var stream in index.Entries) + { + if (piper.ContainsEntry(stream.Key)) + { + frames.Add(this.BuildFullFrameLocked(index, stream)); + } + } + } + + return frames; + } + } + + /// + public void Dispose() + { + this._ctrl.StartedOrStopped -= this.OnStartedOrStopped; + + var cts = Interlocked.Exchange(ref this._cts, null); + try + { + cts?.Cancel(); + } + catch (ObjectDisposedException) + { + // 已停过 + } + + cts?.Dispose(); + } + + private void OnStartedOrStopped(ITagsProjectCtrl sender, TagsProjectEventArgs args) + { + try + { + if (args.IsStarted && args.Project is not null) + { + this.OnProjectStarted(args.Project); + } + else + { + this.OnProjectStopped(); + } + } + catch (Exception ex) + { + // ⚠ "已启动"是在 TagsProjectCtrl.StartPollAsync 的 try 里同步派发的: + // 从这里抛出会被当作启动失败并让 StartPollAsync 抛错(项目随即被释放)。 + // 观测流的问题绝不允许影响项目启动。 + this._logger?.LogError(ex, "观测流处理 StartedOrStopped 时出错"); + } + } + + private void OnProjectStarted(ITagsProject project) + { + lock (this._transitionGate) + { + if (ReferenceEquals(project, this._project)) + { + return; + } + + var epoch = Interlocked.Increment(ref this._epoch); + ProjectIndex index; + try + { + index = ProjectIndex.Build(epoch, project); + } + catch (Exception ex) + { + this._logger?.LogError(ex, "构建观测索引失败(项目根目录={ProjectRoot})", project.ProjectRoot); + return; + } + + lock (this._gate) + { + this._project = project; + this._index = index; + this._state = new StateDto(epoch, true, project.ProjectRoot); + this.Broadcast(SseFrames.State(this._state)); + foreach (var subscriber in this._pipers) + { + subscriber.MarkResync(); + } + } + + // 必须在项目开始轮询之前注册(见 ITagsProject.SetTurnProcessHandler 的"只能在运行前设置")。 + foreach (var entry in index.Entries) + { + var entryKey = entry.Key; + try + { + project.SetTurnProcessHandler(entryKey, (_, _, _) => + { + this.OnTurn(epoch, entryKey); + return Task.CompletedTask; + }); + } + catch (Exception ex) + { + this._logger?.LogWarning( + ex, + "无法为入口 {Entry} 注册轮次回调(项目可能已在运行):该入口只能观测结构,观测不到值", + entryKey); + } + } + } + } + + private void OnProjectStopped() + { + lock (this._transitionGate) + { + lock (this._gate) + { + if (this._project is null) + { + return; + } + + this._project = null; + this._index = null; + this._state = new StateDto(this._state.Epoch, false, this._state.ProjectRoot); + this.Broadcast(SseFrames.State(this._state)); + } + } + } + + /// + /// 对账:以 为准,纠正"未收到事件"的状态差异 + /// (例如 RunAsync 异常退出时项目被释放但不会派发"已停止")。 + /// + private void SyncWithController() + { + var project = this._ctrl.Project; + if (project is null) + { + this.OnProjectStopped(); + return; + } + + if (!ReferenceEquals(project, this._project)) + { + this.OnProjectStarted(project); + } + } + + /// + /// 轮次完成回调。运行在轮询线程上:只做 diff + 一次非阻塞入队。 + /// + private void OnTurn(long epoch, string entryKey) + { + // 没有订阅者时零开销(这是"随订阅者来去动态开关"的正确做法:一次集合判空)。 + if (Volatile.Read(ref this._pipersCount) == 0) + { + return; + } + + var index = this._index; + if (index is null || index.Epoch != epoch) + { + return; + } + + if (!index.ByKey.TryGetValue(entryKey, out var stream)) + { + return; + } + + var delta = stream.Collector.Diff(); + if (delta is null) + { + return; + } + + if (!this._pending.Writer.TryWrite(new PendingBatch(epoch, entryKey, DateTime.UtcNow, delta))) + { + // 待处理队列满:丢弃这一批,让所有订阅者重新同步(值不会永久丢失,下一次全量会带回)。 + Interlocked.Exchange(ref this._overflow, 1); + } + } + + private async Task PumpAsync(CancellationToken ct) + { + try + { + while (await this._pending.Reader.WaitToReadAsync(ct).ConfigureAwait(false)) + { + while (this._pending.Reader.TryRead(out var batch)) + { + if (Interlocked.Exchange(ref this._overflow, 0) == 1) + { + this.MarkAllResync(); + } + + try + { + this.Publish(batch); + } + catch (Exception ex) + { + this._logger?.LogError(ex, "发送观测帧失败(入口={Entry})", batch.EntryKey); + } + } + } + } + catch (OperationCanceledException) + { + // 正常停机 + } + } + + private async Task HeartbeatAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(this._options.ReconcileInterval); + var nextPing = DateTime.UtcNow + this._options.HeartbeatInterval; + + try + { + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) + { + try + { + this.SyncWithController(); + } + catch (Exception ex) + { + this._logger?.LogError(ex, "观测流对账失败"); + } + + if (DateTime.UtcNow >= nextPing) + { + nextPing = DateTime.UtcNow + this._options.HeartbeatInterval; + this.Broadcast(SseFrames.Ping); + } + } + } + catch (OperationCanceledException) + { + // 正常停机 + } + } + + private void Publish(in PendingBatch batch) + { + var index = this._index; + if (index is null || index.Epoch != batch.Epoch) + { + return; + } + + if (!index.ByKey.TryGetValue(batch.EntryKey, out var stream)) + { + return; + } + + var values = batch.Delta.Values; + // 容器标志为空时传 null,让它从 JSON 里省略(空数组会被序列化成 []) + var containerFlags = batch.Delta.ContainerFlags.Count == 0 ? null : batch.Delta.ContainerFlags; + + if (values.Count == 0) + { + // 只有使能状态变化 + this.PublishLocked(batch.Epoch, stream, batch.SampledAtUtc, full: false, Array.Empty(), containerFlags); + return; + } + + var max = Math.Max(1, this._options.MaxValuesPerFrame); + if (values.Count <= max) + { + this.PublishLocked(batch.Epoch, stream, batch.SampledAtUtc, full: false, values, containerFlags); + return; + } + + for (var offset = 0; offset < values.Count; offset += max) + { + var count = Math.Min(max, values.Count - offset); + var chunk = values.GetRange(offset, count); + // 拆帧时只有第一帧带容器标志。 + this.PublishLocked(batch.Epoch, stream, batch.SampledAtUtc, full: false, chunk, offset == 0 ? containerFlags : null); + } + } + + /// + /// 分配序号、渲染、扇出。序号与入队必须在同一把锁内,否则订阅/重同步路径可能插在中间导致乱序。 + /// + private void PublishLocked( + long epoch, + EntryStream stream, + DateTime sampledAtUtc, + bool full, + IReadOnlyList values, + IReadOnlyList? containerFlags) + { + lock (this._gate) + { + if (this._index?.Epoch != epoch) + { + return; + } + + var frame = SseFrames.Values(new EntryValuesDto( + epoch, + stream.Key, + stream.IncrementSeq(), + sampledAtUtc, + full, + values, + containerFlags)); + + foreach (var subscriber in this._pipers) + { + if (subscriber.ContainsEntry(stream.Key)) + { + subscriber.Enqueue(frame); + } + } + } + } + + /// 构建某入口的全量基线帧(调用方必须持有 )。 + private ReadOnlyMemory BuildFullFrameLocked(ProjectIndex index, EntryStream stream) + { + var values = stream.Collector.GetAll(); + return SseFrames.Values(new EntryValuesDto( + index.Epoch, + stream.Key, + stream.IncrementSeq(), + DateTime.UtcNow, + Full: true, + Values: values, + ContainerFlags: null)); + } + + private void Broadcast(ReadOnlyMemory frame) + { + lock (this._gate) + { + foreach (var subscriber in this._pipers) + { + subscriber.Enqueue(frame); + } + } + } + + private void MarkAllResync() + { + lock (this._gate) + { + foreach (var subscriber in this._pipers) + { + subscriber.MarkResync(); + } + } + } + + private readonly record struct PendingBatch(long Epoch, string EntryKey, DateTime SampledAtUtc, EntryDelta Delta); +} diff --git a/src/StdUnit.Tags.WebStream/Core/TagsStreamJsonContext.cs b/src/StdUnit.Tags.WebStream/Core/TagsStreamJsonContext.cs new file mode 100644 index 0000000..33b18b5 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/TagsStreamJsonContext.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace StdUnit.Tags.WebStream; + +/// +/// 线协议的 JSON 元数据(源生成)。
+/// 所有写出的 JSON 都必须走这里的 JsonTypeInfo<T> 重载, +/// 否则 NativeAOT / 裁剪宿主下会因为缺少运行时类型元数据而失败。 +///
+[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(TreeDto))] +[JsonSerializable(typeof(StateDto))] +[JsonSerializable(typeof(EntryValuesDto))] +[JsonSerializable(typeof(WriteTagValueRequest))] +[JsonSerializable(typeof(RescanTagRequest))] +[JsonSerializable(typeof(CommandResult))] +internal sealed partial class TagsStreamJsonContext : JsonSerializerContext +{ +} diff --git a/src/StdUnit.Tags.WebStream/Core/TagsStreamOptions.cs b/src/StdUnit.Tags.WebStream/Core/TagsStreamOptions.cs new file mode 100644 index 0000000..936798e --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/TagsStreamOptions.cs @@ -0,0 +1,39 @@ +namespace StdUnit.Tags.WebStream; + +/// +/// 只读观测流的配置。 +/// +public sealed class TagsStreamOptions +{ + /// + /// SSE 心跳间隔(发送一行注释,防反代/中间设备把空闲连接掐掉)。默认 15 秒。 + /// + public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// 与 对账的间隔:发现"项目已换/已停但没收到事件"后自行纠正状态。 + /// 默认 1 秒。 + /// + public TimeSpan ReconcileInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// 每个订阅者的发送队列容量(帧数)。队列满时丢弃并标记重同步,绝不反压到轮询线程。默认 64。 + /// + public int PiperQueueCapacity { get; set; } = 64; + + /// + /// 单帧最多包含多少个测点值;超出则拆成连续多帧(仅对增量帧生效,全量基线帧不拆)。默认 2000。 + /// + public int MaxValuesPerFrame { get; set; } = 2000; + + /// + /// 轮询线程 → 发送循环的待处理队列容量。队列满时丢弃该批并让所有订阅者重同步。默认 1024。 + /// + public int PendingQueueCapacity { get; set; } = 1024; + + /// + /// 写命令(改值 / 标记未扫描)等待"该入口的下一轮执行完意图"的时间上限。默认 5 秒。
+ /// 入口被禁用或已停止轮询时意图不会被执行,超时后返回 504(意图仍留在队列里,恢复轮询后会执行)。 + ///
+ public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(5); +} diff --git a/src/StdUnit.Tags.WebStream/Sse/SseFrames.cs b/src/StdUnit.Tags.WebStream/Sse/SseFrames.cs new file mode 100644 index 0000000..03f3882 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Sse/SseFrames.cs @@ -0,0 +1,54 @@ +using System.Buffers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// SSE 帧渲染。序列化只在这里发生一次,之后所有订阅者共用同一份字节。
+/// 帧就是以 UTF-8 字节直接表示的,没有包装类型:渲染结果是 +/// event: <name>\ndata: <json>\n\n。 +///
+internal static class SseFrames +{ + private static readonly byte[] EventPrefix = "event: "u8.ToArray(); + + /// + /// 每个 data: 行的前缀,带前导换行:既负责终结前一行(首行 data 时终结 event 行, + /// 续行时终结上一行 data),也是续行本身的前缀。两处字节完全相同,所以共用一份。 + /// + private static readonly byte[] DataLinePrefix = "\ndata: "u8.ToArray(); + + private static readonly byte[] FrameSuffix = "\n\n"u8.ToArray(); + + /// 告诉 EventSource 重连退避。 + public static ReadOnlyMemory Retry { get; } = "retry: 3000\n\n"u8.ToArray(); + + /// 心跳(注释行,客户端忽略)。 + public static ReadOnlyMemory Ping { get; } = ": ping\n\n"u8.ToArray(); + + public static ReadOnlyMemory State(StateDto state) => + Encode("state", JsonSerializer.SerializeToUtf8Bytes(state, TagsStreamJsonContext.Default.StateDto)); + + public static ReadOnlyMemory Values(EntryValuesDto values) => + Encode("values", JsonSerializer.SerializeToUtf8Bytes(values, TagsStreamJsonContext.Default.EntryValuesDto)); + + /// + /// 渲染一帧。 必须是单行 UTF-8 JSON,本方法不按行拆分。
+ /// JSON 规范要求字符串里的控制字符(含 \r / \n)一律转义成 + /// \r / \n 字面量,而这里用的是不带缩进的 SerializeToUtf8Bytes, + /// 所以 0x0A / 0x0D 不可能出现在序列化结果里。
+ ///
+ private static ReadOnlyMemory Encode(string eventName, byte[] json) + { + using var buffer = new MemoryStream(json.Length + 64); + + buffer.Write(EventPrefix); + buffer.Write(Encoding.UTF8.GetBytes(eventName)); + buffer.Write(DataLinePrefix); + buffer.Write(json); + buffer.Write(FrameSuffix); + return buffer.ToArray(); + } +} diff --git a/src/StdUnit.Tags.WebStream/Sse/SseFramesPiper.cs b/src/StdUnit.Tags.WebStream/Sse/SseFramesPiper.cs new file mode 100644 index 0000000..ba73f3a --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Sse/SseFramesPiper.cs @@ -0,0 +1,93 @@ +using System.Threading.Channels; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 一个 SSE 订阅者:一个有界发送队列 + 一个写循环。
+/// 生产端(发送循环/请求线程)只做 (非阻塞),队列满则丢弃并标记重同步—— +/// 绝不反压到轮询线程或发送循环。 +///
+internal sealed class SseFramesPiper : IDisposable +{ + private readonly ITagsStreamHub _hub; + private readonly Channel> _queue; + private readonly IReadOnlySet? _entries; + private int _needsResync; + private int _disposed; + + public SseFramesPiper(ITagsStreamHub hub, IReadOnlySet? entries, int capacity) + { + this._hub = hub; + this._entries = entries; + this._queue = Channel.CreateBounded>(new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.Wait, // 配合 TryWrite:满时返回 false,而不是静默丢弃 + SingleReader = true, + SingleWriter = false, + }); + } + + /// 该订阅者是否关心指定入口(?entries= 为空表示全部)。 + public bool ContainsEntry(string entry) => this._entries is null || this._entries.Contains(entry); + + /// 入队一帧(已渲染的 UTF-8 SSE 字节);队列满就丢弃这一帧并标记需要重同步。 + public void Enqueue(ReadOnlyMemory frame) + { + if (!this._queue.Writer.TryWrite(frame)) + { + this.MarkResync(); + } + } + + public void MarkResync() => Interlocked.Exchange(ref this._needsResync, 1); + + /// + /// 写循环:把队列里的帧写进响应体。
+ ///
+ public async Task PipeFramesToStreamAsync(Stream body, CancellationToken ct) + { + await WriteFrameAsync(body, SseFrames.Retry, ct).ConfigureAwait(false); + + while (!ct.IsCancellationRequested) + { + // 需要重新同步? + if (Interlocked.Exchange(ref this._needsResync, 0) == 1) + { + foreach (var frame in this._hub.BuildSyncFrames(this)) + { + await WriteFrameAsync(body, frame, ct).ConfigureAwait(false); + } + continue; + } + + if (!await this._queue.Reader.WaitToReadAsync(ct).ConfigureAwait(false)) + { + break; + } + + while (this._queue.Reader.TryRead(out var frame)) + { + await WriteFrameAsync(body, frame, ct).ConfigureAwait(false); + } + } + } + + private static async ValueTask WriteFrameAsync(Stream body, ReadOnlyMemory bytes, CancellationToken ct) + { + await body.WriteAsync(bytes, ct).ConfigureAwait(false); + await body.FlushAsync(ct).ConfigureAwait(false); + } + + + public void Dispose() + { + if (Interlocked.Exchange(ref this._disposed, 1) != 0) + { + return; + } + + this._hub.RemovePiper(this); + this._queue.Writer.TryComplete(); + } + +} diff --git a/src/StdUnit.Tags.WebStream/Sse/StreamDto.cs b/src/StdUnit.Tags.WebStream/Sse/StreamDto.cs new file mode 100644 index 0000000..21dfe61 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Sse/StreamDto.cs @@ -0,0 +1,65 @@ +namespace StdUnit.Tags.WebStream; + +/// +/// 一个测点的值。 +/// +/// 与 一致。 +/// 冗余的 类型名,前端不必回查树。 +/// +/// 值的文本形式(Convert.ToString(value, InvariantCulture))。
+/// 用字符串而不是 JSON 数字:AOT 下不能靠运行时类型元数据序列化 object, +/// 且 JS 的 number 只有 double,INT64/UINT64 会静默丢精度。 +/// +/// 对应 (UTC)——该测点最近一次被读/写的时刻。 +/// 是否有未刷写的改动。 +/// 本轮是否已被扫描。 +public sealed record TagValueDto( + string Path, + string? TagKind, + string? Raw, + DateTime TimestampUtc, + bool IsDirty, + bool IsScanned); + +/// +/// 容器的运行时标志(目前只有使能状态)。数量远小于测点数,所以单独一个数组。
+/// 只有 / 有这些标志( 没有 IsEnabled)。 +///
+/// 与 一致。 +/// 自动轮询是否使能。 +public sealed record ContainerFlagDto(string Path, bool IsEnabled); + +/// +/// values 事件:一个入口的一次轮次的结果。
+/// 一帧内的值来自该入口的同一次轮次;不同入口的帧互不同步(各入口并行、各自有扫描周期)。 +///
+/// 代际号;与客户端当前 epoch 不一致时整帧丢弃。 +/// 入口键(entry.TagName())。 +/// 该入口内单调递增的序号;用于发现缺口(缺口 ⇒ 请求重新订阅或等待下一次 Full)。 +/// 本次轮次完成时刻(UTC)——"这批数据是什么时候采的"。 +/// +/// true 表示这是该入口**全部测点**的当前值(订阅基线 / 丢帧后的重同步):客户端应整体替换该入口的值表;
+/// false 表示增量:只更新 里出现的测点。 +/// +/// 值列表(增量模式下只含发生变化的测点)。 +/// 该入口内容器的使能状态变化(无变化时为 null)。 +public sealed record EntryValuesDto( + long Epoch, + string Entry, + long Seq, + DateTime SampledAtUtc, + bool Full, + IReadOnlyList Values, + IReadOnlyList? ContainerFlags); + +/// +/// state 事件:项目生命周期状态。
+/// 新订阅者在连接建立时一定会先收到一帧当前状态(等价于 Rx 的 Replay(1))。 +///
+/// 代际号:每次启动递增,停止事件里保持即停止前的值。 +/// 项目是否在运行。 +/// 项目根目录(停止后保留最后一次的值)。 +public sealed record StateDto( + long Epoch, + bool IsStarted, + string? ProjectRoot); diff --git a/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs b/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs new file mode 100644 index 0000000..53ecf07 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 值流端点(GET /stdunit.tags/stream?entries=a,b):text/event-stream,entries 省略表示全部入口。 +/// +internal static class StreamEndpoint +{ + public static async Task HandleAsync(HttpContext context) + { + var hub = context.RequestServices.GetRequiredService(); + + // 反代/服务器不能缓冲:否则"实时推送"会变成"攒一波再发"。 + context.Features.Get()?.DisableBuffering(); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/event-stream; charset=utf-8"; + context.Response.Headers.CacheControl = "no-cache, no-transform"; + context.Response.Headers["X-Accel-Buffering"] = "no"; // nginx + + var entries = ParseEntries(context.Request.Query["entries"]); + using var piper = hub.Subscribe(entries); + + try + { + await piper.PipeFramesToStreamAsync(context.Response.Body, context.RequestAborted); + } + catch (OperationCanceledException) + { + // 客户端断开/请求中止 + } + } + + /// + /// 解析 ?entries=a,b:逗号分隔、去空白、去重、忽略空段。
+ /// 返回 null 表示"订阅全部入口"(参数缺失 / 全是空白 / 全是空段)。
+ /// 这是 HTTP 线上的写法,所以留在端点这一层,hub 只收 。 + ///
+ internal static HashSet? ParseEntries(string? entries) + { + if (string.IsNullOrWhiteSpace(entries)) + { + return null; + } + + var result = new HashSet(StringComparer.Ordinal); + foreach (var part in entries.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.Length > 0) + { + result.Add(trimmed); + } + } + + return result.Count == 0 ? null : result; + } +} diff --git a/src/StdUnit.Tags.WebStream/StdUnit.Tags.WebStream.csproj b/src/StdUnit.Tags.WebStream/StdUnit.Tags.WebStream.csproj new file mode 100644 index 0000000..d688b67 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/StdUnit.Tags.WebStream.csproj @@ -0,0 +1,25 @@ + + + + 测点项目的只读观测流:结构快照(HTTP)+ 按入口、按轮次的值推送(SSE) + + net8.0 + true + + + + + + + + + + + + + + + + + diff --git a/src/StdUnit.Tags.WebStream/Tree/TreeDto.cs b/src/StdUnit.Tags.WebStream/Tree/TreeDto.cs new file mode 100644 index 0000000..585ff3d --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Tree/TreeDto.cs @@ -0,0 +1,124 @@ +using System.Text.Json.Serialization; + +namespace StdUnit.Tags.WebStream; + +/// +/// 测点树的一个节点(只有结构,没有值)。
+/// 值走 /stdunit.tags/stream 的 values 事件,本结构在一个 内是静态的。
+///
+/// 这是一个封闭的多态类型:线上的判别字段是 kind(grp / cbnt / tag), +/// 每种节点只带自己那几个字段,而不是把所有字段拍扁成一大堆可空列。 +///
+/// +/// 相对项目根组的路径,/ 分隔(根为空串),语法与 的 path 一致。 +/// +/// 父节点路径;根为 null。 +/// 根为 0。 +/// 节点名称。 +/// 解析到的通道名(可能来自上层继承)。 +/// 冒泡后的有效访问模式(RW / RO / R1W / WO)。 +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(TagGrpNodeDto), "grp")] +[JsonDerivedType(typeof(TagCbntNodeDto), "cbnt")] +[JsonDerivedType(typeof(TagLeafNodeDto), "tag")] +public abstract record TagNodeDto( + string Path, + // 根的 Parent 是 null:**必须写出来**(其余可空字段省略),否则前端分不清"根"与"字段缺失"。 + [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] string? Parent, + int Depth, + string Name, + string? ChannelName, + string? AccessMode); + +/// +/// 测点组节点()。比基类多了入口标识、使能与扫描间隔。 +/// +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// +/// 入口测点组非 null:把它作为 ?entries= 与 values.entry 的键(等于 entry.TagName())。 +/// +/// 配置上是否为入口(嵌套入口不生效,只有最外层才有 )。 +/// 自动轮询是否使能(运行时值)。 +/// 冒泡后的有效扫描间隔(毫秒);null 表示未配置。 +public sealed record TagGrpNodeDto( + string Path, + string? Parent, + int Depth, + string Name, + string? ChannelName, + string? AccessMode, + string? EntryKey, + bool IsEntry, + bool IsEnabled, + int? ScanIntervalMs) + : TagNodeDto(Path, Parent, Depth, Name, ChannelName, AccessMode); + +/// +/// 组合测点节点()。其子节点只可能是 。 +/// +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// 自动轮询是否使能(运行时值)。 +/// 起始地址。 +public sealed record TagCbntNodeDto( + string Path, + string? Parent, + int Depth, + string Name, + string? ChannelName, + string? AccessMode, + bool IsEnabled, + string? StartAddress) + : TagNodeDto(Path, Parent, Depth, Name, ChannelName, AccessMode); + +/// +/// 叶测点节点(,含 )。 +/// +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// 见 。 +/// 中的类型名。 +/// 规范化后的地址。 +/// 占用字节数。 +/// 是否只读。 +public sealed record TagLeafNodeDto( + string Path, + string? Parent, + int Depth, + string Name, + string? ChannelName, + string? AccessMode, + string? TagKind, + string? Address, + int? TagSize, + bool IsReadOnly) + : TagNodeDto(Path, Parent, Depth, Name, ChannelName, AccessMode); + +/// +/// 测点树结构(扁平节点列表,前端按 自行组树)。 +/// +/// 代际号:每次项目启动递增。客户端发现 epoch 变化应整树替换。 +/// 取树时项目是否在运行;false 时 为空。 +/// 根节点名称( 为空串的那个节点)。 +/// +/// 扁平节点列表(根在最前,其余按树的深度优先顺序)。
+/// 元素是 / / 之一, +/// 线上对应 kind = grp / cbnt / tag。 +/// +public sealed record TreeDto( + long Epoch, + bool IsStarted, + string RootName, + IReadOnlyList Nodes); diff --git a/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs b/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs new file mode 100644 index 0000000..ddef8f2 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 结构树端点(GET /stdunit.tags/tree):一次性返回扁平结构树。 +/// +internal static class TreeEndpoint +{ + public static async Task HandleAsync(HttpContext context) + { + var hub = context.RequestServices.GetRequiredService(); + + // 自己序列化:不走 MVC / 无参序列化,AOT/裁剪下没有反射路径。 + var bytes = JsonSerializer.SerializeToUtf8Bytes(hub.GetTree(), TagsStreamJsonContext.Default.TreeDto); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json; charset=utf-8"; + context.Response.ContentLength = bytes.Length; + await context.Response.Body.WriteAsync(bytes, context.RequestAborted); + } +} diff --git a/src/StdUnit.Tags.WebStream/Ui/TagsStreamUi.cs b/src/StdUnit.Tags.WebStream/Ui/TagsStreamUi.cs new file mode 100644 index 0000000..2c8df73 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Ui/TagsStreamUi.cs @@ -0,0 +1,93 @@ +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Http; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 内置监控页:把 Ui/index.html 作为嵌入资源取出,注入 <base href> 后整体返回。
+/// 页面里的相对 URL(tree / stream / value / rescan)都相对这个基路径解析, +/// 所以端点挂在什么前缀下、被套进几层路由组,前端都不用改。
+/// 不用静态文件中间件、不依赖宿主配置,也不引入任何前端工程。 +///
+internal static class TagsStreamUi +{ + private const string ResourceName = "StdUnit.Tags.WebStream.Ui.index.html"; + private const string BasePlaceholder = "%%BASE%%"; + private const string TreePlaceholder = "%%TREE_URL%%"; + private const string StreamPlaceholder = "%%STREAM_URL%%"; + private const string WritePlaceholder = "%%WRITE_URL%%"; + private const string RescanPlaceholder = "%%RESCAN_URL%%"; + + /// 嵌入资源原文(含全部占位符),只读一次。 + private static readonly string Template = LoadTemplate(); + + /// + /// 渲染监控页:只填相对路径(来自 的 *Path), + /// 不拼前缀——前缀/挂载点由请求决定,见 。
+ /// 页面里 JS 再用 new URL(相对路径, document.baseURI) 算出真正的地址。 + ///
+ public static string BuildPage( + string treePath, + string streamPath, + string writeValuePath, + string rescanPath) + { + return Template + .Replace(TreePlaceholder, ToRelative(treePath), StringComparison.Ordinal) + .Replace(StreamPlaceholder, ToRelative(streamPath), StringComparison.Ordinal) + .Replace(WritePlaceholder, ToRelative(writeValuePath), StringComparison.Ordinal) + .Replace(RescanPlaceholder, ToRelative(rescanPath), StringComparison.Ordinal); + } + + /// + /// 路由 pattern 是 /tree 这种以 / 开头的形式;页面的相对 URL 不能带它—— + /// 否则 new URL('/tree', base) 会连同 base 里的前缀一起丢掉,直接落到站点根上。 + /// + private static string ToRelative(string path) => path.TrimStart('/'); + + /// + /// 把渲染好的页面写进响应,并按本次请求的路径填 <base href>。
+ /// 基路径 = + 去掉最后一段 + /// (/stdunit.tags/ui → /stdunit.tags/),而不是映射时传进来的前缀——只有请求路径才等于真正生效的基路径 + /// (嵌套路由组 / UsePathBase 都在里面)。 + ///
+ public static async Task WriteAsync(HttpContext context, string page) + { + var html = page.Replace(BasePlaceholder, GetBaseHref(context), StringComparison.Ordinal); + var bytes = Encoding.UTF8.GetBytes(html); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength = bytes.Length; + await context.Response.Body.WriteAsync(bytes, context.RequestAborted); + } + + /// + /// 页面自己的 URL 去掉最后一段就是同级端点所在的目录:/stdunit.tags/ui → /stdunit.tags/。
+ /// 末尾多余的 / 先去掉,免得把 /ui/ 当成目录。 + ///
+ private static string GetBaseHref(HttpContext context) + { + var path = context.Request.PathBase.Add(context.Request.Path).Value ?? "/"; + path = path.TrimEnd('/'); + + var slash = path.LastIndexOf('/'); + var basePath = slash < 0 ? "/" : path[..(slash + 1)]; + + // 合法 URL 里不会出现需要转义的字符,但它是属性值:万一路径段里解码出 '&' 之类也不至于破坏页面。 + return HtmlEncoder.Default.Encode(basePath); + } + + private static string LoadTemplate() + { + using var stream = typeof(TagsStreamUi).Assembly.GetManifestResourceStream(ResourceName); + if (stream is null) + { + throw new InvalidOperationException($"嵌入资源缺失:{ResourceName}"); + } + + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } +} diff --git a/src/StdUnit.Tags.WebStream/Ui/index.html b/src/StdUnit.Tags.WebStream/Ui/index.html new file mode 100644 index 0000000..fe3e049 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Ui/index.html @@ -0,0 +1,422 @@ + + + + + + + +测点监控 · StdUnit.Tags + + + +
+

测点监控

+ 连接中… + + + +
+

加载中…

+ + + diff --git a/src/StdUnit.Tags.WebStream/_Extensions.cs b/src/StdUnit.Tags.WebStream/_Extensions.cs new file mode 100644 index 0000000..556fa4a --- /dev/null +++ b/src/StdUnit.Tags.WebStream/_Extensions.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using StdUnit.Tags.WebStream.Internal; + + +namespace StdUnit.Tags.WebStream; + +/// +/// 只读观测流的入口: 做 DI 注册, 映射端点。 +/// +public static class TagsStreamServiceCollection +{ + /// + /// 注册只读观测流。前提: 已由 AddTagsProjectServices 注册。
+ ///
+ /// 注册三样东西:、(Singleton,程序集内唯一服务面), + /// 以及**同一实例**的 (随宿主启停)。
+ ///
+ /// 启动顺序:hub 以 注册,随宿主一起启动。 + /// 它需要在项目开始轮询之前拿到 才能挂上"轮次完成"回调, + /// 所以请让轮询在宿主启动之后才开始(例如在请求里/更晚注册的 hosted service 里 StartPollAsync)。 + /// 若 hub 启动时项目已在运行,结构树仍可观测,但值观测会缺失并记一条 Warning。 + ///
+ /// 服务集合 + /// 配置回调 + /// + public static IServiceCollection AddTagsStream(this IServiceCollection services, Action? configure = null) + { + var options = new TagsStreamOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(); + + // 注意(两个坑): + // ① 不能写 AddHostedService()——它是另开一个 IHostedService → TagsStreamHub 的 Singleton + // 描述符(TryAddEnumerable(Singleton)),DI 会再造一个实例,宿主启动的 hub + // 和端点解析到的 hub 不是同一份数据; + // ② 工厂的返回类型必须写成具体类型(TagsStreamHub),不能写成 IHostedService:AddHostedService 用 + // TryAddEnumerable 去重,会拿委托返回类型当实现类型,返回 IHostedService 就"与自身无法区分"直接抛。 + services.AddHostedService(sp => (TagsStreamHub)sp.GetRequiredService()); + return services; + } + + + /// + /// 映射端点(全部挂在 下,相对路径固定):
+ /// - GET {prefix}/tree:结构树(一次性 JSON,按 epoch 缓存);
+ /// - GET {prefix}/stream?entries=a,b:值流(text/event-stream),entries 省略表示全部入口;
+ /// - GET {prefix}/ui:内置的极简监控页(单文件 HTML + 内嵌 JS,无需 npm 工程);
+ /// - POST {prefix}/value:改测点值;
+ /// - POST {prefix}/rescan:标记未扫描。
+ ///
+ /// 外部只可改前缀:各端点的相对路径(/tree、/stream …)是线协议的一部分, + /// 监控页注入的也是这些相对路径,真正的 URL 由页面里的 <base href> 解析; + /// 而 base 是按请求路径生成的,因此把本组挂到别的路由组下 + /// (app.MapGroup("/monitor").MapTagsStream())或配了 UsePathBase 都能正常工作。 + ///
+ /// 鉴权:这些端点不内置任何鉴权——两个写端点会把任意测点改成任意值。 + /// 返回值是路由组,映射完直接对整组加策略即可: + /// + /// app.MapTagsStream().RequireAuthorization(); // 整组走默认策略 + /// app.MapTagsStream("/internal/tags").RequireAuthorization("ops"); // 自定义前缀 + 命名策略 + /// + /// 只想让读端点匿名时,可用反向代理挡住 {prefix}/value 与 {prefix}/rescan。 + ///
+ /// 端点构建器 + /// + /// 路由前缀,默认 ; + /// 传 "" 或 "/" 表示不挂前缀(端点直接落在根上)。 + /// + /// 路由组:可继续 RequireAuthorization() / WithTags() 等整组配置。 + public static RouteGroupBuilder MapTagsStream( + this IEndpointRouteBuilder endpoints, + string prefix = EndpointConstants.DefaultPrefix) + { + var group = endpoints.MapGroup(prefix.Length == 0 ? "/" : prefix); + + // 用 RequestDelegate 重载而不是 Delegate:后者要反射分析委托参数,NativeAOT 下不可用(IL2026/IL3050)。 + group.MapGet(EndpointConstants.TreePath, TreeEndpoint.HandleAsync); + group.MapGet(EndpointConstants.StreamPath, StreamEndpoint.HandleAsync); + group.MapPost(EndpointConstants.WriteValuePath, CommandsEndpoint.WriteValueAsync); + group.MapPost(EndpointConstants.RescanPath, CommandsEndpoint.RescanAsync); + + // 页面只注入**相对路径**(与 C# 常量同源),基路径在请求时按实际路径填入,所以这里不需要前缀。 + var page = TagsStreamUi.BuildPage( + EndpointConstants.TreePath, + EndpointConstants.StreamPath, + EndpointConstants.WriteValuePath, + EndpointConstants.RescanPath); + group.MapGet(EndpointConstants.UiPath, context => TagsStreamUi.WriteAsync(context, page)); + + return group; + } +} diff --git a/src/StdUnit.Tags.WebStream/paket.references b/src/StdUnit.Tags.WebStream/paket.references new file mode 100644 index 0000000..92f581b --- /dev/null +++ b/src/StdUnit.Tags.WebStream/paket.references @@ -0,0 +1 @@ +# 本包没有 paket 依赖:ASP.NET Core 通过 提供。 diff --git a/src/publish-packages.ps1 b/src/publish-packages.ps1 index fa5291e..f4492c9 100644 --- a/src/publish-packages.ps1 +++ b/src/publish-packages.ps1 @@ -77,7 +77,8 @@ $projects = @( "StdUnit.Tags.RxExtensions", "StdUnit.Tags.R3Extensions", - "StdUnit.Tags.BlazorLib.Core" + "StdUnit.Tags.BlazorLib.Core", + "StdUnit.Tags.WebStream" ) $baseDir=Split-Path $script:MyInvocation.MyCommand.Path diff --git a/tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj b/tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj new file mode 100644 index 0000000..1c76e2a --- /dev/null +++ b/tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + false + + true + true + + + + + + + + diff --git a/tests/AotSmokeTest.WebStream/Program.cs b/tests/AotSmokeTest.WebStream/Program.cs new file mode 100644 index 0000000..4da9148 --- /dev/null +++ b/tests/AotSmokeTest.WebStream/Program.cs @@ -0,0 +1,276 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StdUnit.Tags; +using StdUnit.Tags.SimpleFiles; +using StdUnit.Tags.WebStream; + +// ───────────────────────────────────────────────────────────────────────────── +// NativeAOT 冒烟测试(WebStream):本程序以 PublishAot=true 发布成原生可执行文件后运行。 +// 覆盖三条"只有真 AOT 才能证明"的路径: +// ① 结构树的**多态 JSON 序列化**([JsonDerivedType] + 源生成)能产出 kind 判别字段; +// ② 值流的 SSE 端点真的能连通:状态 → 全量基线 → 改值 → 增量; +// ③ 整条链路不依赖反射(若走了反射,publish 阶段就会报 IL2026/IL3050,或运行期抛)。 +// ───────────────────────────────────────────────────────────────────────────── + +// 以 PublishAot=true 构建时,SDK 会把该开关写进 runtimeconfig.json,因此连"IL 构建 + JIT 运行"也是 false, +// 与真正的 AOT 宿主行为一致。 +var dynamicCodeSupported = RuntimeFeature.IsDynamicCodeSupported; + +var failures = new List(); +var tempDir = Path.Combine(Path.GetTempPath(), "AotSmokeTest.WebStream", Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(tempDir); + +WebApplication? app = null; +ITagsProjectCtrl? ctrl = null; + +try +{ + await File.WriteAllTextAsync(Path.Combine(tempDir, "int.txt"), "1"); + + var builder = WebApplication.CreateSlimBuilder(args); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddTagsProjectServices(b => + { + b.AddSimpleFilesChannel(); + b.AddSimpleFilesDirectTagBuilder(); + }); + builder.Services.AddTagsStream(o => + { + o.ReconcileInterval = TimeSpan.FromMilliseconds(200); + o.HeartbeatInterval = TimeSpan.FromSeconds(30); + }); + + app = builder.Build(); + app.MapTagsStream(); + await app.StartAsync(); + + var baseUrl = app.Services.GetRequiredService() + .Features.Get()!.Addresses.First(); + Console.WriteLine($"[INFO] listening on {baseUrl}, DynamicCodeSupported={dynamicCodeSupported}"); + + ctrl = app.Services.GetRequiredService(); + _ = Task.Run(() => ctrl.StartPollAsync(tempDir, BuildProjectXml(tempDir), (_, _, _) => Task.CompletedTask)); + + using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + // ① 结构树:多态序列化。等轮询真正起来(isStarted:true)再取,避免与后台启动竞态。 + string tree = string.Empty; + await WaitUntilAsync( + async () => + { + tree = await http.GetStringAsync($"{baseUrl}/stdunit.tags/tree", cts.Token); + return tree.Contains("\"isStarted\":true", StringComparison.Ordinal); + }, + "项目启动并取到结构树", + cts.Token); + + Require(tree.Contains("\"kind\":\"grp\"", StringComparison.Ordinal), "结构树应包含 kind=grp 的多态节点"); + Require(tree.Contains("\"kind\":\"tag\"", StringComparison.Ordinal), "结构树应包含 kind=tag 的多态节点"); + Require(tree.Contains("\"entryKey\":\"g\"", StringComparison.Ordinal), "入口组应带 entryKey"); + Require(tree.Contains("\"tagKind\":\"INT32\"", StringComparison.Ordinal), "叶测点应带 tagKind"); + Require(!tree.Contains("\"tagKind\":null", StringComparison.Ordinal), "多态拆分后不应再出现 tagKind=null 的扁平字段"); + Console.WriteLine("[+] 结构树多态序列化正常"); + + // ①b 内置监控页:嵌入资源读取 + 端点路径注入(同样是"不依赖反射"的路径)。 + using (var ui = await http.GetAsync($"{baseUrl}/stdunit.tags/ui", cts.Token)) + { + Require(ui.IsSuccessStatusCode, $"监控页应返回 2xx,实际 {(int)ui.StatusCode}"); + Require( + ui.Content.Headers.ContentType?.MediaType == "text/html", + "监控页 Content-Type 应为 text/html"); + + var html = await ui.Content.ReadAsStringAsync(cts.Token); + Require(html.Contains("new EventSource", StringComparison.Ordinal), "监控页应内嵌 EventSource 脚本"); + Require(html.Contains("", StringComparison.Ordinal), "监控页应按请求路径注入 "); + Require(html.Contains("new URL('stream', document.baseURI)", StringComparison.Ordinal), "值流端点应注入相对路径并由页面解析"); + Require(html.Contains("new URL('tree', document.baseURI)", StringComparison.Ordinal), "结构树端点应注入相对路径并由页面解析"); + Require(!html.Contains("%%", StringComparison.Ordinal), "监控页不应残留未替换的占位符"); + Console.WriteLine("[+] 监控页可访问且基路径已注入"); + } + + // ② SSE:订阅 → 全量基线 → 改值 → 增量。 + using var response = await http.GetAsync( + $"{baseUrl}/stdunit.tags/stream", + HttpCompletionOption.ResponseHeadersRead, + cts.Token); + Require(response.IsSuccessStatusCode, $"值流应返回 2xx,实际 {(int)response.StatusCode}"); + Require( + response.Content.Headers.ContentType?.MediaType == "text/event-stream", + "值流 Content-Type 应为 text/event-stream"); + + await using var body = await response.Content.ReadAsStreamAsync(cts.Token); + using var reader = new StreamReader(body, Encoding.UTF8); + + var baseline = await ReadUntilAsync(reader, "\"full\":true", cts.Token); + Require(baseline.Contains("event: values", StringComparison.Ordinal), "基线帧应是 values 事件"); + Require(baseline.Contains("\"raw\":\"1\"", StringComparison.Ordinal), "基线帧应带上当前值 1"); + Console.WriteLine("[+] 收到全量基线"); + + await File.WriteAllTextAsync(Path.Combine(tempDir, "int.txt"), "2", cts.Token); + var delta = await ReadUntilAsync(reader, "\"raw\":\"2\"", cts.Token); + Require(delta.Contains("\"full\":false", StringComparison.Ordinal), "改值后应是增量帧(full=false)"); + Console.WriteLine("[+] 收到增量帧"); + + // ②b 写命令:改值(请求体反序列化走源生成;变更在轮询线程上执行,随后由该轮刷到底层) + using (var write = await http.PostAsync( + $"{baseUrl}/stdunit.tags/value", + new StringContent("""{"path":"g/int-v","raw":"7"}""", Encoding.UTF8, "application/json"), + cts.Token)) + { + var writeBody = await write.Content.ReadAsStringAsync(cts.Token); + Require(write.IsSuccessStatusCode, $"改值应返回 2xx,实际 {(int)write.StatusCode}:{writeBody}"); + Require(writeBody.Contains("\"ok\":true", StringComparison.Ordinal), $"改值应返回 ok=true,实际 {writeBody}"); + } + + // 不去直接读文件:SimpleFiles 驱动正在写它,Windows 上会撞共享冲突。 + // 从值流里等新值出现即可证明"命令 → 轮询线程 → 刷写 → 读回"这条链走通了。 + var written = await ReadUntilAsync(reader, "\"raw\":\"7\"", cts.Token); + Require(written.Contains("event: values", StringComparison.Ordinal), "改值后应收到 values 帧"); + Console.WriteLine("[+] 改值已生效"); + + // ②c 标记未扫描 + using (var rescan = await http.PostAsync( + $"{baseUrl}/stdunit.tags/rescan", + new StringContent("""{"path":"g/int-v"}""", Encoding.UTF8, "application/json"), + cts.Token)) + { + var rescanBody = await rescan.Content.ReadAsStringAsync(cts.Token); + Require(rescan.IsSuccessStatusCode, $"标记未扫描应返回 2xx,实际 {(int)rescan.StatusCode}:{rescanBody}"); + } + + Console.WriteLine("[+] 标记未扫描已执行"); + + await ctrl.StopAsync(); + ctrl = null; + + Console.WriteLine($"[INFO] 全部通过(DynamicCodeSupported={dynamicCodeSupported})"); +} +catch (Exception ex) +{ + failures.Add($"{ex.GetType().Name}: {ex.Message}"); + Console.Error.WriteLine($"[!] 失败:{ex}"); +} +finally +{ + if (ctrl is not null) + { + try + { + await ctrl.StopAsync(); + } + catch + { + // 清理失败不影响结论 + } + } + + if (app is not null) + { + try + { + await app.StopAsync(); + await app.DisposeAsync(); + } + catch + { + // 清理失败不影响结论 + } + } + + try + { + Directory.Delete(tempDir, recursive: true); + } + catch + { + // 临时目录残留不影响结论 + } +} + +if (failures.Count > 0) +{ + Console.Error.WriteLine($"WebStream AOT 冒烟测试失败(DynamicCodeSupported={dynamicCodeSupported},{failures.Count} 项):"); + foreach (var failure in failures) + { + Console.Error.WriteLine(" - " + failure); + } + + return 1; +} + +Console.WriteLine($"WebStream AOT 冒烟测试通过(DynamicCodeSupported={dynamicCodeSupported})。"); +return 0; + +void Require(bool condition, string message) +{ + if (!condition) + { + failures.Add(message); + throw new InvalidOperationException(message); + } +} + +static async Task WaitUntilAsync(Func> probe, string what, CancellationToken ct) +{ + var deadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + if (await probe()) + { + return; + } + + await Task.Delay(50, ct); + } + + throw new TimeoutException($"等待超时:{what}"); +} + +/// 一直读 SSE 的文本行,直到累积内容里出现 。 +static async Task ReadUntilAsync(StreamReader reader, string needle, CancellationToken ct) +{ + var buffer = new StringBuilder(); + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (line is null) + { + throw new IOException($"连接在收到 '{needle}' 之前就结束了。已收到:{buffer}"); + } + + buffer.AppendLine(line); + if (buffer.ToString().Contains(needle, StringComparison.Ordinal)) + { + return buffer.ToString(); + } + } + + throw new TimeoutException($"等待超时:{needle}"); +} + +static XElement BuildProjectXml(string baseDir) + => new("root", + new XElement("Channel", + new XAttribute("name", "sf-1"), + new XAttribute("driver", "SimpleFiles"), + new XElement("BaseDir", baseDir)), + new XElement("TagGrp", + new XAttribute("name", "g"), + new XAttribute("isEntry", "true"), + new XAttribute("isEnabled", "true"), + new XAttribute("channel", "sf-1"), + new XAttribute("scanInterval", "100"), + new XElement("Tag", + new XAttribute("name", "int-v"), + new XAttribute("address", "int.txt"), + new XAttribute("type", "INT32")))); diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj b/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj index abec015..026a33d 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj @@ -15,7 +15,7 @@ 另外:不要在 StdUnit.Tags.Tests 里用 #if NETFRAMEWORK 排除整个文件来回避 net8-only 依赖 —— 那是编译期开关,解决不了"引用 net8-only 项目"的问题。 - 现状:暂无测试,作为占位预留。首个用例预期来自 ASP.NET Core / Blazor 方向。 + 现状:承载 StdUnit.Tags.WebStream(ASP.NET Core / SSE 只读观测流)的测试。 --> @@ -27,10 +27,17 @@ - + + + + + + + + + + diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/EntryValuesCollectorTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/EntryValuesCollectorTests.cs new file mode 100644 index 0000000..e69ed98 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/EntryValuesCollectorTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebStream.Internal; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// :只报告真正变化的点、容器标志变化、全量取值。 +/// +public class EntryValuesCollectorTests : IDisposable +{ + private readonly ServiceProvider _root; + private readonly IServiceScope _scope; + + public EntryValuesCollectorTests() + { + this._root = StreamTestContext.CreateServiceProvider(); + this._scope = this._root.CreateScope(); + } + + public void Dispose() + { + this._scope.Dispose(); + this._root.Dispose(); + } + + private ITagsProject CreateProject() => + this._scope.ServiceProvider.GetRequiredService().Create( + StreamTestContext.ProjectRoot, + new XElement("Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", + StreamTestContext.Tag("a"), + StreamTestContext.Group("sub", + StreamTestContext.Tag("b", type: "BIT", address: "2"))))); + + [Fact] + public void Diff_ReportsOnlyChangedValues_AndIsIdempotent() + { + using var project = this.CreateProject(); + var index = ProjectIndex.Build(1, project); + var collector = Assert.Single(index.Entries).Collector; + + // 构建时已按当前值预填:没有任何变化。 + Assert.Null(collector.Diff()); + + StreamTestContext.TagAt(project, "g1/a").Value = (short)42; + + var delta = collector.Diff(); + var value = Assert.Single(delta!.Values!); + Assert.Equal("g1/a", value.Path); + Assert.Equal("INT16", value.TagKind); + Assert.Equal("42", value.Raw); + Assert.Empty(delta.ContainerFlags); + + // 同样的状态再算一次:没有变化。 + Assert.Null(collector.Diff()); + } + + [Fact] + public void Diff_ReportsDirtyAndScannedChanges() + { + using var project = this.CreateProject(); + var index = ProjectIndex.Build(1, project); + var collector = Assert.Single(index.Entries).Collector; + var tag = StreamTestContext.TagAt(project, "g1/sub/b"); + + tag.IsDirty = true; + var delta = collector.Diff(); + var value = Assert.Single(delta!.Values!); + Assert.Equal("g1/sub/b", value.Path); + Assert.True(value.IsDirty); + + tag.IsScanned = true; + var delta2 = collector.Diff(); + Assert.True(Assert.Single(delta2!.Values!).IsScanned); + } + + [Fact] + public void Diff_ReportsContainerEnabledChanges() + { + using var project = this.CreateProject(); + var index = ProjectIndex.Build(1, project); + var collector = Assert.Single(index.Entries).Collector; + var sub = project.Tags.Descendant("g1/sub").AsTagGrp(); + + sub.IsEnabled = false; + + var delta = collector.Diff(); + Assert.Empty(delta!.Values); + var flag = Assert.Single(delta.ContainerFlags); + Assert.Equal("g1/sub", flag.Path); + Assert.False(flag.IsEnabled); + + Assert.Null(collector.Diff()); + } + + [Fact] + public void GetAll_ReturnsEveryLeafWithCurrentValue() + { + using var project = this.CreateProject(); + var index = ProjectIndex.Build(1, project); + var collector = Assert.Single(index.Entries).Collector; + + StreamTestContext.TagAt(project, "g1/a").Value = (short)7; + + var all = collector.GetAll(); + Assert.Equal( + new[] { "g1/a", "g1/sub/b" }, + all.Select(v => v.Path).OrderBy(p => p, StringComparer.Ordinal).ToArray()); + Assert.Equal("7", Assert.Single(all, v => v.Path == "g1/a").Raw); + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/ProjectIndexTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/ProjectIndexTests.cs new file mode 100644 index 0000000..5dd1fd2 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/ProjectIndexTests.cs @@ -0,0 +1,166 @@ +using System; +using System.Text.Json; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebStream.Internal; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// :结构树(扁平 + Parent)、入口索引、路径规则。 +/// +public class ProjectIndexTests : IDisposable +{ + private readonly ServiceProvider _root; + private readonly IServiceScope _scope; + + public ProjectIndexTests() + { + this._root = StreamTestContext.CreateServiceProvider(); + this._scope = this._root.CreateScope(); + } + + public void Dispose() + { + this._scope.Dispose(); + this._root.Dispose(); + } + + private ITagsProject CreateProject() => + this._scope.ServiceProvider.GetRequiredService().Create( + StreamTestContext.ProjectRoot, + new XElement("Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", + StreamTestContext.Tag("a"), + StreamTestContext.Group("sub", false, + StreamTestContext.Tag("b", type: "BIT", access: "RO", address: "2"))), + // 非入口子树:出现在树里,但没有值(不会被任何 runner 轮询) + StreamTestContext.Group("plain"))); + + [Fact] + public void Build_ProducesFlatTreeWithExpectedPathsAndMetadata() + { + using var project = this.CreateProject(); + + var index = ProjectIndex.Build(epoch: 7, project); + + Assert.Equal(7, index.Epoch); + Assert.True(index.Tree.IsStarted); + + var nodes = index.Tree.Nodes; + Assert.Equal(project.Tags.TagName(), index.Tree.RootName); + + // 根:路径为空串、无父节点、深度 0。 + var root = Assert.IsType(nodes[0]); + Assert.Equal(string.Empty, root.Path); + Assert.Null(root.Parent); + Assert.Equal(0, root.Depth); + + var g1 = Assert.IsType(Assert.Single(nodes, n => n.Path == "g1")); + Assert.Equal(string.Empty, g1.Parent); + Assert.Equal(1, g1.Depth); + Assert.True(g1.IsEntry); + Assert.Equal("g1", g1.EntryKey); + Assert.Equal(10, g1.ScanIntervalMs); + + var sub = Assert.IsType(Assert.Single(nodes, n => n.Path == "g1/sub")); + Assert.Equal("g1", sub.Parent); + Assert.Equal(2, sub.Depth); + Assert.False(sub.IsEntry); + Assert.Null(sub.EntryKey); + Assert.False(sub.IsEnabled); + + var b = Assert.IsType(Assert.Single(nodes, n => n.Path == "g1/sub/b")); + Assert.Equal("g1/sub", b.Parent); + Assert.Equal(3, b.Depth); + Assert.Equal("BIT", b.TagKind); + Assert.Equal("2", b.Address); + Assert.True(b.IsReadOnly); + + // 非入口子树照样出现在树里(只是没有值)。 + Assert.Contains(nodes, n => n.Path == "plain"); + } + + [Fact] + public void Tree_SerializesPolymorphicNodeShapes() + { + using var project = this.CreateProject(); + var index = ProjectIndex.Build(1, project); + + var json = JsonSerializer.Serialize(index.Tree, TagsStreamJsonContext.Default.TreeDto); + using var document = JsonDocument.Parse(json); + var nodes = document.RootElement.GetProperty("nodes").EnumerateArray().ToArray(); + + // 根节点的 parent 必须显式写成 null(其余可空字段省略),前端靠它区分"根"与"字段缺失" + var root = Assert.Single(nodes, n => n.GetProperty("path").GetString() == string.Empty); + Assert.Equal(JsonValueKind.Null, root.GetProperty("parent").ValueKind); + + // grp:带入口相关字段,没有 tag 专有字段 + var grp = Assert.Single(nodes, n => n.GetProperty("path").GetString() == "g1"); + Assert.Equal("grp", grp.GetProperty("kind").GetString()); + Assert.True(grp.TryGetProperty("entryKey", out _)); + Assert.True(grp.TryGetProperty("scanIntervalMs", out _)); + Assert.False(grp.TryGetProperty("tagKind", out _)); + + // tag:带测点专有字段,没有 grp 专有字段 + var tag = Assert.Single(nodes, n => n.GetProperty("path").GetString() == "g1/sub/b"); + Assert.Equal("tag", tag.GetProperty("kind").GetString()); + Assert.Equal("BIT", tag.GetProperty("tagKind").GetString()); + Assert.False(tag.TryGetProperty("entryKey", out _)); + Assert.False(tag.TryGetProperty("scanIntervalMs", out _)); + } + + [Fact] + public void Build_OnlyOutermostEntriesBecomeEntryStreams() + { + using var project = this.CreateProject(); + + var index = ProjectIndex.Build(epoch: 1, project); + + var entry = Assert.Single(index.Entries); + Assert.Equal("g1", entry.Key); + Assert.Equal("g1", entry.Path); + Assert.NotNull(index.ByKey["g1"]); + + Assert.Equal( + new[] { "g1/a", "g1/sub/b" }, + entry.Collector.GetAll().Select(v => v.Path).OrderBy(p => p, StringComparer.Ordinal).ToArray()); + } + + [Fact] + public void Build_DoesNotLeakEntryContextBetweenSiblings() + { + using var project = this._scope.ServiceProvider.GetRequiredService().Create( + StreamTestContext.ProjectRoot, + new XElement("Project", + StreamTestContext.Channel("fake"), + StreamTestContext.Channel("fake2"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("a")), + // 两个入口之间的非入口子树:结构上有,但不属于任何入口(不会被轮询) + new XElement("TagGrp", + new XAttribute("name", "plain"), + new XAttribute("isEnabled", "true"), + new XAttribute("channel", "fake"), + StreamTestContext.Tag("p")), + StreamTestContext.EntryGroup("g2", "fake2", + StreamTestContext.Group("sub", StreamTestContext.Tag("b"))))); + + var index = ProjectIndex.Build(epoch: 1, project); + + Assert.Equal(new[] { "g1", "g2" }, index.Entries.Select(e => e.Key).ToArray()); + Assert.Equal(new[] { "g1/a" }, EntryLeafPaths(index, "g1")); + Assert.Equal(new[] { "g2/sub/b" }, EntryLeafPaths(index, "g2")); + + // 非入口子树的路径/深度不受前一个入口的上下文影响 + var p = Assert.IsType(Assert.Single(index.Tree.Nodes, n => n.Path == "plain/p")); + Assert.Equal("plain", p.Parent); + Assert.Equal(2, p.Depth); + } + + private static string[] EntryLeafPaths(ProjectIndex index, string key) => + index.ByKey[key].Collector.GetAll().Select(v => v.Path).OrderBy(p => p, StringComparer.Ordinal).ToArray(); +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/SseFramesTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/SseFramesTests.cs new file mode 100644 index 0000000..2b48f2f --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/SseFramesTests.cs @@ -0,0 +1,85 @@ +using System; +using Xunit; +using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebStream.Internal; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// SSE 帧的线格式:事件名、data: 前缀、camelCase、null 省略。 +/// +public class SseFramesTests +{ + [Fact] + public void State_Format() + { + var text = StreamTestContext.Text(SseFrames.State(new StateDto(7, true, null))); + + Assert.StartsWith("event: state\ndata: ", text); + Assert.EndsWith("\n\n", text); + Assert.Contains("\"epoch\":7", text); + Assert.Contains("\"isStarted\":true", text); + + // null 不出现(DefaultIgnoreCondition = WhenWritingNull) + Assert.DoesNotContain("projectRoot", text); + } + + [Fact] + public void Values_Format() + { + var timestamp = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var frame = SseFrames.Values(new EntryValuesDto( + Epoch: 3, + Entry: "g1", + Seq: 9, + SampledAtUtc: timestamp, + Full: false, + Values: new[] { new TagValueDto("g1/a", "INT16", "42", timestamp, IsDirty: false, IsScanned: true) }, + ContainerFlags: null)); + + var text = StreamTestContext.Text(frame); + + Assert.StartsWith("event: values\ndata: ", text); + Assert.Contains("\"entry\":\"g1\"", text); + Assert.Contains("\"seq\":9", text); + Assert.Contains("\"full\":false", text); + Assert.Contains("\"path\":\"g1/a\"", text); + Assert.Contains("\"raw\":\"42\"", text); + Assert.Contains("\"timestampUtc\":\"2026-01-02T03:04:05Z\"", text); + Assert.DoesNotContain("containerFlags", text); + } + + [Fact] + public void Values_KeepsFrameSingleLineWhenValueContainsCrLf() + { + var timestamp = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var frame = SseFrames.Values( + new EntryValuesDto( + Epoch: 1, + Entry: "g1", + Seq: 1, + SampledAtUtc: timestamp, + Full: false, + Values: new[] { + new TagValueDto("g1/a", "STRING", "a\nb\rc", timestamp, IsDirty: false, IsScanned: true) + }, + ContainerFlags: null + ) + ); + + var text = StreamTestContext.Text(frame); + + // 帧结构只有 3 个换行:event/data 之间 1 个、帧尾 2 个。 + // 值里的 CR/LF 必须被 JSON 转义——否则 SSE 会把一帧拆成多帧(空行即 dispatch)。 + Assert.Equal(3, text.Split('\n').Length - 1); + Assert.False(text.Contains('\r')); + Assert.Contains("a\\nb\\rc", text); + } + + [Fact] + public void Retry_And_Ping_AreWellFormed() + { + Assert.Equal("retry: 3000\n\n", StreamTestContext.Text(SseFrames.Retry)); + Assert.Equal(": ping\n\n", StreamTestContext.Text(SseFrames.Ping)); + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamEndpointParseEntriesTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamEndpointParseEntriesTests.cs new file mode 100644 index 0000000..e41019e --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamEndpointParseEntriesTests.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; +using StdUnit.Tags.WebStream.Internal; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// ?entries= 的解析:逗号分隔、去空白、去重、空段忽略;解析不出来就是"全部入口"。 +/// +public class StreamEndpointParseEntriesTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(",")] + [InlineData(" , , ")] + public void ParseEntries_NothingUsefulMeansAllEntries(string? query) + { + Assert.Null(StreamEndpoint.ParseEntries(query)); + } + + [Fact] + public void ParseEntries_SplitsTrimsAndDeduplicates() + { + var entries = StreamEndpoint.ParseEntries(" g1 , g2 ,g1,, g3 "); + + Assert.NotNull(entries); + Assert.Equal(new[] { "g1", "g2", "g3" }, entries!.OrderBy(e => e, StringComparer.Ordinal).ToArray()); + } + + [Fact] + public void ParseEntries_IsCaseSensitive() + { + // 入口名与 TagName 一致,按序数比较(不做大小写折叠)。 + var entries = StreamEndpoint.ParseEntries("g1,G1"); + + Assert.NotNull(entries); + Assert.Equal(2, entries!.Count); + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamTestContext.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamTestContext.cs new file mode 100644 index 0000000..9723fc7 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamTestContext.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using StdUnit.Tags.Tests.Fakes; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// 观测流测试的公共夹具:用纯内存假驱动构建真实项目(真实 TagsProject + 真实 TagGrpRunner)。 +/// +internal static class StreamTestContext +{ + public static readonly string ProjectRoot = CreateRoot(); + + public static ServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTagsProjectServices(b => + { + b.AddFakedSupport(); + b.AddFakedTagSupport(); + }); + return services.BuildServiceProvider(); + } + + public static XElement Channel(string name) => + new("Channel", new XAttribute("name", name), new XAttribute("driver", "fake")); + + public static XElement Group(string name, params object[] children) => + BuildGroup(name, isEntry: false, isEnabled: true, channel: null, children); + + public static XElement Group(string name, bool isEnabled, params object[] children) => + BuildGroup(name, isEntry: false, isEnabled, channel: null, children); + + public static XElement EntryGroup(string name, params object[] children) => + BuildGroup(name, isEntry: true, isEnabled: true, channel: "fake", children); + + public static XElement EntryGroup(string name, string channel, params object[] children) => + BuildGroup(name, isEntry: true, isEnabled: true, channel, children); + + private static XElement BuildGroup(string name, bool isEntry, bool isEnabled, string? channel, object[] children) => + new("TagGrp", + new XAttribute("name", name), + isEntry ? new XAttribute("isEntry", "true") : null, + new XAttribute("isEnabled", isEnabled ? "true" : "false"), + isEntry ? new XAttribute("channel", channel!) : null, + isEntry ? new XAttribute("scanInterval", 10) : null, + children); + + public static XElement Tag(string name, string type = "INT16", string access = "RW", string address = "0") => + new("Tag", + new XAttribute("name", name), + new XAttribute("type", type), + new XAttribute("access", access), + new XAttribute("address", address)); + + private static string CreateRoot() + { + // 相对路径全部用 Path.Combine 分段,跨平台安全(CI 只跑 Linux)。 + var root = Path.Combine(Path.GetTempPath(), "StdUnit.Tags.WebStream.Tests"); + Directory.CreateDirectory(root); + return root; + } + + public static bool WaitUntil(Func condition, int timeoutMs = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } + Thread.Sleep(10); + } + + return condition(); + } + + public static void StopQuietly(CancellationTokenSource cts, Task run) + { + cts.Cancel(); + try + { + run.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + // 取消时轮询循环以 OCE 退出,这是既有设计。 + } + } + + /// 取消并等待轮询循环退出(唯一的例外是 OCE,属正常停机)。 + public static async Task StopAsync(CancellationTokenSource cts, Task run) + { + cts.Cancel(); + try + { + await run.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // 取消时轮询循环以 OCE 退出,这是既有设计。 + } + } + + public static ITag TagAt(ITagsProject project, string path) => + project.Tags.Descendant(path).AsTag(); + + public static string Text(ReadOnlyMemory frame) => System.Text.Encoding.UTF8.GetString(frame.ToArray()); +} + +/// 可控的假控制器:只用于决定"何时启动/停止"。 +internal sealed class FakeProjectCtrl : ITagsProjectCtrl +{ + public ITagsProject? Project { get; private set; } + + public Func>? OnStartingException { get; set; } + + public event TagsProjectStartedOrStopped? StartedOrStopped; + + public void RaiseStarted(ITagsProject project) + { + this.Project = project; + this.StartedOrStopped?.Invoke(this, new TagsProjectEventArgs(true, project)); + } + + public void RaiseStopped() + { + this.Project = null; + this.StartedOrStopped?.Invoke(this, new TagsProjectEventArgs(false, null)); + } + + public Task StartPollAsync(string? dir, System.Xml.Linq.XElement? root, Func hook) => + throw new NotSupportedException(); + + public Task StopAsync() => Task.CompletedTask; +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamCommandsTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamCommandsTests.cs new file mode 100644 index 0000000..fe0dfe5 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamCommandsTests.cs @@ -0,0 +1,242 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebStream.Internal; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// 写命令:改测点值、标记未扫描。
+/// 真实 TagsProject + 真实 TagGrpRunner(假驱动),意图由轮询线程执行。 +///
+public class TagsStreamCommandsTests : IDisposable +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ShortTimeout = TimeSpan.FromMilliseconds(200); + + private readonly ServiceProvider _root; + private readonly IServiceScope _scope; + + public TagsStreamCommandsTests() + { + this._root = StreamTestContext.CreateServiceProvider(); + this._scope = this._root.CreateScope(); + } + + public void Dispose() + { + this._scope.Dispose(); + this._root.Dispose(); + } + + private ITagsProject CreateProject(XElement xml) => + this._scope.ServiceProvider.GetRequiredService().Create(StreamTestContext.ProjectRoot, xml); + + private static XElement Int16Project(string access = "RW") => new( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("a", type: "INT16", access: access))); + + [Fact] + public async Task WriteValue_ParsesByTagKind_AndApplies() + { + using var project = this.CreateProject(Int16Project()); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var cts = new CancellationTokenSource(); + var run = project.RunAsync(cts.Token); + try + { + var outcome = await TagsStreamCommands.WriteValueAsync( + ctrl, + new WriteTagValueRequest("g1/a", "42"), + Timeout); + + Assert.Equal(200, outcome.StatusCode); + Assert.Null(outcome.Error); + Assert.Equal((short)42, StreamTestContext.TagAt(project, "g1/a").Value); + } + finally + { + await StreamTestContext.StopAsync(cts, run); + } + } + + [Fact] + public async Task WriteValue_BitKind_AcceptsBooleanText() + { + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("b", type: "BIT")))); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var cts = new CancellationTokenSource(); + var run = project.RunAsync(cts.Token); + try + { + var tag = StreamTestContext.TagAt(project, "g1/b"); + + Assert.Equal(200, (await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/b", "1"), Timeout)).StatusCode); + Assert.Equal(true, tag.Value); + + Assert.Equal(200, (await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/b", "false"), Timeout)).StatusCode); + Assert.Equal(false, tag.Value); + } + finally + { + await StreamTestContext.StopAsync(cts, run); + } + } + + [Theory] + [InlineData("abc")] + [InlineData("99999999999")] + public async Task WriteValue_UnparsableValue_IsBadRequest(string raw) + { + using var project = this.CreateProject(Int16Project()); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var cts = new CancellationTokenSource(); + var run = project.RunAsync(cts.Token); + try + { + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/a", raw), Timeout); + + Assert.Equal(400, outcome.StatusCode); + Assert.NotNull(outcome.Error); + } + finally + { + await StreamTestContext.StopAsync(cts, run); + } + } + + [Fact] + public async Task WriteValue_UnknownPath_IsBadRequest() + { + using var project = this.CreateProject(Int16Project()); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var cts = new CancellationTokenSource(); + var run = project.RunAsync(cts.Token); + try + { + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/nope", "1"), Timeout); + Assert.Equal(400, outcome.StatusCode); + } + finally + { + await StreamTestContext.StopAsync(cts, run); + } + } + + [Fact] + public async Task WriteValue_ReadOnlyTag_IsConflict() + { + using var project = this.CreateProject(Int16Project(access: "RO")); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var cts = new CancellationTokenSource(); + var run = project.RunAsync(cts.Token); + try + { + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/a", "1"), Timeout); + Assert.Equal(409, outcome.StatusCode); + } + finally + { + await StreamTestContext.StopAsync(cts, run); + } + } + + [Fact] + public async Task WriteValue_ProjectNotRunning_IsConflict() + { + using var project = this.CreateProject(Int16Project()); + var ctrl = new FakeProjectCtrl(); + + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/a", "1"), Timeout); + + Assert.Equal(409, outcome.StatusCode); + Assert.Contains("未运行", outcome.Error); + } + + [Fact] + public async Task WriteValue_DisabledEntry_TimesOut() + { + // 入口被禁用时 TagGrpRunner 直接跳过,意图永远不会被 drain + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + new XElement("TagGrp", + new XAttribute("name", "g1"), + new XAttribute("isEntry", "true"), + new XAttribute("isEnabled", "false"), + new XAttribute("channel", "fake"), + new XAttribute("scanInterval", 10), + StreamTestContext.Tag("a")))); + + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var cts = new CancellationTokenSource(); + var run = project.RunAsync(cts.Token); + try + { + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/a", "1"), ShortTimeout); + + Assert.Equal(504, outcome.StatusCode); + Assert.Contains("禁用", outcome.Error); + } + finally + { + await StreamTestContext.StopAsync(cts, run); + } + } + + [Fact] + public async Task Rescan_ExecutesIntent() + { + using var project = this.CreateProject(Int16Project()); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var cts = new CancellationTokenSource(); + var run = project.RunAsync(cts.Token); + try + { + var outcome = await TagsStreamCommands.RescanAsync(ctrl, new RescanTagRequest("g1/a"), Timeout); + + // 意图确实被该入口的轮询线程执行了(没超时、没抛错)。 + // "IsScanned 最终为 false" 无法在此断言:同一轮的 ReadAsync 会立刻把它置回 true。 + Assert.Equal(200, outcome.StatusCode); + Assert.Null(outcome.Error); + } + finally + { + await StreamTestContext.StopAsync(cts, run); + } + } + + [Fact] + public void ResetScanned_ClearsTagFlag() + { + using var project = this.CreateProject(Int16Project()); + var tag = StreamTestContext.TagAt(project, "g1/a"); + tag.IsScanned = true; + + TagsStreamCommands.ResetScanned(tag); + + Assert.False(tag.IsScanned); + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs new file mode 100644 index 0000000..3812bfb --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs @@ -0,0 +1,257 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebStream.Internal; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// 的端到端行为:状态(Replay 1)、全量基线、增量、按入口过滤、迟到订阅者。
+/// 用真实的 TagsProject + 真实 TagGrpRunner(假驱动),只把 换成可控的假实现, +/// 以便精确决定"什么时候启动/停止"。 +///
+public class TagsStreamHubTests : IDisposable +{ + private readonly ServiceProvider _root; + private readonly IServiceScope _scope; + + public TagsStreamHubTests() + { + this._root = StreamTestContext.CreateServiceProvider(); + this._scope = this._root.CreateScope(); + } + + public void Dispose() + { + this._scope.Dispose(); + this._root.Dispose(); + } + + private ITagsProject CreateProject(XElement xml) => + this._scope.ServiceProvider.GetRequiredService().Create(StreamTestContext.ProjectRoot, xml); + + private static ITagsProject CreateSingleEntryProject(IServiceScope scope) => + scope.ServiceProvider.GetRequiredService().Create( + StreamTestContext.ProjectRoot, + new XElement("Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", "fake", + StreamTestContext.Tag("a")))); + + private static TagsStreamOptions FastOptions() => new() + { + ReconcileInterval = TimeSpan.FromMilliseconds(50), + HeartbeatInterval = TimeSpan.FromSeconds(30), + PiperQueueCapacity = 256, + }; + + [Fact] + public async Task Hub_PushesStateBaselineAndDelta() + { + using var project = CreateSingleEntryProject(this._scope); + var ctrl = new FakeProjectCtrl(); + using var hub = new TagsStreamHub(ctrl, FastOptions()); + await hub.StartAsync(default); + + using var output = new RecordingStream(); + using var piperCts = new CancellationTokenSource(); + using var piper = hub.Subscribe(entries: null); + var writer = piper.PipeFramesToStreamAsync(output, piperCts.Token); + + // 订阅即刻拿到当前状态(项目未启动)。 + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("event: state"))); + Assert.Contains("\"isStarted\":false", output.Text); + + // 启动:先状态,再各入口全量基线。 + ctrl.RaiseStarted(project); + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"isStarted\":true"))); + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"full\":true"))); + + // 跑真实的轮询:改一个值,应只收到该点的增量。 + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + + StreamTestContext.TagAt(project, "g1/a").Value = (short)42; + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"raw\":\"42\""))); + + var deltaFrame = FrameContaining(output.Text, "\"raw\":\"42\""); + Assert.NotNull(deltaFrame); + Assert.Contains("\"path\":\"g1/a\"", deltaFrame); + Assert.Contains("\"full\":false", deltaFrame); + + // 停止:状态变为未启动。 + ctrl.RaiseStopped(); + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"isStarted\":false"))); + + await StreamTestContext.StopAsync(runCts, run); + await StopSubscriberAsync(piperCts, writer); + await hub.StopAsync(default); + } + + [Fact] + public async Task Subscribe_FiltersByEntry() + { + using var project = this.CreateProject(new XElement("Project", + StreamTestContext.Channel("fake1"), + StreamTestContext.Channel("fake2"), + StreamTestContext.EntryGroup("g1", "fake1", StreamTestContext.Tag("a")), + StreamTestContext.EntryGroup("g2", "fake2", StreamTestContext.Tag("a")))); + + var ctrl = new FakeProjectCtrl(); + using var hub = new TagsStreamHub(ctrl, FastOptions()); + await hub.StartAsync(default); + + using var output = new RecordingStream(); + using var piperCts = new CancellationTokenSource(); + // 只订阅 g2 + using var piper = hub.Subscribe(new HashSet(StringComparer.Ordinal) { "g2" }); + var writer = piper.PipeFramesToStreamAsync(output, piperCts.Token); + + ctrl.RaiseStarted(project); + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"entry\":\"g2\""))); + + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + + StreamTestContext.TagAt(project, "g1/a").Value = (short)11; + StreamTestContext.TagAt(project, "g2/a").Value = (short)22; + + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"raw\":\"22\""))); + await Task.Delay(150); + + Assert.DoesNotContain("\"raw\":\"11\"", output.Text); + + await StreamTestContext.StopAsync(runCts, run); + await StopSubscriberAsync(piperCts, writer); + await hub.StopAsync(default); + } + + [Fact] + public async Task SubscribeAfterStart_ReceivesBaselineImmediately() + { + using var project = CreateSingleEntryProject(this._scope); + var ctrl = new FakeProjectCtrl(); + using var hub = new TagsStreamHub(ctrl, FastOptions()); + await hub.StartAsync(default); + + // 先启动(不跑轮询),再订阅:订阅者应立即拿到状态 + 全量基线。 + ctrl.RaiseStarted(project); + + using var output = new RecordingStream(); + using var piperCts = new CancellationTokenSource(); + using var piper = hub.Subscribe(entries: null); + var writer = piper.PipeFramesToStreamAsync(output, piperCts.Token); + + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"isStarted\":true"))); + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"full\":true"))); + + await StopSubscriberAsync(piperCts, writer); + await hub.StopAsync(default); + } + + /// 取出包含指定片段的那一帧(以空行分隔)。 + private static string? FrameContaining(string text, string needle) + { + var index = text.IndexOf(needle, StringComparison.Ordinal); + if (index < 0) + { + return null; + } + + var start = text.LastIndexOf("\n\n", index, StringComparison.Ordinal); + start = start < 0 ? 0 : start + 2; + var end = text.IndexOf("\n\n", index, StringComparison.Ordinal); + return end < 0 ? text[start..] : text[start..end]; + } + + private static async Task StopSubscriberAsync(CancellationTokenSource cts, Task writer) + { + cts.Cancel(); + try + { + await writer; + } + catch (OperationCanceledException) + { + // 正常停机 + } + } + + /// 线程安全的只写流:把 SSE 字节记录下来供断言。 + private sealed class RecordingStream : Stream + { + private readonly object _gate = new(); + private readonly MemoryStream _inner = new(); + + public string Text + { + get + { + lock (this._gate) + { + return Encoding.UTF8.GetString(this._inner.GetBuffer(), 0, (int)this._inner.Length); + } + } + } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + lock (this._gate) + { + this._inner.Write(buffer, offset, count); + } + } + + public override void Write(ReadOnlySpan buffer) + { + lock (this._gate) + { + this._inner.Write(buffer); + } + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + this.Write(buffer, offset, count); + return Task.CompletedTask; + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + this.Write(buffer.Span); + return default; + } + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs new file mode 100644 index 0000000..53780ff --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs @@ -0,0 +1,178 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using StdUnit.Tags.WebStream; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// :对外只能改前缀,相对路径固定; +/// 返回的路由组能整组加鉴权。 +/// +public class TagsStreamMappingTests +{ + private static (WebApplication App, IEndpointRouteBuilder Builder) CreateApp() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + var app = builder.Build(); + return (app, app); + } + + private static string[] Patterns(IEndpointRouteBuilder builder) => + builder.DataSources + .SelectMany(ds => ds.Endpoints) + .OfType() + .Select(e => e.RoutePattern.RawText!) + .OrderBy(p => p, System.StringComparer.Ordinal) + .ToArray(); + + [Theory] + [InlineData("/stdunit.tags")] + [InlineData("/monitor")] + [InlineData("/stdunit.tags/")] + [InlineData("/")] + [InlineData("")] + public async Task MapTagsStream_PutsEveryEndpointUnderThePrefix(string prefix) + { + var (app, builder) = CreateApp(); + try + { + builder.MapTagsStream(prefix); + + var normalized = prefix.TrimEnd('/'); + Assert.Equal( + new[] + { + $"{normalized}/rescan", + $"{normalized}/stream", + $"{normalized}/tree", + $"{normalized}/ui", + $"{normalized}/value", + }, + Patterns(builder)); + } + finally + { + await app.DisposeAsync(); + } + } + + [Theory] + // 路径 → 期望的 (页面自己的 URL 去掉最后一段) + [InlineData("/stdunit.tags/ui", "", "/stdunit.tags/")] + [InlineData("/monitor/ui", "", "/monitor/")] + [InlineData("/ui", "", "/")] + // 末尾多一个 '/' 也不能当成目录 + [InlineData("/monitor/ui/", "", "/monitor/")] + // PathBase(UsePathBase / 反向代理前缀)必须原样带上 + [InlineData("/stdunit.tags/ui", "/app", "/app/stdunit.tags/")] + public async Task UiPage_BaseHrefComesFromTheRequestPath(string path, string pathBase, string expectedBase) + { + var (app, builder) = CreateApp(); + try + { + builder.MapTagsStream("/monitor"); + + var ui = Assert.Single( + builder.DataSources.SelectMany(ds => ds.Endpoints).OfType(), + e => e.RoutePattern.RawText == "/monitor/ui"); + + var context = new DefaultHttpContext { RequestServices = app.Services }; + context.Request.PathBase = pathBase; + context.Request.Path = path; + context.Response.Body = new MemoryStream(); + await ui.RequestDelegate!(context); + + var html = Encoding.UTF8.GetString(((MemoryStream)context.Response.Body).ToArray()); + Assert.Contains($"", html); + + // 页面里只注入相对路径(与 C# 常量同源),真正的 URL 由 JS 相对 解析。 + Assert.Contains("new URL('tree', document.baseURI).href", html); + Assert.Contains("new URL('stream', document.baseURI).href", html); + Assert.DoesNotContain("%%", html); + } + finally + { + await app.DisposeAsync(); + } + } + + [Fact] + public async Task MapTagsStream_CanBeMountedOnAnotherGroup() + { + var (app, builder) = CreateApp(); + try + { + // 挂到父级路由组下:路由自动带上前缀,监控页的 由请求路径算出,不需要额外配置。 + builder.MapGroup("/monitor").MapTagsStream(); + + Assert.Equal( + new[] + { + "/monitor/stdunit.tags/rescan", + "/monitor/stdunit.tags/stream", + "/monitor/stdunit.tags/tree", + "/monitor/stdunit.tags/ui", + "/monitor/stdunit.tags/value", + }, + Patterns(builder)); + } + finally + { + await app.DisposeAsync(); + } + } + + [Fact] + public async Task MapTagsStream_ReturnsAGroupThatCanRequireAuthorization() + { + var (app, builder) = CreateApp(); + try + { + // 文档里的示例用法:映射完直接对整组加策略。 + builder.MapTagsStream().RequireAuthorization(); + + var endpoints = builder.DataSources.SelectMany(ds => ds.Endpoints).OfType().ToArray(); + Assert.Equal(5, endpoints.Length); + Assert.All(endpoints, e => Assert.Contains(e.Metadata, m => m is IAuthorizeData)); + + // 不传 prefix 时必须落在默认前缀下(= EndpointConstants.DefaultPrefix)。 + Assert.Contains( + $"{EndpointConstants.DefaultPrefix}/tree", + endpoints.Select(e => e.RoutePattern.RawText)); + } + finally + { + await app.DisposeAsync(); + } + } + + [Fact] + public async Task MapTagsStream_GroupCanRequireANamedPolicy() + { + var (app, builder) = CreateApp(); + try + { + // 文档里的第二条示例:自定义前缀 + 命名策略 + builder.MapTagsStream("/internal/tags").RequireAuthorization("ops"); + + var endpoints = builder.DataSources.SelectMany(ds => ds.Endpoints).OfType().ToArray(); + Assert.Equal(5, endpoints.Length); + Assert.All(endpoints, e => Assert.Contains( + e.Metadata.OfType(), + m => m.Policy == "ops")); + } + finally + { + await app.DisposeAsync(); + } + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs new file mode 100644 index 0000000..8e41c8f --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using StdUnit.Tags.Tests.Fakes; +using StdUnit.Tags.WebStream; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; + +/// +/// AddTagsStream:对外只暴露 ,且它与 hosted service 是同一个实例。 +/// +public class TagsStreamRegistrationTests +{ + [Fact] + public void AddTagsStream_ExposesOnlyTheInterfaceAndReusesTheSameInstance() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTagsProjectServices(b => + { + b.AddFakedSupport(); + b.AddFakedTagSupport(); + }); + services.AddTagsStream(); + + // ValidateOnBuild + ValidateScopes:注册表本身也要能被校验通过 + // (实现类是 internal,若 DI 的可访问性检查过不去,这里会先炸)。 + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true, + }); + + var hub = provider.GetRequiredService(); + Assert.Same(hub, provider.GetRequiredService()); // Singleton + + // 同一个实例既是接口服务,又是随宿主启停的 IHostedService。 + Assert.Contains(provider.GetServices(), h => ReferenceEquals(h, hub)); + + Assert.Equal(0, hub.Epoch); + var tree = hub.GetTree(); + Assert.False(tree.IsStarted); + Assert.Empty(tree.Nodes); + } +} From c818977f77cfc194cd9d31ffacd9bc4252bfc240 Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Wed, 28 Oct 2026 22:54:12 +0800 Subject: [PATCH 2/9] add Keyed TagsProjectCtrl support --- .../Commands/CommandsEndpoint.cs | 23 +- .../Core/EndpointConstants.cs | 9 +- .../Core/Registry/TagsStreamDescriptor.cs | 21 ++ .../Core/Registry/TagsStreamEntry.cs | 25 +++ .../Core/Registry/TagsStreamRegistry.cs | 62 ++++++ .../Core/TagsStreamHub.cs | 10 +- .../Sse/StreamEndpoint.cs | 10 +- .../Tree/TreeEndpoint.cs | 10 +- src/StdUnit.Tags.WebStream/_Extensions.cs | 163 +++++++++++--- .../_Extensions_HttpContext.cs | 34 +++ tests/AotSmokeTest.WebStream/Program.cs | 28 +++ .../WebStream/TagsStreamHubTests.cs | 8 +- .../WebStream/TagsStreamMappingTests.cs | 55 +++-- .../WebStream/TagsStreamRegistrationTests.cs | 202 +++++++++++++++++- 14 files changed, 589 insertions(+), 71 deletions(-) create mode 100644 src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamDescriptor.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamEntry.cs create mode 100644 src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamRegistry.cs create mode 100644 src/StdUnit.Tags.WebStream/_Extensions_HttpContext.cs diff --git a/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs b/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs index 6e2073d..32f9655 100644 --- a/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs +++ b/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs @@ -1,7 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; namespace StdUnit.Tags.WebStream.Internal; @@ -12,6 +11,13 @@ internal static class CommandsEndpoint { public static async Task WriteValueAsync(HttpContext context) { + var entry = context.GetKeyedTagsStreamEntry(); + if (entry is null) + { + await context.WriteKeyedStreamNotFoundAsync(); + return; + } + var request = await ReadBodyAsync(context, TagsStreamJsonContext.Default.WriteTagValueRequest) .ConfigureAwait(false); if (request is null) @@ -20,10 +26,8 @@ public static async Task WriteValueAsync(HttpContext context) return; } - var ctrl = context.RequestServices.GetRequiredService(); - var options = context.RequestServices.GetRequiredService(); var outcome = await TagsStreamCommands - .WriteValueAsync(ctrl, request, options.CommandTimeout) + .WriteValueAsync(entry.Ctrl, request, entry.Options.CommandTimeout) .ConfigureAwait(false); await WriteResultAsync(context, outcome).ConfigureAwait(false); @@ -31,6 +35,13 @@ public static async Task WriteValueAsync(HttpContext context) public static async Task RescanAsync(HttpContext context) { + var entry = context.GetKeyedTagsStreamEntry(); + if (entry is null) + { + await context.WriteKeyedStreamNotFoundAsync(); + return; + } + var request = await ReadBodyAsync(context, TagsStreamJsonContext.Default.RescanTagRequest) .ConfigureAwait(false); if (request is null) @@ -39,9 +50,7 @@ public static async Task RescanAsync(HttpContext context) return; } - var ctrl = context.RequestServices.GetRequiredService(); - var options = context.RequestServices.GetRequiredService(); - var outcome = await TagsStreamCommands.RescanAsync(ctrl, request, options.CommandTimeout).ConfigureAwait(false); + var outcome = await TagsStreamCommands.RescanAsync(entry.Ctrl, request, entry.Options.CommandTimeout).ConfigureAwait(false); await WriteResultAsync(context, outcome).ConfigureAwait(false); } diff --git a/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs b/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs index 0a9a3be..8da8def 100644 --- a/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs +++ b/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs @@ -3,12 +3,13 @@ namespace StdUnit.Tags.WebStream; /// /// 端点常量。
/// -/// /{prefix} +/// /{prefix} # 不具名流(AddTagsStream()) /// /ui # 内置监控页(GET) /// /tree # 结构树(GET) /// /stream # 值流(GET,SSE) /// /value # 改测点值(POST) /// /rescan # 标记未扫描(POST) +/// /{prefix}/{keyed}/… # 每一路具名流(AddKeyedTagsStream(key)),路径与上面同形 /// ///
public static class EndpointConstants @@ -18,6 +19,9 @@ public static class EndpointConstants /// public const string DefaultPrefix = "/stdunit.tags"; + /// 路由值名:具名流的 key(/{prefix}/{keyed}/tree)。 + internal const string KeyedRouteValue = "keyed"; + /// 内置监控页(GET)。 internal const string UiPath = "/ui"; @@ -32,4 +36,7 @@ public static class EndpointConstants /// 标记未扫描(POST)。 internal const string RescanPath = "/rescan"; + + /// 具名流的同一套路径(/{keyed}/…)。 + internal static string Keyed(string path) => "/{" + KeyedRouteValue + "}" + path; } diff --git a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamDescriptor.cs b/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamDescriptor.cs new file mode 100644 index 0000000..02d5570 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamDescriptor.cs @@ -0,0 +1,21 @@ +namespace StdUnit.Tags.WebStream; + +/// +/// 一次 AddTagsStream 的登记项。
+/// 作为 DI 里的 ImplementationInstance 存在,所以注册期就能做 key 的格式/保留字/重复校验, +/// 解析期再由 逐个创建 hub。 +///
+internal sealed class TagsStreamDescriptor +{ + public TagsStreamDescriptor(string? key, TagsStreamOptions options) + { + this.Key = key; + this.Options = options; + } + + /// 观测流的路由键;null 表示不具名(端点直接挂在前缀下)。 + public string? Key { get; } + + /// 这一路流自己的配置(每次 AddTagsStream 一份,互不影响)。 + public TagsStreamOptions Options { get; } +} diff --git a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamEntry.cs b/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamEntry.cs new file mode 100644 index 0000000..923b679 --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamEntry.cs @@ -0,0 +1,25 @@ +namespace StdUnit.Tags.WebStream; + +/// +/// 一路观测流:它观测的控制器、配置与 hub。
+/// 端点、命令、hosted service 都通过它拿到"同一个"hub —— 这是"接口服务 = hosted service = 端点看到的实例"的保证。 +///
+internal sealed class TagsStreamEntry +{ + public TagsStreamEntry(string? key, TagsStreamOptions options, ITagsProjectCtrl ctrl, TagsStreamHostedService hub) + { + this.Key = key; + this.Options = options; + this.Ctrl = ctrl; + this.HostedService = hub; + } + + public string? Key { get; } + + public TagsStreamOptions Options { get; } + + /// 这一路流观测的控制器(写命令也用它,保证"看哪个项目就改哪个项目")。 + public ITagsProjectCtrl Ctrl { get; } + + internal TagsStreamHostedService HostedService { get; } +} diff --git a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamRegistry.cs b/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamRegistry.cs new file mode 100644 index 0000000..5332abe --- /dev/null +++ b/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamRegistry.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace StdUnit.Tags.WebStream; + +/// +/// 所有观测流的注册表:key(不具名用 null)→ 一路独立的 hub + 控制器 + 配置。
+/// 每个 key 恰好创建一个 hub,hosted service 与端点共用同一实例。 +///
+internal sealed class TagsStreamRegistry +{ + private readonly List _entries = new(); + private readonly Dictionary _keyed = new(StringComparer.Ordinal); + private TagsStreamEntry? _default; + + public TagsStreamRegistry(IServiceProvider services, IEnumerable descriptors) + { + foreach (var descriptor in descriptors) + { + var ctrl = ResolveCtrl(services, descriptor.Key); + var hub = new TagsStreamHostedService(ctrl, descriptor.Options, services.GetService>()); + var entry = new TagsStreamEntry(descriptor.Key, descriptor.Options, ctrl, hub); + + this._entries.Add(entry); + if (descriptor.Key is null) + { + this._default = entry; + } + else + { + this._keyed.Add(descriptor.Key, entry); + } + } + } + + /// 按 key 取一路流;null 表示不具名的那一路。未注册返回 null。 + public TagsStreamEntry? Get(string? key) => key is null + ? this._default + : this._keyed.TryGetValue(key, out var entry) ? entry : null; + + /// 按 key 取一路流;未注册则抛出(配置错误,不是请求错误)。 + public TagsStreamEntry GetRequired(string? key) => this.Get(key) ?? throw new InvalidOperationException( + key is null + ? $"未注册不具名观测流:请调用 AddTagsStream()。" + : $"未注册 key = \"{key}\" 的观测流:请调用 AddKeyedTagsStream(\"{key}\")。"); + + public IReadOnlyList All => this._entries; + + private static ITagsProjectCtrl ResolveCtrl(IServiceProvider services, string? key) + { + if (key is null) + { + return services.GetRequiredService(); + } + + return services.GetKeyedService(key) + ?? throw new InvalidOperationException( + $"未找到 key = \"{key}\" 的 {nameof(ITagsProjectCtrl)}:" + + $"请先用 AddKeyedTagsProjectCtrl(\"{key}\") 注册它," + + "或用 AddTagsProjectServices 注册不具名的那一个(并给 AddTagsStream 传 null)。"); + } +} diff --git a/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs b/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs index d363131..36dc632 100644 --- a/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs +++ b/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs @@ -18,11 +18,11 @@ namespace StdUnit.Tags.WebStream; /// 任何一环满了都只丢帧并触发重同步,不会反压到轮询。 /// /// -internal sealed class TagsStreamHub : ITagsStreamHub, IHostedService, IDisposable +internal sealed class TagsStreamHostedService : ITagsStreamHub, IHostedService, IDisposable { private readonly ITagsProjectCtrl _ctrl; private readonly TagsStreamOptions _options; - private readonly ILogger? _logger; + private readonly ILogger? _logger; /// 订阅者集合与状态发布。_transitionGate → _gate 是唯一允许的加锁顺序。 private readonly object _gate = new(); @@ -46,12 +46,14 @@ internal sealed class TagsStreamHub : ITagsStreamHub, IHostedService, IDisposabl private Task? _heartbeatTask; /// - /// c'tor + /// c'tor
+ /// 有意保持 internal; + /// 不应该让DI容器自动构建本类的实例 ///
/// 测点项目控制器(需由 AddTagsProjectServices 注册) /// 配置 /// 日志 - public TagsStreamHub(ITagsProjectCtrl ctrl, TagsStreamOptions options, ILogger? logger = null) + internal TagsStreamHostedService(ITagsProjectCtrl ctrl, TagsStreamOptions options, ILogger? logger = null) { this._ctrl = ctrl ?? throw new ArgumentNullException(nameof(ctrl)); this._options = options ?? throw new ArgumentNullException(nameof(options)); diff --git a/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs b/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs index 53ecf07..6cff1a7 100644 --- a/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs +++ b/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.DependencyInjection; namespace StdUnit.Tags.WebStream.Internal; @@ -11,7 +10,12 @@ internal static class StreamEndpoint { public static async Task HandleAsync(HttpContext context) { - var hub = context.RequestServices.GetRequiredService(); + var entry = context.GetKeyedTagsStreamEntry(); + if (entry is null) + { + await context.WriteKeyedStreamNotFoundAsync(); + return; + } // 反代/服务器不能缓冲:否则"实时推送"会变成"攒一波再发"。 context.Features.Get()?.DisableBuffering(); @@ -22,7 +26,7 @@ public static async Task HandleAsync(HttpContext context) context.Response.Headers["X-Accel-Buffering"] = "no"; // nginx var entries = ParseEntries(context.Request.Query["entries"]); - using var piper = hub.Subscribe(entries); + using var piper = entry.HostedService.Subscribe(entries); try { diff --git a/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs b/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs index ddef8f2..a7b6b28 100644 --- a/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs +++ b/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; namespace StdUnit.Tags.WebStream.Internal; @@ -11,10 +10,15 @@ internal static class TreeEndpoint { public static async Task HandleAsync(HttpContext context) { - var hub = context.RequestServices.GetRequiredService(); + var entry = context.GetKeyedTagsStreamEntry(); + if (entry is null) + { + await context.WriteKeyedStreamNotFoundAsync(); + return; + } // 自己序列化:不走 MVC / 无参序列化,AOT/裁剪下没有反射路径。 - var bytes = JsonSerializer.SerializeToUtf8Bytes(hub.GetTree(), TagsStreamJsonContext.Default.TreeDto); + var bytes = JsonSerializer.SerializeToUtf8Bytes(entry.HostedService.GetTree(), TagsStreamJsonContext.Default.TreeDto); context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = "application/json; charset=utf-8"; diff --git a/src/StdUnit.Tags.WebStream/_Extensions.cs b/src/StdUnit.Tags.WebStream/_Extensions.cs index 556fa4a..661e361 100644 --- a/src/StdUnit.Tags.WebStream/_Extensions.cs +++ b/src/StdUnit.Tags.WebStream/_Extensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -13,50 +14,151 @@ namespace StdUnit.Tags.WebStream; public static class TagsStreamServiceCollection { /// - /// 注册只读观测流。前提: 已由 AddTagsProjectServices 注册。
+ /// 注册不具名那一路观测流:观测由 AddTagsProjectServices 注册的 , + /// 端点落在 {prefix}/tree|stream|value|rescan|ui。
+ /// 一个容器里只能有一路不具名流;要多路请用 。
///
- /// 注册三样东西:、(Singleton,程序集内唯一服务面), - /// 以及**同一实例**的 (随宿主启停)。
- ///
- /// 启动顺序:hub 以 注册,随宿主一起启动。 - /// 它需要在项目开始轮询之前拿到 才能挂上"轮次完成"回调, - /// 所以请让轮询在宿主启动之后才开始(例如在请求里/更晚注册的 hosted service 里 StartPollAsync)。 + /// 启动顺序:hub 以 注册,随宿主一起启动。它需要在项目开始轮询之前 + /// 拿到 才能挂上"轮次完成"回调,所以请让轮询在宿主启动之后才开始 + /// (例如在请求里/更晚注册的 hosted service 里 StartPollAsync)。 /// 若 hub 启动时项目已在运行,结构树仍可观测,但值观测会缺失并记一条 Warning。 ///
/// 服务集合 - /// 配置回调 + /// 这一路流的配置回调 /// public static IServiceCollection AddTagsStream(this IServiceCollection services, Action? configure = null) + { + if (services.Any(d => d.ServiceType == typeof(TagsStreamDescriptor) + && ((TagsStreamDescriptor)d.ImplementationInstance!).Key is null)) + { + throw new InvalidOperationException( + "不具名观测流只能注册一路;需要多路请改用 AddKeyedTagsStream(key) 注册具名流。"); + } + + var options = AddTagsStreamCommon(services, configure); + + services.AddSingleton(new TagsStreamDescriptor(key: null, options)); + + // 同一实例注册为 IHostedService:用工厂而不是 AddHostedService()——后者会另开 + // IHostedService → T 的描述符再 new 一个,宿主启动的 hub 与端点看到的不是同一份数据。 + services.AddSingleton( + sp => sp.GetRequiredService() + .GetRequired(null) + .HostedService + ); + + // 不具名那一路按 ITagsStreamHub 解析(具名那一路是 keyed 服务)。 + services.TryAddSingleton( + sp => sp.GetRequiredService() + .GetRequired(null) + .HostedService + ); + + return services; + } + + /// + /// 注册一路具名观测流:观测由 AddKeyedTagsProjectCtrl(key) 注册的 , + /// 端点落在 {prefix}/{key}/tree|stream|value|rescan|ui(监控页各一份)。
+ /// 可与 并存;同一个 key 只能注册一路。启动顺序等约束同 。 + ///
+ /// 服务集合 + /// + /// 控制器的注册键,同时作为路由里的 {keyed} 段:允许字母/数字/./-/_, + /// 且不能是 ui/tree/stream/value/rescan(会与不具名流的端点撞车)。 + /// + /// 这一路流的配置回调(每次调用各一份,互不影响) + /// + public static IServiceCollection AddKeyedTagsStream( this IServiceCollection services, string projctrlKey, Action? configure = null) + { + ArgumentException.ThrowIfNullOrEmpty(projctrlKey); + ValidateStreamKey(services, projctrlKey); + + var options = AddTagsStreamCommon(services, configure); + + services.AddSingleton(new TagsStreamDescriptor(projctrlKey, options)); + + // 同一实例注册为 IHostedService / keyed ITagsStreamHub(原因见 AddTagsStream 里的注释)。 + services.AddSingleton( + sp => sp.GetRequiredService() + .GetRequired(projctrlKey) + .HostedService + ); + services.AddKeyedSingleton( + projctrlKey, + (sp, _) => sp.GetRequiredService() + .GetRequired(projctrlKey) + .HostedService + ); + + return services; + } + + /// + /// 两条注册路径的公共部分:建这一路的 并保证 registry 存在。
+ /// 返回这份 options —— 它要交给 ,所以不能在两个公开方法里各建一份。 + ///
+ private static TagsStreamOptions AddTagsStreamCommon(IServiceCollection services, Action? configure) { var options = new TagsStreamOptions(); configure?.Invoke(options); - services.AddSingleton(options); - services.AddSingleton(); + // 每路流都有自己的一份 options;这里只登记第一条(服务面上仍可解析到它)。 + services.TryAddSingleton(options); + services.TryAddSingleton(); + return options; + } - // 注意(两个坑): - // ① 不能写 AddHostedService()——它是另开一个 IHostedService → TagsStreamHub 的 Singleton - // 描述符(TryAddEnumerable(Singleton)),DI 会再造一个实例,宿主启动的 hub - // 和端点解析到的 hub 不是同一份数据; - // ② 工厂的返回类型必须写成具体类型(TagsStreamHub),不能写成 IHostedService:AddHostedService 用 - // TryAddEnumerable 去重,会拿委托返回类型当实现类型,返回 IHostedService 就"与自身无法区分"直接抛。 - services.AddHostedService(sp => (TagsStreamHub)sp.GetRequiredService()); - return services; + /// + /// 校验 URL 段形式的 key:只含 URL 安全字符、不与不具名流的端点名撞车、不重复。 + /// + private static void ValidateStreamKey(IServiceCollection services, string key) + { + foreach (var c in key) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not ('.' or '-' or '_')) + { + throw new ArgumentException( + $"观测流的 key \"{key}\" 只能包含字母/数字/'.'/'-'/'_':它会直接成为 URL 的一段。", + nameof(key)); + } + } + + if (ReservedStreamKeys.Contains(key)) + { + throw new ArgumentException( + $"观测流的 key \"{key}\" 是保留字:会与不具名流的端点({string.Join(" / ", ReservedStreamKeys)})撞车。", + nameof(key)); + } + + if (services.Any(d => d.ServiceType == typeof(TagsStreamDescriptor) + && ((TagsStreamDescriptor)d.ImplementationInstance!).Key == key)) + { + throw new ArgumentException( + $"观测流的 key \"{key}\" 已经注册过:每个 key 只能对应一路流。", + nameof(key)); + } } + private static readonly HashSet ReservedStreamKeys = + new(new[] { "ui", "tree", "stream", "value", "rescan" }, StringComparer.OrdinalIgnoreCase); + /// /// 映射端点(全部挂在 下,相对路径固定):
+ /// 不具名流(AddTagsStream()): /// - GET {prefix}/tree:结构树(一次性 JSON,按 epoch 缓存);
/// - GET {prefix}/stream?entries=a,b:值流(text/event-stream),entries 省略表示全部入口;
/// - GET {prefix}/ui:内置的极简监控页(单文件 HTML + 内嵌 JS,无需 npm 工程);
/// - POST {prefix}/value:改测点值;
/// - POST {prefix}/rescan:标记未扫描。
+ /// 具名流(AddKeyedTagsStream("dev")):同一套形状,键作为一段路径, + /// 即 {prefix}/{key}/tree、{prefix}/{key}/stream ……;未知 key 返回 404。
///
/// 外部只可改前缀:各端点的相对路径(/tree、/stream …)是线协议的一部分, /// 监控页注入的也是这些相对路径,真正的 URL 由页面里的 <base href> 解析; - /// 而 base 是按请求路径生成的,因此把本组挂到别的路由组下 - /// (app.MapGroup("/monitor").MapTagsStream())或配了 UsePathBase 都能正常工作。 + /// 而 base 是按请求路径生成的,所以具名流的页面自动指向 {prefix}/{key}/…, + /// 把本组挂到别的路由组下(app.MapGroup("/monitor").MapTagsStream())或配了 UsePathBase 也都正常。 ///
/// 鉴权:这些端点不内置任何鉴权——两个写端点会把任意测点改成任意值。 /// 返回值是路由组,映射完直接对整组加策略即可: @@ -72,25 +174,34 @@ public static IServiceCollection AddTagsStream(this IServiceCollection services, /// 传 "" 或 "/" 表示不挂前缀(端点直接落在根上)。 /// /// 路由组:可继续 RequireAuthorization() / WithTags() 等整组配置。 - public static RouteGroupBuilder MapTagsStream( - this IEndpointRouteBuilder endpoints, - string prefix = EndpointConstants.DefaultPrefix) + public static RouteGroupBuilder MapTagsStream(this IEndpointRouteBuilder endpoints, string prefix = EndpointConstants.DefaultPrefix) { var group = endpoints.MapGroup(prefix.Length == 0 ? "/" : prefix); - // 用 RequestDelegate 重载而不是 Delegate:后者要反射分析委托参数,NativeAOT 下不可用(IL2026/IL3050)。 + // 不具名流:{prefix}/tree|stream|value|rescan|ui group.MapGet(EndpointConstants.TreePath, TreeEndpoint.HandleAsync); group.MapGet(EndpointConstants.StreamPath, StreamEndpoint.HandleAsync); group.MapPost(EndpointConstants.WriteValuePath, CommandsEndpoint.WriteValueAsync); group.MapPost(EndpointConstants.RescanPath, CommandsEndpoint.RescanAsync); - // 页面只注入**相对路径**(与 C# 常量同源),基路径在请求时按实际路径填入,所以这里不需要前缀。 + // 具名流:{prefix}/{keyed}/…(同形);未知 key 由端点在运行时返回 404。 + group.MapGet(EndpointConstants.Keyed(EndpointConstants.TreePath), TreeEndpoint.HandleAsync); + group.MapGet(EndpointConstants.Keyed(EndpointConstants.StreamPath), StreamEndpoint.HandleAsync); + group.MapPost(EndpointConstants.Keyed(EndpointConstants.WriteValuePath), CommandsEndpoint.WriteValueAsync); + group.MapPost(EndpointConstants.Keyed(EndpointConstants.RescanPath), CommandsEndpoint.RescanAsync); + + // 页面只注入**相对路径**(与 C# 常量同源),基路径按请求路径生成: + // {prefix}/ui → base={prefix}/, + // {prefix}/{key}/ui → base={prefix}/{key}/, + // 所以一份页面服务所有流。 var page = TagsStreamUi.BuildPage( EndpointConstants.TreePath, EndpointConstants.StreamPath, EndpointConstants.WriteValuePath, - EndpointConstants.RescanPath); + EndpointConstants.RescanPath + ); group.MapGet(EndpointConstants.UiPath, context => TagsStreamUi.WriteAsync(context, page)); + group.MapGet(EndpointConstants.Keyed(EndpointConstants.UiPath), context => TagsStreamUi.WriteAsync(context, page)); return group; } diff --git a/src/StdUnit.Tags.WebStream/_Extensions_HttpContext.cs b/src/StdUnit.Tags.WebStream/_Extensions_HttpContext.cs new file mode 100644 index 0000000..da2878e --- /dev/null +++ b/src/StdUnit.Tags.WebStream/_Extensions_HttpContext.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace StdUnit.Tags.WebStream.Internal; + +/// +/// 端点层的 HTTP 适配: / 路由值 / 404 响应都留在这里, +/// 只认 key,不认识 HTTP。 +/// +internal static class TagsStreamEndpointExtensions +{ + /// + /// 取当前请求对应的那一路观测流:路由里有 {keyed} 就按它找,否则用不具名那一路。
+ /// 找不到返回 null(由调用方决定响应,通常是 )。 + ///
+ public static TagsStreamEntry? GetKeyedTagsStreamEntry(this HttpContext context) => + context.RequestServices + .GetRequiredService() + .Get(context.Request.RouteValues[EndpointConstants.KeyedRouteValue] as string); + + /// 请求里的 key 没有对应的观测流(或压根没注册不具名那一路)时返回 404。 + public static Task WriteKeyedStreamNotFoundAsync(this HttpContext context) + { + var key = context.Request.RouteValues[EndpointConstants.KeyedRouteValue] as string; + + context.Response.StatusCode = StatusCodes.Status404NotFound; + context.Response.ContentType = "text/plain; charset=utf-8"; + return context.Response.WriteAsync( + key is null + ? "未注册不具名观测流(AddTagsStream 未调用)。" + : $"未注册 key = \"{key}\" 的观测流。", + context.RequestAborted); + } +} diff --git a/tests/AotSmokeTest.WebStream/Program.cs b/tests/AotSmokeTest.WebStream/Program.cs index 4da9148..e687d6e 100644 --- a/tests/AotSmokeTest.WebStream/Program.cs +++ b/tests/AotSmokeTest.WebStream/Program.cs @@ -48,6 +48,10 @@ o.HeartbeatInterval = TimeSpan.FromSeconds(30); }); + // 第二路具名流:验证多流的路由/页面在 AOT 下也成立(这一路的项目不启动,只检查结构与 404)。 + builder.Services.AddKeyedTagsProjectCtrl("dev"); + builder.Services.AddKeyedTagsStream("dev"); + app = builder.Build(); app.MapTagsStream(); await app.StartAsync(); @@ -97,6 +101,30 @@ await WaitUntilAsync( Console.WriteLine("[+] 监控页可访问且基路径已注入"); } + // ①c 多流:具名流的页面基路径落在它自己的目录下;未知 key 返回 404。 + using (var ui = await http.GetAsync($"{baseUrl}/stdunit.tags/dev/ui", cts.Token)) + { + Require(ui.IsSuccessStatusCode, $"具名流的监控页应返回 2xx,实际 {(int)ui.StatusCode}"); + var html = await ui.Content.ReadAsStringAsync(cts.Token); + Require( + html.Contains("", StringComparison.Ordinal), + "具名流的监控页基路径应带上它自己的 key"); + } + + using (var treeOfDev = await http.GetAsync($"{baseUrl}/stdunit.tags/dev/tree", cts.Token)) + { + Require(treeOfDev.IsSuccessStatusCode, $"具名流的结构树应返回 2xx,实际 {(int)treeOfDev.StatusCode}"); + var json = await treeOfDev.Content.ReadAsStringAsync(cts.Token); + Require(json.Contains("\"isStarted\":false", StringComparison.Ordinal), "未启动的具名流应返回空结构树"); + } + + using (var unknown = await http.GetAsync($"{baseUrl}/stdunit.tags/nope/tree", cts.Token)) + { + Require((int)unknown.StatusCode == 404, $"未知 key 应返回 404,实际 {(int)unknown.StatusCode}"); + } + + Console.WriteLine("[+] 多流路由正常(具名页面基路径 / 具名结构树 / 未知 key 404)"); + // ② SSE:订阅 → 全量基线 → 改值 → 增量。 using var response = await http.GetAsync( $"{baseUrl}/stdunit.tags/stream", diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs index 3812bfb..cf135a4 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs @@ -12,7 +12,7 @@ namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; /// -/// 的端到端行为:状态(Replay 1)、全量基线、增量、按入口过滤、迟到订阅者。
+/// 的端到端行为:状态(Replay 1)、全量基线、增量、按入口过滤、迟到订阅者。
/// 用真实的 TagsProject + 真实 TagGrpRunner(假驱动),只把 换成可控的假实现, /// 以便精确决定"什么时候启动/停止"。 ///
@@ -56,7 +56,7 @@ public async Task Hub_PushesStateBaselineAndDelta() { using var project = CreateSingleEntryProject(this._scope); var ctrl = new FakeProjectCtrl(); - using var hub = new TagsStreamHub(ctrl, FastOptions()); + using var hub = new TagsStreamHostedService(ctrl, FastOptions()); await hub.StartAsync(default); using var output = new RecordingStream(); @@ -104,7 +104,7 @@ public async Task Subscribe_FiltersByEntry() StreamTestContext.EntryGroup("g2", "fake2", StreamTestContext.Tag("a")))); var ctrl = new FakeProjectCtrl(); - using var hub = new TagsStreamHub(ctrl, FastOptions()); + using var hub = new TagsStreamHostedService(ctrl, FastOptions()); await hub.StartAsync(default); using var output = new RecordingStream(); @@ -137,7 +137,7 @@ public async Task SubscribeAfterStart_ReceivesBaselineImmediately() { using var project = CreateSingleEntryProject(this._scope); var ctrl = new FakeProjectCtrl(); - using var hub = new TagsStreamHub(ctrl, FastOptions()); + using var hub = new TagsStreamHostedService(ctrl, FastOptions()); await hub.StartAsync(default); // 先启动(不跑轮询),再订阅:订阅者应立即拿到状态 + 全量基线。 diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs index 53780ff..3bd91e7 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs @@ -48,15 +48,24 @@ public async Task MapTagsStream_PutsEveryEndpointUnderThePrefix(string prefix) builder.MapTagsStream(prefix); var normalized = prefix.TrimEnd('/'); + var expected = new[] + { + // 不具名流 + $"{normalized}/rescan", + $"{normalized}/stream", + $"{normalized}/tree", + $"{normalized}/ui", + $"{normalized}/value", + // 具名流(同一套形状,多一段 {keyed}) + $"{normalized}/{{keyed}}/rescan", + $"{normalized}/{{keyed}}/stream", + $"{normalized}/{{keyed}}/tree", + $"{normalized}/{{keyed}}/ui", + $"{normalized}/{{keyed}}/value", + }; + Assert.Equal( - new[] - { - $"{normalized}/rescan", - $"{normalized}/stream", - $"{normalized}/tree", - $"{normalized}/ui", - $"{normalized}/value", - }, + expected.OrderBy(p => p, System.StringComparer.Ordinal), Patterns(builder)); } finally @@ -70,8 +79,11 @@ public async Task MapTagsStream_PutsEveryEndpointUnderThePrefix(string prefix) [InlineData("/stdunit.tags/ui", "", "/stdunit.tags/")] [InlineData("/monitor/ui", "", "/monitor/")] [InlineData("/ui", "", "/")] + // 具名流:{prefix}/{key}/ui → base 自动落到 {prefix}/{key}/ + [InlineData("/monitor/dev/ui", "", "/monitor/dev/")] // 末尾多一个 '/' 也不能当成目录 [InlineData("/monitor/ui/", "", "/monitor/")] + [InlineData("/monitor/dev/ui/", "", "/monitor/dev/")] // PathBase(UsePathBase / 反向代理前缀)必须原样带上 [InlineData("/stdunit.tags/ui", "/app", "/app/stdunit.tags/")] public async Task UiPage_BaseHrefComesFromTheRequestPath(string path, string pathBase, string expectedBase) @@ -114,15 +126,22 @@ public async Task MapTagsStream_CanBeMountedOnAnotherGroup() // 挂到父级路由组下:路由自动带上前缀,监控页的 由请求路径算出,不需要额外配置。 builder.MapGroup("/monitor").MapTagsStream(); + var expected = new[] + { + "/monitor/stdunit.tags/rescan", + "/monitor/stdunit.tags/stream", + "/monitor/stdunit.tags/tree", + "/monitor/stdunit.tags/ui", + "/monitor/stdunit.tags/value", + "/monitor/stdunit.tags/{keyed}/rescan", + "/monitor/stdunit.tags/{keyed}/stream", + "/monitor/stdunit.tags/{keyed}/tree", + "/monitor/stdunit.tags/{keyed}/ui", + "/monitor/stdunit.tags/{keyed}/value", + }; + Assert.Equal( - new[] - { - "/monitor/stdunit.tags/rescan", - "/monitor/stdunit.tags/stream", - "/monitor/stdunit.tags/tree", - "/monitor/stdunit.tags/ui", - "/monitor/stdunit.tags/value", - }, + expected.OrderBy(p => p, System.StringComparer.Ordinal), Patterns(builder)); } finally @@ -141,7 +160,7 @@ public async Task MapTagsStream_ReturnsAGroupThatCanRequireAuthorization() builder.MapTagsStream().RequireAuthorization(); var endpoints = builder.DataSources.SelectMany(ds => ds.Endpoints).OfType().ToArray(); - Assert.Equal(5, endpoints.Length); + Assert.Equal(10, endpoints.Length); Assert.All(endpoints, e => Assert.Contains(e.Metadata, m => m is IAuthorizeData)); // 不传 prefix 时必须落在默认前缀下(= EndpointConstants.DefaultPrefix)。 @@ -165,7 +184,7 @@ public async Task MapTagsStream_GroupCanRequireANamedPolicy() builder.MapTagsStream("/internal/tags").RequireAuthorization("ops"); var endpoints = builder.DataSources.SelectMany(ds => ds.Endpoints).OfType().ToArray(); - Assert.Equal(5, endpoints.Length); + Assert.Equal(10, endpoints.Length); Assert.All(endpoints, e => Assert.Contains( e.Metadata.OfType(), m => m.Policy == "ops")); diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs index 8e41c8f..bc90209 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs @@ -1,3 +1,7 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using StdUnit.Tags.Tests.Fakes; @@ -7,12 +11,12 @@ namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; /// -/// AddTagsStream:对外只暴露 ,且它与 hosted service 是同一个实例。 +/// AddTagsStream:对外只暴露 ,且它与 hosted service 是同一个实例; +/// 具名流用 AddKeyedTagsStream(key) 决定观测哪个 。 /// public class TagsStreamRegistrationTests { - [Fact] - public void AddTagsStream_ExposesOnlyTheInterfaceAndReusesTheSameInstance() + private static ServiceCollection CreateServices() { var services = new ServiceCollection(); services.AddLogging(); @@ -21,10 +25,22 @@ public void AddTagsStream_ExposesOnlyTheInterfaceAndReusesTheSameInstance() b.AddFakedSupport(); b.AddFakedTagSupport(); }); + return services; + } + + private static ITagsProject CreateProject(IServiceProvider provider) => + provider.GetRequiredService().Create( + StreamTestContext.ProjectRoot, + new XElement("Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("a")))); + + [Fact] + public void AddTagsStream_ExposesOnlyTheInterfaceAndReusesTheSameInstance() + { + var services = CreateServices(); services.AddTagsStream(); - // ValidateOnBuild + ValidateScopes:注册表本身也要能被校验通过 - // (实现类是 internal,若 DI 的可访问性检查过不去,这里会先炸)。 using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, @@ -42,4 +58,180 @@ public void AddTagsStream_ExposesOnlyTheInterfaceAndReusesTheSameInstance() Assert.False(tree.IsStarted); Assert.Empty(tree.Nodes); } + + [Fact] + public async Task AddTagsStream_BindsToTheKeyedControllerWhenKeyIsGiven() + { + var unnamed = new FakeProjectCtrl(); + var named = new FakeProjectCtrl(); + + var services = CreateServices(); + services.AddSingleton(unnamed); // 后注册的实例覆盖 AddTagsProjectServices 的默认实现 + services.AddKeyedSingleton("dev", named); + services.AddKeyedTagsStream("dev"); + + using var provider = services.BuildServiceProvider(); + using var project = CreateProject(provider); + + var hub = provider.GetRequiredKeyedService("dev"); + var hosted = Assert.Single(provider.GetServices()); + Assert.Same(hub, hosted); + await hosted.StartAsync(default); + try + { + // 不具名的那个被启动,本 hub 不应有任何反应(它绑的是 "dev")。 + unnamed.RaiseStarted(project); + Assert.Equal(0, hub.Epoch); + Assert.False(hub.GetTree().IsStarted); + + // 具名的那个启动,hub 跟上。 + named.RaiseStarted(project); + Assert.Equal(1, hub.Epoch); + Assert.True(hub.GetTree().IsStarted); + Assert.NotEmpty(hub.GetTree().Nodes); + } + finally + { + await hosted.StopAsync(default); + } + } + + [Fact] + public async Task AddTagsStream_RegistersOneIndependentHubPerStream() + { + var unnamed = new FakeProjectCtrl(); + var named = new FakeProjectCtrl(); + + var services = CreateServices(); + services.AddSingleton(unnamed); + services.AddKeyedSingleton("dev", named); + services.AddTagsStream(); // 不具名那一路 + services.AddKeyedTagsStream("dev"); // 具名那一路 + + using var provider = services.BuildServiceProvider(); + using var project = CreateProject(provider); + + var defaultHub = provider.GetRequiredService(); + var devHub = provider.GetRequiredKeyedService("dev"); + Assert.NotSame(defaultHub, devHub); + + // 两路各一个 hosted service,且都指向各自的 hub。 + var hosted = provider.GetServices().ToArray(); + Assert.Equal(2, hosted.Length); + Assert.Contains(hosted, h => ReferenceEquals(h, defaultHub)); + Assert.Contains(hosted, h => ReferenceEquals(h, devHub)); + foreach (var service in hosted) + { + await service.StartAsync(default); + } + + try + { + // 各自只跟自己的控制器走:state/epoch 互不干扰。 + named.RaiseStarted(project); + Assert.Equal(0, defaultHub.Epoch); + Assert.Equal(1, devHub.Epoch); + + unnamed.RaiseStarted(project); + Assert.Equal(1, defaultHub.Epoch); + Assert.Equal(1, devHub.Epoch); + } + finally + { + foreach (var service in hosted) + { + await service.StopAsync(default); + } + } + } + + [Fact] + public void AddTagsStream_RejectsDuplicateStreamKey() + { + var services = CreateServices(); + services.AddKeyedSingleton("dev", new FakeProjectCtrl()); + services.AddKeyedTagsStream("dev"); + + var ex = Assert.Throws(() => services.AddKeyedTagsStream("dev")); + Assert.Contains("\"dev\"", ex.Message); + } + + [Theory] + [InlineData("")] + [InlineData("a/b")] + [InlineData("ui")] + [InlineData("Stream")] + public void AddTagsStream_RejectsUnusableStreamKey(string key) + { + var services = CreateServices(); + services.AddKeyedSingleton(key, new FakeProjectCtrl()); + + Assert.Throws(() => services.AddKeyedTagsStream(key)); + } + + [Fact] + public void AddTagsStream_RejectsASecondUnnamedStream() + { + var services = CreateServices(); + services.AddTagsStream(); + + var ex = Assert.Throws(() => services.AddTagsStream()); + Assert.Contains("AddKeyedTagsStream", ex.Message); + } + + [Fact] + public void AddKeyedTagsStream_RejectsNullAndEmptyKey() + { + var services = CreateServices(); + + Assert.Throws(() => services.AddKeyedTagsStream(null!)); + Assert.Throws(() => services.AddKeyedTagsStream(string.Empty)); + } + + [Fact] + public void AddTagsStream_WhenTheKeyedControllerIsMissing_SaysWhichKey() + { + var services = CreateServices(); + services.AddKeyedTagsStream("dev"); + + using var provider = services.BuildServiceProvider(); + // 具名流要按 key 解析:此时才会去 registry 里创建 hub(进而解析 ctrl)。 + var ex = Assert.Throws( + () => provider.GetRequiredKeyedService("dev")); + + Assert.Contains("\"dev\"", ex.Message); + Assert.Contains("AddKeyedTagsProjectCtrl", ex.Message); + } + + [Fact] + public async Task AddTagsStream_BindsToTheUnnamedControllerByDefault() + { + var unnamed = new FakeProjectCtrl(); + var named = new FakeProjectCtrl(); + + var services = CreateServices(); + services.AddSingleton(unnamed); + services.AddKeyedSingleton("dev", named); + services.AddTagsStream(); // 不传 key + + using var provider = services.BuildServiceProvider(); + using var project = CreateProject(provider); + + var hub = provider.GetRequiredService(); + var hosted = Assert.Single(provider.GetServices()); + await hosted.StartAsync(default); + try + { + named.RaiseStarted(project); + Assert.Equal(0, hub.Epoch); + + unnamed.RaiseStarted(project); + Assert.Equal(1, hub.Epoch); + Assert.True(hub.GetTree().IsStarted); + } + finally + { + await hosted.StopAsync(default); + } + } } From 6d81f3e7262361541910128d389ef1fc107b3cba Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Wed, 28 Oct 2026 23:24:50 +0800 Subject: [PATCH 3/9] samples: add demo for tags stream ui monitor --- .github/workflows/dotnet.yml | 4 + StdUnit.Tags.sln | 7 + samples/WebMonitor/Program.cs | 15 ++ .../WebMonitor/Properties/launchSettings.json | 14 ++ samples/WebMonitor/README.md | 50 ++++++ .../WebMonitor/Tags/TagBackgroundService.cs | 159 ++++++++++++++++++ samples/WebMonitor/Tags/TagsPathDefines.cs | 20 +++ samples/WebMonitor/Tags/_Extensions.cs | 37 ++++ samples/WebMonitor/Tags/line-b/index.xml | 12 ++ samples/WebMonitor/Tags/main/index.xml | 14 ++ samples/WebMonitor/WebMonitor.csproj | 33 ++++ samples/WebMonitor/appsettings.json | 9 + 12 files changed, 374 insertions(+) create mode 100644 samples/WebMonitor/Program.cs create mode 100644 samples/WebMonitor/Properties/launchSettings.json create mode 100644 samples/WebMonitor/README.md create mode 100644 samples/WebMonitor/Tags/TagBackgroundService.cs create mode 100644 samples/WebMonitor/Tags/TagsPathDefines.cs create mode 100644 samples/WebMonitor/Tags/_Extensions.cs create mode 100644 samples/WebMonitor/Tags/line-b/index.xml create mode 100644 samples/WebMonitor/Tags/main/index.xml create mode 100644 samples/WebMonitor/WebMonitor.csproj create mode 100644 samples/WebMonitor/appsettings.json diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 300db0c..d7a85ed 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -167,3 +167,7 @@ jobs: run: | dotnet publish tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj -c Release -r linux-x64 -o ./aot-smoke-out --nologo ./aot-smoke-out/AotSmokeTest.WebStream + + # 示例(常驻服务,不在这里运行)也要保证 AOT 可发布:只 publish,防止它悄悄退化成需要 JIT。 + - name: Publish sample (WebMonitor, NativeAOT) + run: dotnet publish samples/WebMonitor/WebMonitor.csproj -c Release -r linux-x64 -o ./aot-sample-out --nologo diff --git a/StdUnit.Tags.sln b/StdUnit.Tags.sln index db05b97..2e1395a 100644 --- a/StdUnit.Tags.sln +++ b/StdUnit.Tags.sln @@ -67,6 +67,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B9E1E3A0 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotSmokeTest.WebStream", "tests\AotSmokeTest.WebStream\AotSmokeTest.WebStream.csproj", "{87CBE561-3796-48DE-B121-E7534B49FE45}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebMonitor", "samples\WebMonitor\WebMonitor.csproj", "{018E69DC-830B-4DD4-BC68-7716821226E0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -169,6 +171,10 @@ Global {87CBE561-3796-48DE-B121-E7534B49FE45}.Debug|Any CPU.Build.0 = Debug|Any CPU {87CBE561-3796-48DE-B121-E7534B49FE45}.Release|Any CPU.ActiveCfg = Release|Any CPU {87CBE561-3796-48DE-B121-E7534B49FE45}.Release|Any CPU.Build.0 = Release|Any CPU + {018E69DC-830B-4DD4-BC68-7716821226E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {018E69DC-830B-4DD4-BC68-7716821226E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {018E69DC-830B-4DD4-BC68-7716821226E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {018E69DC-830B-4DD4-BC68-7716821226E0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -181,6 +187,7 @@ Global {D9097C66-2929-4F6E-B6A1-C93182F0DFBC} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {1B85FF35-7A81-4CB7-924A-441EC3DA9218} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} {87CBE561-3796-48DE-B121-E7534B49FE45} = {B9E1E3A0-6B7E-4A15-AB8C-CB5E3FFB29C8} + {018E69DC-830B-4DD4-BC68-7716821226E0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6FF52024-3F01-4AC2-B991-CEE89ACF9FFC} diff --git a/samples/WebMonitor/Program.cs b/samples/WebMonitor/Program.cs new file mode 100644 index 0000000..21ae73b --- /dev/null +++ b/samples/WebMonitor/Program.cs @@ -0,0 +1,15 @@ +using StdUnit.Tags.WebStream; +using WebMonitor.Tags; + +var builder = WebApplication.CreateSlimBuilder(args); + +// 只做"绑定":驱动、控制器、观测流、后台任务全部在 Tags/_Extensions.cs 里注册 +// (与 `dotnet new tags.web` 的分层一致:Program.cs 只负责宿主与管线)。 +builder.Services.AddTags(); + +var app = builder.Build(); + +// WebStream 的端点:结构树 / 值流(SSE) / 写命令 / 监控页;不具名流与每个具名流各一套。 +app.MapTagsStream(); + +app.Run(); diff --git a/samples/WebMonitor/Properties/launchSettings.json b/samples/WebMonitor/Properties/launchSettings.json new file mode 100644 index 0000000..88ca2b6 --- /dev/null +++ b/samples/WebMonitor/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/WebMonitor/README.md b/samples/WebMonitor/README.md new file mode 100644 index 0000000..14c571e --- /dev/null +++ b/samples/WebMonitor/README.md @@ -0,0 +1,50 @@ +# WebMonitor —— WebStream 监控 demo(NativeAOT 可发布) + +与 `dotnet new tags.web` 生成的模板同构,只是把 Blazor 监控页换成了 **WebStream** 的只读观测流: + +``` +WebMonitor/ + Program.cs # 宿主与管线:AddTags() + MapTagsStream() + appsettings.json + Properties/launchSettings.json + Tags/ # 测点相关的一切 + _Extensions.cs # DI 注册入口:驱动、控制器、观测流、后台任务 + TagBackgroundService.cs # 启停两路轮询 + 演示用的值模拟器 + TagsPathDefines.cs # 入口名 / key / 测点路径常量 + main/index.xml # 不具名流观测的项目 + line-b/index.xml # 具名流(key=line-b)观测的项目 +``` + +## 两路观测流 + +| 流 | 控制器 | 项目 | 监控页 | +|---|---|---|---| +| 不具名 | `AddTagsProjectServices` 注册的 `ITagsProjectCtrl` | `Tags/main` | `/stdunit.tags/ui` | +| 具名 `line-b` | `AddKeyedTagsProjectCtrl("line-b")` | `Tags/line-b` | `/stdunit.tags/line-b/ui` | + +数据都是磁盘文本文件(SimpleFiles,`autoCreateFile` 会自动建),`TagBackgroundService` +每秒通过 `ITagsProject.WriteIntent` 把值投递到轮询线程上改一次,所以页面上能看到值实时跳动。 + +## 运行 + +```powershell +dotnet run --project samples/WebMonitor/WebMonitor.csproj +``` + +打开 (具名流:`/stdunit.tags/line-b/ui`)。 + +## AOT 发布 + +```powershell +# Linux 需要 clang + zlib1g-dev +dotnet publish samples/WebMonitor/WebMonitor.csproj -c Release -r linux-x64 +dotnet publish samples/WebMonitor/WebMonitor.csproj -c Release -r win-x64 +``` + +产物是原生可执行文件(不依赖 .NET 运行时)。示例刻意避开反射路径:Minimal API 的 +`RequestDelegate` 重载、源生成 JSON(`TagsStreamJsonContext`)、嵌入的单文件监控页、 +`XElement` 代码里建项目描述(不依赖内容文件的相对路径)。CI 的 `aot-smoke` job 会 publish 本示例。 + +> 具名流的 key 同时是路由段;本示例还把它当目录名用(`Tags/line-b`),所以**别用 Windows +> 保留设备名**(`aux`/`con`/`nul`/`com1`…)——`Directory.CreateDirectory("...\line-b")` 换成 `aux` +> 会变成设备路径 `\\.\aux` 并抛 `DirectoryNotFoundException`。库本身只用 key 做路由段,不校验这一点。 diff --git a/samples/WebMonitor/Tags/TagBackgroundService.cs b/samples/WebMonitor/Tags/TagBackgroundService.cs new file mode 100644 index 0000000..b99aaf5 --- /dev/null +++ b/samples/WebMonitor/Tags/TagBackgroundService.cs @@ -0,0 +1,159 @@ +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using StdUnit.Tags; + +namespace WebMonitor.Tags; + +/// +/// 启停两路测点项目,并跑一个只用于演示的值模拟器。
+/// 与模板 tags.web 的 TagBackgroundService 同一形状:轮询必须在这里(宿主启动之后)开始, +/// 观测流的 hub 才能先拿到"项目已启动"事件并挂上"轮次完成"回调。 +///
+internal sealed class TagBackgroundService : BackgroundService +{ + private readonly ITagsProjectCtrl _ctrl; + private readonly ITagsProjectCtrl _lineBCtrl; + private readonly ILogger _logger; + + public TagBackgroundService( + ITagsProjectCtrl ctrl, + [FromKeyedServices(TagsPathDefines.LineBKey)] ITagsProjectCtrl lineBCtrl, + ILogger logger) + { + this._ctrl = ctrl; + this._lineBCtrl = lineBCtrl; + this._logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var tagsRoot = Path.Combine(AppContext.BaseDirectory, "Tags"); + var mainDir = Path.Combine(tagsRoot, "main"); + var lineBDir = Path.Combine(tagsRoot, "line-b"); + + var main = this._ctrl.StartPollAsync(mainDir, LoadProjectXml(mainDir), this.Hook); + var lineB = this._lineBCtrl.StartPollAsync(lineBDir, LoadProjectXml(lineBDir), this.Hook); + var simulator = this.SimulateAsync(stoppingToken); + + try + { + await Task.WhenAll(main, lineB, simulator); + } + catch (OperationCanceledException) + { + // 正常停机:StartPollAsync / 模拟器都以 OCE 结束 + } + finally + { + await StopQuietlyAsync(this._ctrl); + await StopQuietlyAsync(this._lineBCtrl); + } + } + + /// + /// 读 index.xml,并把通道里的**相对** BaseDir 焊到这个文件所在的目录。
+ /// SimpleFiles 的 BaseDir 是按进程的工作目录解析的(Path.Combine(BaseDir, address)), + /// 直接用 . 会让两路项目抢同一个工作目录下的同名文件、`dotnet run` 时还会写进源码目录。 + ///
+ private static XElement LoadProjectXml(string dir) + { + var root = XElement.Load(Path.Combine(dir, "index.xml")); + foreach (var channel in root.Elements("Channel")) + { + var baseDir = channel.Element("BaseDir"); + if (baseDir is not null && baseDir.Value.Length > 0 && !Path.IsPathRooted(baseDir.Value)) + { + baseDir.Value = Path.GetFullPath(Path.Combine(dir, baseDir.Value)); + } + } + + return root; + } + + private Task Hook(ITagsProject project, IServiceProvider services, CancellationToken ct) { + project.RunnerStarted += (grp, ch) => + { + this._logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName()); + return Task.CompletedTask; + }; + project.RunnerCrashed += (grp, ch, ex) => + { + this._logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName()); + return Task.CompletedTask; + }; + return Task.CompletedTask; + } + + /// + /// 演示用模拟器:每秒把值"投递"到对应入口的轮询线程上改(), + /// 由 runner 刷写回文件,下一轮再读回来——页面上就能看到值实时跳动。 + /// + private async Task SimulateAsync(CancellationToken ct) + { + var tick = 0; + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (await timer.WaitForNextTickAsync(ct)) + { + tick++; + await TrySetAsync(this._ctrl, TagsPathDefines.MainEntry, TagsPathDefines.Counter, tick, ct); + await TrySetAsync(this._ctrl, TagsPathDefines.MainEntry, TagsPathDefines.Level, 50 + (tick % 10), ct); + await TrySetAsync(this._lineBCtrl, TagsPathDefines.LineBEntry, TagsPathDefines.Counter, tick * 2, ct); + } + } + + private async Task TrySetAsync(ITagsProjectCtrl ctrl, string entry, string tagName, int value, CancellationToken ct) + { + var project = ctrl.Project; + if (project is null) + { + return; + } + + Task completion; + try + { + if (!project.WriteIntent( + entry, + (grp, _) => + { + grp.Descendant(tagName).AsTag().Value = value; + return ValueTask.CompletedTask; + }, + out completion)) + { + return; // 意图队列满:这一拍丢掉,下一拍再来 + } + } + catch (Exception) + { + return; // 项目正在停止/切换 + } + + var finished = await Task.WhenAny(completion, Task.Delay(TimeSpan.FromSeconds(2), ct)); + if (!ReferenceEquals(finished, completion)) + { + return; // 入口未轮询(被禁用/已停止) + } + + try + { + await completion; + } + catch (Exception) + { + // 写入失败不影响 demo 继续跑(下一拍重试) + } + } + + private static async Task StopQuietlyAsync(ITagsProjectCtrl ctrl) + { + try + { + await ctrl.StopAsync(); + } + catch (Exception) + { + // 停机清理失败不影响退出 + } + } +} diff --git a/samples/WebMonitor/Tags/TagsPathDefines.cs b/samples/WebMonitor/Tags/TagsPathDefines.cs new file mode 100644 index 0000000..6861c85 --- /dev/null +++ b/samples/WebMonitor/Tags/TagsPathDefines.cs @@ -0,0 +1,20 @@ +namespace WebMonitor.Tags; + +/// +/// 入口名 / 观测流 key / 测点路径常量。与两个 index.xml 里的名字逐字一致。 +/// +internal static class TagsPathDefines +{ + /// 不具名流对应的项目入口名(Tags/main/index.xml)。 + public static readonly string MainEntry = "main"; + + /// 具名流的 key:同时是路由段 /stdunit.tags/line-b/ui 与控制器注册键。 + public const string LineBKey = "line-b"; + + /// 具名流对应的项目入口名(Tags/line-b/index.xml)。 + public static readonly string LineBEntry = "line-b"; + + public static readonly string Counter = "counter"; + + public static readonly string Level = "level"; +} diff --git a/samples/WebMonitor/Tags/_Extensions.cs b/samples/WebMonitor/Tags/_Extensions.cs new file mode 100644 index 0000000..921dda4 --- /dev/null +++ b/samples/WebMonitor/Tags/_Extensions.cs @@ -0,0 +1,37 @@ +using StdUnit.Tags; +using StdUnit.Tags.SimpleFiles; +using StdUnit.Tags.WebStream; + +namespace WebMonitor.Tags; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddTags(this IServiceCollection services) + { + services.AddTagsProjectServices(builder => + { + // 本示例只用 SimpleFiles(数据是磁盘上的文本文件),不依赖任何硬件/网络。 + builder.AddSimpleFilesChannel() + .AddSimpleFilesDirectTagBuilder(); + }); + + // 第二路控制器(具名注册):项目配置在 Tags/line-b/index.xml。 + services.AddKeyedTagsProjectCtrl(TagsPathDefines.LineBKey); + + // WebStream:不具名流 + 一路具名流(多流各有一套端点与监控页)。 + services.AddTagsStream(o => + { + o.ReconcileInterval = TimeSpan.FromMilliseconds(20); + o.HeartbeatInterval = TimeSpan.FromSeconds(10); + }); + services.AddKeyedTagsStream(TagsPathDefines.LineBKey, o => + { + o.ReconcileInterval = TimeSpan.FromMilliseconds(20); + o.HeartbeatInterval = TimeSpan.FromSeconds(10); + }); + + // 后台任务:启动两路轮询 + 演示用的值模拟器。 + services.AddHostedService(); + return services; + } +} diff --git a/samples/WebMonitor/Tags/line-b/index.xml b/samples/WebMonitor/Tags/line-b/index.xml new file mode 100644 index 0000000..05d1941 --- /dev/null +++ b/samples/WebMonitor/Tags/line-b/index.xml @@ -0,0 +1,12 @@ + + + + + . + + + + + + + diff --git a/samples/WebMonitor/Tags/main/index.xml b/samples/WebMonitor/Tags/main/index.xml new file mode 100644 index 0000000..f0554a4 --- /dev/null +++ b/samples/WebMonitor/Tags/main/index.xml @@ -0,0 +1,14 @@ + + + + + . + + + + + + + + diff --git a/samples/WebMonitor/WebMonitor.csproj b/samples/WebMonitor/WebMonitor.csproj new file mode 100644 index 0000000..e68237a --- /dev/null +++ b/samples/WebMonitor/WebMonitor.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + false + + + true + true + + + + + + + + + + + + + Always + + + Always + + + diff --git a/samples/WebMonitor/appsettings.json b/samples/WebMonitor/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/WebMonitor/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 9bbf65276f168e6068c37bb998c84667614834fa Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Wed, 28 Oct 2026 23:42:13 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=EF=BC=9AStdUnit?= =?UTF-8?q?.Tags.WebStream=20->=20S.T.WebUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet.yml | 8 ++++---- AGENTS.md | 6 +++--- README.md | 8 ++++---- StdUnit.Tags.sln | 6 +++--- ...346\241\206\346\236\266\345\267\256\345\274\202.md" | 4 ++-- samples/WebMonitor/Program.cs | 4 ++-- samples/WebMonitor/README.md | 4 ++-- samples/WebMonitor/Tags/_Extensions.cs | 4 ++-- samples/WebMonitor/WebMonitor.csproj | 4 ++-- .../Commands/CommandDto.cs | 2 +- .../Commands/CommandsEndpoint.cs | 2 +- .../Commands/TagsStreamCommands.cs | 2 +- .../Core/EndpointConstants.cs | 2 +- .../Core/ITagsStreamHub.cs | 4 ++-- .../Core/Index/EntryStream.cs | 2 +- .../Core/Index/EntryValuesCollector.cs | 2 +- .../Core/Index/ProjectIndex.cs | 2 +- .../Core/Index/TagNodes.cs | 2 +- .../Core/Registry/TagsStreamDescriptor.cs | 2 +- .../Core/Registry/TagsStreamEntry.cs | 2 +- .../Core/Registry/TagsStreamRegistry.cs | 2 +- .../Core/TagsStreamHub.cs | 4 ++-- .../Core/TagsStreamJsonContext.cs | 2 +- .../Core/TagsStreamOptions.cs | 2 +- .../Sse/SseFrames.cs | 2 +- .../Sse/SseFramesPiper.cs | 2 +- .../Sse/StreamDto.cs | 2 +- .../Sse/StreamEndpoint.cs | 2 +- .../StdUnit.Tags.WebUi.csproj} | 2 +- .../Tree/TreeDto.cs | 2 +- .../Tree/TreeEndpoint.cs | 2 +- .../Ui/TagsStreamUi.cs | 4 ++-- .../Ui/index.html | 0 .../_Extensions.cs | 4 ++-- .../_Extensions_HttpContext.cs | 2 +- .../paket.references | 0 src/publish-packages.ps1 | 2 +- .../AotSmokeTest.WebUi.csproj} | 4 ++-- .../Program.cs | 10 +++++----- .../StdUnit.Tags.Tests.NetCoreOnly.csproj | 4 ++-- .../{WebStream => WebUi}/EntryValuesCollectorTests.cs | 6 +++--- .../{WebStream => WebUi}/ProjectIndexTests.cs | 6 +++--- .../{WebStream => WebUi}/SseFramesTests.cs | 6 +++--- .../StreamEndpointParseEntriesTests.cs | 4 ++-- .../{WebStream => WebUi}/StreamTestContext.cs | 4 ++-- .../{WebStream => WebUi}/TagsStreamCommandsTests.cs | 6 +++--- .../{WebStream => WebUi}/TagsStreamHubTests.cs | 6 +++--- .../{WebStream => WebUi}/TagsStreamMappingTests.cs | 4 ++-- .../TagsStreamRegistrationTests.cs | 4 ++-- 49 files changed, 86 insertions(+), 86 deletions(-) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Commands/CommandDto.cs (97%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Commands/CommandsEndpoint.cs (98%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Commands/TagsStreamCommands.cs (99%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/EndpointConstants.cs (97%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/ITagsStreamHub.cs (92%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/Index/EntryStream.cs (96%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/Index/EntryValuesCollector.cs (99%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/Index/ProjectIndex.cs (99%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/Index/TagNodes.cs (95%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/Registry/TagsStreamDescriptor.cs (95%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/Registry/TagsStreamEntry.cs (96%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/Registry/TagsStreamRegistry.cs (98%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/TagsStreamHub.cs (99%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/TagsStreamJsonContext.cs (96%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Core/TagsStreamOptions.cs (98%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Sse/SseFrames.cs (98%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Sse/SseFramesPiper.cs (98%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Sse/StreamDto.cs (99%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Sse/StreamEndpoint.cs (98%) rename src/{StdUnit.Tags.WebStream/StdUnit.Tags.WebStream.csproj => StdUnit.Tags.WebUi/StdUnit.Tags.WebUi.csproj} (97%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Tree/TreeDto.cs (99%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Tree/TreeEndpoint.cs (95%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Ui/TagsStreamUi.cs (97%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/Ui/index.html (100%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/_Extensions.cs (99%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/_Extensions_HttpContext.cs (97%) rename src/{StdUnit.Tags.WebStream => StdUnit.Tags.WebUi}/paket.references (100%) rename tests/{AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj => AotSmokeTest.WebUi/AotSmokeTest.WebUi.csproj} (78%) rename tests/{AotSmokeTest.WebStream => AotSmokeTest.WebUi}/Program.cs (96%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/EntryValuesCollectorTests.cs (96%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/ProjectIndexTests.cs (98%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/SseFramesTests.cs (95%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/StreamEndpointParseEntriesTests.cs (92%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/StreamTestContext.cs (98%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/TagsStreamCommandsTests.cs (98%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/TagsStreamHubTests.cs (98%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/TagsStreamMappingTests.cs (98%) rename tests/StdUnit.Tags.Tests.NetCoreOnly/{WebStream => WebUi}/TagsStreamRegistrationTests.cs (99%) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index d7a85ed..b307bc6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -128,7 +128,7 @@ jobs: # 三个测试: # * `AotSmokeTest.LogicetPlugins`:无 的正常加载 + TryAddLogicet + AOT 下配了 必须快速失败; # * `AotSmokeTest.SimpleFiles`:基本类型测点 + JSON 测点(源生成元数据)读写 + AOT 下缺元数据必须快速失败; - # * `AotSmokeTest.WebStream`:起 Kestrel,结构树的**多态 JSON 序列化**([JsonDerivedType])+ SSE 从基线到增量的全链路。 + # * `AotSmokeTest.WebUi`:起 Kestrel,结构树的**多态 JSON 序列化**([JsonDerivedType])+ SSE 从基线到增量的全链路。 # 为什么在 Linux 跑:NativeAOT 在 ubuntu runner 上只需 clang + zlib1g-dev,比占 Windows runner 便宜,更重要的是不用排队; runs-on: ubuntu-latest @@ -163,10 +163,10 @@ jobs: dotnet publish tests/AotSmokeTest.SimpleFiles/AotSmokeTest.SimpleFiles.csproj -c Release -r linux-x64 -o ./aot-smoke-out --nologo ./aot-smoke-out/AotSmokeTest.SimpleFiles - - name: Smoke test (WebStream, NativeAOT) + - name: Smoke test (WebUi, NativeAOT) run: | - dotnet publish tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj -c Release -r linux-x64 -o ./aot-smoke-out --nologo - ./aot-smoke-out/AotSmokeTest.WebStream + dotnet publish tests/AotSmokeTest.WebUi/AotSmokeTest.WebUi.csproj -c Release -r linux-x64 -o ./aot-smoke-out --nologo + ./aot-smoke-out/AotSmokeTest.WebUi # 示例(常驻服务,不在这里运行)也要保证 AOT 可发布:只 publish,防止它悄悄退化成需要 JIT。 - name: Publish sample (WebMonitor, NativeAOT) diff --git a/AGENTS.md b/AGENTS.md index f8f51f0..1b65275 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,12 +25,12 @@ dotnet test --no-build --collect:"XPlat Code Coverage" --results-directory ./Tes CI(`.github/workflows/dotnet.yml`)有**三个 job**: - **`build`(ubuntu-latest,权威)**:整解 `dotnet build StdUnit.Tags.sln -c Release`(含 net472 的**编译**校验——引用程序集由 SDK 隐式引入的 `Microsoft.NETFramework.ReferenceAssemblies` 提供)+ `dotnet test -f net8.0`(带覆盖率上报 codecov)。 - **`net472-on-mono`(ubuntu-latest)**:`apt-get install -y mono-complete` 自己装 Mono,跑 `dotnet test -f net472`,让 net472 的**运行**测试也自动化、又不占 Windows runner 的排队时间。**mono ≠ .NET Framework**,它绿只说明"大概率没退化",(权威结果仍归发版工作流 `release-nuget.yml` / `release-baget.yml`,windows-latest 全 TFM)+ 本机 Windows。当前是**硬性把关**(未设 `continue-on-error`);万一出现 mono 专有的伪失败,可在 job 上加 `continue-on-error: true` 降级为补充信号。mono job 红时先看是不是"mono 与真框架的差异",别急着改产品代码。 -- **`aot-smoke`(ubuntu-latest)**:三个示例各自 `dotnet publish ... -r linux-x64`(需 `clang` + `zlib1g-dev`)后**运行原生产物**:`AotSmokeTest.LogicetPlugins` 验证"无 `` 的正常加载 + `TryAddLogicet` + AOT 下配了 `` 必须快速失败",`AotSmokeTest.SimpleFiles` 验证"基本类型测点 + JSON 测点(源生成元数据)读写 + AOT 下缺元数据必须快速失败",`AotSmokeTest.WebStream` 验证"起 Kestrel + 结构树的多态 JSON 序列化(`[JsonDerivedType]` 源生成)+ SSE 从全量基线到增量"。改了 AOT 相关代码(`IsAotCompatible`、`[DynamicallyAccessedMembers]`、`Compat/*`、插件加载路径、WebStream 的 DTO/序列化)时以它为准;publish 输出里的 `IL2104`/`IL3053` 来自第三方 `McMaster.NETCore.Plugins`,属已知噪声。 +- **`aot-smoke`(ubuntu-latest)**:三个示例各自 `dotnet publish ... -r linux-x64`(需 `clang` + `zlib1g-dev`)后**运行原生产物**:`AotSmokeTest.LogicetPlugins` 验证"无 `` 的正常加载 + `TryAddLogicet` + AOT 下配了 `` 必须快速失败",`AotSmokeTest.SimpleFiles` 验证"基本类型测点 + JSON 测点(源生成元数据)读写 + AOT 下缺元数据必须快速失败",`AotSmokeTest.WebUi` 验证"起 Kestrel + 结构树的多态 JSON 序列化(`[JsonDerivedType]` 源生成)+ SSE 从全量基线到增量"。改了 AOT 相关代码(`IsAotCompatible`、`[DynamicallyAccessedMembers]`、`Compat/*`、插件加载路径、WebUi 的 DTO/序列化)时以它为准;publish 输出里的 `IL2104`/`IL3053` 来自第三方 `McMaster.NETCore.Plugins`,属已知噪声。 - 两条 Linux job 统一遵循 **"`ubuntu-latest` + 依赖自装"**,不钉镜像版本:本仓库自己装依赖(mono / clang+zlib),就没有理由把 job 的寿命绑在镜像标签上——`ubuntu-22.04` 已被官方公告 2026-09-17 起弃用、2027-04-17 退役(`actions/runner-images#14254`,期间还有 brownout 会直接判红),`ubuntu-latest` 也已宣布 2026-11 起为 26.04。 - 参考实测(2026-10,本机 WSL):mono 上的结果与 Windows 真 .NET Framework 一致。 - 改了 `Compat/*` 或 `#if NETFRAMEWORK` 分支时,仍建议在本机 Windows 上跑一次 `-f net472`(或在 PR 里看 `net472-on-mono`)。 `samples/WpfDemo`(net8.0-windows + WPF)**已从解决方案移除**,目录保留、需要时在本机 Windows 上单独 `dotnet build samples/WpfDemo/WpfDemo.csproj`(只要它还留在解决方案里,Linux 上的整解构建就会失败)。 -`tests/AotSmokeTest.LogicetPlugins`、`tests/AotSmokeTest.SimpleFiles` 与 `tests/AotSmokeTest.WebStream`(都是 net8.0 + `PublishAot=true`)**在解决方案内**(整解构建顺带编译它们),但**不要**改成 net8.0-windows 或其他不可跨平台的形态——CI 的 `aot-smoke` job 要在 Linux 上 publish 并运行它们。 +`tests/AotSmokeTest.LogicetPlugins`、`tests/AotSmokeTest.SimpleFiles` 与 `tests/AotSmokeTest.WebUi`(都是 net8.0 + `PublishAot=true`)**在解决方案内**(整解构建顺带编译它们),但**不要**改成 net8.0-windows 或其他不可跨平台的形态——CI 的 `aot-smoke` job 要在 Linux 上 publish 并运行它们。 - SDK 固定为 **8.0.102**(`global.json`,`rollForward: minor`)。 - 依赖用 **Paket** 管理。**不要 `dotnet add package`**:请改 `paket.dependencies` 后运行 `dotnet paket install`(会更新 `paket.lock`)。 @@ -73,7 +73,7 @@ CI(`.github/workflows/dotnet.yml`)有**三个 job**: - ⚠ 位置敏感的坑:`PublishAot=true` 会被 SDK 写进 `runtimeconfig.json`,使 `RuntimeFeature.IsDynamicCodeSupported` 在 **IL+JIT** 下也是 `false`;宿主据此判定"不支持动态加载",因此 `` 在 AOT(以及 `PublishAot=true` 的 IL 构建)下 **必须快速失败**(`TagsProjectNotSupportedException`),**不要**让它被 `LogicetLoader` 的通用 `catch` 吞掉——配了却静默不跑业务逻辑是硬红线。 - `tests/AotSmokeTest.LogicetPlugins` / `tests/AotSmokeTest.SimpleFiles` / `tests/AotSmokeTest.WebStream` 是这些链路的端到端验证(CI 的 `aot-smoke` job)。 + `tests/AotSmokeTest.LogicetPlugins` / `tests/AotSmokeTest.SimpleFiles` / `tests/AotSmokeTest.WebUi` 是这些链路的端到端验证(CI 的 `aot-smoke` job)。 - net472 缺 init-only setter 时,在 `paket.references` 里加 `IsExternalInit`(已有先例:Core / StdUnit.Tags / ComScanner / OpcUaClient / SimpleFiles)。 - **net472 的引用程序集没有可空标注**,`string.IsNullOrEmpty` 之类的 BCL 后置条件看不到,会出现 net472 专属的 CS8601/CS8603/CS8604 假阳性。改用语言级判空(`x is null || x.Length == 0`),**不要用 `NoWarn` 压掉**。 - 不要用 `| Select-Object -First n` 过滤 `dotnet paket`/`dotnet build` 的长输出:会在第 n 行处终止上游进程。先 `Out-File` 再 grep。 diff --git a/README.md b/README.md index 7856eac..4b534db 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,13 @@ dotnet new tags.driver -n your-project.name -D YourDriver - `StdUnit.Tags.R3Extensions`: `Cysharp/R3`扩展,只依赖于`StdUnit.Tags.Core` - `StdUnit.Tags.BlazorLib.Core`: Blazor 类库,包含核心功能抽象,以及一个极简的监控页面。 - `StdUnit.Tags.McpServer`: 这是一个把`StdUnit.Tags`暴露成 [Model Context Protocol Server](https://modelcontextprotocol.io/) 的类库。 -- `StdUnit.Tags.WebStream`: 只读观测流(ASP.NET Core)。结构树走一次性 `GET`,测点值按"入口 + 轮次"以 SSE(`text/event-stream`)推送;不依赖 Blazor / SignalR,可在 NativeAOT 宿主下使用。自带一个极简监控页(`GET /stdunit.tags/ui`,单文件 HTML + 内嵌 JS,无需 npm 工程),并附带两个写命令(`POST /stdunit.tags/value` 改值、`POST /stdunit.tags/rescan` 标记未扫描)。端点只允许改**路由前缀**(`app.MapTagsStream("/internal/tags")`),相对路径是线协议的一部分;**不内置任何鉴权**,暴露到非受信网络前请对返回的路由组加策略,例如 `app.MapTagsStream().RequireAuthorization()`。 +- `StdUnit.Tags.WebUi`: 只读观测流(ASP.NET Core)。结构树走一次性 `GET`,测点值按"入口 + 轮次"以 SSE(`text/event-stream`)推送;不依赖 Blazor / SignalR,可在 NativeAOT 宿主下使用。自带一个极简监控页(`GET /stdunit.tags/ui`,单文件 HTML + 内嵌 JS,无需 npm 工程),并附带两个写命令(`POST /stdunit.tags/value` 改值、`POST /stdunit.tags/rescan` 标记未扫描)。端点只允许改**路由前缀**(`app.MapTagsStream("/internal/tags")`),相对路径是线协议的一部分;**不内置任何鉴权**,暴露到非受信网络前请对返回的路由组加策略,例如 `app.MapTagsStream().RequireAuthorization()`。 > 备注:源码目录中还有一个 `StdUnit.Tags.BlazorLib`(不是 `StdUnit.Tags.BlazorLib.Core`),放的是常用硬件设备的编辑器和设计器实现。这些只是我把脑子里的片段随手写下来的实验性代码——因为将来可能做成类库,里面不少东西被标成了`public`——目前它只是实验场,不对外发布。 相关测试统一放在 `tests/` 下的两个测试项目中: - `StdUnit.Tags.Tests`: 上述子项目的自动化测试(多目标 `net8.0` + `net472`)。 -- `StdUnit.Tags.Tests.NetCoreOnly`: **仅 `net8.0`** 的测试项目,专门存放无法面向 net472 的测试(Blazor / ASP.NET Core 等);目前承载 `StdUnit.Tags.WebStream` 的测试。 +- `StdUnit.Tags.Tests.NetCoreOnly`: **仅 `net8.0`** 的测试项目,专门存放无法面向 net472 的测试(Blazor / ASP.NET Core 等);目前承载 `StdUnit.Tags.WebUi` 的测试。 这里的自动化测试会在 `main`/`dev` 的推送、PR、发布新版时自动执行,并把结果传到 [codecov.io](https://app.codecov.io/gh/StdUnit/StdUnit.Tags)。 @@ -125,7 +125,7 @@ dotnet new tags.driver -n your-project.name -D YourDriver | R3扩展 | `StdUnit.Tags.R3Extensions` | ✅ 支持 | | McpServer扩展 | `StdUnit.Tags.McpServer` | ✅ 支持 | | BlazorLibCore扩展 | `StdUnit.Tags.BlazorLib.Core` | ✅ 支持 | -| 只读观测流 | `StdUnit.Tags.WebStream` | 🧪 实验 | +| 只读观测流 | `StdUnit.Tags.WebUi` | 🧪 实验 | 上表列出的包中,标记为“实验”的,`..` 里的 `` 跳变可能包含破坏性更新;标记为“支持”的,只有 `` 跳变才可能包含破坏性更新。核心包(`StdUnit.Tags`、`StdUnit.Tags.Core`)不属于驱动/扩展、因此不在此表内,按语义化版本处理——同样只有 `` 跳变才可能包含破坏性更新。 @@ -155,7 +155,7 @@ AI 可以直接参考 [StdUnit.Tags —— 给 AI 的参考手册](https://tags. | `StdUnit.Tags.S7` / `ModbusTcp` / `Hjzk` / `ZLan` / `OpcUaClient` / `ComScanner` / `SimpleFiles` | `net8.0` + `net472` | | `StdUnit.Tags.McpServer` | `net8.0` + `net472`(经 `ModelContextProtocol` 核心包的 `netstandard2.0` 资产) | | `StdUnit.Tags.BlazorLib` / `BlazorLib.Core` | 仅 `net8.0` | -| `StdUnit.Tags.WebStream` | 仅 `net8.0`(ASP.NET Core;依赖 `Microsoft.AspNetCore.App` 框架引用) | +| `StdUnit.Tags.WebUi` | 仅 `net8.0`(ASP.NET Core;依赖 `Microsoft.AspNetCore.App` 框架引用) | | `StdUnit.Tags.SchemaGenerator` | `netstandard2.0`(源生成器) | 两个框架的能力差异在 `Logicet` 插件上(共两处):见 [netfx 的插件化限制](#netfx-的插件化限制) 与 [AOT 的插件化限制](#aot-的插件化限制)。 diff --git a/StdUnit.Tags.sln b/StdUnit.Tags.sln index 2e1395a..c085599 100644 --- a/StdUnit.Tags.sln +++ b/StdUnit.Tags.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.8.12023.21 @@ -61,11 +61,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StdUnit.Tags.Tests", "tests EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2215F920-F9B4-4801-88A1-5F3E3E65B9CA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StdUnit.Tags.WebStream", "src\StdUnit.Tags.WebStream\StdUnit.Tags.WebStream.csproj", "{1B85FF35-7A81-4CB7-924A-441EC3DA9218}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StdUnit.Tags.WebUi", "src\StdUnit.Tags.WebUi\StdUnit.Tags.WebUi.csproj", "{1B85FF35-7A81-4CB7-924A-441EC3DA9218}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B9E1E3A0-6B7E-4A15-AB8C-CB5E3FFB29C8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotSmokeTest.WebStream", "tests\AotSmokeTest.WebStream\AotSmokeTest.WebStream.csproj", "{87CBE561-3796-48DE-B121-E7534B49FE45}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotSmokeTest.WebUi", "tests\AotSmokeTest.WebUi\AotSmokeTest.WebUi.csproj", "{87CBE561-3796-48DE-B121-E7534B49FE45}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebMonitor", "samples\WebMonitor\WebMonitor.csproj", "{018E69DC-830B-4DD4-BC68-7716821226E0}" EndProject diff --git "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" index 8daf8da..2384c06 100644 --- "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" +++ "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" @@ -37,7 +37,7 @@ MCP 测试已在 `McpServer` 支持 net472 后合并回主测试项目(走 `Mo | `StdUnit.Tags.RxExtensions` | NotPlanned | 底层 `System.Reactive` 目前对 AOT 支持不好 | | `StdUnit.Tags.McpServer` | Ready | | | `StdUnit.Tags.BlazorLib.Core` | NotPlanned | Blazor 团队已明确表述不支持 Server 模式的 AOT | -| `StdUnit.Tags.WebStream` | Ready | 只用 Minimal API + SSE + JSON 源生成;**不依赖** SignalR / Session / MVC(这三者都不能 AOT)。宿主是否 `PublishAot` 由宿主自己决定 | +| `StdUnit.Tags.WebUi` | Ready | 只用 Minimal API + SSE + JSON 源生成;**不依赖** SignalR / Session / MVC(这三者都不能 AOT)。宿主是否 `PublishAot` 由宿主自己决定 | | **驱动** | | | | `StdUnit.Tags.S7` | Ready | | | `StdUnit.Tags.ModbusTcp` | Ready | | @@ -51,4 +51,4 @@ MCP 测试已在 `McpServer` 支持 net472 后合并回主测试项目(走 `Mo > 备注: CI中提供了端到端的AOT冒烟示例(位于 `tests/`): > - `tests/AotSmokeTest.LogicetPlugins`(Core + StdUnit.Tags + ModbusTcp) > - `tests/AotSmokeTest.SimpleFiles`(Core + StdUnit.Tags + SimpleFiles) -> - `tests/AotSmokeTest.WebStream`(WebStream:起 Kestrel,验证多态 JSON 序列化与 SSE 全链路) \ No newline at end of file +> - `tests/AotSmokeTest.WebUi`(WebUi:起 Kestrel,验证多态 JSON 序列化与 SSE 全链路) \ No newline at end of file diff --git a/samples/WebMonitor/Program.cs b/samples/WebMonitor/Program.cs index 21ae73b..14f13a5 100644 --- a/samples/WebMonitor/Program.cs +++ b/samples/WebMonitor/Program.cs @@ -1,4 +1,4 @@ -using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebUi; using WebMonitor.Tags; var builder = WebApplication.CreateSlimBuilder(args); @@ -9,7 +9,7 @@ var app = builder.Build(); -// WebStream 的端点:结构树 / 值流(SSE) / 写命令 / 监控页;不具名流与每个具名流各一套。 +// WebUi 的端点:结构树 / 值流(SSE) / 写命令 / 监控页;不具名流与每个具名流各一套。 app.MapTagsStream(); app.Run(); diff --git a/samples/WebMonitor/README.md b/samples/WebMonitor/README.md index 14c571e..440a0ce 100644 --- a/samples/WebMonitor/README.md +++ b/samples/WebMonitor/README.md @@ -1,6 +1,6 @@ -# WebMonitor —— WebStream 监控 demo(NativeAOT 可发布) +# WebMonitor —— WebUi 监控 demo(NativeAOT 可发布) -与 `dotnet new tags.web` 生成的模板同构,只是把 Blazor 监控页换成了 **WebStream** 的只读观测流: +与 `dotnet new tags.web` 生成的模板同构,只是把 Blazor 监控页换成了 **WebUi** 的只读观测流: ``` WebMonitor/ diff --git a/samples/WebMonitor/Tags/_Extensions.cs b/samples/WebMonitor/Tags/_Extensions.cs index 921dda4..6d192b2 100644 --- a/samples/WebMonitor/Tags/_Extensions.cs +++ b/samples/WebMonitor/Tags/_Extensions.cs @@ -1,6 +1,6 @@ using StdUnit.Tags; using StdUnit.Tags.SimpleFiles; -using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebUi; namespace WebMonitor.Tags; @@ -18,7 +18,7 @@ public static IServiceCollection AddTags(this IServiceCollection services) // 第二路控制器(具名注册):项目配置在 Tags/line-b/index.xml。 services.AddKeyedTagsProjectCtrl(TagsPathDefines.LineBKey); - // WebStream:不具名流 + 一路具名流(多流各有一套端点与监控页)。 + // WebUi:不具名流 + 一路具名流(多流各有一套端点与监控页)。 services.AddTagsStream(o => { o.ReconcileInterval = TimeSpan.FromMilliseconds(20); diff --git a/samples/WebMonitor/WebMonitor.csproj b/samples/WebMonitor/WebMonitor.csproj index e68237a..8415fe0 100644 --- a/samples/WebMonitor/WebMonitor.csproj +++ b/samples/WebMonitor/WebMonitor.csproj @@ -14,11 +14,11 @@ + 消费 nuget 包时对应的就是 StdUnit.Tags / StdUnit.Tags.SimpleFiles / StdUnit.Tags.WebUi。 --> - + diff --git a/src/StdUnit.Tags.WebStream/Commands/CommandDto.cs b/src/StdUnit.Tags.WebUi/Commands/CommandDto.cs similarity index 97% rename from src/StdUnit.Tags.WebStream/Commands/CommandDto.cs rename to src/StdUnit.Tags.WebUi/Commands/CommandDto.cs index 99a0b01..872b8e3 100644 --- a/src/StdUnit.Tags.WebStream/Commands/CommandDto.cs +++ b/src/StdUnit.Tags.WebUi/Commands/CommandDto.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// POST /stdunit.tags/value 的请求体:把某个测点的值改成 。 diff --git a/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs b/src/StdUnit.Tags.WebUi/Commands/CommandsEndpoint.cs similarity index 98% rename from src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs rename to src/StdUnit.Tags.WebUi/Commands/CommandsEndpoint.cs index 32f9655..edee788 100644 --- a/src/StdUnit.Tags.WebStream/Commands/CommandsEndpoint.cs +++ b/src/StdUnit.Tags.WebUi/Commands/CommandsEndpoint.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 写命令端点:改测点值(POST /stdunit.tags/value)、标记未扫描(POST /stdunit.tags/rescan)。 diff --git a/src/StdUnit.Tags.WebStream/Commands/TagsStreamCommands.cs b/src/StdUnit.Tags.WebUi/Commands/TagsStreamCommands.cs similarity index 99% rename from src/StdUnit.Tags.WebStream/Commands/TagsStreamCommands.cs rename to src/StdUnit.Tags.WebUi/Commands/TagsStreamCommands.cs index 542404e..eba8033 100644 --- a/src/StdUnit.Tags.WebStream/Commands/TagsStreamCommands.cs +++ b/src/StdUnit.Tags.WebUi/Commands/TagsStreamCommands.cs @@ -1,7 +1,7 @@ using System.Globalization; using Microsoft.AspNetCore.Http; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 写命令的结果:HTTP 状态码 + 错误文本。 diff --git a/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs b/src/StdUnit.Tags.WebUi/Core/EndpointConstants.cs similarity index 97% rename from src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs rename to src/StdUnit.Tags.WebUi/Core/EndpointConstants.cs index 8da8def..e32060d 100644 --- a/src/StdUnit.Tags.WebStream/Core/EndpointConstants.cs +++ b/src/StdUnit.Tags.WebUi/Core/EndpointConstants.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 端点常量。
diff --git a/src/StdUnit.Tags.WebStream/Core/ITagsStreamHub.cs b/src/StdUnit.Tags.WebUi/Core/ITagsStreamHub.cs similarity index 92% rename from src/StdUnit.Tags.WebStream/Core/ITagsStreamHub.cs rename to src/StdUnit.Tags.WebUi/Core/ITagsStreamHub.cs index 0cb6c45..44c893d 100644 --- a/src/StdUnit.Tags.WebStream/Core/ITagsStreamHub.cs +++ b/src/StdUnit.Tags.WebUi/Core/ITagsStreamHub.cs @@ -1,6 +1,6 @@ -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi.Internal; -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; internal interface ITagsStreamHub { diff --git a/src/StdUnit.Tags.WebStream/Core/Index/EntryStream.cs b/src/StdUnit.Tags.WebUi/Core/Index/EntryStream.cs similarity index 96% rename from src/StdUnit.Tags.WebStream/Core/Index/EntryStream.cs rename to src/StdUnit.Tags.WebUi/Core/Index/EntryStream.cs index b771757..783af08 100644 --- a/src/StdUnit.Tags.WebStream/Core/Index/EntryStream.cs +++ b/src/StdUnit.Tags.WebUi/Core/Index/EntryStream.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 一个入口的观测视图:叶子测点集合、容器集合、值收集器。 diff --git a/src/StdUnit.Tags.WebStream/Core/Index/EntryValuesCollector.cs b/src/StdUnit.Tags.WebUi/Core/Index/EntryValuesCollector.cs similarity index 99% rename from src/StdUnit.Tags.WebStream/Core/Index/EntryValuesCollector.cs rename to src/StdUnit.Tags.WebUi/Core/Index/EntryValuesCollector.cs index 384100a..2e18e2c 100644 --- a/src/StdUnit.Tags.WebStream/Core/Index/EntryValuesCollector.cs +++ b/src/StdUnit.Tags.WebUi/Core/Index/EntryValuesCollector.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 一个入口的值收集器:保存上一次的值用于 diff,并能给出当前全量值(订阅基线与重同步)。
diff --git a/src/StdUnit.Tags.WebStream/Core/Index/ProjectIndex.cs b/src/StdUnit.Tags.WebUi/Core/Index/ProjectIndex.cs similarity index 99% rename from src/StdUnit.Tags.WebStream/Core/Index/ProjectIndex.cs rename to src/StdUnit.Tags.WebUi/Core/Index/ProjectIndex.cs index 3b0b093..84ebb03 100644 --- a/src/StdUnit.Tags.WebStream/Core/Index/ProjectIndex.cs +++ b/src/StdUnit.Tags.WebUi/Core/Index/ProjectIndex.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 一个 epoch 内的项目索引:扁平的结构树 + 每个入口的观测视图。
diff --git a/src/StdUnit.Tags.WebStream/Core/Index/TagNodes.cs b/src/StdUnit.Tags.WebUi/Core/Index/TagNodes.cs similarity index 95% rename from src/StdUnit.Tags.WebStream/Core/Index/TagNodes.cs rename to src/StdUnit.Tags.WebUi/Core/Index/TagNodes.cs index e7a33d2..b39d166 100644 --- a/src/StdUnit.Tags.WebStream/Core/Index/TagNodes.cs +++ b/src/StdUnit.Tags.WebUi/Core/Index/TagNodes.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 一个叶测点()及其在树中的路径。 diff --git a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamDescriptor.cs b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamDescriptor.cs similarity index 95% rename from src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamDescriptor.cs rename to src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamDescriptor.cs index 02d5570..33d5008 100644 --- a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamDescriptor.cs +++ b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamDescriptor.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 一次 AddTagsStream 的登记项。
diff --git a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamEntry.cs b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamEntry.cs similarity index 96% rename from src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamEntry.cs rename to src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamEntry.cs index 923b679..c61ac77 100644 --- a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamEntry.cs +++ b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamEntry.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 一路观测流:它观测的控制器、配置与 hub。
diff --git a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamRegistry.cs b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamRegistry.cs similarity index 98% rename from src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamRegistry.cs rename to src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamRegistry.cs index 5332abe..47cf86d 100644 --- a/src/StdUnit.Tags.WebStream/Core/Registry/TagsStreamRegistry.cs +++ b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamRegistry.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 所有观测流的注册表:key(不具名用 null)→ 一路独立的 hub + 控制器 + 配置。
diff --git a/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs b/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs similarity index 99% rename from src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs rename to src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs index 36dc632..2c9dcc8 100644 --- a/src/StdUnit.Tags.WebStream/Core/TagsStreamHub.cs +++ b/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs @@ -1,9 +1,9 @@ using System.Threading.Channels; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi.Internal; -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 只读观测流的核心实现(对外只暴露 ):
diff --git a/src/StdUnit.Tags.WebStream/Core/TagsStreamJsonContext.cs b/src/StdUnit.Tags.WebUi/Core/TagsStreamJsonContext.cs similarity index 96% rename from src/StdUnit.Tags.WebStream/Core/TagsStreamJsonContext.cs rename to src/StdUnit.Tags.WebUi/Core/TagsStreamJsonContext.cs index 33b18b5..1b89052 100644 --- a/src/StdUnit.Tags.WebStream/Core/TagsStreamJsonContext.cs +++ b/src/StdUnit.Tags.WebUi/Core/TagsStreamJsonContext.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 线协议的 JSON 元数据(源生成)。
diff --git a/src/StdUnit.Tags.WebStream/Core/TagsStreamOptions.cs b/src/StdUnit.Tags.WebUi/Core/TagsStreamOptions.cs similarity index 98% rename from src/StdUnit.Tags.WebStream/Core/TagsStreamOptions.cs rename to src/StdUnit.Tags.WebUi/Core/TagsStreamOptions.cs index 936798e..771943f 100644 --- a/src/StdUnit.Tags.WebStream/Core/TagsStreamOptions.cs +++ b/src/StdUnit.Tags.WebUi/Core/TagsStreamOptions.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 只读观测流的配置。 diff --git a/src/StdUnit.Tags.WebStream/Sse/SseFrames.cs b/src/StdUnit.Tags.WebUi/Sse/SseFrames.cs similarity index 98% rename from src/StdUnit.Tags.WebStream/Sse/SseFrames.cs rename to src/StdUnit.Tags.WebUi/Sse/SseFrames.cs index 03f3882..50a198b 100644 --- a/src/StdUnit.Tags.WebStream/Sse/SseFrames.cs +++ b/src/StdUnit.Tags.WebUi/Sse/SseFrames.cs @@ -3,7 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// SSE 帧渲染。序列化只在这里发生一次,之后所有订阅者共用同一份字节。
diff --git a/src/StdUnit.Tags.WebStream/Sse/SseFramesPiper.cs b/src/StdUnit.Tags.WebUi/Sse/SseFramesPiper.cs similarity index 98% rename from src/StdUnit.Tags.WebStream/Sse/SseFramesPiper.cs rename to src/StdUnit.Tags.WebUi/Sse/SseFramesPiper.cs index ba73f3a..6fe4a00 100644 --- a/src/StdUnit.Tags.WebStream/Sse/SseFramesPiper.cs +++ b/src/StdUnit.Tags.WebUi/Sse/SseFramesPiper.cs @@ -1,6 +1,6 @@ using System.Threading.Channels; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 一个 SSE 订阅者:一个有界发送队列 + 一个写循环。
diff --git a/src/StdUnit.Tags.WebStream/Sse/StreamDto.cs b/src/StdUnit.Tags.WebUi/Sse/StreamDto.cs similarity index 99% rename from src/StdUnit.Tags.WebStream/Sse/StreamDto.cs rename to src/StdUnit.Tags.WebUi/Sse/StreamDto.cs index 21dfe61..bf4b309 100644 --- a/src/StdUnit.Tags.WebStream/Sse/StreamDto.cs +++ b/src/StdUnit.Tags.WebUi/Sse/StreamDto.cs @@ -1,4 +1,4 @@ -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 一个测点的值。 diff --git a/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs b/src/StdUnit.Tags.WebUi/Sse/StreamEndpoint.cs similarity index 98% rename from src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs rename to src/StdUnit.Tags.WebUi/Sse/StreamEndpoint.cs index 6cff1a7..8508897 100644 --- a/src/StdUnit.Tags.WebStream/Sse/StreamEndpoint.cs +++ b/src/StdUnit.Tags.WebUi/Sse/StreamEndpoint.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 值流端点(GET /stdunit.tags/stream?entries=a,b):text/event-stream,entries 省略表示全部入口。 diff --git a/src/StdUnit.Tags.WebStream/StdUnit.Tags.WebStream.csproj b/src/StdUnit.Tags.WebUi/StdUnit.Tags.WebUi.csproj similarity index 97% rename from src/StdUnit.Tags.WebStream/StdUnit.Tags.WebStream.csproj rename to src/StdUnit.Tags.WebUi/StdUnit.Tags.WebUi.csproj index d688b67..41b1e6c 100644 --- a/src/StdUnit.Tags.WebStream/StdUnit.Tags.WebStream.csproj +++ b/src/StdUnit.Tags.WebUi/StdUnit.Tags.WebUi.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/StdUnit.Tags.WebStream/Tree/TreeDto.cs b/src/StdUnit.Tags.WebUi/Tree/TreeDto.cs similarity index 99% rename from src/StdUnit.Tags.WebStream/Tree/TreeDto.cs rename to src/StdUnit.Tags.WebUi/Tree/TreeDto.cs index 585ff3d..5c23590 100644 --- a/src/StdUnit.Tags.WebStream/Tree/TreeDto.cs +++ b/src/StdUnit.Tags.WebUi/Tree/TreeDto.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 测点树的一个节点(只有结构,没有值)。
diff --git a/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs b/src/StdUnit.Tags.WebUi/Tree/TreeEndpoint.cs similarity index 95% rename from src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs rename to src/StdUnit.Tags.WebUi/Tree/TreeEndpoint.cs index a7b6b28..03cb9eb 100644 --- a/src/StdUnit.Tags.WebStream/Tree/TreeEndpoint.cs +++ b/src/StdUnit.Tags.WebUi/Tree/TreeEndpoint.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 结构树端点(GET /stdunit.tags/tree):一次性返回扁平结构树。 diff --git a/src/StdUnit.Tags.WebStream/Ui/TagsStreamUi.cs b/src/StdUnit.Tags.WebUi/Ui/TagsStreamUi.cs similarity index 97% rename from src/StdUnit.Tags.WebStream/Ui/TagsStreamUi.cs rename to src/StdUnit.Tags.WebUi/Ui/TagsStreamUi.cs index 2c8df73..8a32a4f 100644 --- a/src/StdUnit.Tags.WebStream/Ui/TagsStreamUi.cs +++ b/src/StdUnit.Tags.WebUi/Ui/TagsStreamUi.cs @@ -2,7 +2,7 @@ using System.Text.Encodings.Web; using Microsoft.AspNetCore.Http; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 内置监控页:把 Ui/index.html 作为嵌入资源取出,注入 <base href> 后整体返回。
@@ -12,7 +12,7 @@ namespace StdUnit.Tags.WebStream.Internal; ///
internal static class TagsStreamUi { - private const string ResourceName = "StdUnit.Tags.WebStream.Ui.index.html"; + private const string ResourceName = "StdUnit.Tags.WebUi.Ui.index.html"; private const string BasePlaceholder = "%%BASE%%"; private const string TreePlaceholder = "%%TREE_URL%%"; private const string StreamPlaceholder = "%%STREAM_URL%%"; diff --git a/src/StdUnit.Tags.WebStream/Ui/index.html b/src/StdUnit.Tags.WebUi/Ui/index.html similarity index 100% rename from src/StdUnit.Tags.WebStream/Ui/index.html rename to src/StdUnit.Tags.WebUi/Ui/index.html diff --git a/src/StdUnit.Tags.WebStream/_Extensions.cs b/src/StdUnit.Tags.WebUi/_Extensions.cs similarity index 99% rename from src/StdUnit.Tags.WebStream/_Extensions.cs rename to src/StdUnit.Tags.WebUi/_Extensions.cs index 661e361..b8032cb 100644 --- a/src/StdUnit.Tags.WebStream/_Extensions.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions.cs @@ -3,10 +3,10 @@ using Microsoft.Extensions.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi.Internal; -namespace StdUnit.Tags.WebStream; +namespace StdUnit.Tags.WebUi; /// /// 只读观测流的入口: 做 DI 注册, 映射端点。 diff --git a/src/StdUnit.Tags.WebStream/_Extensions_HttpContext.cs b/src/StdUnit.Tags.WebUi/_Extensions_HttpContext.cs similarity index 97% rename from src/StdUnit.Tags.WebStream/_Extensions_HttpContext.cs rename to src/StdUnit.Tags.WebUi/_Extensions_HttpContext.cs index da2878e..1de343f 100644 --- a/src/StdUnit.Tags.WebStream/_Extensions_HttpContext.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_HttpContext.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -namespace StdUnit.Tags.WebStream.Internal; +namespace StdUnit.Tags.WebUi.Internal; /// /// 端点层的 HTTP 适配: / 路由值 / 404 响应都留在这里, diff --git a/src/StdUnit.Tags.WebStream/paket.references b/src/StdUnit.Tags.WebUi/paket.references similarity index 100% rename from src/StdUnit.Tags.WebStream/paket.references rename to src/StdUnit.Tags.WebUi/paket.references diff --git a/src/publish-packages.ps1 b/src/publish-packages.ps1 index f4492c9..417e315 100644 --- a/src/publish-packages.ps1 +++ b/src/publish-packages.ps1 @@ -78,7 +78,7 @@ $projects = @( "StdUnit.Tags.R3Extensions", "StdUnit.Tags.BlazorLib.Core", - "StdUnit.Tags.WebStream" + "StdUnit.Tags.WebUi" ) $baseDir=Split-Path $script:MyInvocation.MyCommand.Path diff --git a/tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj b/tests/AotSmokeTest.WebUi/AotSmokeTest.WebUi.csproj similarity index 78% rename from tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj rename to tests/AotSmokeTest.WebUi/AotSmokeTest.WebUi.csproj index 1c76e2a..e7186b8 100644 --- a/tests/AotSmokeTest.WebStream/AotSmokeTest.WebStream.csproj +++ b/tests/AotSmokeTest.WebUi/AotSmokeTest.WebUi.csproj @@ -4,7 +4,7 @@ Exe net8.0 false - true @@ -14,6 +14,6 @@ - + diff --git a/tests/AotSmokeTest.WebStream/Program.cs b/tests/AotSmokeTest.WebUi/Program.cs similarity index 96% rename from tests/AotSmokeTest.WebStream/Program.cs rename to tests/AotSmokeTest.WebUi/Program.cs index e687d6e..cdc4abd 100644 --- a/tests/AotSmokeTest.WebStream/Program.cs +++ b/tests/AotSmokeTest.WebUi/Program.cs @@ -9,10 +9,10 @@ using Microsoft.Extensions.Logging; using StdUnit.Tags; using StdUnit.Tags.SimpleFiles; -using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebUi; // ───────────────────────────────────────────────────────────────────────────── -// NativeAOT 冒烟测试(WebStream):本程序以 PublishAot=true 发布成原生可执行文件后运行。 +// NativeAOT 冒烟测试(WebUi):本程序以 PublishAot=true 发布成原生可执行文件后运行。 // 覆盖三条"只有真 AOT 才能证明"的路径: // ① 结构树的**多态 JSON 序列化**([JsonDerivedType] + 源生成)能产出 kind 判别字段; // ② 值流的 SSE 端点真的能连通:状态 → 全量基线 → 改值 → 增量; @@ -24,7 +24,7 @@ var dynamicCodeSupported = RuntimeFeature.IsDynamicCodeSupported; var failures = new List(); -var tempDir = Path.Combine(Path.GetTempPath(), "AotSmokeTest.WebStream", Guid.NewGuid().ToString("N")); +var tempDir = Path.Combine(Path.GetTempPath(), "AotSmokeTest.WebUi", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); WebApplication? app = null; @@ -226,7 +226,7 @@ await WaitUntilAsync( if (failures.Count > 0) { - Console.Error.WriteLine($"WebStream AOT 冒烟测试失败(DynamicCodeSupported={dynamicCodeSupported},{failures.Count} 项):"); + Console.Error.WriteLine($"WebUi AOT 冒烟测试失败(DynamicCodeSupported={dynamicCodeSupported},{failures.Count} 项):"); foreach (var failure in failures) { Console.Error.WriteLine(" - " + failure); @@ -235,7 +235,7 @@ await WaitUntilAsync( return 1; } -Console.WriteLine($"WebStream AOT 冒烟测试通过(DynamicCodeSupported={dynamicCodeSupported})。"); +Console.WriteLine($"WebUi AOT 冒烟测试通过(DynamicCodeSupported={dynamicCodeSupported})。"); return 0; void Require(bool condition, string message) diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj b/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj index 026a33d..5e02246 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj @@ -15,7 +15,7 @@ 另外:不要在 StdUnit.Tags.Tests 里用 #if NETFRAMEWORK 排除整个文件来回避 net8-only 依赖 —— 那是编译期开关,解决不了"引用 net8-only 项目"的问题。 - 现状:承载 StdUnit.Tags.WebStream(ASP.NET Core / SSE 只读观测流)的测试。 + 现状:承载 StdUnit.Tags.WebUi(ASP.NET Core / SSE 只读观测流)的测试。 --> @@ -29,7 +29,7 @@ - + diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/EntryValuesCollectorTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EntryValuesCollectorTests.cs similarity index 96% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/EntryValuesCollectorTests.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EntryValuesCollectorTests.cs index e69ed98..0dc3bcf 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/EntryValuesCollectorTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EntryValuesCollectorTests.cs @@ -2,11 +2,11 @@ using System.Linq; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; -using StdUnit.Tags.WebStream; -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; using Xunit; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// :只报告真正变化的点、容器标志变化、全量取值。 diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/ProjectIndexTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/ProjectIndexTests.cs similarity index 98% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/ProjectIndexTests.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/ProjectIndexTests.cs index 5dd1fd2..73fc1d0 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/ProjectIndexTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/ProjectIndexTests.cs @@ -3,11 +3,11 @@ using System.Linq; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; -using StdUnit.Tags.WebStream; -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; using Xunit; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// :结构树(扁平 + Parent)、入口索引、路径规则。 diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/SseFramesTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/SseFramesTests.cs similarity index 95% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/SseFramesTests.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/SseFramesTests.cs index 2b48f2f..0ac0e38 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/SseFramesTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/SseFramesTests.cs @@ -1,9 +1,9 @@ using System; using Xunit; -using StdUnit.Tags.WebStream; -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// SSE 帧的线格式:事件名、data: 前缀、camelCase、null 省略。 diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamEndpointParseEntriesTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamEndpointParseEntriesTests.cs similarity index 92% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamEndpointParseEntriesTests.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamEndpointParseEntriesTests.cs index e41019e..e3b0b58 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamEndpointParseEntriesTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamEndpointParseEntriesTests.cs @@ -1,9 +1,9 @@ using System; using System.Linq; -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi.Internal; using Xunit; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// ?entries= 的解析:逗号分隔、去空白、去重、空段忽略;解析不出来就是"全部入口"。 diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamTestContext.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamTestContext.cs similarity index 98% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamTestContext.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamTestContext.cs index 9723fc7..9d93d5e 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/StreamTestContext.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamTestContext.cs @@ -7,7 +7,7 @@ using StdUnit.Tags.Tests.Fakes; using Xunit; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// 观测流测试的公共夹具:用纯内存假驱动构建真实项目(真实 TagsProject + 真实 TagGrpRunner)。 @@ -62,7 +62,7 @@ public static XElement Tag(string name, string type = "INT16", string access = " private static string CreateRoot() { // 相对路径全部用 Path.Combine 分段,跨平台安全(CI 只跑 Linux)。 - var root = Path.Combine(Path.GetTempPath(), "StdUnit.Tags.WebStream.Tests"); + var root = Path.Combine(Path.GetTempPath(), "StdUnit.Tags.WebUi.Tests"); Directory.CreateDirectory(root); return root; } diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamCommandsTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamCommandsTests.cs similarity index 98% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamCommandsTests.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamCommandsTests.cs index fe0dfe5..57918e6 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamCommandsTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamCommandsTests.cs @@ -3,11 +3,11 @@ using System.Threading.Tasks; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; -using StdUnit.Tags.WebStream; -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; using Xunit; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// 写命令:改测点值、标记未扫描。
diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs similarity index 98% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs index cf135a4..6f7c662 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamHubTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs @@ -5,11 +5,11 @@ using System.Threading.Tasks; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; -using StdUnit.Tags.WebStream; -using StdUnit.Tags.WebStream.Internal; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; using Xunit; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// 的端到端行为:状态(Replay 1)、全量基线、增量、按入口过滤、迟到订阅者。
diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs similarity index 98% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs index 3bd91e7..6689fd6 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamMappingTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs @@ -7,10 +7,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; -using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebUi; using Xunit; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// :对外只能改前缀,相对路径固定; diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs similarity index 99% rename from tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs rename to tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs index bc90209..8769c81 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebStream/TagsStreamRegistrationTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs @@ -5,10 +5,10 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using StdUnit.Tags.Tests.Fakes; -using StdUnit.Tags.WebStream; +using StdUnit.Tags.WebUi; using Xunit; -namespace StdUnit.Tags.Tests.NetCoreOnly.WebStream; +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// /// AddTagsStream:对外只暴露 ,且它与 hosted service 是同一个实例; From 1a7f6c564c73ef321b22247106b3835e429a60f1 Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Wed, 28 Oct 2026 23:58:36 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E4=B8=BATagsProjectServiceBuilder=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- samples/WebMonitor/Tags/_Extensions.cs | 27 ++++--- .../_Extensions_Endponits.cs | 75 +++++++++++++++++++ .../_Extensions_HttpContext.cs | 2 +- ...ns.cs => _Extensions_ServiceCollection.cs} | 66 +--------------- .../_Extensions_TagsProjectServiceBuilder.cs | 36 +++++++++ .../TagsProjectServiceCollection.cs | 14 ++++ 6 files changed, 143 insertions(+), 77 deletions(-) create mode 100644 src/StdUnit.Tags.WebUi/_Extensions_Endponits.cs rename src/StdUnit.Tags.WebUi/{_Extensions.cs => _Extensions_ServiceCollection.cs} (61%) create mode 100644 src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs diff --git a/samples/WebMonitor/Tags/_Extensions.cs b/samples/WebMonitor/Tags/_Extensions.cs index 6d192b2..201fecd 100644 --- a/samples/WebMonitor/Tags/_Extensions.cs +++ b/samples/WebMonitor/Tags/_Extensions.cs @@ -13,22 +13,25 @@ public static IServiceCollection AddTags(this IServiceCollection services) // 本示例只用 SimpleFiles(数据是磁盘上的文本文件),不依赖任何硬件/网络。 builder.AddSimpleFilesChannel() .AddSimpleFilesDirectTagBuilder(); + + builder.AddTagsStream(o => + { + o.ReconcileInterval = TimeSpan.FromMilliseconds(20); + o.HeartbeatInterval = TimeSpan.FromSeconds(10); + }); + + + // 注册具名的测点项目控制器(LineB) + builder.AddKeyedTagsProjectCtrl(TagsPathDefines.LineBKey); + builder.AddKeyedTagsStream(TagsPathDefines.LineBKey, o => + { + o.ReconcileInterval = TimeSpan.FromMilliseconds(20); + o.HeartbeatInterval = TimeSpan.FromSeconds(10); + }); }); // 第二路控制器(具名注册):项目配置在 Tags/line-b/index.xml。 - services.AddKeyedTagsProjectCtrl(TagsPathDefines.LineBKey); - // WebUi:不具名流 + 一路具名流(多流各有一套端点与监控页)。 - services.AddTagsStream(o => - { - o.ReconcileInterval = TimeSpan.FromMilliseconds(20); - o.HeartbeatInterval = TimeSpan.FromSeconds(10); - }); - services.AddKeyedTagsStream(TagsPathDefines.LineBKey, o => - { - o.ReconcileInterval = TimeSpan.FromMilliseconds(20); - o.HeartbeatInterval = TimeSpan.FromSeconds(10); - }); // 后台任务:启动两路轮询 + 演示用的值模拟器。 services.AddHostedService(); diff --git a/src/StdUnit.Tags.WebUi/_Extensions_Endponits.cs b/src/StdUnit.Tags.WebUi/_Extensions_Endponits.cs new file mode 100644 index 0000000..aac7e9e --- /dev/null +++ b/src/StdUnit.Tags.WebUi/_Extensions_Endponits.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using StdUnit.Tags.WebUi.Internal; + + +namespace StdUnit.Tags.WebUi; + +public static class TagsStreamExtensions_Endpoints +{ + + /// + /// 映射端点(全部挂在 下,相对路径固定):
+ /// 不具名流(AddTagsStream()): + /// - GET {prefix}/tree:结构树(一次性 JSON,按 epoch 缓存);
+ /// - GET {prefix}/stream?entries=a,b:值流(text/event-stream),entries 省略表示全部入口;
+ /// - GET {prefix}/ui:内置的极简监控页(单文件 HTML + 内嵌 JS,无需 npm 工程);
+ /// - POST {prefix}/value:改测点值;
+ /// - POST {prefix}/rescan:标记未扫描。
+ /// 具名流(AddKeyedTagsStream("dev")):同一套形状,键作为一段路径, + /// 即 {prefix}/{key}/tree、{prefix}/{key}/stream ……;未知 key 返回 404。
+ ///
+ /// 外部只可改前缀:各端点的相对路径(/tree、/stream …)是线协议的一部分, + /// 监控页注入的也是这些相对路径,真正的 URL 由页面里的 <base href> 解析; + /// 而 base 是按请求路径生成的,所以具名流的页面自动指向 {prefix}/{key}/…, + /// 把本组挂到别的路由组下(app.MapGroup("/monitor").MapTagsStream())或配了 UsePathBase 也都正常。 + ///
+ /// 鉴权:这些端点不内置任何鉴权——两个写端点会把任意测点改成任意值。 + /// 返回值是路由组,映射完直接对整组加策略即可: + /// + /// app.MapTagsStream().RequireAuthorization(); // 整组走默认策略 + /// app.MapTagsStream("/internal/tags").RequireAuthorization("ops"); // 自定义前缀 + 命名策略 + /// + /// 只想让读端点匿名时,可用反向代理挡住 {prefix}/value 与 {prefix}/rescan。 + ///
+ /// 端点构建器 + /// + /// 路由前缀,默认 ; + /// 传 "" 或 "/" 表示不挂前缀(端点直接落在根上)。 + /// + /// 路由组:可继续 RequireAuthorization() / WithTags() 等整组配置。 + public static RouteGroupBuilder MapTagsStream(this IEndpointRouteBuilder endpoints, string prefix = EndpointConstants.DefaultPrefix) + { + var group = endpoints.MapGroup(prefix.Length == 0 ? "/" : prefix); + + // 不具名流:{prefix}/tree|stream|value|rescan|ui + group.MapGet(EndpointConstants.TreePath, TreeEndpoint.HandleAsync); + group.MapGet(EndpointConstants.StreamPath, StreamEndpoint.HandleAsync); + group.MapPost(EndpointConstants.WriteValuePath, CommandsEndpoint.WriteValueAsync); + group.MapPost(EndpointConstants.RescanPath, CommandsEndpoint.RescanAsync); + + // 具名流:{prefix}/{keyed}/…(同形);未知 key 由端点在运行时返回 404。 + group.MapGet(EndpointConstants.Keyed(EndpointConstants.TreePath), TreeEndpoint.HandleAsync); + group.MapGet(EndpointConstants.Keyed(EndpointConstants.StreamPath), StreamEndpoint.HandleAsync); + group.MapPost(EndpointConstants.Keyed(EndpointConstants.WriteValuePath), CommandsEndpoint.WriteValueAsync); + group.MapPost(EndpointConstants.Keyed(EndpointConstants.RescanPath), CommandsEndpoint.RescanAsync); + + // 页面只注入**相对路径**(与 C# 常量同源),基路径按请求路径生成: + // {prefix}/ui → base={prefix}/, + // {prefix}/{key}/ui → base={prefix}/{key}/, + // 所以一份页面服务所有流。 + var page = TagsStreamUi.BuildPage( + EndpointConstants.TreePath, + EndpointConstants.StreamPath, + EndpointConstants.WriteValuePath, + EndpointConstants.RescanPath + ); + group.MapGet(EndpointConstants.UiPath, context => TagsStreamUi.WriteAsync(context, page)); + group.MapGet(EndpointConstants.Keyed(EndpointConstants.UiPath), context => TagsStreamUi.WriteAsync(context, page)); + + return group; + } +} diff --git a/src/StdUnit.Tags.WebUi/_Extensions_HttpContext.cs b/src/StdUnit.Tags.WebUi/_Extensions_HttpContext.cs index 1de343f..7d20704 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_HttpContext.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_HttpContext.cs @@ -7,7 +7,7 @@ namespace StdUnit.Tags.WebUi.Internal; /// 端点层的 HTTP 适配: / 路由值 / 404 响应都留在这里, /// 只认 key,不认识 HTTP。 ///
-internal static class TagsStreamEndpointExtensions +internal static class TagsStreamExtensions_HttpContext { /// /// 取当前请求对应的那一路观测流:路由里有 {keyed} 就按它找,否则用不具名那一路。
diff --git a/src/StdUnit.Tags.WebUi/_Extensions.cs b/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs similarity index 61% rename from src/StdUnit.Tags.WebUi/_Extensions.cs rename to src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs index b8032cb..6170d57 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs @@ -1,3 +1,4 @@ +using StdUnit.Tags; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -11,7 +12,7 @@ namespace StdUnit.Tags.WebUi; /// /// 只读观测流的入口: 做 DI 注册, 映射端点。 /// -public static class TagsStreamServiceCollection +public static class TagsStreamExtensions_ServiceCollection { /// /// 注册不具名那一路观测流:观测由 AddTagsProjectServices 注册的 , @@ -142,67 +143,4 @@ private static void ValidateStreamKey(IServiceCollection services, string key) private static readonly HashSet ReservedStreamKeys = new(new[] { "ui", "tree", "stream", "value", "rescan" }, StringComparer.OrdinalIgnoreCase); - - - /// - /// 映射端点(全部挂在 下,相对路径固定):
- /// 不具名流(AddTagsStream()): - /// - GET {prefix}/tree:结构树(一次性 JSON,按 epoch 缓存);
- /// - GET {prefix}/stream?entries=a,b:值流(text/event-stream),entries 省略表示全部入口;
- /// - GET {prefix}/ui:内置的极简监控页(单文件 HTML + 内嵌 JS,无需 npm 工程);
- /// - POST {prefix}/value:改测点值;
- /// - POST {prefix}/rescan:标记未扫描。
- /// 具名流(AddKeyedTagsStream("dev")):同一套形状,键作为一段路径, - /// 即 {prefix}/{key}/tree、{prefix}/{key}/stream ……;未知 key 返回 404。
- ///
- /// 外部只可改前缀:各端点的相对路径(/tree、/stream …)是线协议的一部分, - /// 监控页注入的也是这些相对路径,真正的 URL 由页面里的 <base href> 解析; - /// 而 base 是按请求路径生成的,所以具名流的页面自动指向 {prefix}/{key}/…, - /// 把本组挂到别的路由组下(app.MapGroup("/monitor").MapTagsStream())或配了 UsePathBase 也都正常。 - ///
- /// 鉴权:这些端点不内置任何鉴权——两个写端点会把任意测点改成任意值。 - /// 返回值是路由组,映射完直接对整组加策略即可: - /// - /// app.MapTagsStream().RequireAuthorization(); // 整组走默认策略 - /// app.MapTagsStream("/internal/tags").RequireAuthorization("ops"); // 自定义前缀 + 命名策略 - /// - /// 只想让读端点匿名时,可用反向代理挡住 {prefix}/value 与 {prefix}/rescan。 - ///
- /// 端点构建器 - /// - /// 路由前缀,默认 ; - /// 传 "" 或 "/" 表示不挂前缀(端点直接落在根上)。 - /// - /// 路由组:可继续 RequireAuthorization() / WithTags() 等整组配置。 - public static RouteGroupBuilder MapTagsStream(this IEndpointRouteBuilder endpoints, string prefix = EndpointConstants.DefaultPrefix) - { - var group = endpoints.MapGroup(prefix.Length == 0 ? "/" : prefix); - - // 不具名流:{prefix}/tree|stream|value|rescan|ui - group.MapGet(EndpointConstants.TreePath, TreeEndpoint.HandleAsync); - group.MapGet(EndpointConstants.StreamPath, StreamEndpoint.HandleAsync); - group.MapPost(EndpointConstants.WriteValuePath, CommandsEndpoint.WriteValueAsync); - group.MapPost(EndpointConstants.RescanPath, CommandsEndpoint.RescanAsync); - - // 具名流:{prefix}/{keyed}/…(同形);未知 key 由端点在运行时返回 404。 - group.MapGet(EndpointConstants.Keyed(EndpointConstants.TreePath), TreeEndpoint.HandleAsync); - group.MapGet(EndpointConstants.Keyed(EndpointConstants.StreamPath), StreamEndpoint.HandleAsync); - group.MapPost(EndpointConstants.Keyed(EndpointConstants.WriteValuePath), CommandsEndpoint.WriteValueAsync); - group.MapPost(EndpointConstants.Keyed(EndpointConstants.RescanPath), CommandsEndpoint.RescanAsync); - - // 页面只注入**相对路径**(与 C# 常量同源),基路径按请求路径生成: - // {prefix}/ui → base={prefix}/, - // {prefix}/{key}/ui → base={prefix}/{key}/, - // 所以一份页面服务所有流。 - var page = TagsStreamUi.BuildPage( - EndpointConstants.TreePath, - EndpointConstants.StreamPath, - EndpointConstants.WriteValuePath, - EndpointConstants.RescanPath - ); - group.MapGet(EndpointConstants.UiPath, context => TagsStreamUi.WriteAsync(context, page)); - group.MapGet(EndpointConstants.Keyed(EndpointConstants.UiPath), context => TagsStreamUi.WriteAsync(context, page)); - - return group; - } } diff --git a/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs b/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs new file mode 100644 index 0000000..eb83910 --- /dev/null +++ b/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs @@ -0,0 +1,36 @@ +namespace StdUnit.Tags.WebUi; + +/// +/// extensions for AddTagsStream on TagsProjectServiceBuilder +/// +public static class TagsStreamExtensions_TagsProjectServiceBuilder +{ + /// + /// 注册不具名的观测流。
+ /// 和不具名的相关联。最多只有一路不具名的观测流。 + ///
+ /// + /// + /// + public static TagsProjectServiceBuilder AddTagsStream(this TagsProjectServiceBuilder builder, Action? configure = null) + { + builder.Services.AddTagsStream(configure); + return builder; + } + + + /// + /// 注册一路具名的观测流。
+ /// 和具名的相关联,每个名称下最多只有一路观测流。 + ///
+ /// + /// + /// + /// + public static TagsProjectServiceBuilder AddKeyedTagsStream(this TagsProjectServiceBuilder builder, string projctrlKey, Action? configure = null) + { + builder.Services.AddKeyedTagsStream(projctrlKey, configure); + return builder; + } + +} diff --git a/src/StdUnit.Tags/ServiceExtensions/TagsProjectServiceCollection.cs b/src/StdUnit.Tags/ServiceExtensions/TagsProjectServiceCollection.cs index c41da6e..cb9b96d 100644 --- a/src/StdUnit.Tags/ServiceExtensions/TagsProjectServiceCollection.cs +++ b/src/StdUnit.Tags/ServiceExtensions/TagsProjectServiceCollection.cs @@ -38,6 +38,20 @@ public static IServiceCollection AddKeyedTagsProjectCtrl(this IServiceCollection return services; } + + /// + /// 以指定的key 注册 + /// + /// + /// + /// + public static TagsProjectServiceBuilder AddKeyedTagsProjectCtrl(this TagsProjectServiceBuilder builder, string key) + { + builder.Services.AddKeyedSingleton(key); + return builder; + } + + /// /// 构建测点项目实例 /// From 7b2aae3a2f8b45c746ad487fa95457390930c37a Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Thu, 29 Oct 2026 00:46:24 +0800 Subject: [PATCH 6/9] =?UTF-8?q?TagsProject=E7=9A=84SetTurnProcessHandler?= =?UTF-8?q?=20=E4=B8=8D=E5=86=8D=E9=99=90=E5=88=B6=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E5=9C=A8=E5=90=AF=E5=8A=A8=E5=89=8D=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26\344\270\216\350\276\271\347\225\214.md" | 3 +- samples/WebMonitor/Tags/_Extensions.cs | 7 +- .../Projects/ITagsProject.cs | 17 +-- src/StdUnit.Tags.Core/Projects/TagsProject.cs | 47 ++++---- src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs | 4 +- ..._Endponits.cs => _Extensions_Endpoints.cs} | 8 +- .../_Extensions_ServiceCollection.cs | 14 ++- .../_Extensions_TagsProjectServiceBuilder.cs | 35 ++++-- .../TagsProjectServiceCollection.cs | 2 +- .../WebUi/TagsStreamHubTests.cs | 32 ++++++ .../WebUi/TagsStreamMappingTests.cs | 4 - .../WebUi/TagsStreamRegistrationTests.cs | 65 +++++++++++- .../Projects/TagsProjectTurnProcessTests.cs | 100 +++++++++++++++++- .../TagsProjectServiceCollectionTests.cs | 18 ++++ 14 files changed, 284 insertions(+), 72 deletions(-) rename src/StdUnit.Tags.WebUi/{_Extensions_Endponits.cs => _Extensions_Endpoints.cs} (96%) diff --git "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" index 1e7b801..6a07898 100644 --- "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" +++ "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" @@ -45,7 +45,8 @@ API 细节见教程站点()。 ```csharp proj.SetTurnProcessHandler("g1", async (entry, channel, ct) => { await 遥测(...); await 推送(...); }); ``` -- `SetTurnProcessHandler` **只能在项目运行前设置**(启动 hook 或 `StartedOrStopped` 的"已启动"事件里;运行后调用抛 `InvalidOperationException`),与 `IntentCapacity` "只能在运行前设置"同构。设置无需清除:回调的作用域就是项目实例,项目停止即随之释放。需要运行期动态开关观测时,在已设置的回调内部判断(一次集合查找),不要反复设置/清除。 +- `SetTurnProcessHandler` **运行前、运行中都可以设置**:入口列表在 `Initialize` 之后固定,设置只是替换该入口的回调;运行期设置/清除**从下一轮开始生效**(不中断正在执行的那一轮),所以"观测端比轮询晚启动"(如 WebUi 的 hub 后启动)也能补挂上。设置无需清除:回调的作用域就是项目实例,项目停止即随之释放(项目已释放后调用抛 `ObjectDisposedException`)。但**每个入口只有一个回调**——要多个观察者就自己组装成一个 `TurnProcess`(见上),不要用反复设置/清除做"动态开关",那会与其它观察者互相覆盖;要动态开关就在自己的回调内部判断(一次集合查找)。 + - 历史:该 API 早期要求"只能在运行前设置"(与 `IntentCapacity` 同构)。那条约束只服务于"回调表运行期只读、轮询线程零同步"的实现便利,代价却是**晚挂观测静默降级**(结构树在、值流不在,只有一条 Warning 且不自愈),而顺序由调用方决定、库无法保证,因此改为写时复制的回调表(读路径仍是普通字典读、零锁)。 - 启停用 `ObserveStartedOrStopped()` / `RunnerStarted`;注意 `SetTurnProcessHandler` **只覆盖"产生了轮次"**的情形,入口被禁用、项目未启动/已停止时不产生轮次,也就不会触发。 - 事件/回调都在**轮询线程**上同步触发,拿到的已经是本轮最终结果(值 + 时间戳),不需要、也不应该再去读字段做同步。 - **不同入口的回调并行**(各入口由各自的运行器驱动);回调必须尽快返回(要干重活就只投递到自己的队列,由独立循环消费)。回调抛出的异常不会被吞掉——等价于该入口本轮失败,会进入 `RunnerCrashed` 的恢复路径,观察者请自行 try/catch。 diff --git a/samples/WebMonitor/Tags/_Extensions.cs b/samples/WebMonitor/Tags/_Extensions.cs index 201fecd..6cb3be2 100644 --- a/samples/WebMonitor/Tags/_Extensions.cs +++ b/samples/WebMonitor/Tags/_Extensions.cs @@ -14,14 +14,14 @@ public static IServiceCollection AddTags(this IServiceCollection services) builder.AddSimpleFilesChannel() .AddSimpleFilesDirectTagBuilder(); + // WebUi:不具名的观测流(端点 /stdunit.tags/*)+ 一路具名流(/stdunit.tags/line-b/*,各有一套监控页)。 builder.AddTagsStream(o => { o.ReconcileInterval = TimeSpan.FromMilliseconds(20); o.HeartbeatInterval = TimeSpan.FromSeconds(10); }); - - // 注册具名的测点项目控制器(LineB) + // 具名的测点项目控制器 + 与之配对的具名观测流(项目配置在 Tags/line-b/index.xml)。 builder.AddKeyedTagsProjectCtrl(TagsPathDefines.LineBKey); builder.AddKeyedTagsStream(TagsPathDefines.LineBKey, o => { @@ -30,9 +30,6 @@ public static IServiceCollection AddTags(this IServiceCollection services) }); }); - // 第二路控制器(具名注册):项目配置在 Tags/line-b/index.xml。 - - // 后台任务:启动两路轮询 + 演示用的值模拟器。 services.AddHostedService(); return services; diff --git a/src/StdUnit.Tags.Core/Projects/ITagsProject.cs b/src/StdUnit.Tags.Core/Projects/ITagsProject.cs index 1a4dacb..8646660 100644 --- a/src/StdUnit.Tags.Core/Projects/ITagsProject.cs +++ b/src/StdUnit.Tags.Core/Projects/ITagsProject.cs @@ -81,12 +81,13 @@ public interface ITagsProject : IDisposable /// }); /// ///
- /// 只能在项目运行()之前设置:入口列表在 - /// 之后才确定,运行期不再支持增删(与 "只能在运行前设置"的约定一致)。 - /// 典型设置点是 - /// 的 hook,或 的"已启动"事件——两者都在轮询开始前同步执行。
- /// 需要在运行期"动态开关观测"时,请在已设置的回调内部判断(例如先检查自己的订阅者集合是否为空再直接返回), - /// 而不是反复设置/清除。
+ /// 运行前、运行中都可以设置:入口列表在 之后固定,本方法只替换该入口的回调; + /// 运行期设置/清除从下一轮开始生效(不中断正在执行的那一轮)。常见设置点是 + /// + /// 的 hook 或 的"已启动"事件(两者都在轮询开始前同步执行), + /// 但晚挂同样有效——例如观测端(界面推送流)比轮询晚启动时,可以直接在项目运行中补挂。
+ /// 不要用"反复设置/清除"来做动态开关:每个入口只有一个回调,那样会与其它观察者互相覆盖; + /// 要动态开关就在自己的回调内部判断(例如先检查订阅者集合是否为空再直接返回)。
///
/// 回调运行在轮询线程上,必须尽快返回:不要阻塞,也不要在回调里做 IO / 序列化。 /// 需要较重的处理时,请只把必要信息投递到自己的队列,由独立的后台循环消费。
@@ -102,7 +103,7 @@ public interface ITagsProject : IDisposable ///
/// 注意: /// - /// 必须在 之后、 之前调用; + /// 必须在 之后调用(运行期也可以:从下一轮开始生效); /// 入口名不存在时抛 (与 WriteIntent 的校验一致); /// 不需要清除:回调的作用域就是项目实例本身,项目停止后实例被释放,回调随之消失; /// 只覆盖"产生了轮次"的情形:入口被禁用、项目未启动或已停止时不会产生轮次,也就不会调用; @@ -113,7 +114,7 @@ public interface ITagsProject : IDisposable /// 回调;传 null 表示清除该入口已设置的回调 /// 为 null /// 未找到指定的入口测点组 - /// 项目已经在运行(本方法只能在 之前调用) + /// 项目已释放(回调的作用域就是项目实例) void SetTurnProcessHandler(string entry, TurnProcess? handler); diff --git a/src/StdUnit.Tags.Core/Projects/TagsProject.cs b/src/StdUnit.Tags.Core/Projects/TagsProject.cs index a3a9aa1..4dcf2b6 100644 --- a/src/StdUnit.Tags.Core/Projects/TagsProject.cs +++ b/src/StdUnit.Tags.Core/Projects/TagsProject.cs @@ -225,9 +225,6 @@ public virtual Task RunAsync(CancellationToken ct) throw new InvalidOperationException("未配置入口测点组"); } - // 进入运行期:此后不再接受 TurnProcess 回调注册(见 AddTurnProcessHandler)。 - this._running = true; - var tasks = new ConcurrentBag(); Parallel.ForEach(entries, entry => { @@ -251,7 +248,7 @@ public virtual Task RunAsync(CancellationToken ct) // 公共回调排在 Logicets 之后:订阅者看到的是逻辑处理完成后的值。 // 有意不 catch:回调抛异常等价于本轮失败(与 Logicet 抛异常同义),见 ITagsProject.SetTurnProcessHandler 的文档。 - // 回调表在运行期只读(设置在运行前完成),因此这里不需要任何同步。 + // 回调表是写时复制的:这里读到的那份快照本轮内不会被改(运行期设置/清除从下一轮生效),所以不需要同步。 if (this._entryTurnHandlers.TryGetValue(entry.TagName(), out var handler)) { await handler(entry, ch, turnCt); @@ -352,15 +349,14 @@ protected virtual void CompleteIntentChannels(string disposeMsg) #region TurnProcess handlers /// - /// 入口名 → 该入口的回调(每个入口最多一个)。只在运行前写入、运行期只读 - /// (见 的"只能在运行前设置"约定),因此轮询线程读取时不需要任何同步。 + /// 入口名 → 该入口的回调(每个入口最多一个)。写时复制:设置/清除时在 + /// 下复制一份新表再整体替换,已发布的表实例永不再改, + /// 所以轮询线程可以直接读、不需要任何同步,运行期设置/清除自然就是"从下一轮开始生效"。 /// - private readonly Dictionary _entryTurnHandlers = new(StringComparer.Ordinal); + private volatile Dictionary _entryTurnHandlers = new(StringComparer.Ordinal); - /// - /// 是否已进入运行期( 已开始)。运行期不再接受设置。 - /// - private volatile bool _running; + /// 保护 的复制作业(只在设置/清除时短暂持有)。 + private readonly object _entryTurnHandlersGate = new(); /// public void SetTurnProcessHandler(string entry, TurnProcess? handler) @@ -369,11 +365,11 @@ public void SetTurnProcessHandler(string entry, TurnProcess? handler) { throw new ArgumentNullException(nameof(entry)); } - if (this._running) + if (this._disposed) { - throw new InvalidOperationException( - $"TurnProcess 回调只能在项目运行之前设置(入口={entry}):请在启动 hook 或 StartedOrStopped 的\"已启动\"事件里设置," + - "运行期如需动态开关观测,请在已设置的回调内部判断。"); + throw new ObjectDisposedException( + this.GetType().Name, + $"项目已释放,不能再设置 TurnProcess 回调(入口={entry}):回调的作用域就是项目实例。"); } // 与 WriteIntent 的入口校验保持一致:只允许向合法的入口设置 @@ -383,20 +379,27 @@ public void SetTurnProcessHandler(string entry, TurnProcess? handler) } // 替换语义:需要组合多个观察者时,由调用方自己组装成一个 TurnProcess。 - if (handler is null) - { - this._entryTurnHandlers.Remove(entry); - } - else + // 运行期也允许设置/清除(写时复制,从下一轮开始生效)。 + lock (this._entryTurnHandlersGate) { - this._entryTurnHandlers[entry] = handler; + var updated = new Dictionary(this._entryTurnHandlers, StringComparer.Ordinal); + if (handler is null) + { + updated.Remove(entry); + } + else + { + updated[entry] = handler; + } + + this._entryTurnHandlers = updated; } } #endregion #region - private bool _disposed; + private volatile bool _disposed; protected virtual void Dispose(bool disposing) { diff --git a/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs b/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs index 2c9dcc8..e26d452 100644 --- a/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs +++ b/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs @@ -287,7 +287,7 @@ private void OnProjectStarted(ITagsProject project) } } - // 必须在项目开始轮询之前注册(见 ITagsProject.SetTurnProcessHandler 的"只能在运行前设置")。 + // 运行期也能挂(SetTurnProcessHandler 从下一轮生效):hub 晚于轮询启动时,这里就是在补挂回调。 foreach (var entry in index.Entries) { var entryKey = entry.Key; @@ -303,7 +303,7 @@ private void OnProjectStarted(ITagsProject project) { this._logger?.LogWarning( ex, - "无法为入口 {Entry} 注册轮次回调(项目可能已在运行):该入口只能观测结构,观测不到值", + "无法为入口 {Entry} 注册轮次回调(项目可能已释放):该入口只能观测结构,观测不到值", entryKey); } } diff --git a/src/StdUnit.Tags.WebUi/_Extensions_Endponits.cs b/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs similarity index 96% rename from src/StdUnit.Tags.WebUi/_Extensions_Endponits.cs rename to src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs index aac7e9e..2c32e05 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_Endponits.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs @@ -1,16 +1,14 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using StdUnit.Tags.WebUi.Internal; - namespace StdUnit.Tags.WebUi; +/// +/// extensions for Endpoints +/// public static class TagsStreamExtensions_Endpoints { - /// /// 映射端点(全部挂在 下,相对路径固定):
/// 不具名流(AddTagsStream()): diff --git a/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs b/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs index 6170d57..b443bfa 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs @@ -2,15 +2,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using StdUnit.Tags.WebUi.Internal; namespace StdUnit.Tags.WebUi; /// -/// 只读观测流的入口: 做 DI 注册, 映射端点。 +/// 只读观测流的 DI 注册入口: / 。
+/// 端点映射不在这里,见 里的 MapTagsStream。 ///
public static class TagsStreamExtensions_ServiceCollection { @@ -19,10 +17,10 @@ public static class TagsStreamExtensions_ServiceCollection /// 端点落在 {prefix}/tree|stream|value|rescan|ui。
/// 一个容器里只能有一路不具名流;要多路请用 。
///
- /// 启动顺序:hub 以 注册,随宿主一起启动。它需要在项目开始轮询之前 - /// 拿到 才能挂上"轮次完成"回调,所以请让轮询在宿主启动之后才开始 - /// (例如在请求里/更晚注册的 hosted service 里 StartPollAsync)。 - /// 若 hub 启动时项目已在运行,结构树仍可观测,但值观测会缺失并记一条 Warning。 + /// 启动顺序:hub 以 注册,随宿主一起启动。它在启动时按 + /// 对账,所以晚启动也能补上:即使项目已在轮询, + /// 结构树与值流都正常(轮次回调运行期也能挂,从下一轮开始生效)。 + /// 想尽早拿到结构树,就把轮询放在宿主启动之后(例如在请求里/更晚注册的 hosted service 里 StartPollAsync)。 ///
/// 服务集合 /// 这一路流的配置回调 diff --git a/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs b/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs index eb83910..6f39bc7 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs @@ -1,17 +1,25 @@ namespace StdUnit.Tags.WebUi; /// -/// extensions for AddTagsStream on TagsProjectServiceBuilder +/// 上的观测流注册:实现就是转调 builder.Services 上 +/// 里的同名扩展。
+/// 两条路径写的是同一个服务集合,所以"不具名只能一路"、"key 不能重复/不能是保留字"等校验完全一致; +/// 把注册写进 AddTagsProjectServices 的回调里,等价于直接写 services.AddTagsStream(...)。 ///
public static class TagsStreamExtensions_TagsProjectServiceBuilder { /// - /// 注册不具名的观测流。
- /// 和不具名的相关联。最多只有一路不具名的观测流。 + /// 注册不具名的观测流(等价于 builder.Services.AddTagsStream(configure))。
+ /// 和不具名的 关联;一个容器里最多只有一路不具名的观测流。 ///
+ /// + /// 启动顺序:hub 以 IHostedService 注册,随宿主一起启动;它在启动时按 ITagsProjectCtrl.Project 对账, + /// 所以晚启动也能补上——即使项目已在轮询,结构树与值流都正常(轮次回调运行期也能挂,从下一轮开始生效)。 + /// 想尽早拿到结构树,就把轮询放在宿主启动之后(例如在请求里/更晚注册的 hosted service 里 StartPollAsync)。 + /// /// - /// - /// + /// 这一路流的配置回调 + /// 同一个 builder,便于链式配置 public static TagsProjectServiceBuilder AddTagsStream(this TagsProjectServiceBuilder builder, Action? configure = null) { builder.Services.AddTagsStream(configure); @@ -20,13 +28,20 @@ public static TagsProjectServiceBuilder AddTagsStream(this TagsProjectServiceBui /// - /// 注册一路具名的观测流。
- /// 和具名的相关联,每个名称下最多只有一路观测流。 + /// 注册一路具名的观测流(等价于 builder.Services.AddKeyedTagsStream(projctrlKey, configure))。
+ /// 和具名的 关联,每个名称下最多只有一路观测流。 ///
+ /// + /// 时序约束同 : + /// hub 在启动时对账,晚启动也能补上,顺序只影响"多早开始观测"。 + /// /// - /// - /// - /// + /// + /// 控制器的注册键,同时作为路由里的 {keyed} 段:允许字母/数字/./-/_, + /// 且不能是 ui/tree/stream/value/rescan。 + /// + /// 这一路流的配置回调(每次调用各一份,互不影响) + /// 同一个 builder,便于链式配置 public static TagsProjectServiceBuilder AddKeyedTagsStream(this TagsProjectServiceBuilder builder, string projctrlKey, Action? configure = null) { builder.Services.AddKeyedTagsStream(projctrlKey, configure); diff --git a/src/StdUnit.Tags/ServiceExtensions/TagsProjectServiceCollection.cs b/src/StdUnit.Tags/ServiceExtensions/TagsProjectServiceCollection.cs index cb9b96d..f79c156 100644 --- a/src/StdUnit.Tags/ServiceExtensions/TagsProjectServiceCollection.cs +++ b/src/StdUnit.Tags/ServiceExtensions/TagsProjectServiceCollection.cs @@ -47,7 +47,7 @@ public static IServiceCollection AddKeyedTagsProjectCtrl(this IServiceCollection /// public static TagsProjectServiceBuilder AddKeyedTagsProjectCtrl(this TagsProjectServiceBuilder builder, string key) { - builder.Services.AddKeyedSingleton(key); + builder.Services.AddKeyedTagsProjectCtrl(key); return builder; } diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs index 6f7c662..148b8c8 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs @@ -155,6 +155,38 @@ public async Task SubscribeAfterStart_ReceivesBaselineImmediately() await hub.StopAsync(default); } + [Fact] + public async Task Hub_StartedAfterProject_StillAttachesTurnHandlers() + { + using var project = CreateSingleEntryProject(this._scope); + var ctrl = new FakeProjectCtrl(); + + // 项目先跑起来,hub 还没启动(顺序反了:以前这里会挂不上轮次回调,只能观测结构)。 + ctrl.RaiseStarted(project); + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + + using var hub = new TagsStreamHostedService(ctrl, FastOptions()); + await hub.StartAsync(default); + Assert.True(hub.GetTree().IsStarted); + + using var output = new RecordingStream(); + using var piperCts = new CancellationTokenSource(); + using var piper = hub.Subscribe(entries: null); + var writer = piper.PipeFramesToStreamAsync(output, piperCts.Token); + + // 订阅即拿到全量基线(只读当前值,不能证明回调挂上了)。 + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"full\":true"))); + + // 再改一次值:能收到增量就说明轮次回调确实在运行期补挂上了。 + StreamTestContext.TagAt(project, "g1/a").Value = (short)99; + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"raw\":\"99\""))); + + await StreamTestContext.StopAsync(runCts, run); + await StopSubscriberAsync(piperCts, writer); + await hub.StopAsync(default); + } + /// 取出包含指定片段的那一帧(以空行分隔)。 private static string? FrameContaining(string text, string needle) { diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs index 6689fd6..5cf2c1c 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs @@ -12,10 +12,6 @@ namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; -/// -/// :对外只能改前缀,相对路径固定; -/// 返回的路由组能整组加鉴权。 -/// public class TagsStreamMappingTests { private static (WebApplication App, IEndpointRouteBuilder Builder) CreateApp() diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs index 8769c81..97aef3e 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs @@ -16,7 +16,7 @@ namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; ///
public class TagsStreamRegistrationTests { - private static ServiceCollection CreateServices() + private static ServiceCollection CreateServices(Action? configure = null) { var services = new ServiceCollection(); services.AddLogging(); @@ -24,6 +24,7 @@ private static ServiceCollection CreateServices() { b.AddFakedSupport(); b.AddFakedTagSupport(); + configure?.Invoke(b); }); return services; } @@ -234,4 +235,66 @@ public async Task AddTagsStream_BindsToTheUnnamedControllerByDefault() await hosted.StopAsync(default); } } + + /// + /// builder 版(builder.AddTagsStream(...))只是转调 services 版:注册结果、hosted service 绑定、 + /// 配置回调的去向,以及"不具名只能一路"的校验,都应与 services 版完全一致。 + /// + [Fact] + public void AddTagsStream_OnTheProjectBuilder_RegistersTheUnnamedStream() + { + var services = CreateServices(b => b.AddTagsStream(o => o.HeartbeatInterval = TimeSpan.FromSeconds(7))); + + // 同一个服务集合:services 版仍能看到 builder 版注册的那一路,重复注册照样被拒。 + var ex = Assert.Throws(() => services.AddTagsStream()); + Assert.Contains("AddKeyedTagsStream", ex.Message); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true, + }); + + var hub = provider.GetRequiredService(); + Assert.Same(hub, provider.GetRequiredService()); + Assert.Contains(provider.GetServices(), h => ReferenceEquals(h, hub)); + + // configure 落在这一路流自己的描述符上(不是被丢掉的临时对象)。 + var descriptor = Assert.Single(provider.GetServices()); + Assert.Null(descriptor.Key); + Assert.Equal(TimeSpan.FromSeconds(7), descriptor.Options.HeartbeatInterval); + } + + /// + /// builder 版 AddKeyedTagsStream(key, ...) 应绑定到同 key 的具名控制器, + /// 并把它自己的 交给这一路流。 + /// + [Fact] + public void AddKeyedTagsStream_OnTheProjectBuilder_RegistersTheKeyedStream() + { + var named = new FakeProjectCtrl(); + var services = CreateServices(b => + { + b.AddKeyedTagsProjectCtrl("dev"); + b.AddKeyedTagsStream("dev", o => o.ReconcileInterval = TimeSpan.FromMilliseconds(5)); + }); + + // 后注册的实例覆盖 builder 里注册的实现,便于断言"这一路流到底绑了谁"。 + services.AddKeyedSingleton("dev", named); + + using var provider = services.BuildServiceProvider(); + + var hub = provider.GetRequiredKeyedService("dev"); + var hosted = Assert.Single(provider.GetServices()); + Assert.Same(hub, hosted); + + var descriptor = Assert.Single(provider.GetServices()); + Assert.Equal("dev", descriptor.Key); + Assert.Equal(TimeSpan.FromMilliseconds(5), descriptor.Options.ReconcileInterval); + + // registry 里那一路流观测的就是具名控制器(端点/命令都走同一个 Ctrl)。 + var entry = provider.GetRequiredService().Get("dev"); + Assert.NotNull(entry); + Assert.Same(named, entry!.Ctrl); + } } diff --git a/tests/StdUnit.Tags.Tests/Core/Projects/TagsProjectTurnProcessTests.cs b/tests/StdUnit.Tags.Tests/Core/Projects/TagsProjectTurnProcessTests.cs index 9d1d5dd..58c535e 100644 --- a/tests/StdUnit.Tags.Tests/Core/Projects/TagsProjectTurnProcessTests.cs +++ b/tests/StdUnit.Tags.Tests/Core/Projects/TagsProjectTurnProcessTests.cs @@ -16,7 +16,8 @@ namespace StdUnit.Tags.Tests.Core.Projects; /// - 按入口设置,某入口每一轮触发一次,且只触发该入口的回调;
/// - 排在 Logicets 之后(回调看到的是逻辑处理完成后的值);
/// - 每个入口最多一个回调:重复设置是替换,多个观察者由调用方自己组合(顺序显式);
-/// - 只能在项目运行之前设置(运行后抛 ),传 null 表示清除;
+/// - 运行前/运行中都能设置:运行期设置与清除都从下一轮开始生效,传 null 表示清除;
+/// - 项目已释放后设置抛 ;
/// - 回调抛出的异常不被吞掉(走 的恢复路径);
/// - 入口被禁用时不会产生轮次,因此不会触发;
/// - 未知入口 / null 入口按契约抛异常。 @@ -358,7 +359,7 @@ public async Task Set_Null_ClearsHandler() } [Fact] - public async Task SetTurnProcessHandler_AfterRunStarted_Throws() + public async Task SetTurnProcessHandler_AfterRunStarted_TakesEffectFromNextRound() { using var scope = _root.CreateScope(); using var proj = CreateSingleEntryProject(scope); @@ -366,10 +367,99 @@ public async Task SetTurnProcessHandler_AfterRunStarted_Throws() using var cts = new CancellationTokenSource(); var run = proj.RunAsync(cts.Token); - // 运行期不再接受注册:入口列表在运行前就已固定,动态开关观测应在回调内部判断。 - var ex = Assert.Throws( + // 运行期挂载:下一轮开始生效(回调表是写时复制的,选中快照不会被并发改坏)。 + var count = new int[1]; + var twoTurns = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + proj.SetTurnProcessHandler("g1", (entry, channel, _) => + { + if (Interlocked.Increment(ref count[0]) >= 2) + { + twoTurns.TrySetResult(true); + } + return Task.CompletedTask; + }); + + await WaitOrFailAsync(twoTurns.Task, "运行期设置的回调从下一轮开始触发"); + Assert.True(Volatile.Read(ref count[0]) >= 2); + + await StopAsync(cts, run); + } + + [Fact] + public async Task SetTurnProcessHandler_ClearedWhileRunning_StopsFiringFromNextRound() + { + using var scope = _root.CreateScope(); + using var proj = CreateSingleEntryProject(scope); + + var count = new int[1]; + var firstTurn = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + proj.SetTurnProcessHandler("g1", (entry, channel, _) => + { + Interlocked.Increment(ref count[0]); + firstTurn.TrySetResult(true); + return Task.CompletedTask; + }); + + using var cts = new CancellationTokenSource(); + var run = proj.RunAsync(cts.Token); + await WaitOrFailAsync(firstTurn.Task, "回调先触发一次"); + + // 运行期清除:等在途轮次用掉旧快照后取基线,之后的轮次都不应再进来。 + proj.SetTurnProcessHandler("g1", null); + await Task.Delay(ScanIntervalMs * 3); + var baseline = Volatile.Read(ref count[0]); + + await Task.Delay(ScanIntervalMs * 10); + Assert.Equal(baseline, Volatile.Read(ref count[0])); + + await StopAsync(cts, run); + } + + [Fact] + public async Task SetTurnProcessHandler_OnDisposedProject_Throws() + { + using var scope = _root.CreateScope(); + var proj = CreateSingleEntryProject(scope); + + using var cts = new CancellationTokenSource(); + var run = proj.RunAsync(cts.Token); + await StopAsync(cts, run); + + proj.Dispose(); + + Assert.Throws( () => proj.SetTurnProcessHandler("g1", (entry, channel, _) => Task.CompletedTask)); - Assert.Contains("运行之前", ex.Message); + } + + [Fact] + public async Task SetTurnProcessHandler_ConcurrentSetAndClearWhileRunning_DoesNotCorrupt() + { + using var scope = _root.CreateScope(); + using var proj = CreateSingleEntryProject(scope); + + using var cts = new CancellationTokenSource(); + var run = proj.RunAsync(cts.Token); + + using var stopMutating = new CancellationTokenSource(); + var mutator = Task.Run(() => + { + var i = 0; + while (!stopMutating.IsCancellationRequested) + { + proj.SetTurnProcessHandler("g1", (entry, channel, _) => Task.CompletedTask); + if ((i++ % 2) == 0) + { + proj.SetTurnProcessHandler("g1", null); + } + } + }); + + await Task.Delay(ScanIntervalMs * 20); + stopMutating.Cancel(); + await mutator; + + // 并发设置/清除不应让轮询崩掉(快照替换 + 轮询线程只读)。 + Assert.False(run.IsFaulted, run.Exception?.ToString()); await StopAsync(cts, run); } diff --git a/tests/StdUnit.Tags.Tests/Core/TagsProjectCtrls/TagsProjectServiceCollectionTests.cs b/tests/StdUnit.Tags.Tests/Core/TagsProjectCtrls/TagsProjectServiceCollectionTests.cs index e5aab69..bc2b763 100644 --- a/tests/StdUnit.Tags.Tests/Core/TagsProjectCtrls/TagsProjectServiceCollectionTests.cs +++ b/tests/StdUnit.Tags.Tests/Core/TagsProjectCtrls/TagsProjectServiceCollectionTests.cs @@ -115,4 +115,22 @@ public void UnregisteredKey_Throws() Assert.Throws(() => sp.GetRequiredKeyedService("unregistered_key")); } + + /// + /// + /// 应等价于 : + /// 在 AddTagsProjectServices 的回调里注册即可按 key 解析,且与不具名的那个互不干扰。 + /// + [Fact] + public void AddKeyedTagsProjectCtrl_OnBuilder_RegistersKeyedCtrl() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTagsProjectServices(builder => builder.AddKeyedTagsProjectCtrl("alpha")); + + using var sp = services.BuildServiceProvider(); + var ctrl = sp.GetRequiredKeyedService("alpha"); + Assert.NotNull(ctrl); + Assert.NotSame(sp.GetRequiredService(), ctrl); + } } From 70923ff8c2936e683710bffdbee0cb7e61ac8086 Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Thu, 29 Oct 2026 00:46:24 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E5=A2=9E=E5=8A=A0Designer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- README.md | 4 +- ...06\346\236\266\345\267\256\345\274\202.md" | 4 +- samples/WebMonitor/README.md | 23 +- samples/WebMonitor/Tags/_Extensions.cs | 5 +- samples/WebMonitor/WebMonitor.csproj | 2 +- src/StdUnit.Tags.ComScanner/Schemas/com.xsd | 45 +- src/StdUnit.Tags.Core/Schemas/README.md | 30 + src/StdUnit.Tags.Core/Schemas/tagsproject.xsd | 104 +- src/StdUnit.Tags.Hjzk/Schemas/hjzk.xsd | 25 +- .../Schemas/modbustcp.xsd | 25 +- .../Schemas/opcua-client.xsd | 48 +- src/StdUnit.Tags.S7/Schemas/s7.xsd | 23 +- .../Schemas/simplefiles.xsd | 9 +- .../Core/EndpointConstants.cs | 11 + .../Core/TagsStreamJsonContext.cs | 1 + .../Designer/DesignerCatalog.cs | 605 +++++++++++ .../Designer/DesignerCatalogDto.cs | 136 +++ .../Designer/DesignerCatalogEndpoint.cs | 27 + .../StdUnit.Tags.WebUi.csproj | 5 +- src/StdUnit.Tags.WebUi/Ui/DesignerUi.cs | 29 + src/StdUnit.Tags.WebUi/Ui/TagsStreamUi.cs | 61 +- src/StdUnit.Tags.WebUi/Ui/WebPage.cs | 59 ++ src/StdUnit.Tags.WebUi/Ui/designer.html | 999 ++++++++++++++++++ src/StdUnit.Tags.WebUi/Ui/index.html | 88 +- .../_Extensions_Endpoints.cs | 17 +- .../_Extensions_ServiceCollection.cs | 11 +- .../_Extensions_TagsProjectServiceBuilder.cs | 2 +- src/StdUnit.Tags.ZLan/Schemas/zlan.xsd | 25 +- tests/AotSmokeTest.WebUi/Program.cs | 38 +- .../StdUnit.Tags.Tests.NetCoreOnly.csproj | 2 +- .../WebUi/DesignerCatalogTests.cs | 293 +++++ .../WebUi/TagsStreamMappingTests.cs | 49 +- .../WebUi/TagsStreamRegistrationTests.cs | 11 + .../Schema/DriverSchemaAnnotationsTests.cs | 114 ++ 35 files changed, 2773 insertions(+), 159 deletions(-) create mode 100644 src/StdUnit.Tags.WebUi/Designer/DesignerCatalog.cs create mode 100644 src/StdUnit.Tags.WebUi/Designer/DesignerCatalogDto.cs create mode 100644 src/StdUnit.Tags.WebUi/Designer/DesignerCatalogEndpoint.cs create mode 100644 src/StdUnit.Tags.WebUi/Ui/DesignerUi.cs create mode 100644 src/StdUnit.Tags.WebUi/Ui/WebPage.cs create mode 100644 src/StdUnit.Tags.WebUi/Ui/designer.html create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/DesignerCatalogTests.cs create mode 100644 tests/StdUnit.Tags.Tests/Core/Schema/DriverSchemaAnnotationsTests.cs diff --git a/AGENTS.md b/AGENTS.md index 1b65275..820c772 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ StdUnit.Tags.Core 硬件无关的核心抽象 + Schemas/tagsproje ``` - `StdUnit.Tags.SchemaGenerator` 是源生成器,把各项目的 XSD 编译成 DLL 内字符串常量(AOT / trim 友好)。 -- 新增驱动包时,需一并提供 `Schemas/{driver}.xsd`,并以 `buildTransitive` 方式注入到消费方项目树(照抄 `StdUnit.Tags.Core.csproj` + `Schemas/StdUnit.Tags.Core.targets` 的模式),并实现 `ITagsProjectSchemaProvider` 注册为 Singleton。 +- 新增驱动包时,需一并提供 `Schemas/{driver}.xsd`,并以 `buildTransitive` 方式注入到消费方项目树(照抄 `StdUnit.Tags.Core.csproj` + `Schemas/StdUnit.Tags.Core.targets` 的模式),并在 schema 里用 `xs:annotation/xs:appinfo` 声明 ``(必须与 `XxxNames.DriverName` 一致;`WebUi` 的 `/catalog` 设计器元数据靠它把 driver 值绑到命名空间——XSD 1.0 表达不了这层关系,靠命名约定也不行),再实现 `ITagsProjectSchemaProvider` 注册为 Singleton。 - 测试分两个项目(xUnit),且**主测试项目多目标 `net8.0;net472`**: - `tests/StdUnit.Tags.Tests`:主体测试,跑在两个 TFM 上; - `tests/StdUnit.Tags.Tests.NetCoreOnly`:**仅 net8.0**,专门存放无法面向 net472 的测试(如依赖 `AssemblyLoadContext`,或将来涉及 Blazor / ASP.NET Core 的测试)。目前为空项,作占位预留。 diff --git a/README.md b/README.md index 4b534db..1e3e995 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ dotnet new tags.driver -n your-project.name -D YourDriver - `StdUnit.Tags.R3Extensions`: `Cysharp/R3`扩展,只依赖于`StdUnit.Tags.Core` - `StdUnit.Tags.BlazorLib.Core`: Blazor 类库,包含核心功能抽象,以及一个极简的监控页面。 - `StdUnit.Tags.McpServer`: 这是一个把`StdUnit.Tags`暴露成 [Model Context Protocol Server](https://modelcontextprotocol.io/) 的类库。 -- `StdUnit.Tags.WebUi`: 只读观测流(ASP.NET Core)。结构树走一次性 `GET`,测点值按"入口 + 轮次"以 SSE(`text/event-stream`)推送;不依赖 Blazor / SignalR,可在 NativeAOT 宿主下使用。自带一个极简监控页(`GET /stdunit.tags/ui`,单文件 HTML + 内嵌 JS,无需 npm 工程),并附带两个写命令(`POST /stdunit.tags/value` 改值、`POST /stdunit.tags/rescan` 标记未扫描)。端点只允许改**路由前缀**(`app.MapTagsStream("/internal/tags")`),相对路径是线协议的一部分;**不内置任何鉴权**,暴露到非受信网络前请对返回的路由组加策略,例如 `app.MapTagsStream().RequireAuthorization()`。 +- `StdUnit.Tags.WebUi`: 测点项目的 **Web 界面**(ASP.NET Core,不依赖 Blazor / SignalR,可在 NativeAOT 宿主下使用)。监控侧:结构树走一次性 `GET`、值按"入口 + 轮次"以 SSE(`text/event-stream`)推送,内置极简监控页(`GET /stdunit.tags/ui`);设计器侧:把核心 + 各驱动 schema 内省成元数据(`GET /stdunit.tags/catalog`),通道/测点设计器据此渲染表单、导入既有项目 XML 并导出(`GET /stdunit.tags/designer`,未知驱动/元素/属性原样保留)。页面都是单文件 HTML + 内嵌 JS,无需 npm 工程。另有两个写命令(`POST /stdunit.tags/value` 改值、`POST /stdunit.tags/rescan` 标记未扫描)。端点只允许改**路由前缀**(`app.MapTagsStream("/internal/tags")`),相对路径是线协议的一部分;**不内置任何鉴权**,暴露到非受信网络前请对返回的路由组加策略,例如 `app.MapTagsStream().RequireAuthorization()`。 > 备注:源码目录中还有一个 `StdUnit.Tags.BlazorLib`(不是 `StdUnit.Tags.BlazorLib.Core`),放的是常用硬件设备的编辑器和设计器实现。这些只是我把脑子里的片段随手写下来的实验性代码——因为将来可能做成类库,里面不少东西被标成了`public`——目前它只是实验场,不对外发布。 @@ -125,7 +125,7 @@ dotnet new tags.driver -n your-project.name -D YourDriver | R3扩展 | `StdUnit.Tags.R3Extensions` | ✅ 支持 | | McpServer扩展 | `StdUnit.Tags.McpServer` | ✅ 支持 | | BlazorLibCore扩展 | `StdUnit.Tags.BlazorLib.Core` | ✅ 支持 | -| 只读观测流 | `StdUnit.Tags.WebUi` | 🧪 实验 | +| Web 界面(监控 + 设计器) | `StdUnit.Tags.WebUi` | 🧪 实验 | 上表列出的包中,标记为“实验”的,`..` 里的 `` 跳变可能包含破坏性更新;标记为“支持”的,只有 `` 跳变才可能包含破坏性更新。核心包(`StdUnit.Tags`、`StdUnit.Tags.Core`)不属于驱动/扩展、因此不在此表内,按语义化版本处理——同样只有 `` 跳变才可能包含破坏性更新。 diff --git "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" index 2384c06..1edc762 100644 --- "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" +++ "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/4. \347\233\256\346\240\207\346\241\206\346\236\266\345\267\256\345\274\202.md" @@ -37,7 +37,7 @@ MCP 测试已在 `McpServer` 支持 net472 后合并回主测试项目(走 `Mo | `StdUnit.Tags.RxExtensions` | NotPlanned | 底层 `System.Reactive` 目前对 AOT 支持不好 | | `StdUnit.Tags.McpServer` | Ready | | | `StdUnit.Tags.BlazorLib.Core` | NotPlanned | Blazor 团队已明确表述不支持 Server 模式的 AOT | -| `StdUnit.Tags.WebUi` | Ready | 只用 Minimal API + SSE + JSON 源生成;**不依赖** SignalR / Session / MVC(这三者都不能 AOT)。宿主是否 `PublishAot` 由宿主自己决定 | +| `StdUnit.Tags.WebUi` | Ready | 只用 Minimal API + SSE + JSON 源生成;**不依赖** SignalR / Session / MVC / Blazor(这些都过不了 AOT)。设计器元数据(`/catalog`)在运行期读/编译 XSD——`System.Xml.Schema` 本身是纯 IL,AOT 下已验证可用 | | **驱动** | | | | `StdUnit.Tags.S7` | Ready | | | `StdUnit.Tags.ModbusTcp` | Ready | | @@ -51,4 +51,4 @@ MCP 测试已在 `McpServer` 支持 net472 后合并回主测试项目(走 `Mo > 备注: CI中提供了端到端的AOT冒烟示例(位于 `tests/`): > - `tests/AotSmokeTest.LogicetPlugins`(Core + StdUnit.Tags + ModbusTcp) > - `tests/AotSmokeTest.SimpleFiles`(Core + StdUnit.Tags + SimpleFiles) -> - `tests/AotSmokeTest.WebUi`(WebUi:起 Kestrel,验证多态 JSON 序列化与 SSE 全链路) \ No newline at end of file +> - `tests/AotSmokeTest.WebUi`(WebUi:起 Kestrel,验证多态 JSON 序列化、SSE 全链路、监控页/设计器页与 `/catalog` 的 XSD 内省) \ No newline at end of file diff --git a/samples/WebMonitor/README.md b/samples/WebMonitor/README.md index 440a0ce..1f09669 100644 --- a/samples/WebMonitor/README.md +++ b/samples/WebMonitor/README.md @@ -1,6 +1,6 @@ # WebMonitor —— WebUi 监控 demo(NativeAOT 可发布) -与 `dotnet new tags.web` 生成的模板同构,只是把 Blazor 监控页换成了 **WebUi** 的只读观测流: +与 `dotnet new tags.web` 生成的模板同构,只是把 Blazor 监控页换成了 **WebUi**(监控页 + 设计器,AOT 友好): ``` WebMonitor/ @@ -15,6 +15,24 @@ WebMonitor/ line-b/index.xml # 具名流(key=line-b)观测的项目 ``` +## 页面 + +| 页面 | URL | 说明 | +|---|---|---| +| 监控页(不具名流) | `/stdunit.tags/ui` | 结构树 + SSE 值流 | +| 监控页(具名 `line-b`) | `/stdunit.tags/line-b/ui` | 同上,观测 `Tags/line-b` | +| **设计器** | `/stdunit.tags/designer` | 通道 + 测点设计、导入既有项目 XML、导出项目 XML | +| 设计器元数据 | `/stdunit.tags/catalog` | 设计器用的 schema 内省结果(JSON) | + +设计器与具体流无关(schema 是全局的),所以只有不具名的一份。页面里的通道字段完全由 +`/catalog` 驱动——新增驱动不用改页面。它的驱动列表来自各驱动的 `ITagsProjectSchemaProvider`, +所以示例用 `AddSimpleFilesSupport()`(而不是 `AddSimpleFilesChannel()` + +`AddSimpleFilesDirectTagBuilder()`):它是后两者的超集,会一并注册 schema provider。 + +设计器支持把既有 `index.xml` 粘贴/选择文件导入("尽力解析"):认得出的按 schema 回填到表单, +认不出的驱动字段、扩展属性、未知元素**原样保留**,导出时再写回去(XML 注释会丢失)。 +未注册驱动的通道不会被丢弃,只会在导出页提示"驱动不在目录里"。 + ## 两路观测流 | 流 | 控制器 | 项目 | 监控页 | @@ -31,7 +49,8 @@ WebMonitor/ dotnet run --project samples/WebMonitor/WebMonitor.csproj ``` -打开 (具名流:`/stdunit.tags/line-b/ui`)。 +打开 (具名流:`/stdunit.tags/line-b/ui`; +设计器:)。 ## AOT 发布 diff --git a/samples/WebMonitor/Tags/_Extensions.cs b/samples/WebMonitor/Tags/_Extensions.cs index 6cb3be2..4e92981 100644 --- a/samples/WebMonitor/Tags/_Extensions.cs +++ b/samples/WebMonitor/Tags/_Extensions.cs @@ -11,8 +11,9 @@ public static IServiceCollection AddTags(this IServiceCollection services) services.AddTagsProjectServices(builder => { // 本示例只用 SimpleFiles(数据是磁盘上的文本文件),不依赖任何硬件/网络。 - builder.AddSimpleFilesChannel() - .AddSimpleFilesDirectTagBuilder(); + // 用 Support 而不是 Channel+DirectTagBuilder:它同时注册 SimpleFilesSchemaProvider, + // 设计器元数据(/stdunit.tags/catalog)里的驱动列表就来自这些 schema provider。 + builder.AddSimpleFilesSupport(); // WebUi:不具名的观测流(端点 /stdunit.tags/*)+ 一路具名流(/stdunit.tags/line-b/*,各有一套监控页)。 builder.AddTagsStream(o => diff --git a/samples/WebMonitor/WebMonitor.csproj b/samples/WebMonitor/WebMonitor.csproj index 8415fe0..7d57fae 100644 --- a/samples/WebMonitor/WebMonitor.csproj +++ b/samples/WebMonitor/WebMonitor.csproj @@ -6,7 +6,7 @@ enable false - true diff --git a/src/StdUnit.Tags.ComScanner/Schemas/com.xsd b/src/StdUnit.Tags.ComScanner/Schemas/com.xsd index d63e120..0c259c3 100644 --- a/src/StdUnit.Tags.ComScanner/Schemas/com.xsd +++ b/src/StdUnit.Tags.ComScanner/Schemas/com.xsd @@ -25,6 +25,11 @@ targetNamespace="tags:com" elementFormDefault="qualified"> + + + + + @@ -46,15 +51,35 @@ - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/StdUnit.Tags.Core/Schemas/README.md b/src/StdUnit.Tags.Core/Schemas/README.md index 547ce3d..0b13b0a 100644 --- a/src/StdUnit.Tags.Core/Schemas/README.md +++ b/src/StdUnit.Tags.Core/Schemas/README.md @@ -142,3 +142,33 @@ services.AddTagsProjectServices(b => ``` > 注:运行时校验(步骤 1)与编辑器注入(步骤 2)相互独立。没有硬性要求全部完成。 + +### 3. 设计器元数据:让 schema 自己声明驱动名(可选,但推荐) + +`StdUnit.Tags.WebUi` 的 `GET {prefix}/catalog` 会把**所有注册了 `ITagsProjectSchemaProvider` 的 schema** +内省成设计器元数据(通道/测点设计器据此渲染表单、导出 XML)。它需要知道"哪个 schema 属于哪个 `driver`"—— +这件事 **XSD 1.0 表达不了**(不能按属性值切换内容模型),而且靠命名约定也不行 +(`HjzkIoBox` ↔ `tags:hjzk`、`ZLanTcp` ↔ `tags:zlan` 就对不上)。所以由 schema 自己声明: + +```xml + + + + + + + + + + + +``` + +- `` **必须与运行时 `XxxNames.DriverName`(即 XML 里的 `driver="…"`)完全一致**; + `prefix` 缺省取命名空间后缀(`tags:mydriver` → `mydriver`)。 +- `` 只影响 UI:`label` / `order` / `group` / `placeholder` / `help`。XSD 会忽略 `appinfo` + 里的自定义元素,对校验零影响;**不写也能用**(设计器退化为按属性名显示)。 +- 这两种元素都按**本地名**匹配,跟 `xs:schema` 上有没有默认 `xmlns` 无关。 +- 没声明 `` 的 schema 仍然参与校验,只是不出现在设计器的驱动列表里。 + diff --git a/src/StdUnit.Tags.Core/Schemas/tagsproject.xsd b/src/StdUnit.Tags.Core/Schemas/tagsproject.xsd index 2d62661..2d49e10 100644 --- a/src/StdUnit.Tags.Core/Schemas/tagsproject.xsd +++ b/src/StdUnit.Tags.Core/Schemas/tagsproject.xsd @@ -95,20 +95,38 @@ + 对应 TagDescriptor(XElementExtensions_TagDescriptor.ToTagDescriptor) + 设计器元数据(xs:appinfo/ui)只影响 UI,校验时被忽略。 --> + - + + + - + + + - - - - - - + + + + + + + + + + + + + + + + + + @@ -116,34 +134,62 @@ + - + + + - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + @@ -152,13 +198,18 @@ 驱动专属子元素在各自的驱动 schema(Schemas/Drivers/*.xsd,独立命名空间)中声明, 这里用 放行。 --> + - - + + + + + + @@ -175,10 +226,13 @@ Project 是项目描述文件的文档根元素(名字是约定,运行期不校验根元素名)。 子元素 Channel / TagGrp / Logicet 顺序任意、数量任意。 --> + - + + + diff --git a/src/StdUnit.Tags.Hjzk/Schemas/hjzk.xsd b/src/StdUnit.Tags.Hjzk/Schemas/hjzk.xsd index 8c82feb..3cd3ec8 100644 --- a/src/StdUnit.Tags.Hjzk/Schemas/hjzk.xsd +++ b/src/StdUnit.Tags.Hjzk/Schemas/hjzk.xsd @@ -18,17 +18,32 @@ targetNamespace="tags:hjzk" elementFormDefault="qualified"> + + + + + - + + + - - - - + + + + + + + + + + + + diff --git a/src/StdUnit.Tags.ModbusTcp/Schemas/modbustcp.xsd b/src/StdUnit.Tags.ModbusTcp/Schemas/modbustcp.xsd index 5527485..089e835 100644 --- a/src/StdUnit.Tags.ModbusTcp/Schemas/modbustcp.xsd +++ b/src/StdUnit.Tags.ModbusTcp/Schemas/modbustcp.xsd @@ -39,10 +39,25 @@ - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/src/StdUnit.Tags.OpcUaClient/Schemas/opcua-client.xsd b/src/StdUnit.Tags.OpcUaClient/Schemas/opcua-client.xsd index 3ce427a..cd40dd1 100644 --- a/src/StdUnit.Tags.OpcUaClient/Schemas/opcua-client.xsd +++ b/src/StdUnit.Tags.OpcUaClient/Schemas/opcua-client.xsd @@ -34,20 +34,40 @@ targetNamespace="tags:opcuaclient" elementFormDefault="qualified"> + + + + + + - - - - + + + + + + + + + + + + + - - + + + + + + + @@ -57,12 +77,20 @@ - + + + - - - + + + + + + + + + diff --git a/src/StdUnit.Tags.S7/Schemas/s7.xsd b/src/StdUnit.Tags.S7/Schemas/s7.xsd index 3be58eb..d2a4e17 100644 --- a/src/StdUnit.Tags.S7/Schemas/s7.xsd +++ b/src/StdUnit.Tags.S7/Schemas/s7.xsd @@ -17,15 +17,30 @@ targetNamespace="tags:s7" elementFormDefault="qualified"> + + + + + - - - - + + + + + + + + + + + + diff --git a/src/StdUnit.Tags.SimpleFiles/Schemas/simplefiles.xsd b/src/StdUnit.Tags.SimpleFiles/Schemas/simplefiles.xsd index 4ad6aa6..1e13f3c 100644 --- a/src/StdUnit.Tags.SimpleFiles/Schemas/simplefiles.xsd +++ b/src/StdUnit.Tags.SimpleFiles/Schemas/simplefiles.xsd @@ -15,6 +15,13 @@ targetNamespace="tags:simplefiles" elementFormDefault="qualified"> - + + + + + + + + diff --git a/src/StdUnit.Tags.WebUi/Core/EndpointConstants.cs b/src/StdUnit.Tags.WebUi/Core/EndpointConstants.cs index e32060d..e6844a8 100644 --- a/src/StdUnit.Tags.WebUi/Core/EndpointConstants.cs +++ b/src/StdUnit.Tags.WebUi/Core/EndpointConstants.cs @@ -5,10 +5,12 @@ namespace StdUnit.Tags.WebUi; /// /// /{prefix} # 不具名流(AddTagsStream()) /// /ui # 内置监控页(GET) +/// /designer # 内置设计器页(GET,通道 + 测点,导出项目 XML) /// /tree # 结构树(GET) /// /stream # 值流(GET,SSE) /// /value # 改测点值(POST) /// /rescan # 标记未扫描(POST) +/// /catalog # 设计器元数据(GET,与具体流无关,只有这一份) /// /{prefix}/{keyed}/… # 每一路具名流(AddKeyedTagsStream(key)),路径与上面同形 /// ///
@@ -25,6 +27,9 @@ public static class EndpointConstants /// 内置监控页(GET)。 internal const string UiPath = "/ui"; + /// 内置设计器页(GET):通道 + 测点,导出项目 XML。 + internal const string DesignerPath = "/designer"; + /// 结构树(GET)。 internal const string TreePath = "/tree"; @@ -37,6 +42,12 @@ public static class EndpointConstants /// 标记未扫描(POST)。 internal const string RescanPath = "/rescan"; + /// + /// 设计器元数据(GET):核心 + 驱动 schema 内省成的 JSON(通道/测点设计器用)。 + /// 与具体观测流无关,因此没有具名版本。 + /// + internal const string CatalogPath = "/catalog"; + /// 具名流的同一套路径(/{keyed}/…)。 internal static string Keyed(string path) => "/{" + KeyedRouteValue + "}" + path; } diff --git a/src/StdUnit.Tags.WebUi/Core/TagsStreamJsonContext.cs b/src/StdUnit.Tags.WebUi/Core/TagsStreamJsonContext.cs index 1b89052..4131368 100644 --- a/src/StdUnit.Tags.WebUi/Core/TagsStreamJsonContext.cs +++ b/src/StdUnit.Tags.WebUi/Core/TagsStreamJsonContext.cs @@ -17,6 +17,7 @@ namespace StdUnit.Tags.WebUi; [JsonSerializable(typeof(WriteTagValueRequest))] [JsonSerializable(typeof(RescanTagRequest))] [JsonSerializable(typeof(CommandResult))] +[JsonSerializable(typeof(DesignerCatalogDto))] internal sealed partial class TagsStreamJsonContext : JsonSerializerContext { } diff --git a/src/StdUnit.Tags.WebUi/Designer/DesignerCatalog.cs b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalog.cs new file mode 100644 index 0000000..2711d86 --- /dev/null +++ b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalog.cs @@ -0,0 +1,605 @@ +using System.Globalization; +using System.Xml; +using System.Xml.Linq; +using System.Xml.Schema; + +namespace StdUnit.Tags.WebUi.Internal; + +/// +/// 把核心 schema + 所有已注册的驱动 schema 内省成 。
+///
+/// 两条数据通道,职责不重叠:
+/// 1. (编译后的)——结构、类型、必填、默认值、枚举、数值范围、xs:any 放行;
+/// 2. ——xs:annotation/xs:appinfo 里的 <driver>(驱动名绑定)与 +/// <ui>(文案/顺序/分组)。注释是文档级事实,直接读 XML 比在编译产物上找 Annotation 更可靠。
+///
+/// 结果在首次访问时构建并缓存(schema 集在一次运行内不变)。schema 本身有错时会让 +/// 抛异常——这与 EnableXmlSchemaValidation() 的行为一致, +/// 属于配置错误而不是请求错误。 +///
+internal sealed class DesignerCatalog +{ + /// catalog 结构版本(对外契约)。 + public const int CurrentVersion = 1; + + /// 文档根元素名(核心 schema 的约定,运行期不校验根元素名)。 + public const string RootElementName = "Project"; + + private static readonly XNamespace Xs = XmlSchema.Namespace; + + private readonly Lazy _catalog; + + /// + /// c'tor + /// + /// 额外的 schema 提供者(各驱动包注册的,可为 null/空) + public DesignerCatalog(IEnumerable? providers = null) + { + this._catalog = new Lazy(() => Build(providers)); + } + + /// 内省结果(首次访问时构建)。 + public DesignerCatalogDto Catalog => this._catalog.Value; + + private static DesignerCatalogDto Build(IEnumerable? providers) + { + var sources = new List<(string LogicalName, string Content)>(); + sources.AddRange(TagsProjectSchema.GetSchemaContents()); + if (providers is not null) + { + foreach (var provider in providers) + { + sources.AddRange(provider.GetSchemaContents()); + } + } + + var annotations = new SchemaAnnotations(sources); + var set = Compile(sources); + + var root = set.GlobalElements[new XmlQualifiedName(RootElementName)] as XmlSchemaElement + ?? throw new InvalidOperationException( + $"核心 schema 里没有 <{RootElementName}> 的全局元素声明,无法构建设计器元数据。"); + + var collector = new NodeCollector(annotations); + collector.Visit(root); + + return new DesignerCatalogDto( + CurrentVersion, + collector.Nodes[0], + collector.Nodes.Skip(1).ToArray(), + CollectDrivers(set, annotations), + BuiltinTagTypes()); + } + + private static XmlSchemaSet Compile(IEnumerable<(string LogicalName, string Content)> sources) + { + var set = new XmlSchemaSet(); + foreach (var (logicalName, content) in sources) + { + using var text = new StringReader(content); + using var reader = XmlReader.Create(text); + try + { + set.Add(XmlSchema.Read(reader, null)!); + } + catch (XmlSchemaException ex) + { + throw new InvalidOperationException($"无法解析 schema \"{logicalName}\":{ex.Message}", ex); + } + } + + set.Compile(); + return set; + } + + /// + /// 内置测点类型。schema 里 type 故意是开放字符串(插件可注册自定义类型), + /// 所以这只是给设计器的候选提示,不是闭集。 + /// + private static IReadOnlyList BuiltinTagTypes() => new[] + { + BuiltinTagKinds.BIT, + BuiltinTagKinds.BYTE, + BuiltinTagKinds.INT16, + BuiltinTagKinds.UINT16, + BuiltinTagKinds.INT32, + BuiltinTagKinds.UINT32, + BuiltinTagKinds.INT64, + BuiltinTagKinds.UINT64, + BuiltinTagKinds.FLOAT, + BuiltinTagKinds.DOUBLE, + BuiltinTagKinds.STR, + BuiltinTagKinds.DI, + BuiltinTagKinds.DO, + }; + + private static IReadOnlyList CollectDrivers(XmlSchemaSet set, SchemaAnnotations annotations) + { + var drivers = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (XmlSchema schema in set.Schemas()) + { + var ns = schema.TargetNamespace; + // 核心 schema 没有 targetNamespace,也因此不可能有驱动声明。 + if (string.IsNullOrEmpty(ns)) + { + continue; + } + + var driver = annotations.GetDriver(ns); + if (driver is null || !seen.Add(driver.Name)) + { + continue; + } + + var index = 0; + var fields = new List<(int Index, DesignerFieldDto Field)>(); + foreach (XmlSchemaElement element in schema.Elements.Values) + { + // 通道里的驱动字段是经核心 schema 的 xs:any minOccurs="0" 插进来的, + // 所以"是否必填"与元素自己的 minOccurs(全局声明默认 1)无关:一律可选。 + fields.Add((index++, DescribeElement(element, ns, annotations, required: false))); + } + + drivers.Add(new DesignerDriverDto( + driver.Name, + driver.DisplayName ?? driver.Name, + ns, + driver.Prefix ?? DerivePrefix(ns), + // OrderBy 是稳定排序:未声明 order 的字段保持 schema 里的声明顺序。 + fields.OrderBy(f => f.Field.Order ?? int.MaxValue).Select(f => f.Field).ToArray())); + } + + return drivers.OrderBy(d => d.Name, StringComparer.Ordinal).ToArray(); + } + + /// 命名空间 tags:s7 → 前缀 s7(去掉非 NCName 字符)。 + private static string DerivePrefix(string ns) + { + var suffix = ns[(ns.LastIndexOf(':') + 1)..]; + var prefix = new string(suffix.Where(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.').ToArray()); + if (prefix.Length == 0) + { + return "drv"; + } + + // NCName 不能以数字/'-'/'.' 开头。 + return char.IsLetter(prefix[0]) || prefix[0] == '_' ? prefix : "d" + prefix; + } + + private static DesignerFieldDto DescribeElement( + XmlSchemaElement element, + string ns, + SchemaAnnotations annotations, + bool required) + { + var name = element.QualifiedName.Name; + var ui = annotations.ElementUi(ns, name); + var type = element.ElementSchemaType; + + if (type is XmlSchemaComplexType complex) + { + var (children, _) = WalkParticle(complex.ContentTypeParticle); + return new DesignerFieldDto( + Name: name, + Type: null, + Kind: DesignerFieldKind.Group, + Required: required, + Default: null, + EnumValues: null, + Min: null, + Max: null, + MinLength: null, + Label: ui?.Label, + Order: ui?.Order, + Group: ui?.Group, + Placeholder: ui?.Placeholder, + Help: ui?.Help, + Children: children.Select(c => DescribeElement(c, ns, annotations, required: c.MinOccurs > 0)) + .OrderBy(f => f.Order ?? int.MaxValue) + .ToArray()); + } + + var shape = DescribeSimpleType(type as XmlSchemaSimpleType); + return new DesignerFieldDto( + Name: name, + Type: shape.Type, + Kind: shape.Kind, + Required: required, + Default: null, + EnumValues: shape.Enums, + Min: shape.Min, + Max: shape.Max, + MinLength: shape.MinLength, + Label: ui?.Label, + Order: ui?.Order, + Group: ui?.Group, + Placeholder: ui?.Placeholder, + Help: ui?.Help, + Children: null); + } + + private static DesignerFieldDto DescribeAttribute( + XmlSchemaAttribute attribute, + string ownerTypeName, + string ns, + SchemaAnnotations annotations) + { + var name = attribute.QualifiedName.Name; + var ui = annotations.AttributeUi(ns, ownerTypeName, name); + var shape = DescribeSimpleType(attribute.AttributeSchemaType); + + return new DesignerFieldDto( + Name: name, + Type: shape.Type, + Kind: shape.Kind, + Required: attribute.Use == XmlSchemaUse.Required, + Default: attribute.Use == XmlSchemaUse.Required ? null : attribute.DefaultValue, + EnumValues: shape.Enums, + Min: shape.Min, + Max: shape.Max, + MinLength: shape.MinLength, + Label: ui?.Label, + Order: ui?.Order, + Group: ui?.Group, + Placeholder: ui?.Placeholder, + Help: ui?.Help, + Children: null); + } + + /// + /// 简单类型的"形状":基础类型名 + 渲染类别 + 枚举 + 数值范围 + 最小长度。 + /// + private static SimpleShape DescribeSimpleType(XmlSchemaSimpleType? type) + { + var enums = new List(); + decimal? min = null; + decimal? max = null; + int? minLength = null; + + if (type?.Content is XmlSchemaSimpleTypeRestriction restriction) + { + foreach (XmlSchemaFacet facet in restriction.Facets) + { + switch (facet) + { + case XmlSchemaEnumerationFacet e: + if (!string.IsNullOrEmpty(e.Value)) + { + enums.Add(e.Value); + } + break; + case XmlSchemaMinInclusiveFacet minInclusive: + min = ParseDecimal(minInclusive.Value) ?? min; + break; + case XmlSchemaMaxInclusiveFacet maxInclusive: + max = ParseDecimal(maxInclusive.Value) ?? max; + break; + case XmlSchemaMaxExclusiveFacet maxExclusive: + max = ParseDecimal(maxExclusive.Value) ?? max; + break; + case XmlSchemaMinLengthFacet minLen: + minLength = int.TryParse(minLen.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) + ? n + : minLength; + break; + } + } + } + + var code = type?.Datatype?.TypeCode ?? XmlTypeCode.String; + + // positiveInteger / nonNegativeInteger 的边界来自类型本身,不是显式 facet。 + min ??= code switch + { + XmlTypeCode.PositiveInteger => 1, + XmlTypeCode.NonNegativeInteger => 0, + _ => null, + }; + + var kind = enums.Count > 0 + ? DesignerFieldKind.Enum + : code == XmlTypeCode.Boolean + ? DesignerFieldKind.Bool + : IsNumeric(code) + ? DesignerFieldKind.Number + : DesignerFieldKind.String; + + return new SimpleShape(code.ToString(), kind, enums.Count == 0 ? null : enums, min, max, minLength); + } + + private static bool IsNumeric(XmlTypeCode code) => code is + XmlTypeCode.Decimal or XmlTypeCode.Double or XmlTypeCode.Float or + XmlTypeCode.Integer or XmlTypeCode.NonPositiveInteger or XmlTypeCode.NegativeInteger or + XmlTypeCode.NonNegativeInteger or XmlTypeCode.PositiveInteger or + XmlTypeCode.Long or XmlTypeCode.Int or XmlTypeCode.Short or XmlTypeCode.Byte or + XmlTypeCode.UnsignedLong or XmlTypeCode.UnsignedInt or XmlTypeCode.UnsignedShort or XmlTypeCode.UnsignedByte; + + private static decimal? ParseDecimal(string? value) => + decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result) ? result : null; + + /// + /// 收集一个内容模型里的元素声明,并判断是否放行"其它命名空间"的元素(xs:any namespace="##other")。 + /// + private static (List Elements, bool AllowsForeignElements) WalkParticle(XmlSchemaParticle? particle) + { + var elements = new List(); + var allowsForeign = false; + Walk(particle); + return (elements, allowsForeign); + + void Walk(XmlSchemaParticle? p) + { + switch (p) + { + case XmlSchemaElement element: + elements.Add(element); + break; + case XmlSchemaGroupBase group: + foreach (var item in group.Items) + { + Walk(item as XmlSchemaParticle); + } + break; + case XmlSchemaAny any: + // Namespace 形如 "##other" / "##any" / "tags:s7"。 + allowsForeign |= (any.Namespace ?? string.Empty).Contains("##other", StringComparison.Ordinal) + || (any.Namespace ?? string.Empty).Contains("##any", StringComparison.Ordinal); + break; + } + } + } + + /// + /// 从根元素出发按"元素 → 类型 → 子元素"递归,收集全部节点定义(同名只收一次: + /// TagGrp 自嵌套就是同一个类型)。 + /// + private sealed class NodeCollector + { + private readonly SchemaAnnotations _annotations; + private readonly HashSet _seen = new(StringComparer.Ordinal); + + public NodeCollector(SchemaAnnotations annotations) + { + this._annotations = annotations; + } + + /// 按发现顺序排列:第 0 个是根元素。 + public List Nodes { get; } = new(); + + public void Visit(XmlSchemaElement element) + { + var name = element.QualifiedName.Name; + if (string.IsNullOrEmpty(name) || !this._seen.Add(name)) + { + return; + } + + var type = element.ElementSchemaType; + if (type is XmlSchemaComplexType complex) + { + var typeName = complex.QualifiedName.Name; + var ui = this._annotations.TypeUi(typeName) ?? this._annotations.ElementUi(string.Empty, name); + var (children, allowsForeign) = WalkParticle(complex.ContentTypeParticle); + + this.Nodes.Add(new DesignerNodeDto( + name, + ui?.Label, + DescribeAttributes(complex, typeName), + children.Select(c => c.QualifiedName.Name).ToArray(), + allowsForeign, + complex.AttributeWildcard is not null, + TextContent: null)); + + foreach (var child in children) + { + this.Visit(child); + } + + return; + } + + // 纯文本元素():没有属性、没有子元素,值就是元素文本。 + var simpleUi = this._annotations.ElementUi(string.Empty, name); + this.Nodes.Add(new DesignerNodeDto( + name, + simpleUi?.Label, + Array.Empty(), + Array.Empty(), + AllowsForeignElements: false, + AllowsCustomAttributes: false, + TextContent: simpleUi?.Help ?? simpleUi?.Placeholder ?? "文本")); + } + + private DesignerFieldDto[] DescribeAttributes(XmlSchemaComplexType complex, string typeName) + { + var index = 0; + var result = new List<(int Index, DesignerFieldDto Field)>(); + foreach (XmlSchemaAttribute attribute in complex.AttributeUses.Values) + { + if (attribute.Use == XmlSchemaUse.Prohibited) + { + continue; + } + + result.Add((index, DescribeAttribute(attribute, typeName, string.Empty, this._annotations))); + index++; + } + + // 显式 order 优先;同一档内保持声明顺序(OrderBy 稳定)。 + return result.OrderBy(r => r.Field.Order ?? int.MaxValue) + .ThenBy(r => r.Index) + .Select(r => r.Field) + .ToArray(); + } + } + + /// + /// 一次简单类型的"形状":基础类型名 + 渲染类别 + 枚举 + 数值范围 + 最小长度。 + /// 属性自己的 default 不在这里(由 直接读 )。 + /// + private sealed record SimpleShape( + string? Type, + string Kind, + IReadOnlyList? Enums, + decimal? Min, + decimal? Max, + int? MinLength); + + /// + /// xs:annotation/xs:appinfo 里的设计器提示,按"文档中的位置"索引。
+ /// 内容形状(appinfo 里的元素不带命名空间):
+ /// + /// <xs:schema …> + /// <xs:annotation><xs:appinfo> + /// <driver name="S7" displayName="Siemens S7" prefix="s7"/> + /// </xs:appinfo></xs:annotation> + /// … + /// <xs:complexType name="TagGrpType"> + /// <xs:annotation><xs:appinfo><ui label="测点组" order="2"/></xs:appinfo></xs:annotation> + /// <xs:attribute name="isEntry"> + /// <xs:annotation><xs:appinfo><ui label="作为入口" order="1" group="基础"/></xs:appinfo></xs:annotation> + /// </xs:attribute> + /// </xs:complexType> + /// </xs:schema> + /// + /// XSD 会忽略这些内容(appinfo 可以放任意 XML),所以对校验无影响。 + ///
+ private sealed class SchemaAnnotations + { + private readonly Dictionary _drivers = new(StringComparer.Ordinal); + private readonly Dictionary<(string Ns, string Name), UiInfo> _types = new(); + private readonly Dictionary<(string Ns, string Name), UiInfo> _elements = new(); + private readonly Dictionary<(string Ns, string Owner, string Name), UiInfo> _attributes = new(); + + public SchemaAnnotations(IEnumerable<(string LogicalName, string Content)> sources) + { + foreach (var (_, content) in sources) + { + this.Add(content); + } + } + + public DriverAnnotation? GetDriver(string ns) => this._drivers.GetValueOrDefault(ns); + + public UiInfo? TypeUi(string typeName) => this._types.GetValueOrDefault((string.Empty, typeName)); + + public UiInfo? ElementUi(string ns, string name) => this._elements.GetValueOrDefault((ns, name)); + + public UiInfo? AttributeUi(string ns, string ownerTypeName, string name) => + this._attributes.GetValueOrDefault((ns, ownerTypeName, name)); + + private void Add(string content) + { + XDocument document; + try + { + document = XDocument.Parse(content); + } + catch (XmlException) + { + // schema 本身有问题时 Compile 会给出正式的错误,这里只负责别让元数据先行崩掉。 + return; + } + + var root = document.Root; + if (root is null) + { + return; + } + + var ns = (string?)root.Attribute("targetNamespace") ?? string.Empty; + + var driver = ParseDriver(root); + if (driver is not null) + { + this._drivers[ns] = driver; + } + + foreach (var element in root.Descendants(Xs + "element")) + { + var name = (string?)element.Attribute("name"); + var ui = ParseUi(element); + if (name is not null && ui is not null) + { + this._elements[(ns, name)] = ui; + } + } + + foreach (var complexType in root.Descendants(Xs + "complexType")) + { + var typeName = (string?)complexType.Attribute("name"); + if (typeName is null) + { + continue; + } + + var ui = ParseUi(complexType); + if (ui is not null) + { + this._types[(ns, typeName)] = ui; + } + + foreach (var attribute in complexType.Elements(Xs + "attribute")) + { + var name = (string?)attribute.Attribute("name"); + var attributeUi = ParseUi(attribute); + if (name is not null && attributeUi is not null) + { + this._attributes[(ns, typeName, name)] = attributeUi; + } + } + } + } + + private static DriverAnnotation? ParseDriver(XElement schema) + { + var driver = AppinfoChild(schema, "driver"); + var name = (string?)driver?.Attribute("name"); + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + return new DriverAnnotation( + name!, + (string?)driver!.Attribute("displayName") ?? (string?)driver.Attribute("label"), + (string?)driver.Attribute("prefix")); + } + + private static UiInfo? ParseUi(XElement owner) + { + var ui = AppinfoChild(owner, "ui"); + if (ui is null) + { + return null; + } + + return new UiInfo( + (string?)ui.Attribute("label"), + int.TryParse((string?)ui.Attribute("order"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var order) + ? order + : null, + (string?)ui.Attribute("group"), + (string?)ui.Attribute("placeholder"), + (string?)ui.Attribute("help") ?? (string?)ui.Attribute("description")); + } + + /// + /// 取 xs:annotation/xs:appinfo 下按本地名匹配的提示元素。
+ /// 不能按"无命名空间"去找:appinfo 里的内容会继承 xs:schema 上的默认 xmlns, + /// 而多个驱动 schema(modbustcp / zlan / hjzk / com / opcuaclient)都声明了 + /// xmlns="tags:{driver}",于是 <driver>/<ui> 落进了目标命名空间。 + ///
+ private static XElement? AppinfoChild(XElement owner, string localName) => + owner.Element(Xs + "annotation")? + .Element(Xs + "appinfo")? + .Elements() + .FirstOrDefault(e => e.Name.LocalName == localName); + } + + private sealed record DriverAnnotation(string Name, string? DisplayName, string? Prefix); + + private sealed record UiInfo(string? Label, int? Order, string? Group, string? Placeholder, string? Help); +} diff --git a/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogDto.cs b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogDto.cs new file mode 100644 index 0000000..9c8bddb --- /dev/null +++ b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogDto.cs @@ -0,0 +1,136 @@ +namespace StdUnit.Tags.WebUi; + +/// +/// 设计器元数据("catalog"):把核心 schema + 所有已注册的驱动 schema 内省成一份 +/// JSON,供通道设计器 / 测点设计器渲染表单并导出 XML。
+///
+/// 它不引入第二套事实源:结构与类型全部来自运行期真正用来校验的那套 +/// (核心 加上各驱动 +/// 注册的 ),文案/顺序/分组来自 XSD 里 +/// xs:annotation/xs:appinfo 的 <ui> 与 <driver>(见各自的 .xsd 注释)。
+///
+/// 它不负责校验导出结果:导出后请直接走 —— +/// catalog 只让表单"事前就长对",最终裁判永远是 schema 本身。 +///
+/// catalog 自身的版本号(结构变更时递增,前端可据此降级)。 +/// 根元素 <Project> 的节点定义(不在 内)。 +/// 除根以外的全部节点定义(Channel / TagGrp / TagCbnt / Tag / Logicet)。 +/// 各驱动通道的专属字段;按驱动名排序。 +/// +/// 内置测点类型()。只是提示:schema 里 type 是开放字符串 +/// (插件可注册自定义类型),设计器应允许自由输入。 +/// +public sealed record DesignerCatalogDto( + int Version, + DesignerNodeDto Root, + IReadOnlyList Nodes, + IReadOnlyList Drivers, + IReadOnlyList TagTypes); + +/// +/// 一个 XML 节点(元素)的定义:属性 + 允许的子元素 + "还有 schema 看不到的扩展"的开关。
+/// 对应 XSD 里的一个 xs:complexType(Logicet 这类纯文本元素例外,见 )。 +///
+/// 元素名(本地名,核心 schema 无命名空间)。 +/// 显示名(来自 xs:appinfo/ui@label)。 +/// 该节点的属性(含默认值、枚举、范围)。 +/// 允许出现在其下的子元素名(核心 schema 内)。 +/// +/// 是否放行其它命名空间的子元素(xs:any namespace="##other")。 +/// 只有 Channel 会为 true:驱动专属字段就是在那里塞进去的。 +/// +/// +/// 是否有 xs:anyAttribute——意味着"schema 看不到的扩展属性"。Tag / TagCbnt / +/// TagGrp / Channel 都是 true(如 SimpleFiles 的 autoCreateFile、COM 的 marker)。 +/// +/// 纯文本元素的提示(Logicet 是 DLL 路径);非纯文本元素为 null。 +public sealed record DesignerNodeDto( + string Name, + string? Label, + IReadOnlyList Attributes, + IReadOnlyList Children, + bool AllowsForeignElements, + bool AllowsCustomAttributes, + string? TextContent); + +/// +/// 一个"字段":既表示属性(),也表示元素(驱动字段、嵌套组)。 +/// +/// 属性名或元素名。 +/// +/// 基础类型:System.Xml.XmlTypeCode 的名字(String / Boolean / Int16 / +/// UnsignedShort / PositiveInteger …); 时为 null。 +/// +/// +/// 前端该渲染成什么:string / bool / number / enum / group。 +/// +/// 属性是 use="required";元素是 minOccurs>0。 +/// schema 声明的默认值(default="…");没有则为 null。 +/// 枚举候选值( 时非空)。 +/// 下界(minInclusive;positiveInteger 视为 1,nonNegativeInteger 视为 0)。 +/// 上界(maxInclusive / maxExclusive)。 +/// 最小长度(xs:minLength),用于提示"非空"。 +/// 显示名(ui@label)。 +/// 排序提示(ui@order);null 表示按 schema 声明顺序。 +/// 分组(ui@group),如"连接"/"安全"。 +/// 输入框占位符(ui@placeholder)。 +/// 帮助文本(ui@help / ui@description)。 +/// 嵌套子元素( 时非空,如 OpcUa 的 ServerOpt)。 +public sealed record DesignerFieldDto( + string Name, + string? Type, + string Kind, + bool Required, + string? Default, + IReadOnlyList? EnumValues, + decimal? Min, + decimal? Max, + int? MinLength, + string? Label, + int? Order, + string? Group, + string? Placeholder, + string? Help, + IReadOnlyList? Children); + +/// +/// 一个驱动通道的定义:<Channel driver="Name"> 下允许的专属子元素。
+/// 驱动名 ↔ 驱动 schema 的绑定不由 XSD 推导(XSD 1.0 无法按属性值切换内容模型, +/// 且 HjzkIoBox/tags:hjzk、ZLanTcp/tags:zlan 这类名字也对不上), +/// 而是由驱动 schema 自己在 xs:annotation/xs:appinfo/driver 里声明。 +///
+/// +/// driver 属性的取值(与 XxxNames.DriverName 一致,如 S7);取自 +/// <driver name="…"/>。 +/// +/// 显示名(<driver displayName="…"/>),缺省同 。 +/// 驱动字段所属的 XML 命名空间(tags:s7),导出时据此声明 xmlns。 +/// 导出时建议的前缀(<driver prefix="s7"/>,缺省取命名空间后缀)。 +/// 该驱动的专属子元素;按 order 再按声明顺序。 +public sealed record DesignerDriverDto( + string Name, + string? DisplayName, + string Namespace, + string Prefix, + IReadOnlyList Fields); + +/// +/// 的取值(前端渲染依据)。 +/// +public static class DesignerFieldKind +{ + /// 自由文本。 + public const string String = "string"; + + /// 布尔(xs:boolean):true / false。 + public const string Bool = "bool"; + + /// 数字(整数或浮点)。 + public const string Number = "number"; + + /// 枚举:从 里选。 + public const string Enum = "enum"; + + /// 有子字段的复合元素(如 OpcUa 的 ServerOpt)。 + public const string Group = "group"; +} diff --git a/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogEndpoint.cs b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogEndpoint.cs new file mode 100644 index 0000000..bcfd730 --- /dev/null +++ b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogEndpoint.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace StdUnit.Tags.WebUi.Internal; + +/// +/// 设计器元数据端点(GET {prefix}/catalog):把核心 + 驱动 schema 内省成一份 JSON, +/// 供通道/测点设计器渲染表单与导出 XML。
+/// 与其它端点的区别:它不针对某一路观测流(schema 与项目无关),所以既没有不具名/具名之分, +/// 也不需要路由里的 {keyed}。 +///
+internal static class DesignerCatalogEndpoint +{ + public static async Task HandleAsync(HttpContext context) + { + var catalog = context.RequestServices.GetRequiredService().Catalog; + + // 自己序列化:不走 MVC / 无参序列化,AOT/裁剪下没有反射路径。 + var bytes = JsonSerializer.SerializeToUtf8Bytes(catalog, TagsStreamJsonContext.Default.DesignerCatalogDto); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json; charset=utf-8"; + context.Response.ContentLength = bytes.Length; + await context.Response.Body.WriteAsync(bytes, context.RequestAborted); + } +} diff --git a/src/StdUnit.Tags.WebUi/StdUnit.Tags.WebUi.csproj b/src/StdUnit.Tags.WebUi/StdUnit.Tags.WebUi.csproj index 41b1e6c..fdc9a2f 100644 --- a/src/StdUnit.Tags.WebUi/StdUnit.Tags.WebUi.csproj +++ b/src/StdUnit.Tags.WebUi/StdUnit.Tags.WebUi.csproj @@ -1,7 +1,7 @@ - 测点项目的只读观测流:结构快照(HTTP)+ 按入口、按轮次的值推送(SSE) + 测点项目的 Web 界面(自托管、AOT 友好):监控页(结构树 + SSE 值流)与设计器(通道/测点,导出项目 XML) net8.0 true @@ -10,9 +10,10 @@ - + diff --git a/src/StdUnit.Tags.WebUi/Ui/DesignerUi.cs b/src/StdUnit.Tags.WebUi/Ui/DesignerUi.cs new file mode 100644 index 0000000..90af74d --- /dev/null +++ b/src/StdUnit.Tags.WebUi/Ui/DesignerUi.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; + +namespace StdUnit.Tags.WebUi.Internal; + +/// +/// 内置设计器页:Ui/designer.html 作为嵌入资源,注入 <base href> 与 catalog 的相对路径后返回。
+/// 页面只做前端组装(通道 + 测点树 + Logicet),字段从哪里来/长什么样完全由 +/// 给的元数据决定——所以"新增一个驱动"不需要改这个页面。 +///
+internal static class DesignerUi +{ + private const string ResourceName = "StdUnit.Tags.WebUi.Ui.designer.html"; + private const string CatalogPlaceholder = "%%CATALOG_URL%%"; + private const string MonitorPlaceholder = "%%MONITOR_URL%%"; + + /// 嵌入资源原文(含全部占位符),只读一次。 + private static readonly string Template = WebPage.LoadTemplate(ResourceName); + + /// + /// 渲染设计器页:只填相对路径(与 同源),前缀由请求决定。 + /// + public static string BuildPage(string catalogPath, string monitorPath) => + Template + .Replace(CatalogPlaceholder, catalogPath.TrimStart('/'), StringComparison.Ordinal) + .Replace(MonitorPlaceholder, monitorPath.TrimStart('/'), StringComparison.Ordinal); + + /// 把渲染好的页面写进响应(基路径由 按请求路径填)。 + public static Task WriteAsync(HttpContext context, string page) => WebPage.WriteAsync(context, page); +} diff --git a/src/StdUnit.Tags.WebUi/Ui/TagsStreamUi.cs b/src/StdUnit.Tags.WebUi/Ui/TagsStreamUi.cs index 8a32a4f..eadd675 100644 --- a/src/StdUnit.Tags.WebUi/Ui/TagsStreamUi.cs +++ b/src/StdUnit.Tags.WebUi/Ui/TagsStreamUi.cs @@ -1,43 +1,44 @@ using System.Text; -using System.Text.Encodings.Web; using Microsoft.AspNetCore.Http; namespace StdUnit.Tags.WebUi.Internal; /// /// 内置监控页:把 Ui/index.html 作为嵌入资源取出,注入 <base href> 后整体返回。
-/// 页面里的相对 URL(tree / stream / value / rescan)都相对这个基路径解析, +/// 页面里的相对 URL(tree / stream / value / rescan / designer)都相对这个基路径解析, /// 所以端点挂在什么前缀下、被套进几层路由组,前端都不用改。
/// 不用静态文件中间件、不依赖宿主配置,也不引入任何前端工程。 ///
internal static class TagsStreamUi { private const string ResourceName = "StdUnit.Tags.WebUi.Ui.index.html"; - private const string BasePlaceholder = "%%BASE%%"; private const string TreePlaceholder = "%%TREE_URL%%"; private const string StreamPlaceholder = "%%STREAM_URL%%"; private const string WritePlaceholder = "%%WRITE_URL%%"; private const string RescanPlaceholder = "%%RESCAN_URL%%"; + private const string DesignerPlaceholder = "%%DESIGNER_URL%%"; /// 嵌入资源原文(含全部占位符),只读一次。 - private static readonly string Template = LoadTemplate(); + private static readonly string Template = WebPage.LoadTemplate(ResourceName); /// /// 渲染监控页:只填相对路径(来自 的 *Path), - /// 不拼前缀——前缀/挂载点由请求决定,见 。
+ /// 不拼前缀——前缀/挂载点由请求决定,见 。
/// 页面里 JS 再用 new URL(相对路径, document.baseURI) 算出真正的地址。 ///
public static string BuildPage( string treePath, string streamPath, string writeValuePath, - string rescanPath) + string rescanPath, + string designerPath) { return Template .Replace(TreePlaceholder, ToRelative(treePath), StringComparison.Ordinal) .Replace(StreamPlaceholder, ToRelative(streamPath), StringComparison.Ordinal) .Replace(WritePlaceholder, ToRelative(writeValuePath), StringComparison.Ordinal) - .Replace(RescanPlaceholder, ToRelative(rescanPath), StringComparison.Ordinal); + .Replace(RescanPlaceholder, ToRelative(rescanPath), StringComparison.Ordinal) + .Replace(DesignerPlaceholder, ToRelative(designerPath), StringComparison.Ordinal); } /// @@ -46,48 +47,6 @@ public static string BuildPage( /// private static string ToRelative(string path) => path.TrimStart('/'); - /// - /// 把渲染好的页面写进响应,并按本次请求的路径填 <base href>。
- /// 基路径 = + 去掉最后一段 - /// (/stdunit.tags/ui → /stdunit.tags/),而不是映射时传进来的前缀——只有请求路径才等于真正生效的基路径 - /// (嵌套路由组 / UsePathBase 都在里面)。 - ///
- public static async Task WriteAsync(HttpContext context, string page) - { - var html = page.Replace(BasePlaceholder, GetBaseHref(context), StringComparison.Ordinal); - var bytes = Encoding.UTF8.GetBytes(html); - - context.Response.StatusCode = StatusCodes.Status200OK; - context.Response.ContentType = "text/html; charset=utf-8"; - context.Response.ContentLength = bytes.Length; - await context.Response.Body.WriteAsync(bytes, context.RequestAborted); - } - - /// - /// 页面自己的 URL 去掉最后一段就是同级端点所在的目录:/stdunit.tags/ui → /stdunit.tags/。
- /// 末尾多余的 / 先去掉,免得把 /ui/ 当成目录。 - ///
- private static string GetBaseHref(HttpContext context) - { - var path = context.Request.PathBase.Add(context.Request.Path).Value ?? "/"; - path = path.TrimEnd('/'); - - var slash = path.LastIndexOf('/'); - var basePath = slash < 0 ? "/" : path[..(slash + 1)]; - - // 合法 URL 里不会出现需要转义的字符,但它是属性值:万一路径段里解码出 '&' 之类也不至于破坏页面。 - return HtmlEncoder.Default.Encode(basePath); - } - - private static string LoadTemplate() - { - using var stream = typeof(TagsStreamUi).Assembly.GetManifestResourceStream(ResourceName); - if (stream is null) - { - throw new InvalidOperationException($"嵌入资源缺失:{ResourceName}"); - } - - using var reader = new StreamReader(stream, Encoding.UTF8); - return reader.ReadToEnd(); - } + /// 把渲染好的页面写进响应(基路径由 按请求路径填)。 + public static Task WriteAsync(HttpContext context, string page) => WebPage.WriteAsync(context, page); } diff --git a/src/StdUnit.Tags.WebUi/Ui/WebPage.cs b/src/StdUnit.Tags.WebUi/Ui/WebPage.cs new file mode 100644 index 0000000..2ff1907 --- /dev/null +++ b/src/StdUnit.Tags.WebUi/Ui/WebPage.cs @@ -0,0 +1,59 @@ +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Http; + +namespace StdUnit.Tags.WebUi.Internal; + +/// +/// 内置页面的公共部分(监控页与设计器共用):模板里的 %%BASE%% 按本次请求路径填 +/// <base href>,页面里的相对 URL 再由浏览器按它解析。
+/// 这样端点挂在什么前缀下、被套进几层路由组、有没有 UsePathBase,前端都不用改。 +///
+internal static class WebPage +{ + /// 页面模板里的基路径占位符。 + public const string BasePlaceholder = "%%BASE%%"; + + /// + /// 把渲染好的页面写进响应,并按本次请求的路径填 <base href>。
+ /// 基路径 = + 去掉最后一段 + /// (/stdunit.tags/designer → /stdunit.tags/),而不是映射时传进来的前缀—— + /// 只有请求路径才等于真正生效的基路径(嵌套路由组 / UsePathBase 都在里面)。 + ///
+ public static async Task WriteAsync(HttpContext context, string page) + { + var html = page.Replace(BasePlaceholder, GetBaseHref(context), StringComparison.Ordinal); + var bytes = Encoding.UTF8.GetBytes(html); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength = bytes.Length; + await context.Response.Body.WriteAsync(bytes, context.RequestAborted); + } + + /// + /// 页面自己的 URL 去掉最后一段就是同级端点所在的目录:/stdunit.tags/ui → /stdunit.tags/。
+ /// 末尾多余的 / 先去掉,免得把 /ui/ 当成目录。 + ///
+ public static string GetBaseHref(HttpContext context) + { + var path = context.Request.PathBase.Add(context.Request.Path).Value ?? "/"; + path = path.TrimEnd('/'); + + var slash = path.LastIndexOf('/'); + var basePath = slash < 0 ? "/" : path[..(slash + 1)]; + + // 合法 URL 里不会出现需要转义的字符,但它是属性值:万一路径段里解码出 '&' 之类也不至于破坏页面。 + return HtmlEncoder.Default.Encode(basePath); + } + + /// 取出程序集里嵌入的页面模板(只读一次)。 + public static string LoadTemplate(string resourceName) + { + using var stream = typeof(WebPage).Assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"嵌入资源缺失:{resourceName}"); + + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } +} diff --git a/src/StdUnit.Tags.WebUi/Ui/designer.html b/src/StdUnit.Tags.WebUi/Ui/designer.html new file mode 100644 index 0000000..73fb93e --- /dev/null +++ b/src/StdUnit.Tags.WebUi/Ui/designer.html @@ -0,0 +1,999 @@ + + + + + + + +测点项目设计器 · StdUnit.Tags + + + +
+

测点项目设计器

+ + 监控页 → +
+

加载中…

+ + + diff --git a/src/StdUnit.Tags.WebUi/Ui/index.html b/src/StdUnit.Tags.WebUi/Ui/index.html index fe3e049..dcafeba 100644 --- a/src/StdUnit.Tags.WebUi/Ui/index.html +++ b/src/StdUnit.Tags.WebUi/Ui/index.html @@ -7,35 +7,80 @@ 测点监控 · StdUnit.Tags @@ -44,6 +89,7 @@

测点监控

连接中… + 设计器 →

加载中…

@@ -57,6 +103,7 @@

测点监控

const STREAM_URL = new URL('%%STREAM_URL%%', document.baseURI).href; const WRITE_URL = new URL('%%WRITE_URL%%', document.baseURI).href; const RESCAN_URL = new URL('%%RESCAN_URL%%', document.baseURI).href; + const DESIGNER_URL = new URL('%%DESIGNER_URL%%', document.baseURI).href; const statusEl = document.getElementById('status'); const metaEl = document.getElementById('meta'); @@ -64,6 +111,9 @@

测点监控

const treeEl = document.getElementById('tree'); const filterEl = document.getElementById('filter'); + // 设计器只有不具名的一份(与 schema 一样全局),具名流的页面里这个链接指向共享的那一页。 + document.getElementById('designer').href = DESIGNER_URL; + const BIT_KINDS = new Set(['BIT', 'DI', 'DO']); const EDITABLE_KINDS = new Set([ 'BYTE', 'INT16', 'UINT16', 'INT32', 'UINT32', 'INT64', 'UINT64', diff --git a/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs b/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs index 2c32e05..25c062a 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs @@ -15,10 +15,13 @@ public static class TagsStreamExtensions_Endpoints /// - GET {prefix}/tree:结构树(一次性 JSON,按 epoch 缓存);
/// - GET {prefix}/stream?entries=a,b:值流(text/event-stream),entries 省略表示全部入口;
/// - GET {prefix}/ui:内置的极简监控页(单文件 HTML + 内嵌 JS,无需 npm 工程);
+ /// - GET {prefix}/designer:内置的通道/测点设计器(同一套单文件页面,导出项目 XML);
+ /// - GET {prefix}/catalog:设计器元数据(核心 + 驱动 schema 的内省结果);
/// - POST {prefix}/value:改测点值;
/// - POST {prefix}/rescan:标记未扫描。
/// 具名流(AddKeyedTagsStream("dev")):同一套形状,键作为一段路径, - /// 即 {prefix}/{key}/tree、{prefix}/{key}/stream ……;未知 key 返回 404。
+ /// 即 {prefix}/{key}/tree、{prefix}/{key}/stream ……;未知 key 返回 404。 + /// (designer / catalog 与具体流无关,只有不具名的一份。)
///
/// 外部只可改前缀:各端点的相对路径(/tree、/stream …)是线协议的一部分, /// 监控页注入的也是这些相对路径,真正的 URL 由页面里的 <base href> 解析; @@ -32,6 +35,10 @@ public static class TagsStreamExtensions_Endpoints /// app.MapTagsStream("/internal/tags").RequireAuthorization("ops"); // 自定义前缀 + 命名策略 /// /// 只想让读端点匿名时,可用反向代理挡住 {prefix}/value 与 {prefix}/rescan。 + ///
+ /// 设计器元数据:GET {prefix}/catalog 把核心 + 驱动 schema 内省成 JSON, + /// GET {prefix}/designer 就是用它渲染的设计器页面。两者都与具体观测流无关, + /// 只有这一份、没有具名版本;只读,不涉及项目数据。 ///
/// 端点构建器 /// @@ -63,11 +70,17 @@ public static RouteGroupBuilder MapTagsStream(this IEndpointRouteBuilder endpoin EndpointConstants.TreePath, EndpointConstants.StreamPath, EndpointConstants.WriteValuePath, - EndpointConstants.RescanPath + EndpointConstants.RescanPath, + EndpointConstants.DesignerPath ); group.MapGet(EndpointConstants.UiPath, context => TagsStreamUi.WriteAsync(context, page)); group.MapGet(EndpointConstants.Keyed(EndpointConstants.UiPath), context => TagsStreamUi.WriteAsync(context, page)); + // 设计器:与具体流无关(schema 是全局的),所以既没有具名版本,也不需要 {keyed}。 + group.MapGet(EndpointConstants.CatalogPath, DesignerCatalogEndpoint.HandleAsync); + var designerPage = DesignerUi.BuildPage(EndpointConstants.CatalogPath, EndpointConstants.UiPath); + group.MapGet(EndpointConstants.DesignerPath, context => DesignerUi.WriteAsync(context, designerPage)); + return group; } } diff --git a/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs b/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs index b443bfa..85fea89 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs @@ -2,12 +2,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using StdUnit.Tags.WebUi.Internal; namespace StdUnit.Tags.WebUi; /// -/// 只读观测流的 DI 注册入口: / 。
+/// 观测流(外加设计器元数据)的 DI 注册入口: / 。
/// 端点映射不在这里,见 里的 MapTagsStream。 ///
public static class TagsStreamExtensions_ServiceCollection @@ -64,7 +65,7 @@ public static IServiceCollection AddTagsStream(this IServiceCollection services, /// 服务集合 /// /// 控制器的注册键,同时作为路由里的 {keyed} 段:允许字母/数字/./-/_, - /// 且不能是 ui/tree/stream/value/rescan(会与不具名流的端点撞车)。 + /// 且不能是 ui/designer/tree/stream/value/rescan/catalog(会与不具名流的端点撞车)。 /// /// 这一路流的配置回调(每次调用各一份,互不影响) /// @@ -105,6 +106,10 @@ private static TagsStreamOptions AddTagsStreamCommon(IServiceCollection services // 每路流都有自己的一份 options;这里只登记第一条(服务面上仍可解析到它)。 services.TryAddSingleton(options); services.TryAddSingleton(); + + // 设计器元数据({prefix}/catalog)与具体流无关:核心 schema + 各驱动注册的 + // ITagsProjectSchemaProvider,解析时一次性内省并缓存。 + services.TryAddSingleton(); return options; } @@ -140,5 +145,5 @@ private static void ValidateStreamKey(IServiceCollection services, string key) } private static readonly HashSet ReservedStreamKeys = - new(new[] { "ui", "tree", "stream", "value", "rescan" }, StringComparer.OrdinalIgnoreCase); + new(new[] { "ui", "designer", "tree", "stream", "value", "rescan", "catalog" }, StringComparer.OrdinalIgnoreCase); } diff --git a/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs b/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs index 6f39bc7..eb67efc 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs @@ -38,7 +38,7 @@ public static TagsProjectServiceBuilder AddTagsStream(this TagsProjectServiceBui /// /// /// 控制器的注册键,同时作为路由里的 {keyed} 段:允许字母/数字/./-/_, - /// 且不能是 ui/tree/stream/value/rescan。 + /// 且不能是 ui/designer/tree/stream/value/rescan/catalog。 /// /// 这一路流的配置回调(每次调用各一份,互不影响) /// 同一个 builder,便于链式配置 diff --git a/src/StdUnit.Tags.ZLan/Schemas/zlan.xsd b/src/StdUnit.Tags.ZLan/Schemas/zlan.xsd index 4465e4f..68601cb 100644 --- a/src/StdUnit.Tags.ZLan/Schemas/zlan.xsd +++ b/src/StdUnit.Tags.ZLan/Schemas/zlan.xsd @@ -18,17 +18,32 @@ targetNamespace="tags:zlan" elementFormDefault="qualified"> + + + + + - + + + - - - - + + + + + + + + + + + + diff --git a/tests/AotSmokeTest.WebUi/Program.cs b/tests/AotSmokeTest.WebUi/Program.cs index cdc4abd..bab1afa 100644 --- a/tests/AotSmokeTest.WebUi/Program.cs +++ b/tests/AotSmokeTest.WebUi/Program.cs @@ -39,8 +39,8 @@ builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddTagsProjectServices(b => { - b.AddSimpleFilesChannel(); - b.AddSimpleFilesDirectTagBuilder(); + // Support = Channel + DirectTagBuilder + SchemaProvider:设计器元数据(/catalog)需要后者。 + b.AddSimpleFilesSupport(); }); builder.Services.AddTagsStream(o => { @@ -101,6 +101,40 @@ await WaitUntilAsync( Console.WriteLine("[+] 监控页可访问且基路径已注入"); } + // ①d 设计器:catalog(运行期读 XSD + 源生成 JSON)与页面(嵌入资源 + 占位符注入)。 + // 这两条在 AOT 下最容易出问题:XmlSchema 的读取/编译 + 元数据驱动的 JSON 序列化。 + using (var catalog = await http.GetAsync($"{baseUrl}/stdunit.tags/catalog", cts.Token)) + { + Require(catalog.IsSuccessStatusCode, $"设计器元数据应返回 2xx,实际 {(int)catalog.StatusCode}"); + Require( + catalog.Content.Headers.ContentType?.MediaType == "application/json", + "设计器元数据 Content-Type 应为 application/json"); + + var json = await catalog.Content.ReadAsStringAsync(cts.Token); + Require(json.Contains("\"root\":{", StringComparison.Ordinal), "元数据应带根节点定义"); + Require(json.Contains("\"name\":\"Channel\"", StringComparison.Ordinal), "元数据应包含 Channel 节点"); + Require(json.Contains("\"name\":\"TagGrp\"", StringComparison.Ordinal), "元数据应包含 TagGrp 节点"); + // 驱动来自 schema 里的 (不是命名空间约定):SimpleFiles 必须出现。 + Require(json.Contains("\"name\":\"SimpleFiles\"", StringComparison.Ordinal), "元数据应绑定 SimpleFiles 驱动"); + Require(json.Contains("\"prefix\":\"sf\"", StringComparison.Ordinal), "驱动前缀应来自 schema 声明"); + Require(json.Contains("\"name\":\"BaseDir\"", StringComparison.Ordinal), "驱动字段应来自驱动 schema"); + Console.WriteLine("[+] 设计器元数据(runtime XSD 内省)正常"); + } + + using (var designer = await http.GetAsync($"{baseUrl}/stdunit.tags/designer", cts.Token)) + { + Require(designer.IsSuccessStatusCode, $"设计器页应返回 2xx,实际 {(int)designer.StatusCode}"); + Require( + designer.Content.Headers.ContentType?.MediaType == "text/html", + "设计器页 Content-Type 应为 text/html"); + + var html = await designer.Content.ReadAsStringAsync(cts.Token); + Require(html.Contains("", StringComparison.Ordinal), "设计器页应按请求路径注入 "); + Require(html.Contains("new URL('catalog', document.baseURI)", StringComparison.Ordinal), "设计器页应注入 catalog 的相对路径"); + Require(!html.Contains("%%", StringComparison.Ordinal), "设计器页不应残留未替换的占位符"); + Console.WriteLine("[+] 设计器页可访问"); + } + // ①c 多流:具名流的页面基路径落在它自己的目录下;未知 key 返回 404。 using (var ui = await http.GetAsync($"{baseUrl}/stdunit.tags/dev/ui", cts.Token)) { diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj b/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj index 5e02246..18fb901 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj @@ -15,7 +15,7 @@ 另外:不要在 StdUnit.Tags.Tests 里用 #if NETFRAMEWORK 排除整个文件来回避 net8-only 依赖 —— 那是编译期开关,解决不了"引用 net8-only 项目"的问题。 - 现状:承载 StdUnit.Tags.WebUi(ASP.NET Core / SSE 只读观测流)的测试。 + 现状:承载 StdUnit.Tags.WebUi(ASP.NET Core:监控页 + 设计器 + SSE 观测流)的测试。 --> diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/DesignerCatalogTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/DesignerCatalogTests.cs new file mode 100644 index 0000000..8d51073 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/DesignerCatalogTests.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; + +/// +/// :把核心 schema + 驱动 schema 内省成设计器元数据。
+/// 验证的是"XSD 里写了什么 → catalog 里就该是什么":结构/类型/默认值/枚举/范围来自编译后的 +/// XmlSchemaSet,驱动名绑定与文案来自 xs:appinfo。 +///
+public class DesignerCatalogTests +{ + private sealed class FakeSchemaProvider : ITagsProjectSchemaProvider + { + private readonly (string LogicalName, string Content)[] _schemas; + + public FakeSchemaProvider(params (string LogicalName, string Content)[] schemas) => this._schemas = schemas; + + public IEnumerable<(string LogicalName, string Content)> GetSchemaContents() => this._schemas; + } + + private static DesignerCatalogDto Build(params (string LogicalName, string Content)[] schemas) + { + var services = new ServiceCollection(); + services.AddLogging(); + if (schemas.Length > 0) + { + services.AddSingleton(new FakeSchemaProvider(schemas)); + } + + using var provider = services.BuildServiceProvider(); + return new DesignerCatalog(provider.GetServices()).Catalog; + } + + /// 一个最小但完整的驱动 schema:驱动声明 + 枚举 + 范围 + 嵌套组 + appinfo 文案。 + private const string FakeDriverSchema = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + /// 有 targetNamespace 但没声明 <driver> 的扩展 schema(不该被当成通道驱动)。 + private const string SchemaWithoutDriver = """ + + + + + """; + + private static DesignerNodeDto Node(DesignerCatalogDto catalog, string name) => + catalog.Nodes.Single(n => n.Name == name); + + private static DesignerFieldDto Field(IEnumerable fields, string name) => + fields.Single(f => f.Name == name); + + [Fact] + public void Catalog_DescribesTheCoreSchema() + { + var catalog = Build(); + + Assert.Equal(DesignerCatalog.CurrentVersion, catalog.Version); + Assert.Equal("Project", catalog.Root.Name); + Assert.Equal(new[] { "Channel", "TagGrp", "Logicet" }, catalog.Root.Children); + Assert.True(catalog.Root.AllowsCustomAttributes); + Assert.False(catalog.Root.AllowsForeignElements); + + // 根在 Nodes 里不重复出现;TagGrp 自嵌套只收一次。 + Assert.DoesNotContain(catalog.Nodes, n => n.Name == "Project"); + Assert.Equal( + new[] { "Channel", "TagGrp", "TagCbnt", "Tag", "Logicet" }, + catalog.Nodes.Select(n => n.Name).ToArray()); + } + + [Fact] + public void Catalog_ExposesAttributesWithDefaultsEnumsAndRanges() + { + var catalog = Build(); + var tag = Node(catalog, "Tag"); + + var name = Field(tag.Attributes, "name"); + Assert.True(name.Required); + Assert.Equal(DesignerFieldKind.String, name.Kind); + Assert.Equal(1, name.MinLength); // NameType 的 xs:minLength + Assert.Equal("名称", name.Label); + + var endian = Field(tag.Attributes, "endian"); + Assert.Equal(DesignerFieldKind.Enum, endian.Kind); + Assert.Equal(new[] { "LittleEndian", "BigEndian" }, endian.EnumValues!); + Assert.Equal("LittleEndian", endian.Default); + + var access = Field(tag.Attributes, "access"); + Assert.Equal(new[] { "RW", "RO", "R1W", "WO" }, access.EnumValues!); + Assert.Null(access.Default); + + var tagSize = Field(tag.Attributes, "tagSize"); + Assert.Equal(DesignerFieldKind.Number, tagSize.Kind); + Assert.Equal(1, tagSize.Min); // xs:positiveInteger + } + + [Fact] + public void Catalog_RespectsUiOrder() + { + var catalog = Build(); + var grp = Node(catalog, "TagGrp"); + + // 声明顺序是 name/channel/isEntry/isEnabled/scanInterval/access, + // appinfo 的 order 必须压过它。 + Assert.Equal( + new[] { "name", "isEntry", "isEnabled", "scanInterval", "channel", "access" }, + grp.Attributes.Select(a => a.Name).ToArray()); + } + + [Fact] + public void Catalog_MarksOpenContentModels() + { + var catalog = Build(); + + var channel = Node(catalog, "Channel"); + Assert.True(channel.AllowsForeignElements); // xs:any namespace="##other" + Assert.True(channel.AllowsCustomAttributes); + Assert.Empty(channel.Children); // 驱动字段只能在 #other 命名空间 + + var grp = Node(catalog, "TagGrp"); + Assert.Equal(new[] { "TagGrp", "TagCbnt", "Tag" }, grp.Children); + Assert.False(grp.AllowsForeignElements); + Assert.True(grp.AllowsCustomAttributes); // 驱动/插件自定义属性 + + Assert.Empty(Node(catalog, "Tag").Children); + Assert.Equal("Tag", Assert.Single(Node(catalog, "TagCbnt").Children)); + } + + [Fact] + public void Catalog_DescribesTextOnlyElements() + { + var catalog = Build(); + var logicet = Node(catalog, "Logicet"); + + Assert.Empty(logicet.Attributes); + Assert.Empty(logicet.Children); + Assert.False(logicet.AllowsCustomAttributes); + Assert.Equal("相对项目根目录或绝对路径", logicet.TextContent); + } + + [Fact] + public void Catalog_ExposesBuiltinTagTypesAsHints() + { + var catalog = Build(); + + Assert.Contains(BuiltinTagKinds.INT16, catalog.TagTypes); + Assert.Contains(BuiltinTagKinds.STR, catalog.TagTypes); + // schema 里 type 是开放字符串:这里只是候选,不是闭集(故不含 Unknown)。 + Assert.DoesNotContain(BuiltinTagKinds.Unknown, catalog.TagTypes); + } + + [Fact] + public void Catalog_WithoutAnyProvider_HasNoDrivers() + { + var catalog = Build(); + + Assert.Empty(catalog.Drivers); + } + + [Fact] + public void Catalog_BindsDriverFromSchemaAppInfo() + { + var catalog = Build(("fake.xsd", FakeDriverSchema)); + + var driver = Assert.Single(catalog.Drivers); + Assert.Equal("Fake", driver.Name); + Assert.Equal("假驱动", driver.DisplayName); + Assert.Equal("tags:fake", driver.Namespace); + Assert.Equal("fk", driver.Prefix); + } + + [Fact] + public void Catalog_DriverFieldsCarryTypeEnumAndRange() + { + var catalog = Build(("fake.xsd", FakeDriverSchema)); + var fields = catalog.Drivers.Single().Fields; + + // appinfo 的 order 生效(Host=1 / Mode=2 / Retries=3 / Nested=4)。 + Assert.Equal(new[] { "Host", "Mode", "Retries", "Nested" }, fields.Select(f => f.Name).ToArray()); + + var host = Field(fields, "Host"); + Assert.Equal(DesignerFieldKind.String, host.Kind); + Assert.Equal("String", host.Type); + Assert.Equal("主机", host.Label); + Assert.Equal("localhost", host.Placeholder); + Assert.False(host.Required); // 通道字段都可省略 + + var mode = Field(fields, "Mode"); + Assert.Equal(DesignerFieldKind.Enum, mode.Kind); + Assert.Equal(new[] { "Fast", "Slow" }, mode.EnumValues!); + + var retries = Field(fields, "Retries"); + Assert.Equal(DesignerFieldKind.Number, retries.Kind); + Assert.Equal(2, retries.Min); // 命名简单类型上的 minInclusive + } + + [Fact] + public void Catalog_NestedDriverElementBecomesAGroup() + { + var catalog = Build(("fake.xsd", FakeDriverSchema)); + + var nested = Field(catalog.Drivers.Single().Fields, "Nested"); + Assert.Equal(DesignerFieldKind.Group, nested.Kind); + Assert.Null(nested.Type); + + var inner = Assert.Single(nested.Children!); + Assert.Equal("Inner", inner.Name); + Assert.True(inner.Required); // minOccurs="1" + Assert.Equal("内层", inner.Label); + } + + [Fact] + public void Catalog_IgnoresSchemasWithoutDriverDeclaration() + { + var catalog = Build( + ("fake.xsd", FakeDriverSchema), + ("other.xsd", SchemaWithoutDriver)); + + // 只有声明了 的 schema 才算通道驱动;其它扩展 schema 仍可参与校验。 + Assert.Equal(new[] { "Fake" }, catalog.Drivers.Select(d => d.Name).ToArray()); + } + + [Fact] + public void Catalog_DerivesPrefixFromNamespaceWhenNotDeclared() + { + var schema = FakeDriverSchema.Replace(" prefix=\"fk\"", string.Empty, StringComparison.Ordinal); + + var driver = Assert.Single(Build(("fake.xsd", schema)).Drivers); + Assert.Equal("fake", driver.Prefix); // tags:fake → fake + } + + [Fact] + public void Catalog_IsRegisteredByAddTagsStreamAndServed() + { + // 服务面上能直接解析(端点在 MapTagsStream 里用它)。 + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTagsProjectServices(b => { }); + services.AddTagsStream(); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true }); + var catalog = provider.GetRequiredService().Catalog; + + Assert.Equal("Project", catalog.Root.Name); + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs index 5cf2c1c..2cfb3e8 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs @@ -46,7 +46,9 @@ public async Task MapTagsStream_PutsEveryEndpointUnderThePrefix(string prefix) var normalized = prefix.TrimEnd('/'); var expected = new[] { - // 不具名流 + // 不具名流(含与流无关的 catalog / designer) + $"{normalized}/catalog", + $"{normalized}/designer", $"{normalized}/rescan", $"{normalized}/stream", $"{normalized}/tree", @@ -113,6 +115,44 @@ public async Task UiPage_BaseHrefComesFromTheRequestPath(string path, string pat } } + [Fact] + public async Task DesignerPage_IsServedWithRelativeCatalogAndBase() + { + var (app, builder) = CreateApp(); + try + { + builder.MapTagsStream("/monitor"); + + var designerPage = Assert.Single( + builder.DataSources.SelectMany(ds => ds.Endpoints).OfType(), + e => e.RoutePattern.RawText == "/monitor/designer"); + + var context = new DefaultHttpContext { RequestServices = app.Services }; + context.Request.Path = "/monitor/designer"; + context.Response.Body = new MemoryStream(); + await designerPage.RequestDelegate!(context); + + var html = Encoding.UTF8.GetString(((MemoryStream)context.Response.Body).ToArray()); + + Assert.Equal("text/html; charset=utf-8", context.Response.ContentType); + Assert.Contains("", html); + // catalog / 监控页都是相对路径(模板占位符必须全部替换干净)。 + Assert.Contains("new URL('catalog', document.baseURI).href", html); + Assert.Contains("new URL('ui', document.baseURI).href", html); + // 四个页签(通道 / 测点 / 导入 / 导出)与导入解析入口都在嵌入资源里。 + Assert.Contains("data-tab=\"channels\"", html); + Assert.Contains("data-tab=\"tags\"", html); + Assert.Contains("data-tab=\"import\"", html); + Assert.Contains("data-tab=\"export\"", html); + Assert.Contains("parseProjectXml", html); + Assert.DoesNotContain("%%", html); + } + finally + { + await app.DisposeAsync(); + } + } + [Fact] public async Task MapTagsStream_CanBeMountedOnAnotherGroup() { @@ -124,6 +164,8 @@ public async Task MapTagsStream_CanBeMountedOnAnotherGroup() var expected = new[] { + "/monitor/stdunit.tags/catalog", + "/monitor/stdunit.tags/designer", "/monitor/stdunit.tags/rescan", "/monitor/stdunit.tags/stream", "/monitor/stdunit.tags/tree", @@ -156,7 +198,7 @@ public async Task MapTagsStream_ReturnsAGroupThatCanRequireAuthorization() builder.MapTagsStream().RequireAuthorization(); var endpoints = builder.DataSources.SelectMany(ds => ds.Endpoints).OfType().ToArray(); - Assert.Equal(10, endpoints.Length); + Assert.Equal(12, endpoints.Length); Assert.All(endpoints, e => Assert.Contains(e.Metadata, m => m is IAuthorizeData)); // 不传 prefix 时必须落在默认前缀下(= EndpointConstants.DefaultPrefix)。 @@ -180,7 +222,7 @@ public async Task MapTagsStream_GroupCanRequireANamedPolicy() builder.MapTagsStream("/internal/tags").RequireAuthorization("ops"); var endpoints = builder.DataSources.SelectMany(ds => ds.Endpoints).OfType().ToArray(); - Assert.Equal(10, endpoints.Length); + Assert.Equal(12, endpoints.Length); Assert.All(endpoints, e => Assert.Contains( e.Metadata.OfType(), m => m.Policy == "ops")); @@ -191,3 +233,4 @@ public async Task MapTagsStream_GroupCanRequireANamedPolicy() } } } + diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs index 97aef3e..d249f48 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs @@ -189,6 +189,17 @@ public void AddKeyedTagsStream_RejectsNullAndEmptyKey() Assert.Throws(() => services.AddKeyedTagsStream(string.Empty)); } + [Fact] + public void AddKeyedTagsStream_RejectsTheReservedCatalogKey() + { + var services = CreateServices(); + services.AddKeyedSingleton("catalog", new FakeProjectCtrl()); + + // /catalog 是设计器元数据端点(与具体流无关),不能被某一路具名流占用。 + var ex = Assert.Throws(() => services.AddKeyedTagsStream("catalog")); + Assert.Contains("catalog", ex.Message); + } + [Fact] public void AddTagsStream_WhenTheKeyedControllerIsMissing_SaysWhichKey() { diff --git a/tests/StdUnit.Tags.Tests/Core/Schema/DriverSchemaAnnotationsTests.cs b/tests/StdUnit.Tags.Tests/Core/Schema/DriverSchemaAnnotationsTests.cs new file mode 100644 index 0000000..d47ccd1 --- /dev/null +++ b/tests/StdUnit.Tags.Tests/Core/Schema/DriverSchemaAnnotationsTests.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; +using Xunit; + +namespace StdUnit.Tags.Tests.Core.Schema; + +/// +/// 各驱动 schema 里的设计器元数据(xs:annotation/xs:appinfo/driver)与 XSD 本身的有效性。
+/// 为什么需要它:驱动 xsd 只以 AdditionalFiles 参与编译(源生成器把它们变成字符串常量), +/// 构建不会校验 XSD 语法;一旦写坏,消费方一启用 EnableXmlSchemaValidation() 就炸。 +/// 这里同时锁住两件事:
+/// 1. 核心 + 每个驱动 schema 能编译,且能用它校验一份最小的项目 XML;
+/// 2. <driver name> 必须与 XxxNames.DriverName(即 driver="…" 的取值)一致—— +/// WebUi 的 /catalog 就是靠这条声明把驱动名绑到命名空间的。 +///
+public class DriverSchemaAnnotationsTests +{ + public static IEnumerable Drivers => new[] + { + new object[] { S7.S7Names.DriverName, "tags:s7", new S7.S7SchemaProvider() }, + new object[] { ModbusTcp.ModbusTcpNames.DriverName, "tags:modbustcp", new ModbusTcp.ModbusTcpSchemaProvider() }, + new object[] { ZLan.ZLanTcpNames.DriverName, "tags:zlan", new ZLan.ZLanTcpSchemaProvider() }, + new object[] { Hjzk.HjzkNames.DriverName, "tags:hjzk", new Hjzk.HjzkSchemaProvider() }, + new object[] { ComScanner.ComDriverNames.DriverName, "tags:com", new ComScanner.ComScannerSchemaProvider() }, + new object[] { OpcUaClient.OpcUaClientNames.DriverName, "tags:opcuaclient", new OpcUaClient.OpcUaClientSchemaProvider() }, + new object[] { SimpleFiles.SimpleFilesNames.DriverName, "tags:simplefiles", new SimpleFiles.SimpleFilesSchemaProvider() }, + }; + + private static readonly XNamespace Xs = "http://www.w3.org/2001/XMLSchema"; + + /// 取 schema 里声明的驱动名(按本地名匹配:appinfo 内容可能继承默认 xmlns)。 + private static string? DeclaredDriverName(string schemaContent) + { + var root = XDocument.Parse(schemaContent).Root!; + var driver = root.Element(Xs + "annotation")? + .Element(Xs + "appinfo")? + .Elements() + .FirstOrDefault(e => e.Name.LocalName == "driver"); + return (string?)driver?.Attribute("name"); + } + + [Theory] + [MemberData(nameof(Drivers))] + public void DriverSchema_DeclaresItsDriverNameAndNamespace(string driverName, string expectedNamespace, ITagsProjectSchemaProvider provider) + { + var schemas = provider.GetSchemaContents().ToArray(); + var (_, content) = Assert.Single(schemas); + + var root = XDocument.Parse(content).Root!; + Assert.Equal(expectedNamespace, (string?)root.Attribute("targetNamespace")); + Assert.Equal(driverName, DeclaredDriverName(content)); + } + + [Theory] + [MemberData(nameof(Drivers))] + public void DriverSchema_CompilesAndValidatesAMinimalProject(string driverName, string _, ITagsProjectSchemaProvider provider) + { + var contents = TagsProjectSchema.GetSchemaContents().Concat(provider.GetSchemaContents()).ToArray(); + + var project = new XElement("Project", + new XElement("Channel", + new XAttribute("name", "c1"), + new XAttribute("driver", driverName)), + new XElement("TagGrp", + new XAttribute("name", "g1"), + new XAttribute("isEntry", "true"), + new XAttribute("channel", "c1"), + new XElement("Tag", + new XAttribute("name", "t1"), + new XAttribute("type", "INT16"), + new XAttribute("address", "0")))); + + var errors = TagsProjectSchema.Validate(project, contents); + Assert.Empty(errors); + } + + [Fact] + public void AllDriverSchemas_CompileTogetherWithTheCoreSchema() + { + // 运行期就是这么做的(TagsProjectSchemaValidator:核心 + 所有 provider 合并成一个 XmlSchemaSet)。 + var contents = TagsProjectSchema.GetSchemaContents() + .Concat(Drivers.Select(d => (ITagsProjectSchemaProvider)d[2]).SelectMany(p => p.GetSchemaContents())) + .ToArray(); + + var project = new XElement("Project", + new XElement("Channel", new XAttribute("name", "c1"), new XAttribute("driver", "S7")), + new XElement("TagGrp", + new XAttribute("name", "g1"), + new XAttribute("isEntry", "true"), + new XAttribute("channel", "c1"), + new XElement("Tag", new XAttribute("name", "t1"), new XAttribute("address", "DB1.0")))); + + var errors = TagsProjectSchema.Validate(project, contents); + + // 注意:驱动专属子元素在别的命名空间,这里不校验它们——只确认 8 个 schema 能共存。 + // "c1" 没有驱动专属字段会有别的错误吗?不会:Channel 的 xs:any 是 minOccurs=0。 + Assert.Empty(errors); + } + + [Fact] + public void CoreSchema_CarriesUiAnnotations() + { + var (_, content) = TagsProjectSchema.GetSchemaContents().Single(); + var root = XDocument.Parse(content).Root!; + + // 设计器文案挂在 xs:complexType 上(节点)与 xs:attribute 上(字段)。 + var tagType = root.Elements(Xs + "complexType").Single(e => (string?)e.Attribute("name") == "TagType"); + Assert.Equal("测点", (string?)tagType.Element(Xs + "annotation")?.Element(Xs + "appinfo")?.Element("ui")?.Attribute("label")); + + var isEntry = root.Descendants(Xs + "attribute").Single(e => (string?)e.Attribute("name") == "isEntry"); + Assert.Equal("作为入口", (string?)isEntry.Element(Xs + "annotation")?.Element(Xs + "appinfo")?.Element("ui")?.Attribute("label")); + } +} From 5a5eeffde5d4d2fc571841b0caa7e8bc68b46210 Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Thu, 29 Oct 2026 19:32:28 +0800 Subject: [PATCH 8/9] =?UTF-8?q?Designer:=20=E5=A2=9E=E5=8A=A0=E6=9B=B4?= =?UTF-8?q?=E5=A4=9A=E8=87=AA=E5=AE=9A=E4=B9=89=E5=B1=9E=E6=80=A7=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + StdUnit.Tags.sln | 16 +- samples/WebMonitor/Program.cs | 4 + samples/WebMonitor/Tags/HeartBeatLogicet.cs | 64 +++++++ .../WebMonitor/Tags/TagBackgroundService.cs | 28 ++- samples/WebMonitor/Tags/_Extensions.cs | 5 +- samples/WebMonitor/Tags/main/index.xml | 22 ++- samples/WebMonitor/WebMonitor.csproj | 5 +- src/StdUnit.Tags.Core/Schemas/README.md | 37 +++- src/StdUnit.Tags.Core/Schemas/tagsproject.xsd | 6 +- .../Schemas/modbustcp.xsd | 7 +- src/StdUnit.Tags.S7/Schemas/s7.xsd | 10 +- .../Schemas/simplefiles.xsd | 6 +- .../Designer/DesignerCatalog.cs | 138 +++++++++++++- .../Designer/DesignerCatalogDto.cs | 24 ++- src/StdUnit.Tags.WebUi/Ui/designer.html | 178 ++++++++++++++++-- src/StdUnit.Tags.WebUi/Ui/index.html | 26 ++- .../Properties/launchSettings.json | 12 ++ .../WebUi/DesignerCatalogTests.cs | 64 +++++++ .../Schema/DriverSchemaAnnotationsTests.cs | 28 +++ 20 files changed, 634 insertions(+), 47 deletions(-) create mode 100644 samples/WebMonitor/Tags/HeartBeatLogicet.cs create mode 100644 tests/AotSmokeTest.WebUi/Properties/launchSettings.json diff --git a/AGENTS.md b/AGENTS.md index 820c772..f3b0f1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ StdUnit.Tags.Core 硬件无关的核心抽象 + Schemas/tagsproje - `StdUnit.Tags.SchemaGenerator` 是源生成器,把各项目的 XSD 编译成 DLL 内字符串常量(AOT / trim 友好)。 - 新增驱动包时,需一并提供 `Schemas/{driver}.xsd`,并以 `buildTransitive` 方式注入到消费方项目树(照抄 `StdUnit.Tags.Core.csproj` + `Schemas/StdUnit.Tags.Core.targets` 的模式),并在 schema 里用 `xs:annotation/xs:appinfo` 声明 ``(必须与 `XxxNames.DriverName` 一致;`WebUi` 的 `/catalog` 设计器元数据靠它把 driver 值绑到命名空间——XSD 1.0 表达不了这层关系,靠命名约定也不行),再实现 `ITagsProjectSchemaProvider` 注册为 Singleton。 +如果驱动会从 `TagDescriptor.Extras` / `TagCbntDescriptor.Extras` 里读**自己的属性**(如 S7 的 `maxlen`、SimpleFiles 的 `autoCreateFile`、ModbusTcp 的 `interpret`),还要在同一个 `appinfo` 里用 `` / `` 声明;核心 schema 因 `xs:anyAttribute` 放行这些属性,但 XSD 1.0 无法给既有类型追加属性,所以"有哪些、什么类型、什么文案"只能由驱动声明(仅供设计器,不参与校验)。详见 `Schemas/README.md`。 - 测试分两个项目(xUnit),且**主测试项目多目标 `net8.0;net472`**: - `tests/StdUnit.Tags.Tests`:主体测试,跑在两个 TFM 上; - `tests/StdUnit.Tags.Tests.NetCoreOnly`:**仅 net8.0**,专门存放无法面向 net472 的测试(如依赖 `AssemblyLoadContext`,或将来涉及 Blazor / ASP.NET Core 的测试)。目前为空项,作占位预留。 diff --git a/StdUnit.Tags.sln b/StdUnit.Tags.sln index c085599..c1f8fc1 100644 --- a/StdUnit.Tags.sln +++ b/StdUnit.Tags.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.8.12023.21 @@ -180,11 +179,26 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {9B38BA08-BC35-CB42-62F6-827CE718B8CB} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {4363E7BE-55F3-E487-A37A-F5960CDE8A75} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {2A826F47-CB3D-3413-56D6-7B97AC27C703} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {097C9167-CDCA-2E8E-0026-4841253F8441} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {D332EC9E-B8EC-757C-0221-8AFF74E19D03} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {843509DD-C5C6-29CE-0193-B38009005AB1} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {178490E7-7FCC-24DF-555B-6260D39E28AE} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {BB6EE666-3188-F26F-0743-479FD22C858A} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {50F1A22F-6E8D-DB4C-C36C-6D572A9F717E} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {78604125-E5F2-AB5A-720E-BEDDF99D794F} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} {EAE8F527-A0CA-74B7-35CD-0388B2309C50} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {AD9C0AFC-1563-8496-7306-C3DA50242547} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {DA1DE182-1E44-26F1-7A75-E59A35C4B677} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {4C70D391-DA84-79E3-2601-FFEC384F48B0} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} {CFD0A797-F315-4012-A3A7-5FDC703B0A6A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {B0D583C1-2059-420E-875C-BC63167187B2} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} + {A1C37BF0-5B93-4861-A936-5DD114C3C22B} = {B9E1E3A0-6B7E-4A15-AB8C-CB5E3FFB29C8} {C05B9E35-3DED-4DE5-BCCA-32F655B3DC2A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {D9097C66-2929-4F6E-B6A1-C93182F0DFBC} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {3B44DA2B-D44B-14EE-9973-19C90BA6A579} = {B9E1E3A0-6B7E-4A15-AB8C-CB5E3FFB29C8} {1B85FF35-7A81-4CB7-924A-441EC3DA9218} = {2215F920-F9B4-4801-88A1-5F3E3E65B9CA} {87CBE561-3796-48DE-B121-E7534B49FE45} = {B9E1E3A0-6B7E-4A15-AB8C-CB5E3FFB29C8} {018E69DC-830B-4DD4-BC68-7716821226E0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} diff --git a/samples/WebMonitor/Program.cs b/samples/WebMonitor/Program.cs index 14f13a5..24936fe 100644 --- a/samples/WebMonitor/Program.cs +++ b/samples/WebMonitor/Program.cs @@ -3,6 +3,10 @@ var builder = WebApplication.CreateSlimBuilder(args); +builder.Services.AddLogging(lb =>{ + lb.AddConsole(); + lb.AddDebug(); +}); // 只做"绑定":驱动、控制器、观测流、后台任务全部在 Tags/_Extensions.cs 里注册 // (与 `dotnet new tags.web` 的分层一致:Program.cs 只负责宿主与管线)。 builder.Services.AddTags(); diff --git a/samples/WebMonitor/Tags/HeartBeatLogicet.cs b/samples/WebMonitor/Tags/HeartBeatLogicet.cs new file mode 100644 index 0000000..3bd07ff --- /dev/null +++ b/samples/WebMonitor/Tags/HeartBeatLogicet.cs @@ -0,0 +1,64 @@ +using StdUnit.Tags; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WebMonitor.Tags.Logicets; + +public class HeartBeatLogicet : LogicetBase +{ + private readonly ITagGrp _entry; + private readonly string _entryName; + + private readonly ITag _heartReq; + private readonly ITag _heartAck; + private readonly ILogger _logger; + + private readonly ITag _interval; + private Stopwatch _stopwatch = new Stopwatch(); + private bool _fst = true; + private TimeSpan _duration = TimeSpan.Zero; + + + public HeartBeatLogicet(ITagsProject proj, ILogger logger) : base(proj) + { + this._entry = this.Tags.SelectGrp(TagsPathDefines.MainEntry); + this._entryName = this._entry.TagName(); + + var grp = this._entry.SelectGrp("通用状态"); + + _heartReq = this._entry.SelectTag("通用状态/PLC/心跳请求"); + _heartAck = this._entry.SelectTag("通用状态/MST/心跳响应"); + _interval = this._entry.SelectTag("通用状态/MST/扫描周期"); + this._logger = logger; + } + + public override int Order => 2; + + public override bool MatchEntry(ITagGrp entry) => entry.TagName() == this._entryName; + + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) + { + _heartAck.Value = _heartReq.Value; + + + // 统计周期 + if (_fst) + { + _stopwatch.Start(); + _fst = false; + } + else + { + _duration = _stopwatch.Elapsed; + _interval.Value = (float)_duration.TotalMilliseconds; + _stopwatch.Restart(); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/samples/WebMonitor/Tags/TagBackgroundService.cs b/samples/WebMonitor/Tags/TagBackgroundService.cs index b99aaf5..c2b599c 100644 --- a/samples/WebMonitor/Tags/TagBackgroundService.cs +++ b/samples/WebMonitor/Tags/TagBackgroundService.cs @@ -1,6 +1,7 @@ using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; using StdUnit.Tags; +using WebMonitor.Tags.Logicets; namespace WebMonitor.Tags; @@ -31,8 +32,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var mainDir = Path.Combine(tagsRoot, "main"); var lineBDir = Path.Combine(tagsRoot, "line-b"); - var main = this._ctrl.StartPollAsync(mainDir, LoadProjectXml(mainDir), this.Hook); - var lineB = this._lineBCtrl.StartPollAsync(lineBDir, LoadProjectXml(lineBDir), this.Hook); + var main = this._ctrl.StartPollAsync(mainDir, LoadProjectXml(mainDir), this.Hook1); + var lineB = this._lineBCtrl.StartPollAsync(lineBDir, LoadProjectXml(lineBDir), this.Hook2); var simulator = this.SimulateAsync(stoppingToken); try @@ -70,7 +71,7 @@ private static XElement LoadProjectXml(string dir) return root; } - private Task Hook(ITagsProject project, IServiceProvider services, CancellationToken ct) { + private Task Hook1(ITagsProject project, IServiceProvider services, CancellationToken ct) { project.RunnerStarted += (grp, ch) => { this._logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName()); @@ -81,6 +82,27 @@ private Task Hook(ITagsProject project, IServiceProvider services, CancellationT this._logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName()); return Task.CompletedTask; }; + + project.Logicets.Add(new HeartBeatLogicet( + project, + services.GetRequiredService>() + )); + return Task.CompletedTask; + } + + private Task Hook2(ITagsProject project, IServiceProvider services, CancellationToken ct) + { + project.RunnerStarted += (grp, ch) => + { + this._logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName()); + return Task.CompletedTask; + }; + project.RunnerCrashed += (grp, ch, ex) => + { + this._logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName()); + return Task.CompletedTask; + }; + return Task.CompletedTask; } diff --git a/samples/WebMonitor/Tags/_Extensions.cs b/samples/WebMonitor/Tags/_Extensions.cs index 4e92981..f08ba1e 100644 --- a/samples/WebMonitor/Tags/_Extensions.cs +++ b/samples/WebMonitor/Tags/_Extensions.cs @@ -1,5 +1,6 @@ using StdUnit.Tags; using StdUnit.Tags.SimpleFiles; +using StdUnit.Tags.S7; using StdUnit.Tags.WebUi; namespace WebMonitor.Tags; @@ -10,10 +11,8 @@ public static IServiceCollection AddTags(this IServiceCollection services) { services.AddTagsProjectServices(builder => { - // 本示例只用 SimpleFiles(数据是磁盘上的文本文件),不依赖任何硬件/网络。 - // 用 Support 而不是 Channel+DirectTagBuilder:它同时注册 SimpleFilesSchemaProvider, - // 设计器元数据(/stdunit.tags/catalog)里的驱动列表就来自这些 schema provider。 builder.AddSimpleFilesSupport(); + builder.AddS7Support(); // WebUi:不具名的观测流(端点 /stdunit.tags/*)+ 一路具名流(/stdunit.tags/line-b/*,各有一套监控页)。 builder.AddTagsStream(o => diff --git a/samples/WebMonitor/Tags/main/index.xml b/samples/WebMonitor/Tags/main/index.xml index f0554a4..d99efd7 100644 --- a/samples/WebMonitor/Tags/main/index.xml +++ b/samples/WebMonitor/Tags/main/index.xml @@ -1,12 +1,32 @@ - + . + + 172.16.10.20 + 0 + 1 + 3 + + + + + + + + + + + + + + + diff --git a/samples/WebMonitor/WebMonitor.csproj b/samples/WebMonitor/WebMonitor.csproj index 7d57fae..ada80b8 100644 --- a/samples/WebMonitor/WebMonitor.csproj +++ b/samples/WebMonitor/WebMonitor.csproj @@ -11,14 +11,17 @@ 产物是真·原生可执行文件,不依赖 .NET 运行时。 --> true true + en
+ - + + diff --git a/src/StdUnit.Tags.Core/Schemas/README.md b/src/StdUnit.Tags.Core/Schemas/README.md index 0b13b0a..83d17ab 100644 --- a/src/StdUnit.Tags.Core/Schemas/README.md +++ b/src/StdUnit.Tags.Core/Schemas/README.md @@ -155,6 +155,13 @@ services.AddTagsProjectServices(b => targetNamespace="tags:mydriver" elementFormDefault="qualified"> + + + + + @@ -167,8 +174,34 @@ services.AddTagsProjectServices(b => - `` **必须与运行时 `XxxNames.DriverName`(即 XML 里的 `driver="…"`)完全一致**; `prefix` 缺省取命名空间后缀(`tags:mydriver` → `mydriver`)。 -- `` 只影响 UI:`label` / `order` / `group` / `placeholder` / `help`。XSD 会忽略 `appinfo` - 里的自定义元素,对校验零影响;**不写也能用**(设计器退化为按属性名显示)。 +- `` 只影响 UI:`label` / `order` / `group` / `placeholder` / `help` / `appliesToDrivers`。 + 其中 `appliesToDrivers`(逗号分隔的驱动名)用来给**核心 schema 里“只有部分驱动认”的属性**划作用域—— + 典型例子是 `TagCbnt` 的 `area`/`slave`(只有 ModbusTcp / HjzkIoBox / ZLanTcp 读)。XSD 1.0 只能给类型 + *加* 属性、不能按驱动 *删* 属性,所以用这个提示让设计器按有效驱动过滤(已写了值的属性仍会显示,避免 + “看不见却被导出”)。XSD 会忽略 `appinfo` 里的自定义元素,对校验零影响;**不写也能用**(设计器退化为按属性名显示)。 - 这两种元素都按**本地名**匹配,跟 `xs:schema` 上有没有默认 `xmlns` 无关。 - 没声明 `` 的 schema 仍然参与校验,只是不出现在设计器的驱动列表里。 +#### 为什么需要 `` / `` + +`Tag` / `TagCbnt` / `TagGrp` 在核心 schema 里都带 ``: +**任何属性都是合法的**。可运行期真正认哪些键,取决于驱动——它们从 +`TagDescriptor.Extras` / `TagCbntDescriptor.Extras` 里读(如 S7 的 `maxlen`、SimpleFiles 的 +`autoCreateFile`、ModbusTcp 的 `interpret`)。XSD 1.0 **不允许**跨命名空间给既有类型追加属性, +所以这批属性永远进不了核心 schema 的类型定义;只能由驱动"声明"一次,供设计器渲染与提示: + +| 属性 | 含义 | +|---|---| +| `name` | 属性名(必须与运行期 `Extras` 的键一致) | +| `kind` | 控件类型:`string` / `bool` / `number` / `enum`(缺省按 `type` 推断) | +| `type` | 仅作文档/推断用(如 `positiveInteger`) | +| `label` / `order` / `group` / `placeholder` / `help` | 与 `` 同义 | +| `required` | 仅提示(设计器不会因此拒绝导出) | +| `min` / `max` / `minLength` / `enumValues` | 数值/长度/枚举提示(`enumValues` 逗号分隔) | +| `appliesToTypes` | 逗号分隔的测点 `type` 列表;只有命中时才显示(如 S7 的 `maxlen` 只对 `STR`) | + +**这些声明不参与校验**:写错的属性名依旧是"合法 XML",只是设计器不会给出提示。若需要"属性拼写查错", +请在加载期做 **advisory** 诊断(既不拒绝,也不静默),当前库没有内置。 + +未声明的属性不会丢:设计器把它们归为"自定义属性",导入/导出都原样保留。 + diff --git a/src/StdUnit.Tags.Core/Schemas/tagsproject.xsd b/src/StdUnit.Tags.Core/Schemas/tagsproject.xsd index 2d49e10..6bb6168 100644 --- a/src/StdUnit.Tags.Core/Schemas/tagsproject.xsd +++ b/src/StdUnit.Tags.Core/Schemas/tagsproject.xsd @@ -155,10 +155,12 @@ - + - + diff --git a/src/StdUnit.Tags.ModbusTcp/Schemas/modbustcp.xsd b/src/StdUnit.Tags.ModbusTcp/Schemas/modbustcp.xsd index 089e835..1af0ea3 100644 --- a/src/StdUnit.Tags.ModbusTcp/Schemas/modbustcp.xsd +++ b/src/StdUnit.Tags.ModbusTcp/Schemas/modbustcp.xsd @@ -39,9 +39,14 @@ - + + diff --git a/src/StdUnit.Tags.S7/Schemas/s7.xsd b/src/StdUnit.Tags.S7/Schemas/s7.xsd index d2a4e17..a6eed30 100644 --- a/src/StdUnit.Tags.S7/Schemas/s7.xsd +++ b/src/StdUnit.Tags.S7/Schemas/s7.xsd @@ -19,9 +19,17 @@ + 所以绑定声明由驱动 schema 自己持有;见 WebUi 的 /catalog 端点。 + + tagAttribute:驱动"额外认"的测点属性。它们不在核心 TagType 里(XSD 1.0 不允许跨命名空间 + 给既有类型追加属性),运行期落在 TagDescriptor.Extras;schema 因 xs:anyAttribute 放行, + 所以这里只是**给设计器看的声明**,不参与校验。 --> + + + diff --git a/src/StdUnit.Tags.WebUi/Designer/DesignerCatalog.cs b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalog.cs index 2711d86..38708b7 100644 --- a/src/StdUnit.Tags.WebUi/Designer/DesignerCatalog.cs +++ b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalog.cs @@ -148,7 +148,9 @@ private static IReadOnlyList CollectDrivers(XmlSchemaSet set, ns, driver.Prefix ?? DerivePrefix(ns), // OrderBy 是稳定排序:未声明 order 的字段保持 schema 里的声明顺序。 - fields.OrderBy(f => f.Field.Order ?? int.MaxValue).Select(f => f.Field).ToArray())); + fields.OrderBy(f => f.Field.Order ?? int.MaxValue).Select(f => f.Field).ToArray(), + driver.TagAttributes, + driver.CbntAttributes)); } return drivers.OrderBy(d => d.Name, StringComparer.Ordinal).ToArray(); @@ -245,7 +247,8 @@ private static DesignerFieldDto DescribeAttribute( Group: ui?.Group, Placeholder: ui?.Placeholder, Help: ui?.Help, - Children: null); + Children: null, + AppliesToDrivers: ui?.AppliesToDrivers); } /// @@ -449,11 +452,15 @@ private sealed record SimpleShape( /// /// xs:annotation/xs:appinfo 里的设计器提示,按"文档中的位置"索引。
- /// 内容形状(appinfo 里的元素不带命名空间):
+ /// 内容形状(appinfo 里的元素不带命名空间;多个参数与 xs:schema 上有没有默认 xmlns 无关,按本地名匹配):
/// /// <xs:schema …> /// <xs:annotation><xs:appinfo> /// <driver name="S7" displayName="Siemens S7" prefix="s7"/> + /// <!-- 驱动额外认的测点/组合属性:XSD 1.0 表达不了,只能在这里"声明" --> + /// <tagAttribute name="maxlen" kind="number" min="1" max="255" label="字符串最大长度" + /// appliesToTypes="STR" required="true"/> + /// <cbntAttribute name="…" …/> /// </xs:appinfo></xs:annotation> /// … /// <xs:complexType name="TagGrpType"> @@ -565,7 +572,103 @@ private void Add(string content) return new DriverAnnotation( name!, (string?)driver!.Attribute("displayName") ?? (string?)driver.Attribute("label"), - (string?)driver.Attribute("prefix")); + (string?)driver.Attribute("prefix"), + ParseAttributeDeclarations(schema, "tagAttribute"), + ParseAttributeDeclarations(schema, "cbntAttribute")); + } + + /// + /// 解析 <tagAttribute> / <cbntAttribute> 声明(驱动额外认的属性)。 + /// 这些属性运行期落在 TagDescriptor.Extras / TagCbntDescriptor.Extras, + /// schema 只能靠 xs:anyAttribute 放行,所以"有哪些、什么类型、什么文案"只能在这里声明。 + /// + private static IReadOnlyList ParseAttributeDeclarations(XElement schema, string localName) + { + var result = new List<(int Sort, DesignerFieldDto Field)>(); + foreach (var element in AppinfoChildren(schema, localName)) + { + var field = ParseAttributeDeclaration(element); + if (field is not null) + { + // OrderBy 是稳定排序:没写 order 的保持 schema 里的声明顺序。 + result.Add((field.Order ?? int.MaxValue, field)); + } + } + + return result.OrderBy(r => r.Sort).Select(r => r.Field).ToArray(); + } + + private static DesignerFieldDto? ParseAttributeDeclaration(XElement element) + { + var name = (string?)element.Attribute("name"); + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + var type = (string?)element.Attribute("type"); + var enumValues = SplitList((string?)element.Attribute("enumValues")); + var kind = (string?)element.Attribute("kind") + ?? (enumValues is not null + ? DesignerFieldKind.Enum + : InferKind(type)); + + return new DesignerFieldDto( + Name: name!, + Type: type, + Kind: kind, + Required: bool.TryParse((string?)element.Attribute("required"), out var required) && required, + Default: (string?)element.Attribute("default"), + EnumValues: enumValues, + Min: ParseDecimal((string?)element.Attribute("min")), + Max: ParseDecimal((string?)element.Attribute("max")), + MinLength: int.TryParse((string?)element.Attribute("minLength"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var minLength) + ? minLength + : null, + Label: (string?)element.Attribute("label"), + Order: int.TryParse((string?)element.Attribute("order"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var order) + ? order + : null, + Group: (string?)element.Attribute("group"), + Placeholder: (string?)element.Attribute("placeholder"), + Help: (string?)element.Attribute("help") ?? (string?)element.Attribute("description"), + Children: null, + AppliesToTypes: SplitList((string?)element.Attribute("appliesToTypes"))); + } + + /// 逗号分隔的列表(去空白、去空项);空则返回 null。 + private static IReadOnlyList? SplitList(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var items = text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return items.Length == 0 ? null : items; + } + + /// 没写 kind 时按 type 猜一个渲染类别(纯提示,猜错最多是控件类型不理想)。 + private static string InferKind(string? type) + { + var text = type ?? string.Empty; + if (text.Contains("boolean", StringComparison.OrdinalIgnoreCase)) + { + return DesignerFieldKind.Bool; + } + + if (text.Contains("int", StringComparison.OrdinalIgnoreCase) + || text.Contains("short", StringComparison.OrdinalIgnoreCase) + || text.Contains("byte", StringComparison.OrdinalIgnoreCase) + || text.Contains("decimal", StringComparison.OrdinalIgnoreCase) + || text.Contains("double", StringComparison.OrdinalIgnoreCase) + || text.Contains("float", StringComparison.OrdinalIgnoreCase) + || text.Contains("number", StringComparison.OrdinalIgnoreCase)) + { + return DesignerFieldKind.Number; + } + + return DesignerFieldKind.String; } private static UiInfo? ParseUi(XElement owner) @@ -583,7 +686,8 @@ private void Add(string content) : null, (string?)ui.Attribute("group"), (string?)ui.Attribute("placeholder"), - (string?)ui.Attribute("help") ?? (string?)ui.Attribute("description")); + (string?)ui.Attribute("help") ?? (string?)ui.Attribute("description"), + SplitList((string?)ui.Attribute("appliesToDrivers"))); } /// @@ -593,13 +697,29 @@ private void Add(string content) /// xmlns="tags:{driver}",于是 <driver>/<ui> 落进了目标命名空间。 /// private static XElement? AppinfoChild(XElement owner, string localName) => + AppinfoChildren(owner, localName).FirstOrDefault(); + + /// 同上,取全部同名提示元素(如多个 <tagAttribute>)。 + private static IEnumerable AppinfoChildren(XElement owner, string localName) => owner.Element(Xs + "annotation")? .Element(Xs + "appinfo")? .Elements() - .FirstOrDefault(e => e.Name.LocalName == localName); + .Where(e => e.Name.LocalName == localName) + ?? Enumerable.Empty(); } - private sealed record DriverAnnotation(string Name, string? DisplayName, string? Prefix); - - private sealed record UiInfo(string? Label, int? Order, string? Group, string? Placeholder, string? Help); + private sealed record DriverAnnotation( + string Name, + string? DisplayName, + string? Prefix, + IReadOnlyList TagAttributes, + IReadOnlyList CbntAttributes); + + private sealed record UiInfo( + string? Label, + int? Order, + string? Group, + string? Placeholder, + string? Help, + IReadOnlyList? AppliesToDrivers = null); } diff --git a/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogDto.cs b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogDto.cs index 9c8bddb..99223a1 100644 --- a/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogDto.cs +++ b/src/StdUnit.Tags.WebUi/Designer/DesignerCatalogDto.cs @@ -76,6 +76,15 @@ public sealed record DesignerNodeDto( /// 输入框占位符(ui@placeholder)。 /// 帮助文本(ui@help / ui@description)。 /// 嵌套子元素( 时非空,如 OpcUa 的 ServerOpt)。 +/// +/// 仅对驱动声明的 Tag 属性有意义:只在测点 type 命中这些值时才显示(如 S7 的 maxlen 只对 STR)。 +/// null/空表示不限。它只是UI 提示,不参与校验。 +/// +/// +/// 仅对该属性有意义:只有节点所在通道的驱动命中这些值时才显示(如 TagCbnt 的 area/slave +/// 只对 ModbusTcp / HjzkIoBox / ZLanTcp)。null/空表示不限。同样只是UI 提示: +/// 已经写了值的属性即使不适用也会显示,避免"看不见却被导出"。 +/// public sealed record DesignerFieldDto( string Name, string? Type, @@ -91,7 +100,9 @@ public sealed record DesignerFieldDto( string? Group, string? Placeholder, string? Help, - IReadOnlyList? Children); + IReadOnlyList? Children, + IReadOnlyList? AppliesToTypes = null, + IReadOnlyList? AppliesToDrivers = null); /// /// 一个驱动通道的定义:<Channel driver="Name"> 下允许的专属子元素。
@@ -107,12 +118,21 @@ public sealed record DesignerFieldDto( /// 驱动字段所属的 XML 命名空间(tags:s7),导出时据此声明 xmlns。 /// 导出时建议的前缀(<driver prefix="s7"/>,缺省取命名空间后缀)。 /// 该驱动的专属子元素;按 order 再按声明顺序。 +/// +/// 该驱动额外认的 Tag 属性(如 S7 的 maxlen、SimpleFiles 的 autoCreateFile)。 +/// 它们不在核心 schema 里——XSD 1.0 不允许跨命名空间给既有类型追加属性,所以由驱动在 xsd 的 +/// xs:appinfo 里声明(<tagAttribute>)。运行期这些属性落在 +/// TagDescriptor.Extras,不会被 schema 校验拒绝。 +/// +/// 该驱动额外认的 TagCbnt 属性(<cbntAttribute>),语义同上。 public sealed record DesignerDriverDto( string Name, string? DisplayName, string Namespace, string Prefix, - IReadOnlyList Fields); + IReadOnlyList Fields, + IReadOnlyList TagAttributes, + IReadOnlyList CbntAttributes); /// /// 的取值(前端渲染依据)。 diff --git a/src/StdUnit.Tags.WebUi/Ui/designer.html b/src/StdUnit.Tags.WebUi/Ui/designer.html index 73fb93e..30567d4 100644 --- a/src/StdUnit.Tags.WebUi/Ui/designer.html +++ b/src/StdUnit.Tags.WebUi/Ui/designer.html @@ -197,7 +197,8 @@

测点项目设计器

/** 设计器里的全部状态;字段值都是字符串(空串 = 省略,交给运行期默认值)。 */ const state = { - tab: 'channels', selected: 0, + tab: 'channels', + selected: 0, channels: [], tree: [], logicets: [], // 导入时认不出的东西(未知扩展元素/属性):原样保留,导出时再吐出去,避免"打开再保存就丢配置"。 extraXml: [], projectAttrs: {}, importIssues: [] @@ -253,10 +254,37 @@

测点项目设计器

function newChannel() { const d = cat.drivers[0]; - return { name: uniqueName('ch', new Set(channelNames())), driver: d ? d.name : '', values: {}, groups: {}, extras: [], extraAttrs: {} }; + return { name: uniqueName('ch', new Set(channelNames())), driver: d ? d.name : '', values: {}, groups: {}, extras: [], extraAttrs: [] }; } - const newNode = el => ({ el, attrs: {}, children: [], extras: [], extraAttrs: {}, collapsed: false }); + const newNode = el => ({ el, attrs: {}, children: [], extras: [], extraAttrs: [], collapsed: false }); + + /** + * 非核心属性统一存成 [{name, value}]:既装"驱动声明的额外属性"(如 S7 的 maxlen), + * 也装"导入进来但没人认识的自定义属性"。顺带兼容旧版本存的 {name: value} 对象形式。 + */ + function extraAttrsOf(owner) { + if (!Array.isArray(owner.extraAttrs)) { + const obj = owner.extraAttrs || {}; + owner.extraAttrs = Object.entries(obj).map(([name, value]) => ({ name, value })); + } + return owner.extraAttrs; + } + + /** 按名字取/设一条非核心属性;值为空即删除(避免导出空属性)。 */ + function setExtraAttr(owner, name, value) { + const list = extraAttrsOf(owner); + const index = list.findIndex(a => a.name === name); + if (value === '' || value == null) { + if (index >= 0) list.splice(index, 1); + } else if (index >= 0) { + list[index].value = value; + } else { + list.push({ name, value }); + } + } + + const getExtraAttr = (owner, name) => extraAttrsOf(owner).find(a => a.name === name)?.value ?? ''; /* ───────────────────────── 导入(尽力解析) ───────────────────────── */ @@ -286,7 +314,7 @@

测点项目设计器

for (const a of [...el.attributes]) { if (a.name === 'name' || a.name === 'driver' || a.name === 'xmlns' || a.name.startsWith('xmlns:')) continue; - ch.extraAttrs[a.name] = a.value; + extraAttrsOf(ch).push({ name: a.name, value: a.value }); } for (const child of [...el.children]) { @@ -309,7 +337,7 @@

测点项目设计器

for (const a of [...el.attributes]) { if (a.name === 'xmlns' || a.name.startsWith('xmlns:')) continue; if (known.has(a.name)) node.attrs[a.name] = a.value; - else node.extraAttrs[a.name] = a.value; // 如 SimpleFiles 测点的 autoCreateFile + else extraAttrsOf(node).push({ name: a.name, value: a.value }); // 如 SimpleFiles 测点的 autoCreateFile } for (const child of [...el.children]) { @@ -522,11 +550,17 @@

测点项目设计器

out.push(driverFields(drv.fields || [], ch.values, ch.groups, 0)); } - const extras = (ch.extras || []).length + Object.keys(ch.extraAttrs || {}).length; + const extras = (ch.extras || []).length + extraAttrsOf(ch).length; if (extras > 0) { out.push(h('p', { class: 'muted', text: '该通道包含 ' + extras + ' 项导入时无法识别的配置,导出时会原样写回。' })); } + out.push(h('div', { class: 'row section' }, + h('span', { class: 'label-title', text: '自定义属性' }), + h('span', { class: 'muted', text: '通道没有驱动专属属性声明,这里可加任意扩展属性' }))); + out.push(customAttrRows(ch, new Set())); + out.push(addCustomAttrRow(ch)); + return out; } @@ -544,7 +578,7 @@

测点项目设计器

wrap.append(h('p', { class: 'muted', text: '还没有测点组:至少需要一个 isEntry="true" 的入口组,项目才能轮询。' })); } - state.tree.forEach((n, i) => wrap.append(nodeCard(n, state.tree, i))); + state.tree.forEach((n, i) => wrap.append(nodeCard(n, state.tree, i, ''))); wrap.append(h('div', { class: 'card' }, h('div', { class: 'row section' }, @@ -574,11 +608,32 @@

测点项目设计器

render(); } - function nodeCard(node, siblings, index) { + /** 声明的作用域:只在有效驱动命中 `appliesToDrivers` 时显示;已写值的例外(避免"看不见却被导出")。 */ + function attrAppliesToDriver(field, driverName, value) { + const drivers = field.appliesToDrivers; + if (!drivers || drivers.length === 0) return true; + if (!driverName) return true; // 驱动未知:先都显示,免得藏掉合法配置 + if (drivers.includes(driverName)) return true; + return value !== undefined && value !== null && value !== ''; // 已经写了值就保留可见 + } + + function nodeCard(node, siblings, index, inheritedChannel) { const schema = nodeOf(node.el); const badge = h('span', { class: 'badge', text: node.attrs.name || '(未命名)' }); const collapsed = node.collapsed === true; + // 有效通道/驱动:Tag/TagCbnt 自己不写 driver,要沿 channel 冒泡到 。 + const ownChannel = node.attrs.channel || inheritedChannel || ''; + const channel = state.channels.find(c => c.name === ownChannel); + const driver = channel ? driverOf(channel.driver) : null; + + // 驱动的额外属性(XSD 1.0 表达不了,由驱动 schema 用 appinfo 声明)。 + const declared = node.el === 'Tag' + ? (driver?.tagAttributes || []) + : node.el === 'TagCbnt' + ? (driver?.cbntAttributes || []) + : []; + // 折叠开关放在右列最前面:左列的标题文字位置不变(和下面字段的 label 列严格对齐)。 const toggle = h('button', { class: 'toggle', @@ -603,8 +658,11 @@

测点项目设计器

const fields = h('div', { class: 'fields' }); for (const a of schema.attributes) { + if (!attrAppliesToDriver(a, driver ? driver.name : '', node.attrs[a.name])) continue; + if (a.name === 'channel') { - const select = h('select', { onchange: e => { node.attrs.channel = e.target.value; } }, + // 换通道可能换驱动 → 重新渲染(子节点的"驱动专属属性"要跟着变)。 + const select = h('select', { onchange: e => { node.attrs.channel = e.target.value; render(); } }, h('option', { value: '' }, '(继承)'), ...channelNames().map(n => h('option', { value: n }, n))); select.value = node.attrs.channel || ''; @@ -612,6 +670,19 @@

测点项目设计器

continue; } + if (a.name === 'type' && node.el === 'Tag') { + // type 决定哪些驱动专属属性可见(如 S7 maxlen 只对 STR):失焦时刷新一次,输入时不打断。 + const input = h('input', { + type: 'text', + placeholder: a.placeholder ?? '', + value: node.attrs.type ?? '', + oninput: e => { node.attrs.type = e.target.value; }, + onchange: () => render() + }); + fields.append(fieldRow(a, node.attrs.type ?? '', v => { node.attrs.type = v; }, input)); + continue; + } + const onChange = a.name === 'name' ? v => { node.attrs.name = v; badge.textContent = v || '(未命名)'; } : v => { node.attrs[a.name] = v; }; @@ -619,15 +690,39 @@

测点项目设计器

} card.append(fields); - const extras = (node.extras || []).length + Object.keys(node.extraAttrs || {}).length; + const extras = (node.extras || []).length + extraAttrsOf(node).length; if (extras > 0) { card.append(h('p', { class: 'muted', text: '有 ' + extras + ' 项导入时无法识别的配置,导出时会原样写回。' })); } + // 驱动声明的额外属性 + 剩余的"自定义/未声明"属性。 + // 顺序:小节标题 → 声明属性(有 label/枚举/范围)→ 未声明属性(名字可编辑)→ 添加按钮。 + if (node.el === 'Tag' || node.el === 'TagCbnt') { + const declaredNames = new Set(declared.map(f => f.name)); + const hasExtras = extraAttrsOf(node).length > 0; + const hint = driver + ? (driver.displayName || driver.name) + : (ownChannel + ? '通道 "' + ownChannel + '" 的驱动未注册,无法确定可用属性' + : '未指定通道:无法确定驱动,额外属性请用自定义属性'); + + if (declared.length > 0 || hasExtras) { + card.append(h('div', { class: 'row section' }, + h('span', { class: 'label-title', text: node.el === 'Tag' ? '驱动专属测点属性' : '驱动专属组合属性' }), + h('span', { class: 'badge', text: hint }))); + + const extraFields = h('div', { class: 'fields' }); + extraFields.append(...declaredAttrRows(declared, node, node), customAttrRows(node, declaredNames)); + if (extraFields.childElementCount > 0) card.append(extraFields); + } + + card.append(addCustomAttrRow(node)); + } + const childKinds = schema.children || []; if (node.children.length > 0 || childKinds.length > 0) { const box = h('div', { class: 'children' }); - node.children.forEach((c, i) => box.append(nodeCard(c, node.children, i))); + node.children.forEach((c, i) => box.append(nodeCard(c, node.children, i, ownChannel))); if (childKinds.length > 0) { box.append(h('div', { class: 'bar' }, ...childKinds.map(k => h('button', { @@ -672,6 +767,57 @@

测点项目设计器

return n; } + /* ───────────────────────── 非核心属性(驱动声明 + 自定义) ───────────────────────── */ + + /** 声明的属性只在测点 type 命中时才显示(如 S7 的 maxlen 只对 STR);它只是 UI 提示。 */ + function attrApplies(field, node) { + const types = field.appliesToTypes; + if (!types || types.length === 0) return true; + const type = node && node.attrs ? node.attrs.type : null; + if (!type) return true; // 还没填 type:先显示,避免"填了类型才冒出来"让人困惑 + return types.includes(type); + } + + /** 驱动声明的额外属性(Tag/TagCbnt):值存在 extraAttrs 里,导出时按普通属性写回。 */ + function declaredAttrRows(fields, owner, node) { + const rows = []; + for (const f of fields) { + if (attrApplies(f, node)) { + rows.push(fieldRow(f, getExtraAttr(owner, f.name), v => setExtraAttr(owner, f.name, v))); + } + } + return rows; + } + + /** 剩余的非核心属性 = 声明之外的"自定义/未声明"属性;名字和值都可编辑。 */ + function customAttrRows(owner, declaredNames) { + const box = h('div', { class: 'fields' }); + for (const entry of extraAttrsOf(owner)) { + if (declaredNames.has(entry.name)) continue; + box.append(h('div', { class: 'row' }, + h('input', { + type: 'text', value: entry.name, placeholder: '属性名(未声明)', spellcheck: 'false', + oninput: e => { entry.name = e.target.value; } + }), + h('div', { class: 'row-inline' }, + h('input', { + type: 'text', value: entry.value, placeholder: '值', + oninput: e => { entry.value = e.target.value; } + }), + h('button', { class: 'mini', onclick: () => { const i = extraAttrsOf(owner).indexOf(entry); extraAttrsOf(owner).splice(i, 1); render(); } }, '删除')))); + } + return box; + } + + function addCustomAttrRow(owner) { + return h('div', { class: 'bar' }, + h('button', { + class: 'mini', + onclick: () => { extraAttrsOf(owner).push({ name: '', value: '' }); render(); } + }, '+ 自定义属性'), + h('span', { class: 'muted', text: '未声明的属性不会被 schema 拒绝,导出时原样写回' })); + } + /* ───────────────────────── 导入页 ───────────────────────── */ function renderImportTab() { @@ -771,8 +917,9 @@

测点项目设计器

const known = schema.attributes .filter(a => node.attrs[a.name] !== undefined && node.attrs[a.name] !== '') .map(a => ' ' + a.name + '="' + esc(node.attrs[a.name]) + '"'); - const extras = Object.entries(node.extraAttrs || {}) - .map(([k, v]) => ' ' + k + '="' + esc(v) + '"'); + const extras = extraAttrsOf(node) + .filter(a => a.name) + .map(a => ' ' + a.name + '="' + esc(a.value) + '"'); const attrs = known.concat(extras).join(''); const kids = node.children.flatMap(c => nodeXml(c, depth + 1)); @@ -833,8 +980,9 @@

测点项目设计器

const drv = driverOf(ch.driver); const body = drv ? fieldsXml(drv.fields || [], ch.values, ch.groups, drv.prefix, 2) : []; const raw = (ch.extras || []).map(x => ' ' + rawXml(x)); - const extraAttrs = Object.entries(ch.extraAttrs || {}) - .map(([k, v]) => ' ' + k + '="' + esc(v) + '"') + const extraAttrs = extraAttrsOf(ch) + .filter(a => a.name) + .map(a => ' ' + a.name + '="' + esc(a.value) + '"') .join(''); const head = ' 测点监控 // 根的 parent 是 null;它自己的 path 是 ''。若直接用 parent 作键,根会和"根的孩子们" // 落进同一个桶,递归时会自己渲染自己(栈溢出)。 const key = n.parent == null ? '\u0000root' : n.parent; - if (!map.has(key)) map.set(key, []); + if (!map.has(key)) { + map.set(key, []); + } map.get(key).push(n); } return map; @@ -221,7 +223,8 @@

测点监控

function flash(text, isError) { msgEl.textContent = text; msgEl.className = 'muted' + (isError ? ' err' : ''); - if (flashTimer) clearTimeout(flashTimer); + if (flashTimer) + clearTimeout(flashTimer); flashTimer = setTimeout(() => { msgEl.textContent = ''; msgEl.className = 'muted'; @@ -310,6 +313,7 @@

测点监控

const details = document.createElement('details'); details.open = n.kind === 'grp'; + // summary: TagName、StartAddress、Channel e.t.c const summary = document.createElement('summary'); const name = document.createElement('span'); name.textContent = n.name; @@ -317,8 +321,10 @@

测点监控

meta.className = 'k'; meta.textContent = label(n); summary.append(name, meta); + details.appendChild(summary); + // render children recursively for (const child of byParent.get(n.path) ?? []) { renderNode(child, byParent, details); } @@ -344,11 +350,20 @@

测点监控

} const byParent = childrenByParent(tree.nodes); - for (const n of tree.nodes) if (n.entryKey) entryCount++; + for (const n of tree.nodes) + { + if (n.entryKey) + { + entryCount++; + } + } // 根节点(__main__)只是个挂载点,直接渲染它的孩子(契约:根在最前)。 const root = tree.nodes[0]; - for (const n of (root ? byParent.get(root.path) ?? [] : [])) renderNode(n, byParent, treeEl); + for (const n of (root ? byParent.get(root.path) ?? [] : [])) + { + renderNode(n, byParent, treeEl); + } updateMeta(); applyFilter(); } @@ -403,7 +418,8 @@

测点监控

async function loadTree() { const resp = await fetch(TREE_URL, { cache: 'no-store' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); - renderTree(await resp.json()); + var tree = await resp.json(); + renderTree(tree); } function connect() { diff --git a/tests/AotSmokeTest.WebUi/Properties/launchSettings.json b/tests/AotSmokeTest.WebUi/Properties/launchSettings.json new file mode 100644 index 0000000..c6a44d7 --- /dev/null +++ b/tests/AotSmokeTest.WebUi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "AotSmokeTest.WebUi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:51975;http://localhost:51976" + } + } +} \ No newline at end of file diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/DesignerCatalogTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/DesignerCatalogTests.cs index 8d51073..ddf48aa 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/DesignerCatalogTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/DesignerCatalogTests.cs @@ -44,6 +44,11 @@ private static DesignerCatalogDto Build(params (string LogicalName, string Conte xmlns="tags:fake" targetNamespace="tags:fake" elementFormDefault="qualified"> + + + @@ -204,6 +209,25 @@ public void Catalog_WithoutAnyProvider_HasNoDrivers() Assert.Empty(catalog.Drivers); } + [Fact] + public void Catalog_ExposesDriverScopedAttributes() + { + var catalog = Build(); + + var cbnt = Node(catalog, "TagCbnt"); + var slave = Field(cbnt.Attributes, "slave"); + var area = Field(cbnt.Attributes, "area"); + + // 核心 schema 里只有 ModbusTcp / HjzkIoBox / ZLanTcp 会读这两个属性。 + Assert.Equal(new[] { "ModbusTcp", "HjzkIoBox", "ZLanTcp" }, slave.AppliesToDrivers!); + Assert.Equal(slave.AppliesToDrivers, area.AppliesToDrivers); + + // 通用属性不带作用域(任何驱动下都显示)。 + Assert.Null(Field(cbnt.Attributes, "name").AppliesToDrivers); + Assert.Null(Field(Node(catalog, "Tag").Attributes, "address").AppliesToDrivers); + Assert.Null(Field(Node(catalog, "TagGrp").Attributes, "isEntry").AppliesToDrivers); + } + [Fact] public void Catalog_BindsDriverFromSchemaAppInfo() { @@ -216,6 +240,46 @@ public void Catalog_BindsDriverFromSchemaAppInfo() Assert.Equal("fk", driver.Prefix); } + /// + /// 驱动额外认的 Tag/TagCbnt 属性(XSD 1.0 表达不了)来自 schema 的 + /// <tagAttribute> / <cbntAttribute> 声明:它们不混进节点的属性列表, + /// 而是单独给设计器用(按有效驱动 + 测点 type 过滤显示)。 + /// + [Fact] + public void Catalog_ParsesDriverDeclaredTagAndCbntAttributes() + { + var catalog = Build(("fake.xsd", FakeDriverSchema)); + var driver = Assert.Single(catalog.Drivers); + + // order 只影响排序;retries(10) 在 secret(20) 之前。 + Assert.Equal(new[] { "retries", "secret" }, driver.TagAttributes.Select(a => a.Name).ToArray()); + + var secret = driver.TagAttributes.Single(a => a.Name == "secret"); + Assert.Equal(DesignerFieldKind.String, secret.Kind); + Assert.Equal("密钥", secret.Label); + Assert.Equal("安全", secret.Group); + Assert.Equal(new[] { "STR", "JSON" }, secret.AppliesToTypes!); + Assert.False(secret.Required); + + // 没写 kind 时按 type 推断成数字。 + var retries = driver.TagAttributes.Single(a => a.Name == "retries"); + Assert.Equal(DesignerFieldKind.Number, retries.Kind); + Assert.Equal(1, retries.Min); + Assert.Equal(9, retries.Max); + Assert.True(retries.Required); + Assert.Null(retries.AppliesToTypes); + + var slot = Assert.Single(driver.CbntAttributes); + Assert.Equal("slot", slot.Name); + Assert.Equal(DesignerFieldKind.Number, slot.Kind); // 显式 kind + Assert.Equal("unsignedByte", slot.Type); + + // 这些属性不在核心 schema 里:节点定义不该被它们污染。 + var tag = Node(catalog, "Tag"); + Assert.DoesNotContain(tag.Attributes, a => a.Name == "secret" || a.Name == "retries"); + Assert.DoesNotContain(Node(catalog, "TagCbnt").Attributes, a => a.Name == "slot"); + } + [Fact] public void Catalog_DriverFieldsCarryTypeEnumAndRange() { diff --git a/tests/StdUnit.Tags.Tests/Core/Schema/DriverSchemaAnnotationsTests.cs b/tests/StdUnit.Tags.Tests/Core/Schema/DriverSchemaAnnotationsTests.cs index d47ccd1..de15af5 100644 --- a/tests/StdUnit.Tags.Tests/Core/Schema/DriverSchemaAnnotationsTests.cs +++ b/tests/StdUnit.Tags.Tests/Core/Schema/DriverSchemaAnnotationsTests.cs @@ -75,6 +75,34 @@ public void DriverSchema_CompilesAndValidatesAMinimalProject(string driverName, Assert.Empty(errors); } + /// + /// 驱动"额外认的测点属性"(<tagAttribute>)声明必须覆盖运行期真正从 + /// TagDescriptor.Extras 里读的那些键——这条断言就是为了防止声明与解析代码漂移。 + /// + [Theory] + [InlineData("S7", "maxlen")] + [InlineData("SimpleFiles", "autoCreateFile")] + [InlineData("ModbusTcp", "interpret")] + public void DriverSchema_DeclaresItsExtraTagAttributes(string driverName, string expectedAttribute) + { + var provider = Drivers.Select(d => (ITagsProjectSchemaProvider)d[2]).Single(p => + { + var content = p.GetSchemaContents().Single().Content; + return DeclaredDriverName(content) == driverName; + }); + + var root = XDocument.Parse(provider.GetSchemaContents().Single().Content).Root!; + var declared = root.Element(Xs + "annotation")? + .Element(Xs + "appinfo")? + .Elements() + .Where(e => e.Name.LocalName == "tagAttribute") + .Select(e => (string?)e.Attribute("name")) + .Where(n => n is not null) + .ToArray(); + + Assert.Contains(expectedAttribute, declared!); + } + [Fact] public void AllDriverSchemas_CompileTogetherWithTheCoreSchema() { From 48303d811fcd9c2c54e11a5bd5041494d9811da7 Mon Sep 17 00:00:00 2001 From: newbienewbie Date: Thu, 29 Oct 2026 22:24:51 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E4=B8=BAITagGroupRunner=E9=92=A9=E5=AD=90?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20ct=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这是破坏性更新。所有下游的钩子注册,都需要加上一个ct参数: runner.RunnerStarted += (grp, ch, ct) => { }; runner.TurnProcess += (grp, ch, ct) => { }; --- AGENTS.md | 1 + CHANGELOG.md | 50 ++ README.md | 2 + codecov.yml | 4 + ...02\345\270\270\345\244\204\347\220\206.md" | 2 +- ...70\345\277\203\346\250\241\345\236\213.md" | 2 +- ...26\344\270\216\350\276\271\347\225\214.md" | 6 +- .../NixMonitor/Tags/TagBackgroundService.cs | 4 +- .../WebMonitor/Tags/TagBackgroundService.cs | 8 +- samples/WpfDemo/App.xaml.cs | 4 +- src/StdUnit.Tags.Core/Plugins/ILogicet.cs | 2 +- .../Projects/ITagsProject.cs | 2 + .../TagGrpRunner/ITagGrpRunner.cs | 29 +- src/StdUnit.Tags.WebUi/Sse/SseFramesPiper.cs | 26 +- .../TagGrpRunners/TagGrpRunner.cs | 68 +-- .../Fakes/FakedChannel.cs | 25 + .../Fakes/FakedChannelFactory.cs | 28 ++ .../Fakes/FakedTag.cs | 35 ++ .../Fakes/FakedTagCbnt.cs | 81 ++++ .../Fakes/TagsProject_Extensions.cs | 36 ++ .../StdUnit.Tags.Tests.NetCoreOnly.csproj | 8 - .../WebUi/EndpointTests.cs | 453 ++++++++++++++++++ .../WebUi/RecordingStream.cs | 79 +++ .../WebUi/StreamTestContext.cs | 27 ++ .../WebUi/TagsStreamCommandsTests.cs | 159 ++++++ .../WebUi/TagsStreamHubEdgeCaseTests.cs | 320 +++++++++++++ .../WebUi/TagsStreamHubTests.cs | 95 +--- .../Projects/TagsProjectTurnProcessTests.cs | 2 +- .../Core/TagGrpRunners/TagGrpRunnerTests.cs | 80 +++- .../StdUnit.Tags.Tests/Fakes/FakedTagCbnt.cs | 78 +++ .../Fakes/TagsProject_Extensions.cs | 2 + 31 files changed, 1580 insertions(+), 138 deletions(-) create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedChannel.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedChannelFactory.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedTag.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedTagCbnt.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/TagsProject_Extensions.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/RecordingStream.cs create mode 100644 tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubEdgeCaseTests.cs create mode 100644 tests/StdUnit.Tags.Tests/Fakes/FakedTagCbnt.cs diff --git a/AGENTS.md b/AGENTS.md index f3b0f1c..048bdda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,7 @@ StdUnit.Tags.Core 硬件无关的核心抽象 + Schemas/tagsproje - **时间戳一律 UTC**:`ITag.Timestamp` 用 `DateTime.UtcNow` 写入,**不要用 `DateTime.Now`**(本地时间跨时区/夏令时不可比);展示或与本地时间比较时由调用方自行转换。 - **启停语义**:`ITagsProjectCtrl.StopAsync()` 会先**等轮询循环退出**(有界超时,默认 30s)再释放项目、断开通道、触发"已停止"事件;`StartPollAsync` 的 `finally` 是项目的**唯一常规释放点**(只有等待超时那一刻才由 `StopAsync` 兜底释放,因此"只释放一次"是硬约束)。改启停或清理路径时必须保持这个顺序,详见 `docs/设计决策与边界/`。 +- **取消不是崩溃**:`StopAsync` 引起的取消不触发 `RunnerCrashed`、不计入连续失败、不按重试策略退避(`TagGrpRunner` 用 `catch (OperationCanceledException) when (ct.IsCancellationRequested)` 单独处理),但通道照常断开、`StartAsync` 仍以 OCE 结束。判据是"令牌确实已取消":驱动因自身原因(如设备超时)抛的 OCE 仍是崩溃。`RunnerStarted` / `RunnerCrashed` / `TurnProcess` 拿到的都是同一个生命周期令牌;**清理类异步操作一律用 `CancellationToken.None`**(见 `DisconnectAllAsync`)。 - 公共编译设置(`ImplicitUsings` + `Nullable` + `LangVersion latest` + XML 文档文件)在 `src/Directory.Build.props`(与 `tests/Directory.Build.props`,后者不含包元数据),NuGet 包元数据(作者/授权/仓库/项目主页/标签/README)只收口在 `src/Directory.Build.props`;**新增会被发布的包时,务必在自己 csproj 里补一行 ``**(否则 nuget.org 上只会显示 SDK 占位文本 `Package Description`),并把项目名加进 `src/publish-packages.ps1` 的列表。 - **版本号不要写进 workflow**:dev 通道(CI 的迭代产物 / 本地预演)用 `src/publish-packages.ps1 -DevBuildNumber <构建号>`,基础版本由脚本读仓库根的 `next-release-version.txt` 拼成 `<基础版本>-dev.<构建号>`;正式发布仍用 `-packageVersion <语义化版本>`。正式版发布后要改的是那个文件。README **不写**发版细节(它会被打进每个 nupkg),版本通道与发布流程都在 `docs/设计决策与边界/3. 打包与发布.md`。 - **测试代码必须跨平台**(CI 只跑 Linux),两类路径都要当心: diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f6bcf..c04c049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ 本文件记录**用户可见**的变化。版本号遵循[语义化版本](https://semver.org/lang/zh-CN/) `..`。 +## 2.0.0-beta5(尚未发布) + +> 本批仍是 `2.0.0` 的预发布——`2.0.0` 正式版保持未发布。 + +主题:**取消令牌贯穿全部运行期回调**(并把"取消"从"崩溃"里摘出来),以及**新包 `StdUnit.Tags.WebUi`**(SSE 值流 + 结构树 + 设计器)。 + +### 破坏性变更 + +- **`TurnProcess` 增加取消令牌**:签名由 `Task TurnProcess(ITagGrp grp, ITagChannel? channel)` + 变为 `Task TurnProcess(ITagGrp grp, ITagChannel? channel, CancellationToken ct)`(与 beta4 的 `ILogicet.ProcessAsync` 一致)。 + - 订阅 `ITagGrpRunner.TurnProcess` 的 lambda / 方法组要补参数;二进制层面同样是破坏性的,**旧版本编译出的程序集需要重编**。 + - 这里新增的`ct` 代表**项目生命周期令牌**(`ITagsProjectCtrl.StopAsync()` 时取消);回调里发起的异步操作应把它透传下去。 +- **`ITagsProject` 新增成员 `SetTurnProcessHandler(string entry, TurnProcess? handler)`**: + 这是**公开接口新增成员**——自定义实现 `ITagsProject` 的代码(多为测试替身)必须补上实现;只调库、不实现该接口的代码不受影响。 +- **`RunnerStarted` / `RunnerCrashed` 增加取消令牌**: + - `RunnerStarted`:`(ITagGrp grp, ITagChannel? channel)` → `(ITagGrp grp, ITagChannel? channel, CancellationToken ct)`; + - `RunnerCrashed`:`(ITagGrp grp, ITagChannel? channel, Exception ex)` → `(ITagGrp grp, ITagChannel? channel, Exception ex, CancellationToken ct)`。 + - 订阅方的 lambda / 方法组需要补参数;用旧版本编译出的订阅代码需要重编。**`ct` 同样是项目生命周期令牌**。 + - `RunnerCrashed` 的 `ct` 进入时通常是**未取消**的(取消不再是崩溃,见下);但回调执行期间项目可能被停止, + 令牌随时可能取消——"必须完成"的清理/上报请改用 `CancellationToken.None`。 +- 示例(`samples/NixMonitor`、`samples/WebMonitor`、`samples/WpfDemo`)已按新签名更新。 + +### 行为变更 + +- **取消不再被当作崩溃**:项目停止引起的取消不再触发 `RunnerCrashed`、不计入连续失败、也不按重试策略退避 + (该入口子树里的全部通道仍会被断开,`StartAsync` 仍以 `OperationCanceledException` 结束)。 + 此前每次正常停机都会上报一条 `TaskCanceledException` 崩溃,在监控里表现为"停机即故障"。 + - 判据是**令牌确实已取消**而不是只看异常类型:驱动因自身原因(如设备超时)抛的 `OperationCanceledException` 仍是崩溃。 + - 同理,回调(`ILogicet.ProcessAsync` / `TurnProcess`)在**令牌未取消**时抛 `OperationCanceledException` 依旧是失败; + 停机取消时才不再计入失败。 +- 取消时不再空等退避延迟(`ITagGrpRunnerRetryStrategy` 仍按既有契约每轮被询问一次,只是不再真的等待)。 +- `SetTurnProcessHandler` **运行前、运行中都可以设置/替换/清除**,运行期改动从下一轮开始生效(回调表写时复制, + 轮询线程零锁)。每个入口只有一个回调,重复设置是替换——需要多个观察者请自行组合成一个 `TurnProcess`。 + +### 新增 + +- **新包 `StdUnit.Tags.WebUi`**(仅 `net8.0`,ASP.NET Core;不依赖 Blazor / SignalR,可在 NativeAOT 宿主下运行): + - 监控:结构树走一次性 `GET /stdunit.tags/tree`,值按"入口 + 轮次"以 SSE(`GET /stdunit.tags/stream`)推送,内置极简监控页 `GET /stdunit.tags/ui`。 + - 设计器:把核心 + 各驱动 schema 内省成元数据(`GET /stdunit.tags/catalog`),设计器页 `GET /stdunit.tags/designer` 据此渲染表单、 + 导入既有项目 XML 并导出(未知驱动/元素/属性原样保留)。 + - 写命令:`POST /stdunit.tags/value`(改测点值)、`POST /stdunit.tags/rescan`(标记未扫描)。 + - 注册:`services.AddTagsStream()` / `app.MapTagsStream()`;多项目场景用 `AddKeyedTagsStream(key)` 注册具名流(路径形如 `/stdunit.tags/{key}/stream`)。 + 只允许改**路由前缀**(`app.MapTagsStream("/internal/tags")`),相对路径是线协议的一部分。 + - **不内置任何鉴权**,暴露到非受信网络前请自行加策略(如 `app.MapTagsStream().RequireAuthorization()`)。 +- **`AddKeyedTagsProjectCtrl(key)`**(`StdUnit.Tags`,`IServiceCollection` 与 `TagsProjectServiceBuilder` 上各一个): + 以指定 key 注册 `ITagsProjectCtrl`;配合 `AddKeyedTagsStream(key)` 可以在一个宿主里同时监控多个项目。 +- **驱动 XSD 增加设计器元数据**:核心与各驱动 schema 用 `xs:annotation/xs:appinfo` 声明 ``, + 以及驱动自有属性(`` / ``)。**只供设计器/内省使用,不参与校验**——现有 XML 的校验行为不变。 +- 新示例 `samples/WebMonitor`(Web 监控 + 设计器演示)与 AOT 冒烟测试 `tests/AotSmokeTest.WebUi`(CI 的 `aot-smoke` job 覆盖)。 + ## 2.0.0-beta4 > 本批仍是 `2.0.0` 的预发布——`2.0.0` 正式版保持未发布。 diff --git a/README.md b/README.md index 1e3e995..ee61f48 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ | StdUnit.Tags.RxExtensions | Rx.NET 扩展 | [![codecov](https://codecov.io/gh/StdUnit/StdUnit.Tags/graph/badge.svg?component=stdunit_tags_rx )](https://app.codecov.io/gh/StdUnit/StdUnit.Tags/components?components%5B0%5D=stdunit_tags_rx ) | | StdUnit.Tags.R3Extensions | R3 扩展 | [![codecov](https://codecov.io/gh/StdUnit/StdUnit.Tags/graph/badge.svg?component=stdunit_tags_r3 )](https://app.codecov.io/gh/StdUnit/StdUnit.Tags/components?components%5B0%5D=stdunit_tags_r3 ) | | StdUnit.Tags.McpServer | McpServer 扩展 | [![codecov](https://codecov.io/gh/StdUnit/StdUnit.Tags/graph/badge.svg?component=stdunit_tags_mcpserver )](https://app.codecov.io/gh/StdUnit/StdUnit.Tags/components?components%5B0%5D=stdunit_tags_mcpserver ) | +| StdUnit.Tags.WebUi | Web监控和设计器 | [![codecov](https://codecov.io/gh/StdUnit/StdUnit.Tags/graph/badge.svg?component=stdunit_tags_webui)](https://app.codecov.io/gh/StdUnit/StdUnit.Tags/components?components%5B0%5D=stdunit_tags_webui ) | + diff --git a/codecov.yml b/codecov.yml index b75d6fe..4969d05 100644 --- a/codecov.yml +++ b/codecov.yml @@ -70,6 +70,10 @@ component_management: name: "SimpleFiles" paths: - "src/StdUnit.Tags.SimpleFiles/**" + - component_id: stdunit_tags_webui + name: "WebUI" + paths: + - "src/StdUnit.Tags.WebUI/**" ignore: - "**/*.md" diff --git "a/docs/\345\274\202\345\270\270\345\244\204\347\220\206.md" "b/docs/\345\274\202\345\270\270\345\244\204\347\220\206.md" index 652c7de..b9303d9 100644 --- "a/docs/\345\274\202\345\270\270\345\244\204\347\220\206.md" +++ "b/docs/\345\274\202\345\270\270\345\244\204\347\220\206.md" @@ -61,7 +61,7 @@ catch (TagsProjectLoadException ex) #### 运行期故障消息必须能定位现场 -运行期异常最终由 `TagGrpRunner` 捕获,并交给重试策略与 `RunnerCrashed(entry, channel, ex)`。注意事件里的 `channel` 只是入口的**主通道**(`SearchChannel()`)——一个入口可以有多个通道(如 S7 + 扫码枪),所以**异常消息本身**必须能指认是哪个通道、在做什么: +运行期异常最终由 `TagGrpRunner` 捕获,并交给重试策略与 `RunnerCrashed(entry, channel, ex, ct)`。**取消不是崩溃**:项目停止(`StopAsync`)导致的取消不会触发该事件,也不计入连续失败、不按重试策略退避(连接仍会断开)——所以进到回调里基本就等价于"真故障";`ct` 是项目生命周期令牌,回调执行期间可能被取消,做清理/上报时按"尽力完成"处理(必要时用 `CancellationToken.None`)。注意事件里的 `channel` 只是入口的**主通道**(`SearchChannel()`)——一个入口可以有多个通道(如 S7 + 扫码枪),所以**异常消息本身**必须能指认是哪个通道、在做什么: 1. **带通道名**:`通道({ChannelName()})`。多通道入口里没有它就无法定位,不要写成 `"会话未连接"` 这种只有状态、没有主体的消息。 2. **带操作与地址/端点**:`读取DB失败:DB1.100,长度=3`、`连接PLC失败:10.0.0.5 Rack=0 Slot=1`。 diff --git "a/docs/\346\240\270\345\277\203\346\250\241\345\236\213.md" "b/docs/\346\240\270\345\277\203\346\250\241\345\236\213.md" index 5da2784..b647e74 100644 --- "a/docs/\346\240\270\345\277\203\346\250\241\345\236\213.md" +++ "b/docs/\346\240\270\345\277\203\346\250\241\345\236\213.md" @@ -65,4 +65,4 @@ 两条基本原则: - **加载期异常**:按类型构成一棵异常类型树,方便开发者诊断,并且有意设计成在加载期就拒绝(快速失败)。 -- **运行期异常**:一轮里抛出的任何异常都会通知 `RunnerCrashed`(可观测),随后断开该入口子树里的全部通道,再按用户注册的重试策略退避后重试——入口既不会疯狂重连,也不会静默冻结。这也决定了驱动层的一条要求:**设备报的读取失败必须抛出去**,不要"跳过 + 打日志 + 保留上次值"——详见 [异常处理](./异常处理.md)。 +- **运行期异常**:一轮里抛出的任何异常都会通知 `RunnerCrashed`(可观测),随后断开该入口子树里的全部通道,再按用户注册的重试策略退避后重试——入口既不会疯狂重连,也不会静默冻结。**取消不算崩溃**:`StopAsync` 引起的取消不触发 `RunnerCrashed`、不计入连续失败(连接仍会断开)。这也决定了驱动层的一条要求:**设备报的读取失败必须抛出去**,不要"跳过 + 打日志 + 保留上次值"——详见 [异常处理](./异常处理.md)。 diff --git "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" index 6a07898..4b4bec8 100644 --- "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" +++ "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" @@ -48,8 +48,9 @@ API 细节见教程站点()。 - `SetTurnProcessHandler` **运行前、运行中都可以设置**:入口列表在 `Initialize` 之后固定,设置只是替换该入口的回调;运行期设置/清除**从下一轮开始生效**(不中断正在执行的那一轮),所以"观测端比轮询晚启动"(如 WebUi 的 hub 后启动)也能补挂上。设置无需清除:回调的作用域就是项目实例,项目停止即随之释放(项目已释放后调用抛 `ObjectDisposedException`)。但**每个入口只有一个回调**——要多个观察者就自己组装成一个 `TurnProcess`(见上),不要用反复设置/清除做"动态开关",那会与其它观察者互相覆盖;要动态开关就在自己的回调内部判断(一次集合查找)。 - 历史:该 API 早期要求"只能在运行前设置"(与 `IntentCapacity` 同构)。那条约束只服务于"回调表运行期只读、轮询线程零同步"的实现便利,代价却是**晚挂观测静默降级**(结构树在、值流不在,只有一条 Warning 且不自愈),而顺序由调用方决定、库无法保证,因此改为写时复制的回调表(读路径仍是普通字典读、零锁)。 - 启停用 `ObserveStartedOrStopped()` / `RunnerStarted`;注意 `SetTurnProcessHandler` **只覆盖"产生了轮次"**的情形,入口被禁用、项目未启动/已停止时不产生轮次,也就不会触发。 +- **取消不是崩溃**:`StopAsync` 导致的取消不会触发 `RunnerCrashed`,也不计入连续失败、不按重试策略退避(仍会断开该入口的全部通道)。因此进到 `RunnerCrashed` 基本等价于"真故障",可放心在那里做告警/计数。 - 事件/回调都在**轮询线程**上同步触发,拿到的已经是本轮最终结果(值 + 时间戳),不需要、也不应该再去读字段做同步。 -- **不同入口的回调并行**(各入口由各自的运行器驱动);回调必须尽快返回(要干重活就只投递到自己的队列,由独立循环消费)。回调抛出的异常不会被吞掉——等价于该入口本轮失败,会进入 `RunnerCrashed` 的恢复路径,观察者请自行 try/catch。 +- **不同入口的回调并行**(各入口由各自的运行器驱动);回调必须尽快返回(要干重活就只投递到自己的队列,由独立循环消费)。回调抛出的异常不会被吞掉——等价于该入口本轮失败,会进入 `RunnerCrashed` 的恢复路径(例外:停机取消导致的 `OperationCanceledException` 不算失败),观察者请自行 try/catch。 - 直接读 `ITag.Value` 只适用于"同一轮询线程内"或"接受最终一致性"的场景(例如 `Watch(startWithCurrent: true)` 拿首帧)。**界面不要轮询字段**——那是事件该干的事。 ### 时间戳:一律 UTC @@ -66,7 +67,8 @@ API 细节见教程站点()。 ### 线程模型与伸缩 - 循环内的回调(`ILogicet.ProcessAsync`、`TagGrpWriteIntent`)**必须尽快返回**:不要 `.Result` / `.Wait()` / `Thread.Sleep`,也不要写成"等待外部事件"的形态,否则与 `WriteIntent` 构成环路等待死锁(见[外部意图](../外部意图.md))。 -- 这两个回调拿到的 `ct` 是**项目生命周期令牌**(`StopAsync` 时取消),**不是**单轮超时。停机时想尽快收手就用 `if (ct.IsCancellationRequested) return;`;**不要**抛 `OperationCanceledException`——轮询器会把它当成一次崩溃(触发 `RunnerCrashed` 并按策略断开重连)。也不能借它把长任务搬进回调。 +- 这两个回调拿到的 `ct` 是**项目生命周期令牌**(`StopAsync` 时取消),**不是**单轮超时。停机时想尽快收手就用 `if (ct.IsCancellationRequested) return;`;**不要**抛 `OperationCanceledException`——令牌没取消时抛它仍会被当成一次崩溃(触发 `RunnerCrashed` 并按策略断开重连)。也不能借它把长任务搬进回调。 +- `RunnerStarted` / `RunnerCrashed` 拿到的也是同一个生命周期令牌。`RunnerCrashed` 只在真故障时触发(取消不算崩溃),但回调执行期间项目可能被停止、令牌随时可能取消;要在崩溃后做"必须完成"的清理/上报,请按 `DisconnectAllAsync` 的口径用 `CancellationToken.None`。 - 底层是同步实现的驱动(S7 / ComScanner)会实际占住线程;入口数量上到几十个时请评估线程池与设备吞吐。当前**不设并行度上限,也没有调度器扩展点**。 不过这两点并不是什么实际问题。在工厂,有3种使用场景: diff --git a/samples/NixMonitor/Tags/TagBackgroundService.cs b/samples/NixMonitor/Tags/TagBackgroundService.cs index 7defa08..3070d2c 100644 --- a/samples/NixMonitor/Tags/TagBackgroundService.cs +++ b/samples/NixMonitor/Tags/TagBackgroundService.cs @@ -27,12 +27,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) }); await _ctrl.StartPollAsync(Path.Combine(dir, "Tags"), null, (proj, sp, ct) => { - proj.RunnerStarted += (grp, ch) => + proj.RunnerStarted += (grp, ch, ct) => { _logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName()); return Task.CompletedTask; }; - proj.RunnerCrashed += (grp, ch, ex) => + proj.RunnerCrashed += (grp, ch, ex, ct) => { _logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName()); return Task.CompletedTask; diff --git a/samples/WebMonitor/Tags/TagBackgroundService.cs b/samples/WebMonitor/Tags/TagBackgroundService.cs index c2b599c..e8586eb 100644 --- a/samples/WebMonitor/Tags/TagBackgroundService.cs +++ b/samples/WebMonitor/Tags/TagBackgroundService.cs @@ -72,12 +72,12 @@ private static XElement LoadProjectXml(string dir) } private Task Hook1(ITagsProject project, IServiceProvider services, CancellationToken ct) { - project.RunnerStarted += (grp, ch) => + project.RunnerStarted += (grp, ch, ct) => { this._logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName()); return Task.CompletedTask; }; - project.RunnerCrashed += (grp, ch, ex) => + project.RunnerCrashed += (grp, ch, ex, ct) => { this._logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName()); return Task.CompletedTask; @@ -92,12 +92,12 @@ private Task Hook1(ITagsProject project, IServiceProvider services, Cancellation private Task Hook2(ITagsProject project, IServiceProvider services, CancellationToken ct) { - project.RunnerStarted += (grp, ch) => + project.RunnerStarted += (grp, ch, ct) => { this._logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName()); return Task.CompletedTask; }; - project.RunnerCrashed += (grp, ch, ex) => + project.RunnerCrashed += (grp, ch, ex, ct) => { this._logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName()); return Task.CompletedTask; diff --git a/samples/WpfDemo/App.xaml.cs b/samples/WpfDemo/App.xaml.cs index 3b6c72c..d93694f 100644 --- a/samples/WpfDemo/App.xaml.cs +++ b/samples/WpfDemo/App.xaml.cs @@ -47,12 +47,12 @@ await ctrl.StartPollAsync(Path.Combine(dir, "Tags"), null, (proj, sp, ct) => loggerFactory.CreateLogger() )); - proj.RunnerStarted += (grp, ch) => + proj.RunnerStarted += (grp, ch, ct) => { logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName()); return Task.CompletedTask; }; - proj.RunnerCrashed += (grp, ch, ex) => + proj.RunnerCrashed += (grp, ch, ex, ct) => { logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName()); MessageBox.Show($"Tags处理发生异常:{ex.Message}"); diff --git a/src/StdUnit.Tags.Core/Plugins/ILogicet.cs b/src/StdUnit.Tags.Core/Plugins/ILogicet.cs index 9e1f526..eca11f1 100644 --- a/src/StdUnit.Tags.Core/Plugins/ILogicet.cs +++ b/src/StdUnit.Tags.Core/Plugins/ILogicet.cs @@ -38,7 +38,7 @@ public interface ILogicet /// 项目的生命周期令牌:项目停止()时取消。
/// 它不是"单轮超时"令牌——框架不会为某一轮单独取消,同一入口每一轮拿到的都是同一个令牌。
/// 建议用 if (ct.IsCancellationRequested) return; 提前结束本轮;不要在这里抛 - /// ,它会被运行器当成一次失败(触发 并按策略断开重连)。
+ /// ,令牌没取消时抛它会被运行器当成一次失败(触发 并按策略断开重连)。
/// 不要缓存它、也不要每轮 Register;它也不能让本方法变成"可以慢慢跑"——本方法仍必须尽快返回。 /// /// diff --git a/src/StdUnit.Tags.Core/Projects/ITagsProject.cs b/src/StdUnit.Tags.Core/Projects/ITagsProject.cs index 8646660..454a32b 100644 --- a/src/StdUnit.Tags.Core/Projects/ITagsProject.cs +++ b/src/StdUnit.Tags.Core/Projects/ITagsProject.cs @@ -56,11 +56,13 @@ public interface ITagsProject : IDisposable /// /// 轮询开始,会透传给。
+ /// 回调拿到的是项目的生命周期取消令牌( 时取消)。 ///
event RunnerStarted? RunnerStarted; /// /// 轮询崩溃,会透传给。
+ /// 取消不是崩溃:项目停止导致的取消不会触发本事件。
/// 通常来说,在该委托正常结束后,项目会自行重启轮询循环。
/// 但需要注意的是,这个自行重启是指事件委托没有再向外抛出异常的情况:
/// 在中外抛异常会被视作对应入口需要停机! diff --git a/src/StdUnit.Tags.Core/TagGrpRunner/ITagGrpRunner.cs b/src/StdUnit.Tags.Core/TagGrpRunner/ITagGrpRunner.cs index 96ea5a3..5a244c1 100644 --- a/src/StdUnit.Tags.Core/TagGrpRunner/ITagGrpRunner.cs +++ b/src/StdUnit.Tags.Core/TagGrpRunner/ITagGrpRunner.cs @@ -27,20 +27,34 @@ namespace StdUnit.Tags; /// /// 入口的主通道( 的解析结果,可能为 null)。 /// +/// +/// 项目的生命周期取消令牌(与本轮读/写、以及 用的是同一个)。
+/// 回调里发起的异步操作应把它透传下去,这样"停止项目"能及时打断, +/// 不至于拖到 的等待超时。 +/// /// -public delegate Task RunnerStarted(ITagGrp grp, ITagChannel? channel); +public delegate Task RunnerStarted(ITagGrp grp, ITagChannel? channel, CancellationToken ct); /// -/// 中有错误出现 +/// 中有错误出现。
+/// 取消不是崩溃:项目停止()导致的取消不会触发本事件, +/// 因此回调被调用时通常意味着真实故障(设备/协议错误、逻辑组件抛异常等)。 ///
/// 入口测点组 /// /// 入口的主通道( 的解析结果,可能为 null); /// 清理路径断开的是入口相关的全部通道,不只是它。 /// -/// +/// 本轮导致故障的异常(通常是驱动或逻辑组件抛出的原始异常,未被包装) +/// +/// 项目的生命周期取消令牌。
+/// 进入回调时它通常尚未取消;但回调执行期间项目可能被停止,它随时可能被取消, +/// 所以回调里等待的异步操作要么接受被取消,要么按"清理动作尽力完成"的原则改用 +/// 。回调本身必须尽快返回,也不要向外抛异常 +/// (抛异常会被视作该入口需要停机)。 +/// /// -public delegate Task RunnerCrashed(ITagGrp grp, ITagChannel? channel, Exception ex); +public delegate Task RunnerCrashed(ITagGrp grp, ITagChannel? channel, Exception ex, CancellationToken ct); /// @@ -73,6 +87,7 @@ public interface ITagGrpRunner /// /// 崩溃事件。
+ /// 取消不是崩溃:项目停止导致的取消不会触发本事件。
/// 注意这里不要再有异常发生,否则会视作对应的入口轮询需要停机 ///
event RunnerCrashed? RunnerCrashed; @@ -84,10 +99,12 @@ public interface ITagGrpRunner /// /// 启动对群组的监控: loop(意图执行-> 读取输入 -> 逻辑处理 -> 刷写输出)
- /// 一旦成功启动轮询,除非主动取消,否则不会抛出异常。 + /// 一旦成功启动轮询,除非主动取消,否则不会抛出异常。
+ /// 取消不是崩溃: 取消时本方法以 结束 + /// (入口子树里的通道照常断开),但不会触发 。 ///
/// 入口;其子树中的通道由本运行器建立与断开连接 - /// + /// 项目生命周期取消令牌;取消即结束本方法 /// Task StartAsync(ITagGrp entry, CancellationToken ct); } \ No newline at end of file diff --git a/src/StdUnit.Tags.WebUi/Sse/SseFramesPiper.cs b/src/StdUnit.Tags.WebUi/Sse/SseFramesPiper.cs index 6fe4a00..f0379eb 100644 --- a/src/StdUnit.Tags.WebUi/Sse/SseFramesPiper.cs +++ b/src/StdUnit.Tags.WebUi/Sse/SseFramesPiper.cs @@ -9,6 +9,12 @@ namespace StdUnit.Tags.WebUi.Internal; ///
internal sealed class SseFramesPiper : IDisposable { + /// + /// 队列里的"唤醒信号"占位帧(真实帧永远非空)。 只置标志不足以唤醒 + /// 已阻塞在 上的写循环,所以顺手塞一个空帧。 + /// + private static readonly ReadOnlyMemory WakeUpSignal = ReadOnlyMemory.Empty; + private readonly ITagsStreamHub _hub; private readonly Channel> _queue; private readonly IReadOnlySet? _entries; @@ -39,7 +45,19 @@ public void Enqueue(ReadOnlyMemory frame) } } - public void MarkResync() => Interlocked.Exchange(ref this._needsResync, 1); + /// + /// 标记需要重同步,并唤醒可能正阻塞在 上的写循环。
+ /// 只置标志是不够的:写循环"刚读完队列里的最后一帧"与"标志置位"之间总有窗口,错过这一次唤醒, + /// 重同步就要等到下一次心跳/增量才会被处理(订阅者表现为迟迟收不到基线)。 + ///
+ public void MarkResync() + { + if (Interlocked.Exchange(ref this._needsResync, 1) == 0) + { + // 队列满时 TryWrite 失败没关系:那说明写循环并不空闲,它回到循环顶部仍会看到标志。 + this._queue.Writer.TryWrite(WakeUpSignal); + } + } /// /// 写循环:把队列里的帧写进响应体。
@@ -67,6 +85,12 @@ public async Task PipeFramesToStreamAsync(Stream body, CancellationToken ct) while (this._queue.Reader.TryRead(out var frame)) { + if (frame.IsEmpty) + { + // 唤醒信号:真正的工作在循环顶部的标志位上做。 + continue; + } + await WriteFrameAsync(body, frame, ct).ConfigureAwait(false); } } diff --git a/src/StdUnit.Tags/TagGrpRunners/TagGrpRunner.cs b/src/StdUnit.Tags/TagGrpRunners/TagGrpRunner.cs index 777f766..2898da0 100644 --- a/src/StdUnit.Tags/TagGrpRunners/TagGrpRunner.cs +++ b/src/StdUnit.Tags/TagGrpRunners/TagGrpRunner.cs @@ -55,8 +55,9 @@ public virtual async Task StartAsync(ITagGrp entry, CancellationToken ct) // 本次启动需要确保连接的全部通道(含主通道,主通道排在最前)。 // 用空集合初始化,保证即使“解析通道”阶段抛异常,清理路径也不会空引用。 IReadOnlyList channels = Array.Empty(); - // 本次启动是否失败?如果入口被禁用,不会被视为失败,即禁用会复位失败计数器 - var failed = false; + // 本轮是否"异常收场"(崩溃或被取消)?两者都要断开连接,但只有崩溃算失败。 + // 为 false 表示本轮正常走完(含"入口被禁用"),会复位失败计数器。 + var aborted = false; try { if (!entry.IsEnabled) @@ -69,7 +70,7 @@ public virtual async Task StartAsync(ITagGrp entry, CancellationToken ct) channels = entry.CollectChannels(); if (RunnerStarted is not null) { - await RunnerStarted(entry, channel); + await RunnerStarted(entry, channel, ct); } // 开始轮询 @@ -113,36 +114,43 @@ public virtual async Task StartAsync(ITagGrp entry, CancellationToken ct) } } } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // 取消不是崩溃:不发 RunnerCrashed、不计入连续失败、也不按重试策略退避。 + // 判据是"令牌确实已取消"而不是只看异常类型——驱动因自身原因抛的 OCE(如设备超时)仍是崩溃。 + // 连接照样要断开(清理在 finally 里统一做,与崩溃路径同构)。 + aborted = true; + } catch (Exception ex) { - failed = true; + aborted = true; _consecutiveFailures++; - try + if (RunnerCrashed is not null) { - if (RunnerCrashed is not null) + try { - try - { - await RunnerCrashed(entry, channel, ex); - } - catch (Exception handlingError) - { - this._logger.LogCritical( - handlingError, - "测点分组(分组={grp},通道={channel})错误处理又抛出了错误,这破坏了错误处理不能再抛出异常的假设", - entry.TagName(), - channel?.ChannelName() ?? "null"); - throw; - } + await RunnerCrashed(entry, channel, ex, ct); + } + catch (Exception handlingError) + { + this._logger.LogCritical( + handlingError, + "测点分组(分组={grp},通道={channel})错误处理又抛出了错误,这破坏了错误处理不能再抛出异常的假设", + entry.TagName(), + channel?.ChannelName() ?? "null"); + throw; } } - finally + } + finally + { + if (aborted) { try { // 注意:这里必须使用 CancellationToken.None 而非 ct。 - // 本 catch 块的触发原因可能是 轮询循环中的 Task.Delay(ct) 抛 OCE。 + // 本路径的触发原因可能是 轮询循环中的 Task.Delay(ct) 抛 OCE。 // 若沿用已取消的 ct,通道内部(如 S7 的 ExecuteOneByOneAsync 中 _rw.WaitAsync(ct))会立即抛 // OperationCanceledException,导致 DisconnectAsync 根本没执行——连接资源泄漏, // 下次 StartAsync 时旧连接可能仍处于异常状态。 @@ -169,21 +177,27 @@ public virtual async Task StartAsync(ITagGrp entry, CancellationToken ct) entry.TagName()); } } - } - finally - { - // 成功迭代则重置连续失败计数器;失败则使用策略等待 - if (!failed) + else { + // 成功迭代则重置连续失败计数器 _consecutiveFailures = 0; } + // 退避值仍按既有契约每轮取一次(策略的调用次数是可观测行为), + // 但已取消就不再真的等待:那只会被立刻打断,还多产出一条 OCE。 var delay = _consecutiveFailures > 0 ? _retryStrategy.GetDelay(_consecutiveFailures) : TimeSpan.FromMilliseconds(entry.SearchScanInterval() ?? 0); - await Task.Delay(delay, ct); + if (!ct.IsCancellationRequested) + { + await Task.Delay(delay, ct); + } } } + + // 取消不是崩溃,但对外仍表现为"任务被取消":停止后 StartAsync 返回的 Task 处于 Canceled 状态 + // 是既有契约(TagsProjectCtrl.StartPollAsync 与各示例据此区分"被停止"),不能悄悄降级成正常返回。 + ct.ThrowIfCancellationRequested(); } /// diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedChannel.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedChannel.cs new file mode 100644 index 0000000..0a5216c --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedChannel.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace StdUnit.Tags.Tests.Fakes; + +// 本文件是主测试项目 Fakes/FakedChannel.cs 的副本: +// 本项目所需的夹具都在本项目下自带一份,不引用主测试项目的源码(见 csproj 顶部注释)。 + +internal class FakedChannel : ITagChannel +{ + public FakedChannel(TagChannelDescriptor descriptor) + { + Descriptor = descriptor; + } + public TagChannelDescriptor Descriptor { get; } + + public Task DisconnectAsync(CancellationToken ct) => Task.CompletedTask; + + public void Dispose() { } + + public Task EnsureConnectedAsync(bool force, CancellationToken ct) + { + return Task.CompletedTask; + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedChannelFactory.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedChannelFactory.cs new file mode 100644 index 0000000..c49bd2b --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedChannelFactory.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace StdUnit.Tags.Tests.Fakes; + +// 本文件是主测试项目 Fakes/FakedChannelFactory.cs 的副本: +// 本项目所需的夹具都在本项目下自带一份,不引用主测试项目的源码(见 csproj 顶部注释)。 + +internal class FakedChannelFactory : ITagChannelFactory +{ + public FakedChannelFactory() + { + } + + + public ITagChannel Create(TagChannelDescriptor chDescriptor) + { + return new FakedChannel(chDescriptor); + } + + + private static IReadOnlyList _drivers = new List() { "fake" }; + + + /// + /// + /// + public IReadOnlyList GetAvailableDrivers() => _drivers; +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedTag.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedTag.cs new file mode 100644 index 0000000..ed64344 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedTag.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace StdUnit.Tags.Tests.Fakes; + +// 本文件是主测试项目 Fakes/FakedTag.cs 的副本: +// 本项目所需的夹具都在本项目下自带一份,不引用主测试项目的源码(见 csproj 顶部注释)。 + +/// +/// 纯内存测点,不关联任何物理设备,仅用于测试。 +/// +internal class FakedTag : Tag +{ + public FakedTag(TagDescriptor descriptor, FakedChannel? channel, ITagGrp parent) + : base(descriptor, channel, TagContainer.From(parent)) + { + } + + public override Task ReadAsync(CancellationToken ct) => Task.CompletedTask; + public override Task WriteAsync(CancellationToken ct) { IsDirty = false; return Task.CompletedTask; } +} + +/// +/// 构建 实例。 +/// +internal class FakedTagBuilder : TagBuilderBase +{ + public FakedTagBuilder(TagDescriptor descriptor) => TagDescriptor = descriptor; + + protected override ITag Fallback(ITagChannel channel) + { + var fakeCh = channel as FakedChannel; + return new FakedTag(TagDescriptor, fakeCh, Parent); + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedTagCbnt.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedTagCbnt.cs new file mode 100644 index 0000000..11bf0fa --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/FakedTagCbnt.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StdUnit.Tags.Tests.Fakes; + +// 本文件是主测试项目 Fakes/FakedTagCbnt.cs 的副本: +// 本项目所需的夹具都在本项目下自带一份,不引用主测试项目的源码(见 csproj 顶部注释)。 + +/// +/// 纯内存测点组合,不关联任何物理设备,仅用于测试。
+/// 没有缓存、没有批量 IO:读/写都只作用在子测点自己的值上,用来看"结构里出现 TagCbnt 时各处是否处理正确"。 +///
+internal sealed class FakedTagCbnt : ITagCbnt +{ + public FakedTagCbnt(TagCbntDescriptor descriptor) + { + this.Descriptor = descriptor; + this.StartAddress = descriptor.StartAddress; + this.IsEnabled = descriptor.IsEnabled; + } + + public TagCbntDescriptor Descriptor { get; set; } + + public ITagGrp? Parent { get; set; } + + public IDictionary Children { get; } = new Dictionary(); + + public ITagCbntor this[string tagName] => this.Children[tagName]; + + public bool IsEnabled { get; set; } + + public bool IsScanned { get; set; } + + public ITagChannel? Channel { get; set; } + + public string StartAddress { get; set; } + + public bool IsDirty { get; set; } + + public Task ReadAsync(CancellationToken ct) => Task.CompletedTask; + + public Task WriteAsync(CancellationToken ct) + { + this.IsDirty = false; + return Task.CompletedTask; + } +} + +/// 组合子:值是内存里的一份数据,读/写不碰硬件。 +internal sealed class FakedTagCbntor : TagCbntor +{ + public FakedTagCbntor(TagDescriptor tagDescriptor, ITagCbnt tagCbnt) + : base(tagDescriptor, tagCbnt, tagOffset: 0, cacheOffset: 0) + { + } + + public override object? Value { get; set; } + + public override Task ReadAsync(CancellationToken ct) => Task.CompletedTask; + + public override Task WriteAsync(CancellationToken ct) + { + this.IsDirty = false; + return Task.CompletedTask; + } +} + +/// 构建 。 +internal sealed class FakedTagCbntBuilder : TagCbntBuilderBase +{ + public FakedTagCbntBuilder() + : base(new FakedTagCbnt(new TagCbntDescriptor { Name = "unknown", StartAddress = "0" })) + { + } + + protected override ITagCbntor Fallback(TagDescriptor descriptor, ITagChannel channel) => + new FakedTagCbntor(descriptor, this.TagCbnt); + + protected override TagCbntBuilderBase AutoLayout() => this; +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/TagsProject_Extensions.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/TagsProject_Extensions.cs new file mode 100644 index 0000000..3124b79 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/Fakes/TagsProject_Extensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace StdUnit.Tags.Tests.Fakes; + +// 本文件是主测试项目 Fakes/TagsProject_Extensions.cs 的副本: +// 本项目所需的夹具都在本项目下自带一份,不引用主测试项目的源码(见 csproj 顶部注释)。 + +internal static class TagsProject_Extensions +{ + internal static TagsProjectServiceBuilder AddFakedSupport(this TagsProjectServiceBuilder builder) + { + builder.Services.AddKeyedSingleton("fake"); + builder.ConfigChannelsFactory((sp, composite) => + { + var factory = sp.GetRequiredKeyedService("fake"); + composite.AddFactory(factory); + }); + + return builder; + } + + /// + /// 注册 faked 测点构建器,使 faked 通道能解析 <Tag> 元素。 + /// + internal static TagsProjectServiceBuilder AddFakedTagSupport(this TagsProjectServiceBuilder builder) + { + builder.ConfigTagsLoader((_, composite) => + { + composite.AddDirectTagBuilder((channel, descriptor) => + channel is FakedChannel ? new FakedTagBuilder(descriptor) : null); + composite.AddTagsCbntBuilder((channel, descriptor) => + channel is FakedChannel ? new FakedTagCbntBuilder().WithCbntDescriptor(descriptor) : null); + }); + return builder; + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj b/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj index 18fb901..b817e1c 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/StdUnit.Tags.Tests.NetCoreOnly.csproj @@ -32,13 +32,5 @@ - - - - - - - - diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs new file mode 100644 index 0000000..b3cc302 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs @@ -0,0 +1,453 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StdUnit.Tags.Tests.Fakes; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; + +/// +/// 端点层(HTTP 适配):直接喂 ,不起 Kestrel。
+/// 覆盖 /tree、/stream、/catalog、/value、/rescan 的处理函数、 +/// "未注册观测流 → 404" 的两条文案,以及这些响应里的 DTO 序列化(源生成的 TagsStreamJsonContext)。 +///
+public class EndpointTests +{ + private static XElement SingleTagProject() => new( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("a")), + // 非入口组里的测点:会被轮询跳过,所以写命令必须拒绝("不在任何入口内")。 + new XElement( + "TagGrp", + new XAttribute("name", "plain"), + new XAttribute("isEnabled", "true"), + new XAttribute("channel", "fake"), + StreamTestContext.Tag("b"))); + + [Fact] + public async Task Tree_ReturnsEmptyTreeBeforeProjectStarts() + { + using var host = EndpointHost.Create(); + _ = host.StartHub(); + + var context = host.CreateContext(); + await TreeEndpoint.HandleAsync(context); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", context.Response.ContentType); + + var json = EndpointHost.ReadBody(context); + Assert.Contains("\"isStarted\":false", json); + Assert.Contains("\"nodes\":[]", json); + } + + [Fact] + public async Task Tree_ReturnsFlatNodesAfterProjectStarts() + { + using var host = EndpointHost.Create(); + var hub = host.StartHub(); + using var project = host.StartProject(SingleTagProject()); + try + { + var context = host.CreateContext(); + await TreeEndpoint.HandleAsync(context); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Equal(context.Response.ContentLength, ((MemoryStream)context.Response.Body).Length); + + var json = EndpointHost.ReadBody(context); + Assert.Contains("\"isStarted\":true", json); + Assert.Contains("\"rootName\":", json); + Assert.Contains("\"depth\":0", json); + // 三种节点都出现在扁平列表里(grp 的入口标识、叶测点的 kind/address)。 + Assert.Contains("\"kind\":\"grp\"", json); + Assert.Contains("\"kind\":\"tag\"", json); + Assert.Contains("\"path\":\"g1/a\"", json); + Assert.Contains("\"entryKey\":\"g1\"", json); + } + finally + { + await hub.StopAsync(default); + } + } + + [Fact] + public async Task Tree_DescribesTagCbntNodes() + { + using var host = EndpointHost.Create(); + var hub = host.StartHub(); + using var project = host.StartProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup( + "g1", + new XElement( + "TagCbnt", + new XAttribute("name", "c"), + new XAttribute("address", "0"), + StreamTestContext.Tag("x", address: "0"), + StreamTestContext.Tag("y", address: "1"))))); + + try + { + var context = host.CreateContext(); + await TreeEndpoint.HandleAsync(context); + + var json = EndpointHost.ReadBody(context); + Assert.Contains("\"kind\":\"cbnt\"", json); + Assert.Contains("\"path\":\"g1/c\"", json); + Assert.Contains("\"startAddress\":\"0\"", json); + Assert.Contains("\"path\":\"g1/c/x\"", json); + Assert.Contains("\"path\":\"g1/c/y\"", json); + } + finally + { + await hub.StopAsync(default); + } + } + + [Fact] + public async Task Tree_ServesKeyedStream() + { + using var host = EndpointHost.Create(withDefaultStream: false, keyedStream: "dev"); + var hub = host.StartHub("dev"); + using var project = host.StartProject(SingleTagProject()); + try + { + var context = host.CreateContext(key: "dev"); + await TreeEndpoint.HandleAsync(context); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Contains("\"path\":\"g1/a\"", EndpointHost.ReadBody(context)); + } + finally + { + await hub.StopAsync(default); + } + } + + [Fact] + public async Task Tree_UnknownKey_IsNotFound() + { + using var host = EndpointHost.Create(withDefaultStream: false, keyedStream: "dev"); + _ = host.StartHub("dev"); + + var context = host.CreateContext(key: "nope"); + await TreeEndpoint.HandleAsync(context); + + Assert.Equal(404, context.Response.StatusCode); + Assert.Equal("text/plain; charset=utf-8", context.Response.ContentType); + Assert.Contains("未注册 key = \"nope\" 的观测流", EndpointHost.ReadBody(context)); + } + + [Fact] + public async Task Tree_DefaultStreamNotRegistered_IsNotFound() + { + using var host = EndpointHost.Create(withDefaultStream: false, keyedStream: "dev"); + _ = host.StartHub("dev"); + + var context = host.CreateContext(); + await TreeEndpoint.HandleAsync(context); + + Assert.Equal(404, context.Response.StatusCode); + Assert.Contains("未注册不具名观测流", EndpointHost.ReadBody(context)); + } + + [Fact] + public async Task Catalog_ReturnsDesignerMetadataJson() + { + using var host = EndpointHost.Create(); + _ = host.StartHub(); + + var context = host.CreateContext(); + await DesignerCatalogEndpoint.HandleAsync(context); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", context.Response.ContentType); + + var json = EndpointHost.ReadBody(context); + Assert.Contains("\"version\":1", json); + Assert.Contains("\"root\":", json); + // 核心 schema 的全部节点都应出现在 catalog 里(设计器靠它渲染表单)。 + Assert.Contains("\"name\":\"Channel\"", json); + Assert.Contains("\"name\":\"TagCbnt\"", json); + Assert.Contains("\"name\":\"Logicet\"", json); + Assert.Contains("\"tagTypes\":", json); + } + + [Fact] + public async Task Stream_PushesFramesUntilRequestAborted() + { + using var host = EndpointHost.Create(); + var hub = host.StartHub(); + using var project = host.StartProject(SingleTagProject()); + + using var output = new RecordingStream(); + using var cts = new CancellationTokenSource(); + var context = host.CreateContext(queryString: "?entries=g1", abort: cts.Token); + context.Response.Body = output; + context.Features.Set(new StreamResponseBodyFeature(output)); + + var handling = StreamEndpoint.HandleAsync(context); + try + { + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("event: state"))); + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"entry\":\"g1\""))); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Equal("text/event-stream; charset=utf-8", context.Response.ContentType); + Assert.Equal("no-cache, no-transform", context.Response.Headers.CacheControl); + Assert.Equal("no", context.Response.Headers["X-Accel-Buffering"]); + } + finally + { + // 客户端断开:端点内部吞掉 OCE,正常返回。 + cts.Cancel(); + await handling; + await hub.StopAsync(default); + } + } + + [Fact] + public async Task Stream_UnknownKey_IsNotFound() + { + using var host = EndpointHost.Create(); + _ = host.StartHub(); + + var context = host.CreateContext(key: "nope"); + await StreamEndpoint.HandleAsync(context); + + Assert.Equal(404, context.Response.StatusCode); + Assert.Contains("nope", EndpointHost.ReadBody(context)); + } + + [Fact] + public async Task WriteValue_AppliesTheValueWithinTheEntry() + { + using var host = EndpointHost.Create(); + var hub = host.StartHub(); + using var project = host.StartProject(SingleTagProject()); + + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + try + { + var context = host.CreateContext(body: """{"path":"g1/a","raw":"7"}"""); + await CommandsEndpoint.WriteValueAsync(context); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", context.Response.ContentType); + Assert.Contains("\"ok\":true", EndpointHost.ReadBody(context)); + Assert.Equal((short)7, StreamTestContext.TagAt(project, "g1/a").Value); + } + finally + { + await StreamTestContext.StopAsync(runCts, run); + await hub.StopAsync(default); + } + } + + [Fact] + public async Task WriteValue_MalformedJson_IsBadRequest() + { + using var host = EndpointHost.Create(); + _ = host.StartHub(); + + var context = host.CreateContext(body: "not-a-json"); + await CommandsEndpoint.WriteValueAsync(context); + + Assert.Equal(400, context.Response.StatusCode); + AssertMalformedJson(context); + } + + [Fact] + public async Task Rescan_MalformedJson_IsBadRequest() + { + using var host = EndpointHost.Create(); + _ = host.StartHub(); + + var context = host.CreateContext(body: "{}oops"); + await CommandsEndpoint.RescanAsync(context); + + Assert.Equal(400, context.Response.StatusCode); + AssertMalformedJson(context); + } + + /// 序列化会转义非 ASCII,所以按 JSON 取属性再断言文案。 + private static void AssertMalformedJson(HttpContext context) + { + using var document = JsonDocument.Parse(EndpointHost.ReadBody(context)); + var root = document.RootElement; + Assert.False(root.GetProperty("ok").GetBoolean()); + Assert.Contains("请求体不是合法的 JSON", root.GetProperty("error").GetString()); + } + + [Fact] + public async Task Rescan_MarksTheTagUnscanned() + { + using var host = EndpointHost.Create(); + var hub = host.StartHub(); + using var project = host.StartProject(SingleTagProject()); + + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + try + { + var context = host.CreateContext(body: """{"path":"g1/a"}"""); + await CommandsEndpoint.RescanAsync(context); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Contains("\"ok\":true", EndpointHost.ReadBody(context)); + } + finally + { + await StreamTestContext.StopAsync(runCts, run); + await hub.StopAsync(default); + } + } + + [Fact] + public async Task Commands_UseTheKeyedStreamController() + { + using var host = EndpointHost.Create(withDefaultStream: false, keyedStream: "dev"); + var hub = host.StartHub("dev"); + using var project = host.StartProject(SingleTagProject()); + + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + try + { + var context = host.CreateContext(key: "dev", body: """{"path":"g1/a","raw":"9"}"""); + await CommandsEndpoint.WriteValueAsync(context); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Equal((short)9, StreamTestContext.TagAt(project, "g1/a").Value); + } + finally + { + await StreamTestContext.StopAsync(runCts, run); + await hub.StopAsync(default); + } + } + + [Fact] + public async Task Commands_UnknownKey_IsNotFound() + { + using var host = EndpointHost.Create(); + _ = host.StartHub(); + + var context = host.CreateContext(key: "nope", body: """{"path":"g1/a","raw":"1"}"""); + await CommandsEndpoint.WriteValueAsync(context); + + Assert.Equal(404, context.Response.StatusCode); + Assert.Contains("nope", EndpointHost.ReadBody(context)); + } +} + +/// +/// 端点测试用的宿主:真实 DI + 真实 TagsProject(假驱动),只把 +/// 换成可控的假实现(决定"何时启动/停止")。 +/// +internal sealed class EndpointHost : IDisposable +{ + private EndpointHost(ServiceProvider root, FakeProjectCtrl ctrl) + { + this.Root = root; + this.Ctrl = ctrl; + } + + public ServiceProvider Root { get; } + + public FakeProjectCtrl Ctrl { get; } + + public static EndpointHost Create(bool withDefaultStream = true, string? keyedStream = null) + { + var ctrl = new FakeProjectCtrl(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTagsProjectServices(b => + { + b.AddFakedSupport(); + b.AddFakedTagSupport(); + }); + + // 端点的启停由测试决定:换成假的控制器(注册顺序保证后注册的生效)。 + services.RemoveAll(); + services.AddSingleton(ctrl); + + if (withDefaultStream) + { + services.AddTagsStream(FastOptions); + } + + if (keyedStream is not null) + { + services.AddKeyedSingleton(keyedStream, ctrl); + services.AddKeyedTagsStream(keyedStream, FastOptions); + } + + return new EndpointHost(services.BuildServiceProvider(), ctrl); + } + + public ITagsProject CreateProject(XElement xml) => + this.Root.GetRequiredService().Create(StreamTestContext.ProjectRoot, xml); + + /// 创建并对控制器派发"已启动"(不跑轮询)。 + public ITagsProject StartProject(XElement xml) + { + var project = this.CreateProject(xml); + this.Ctrl.RaiseStarted(project); + return project; + } + + public TagsStreamHostedService StartHub(string? key = null) + { + var hub = this.Root.GetRequiredService().GetRequired(key).HostedService; + hub.StartAsync(default).GetAwaiter().GetResult(); + return hub; + } + + public HttpContext CreateContext( + string? key = null, + string? queryString = null, + string? body = null, + CancellationToken abort = default) + { + var context = new DefaultHttpContext { RequestServices = this.Root }; + context.Request.Method = body is null ? HttpMethods.Get : HttpMethods.Post; + context.Request.QueryString = new QueryString(queryString ?? string.Empty); + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body ?? string.Empty)); + context.RequestAborted = abort; + context.Response.Body = new MemoryStream(); + + if (key is not null) + { + context.Request.RouteValues[EndpointConstants.KeyedRouteValue] = key; + } + + return context; + } + + public static string ReadBody(HttpContext context) => + Encoding.UTF8.GetString(((MemoryStream)context.Response.Body).ToArray()); + + public void Dispose() => this.Root.Dispose(); + + private static void FastOptions(TagsStreamOptions options) + { + options.ReconcileInterval = TimeSpan.FromMilliseconds(20); + options.HeartbeatInterval = TimeSpan.FromSeconds(30); + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/RecordingStream.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/RecordingStream.cs new file mode 100644 index 0000000..efccef5 --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/RecordingStream.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; + +/// 线程安全的只写流:把 SSE 字节记录下来供断言。 +internal sealed class RecordingStream : Stream +{ + private readonly object _gate = new(); + private readonly MemoryStream _inner = new(); + + public string Text + { + get + { + lock (this._gate) + { + return Encoding.UTF8.GetString(this._inner.GetBuffer(), 0, (int)this._inner.Length); + } + } + } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + lock (this._gate) + { + this._inner.Write(buffer, offset, count); + } + } + + public override void Write(ReadOnlySpan buffer) + { + lock (this._gate) + { + this._inner.Write(buffer); + } + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + this.Write(buffer, offset, count); + return Task.CompletedTask; + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + this.Write(buffer.Span); + return default; + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamTestContext.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamTestContext.cs index 9d93d5e..917f88c 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamTestContext.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/StreamTestContext.cs @@ -1,10 +1,13 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; using StdUnit.Tags.Tests.Fakes; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; using Xunit; namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; @@ -115,6 +118,30 @@ public static ITag TagAt(ITagsProject project, string path) => public static string Text(ReadOnlyMemory frame) => System.Text.Encoding.UTF8.GetString(frame.ToArray()); } +/// 只关心"重同步帧被要求构建了几次"的假 hub。 +internal sealed class SyncCountingHub : ITagsStreamHub +{ + private int _syncCalls; + + public int SyncCalls => Volatile.Read(ref this._syncCalls); + + public long Epoch => 1; + + public TreeDto GetTree() => new(this.Epoch, false, string.Empty, Array.Empty()); + + public SseFramesPiper Subscribe(IReadOnlySet? entries) => throw new NotSupportedException(); + + public void RemovePiper(SseFramesPiper piper) + { + } + + public IReadOnlyList> BuildSyncFrames(SseFramesPiper piper) + { + Interlocked.Increment(ref this._syncCalls); + return new[] { SseFrames.Ping }; + } +} + /// 可控的假控制器:只用于决定"何时启动/停止"。 internal sealed class FakeProjectCtrl : ITagsProjectCtrl { diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamCommandsTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamCommandsTests.cs index 57918e6..b95c4d7 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamCommandsTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamCommandsTests.cs @@ -239,4 +239,163 @@ public void ResetScanned_ClearsTagFlag() Assert.False(tag.IsScanned); } + + [Fact] + public void ResetScanned_ChildOfTagCbnt_BubblesToTheContainer() + { + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup( + "g1", + new XElement( + "TagCbnt", + new XAttribute("name", "c"), + new XAttribute("address", "0"), + StreamTestContext.Tag("x", address: "0"))))); + + var cbnt = project.Tags.Descendant("g1/c").AsTagCbnt(); + var child = StreamTestContext.TagAt(project, "g1/c/x"); + cbnt.IsScanned = true; + child.IsScanned = true; + + // 组合子属于组合:标"未扫描"时必须冒泡到组合(整块缓存会一起重读)。 + TagsStreamCommands.ResetScanned(child); + + Assert.False(cbnt.IsScanned); + } + + /// 每种内置类型都按 TagKind 精确解析(库本身不做跨类型转换)。 + [Theory] + [InlineData("BYTE", "200", typeof(byte))] + [InlineData("INT16", "-12345", typeof(short))] + [InlineData("UINT16", "54321", typeof(ushort))] + [InlineData("INT32", "-100000", typeof(int))] + [InlineData("UINT32", "100000", typeof(uint))] + [InlineData("INT64", "-5000000000", typeof(long))] + [InlineData("UINT64", "5000000000", typeof(ulong))] + [InlineData("FLOAT", "1.5", typeof(float))] + [InlineData("DOUBLE", "2.25", typeof(double))] + [InlineData("STR", "hello", typeof(string))] + [InlineData("BIT", "true", typeof(bool))] + [InlineData("DI", "1", typeof(bool))] + [InlineData("DO", "0", typeof(bool))] + public async Task WriteValue_ParsesEveryBuiltinKind(string type, string raw, Type expectedType) + { + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("a", type: type)))); + + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var cts = new CancellationTokenSource(); + var run = project.RunAsync(cts.Token); + try + { + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/a", raw), Timeout); + + Assert.Equal(200, outcome.StatusCode); + Assert.Null(outcome.Error); + + var value = StreamTestContext.TagAt(project, "g1/a").Value; + Assert.NotNull(value); + Assert.Equal(expectedType, value!.GetType()); + } + finally + { + await StreamTestContext.StopAsync(cts, run); + } + } + + [Fact] + public async Task WriteValue_NullPath_IsBadRequest() + { + var ctrl = new FakeProjectCtrl(); + + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest(null, "1"), Timeout); + + Assert.Equal(400, outcome.StatusCode); + Assert.Contains("path 不能为空", outcome.Error); + } + + [Fact] + public async Task WriteValue_NullRaw_IsBadRequest() + { + using var project = this.CreateProject(Int16Project()); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/a", null), Timeout); + + Assert.Equal(400, outcome.StatusCode); + Assert.Contains("raw 不能为空", outcome.Error); + } + + [Fact] + public async Task WriteValue_NonLeafPath_IsBadRequest() + { + using var project = this.CreateProject(Int16Project()); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + // 指向测点组(及其根)都不是叶测点 + Assert.Equal(400, (await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1", "1"), Timeout)).StatusCode); + Assert.Equal(400, (await TagsStreamCommands.RescanAsync(ctrl, new RescanTagRequest("g1"), Timeout)).StatusCode); + } + + [Fact] + public async Task WriteValue_TagOutsideAnyEntry_IsConflict() + { + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + new XElement( + "TagGrp", + new XAttribute("name", "plain"), + new XAttribute("isEnabled", "true"), + new XAttribute("channel", "fake"), + StreamTestContext.Tag("a")))); + + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("plain/a", "1"), Timeout); + + Assert.Equal(409, outcome.StatusCode); + Assert.Contains("不在任何入口内", outcome.Error); + } + + [Fact] + public async Task WriteValue_BitTextNotParseable_IsBadRequest() + { + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("b", type: "BIT")))); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/b", "maybe"), Timeout); + + Assert.Equal(400, outcome.StatusCode); + Assert.Contains("布尔", outcome.Error); + } + + [Fact] + public async Task WriteValue_CustomKind_IsNotWritableAsText() + { + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("j", type: "JSON")))); + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + var outcome = await TagsStreamCommands.WriteValueAsync(ctrl, new WriteTagValueRequest("g1/j", "{}"), Timeout); + + Assert.Equal(400, outcome.StatusCode); + Assert.Contains("不支持用文本写入的类型", outcome.Error); + } } diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubEdgeCaseTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubEdgeCaseTests.cs new file mode 100644 index 0000000..55fbcba --- /dev/null +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubEdgeCaseTests.cs @@ -0,0 +1,320 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using StdUnit.Tags.WebUi; +using StdUnit.Tags.WebUi.Internal; +using Xunit; + +namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; + +/// +/// 的边界行为:参数校验、启停幂等、无订阅者时的零开销、 +/// 心跳、按 拆帧、容器标志单独成帧, +/// 以及 队列溢出时的"丢弃 + 重同步"(绝不反压)。 +/// +public class TagsStreamHubEdgeCaseTests : IDisposable +{ + private readonly ServiceProvider _root; + private readonly IServiceScope _scope; + + public TagsStreamHubEdgeCaseTests() + { + this._root = StreamTestContext.CreateServiceProvider(); + this._scope = this._root.CreateScope(); + } + + public void Dispose() + { + this._scope.Dispose(); + this._root.Dispose(); + } + + private ITagsProject CreateProject(XElement xml) => + this._scope.ServiceProvider.GetRequiredService().Create(StreamTestContext.ProjectRoot, xml); + + private static TagsStreamOptions Options() => new() + { + ReconcileInterval = TimeSpan.FromMilliseconds(20), + HeartbeatInterval = TimeSpan.FromSeconds(30), + PiperQueueCapacity = 256, + }; + + [Fact] + public void Ctor_RejectsNullAndOutOfRangeArguments() + { + var ok = Options(); + + Assert.Throws(() => new TagsStreamHostedService(null!, ok)); + Assert.Throws(() => new TagsStreamHostedService(new FakeProjectCtrl(), null!)); + + Assert.Throws(() => + new TagsStreamHostedService(new FakeProjectCtrl(), new TagsStreamOptions { ReconcileInterval = TimeSpan.Zero })); + Assert.Throws(() => + new TagsStreamHostedService(new FakeProjectCtrl(), new TagsStreamOptions { HeartbeatInterval = TimeSpan.Zero })); + Assert.Throws(() => + new TagsStreamHostedService(new FakeProjectCtrl(), new TagsStreamOptions { PiperQueueCapacity = 0 })); + Assert.Throws(() => + new TagsStreamHostedService(new FakeProjectCtrl(), new TagsStreamOptions { PendingQueueCapacity = 0 })); + } + + [Fact] + public async Task StartAndStop_AreIdempotent() + { + using var hub = new TagsStreamHostedService(new FakeProjectCtrl(), Options()); + + await hub.StartAsync(default); + await hub.StartAsync(default); // 第二次什么也不做 + await hub.StopAsync(default); + await hub.StopAsync(default); // 第二次时 pump/heartbeat 已为 null + } + + [Fact] + public async Task OnTurn_WithoutSubscribers_AndAfterStop_IsIgnored() + { + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("a")))); + + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var hub = new TagsStreamHostedService(ctrl, Options()); + await hub.StartAsync(default); + + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + try + { + // 没有订阅者:轮次回调的第一件事就是零开销返回。 + StreamTestContext.TagAt(project, "g1/a").Value = (short)1; + Assert.True(StreamTestContext.WaitUntil( + () => Equals((short)1, StreamTestContext.TagAt(project, "g1/a").Value))); + + // 已停止(索引被清空)但轮询还在跑:同样必须忽略,不能抛异常。 + ctrl.RaiseStopped(); + Assert.False(hub.GetTree().IsStarted); + + StreamTestContext.TagAt(project, "g1/a").Value = (short)2; + Assert.True(StreamTestContext.WaitUntil( + () => Equals((short)2, StreamTestContext.TagAt(project, "g1/a").Value))); + Assert.False(hub.GetTree().IsStarted); + } + finally + { + await StreamTestContext.StopAsync(runCts, run); + await hub.StopAsync(default); + } + } + + [Fact] + public async Task Heartbeat_SendsPingToSubscribers() + { + var options = Options(); + options.HeartbeatInterval = TimeSpan.FromMilliseconds(50); + + using var hub = new TagsStreamHostedService(new FakeProjectCtrl(), options); + await hub.StartAsync(default); + + using var output = new RecordingStream(); + using var piperCts = new CancellationTokenSource(); + using var piper = hub.Subscribe(entries: null); + var writer = piper.PipeFramesToStreamAsync(output, piperCts.Token); + + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains(": ping"))); + + piperCts.Cancel(); + try + { + await writer; + } + catch (OperationCanceledException) + { + // 正常停机 + } + + await hub.StopAsync(default); + } + + [Fact] + public async Task Delta_IsSplitByMaxValuesPerFrame() + { + var options = Options(); + options.MaxValuesPerFrame = 1; + + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup( + "g1", + StreamTestContext.Tag("a"), + StreamTestContext.Tag("b"), + StreamTestContext.Tag("c")))); + + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var hub = new TagsStreamHostedService(ctrl, options); + await hub.StartAsync(default); + + using var output = new RecordingStream(); + using var piperCts = new CancellationTokenSource(); + using var piper = hub.Subscribe(entries: null); + var writer = piper.PipeFramesToStreamAsync(output, piperCts.Token); + + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"full\":true"))); + + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + try + { + StreamTestContext.TagAt(project, "g1/a").Value = (short)1; + StreamTestContext.TagAt(project, "g1/b").Value = (short)2; + StreamTestContext.TagAt(project, "g1/c").Value = (short)3; + + Assert.True(StreamTestContext.WaitUntil(() => + output.Text.Contains("\"raw\":\"1\"", StringComparison.Ordinal) + && output.Text.Contains("\"raw\":\"2\"", StringComparison.Ordinal) + && output.Text.Contains("\"raw\":\"3\"", StringComparison.Ordinal))); + + // 单帧上限是 1:任何一个增量帧里都不能出现两个值(这条断言不受"分几轮采到"影响)。 + var deltas = Frames(output.Text).Where(f => f.Contains("\"full\":false", StringComparison.Ordinal)).ToArray(); + Assert.NotEmpty(deltas); + Assert.All(deltas, f => Assert.True(CountOccurrences(f, "\"path\":") <= 1, f)); + + // 序号按写出顺序严格递增(拆帧不会打乱 seq)。 + var seqs = deltas.Select(Seq).ToArray(); + Assert.Equal(seqs.OrderBy(s => s).ToArray(), seqs); + Assert.Equal(seqs.Length, seqs.Distinct().Count()); + } + finally + { + await StreamTestContext.StopAsync(runCts, run); + piperCts.Cancel(); + try + { + await writer; + } + catch (OperationCanceledException) + { + // 正常停机 + } + + await hub.StopAsync(default); + } + } + + [Fact] + public async Task ContainerFlagChange_IsPublishedWithoutValues() + { + using var project = this.CreateProject(new XElement( + "Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Group("sub", StreamTestContext.Tag("a"))))); + + var ctrl = new FakeProjectCtrl(); + ctrl.RaiseStarted(project); + + using var hub = new TagsStreamHostedService(ctrl, Options()); + await hub.StartAsync(default); + + using var output = new RecordingStream(); + using var piperCts = new CancellationTokenSource(); + using var piper = hub.Subscribe(entries: null); + var writer = piper.PipeFramesToStreamAsync(output, piperCts.Token); + + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"full\":true"))); + + using var runCts = new CancellationTokenSource(); + var run = project.RunAsync(runCts.Token); + try + { + // 使能状态变化:值没变,所以是一帧"只有容器标志"的增量。 + project.Tags.Descendant("g1/sub").AsTagGrp().IsEnabled = false; + + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains("\"containerFlags\":[{\"path\":\"g1/sub\",\"isEnabled\":false}]"))); + + var frame = Frames(output.Text).Single(f => f.Contains("\"containerFlags\"", StringComparison.Ordinal)); + Assert.Contains("\"values\":[]", frame); + Assert.Contains("\"full\":false", frame); + } + finally + { + await StreamTestContext.StopAsync(runCts, run); + piperCts.Cancel(); + try + { + await writer; + } + catch (OperationCanceledException) + { + // 正常停机 + } + + await hub.StopAsync(default); + } + } + + [Fact] + public async Task Piper_QueueOverflow_MarksResyncInsteadOfBlocking() + { + var hub = new SyncCountingHub(); + using var piper = new SseFramesPiper(hub, entries: null, capacity: 2); + + // 写循环还没开始:把队列灌满,再多入一帧必然失败 —— 那一帧被丢弃并标记重同步。 + piper.Enqueue(SseFrames.Ping); + piper.Enqueue(SseFrames.Ping); + piper.Enqueue(SseFrames.Ping); + + using var output = new RecordingStream(); + using var cts = new CancellationTokenSource(); + var writer = piper.PipeFramesToStreamAsync(output, cts.Token); + + Assert.True(StreamTestContext.WaitUntil(() => hub.SyncCalls > 0)); + + cts.Cancel(); + try + { + await writer; + } + catch (OperationCanceledException) + { + // 正常停机 + } + } + + /// 按 SSE 帧分隔符(空行)切分。 + private static string[] Frames(string text) => + text.Split(new[] { "\n\n" }, StringSplitOptions.RemoveEmptyEntries); + + private static int CountOccurrences(string text, string needle) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + + return count; + } + + private static long Seq(string frame) + { + const string Marker = "\"seq\":"; + var index = frame.IndexOf(Marker, StringComparison.Ordinal); + Assert.True(index >= 0, "帧里没有 seq:" + frame); + var start = index + Marker.Length; + var end = start; + while (end < frame.Length && (char.IsAsciiDigit(frame[end]) || frame[end] == '-')) + { + end++; + } + + return long.Parse(frame[start..end], System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs index 148b8c8..e7949b8 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs @@ -1,6 +1,4 @@ using System; -using System.IO; -using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; @@ -187,6 +185,28 @@ public async Task Hub_StartedAfterProject_StillAttachesTurnHandlers() await hub.StopAsync(default); } + [Fact] + public async Task Piper_MarkResync_WakesIdleWriteLoop() + { + // 回归:MarkResync 只置标志时,写循环可能刚好在"读完队列里的最后一帧"之后重新阻塞, + // 这一次唤醒就丢了,重同步要等到下一次心跳/增量才处理(并行跑测试时表现为偶发拿不到基线)。 + var hub = new SyncCountingHub(); + using var output = new RecordingStream(); + using var piperCts = new CancellationTokenSource(); + using var piper = new SseFramesPiper(hub, entries: null, capacity: 8); + var writer = piper.PipeFramesToStreamAsync(output, piperCts.Token); + + // 先入队一帧并等它写完:此后写循环阻塞在 WaitToReadAsync 上,没有新帧不会自己醒。 + piper.Enqueue(SseFrames.Ping); + Assert.True(StreamTestContext.WaitUntil(() => output.Text.Contains(": ping"))); + await Task.Delay(100); + + piper.MarkResync(); + Assert.True(StreamTestContext.WaitUntil(() => hub.SyncCalls > 0)); + + await StopSubscriberAsync(piperCts, writer); + } + /// 取出包含指定片段的那一帧(以空行分隔)。 private static string? FrameContaining(string text, string needle) { @@ -215,75 +235,4 @@ private static async Task StopSubscriberAsync(CancellationTokenSource cts, Task } } - /// 线程安全的只写流:把 SSE 字节记录下来供断言。 - private sealed class RecordingStream : Stream - { - private readonly object _gate = new(); - private readonly MemoryStream _inner = new(); - - public string Text - { - get - { - lock (this._gate) - { - return Encoding.UTF8.GetString(this._inner.GetBuffer(), 0, (int)this._inner.Length); - } - } - } - - public override bool CanRead => false; - - public override bool CanSeek => false; - - public override bool CanWrite => true; - - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Flush() - { - } - - public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - - public override void SetLength(long value) => throw new NotSupportedException(); - - public override void Write(byte[] buffer, int offset, int count) - { - lock (this._gate) - { - this._inner.Write(buffer, offset, count); - } - } - - public override void Write(ReadOnlySpan buffer) - { - lock (this._gate) - { - this._inner.Write(buffer); - } - } - - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - this.Write(buffer, offset, count); - return Task.CompletedTask; - } - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - this.Write(buffer.Span); - return default; - } - } } diff --git a/tests/StdUnit.Tags.Tests/Core/Projects/TagsProjectTurnProcessTests.cs b/tests/StdUnit.Tags.Tests/Core/Projects/TagsProjectTurnProcessTests.cs index 58c535e..9642b58 100644 --- a/tests/StdUnit.Tags.Tests/Core/Projects/TagsProjectTurnProcessTests.cs +++ b/tests/StdUnit.Tags.Tests/Core/Projects/TagsProjectTurnProcessTests.cs @@ -475,7 +475,7 @@ public async Task Handler_SubscriberThrows_EntersRunnerCrashed() (entry, channel, _) => throw new InvalidOperationException("observer boom")); var crashed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - proj.RunnerCrashed += (entry, channel, ex) => + proj.RunnerCrashed += (entry, channel, ex, ct) => { crashed.TrySetResult(ex); return Task.CompletedTask; diff --git a/tests/StdUnit.Tags.Tests/Core/TagGrpRunners/TagGrpRunnerTests.cs b/tests/StdUnit.Tags.Tests/Core/TagGrpRunners/TagGrpRunnerTests.cs index 2d6abd1..e45954f 100644 --- a/tests/StdUnit.Tags.Tests/Core/TagGrpRunners/TagGrpRunnerTests.cs +++ b/tests/StdUnit.Tags.Tests/Core/TagGrpRunners/TagGrpRunnerTests.cs @@ -28,8 +28,7 @@ public class TagGrpRunnerTests /// /// 调用 StartAsync 并在取消时吞掉 OperationCanceledException。 - /// TagGrpRunner 的外层 finally 中有 Task.Delay(ScanInterval, ct), - /// 取消后会抛出 OCE,这是其当前设计。 + /// 取消后 StartAsync 以 OCE 结束是其对外契约(停止 = 任务取消),只是不再伪造一次崩溃事件。 /// private async Task RunUntilCancelled(TagGrpRunner runner, ITagGrp entry, CancellationToken ct) { @@ -55,12 +54,12 @@ public async Task StartAsync_NormalLoop_CallsReadWriteAndEvents() var turnStartedCalled = false; var turnProcessCalled = false; - runner.RunnerStarted += (grp, ch) => + runner.RunnerStarted += (grp, ch, ct) => { turnStartedCalled = true; return Task.CompletedTask; }; - runner.TurnProcess += (grp, ch, _) => + runner.TurnProcess += (grp, ch, ct) => { turnProcessCalled = true; cts.Cancel(); @@ -106,11 +105,15 @@ public async Task StartAsync_TurnStartedEvent_Fires() using var cts = new CancellationTokenSource(); ITagGrp? capturedEntry = null; ITagChannel? capturedChannel = null; + CancellationToken capturedCt = default; + var ctCancelledAtCapture = true; - runner.RunnerStarted += (grp, ch) => + runner.RunnerStarted += (grp, ch, ct) => { capturedEntry = grp; capturedChannel = ch; + capturedCt = ct; + ctCancelledAtCapture = ct.IsCancellationRequested; cts.Cancel(); return Task.CompletedTask; }; @@ -121,6 +124,65 @@ public async Task StartAsync_TurnStartedEvent_Fires() // Assert Assert.Same(entry, capturedEntry); Assert.Same(entry.Channel, capturedChannel); + Assert.Equal(cts.Token, capturedCt); + Assert.False(ctCancelledAtCapture, "启动回调拿到的应是尚可用的生命周期令牌"); + } + + [Fact] + public async Task StartAsync_TurnCrashedEvent_ReceivesLifecycleToken() + { + // 崩溃回调拿到的必须是 StartAsync 的同一个生命周期令牌,且真故障时它尚未取消 + // (取消不是崩溃,不会走到这个回调里来)。 + var (runner, entry, _, _) = CreateRunner(); + entry.Descriptor!.ScanInterval = 20; + entry.IsEnabled = true; + entry.ReadAsyncThrows = new InvalidOperationException("模拟读取异常"); + + using var cts = new CancellationTokenSource(); + CancellationToken capturedCt = default; + var ctCancelledAtCapture = true; + + runner.RunnerCrashed += (grp, ch, ex, ct) => + { + capturedCt = ct; + ctCancelledAtCapture = ct.IsCancellationRequested; + cts.Cancel(); + return Task.CompletedTask; + }; + + // Act + await RunUntilCancelled(runner, entry, cts.Token).WaitAsync(TimeSpan.FromSeconds(15)); + + // Assert + Assert.Equal(cts.Token, capturedCt); + Assert.False(ctCancelledAtCapture, "真故障时生命周期令牌尚未取消,回调可直接沿用它做上报"); + } + + [Fact] + public async Task StartAsync_Cancellation_DoesNotFireRunnerCrashed() + { + // 回归测试:取消不是崩溃——StopAsync 期间不应伪造一次 RunnerCrashed(旧实现会把 + // 轮询里的 Task.Delay(ct) 抛出的 OCE 当成崩溃上报,导致监控里每次停机都多一条失败)。 + var (runner, entry, _, _) = CreateRunner(); + entry.Descriptor!.ScanInterval = 10; + entry.IsEnabled = true; + + using var cts = new CancellationTokenSource(); + entry.OnRead = () => cts.Cancel(); + + var crashCount = 0; + runner.RunnerCrashed += (grp, ch, ex, ct) => + { + crashCount++; + return Task.CompletedTask; + }; + + // Act + await RunUntilCancelled(runner, entry, cts.Token).WaitAsync(TimeSpan.FromSeconds(15)); + + // Assert + Assert.True(entry.ReadAsyncCallCount >= 1); + Assert.Equal(0, crashCount); } [Fact] @@ -203,7 +265,7 @@ public async Task StartAsync_Exception_FiresTurnCrashed() Exception? capturedEx = null; var crashedFired = false; - runner.RunnerCrashed += (grp, ch, ex) => + runner.RunnerCrashed += (grp, ch, ex, ct) => { crashedFired = true; capturedEx = ex; @@ -234,7 +296,7 @@ public async Task StartAsync_TurnCrashedHandlerThrows_ExceptionPropagates() // 而一旦截止期先到期,外层 finally 的 Task.Delay(delay, ct) 会把结果变成 TaskCanceledException, // 掩盖真实断言(曾因此在负载高时偶发失败)。兜底改用 WaitAsync —— 超时抛 TimeoutException, // 不参与 runner 的取消语义。 - runner.RunnerCrashed += (grp, ch, ex) => + runner.RunnerCrashed += (grp, ch, ex, ct) => throw new InvalidOperationException("错误处理也抛异常"); // Act & Assert @@ -611,7 +673,7 @@ public async Task StartAsync_ConsecutiveFailures_GrowDelay() using var cts = new CancellationTokenSource(); var crashCount = 0; - runner.RunnerCrashed += (_, _, _) => + runner.RunnerCrashed += (_, _, _, ct) => { crashCount++; @@ -687,7 +749,7 @@ public async Task StartAsync_FailResetThenAccumulateAgain() } }; - runner.RunnerCrashed += (_, _, _) => + runner.RunnerCrashed += (_, _, _, ct) => { totalCrashes++; diff --git a/tests/StdUnit.Tags.Tests/Fakes/FakedTagCbnt.cs b/tests/StdUnit.Tags.Tests/Fakes/FakedTagCbnt.cs new file mode 100644 index 0000000..8e09c94 --- /dev/null +++ b/tests/StdUnit.Tags.Tests/Fakes/FakedTagCbnt.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StdUnit.Tags.Tests.Fakes; + +/// +/// 纯内存测点组合,不关联任何物理设备,仅用于测试。
+/// 没有缓存、没有批量 IO:读/写都只作用在子测点自己的值上,用来看"结构里出现 TagCbnt 时各处是否处理正确"。 +///
+internal sealed class FakedTagCbnt : ITagCbnt +{ + public FakedTagCbnt(TagCbntDescriptor descriptor) + { + this.Descriptor = descriptor; + this.StartAddress = descriptor.StartAddress; + this.IsEnabled = descriptor.IsEnabled; + } + + public TagCbntDescriptor Descriptor { get; set; } + + public ITagGrp? Parent { get; set; } + + public IDictionary Children { get; } = new Dictionary(); + + public ITagCbntor this[string tagName] => this.Children[tagName]; + + public bool IsEnabled { get; set; } + + public bool IsScanned { get; set; } + + public ITagChannel? Channel { get; set; } + + public string StartAddress { get; set; } + + public bool IsDirty { get; set; } + + public Task ReadAsync(CancellationToken ct) => Task.CompletedTask; + + public Task WriteAsync(CancellationToken ct) + { + this.IsDirty = false; + return Task.CompletedTask; + } +} + +/// 组合子:值是内存里的一份数据,读/写不碰硬件。 +internal sealed class FakedTagCbntor : TagCbntor +{ + public FakedTagCbntor(TagDescriptor tagDescriptor, ITagCbnt tagCbnt) + : base(tagDescriptor, tagCbnt, tagOffset: 0, cacheOffset: 0) + { + } + + public override object? Value { get; set; } + + public override Task ReadAsync(CancellationToken ct) => Task.CompletedTask; + + public override Task WriteAsync(CancellationToken ct) + { + this.IsDirty = false; + return Task.CompletedTask; + } +} + +/// 构建 。 +internal sealed class FakedTagCbntBuilder : TagCbntBuilderBase +{ + public FakedTagCbntBuilder() + : base(new FakedTagCbnt(new TagCbntDescriptor { Name = "unknown", StartAddress = "0" })) + { + } + + protected override ITagCbntor Fallback(TagDescriptor descriptor, ITagChannel channel) => + new FakedTagCbntor(descriptor, this.TagCbnt); + + protected override TagCbntBuilderBase AutoLayout() => this; +} diff --git a/tests/StdUnit.Tags.Tests/Fakes/TagsProject_Extensions.cs b/tests/StdUnit.Tags.Tests/Fakes/TagsProject_Extensions.cs index 57efa4e..7d50f8a 100644 --- a/tests/StdUnit.Tags.Tests/Fakes/TagsProject_Extensions.cs +++ b/tests/StdUnit.Tags.Tests/Fakes/TagsProject_Extensions.cs @@ -25,6 +25,8 @@ internal static TagsProjectServiceBuilder AddFakedTagSupport(this TagsProjectSer { composite.AddDirectTagBuilder((channel, descriptor) => channel is FakedChannel ? new FakedTagBuilder(descriptor) : null); + composite.AddTagsCbntBuilder((channel, descriptor) => + channel is FakedChannel ? new FakedTagCbntBuilder().WithCbntDescriptor(descriptor) : null); }); return builder; }