Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
- 写命令:`POST /stdunit.tags/value`(改测点值)、`POST /stdunit.tags/rescan`(标记未扫描)。
- 注册:`services.AddTagsStream()` / `app.MapTagsStream()`;多项目场景用 `AddKeyedTagsStream(key)` 注册具名流(路径形如 `/stdunit.tags/{key}/stream`)。
只允许改**路由前缀**(`app.MapTagsStream("/internal/tags")`),相对路径是线协议的一部分。
- 启动时机:观测流在 `app.MapTagsStream()` 映射端点时启动(每路一次、幂等),**不注册成 `IHostedService`**,
因此不依赖 Generic Host 的启动顺序;启动时会按 `ITagsProjectCtrl.Project` 对账,项目先跑起来也能补上(值流从下一轮生效)。
- **不内置任何鉴权**,暴露到非受信网络前请自行加策略(如 `app.MapTagsStream().RequireAuthorization()`)。
- **`AddKeyedTagsProjectCtrl(key)`**(`StdUnit.Tags`,`IServiceCollection` 与 `TagsProjectServiceBuilder` 上各一个):
以指定 key 注册 `ITagsProjectCtrl`;配合 `AddKeyedTagsStream(key)` 可以在一个宿主里同时监控多个项目。
Expand Down
4 changes: 2 additions & 2 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ component_management:
paths:
- "src/StdUnit.Tags.SimpleFiles/**"
- component_id: stdunit_tags_webui
name: "WebUI"
name: "WebUi"
paths:
- "src/StdUnit.Tags.WebUI/**"
- "src/StdUnit.Tags.WebUi/**"

ignore:
- "**/*.md"
Expand Down
4 changes: 2 additions & 2 deletions samples/WebMonitor/Tags/TagBackgroundService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ namespace WebMonitor.Tags;

/// <summary>
/// 启停两路测点项目,并跑一个只用于演示的值模拟器。<br/>
/// 与模板 <c>tags.web</c> 的 <c>TagBackgroundService</c> 同一形状:轮询必须在这里(宿主启动之后)开始,
/// 观测流的 hub 才能先拿到"项目已启动"事件并挂上"轮次完成"回调。
/// 与模板 <c>tags.web</c> 的 <c>TagBackgroundService</c> 同一形状:观测流的 hub 在 <c>MapTagsStream()</c> 时
/// (Program.cs,早于本服务)就已经启动,所以这里只管轮询;哪一路先起来都不影响观测。
/// </summary>
internal sealed class TagBackgroundService : BackgroundService
{
Expand Down
8 changes: 4 additions & 4 deletions src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ namespace StdUnit.Tags.WebUi;

/// <summary>
/// 一路观测流:它观测的控制器、配置与 hub。<br/>
/// 端点、命令、hosted service 都通过它拿到"同一个"hub —— 这是"接口服务 = hosted service = 端点看到的实例"的保证。
/// 端点、命令、hub 启动点都通过它拿到"同一个"hub —— 这是"接口服务 = 端点看到的实例"的保证。
/// </summary>
internal sealed class TagsStreamEntry
{
public TagsStreamEntry(string? key, TagsStreamOptions options, ITagsProjectCtrl ctrl, TagsStreamHostedService hub)
public TagsStreamEntry(string? key, TagsStreamOptions options, ITagsProjectCtrl ctrl, TagsStreamHub hub)
{
this.Key = key;
this.Options = options;
this.Ctrl = ctrl;
this.HostedService = hub;
this.Hub = hub;
}

public string? Key { get; }
Expand All @@ -21,5 +21,5 @@ public TagsStreamEntry(string? key, TagsStreamOptions options, ITagsProjectCtrl
/// <summary>这一路流观测的控制器(写命令也用它,保证"看哪个项目就改哪个项目")。</summary>
public ITagsProjectCtrl Ctrl { get; }

internal TagsStreamHostedService HostedService { get; }
internal TagsStreamHub Hub { get; }
}
16 changes: 14 additions & 2 deletions src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace StdUnit.Tags.WebUi;

/// <summary>
/// 所有观测流的注册表:key(不具名用 <c>null</c>)→ 一路独立的 hub + 控制器 + 配置。<br/>
/// 每个 key 恰好创建一个 hub,hosted service 与端点共用同一实例。
/// 每个 key 恰好创建一个 hub,端点与 hub 启动点共用同一实例。
/// </summary>
internal sealed class TagsStreamRegistry
{
Expand All @@ -18,7 +18,7 @@ public TagsStreamRegistry(IServiceProvider services, IEnumerable<TagsStreamDescr
foreach (var descriptor in descriptors)
{
var ctrl = ResolveCtrl(services, descriptor.Key);
var hub = new TagsStreamHostedService(ctrl, descriptor.Options, services.GetService<ILogger<TagsStreamHostedService>>());
var hub = new TagsStreamHub(ctrl, descriptor.Options, services.GetService<ILogger<TagsStreamHub>>());
var entry = new TagsStreamEntry(descriptor.Key, descriptor.Options, ctrl, hub);

this._entries.Add(entry);
Expand Down Expand Up @@ -46,6 +46,18 @@ key is null

public IReadOnlyList<TagsStreamEntry> All => this._entries;

/// <summary>
/// 启动全部流的 hub(幂等)。<b>这是观测流的唯一启动点</b>,由 <c>MapTagsStream()</c> 在映射端点时调用:
/// 映射必然早于任何请求,也早于 <c>StartPollAsync</c>,所以不需要 Generic Host 的 <c>IHostedService</c>。
/// </summary>
public void StartAll()
{
foreach (var entry in this._entries)
{
entry.Hub.Start();
}
}

private static ITagsProjectCtrl ResolveCtrl(IServiceProvider services, string? key)
{
if (key is null)
Expand Down
38 changes: 28 additions & 10 deletions src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StdUnit.Tags.WebUi.Internal;

Expand All @@ -10,19 +9,25 @@ namespace StdUnit.Tags.WebUi;
/// - 跟踪 <see cref="ITagsProjectCtrl"/> 的启停,维护每次启动的 <c>epoch</c> 与结构索引;<br/>
/// - 为每个入口用 <see cref="ITagsProject.SetTurnProcessHandler"/> 挂一个"轮次完成"回调,做值 diff;<br/>
/// - 把结果序列化成 SSE 帧,扇出给各订阅者(每帧只序列化一次)。<br/>
/// 同时是 <see cref="IHostedService"/>(随宿主启停)与订阅者集合的持有者:订阅相关的成员只出现在内部的
/// 同时是订阅者集合的持有者:订阅相关的成员只出现在内部的
/// <see cref="ITagsStreamHub"/> 上,只服务同一程序集里的端点与 <see cref="SseFramesPiper"/>。
/// <para>
/// <b>启动时机</b>:由 <c>MapTagsStream()</c>(<see cref="TagsStreamRegistry.StartAll"/>)启动——
/// 映射端点必然发生在任何请求之前,也早于 <c>StartPollAsync</c>,因此<b>不依赖 Generic Host 的
/// <c>IHostedService</c> 机制</b>;启动后运行期的状态迁移一律以 <see cref="ITagsProjectCtrl.StartedOrStopped"/> 为准
/// (见 <see cref="OnStartedOrStopped"/>)。
/// </para>
/// <para>
/// <b>线程模型</b>:轮次回调在<b>轮询线程</b>上执行,只做 O(改动的点) 的字典写入 + 一次 <c>TryWrite</c>,
/// 不做序列化、不做 IO、不阻塞;序列化与扇出在单独的发送循环里,每个订阅者另有自己的写循环与有界队列。
/// 任何一环满了都只丢帧并触发重同步,不会反压到轮询。
/// </para>
/// </summary>
internal sealed class TagsStreamHostedService : ITagsStreamHub, IHostedService, IDisposable
internal sealed class TagsStreamHub : ITagsStreamHub, IDisposable
{
private readonly ITagsProjectCtrl _ctrl;
private readonly TagsStreamOptions _options;
private readonly ILogger<TagsStreamHostedService>? _logger;
private readonly ILogger<TagsStreamHub>? _logger;

/// <summary>订阅者集合与状态发布。<c>_transitionGate → _gate</c> 是唯一允许的加锁顺序。</summary>
private readonly object _gate = new();
Expand Down Expand Up @@ -53,7 +58,7 @@ internal sealed class TagsStreamHostedService : ITagsStreamHub, IHostedService,
/// <param name="ctrl">测点项目控制器(需由 <c>AddTagsProjectServices</c> 注册)</param>
/// <param name="options">配置</param>
/// <param name="logger">日志</param>
internal TagsStreamHostedService(ITagsProjectCtrl ctrl, TagsStreamOptions options, ILogger<TagsStreamHostedService>? logger = null)
internal TagsStreamHub(ITagsProjectCtrl ctrl, TagsStreamOptions options, ILogger<TagsStreamHub>? logger = null)
{
this._ctrl = ctrl ?? throw new ArgumentNullException(nameof(ctrl));
this._options = options ?? throw new ArgumentNullException(nameof(options));
Expand Down Expand Up @@ -87,12 +92,23 @@ internal TagsStreamHostedService(ITagsProjectCtrl ctrl, TagsStreamOptions option
/// <inheritdoc/>
public long Epoch => this._state.Epoch;

/// <inheritdoc/>
public Task StartAsync(CancellationToken cancellationToken)
/// <summary>
/// 幂等启动:订阅 <see cref="ITagsProjectCtrl.StartedOrStopped"/>、拉起发送循环与心跳循环,
/// 并按 <see cref="ITagsProjectCtrl.Project"/> 对账(补上"启动之前项目就已经在跑"的索引与轮次回调)。<br/>
/// 由 <c>MapTagsStream()</c> 统一调用(<see cref="TagsStreamRegistry.StartAll"/>);
/// 启动之后一直订阅到 <see cref="StopAsync"/>/<see cref="Dispose"/>——项目停止时不能退订,
/// 否则接不到下一次"已启动"。
/// <para>
/// <b>同步</b>:内部只有"订阅事件 + 起两个后台循环 + 一次对账",没有 IO,全部同步完成;
/// 所以刻意不返回 <see cref="Task"/>——调用方(同步的 <c>MapTagsStream</c>)不必也不需要阻塞等待,
/// 从而不给"同步等待异步"留下死锁口子。<see cref="StopAsync"/> 不同,它要等循环真正退出。
/// </para>
/// </summary>
public void Start()
{
if (Interlocked.Exchange(ref this._started, 1) != 0)
{
return Task.CompletedTask;
return;
}

var cts = new CancellationTokenSource();
Expand All @@ -103,10 +119,12 @@ public Task StartAsync(CancellationToken cancellationToken)

// 订阅之前项目可能就已经在跑(hub 启动得晚):先对一次账。
this.SyncWithController();
return Task.CompletedTask;
}

/// <inheritdoc/>
/// <summary>
/// 停止观测:退订事件、取消并等待发送/心跳循环退出。<br/>
/// 正常停机由 DI 容器释放单例时的 <see cref="Dispose"/> 兜底(只取消、不等待)。
/// </summary>
public async Task StopAsync(CancellationToken cancellationToken)
{
this._ctrl.StartedOrStopped -= this.OnStartedOrStopped;
Expand Down
2 changes: 1 addition & 1 deletion src/StdUnit.Tags.WebUi/Sse/StreamEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,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 = entry.HostedService.Subscribe(entries);
using var piper = entry.Hub.Subscribe(entries);

try
{
Expand Down
2 changes: 1 addition & 1 deletion src/StdUnit.Tags.WebUi/Tree/TreeEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static async Task HandleAsync(HttpContext context)
}

// 自己序列化:不走 MVC / 无参序列化,AOT/裁剪下没有反射路径。
var bytes = JsonSerializer.SerializeToUtf8Bytes(entry.HostedService.GetTree(), TagsStreamJsonContext.Default.TreeDto);
var bytes = JsonSerializer.SerializeToUtf8Bytes(entry.Hub.GetTree(), TagsStreamJsonContext.Default.TreeDto);

context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "application/json; charset=utf-8";
Expand Down
7 changes: 7 additions & 0 deletions src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StdUnit.Tags.WebUi.Internal;

namespace StdUnit.Tags.WebUi;
Expand Down Expand Up @@ -39,6 +40,7 @@ public static class TagsStreamExtensions_Endpoints
/// <b>设计器元数据</b>:<c>GET {prefix}/catalog</c> 把核心 + 驱动 schema 内省成 JSON,
/// <c>GET {prefix}/designer</c> 就是用它渲染的设计器页面。两者都与具体观测流无关,
/// 只有这一份、没有具名版本;只读,不涉及项目数据。
/// <br/>
/// </summary>
/// <param name="endpoints">端点构建器</param>
/// <param name="prefix">
Expand All @@ -48,6 +50,11 @@ public static class TagsStreamExtensions_Endpoints
/// <returns>路由组:可继续 <c>RequireAuthorization()</c> / <c>WithTags()</c> 等整组配置。</returns>
public static RouteGroupBuilder MapTagsStream(this IEndpointRouteBuilder endpoints, string prefix = EndpointConstants.DefaultPrefix)
{
// 观测流的**唯一启动点**:映射端点必然早于任何请求,也早于 StartPollAsync,
// 所以 hub 不需要注册成 IHostedService,也不依赖 Generic Host 的启动顺序。
// 只 map 不 AddTagsStream 时是 no-op(端点会在运行时回 404)。
endpoints.ServiceProvider.GetService<TagsStreamRegistry>()?.StartAll();

var group = endpoints.MapGroup(prefix.Length == 0 ? "/" : prefix);

// 不具名流:{prefix}/tree|stream|value|rescan|ui
Expand Down
31 changes: 9 additions & 22 deletions src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using StdUnit.Tags;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using StdUnit.Tags.WebUi.Internal;


Expand All @@ -18,10 +17,10 @@ public static class TagsStreamExtensions_ServiceCollection
/// 端点落在 <c>{prefix}/tree|stream|value|rescan|ui</c>。<br/>
/// 一个容器里只能有一路不具名流;要多路请用 <see cref="AddKeyedTagsStream"/>。<br/>
/// <br/>
/// <b>启动顺序</b>:hub 以 <see cref="IHostedService"/> 注册,随宿主一起启动。它在启动时按
/// <see cref="ITagsProjectCtrl.Project"/> 对账,所以<b>晚启动也能补上</b>:即使项目已在轮询,
/// 结构树与值流都正常(轮次回调运行期也能挂,从下一轮开始生效)。
/// 想尽早拿到结构树,就把轮询放在宿主启动之后(例如在请求里/更晚注册的 hosted service 里 <c>StartPollAsync</c>)。
/// <b>启动时机</b>:hub 在 <c>MapTagsStream()</c> 映射端点时启动(那必然早于任何请求,也早于 <c>StartPollAsync</c>),
/// <b>不依赖 Generic Host 的 <c>IHostedService</c></b>。DI 注册本来就在 <c>Build()</c> 之前完成、映射在其后,
/// 所以这个顺序天然满足;启动时它会按 <see cref="ITagsProjectCtrl.Project"/> 对账,
/// 项目已经在轮询也能补上(轮次回调运行期也能挂,从下一轮开始生效)。
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configure">这一路流的配置回调</param>
Expand All @@ -39,19 +38,12 @@ public static IServiceCollection AddTagsStream(this IServiceCollection services,

services.AddSingleton(new TagsStreamDescriptor(key: null, options));

// 同一实例注册为 IHostedService:用工厂而不是 AddHostedService<T>()——后者会另开
// IHostedService → T 的描述符再 new 一个,宿主启动的 hub 与端点看到的不是同一份数据。
services.AddSingleton<IHostedService>(
sp => sp.GetRequiredService<TagsStreamRegistry>()
.GetRequired(null)
.HostedService
);

// 不具名那一路按 ITagsStreamHub 解析(具名那一路是 keyed 服务)。
// 启动点是 MapTagsStream():见 TagsStreamRegistry.StartAll()。
services.TryAddSingleton<ITagsStreamHub>(
sp => sp.GetRequiredService<TagsStreamRegistry>()
.GetRequired(null)
.HostedService
.Hub
);

return services;
Expand All @@ -60,7 +52,7 @@ public static IServiceCollection AddTagsStream(this IServiceCollection services,
/// <summary>
/// 注册一路<b>具名</b>观测流:观测由 <c>AddKeyedTagsProjectCtrl(key)</c> 注册的 <see cref="ITagsProjectCtrl"/>,
/// 端点落在 <c>{prefix}/{key}/tree|stream|value|rescan|ui</c>(监控页各一份)。<br/>
/// 可与 <see cref="AddTagsStream"/> 并存;同一个 key 只能注册一路。启动顺序等约束同 <see cref="AddTagsStream"/>。
/// 可与 <see cref="AddTagsStream"/> 并存;同一个 key 只能注册一路。启动时机等约束同 <see cref="AddTagsStream"/>。
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="projctrlKey">
Expand All @@ -78,17 +70,12 @@ public static IServiceCollection AddKeyedTagsStream( this IServiceCollection ser

services.AddSingleton(new TagsStreamDescriptor(projctrlKey, options));

// 同一实例注册为 IHostedService / keyed ITagsStreamHub(原因见 AddTagsStream 里的注释)。
services.AddSingleton<IHostedService>(
sp => sp.GetRequiredService<TagsStreamRegistry>()
.GetRequired(projctrlKey)
.HostedService
);
// 同一实例注册为 keyed ITagsStreamHub(原因见 AddTagsStream 里的注释)。
services.AddKeyedSingleton<ITagsStreamHub>(
projctrlKey,
(sp, _) => sp.GetRequiredService<TagsStreamRegistry>()
.GetRequired(projctrlKey)
.HostedService
.Hub
);

return services;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ public static class TagsStreamExtensions_TagsProjectServiceBuilder
/// 和不具名的 <see cref="ITagsProjectCtrl"/> 关联;一个容器里最多只有一路不具名的观测流。
/// </summary>
/// <remarks>
/// <b>启动顺序</b>:hub 以 <c>IHostedService</c> 注册,随宿主一起启动;它在启动时按 <c>ITagsProjectCtrl.Project</c> 对账,
/// <b>启动时机</b>:hub 在 <c>MapTagsStream()</c> 映射端点时启动(早于任何请求,也早于 <c>StartPollAsync</c>),
/// 不依赖 Generic Host 的 <c>IHostedService</c>。它在启动时按 <c>ITagsProjectCtrl.Project</c> 对账,
/// 所以<b>晚启动也能补上</b>——即使项目已在轮询,结构树与值流都正常(轮次回调运行期也能挂,从下一轮开始生效)。
/// 想尽早拿到结构树,就把轮询放在宿主启动之后(例如在请求里/更晚注册的 hosted service 里 <c>StartPollAsync</c>)。
/// </remarks>
/// <param name="builder"></param>
/// <param name="configure">这一路流的配置回调</param>
Expand All @@ -33,7 +33,7 @@ public static TagsProjectServiceBuilder AddTagsStream(this TagsProjectServiceBui
/// </summary>
/// <remarks>
/// 时序约束同 <see cref="AddTagsStream(TagsProjectServiceBuilder, Action{TagsStreamOptions})"/>:
/// hub 在启动时对账,晚启动也能补上,顺序只影响"多早开始观测"。
/// hub 在 <c>MapTagsStream()</c> 时启动,端口映射发生在任何请求之前,所以顺序只影响"多早开始观测"。
/// </remarks>
/// <param name="builder"></param>
/// <param name="projctrlKey">
Expand Down
6 changes: 3 additions & 3 deletions tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,10 @@ public ITagsProject StartProject(XElement xml)
return project;
}

public TagsStreamHostedService StartHub(string? key = null)
public TagsStreamHub StartHub(string? key = null)
{
var hub = this.Root.GetRequiredService<TagsStreamRegistry>().GetRequired(key).HostedService;
hub.StartAsync(default).GetAwaiter().GetResult();
var hub = this.Root.GetRequiredService<TagsStreamRegistry>().GetRequired(key).Hub;
hub.Start();
return hub;
}

Expand Down
Loading
Loading