diff --git a/DMS.Infrastructure.UnitTests/Repository_Test/BaseRepositoryTests.cs b/DMS.Infrastructure.UnitTests/Repository_Test/BaseRepositoryTests.cs index bc84781..a35af19 100644 --- a/DMS.Infrastructure.UnitTests/Repository_Test/BaseRepositoryTests.cs +++ b/DMS.Infrastructure.UnitTests/Repository_Test/BaseRepositoryTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; +using DMS.Config; namespace DMS.Infrastructure.UnitTests.Repository_Test { @@ -18,7 +19,10 @@ namespace DMS.Infrastructure.UnitTests.Repository_Test // Load real connection settings var connectionSettings = new Config.AppSettings() { - Database = "DMS_test" + Database = new DatabaseSettings() + { + Database = "dms_test" + } }; _sqlSugarDbContext = new SqlSugarDbContext(connectionSettings); _sqlSugarDbContext.GetInstance().DbMaintenance.CreateDatabase(); diff --git a/DMS.Infrastructure/Services/VarDataService.cs b/DMS.Infrastructure/Services/VarDataService.cs index c25c321..590f584 100644 --- a/DMS.Infrastructure/Services/VarDataService.cs +++ b/DMS.Infrastructure/Services/VarDataService.cs @@ -9,142 +9,18 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using DMS.Infrastructure.Repositories; namespace DMS.Infrastructure.Services { - public class VarDataService + public class VarDataService : BaseService { private readonly IMapper _mapper; - private readonly SqlSugarDbContext _dbContext; - private SqlSugarClient Db => _dbContext.GetInstance(); - public VarDataService(IMapper mapper, SqlSugarDbContext dbContext) + public VarDataService(IMapper mapper, VarDataRepository repository) : base(mapper, repository) { _mapper = mapper; - _dbContext = dbContext; } - public async Task AddAsync(IEnumerable variableDatas) - { - Stopwatch stopwatch = new Stopwatch(); - stopwatch.Start(); - Stopwatch stopwatch2 = new Stopwatch(); - stopwatch2.Start(); - var dbList = variableDatas.Select(vb => _mapper.Map(vb)) - .ToList(); - stopwatch2.Stop(); - //NlogHelper.Info($"复制 Variable'{variableDatas.Count()}'个, 耗时:{stopwatch2.ElapsedMilliseconds}ms"); - - var res = await Db.Insertable(dbList) - .ExecuteCommandAsync(); - - stopwatch.Stop(); - //NlogHelper.Info($"新增VariableData '{variableDatas.Count()}'个, 耗时:{stopwatch.ElapsedMilliseconds}ms"); - return res; - } - - public async Task UpdateAsync(List variableDatas) - { - Stopwatch stopwatch = new Stopwatch(); - stopwatch.Start(); - - var dbVarDatas = variableDatas.Select(vd => _mapper.Map(vd)); - var result = await Db.Updateable(dbVarDatas.ToList()) - .ExecuteCommandAsync(); - - stopwatch.Stop(); - //NlogHelper.Info($"更新VariableData {variableDatas.Count()}个 耗时:{stopwatch.ElapsedMilliseconds}ms"); - return result; - } - - public async Task DeleteAsync(IEnumerable variableDatas) - { - Stopwatch stopwatch = new Stopwatch(); - stopwatch.Start(); - - var dbList = variableDatas.Select(vd => _mapper.Map(vd)) - .ToList(); - var result = await Db.Deleteable(dbList) - .ExecuteCommandAsync(); - - stopwatch.Stop(); - //NlogHelper.Info($"删除VariableData: '{variableDatas.Count()}'个 耗时:{stopwatch.ElapsedMilliseconds}ms"); - return result; - } - - public async Task AddMqttToVariablesAsync(IEnumerable variableMqttList) - { - await Db.BeginTranAsync(); - - try - { - int affectedCount = 0; - var variableIds = variableMqttList.Select(vm => vm.Variable.Id).Distinct().ToList(); - var mqttIds = variableMqttList.Select(vm => vm.Mqtt.Id).Distinct().ToList(); - - // 1. 一次性查询所有相关的现有别名 - var existingAliases = await Db.Queryable() - .Where(it => variableIds.Contains(it.VariableId) && mqttIds.Contains(it.MqttId)) - .ToListAsync(); - - var existingAliasesDict = existingAliases - .ToDictionary(a => (a.VariableId, a.Mqtt.Id), a => a); - - var toInsert = new List(); - var toUpdate = new List(); - - foreach (var variableMqtt in variableMqttList) - { - var key = (variableMqtt.Variable.Id, variableMqtt.Mqtt.Id); - if (existingAliasesDict.TryGetValue(key, out var existingAlias)) - { - // 如果存在但别名不同,则准备更新 - // if (existingAlias.MqttAlias != variableMqtt.MqttAlias) - // { - // existingAlias.MqttAlias = variableMqtt.MqttAlias; - // existingAlias.UpdateTime = DateTime.Now; - // toUpdate.Add(existingAlias); - // } - } - else - { - // 如果不存在,则准备插入 - toInsert.Add(new DbVariableMqtt - { - VariableId = variableMqtt.Variable.Id, - MqttId = variableMqtt.Mqtt.Id, - // MqttAlias = variableMqtt.MqttAlias, - CreateTime = DateTime.Now, - UpdateTime = DateTime.Now - }); - } - } - - // 2. 批量更新 - if (toUpdate.Any()) - { - var updateResult = await Db.Updateable(toUpdate).ExecuteCommandAsync(); - affectedCount += updateResult; - } - - // 3. 批量插入 - if (toInsert.Any()) - { - var insertResult = await Db.Insertable(toInsert).ExecuteCommandAsync(); - affectedCount += insertResult; - } - - await Db.CommitTranAsync(); - //NlogHelper.Info($"成功为 {variableMqttList.Count()} 个变量请求添加/更新了MQTT服务器关联,实际影响 {affectedCount} 个。"); - return affectedCount; - } - catch (Exception ex) - { - await Db.RollbackTranAsync(); - //NlogHelper.Error($"为变量添加MQTT服务器关联时发生错误: {ex.Message}", ex); - // 根据需要,可以向上层抛出异常 - throw; - } - } } } diff --git a/DMS.Infrastructure/Services/VarTableService.cs b/DMS.Infrastructure/Services/VarTableService.cs new file mode 100644 index 0000000..c3cd46b --- /dev/null +++ b/DMS.Infrastructure/Services/VarTableService.cs @@ -0,0 +1,13 @@ +using AutoMapper; +using DMS.Core.Models; +using DMS.Infrastructure.Entities; +using DMS.Infrastructure.Repositories; + +namespace DMS.Infrastructure.Services; + +public class VarTableService : BaseService +{ + public VarTableService(IMapper mapper, VarTableRepository repository) : base(mapper, repository) + { + } +} \ No newline at end of file diff --git a/软件设计文档/00-软件设计蓝图(最终版).md b/软件设计文档/00-软件设计蓝图(最终版).md new file mode 100644 index 0000000..9cef823 --- /dev/null +++ b/软件设计文档/00-软件设计蓝图(最终版).md @@ -0,0 +1,21 @@ +# 设备管理系统(DMS) - 软件设计蓝图 (最终版) + +本文档是设备管理系统(DMS)的最终软件设计蓝图。它整合了所有设计阶段的探讨成果,形成了一套完整的、一致的、可供开发团队直接使用的技术规范。 + +## 文档结构与阅读顺序 + +请遵循以下顺序阅读文档,以获得从宏观到微观的最佳理解: + +1. **`01-项目总体设计与依赖.md`**: 描述了项目的分层架构、各层职责以及每个项目所需的NuGet包依赖。 +2. **`02-核心领域模型与接口.md`**: 定义了`DMS.Core`项目,它是整个系统的基石,包含所有业务实体和核心接口。 +3. **`03-应用服务与数据传输对象.md`**: 定义了`DMS.Application`层,负责编排业务逻辑和数据转换。 +4. **`04-基础设施层-仓储与事务.md`**: 详细设计了数据库实体、仓储实现以及使用`IRepositoryManager`进行事务管理。 +5. **`05-基础设施层-后台服务与通信.md`**: 详细设计了S7通信的“编排者-代理”模式,以及与MQTT的集成。 +6. **`06-核心服务-中央通道总线设计.md`**: 阐述了作为系统高性能通信骨架的`IChannelBus`服务。 +7. **`07-核心服务-日志记录与聚合过滤.md`**: 阐述了基于NLog的、带有智能过滤功能的日志系统。 +8. **`08-WPF表现层-MVVM与响应式UI.md`**: 详细设计了WPF层的MVVM架构,以及如何通过`ItemViewModel`和消息总线构建响应式UI。 +9. **`09-WPF表现层-动态菜单与导航.md`**: 阐述了基于数据库的动态菜单和参数化导航系统的设计。 +10. **`10-专题设计-MQTT别名关联.md`**: 专门针对“变量-服务器”的别名需求,设计了“关联实体”方案。 + +--- +*文档生成日期: 2025年7月20日* diff --git a/软件设计文档/01-项目总体设计与依赖.md b/软件设计文档/01-项目总体设计与依赖.md new file mode 100644 index 0000000..3122417 --- /dev/null +++ b/软件设计文档/01-项目总体设计与依赖.md @@ -0,0 +1,100 @@ +# 01. 项目总体设计与依赖 + +本文档定义了项目的最终分层架构、各项目职责以及每个项目所需的NuGet包依赖清单。 + +## 1. 项目分层架构 + +### 1.1. 设计思路与考量 + +* **分层架构 (Layered Architecture)**:采用经典的洋葱架构(或称整洁架构)思想,将整个系统划分为 `Core` (核心)、`Application` (应用)、`Infrastructure` (基础设施) 和 `WPF` (表现) 四个逻辑层。 +* **依赖倒置原则 (Dependency Inversion Principle, DIP)**:高层模块(如 `Application`)不直接依赖低层模块(如 `Infrastructure`)的具体实现,而是依赖于抽象(接口)。这些抽象定义在 `Core` 层。 + +### 1.2. 设计优势 + +* **高内聚、低耦合**:每个层级职责单一,内部功能紧密相关(高内聚),层与层之间通过接口而非具体实现进行交互,降低了相互依赖性(低耦合)。 +* **可维护性**:当某个技术实现(如数据库从SqlSugar切换到EF Core)发生变化时,只需修改 `Infrastructure` 层,对 `Application` 和 `Core` 层的影响最小。 +* **可测试性**:核心业务逻辑(`Core` 和 `Application`)不依赖外部技术,可以独立进行单元测试,无需启动数据库或外部服务,提高了测试效率和覆盖率。 +* **可扩展性**:易于引入新的功能模块或替换现有技术栈,例如,增加新的通信协议或更换UI框架。 +* **业务逻辑清晰**:核心业务规则集中在 `Core` 层,应用层负责业务流程的编排,使得业务逻辑一目了然。 + +### 1.3. 设计劣势/权衡 + +* **初期复杂性**:相比于单体应用或简单的三层架构,分层架构在项目初期需要更多的设计投入和代码组织,增加了学习曲线。 +* **层间通信开销**:严格的分层可能导致数据在层间传递时需要进行多次对象转换(如领域模型 -> DTO -> 数据库实体),增加了少量运行时开销和代码量。 +* **过度设计风险**:对于非常简单的应用,过度分层可能引入不必要的复杂性,反而降低开发效率。 + +### 1.4. 各层职责 + +| 项目名 | 核心职责 | 关键内容 | +| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `DMS.Core` | **领域核心**:定义业务规则和数据结构,不依赖任何具体技术实现。是整个系统的稳定内核。 | 领域模型 (`Device`), 业务枚举, 核心接口 (`IRepositoryManager`, `IDeviceRepository`)。 | +| `DMS.Application` | **应用服务**:编排领域模型完成业务用例(Use Case),处理DTO转换。是UI层与核心层的桥梁。 | 应用服务 (`DeviceAppService`), 数据传输对象 (DTOs), AutoMapper配置。 | +| `DMS.Infrastructure` | **基础设施**:提供所有外部技术的具体实现,如数据库访问、协议通信、日志记录等。负责实现核心层的接口。 | 数据库实体, `RepositoryManager`实现, S7/MQTT通信服务, NLog日志Target。 | +| `DMS.WPF` | **表现层**:提供用户界面(UI),采用MVVM模式。负责用户交互、数据显示和请求派发。 | 视图 (Views), 视图模型 (ViewModels), 核心UI服务 (`IChannelBus`, `INavigationService`)。 | + +### 1.5. 依赖关系图 + +```mermaid +graph TD + DMS.WPF --> DMS.Application + DMS.WPF --> DMS.Infrastructure + DMS.Application --> DMS.Core + DMS.Infrastructure --> DMS.Core +``` + +## 2. NuGet包依赖清单 + +### 2.1. 设计思路与考量 + +* **按需引入**:每个项目只引入其职责范围内必需的NuGet包,避免不必要的依赖膨胀。 +* **版本管理**:建议使用 `Directory.Packages.props` 或 `PackageReference` 统一管理所有项目的NuGet包版本,确保一致性。 +* **稳定性优先**:优先选择成熟、稳定且社区活跃的库。 + +### 2.2. 设计优势 + +* **项目职责清晰**:通过依赖的NuGet包可以直观地看出该项目的职责和技术栈。 +* **减少冲突**:避免不同项目引入相同库的不同版本导致的冲突。 +* **优化构建速度**:减少不必要的引用可以略微加快项目构建速度。 + +### 2.3. 设计劣势/权衡 + +* **管理开销**:需要手动为每个项目添加引用,并确保版本一致性(尽管有工具可以辅助)。 +* **学习成本**:对于不熟悉特定库的开发者,需要额外学习其用法。 + +### 2.4. 各项目依赖 + +### `DMS.Core` +* **设计思路**:作为核心业务逻辑层,应保持技术中立和纯净,不依赖任何外部框架或第三方库。 +* **优势**:最大化可移植性和可测试性,确保业务规则的稳定性,不受外部技术变化的影响。 +* **劣势**:如果某些通用工具类(如简单的辅助函数)被多个层使用,可能需要在 `Core` 层手动实现或复制,而不是直接引用一个通用库。 +* **依赖**:无任何第三方依赖。 + +### `DMS.Application` +* **设计思路**:负责业务流程编排和数据转换,需要对象映射工具。 +* **优势**:`AutoMapper` 简化了DTO与领域模型之间的转换,减少了大量重复的映射代码。 +* **劣势**:引入了额外的映射配置,对于简单的映射可能显得有些重,且需要一定的学习成本。 +* **依赖**: + * `AutoMapper` + * `AutoMapper.Extensions.Microsoft.DependencyInjection` + +### `DMS.Infrastructure` +* **设计思路**:作为技术实现层,需要与数据库、PLC、MQTT等外部系统交互,因此需要引入相应的技术栈库。 +* **优势**:利用成熟的第三方库(如 `SqlSugar`, `S7netplus`, `MQTTnet`)可以大大加速开发,并利用其经过验证的稳定性、性能和功能。 +* **劣势**:对特定库的依赖性强,未来替换成本较高;需要关注库的更新和兼容性问题。 +* **依赖**: + * `SqlSugarCore` + * `S7netplus` + * `MQTTnet` + * `MQTTnet.Extensions.ManagedClient` + * `NLog.Extensions.Logging` + +### `DMS.WPF` +* **设计思路**:作为表现层,需要UI框架、MVVM支持、依赖注入和日志集成。 +* **优势**:`iNKORE.UI.WPF.Modern` 提供现代化的UI组件;`CommunityToolkit.Mvvm` 简化MVVM开发;`Microsoft.Extensions.DependencyInjection` 提供标准的DI容器。 +* **劣势**:引入了UI框架和MVVM库的学习成本;UI框架可能存在一定的定制限制。 +* **依赖**: + * `Microsoft.Extensions.DependencyInjection` + * `Microsoft.Extensions.Hosting` (用于 `IHostedService`) + * `CommunityToolkit.Mvvm` + * `iNKORE.UI.WPF.Modern` + * `NLog.Extensions.Logging` \ No newline at end of file diff --git a/软件设计文档/02-核心领域模型与接口.md b/软件设计文档/02-核心领域模型与接口.md new file mode 100644 index 0000000..8b52afb --- /dev/null +++ b/软件设计文档/02-核心领域模型与接口.md @@ -0,0 +1,461 @@ +# 02. 核心领域模型与接口 + +本文档定义了 `DMS.Core` 项目的最终设计,它是整个系统的基石,包含所有业务实体和核心接口的定义。 + +## 1. 目录结构 + +``` +DMS.Core/ +├── Enums/ +│ ├── PollLevelType.cs +│ ├── ProtocolType.cs +│ └── SignalType.cs +├── Models/ +│ ├── Device.cs +│ ├── MqttServer.cs +│ ├── Variable.cs +│ ├── VariableMqttAlias.cs +│ └── VariableTable.cs +├── Interfaces/ +│ ├── IRepositoryManager.cs +│ ├── IBaseRepository.cs +│ ├── IDeviceRepository.cs +│ ├── IMqttServerRepository.cs +│ ├── IVariableMqttAliasRepository.cs +│ ├── IVariableRepository.cs +│ └── IVariableTableRepository.cs +└── DMS.Core.csproj +``` + +## 2. 核心接口 + +### 2.1. `IRepositoryManager.cs` (工作单元模式) + +* **设计思路**:实现工作单元(Unit of Work, UoW)模式。它作为所有仓储的统一入口,并管理数据库事务。所有通过 `IRepositoryManager` 获取的仓储实例都共享同一个数据库上下文和事务。 +* **优势**: + * **原子性**:确保跨多个仓储的操作(如创建设备、变量表和菜单)要么全部成功,要么全部失败,维护数据一致性。 + * **简化事务管理**:应用层无需直接与数据库事务API交互,只需调用 `BeginTransaction()`, `CommitAsync()`, `RollbackAsync()`。 + * **解耦**:应用层不直接依赖具体的仓储实现,而是依赖于 `IRepositoryManager` 这一抽象。 + * **资源优化**:在单个业务操作中,所有仓储共享同一个数据库连接,减少了连接开销。 +* **劣势/权衡**: + * **复杂性增加**:相比于直接使用仓储,引入UoW模式增加了额外的抽象层和概念。 + * **仓储依赖**:`IRepositoryManager` 接口需要列出所有它管理的仓储,当新增仓储时,需要修改此接口。 + +```csharp +// 文件: DMS.Core/Interfaces/IRepositoryManager.cs +namespace DMS.Core.Interfaces; + +/// +/// 定义了一个仓储管理器,它使用工作单元模式来组合多个仓储操作,以确保事务的原子性。 +/// 实现了IDisposable,以确保数据库连接等资源能被正确释放。 +/// +public interface IRepositoryManager : IDisposable +{ + /// + /// 获取设备仓储的实例。 + /// 所有通过此管理器获取的仓储都共享同一个数据库上下文和事务。 + /// + IDeviceRepository Devices { get; } + + /// + /// 获取变量表仓储的实例。 + /// + IVariableTableRepository VariableTables { get; } + + /// + /// 获取变量仓储的实例。 + /// + IVariableRepository Variables { get; } + + /// + /// 获取MQTT服务器仓储的实例。 + /// + IMqttServerRepository MqttServers { get; } + + /// + /// 获取变量MQTT别名仓储的实例。 + /// + IVariableMqttAliasRepository VariableMqttAliases { get; } + + /// + /// 获取菜单仓储的实例。 + /// + IMenuRepository Menus { get; } + + /// + /// 开始一个新的数据库事务。 + /// + void BeginTransaction(); + + /// + /// 异步提交当前事务中的所有变更。 + /// + /// 一个表示异步操作的任务。 + Task CommitAsync(); + + /// + /// 异步回滚当前事务中的所有变更。 + /// + /// 一个表示异步操作的任务。 + Task RollbackAsync(); +} +``` + +### 2.2. 仓储接口 (`IBaseRepository.cs`, `IDeviceRepository.cs` 等) + +* **设计思路**:采用仓储(Repository)模式,为每个聚合根(或主要实体)定义一个数据访问接口。这些接口定义了对领域对象集合的操作,隐藏了底层数据存储的细节。 +* **优势**: + * **解耦**:将业务逻辑与数据访问技术(如SqlSugar、EF Core)分离,业务层不关心数据如何存储和检索。 + * **可测试性**:可以轻松地为仓储接口创建Mock或Stub实现,便于单元测试业务逻辑。 + * **领域驱动**:接口方法命名应反映领域语言,而不是数据库操作(如 `GetActiveDevicesWithDetailsAsync` 而非 `SelectFromDevicesJoinVariableTables`)。 +* **劣势/权衡**: + * **抽象开销**:对于简单的CRUD操作,引入仓储模式可能增加一些代码量和抽象层级。 + * **过度抽象**:如果每个查询都定义一个方法,接口会变得非常庞大。需要权衡通用查询和特定业务查询。 + +```csharp +// 文件: DMS.Core/Interfaces/IBaseRepository.cs +namespace DMS.Core.Interfaces; + +/// +/// 提供泛型数据访问操作的基础仓储接口。 +/// +/// 领域模型的类型。 +public interface IBaseRepository where T : class +{ + /// + /// 异步根据ID获取单个实体。 + /// + /// 实体的主键ID。 + /// 找到的实体,如果不存在则返回null。 + Task GetByIdAsync(int id); + + /// + /// 异步获取所有实体。 + /// + /// 所有实体的列表。 + Task> GetAllAsync(); + + /// + /// 异步添加一个新实体。 + /// + /// 要添加的实体。 + Task AddAsync(T entity); + + /// + /// 异步更新一个已存在的实体。 + /// + /// 要更新的实体。 + Task UpdateAsync(T entity); + + /// + /// 异步根据ID删除一个实体。 + /// + /// 要删除的实体的主键ID。 + Task DeleteAsync(int id); +} + +// 文件: DMS.Core/Interfaces/IDeviceRepository.cs +using DMS.Core.Models; + +namespace DMS.Core.Interfaces; + +/// +/// 继承自IBaseRepository,提供设备相关的特定数据查询功能。 +/// +public interface IDeviceRepository : IBaseRepository +{ + /// + /// 异步获取所有激活的设备,并级联加载其下的变量表和变量。 + /// 这是后台轮询服务需要的主要数据。 + /// + /// 包含完整层级结构的激活设备列表。 + Task> GetActiveDevicesWithDetailsAsync(ProtocolType protocol); + + /// + /// 异步根据设备ID获取设备及其所有详细信息(变量表、变量、MQTT别名等)。 + /// + /// 设备ID。 + /// 包含详细信息的设备对象。 + Task GetDeviceWithDetailsAsync(int deviceId); +} +``` + +## 3. 核心领域模型 + +* **设计思路**:领域模型是业务规则和数据的核心载体。它们是贫血模型(Anemic Domain Model),主要包含数据属性,行为逻辑则由应用服务和领域服务(如果需要)处理。模型之间通过导航属性(如 `List`)建立关系,反映业务实体间的关联。 +* **优势**: + * **清晰反映业务**:模型结构直接对应业务概念,易于理解和沟通。 + * **可持久化**:模型可以直接或通过映射转换为数据库实体进行持久化。 + * **技术中立**:不包含任何与特定技术(如数据库、UI)相关的代码。 +* **劣势/权衡**: + * **贫血模型争议**:一些DDD(领域驱动设计)倡导者认为贫血模型将数据和行为分离,导致领域逻辑分散。但在CRUD为主的应用中,这种简单性通常是可接受的。 + * **对象图管理**:当模型之间存在复杂关系时,加载完整的对象图可能需要复杂的查询和映射。 + +### `Device.cs` + +```csharp +// 文件: DMS.Core/Models/Device.cs +/// +/// 代表一个可管理的物理或逻辑设备。 +/// +public class Device +{ + /// + /// 唯一标识符。 + /// + public int Id { get; set; } + + /// + /// 设备名称,用于UI显示和识别。 + /// + public string Name { get; set; } + + /// + /// 设备使用的通信协议。 + /// + public ProtocolType Protocol { get; set; } + + /// + /// 设备的IP地址。 + /// + public string IpAddress { get; set; } + + /// + /// 设备的通信端口号。 + /// + public int Port { get; set; } + + /// + /// S7 PLC的机架号。 + /// + public int Rack { get; set; } + + /// + /// S7 PLC的槽号。 + /// + public int Slot { get; set; } + + /// + /// 指示此设备是否处于激活状态。只有激活的设备才会被轮询。 + /// + public bool IsActive { get; set; } + + /// + /// 此设备包含的变量表集合。 + /// + public List VariableTables { get; set; } = new(); +} +``` + +### `Variable.cs` + +```csharp +// 文件: DMS.Core/Models/Variable.cs +/// +/// 核心数据点,代表从设备读取的单个值。 +/// +public class Variable +{ + /// + /// 唯一标识符。 + /// + public int Id { get; set; } + + /// + /// 变量名。 + /// + public string Name { get; set; } + + /// + /// 在设备中的地址 (例如: DB1.DBD0, M100.0)。 + /// + public string Address { get; set; } + + /// + /// 变量的数据类型。 + /// + public SignalType DataType { get; set; } + + /// + /// 变量的轮询级别,决定了其读取频率。 + /// + public PollLevelType PollLevel { get; set; } + + /// + /// 指示此变量是否处于激活状态。 + /// + public bool IsActive { get; set; } + + /// + /// 所属变量表的ID。 + /// + public int VariableTableId { get; set; } + + /// + /// 所属变量表的导航属性。 + /// + public VariableTable VariableTable { get; set; } + + /// + /// 此变量的所有MQTT发布别名关联。一个变量可以关联多个MQTT服务器,每个关联可以有独立的别名。 + /// + public List MqttAliases { get; set; } = new(); + + /// + /// 存储从设备读取到的最新值。此属性不应持久化到数据库,仅用于运行时。 + /// + [System.ComponentModel.DataAnnotations.Schema.NotMapped] // 标记此属性不映射到数据库 + public object DataValue { get; set; } +} +``` + +### `VariableMqttAlias.cs` + +```csharp +// 文件: DMS.Core/Models/VariableMqttAlias.cs +/// +/// 领域模型:代表一个变量到一个MQTT服务器的特定关联,包含专属别名。 +/// 这是一个关联实体,用于解决多对多关系中需要额外属性(别名)的问题。 +/// +public class VariableMqttAlias +{ + /// + /// 唯一标识符。 + /// + public int Id { get; set; } + + /// + /// 关联的变量ID。 + /// + public int VariableId { get; set; } + + /// + /// 关联的MQTT服务器ID。 + /// + public int MqttServerId { get; set; } + + /// + /// 针对此特定[变量-服务器]连接的发布别名。此别名将用于构建MQTT Topic。 + /// + public string Alias { get; set; } + + /// + /// 关联的变量导航属性。 + /// + public Variable Variable { get; set; } + + /// + /// 关联的MQTT服务器导航属性。 + /// + public MqttServer MqttServer { get; set; } +} +``` + +## 4. 核心枚举 + +* **设计思路**:使用C#枚举来表示业务中固定的、有限的分类,如协议类型、信号类型、轮询级别。这提供了类型安全和代码可读性。 +* **优势**: + * **类型安全**:避免使用魔术字符串或整数,减少运行时错误。 + * **代码可读性**:提高代码的自解释性。 + * **易于维护**:所有可能的值集中管理,修改方便。 +* **劣势/权衡**: + * **扩展性**:如果枚举值需要频繁变动或由用户定义,则枚举可能不是最佳选择,可能需要考虑数据库驱动的查找表。 + * **持久化**:枚举在数据库中通常存储为整数或字符串,需要注意映射和转换。 + +### `PollLevelType.cs` + +```csharp +// 文件: DMS.Core/Enums/PollLevelType.cs +/// +/// 定义了变量的轮询级别,决定了其读取频率。 +/// +public enum PollLevelType +{ + /// + /// 不进行轮询。 + /// + Off, + + /// + /// 高频轮询(例如:200ms)。 + /// + High, + + /// + /// 中频轮询(例如:1000ms)。 + /// + Medium, + + /// + /// 低频轮询(例如:5000ms)。 + /// + Low +} +``` + +### `ProtocolType.cs` + +```csharp +// 文件: DMS.Core/Enums/ProtocolType.cs +/// +/// 定义了设备支持的通信协议类型。 +/// +public enum ProtocolType +{ + /// + /// Siemens S7 通信协议。 + /// + S7, + + /// + /// OPC UA (Unified Architecture) 协议。 + /// + OpcUa, + + /// + /// Modbus TCP 协议。 + /// + ModbusTcp +} +``` + +### `SignalType.cs` + +```csharp +// 文件: DMS.Core/Enums/SignalType.cs +/// +/// 定义了变量支持的数据类型。 +/// +public enum SignalType +{ + /// + /// 布尔值 (true/false)。 + /// + Boolean, + + /// + /// 字节 (8-bit 无符号整数)。 + /// + Byte, + + /// + /// 16位有符号整数。 + /// + Int16, + + /// + /// 32位有符号整数。 + /// + Int32, + + /// + /// 单精度浮点数。 + /// + Float, + + /// + /// 字符串。 + /// + String +} +``` \ No newline at end of file diff --git a/软件设计文档/03-应用服务与数据传输对象.md b/软件设计文档/03-应用服务与数据传输对象.md new file mode 100644 index 0000000..4ad86e9 --- /dev/null +++ b/软件设计文档/03-应用服务与数据传输对象.md @@ -0,0 +1,302 @@ +# 03. 应用服务与数据传输对象 + +本文档定义了 `DMS.Application` 层的最终设计。该层作为业务逻辑的协调者,负责处理用例、数据转换,并作为表现层与核心层的桥梁。 + +## 1. 目录结构 + +``` +DMS.Application/ +├── DTOs/ +│ ├── DeviceDto.cs +│ ├── VariableDto.cs +│ ├── VariableMqttAliasDto.cs +│ └── ... (其他增、删、改、查相关的DTO) +├── Interfaces/ +│ ├── IDeviceAppService.cs +│ ├── IMqttAliasAppService.cs +│ └── ... (其他应用服务接口) +├── Services/ +│ ├── DeviceAppService.cs +│ ├── MqttAliasAppService.cs +│ └── ... (其他应用服务实现) +├── Profiles/ +│ └── MappingProfile.cs +└── DMS.Application.csproj +``` + +## 2. 应用服务设计 + +### 2.1. 设计思路与考量 + +* **职责**:应用服务(Application Services)是应用程序的用例(Use Case)实现者。它们负责编排领域模型和仓储来完成特定的业务操作,处理事务、授权、日志等应用级关注点。 +* **依赖**:应用服务依赖于 `DMS.Core` 中定义的接口(如 `IRepositoryManager`),而不直接依赖 `DMS.Infrastructure` 的具体实现,遵循依赖倒置原则。 +* **事务管理**:通过注入 `IRepositoryManager` 来管理事务,确保业务操作的原子性。 + +### 2.2. 设计优势 + +* **业务流程清晰**:每个应用服务方法对应一个具体的业务用例,代码结构清晰,易于理解业务流程。 +* **解耦**:将业务逻辑与UI层和数据访问层解耦,提高了代码的可维护性和可测试性。 +* **事务边界明确**:应用服务是定义事务边界的理想位置,确保业务操作的完整性。 +* **可重用性**:应用服务可以被不同的客户端(如WPF UI、Web API等)复用。 + +### 2.3. 设计劣势/权衡 + +* **代码量增加**:相比于直接在UI层或仓储层编写业务逻辑,应用服务模式会增加一些代码量和抽象层级。 +* **贫血应用服务**:如果应用服务仅仅是简单地调用仓储方法,而没有包含任何业务逻辑,则可能退化为“贫血应用服务”,失去了其应有的价值。 + +### 2.4. 示例:`IDeviceAppService.cs` + +```csharp +// 文件: DMS.Application/Interfaces/IDeviceAppService.cs +using DMS.Application.DTOs; +using DMS.Core.Enums; + +namespace DMS.Application.Interfaces; + +/// +/// 定义设备管理相关的应用服务操作。 +/// +public interface IDeviceAppService +{ + /// + /// 异步根据ID获取设备DTO。 + /// + Task GetDeviceByIdAsync(int id); + + /// + /// 异步获取所有设备DTO列表。 + /// + Task> GetAllDevicesAsync(); + + /// + /// 异步创建一个新设备及其关联的变量表和菜单(事务性操作)。 + /// + /// 包含设备、变量表和菜单信息的DTO。 + /// 新创建设备的ID。 + Task CreateDeviceWithDetailsAsync(CreateDeviceWithDetailsDto dto); + + /// + /// 异步更新一个已存在的设备。 + /// + Task UpdateDeviceAsync(UpdateDeviceDto deviceDto); + + /// + /// 异步删除一个设备。 + /// + Task DeleteDeviceAsync(int id); + + /// + /// 异步切换设备的激活状态。 + /// + Task ToggleDeviceActiveStateAsync(int id); + + /// + /// 异步获取指定协议类型的设备列表。 + /// + Task> GetDevicesByProtocolAsync(ProtocolType protocol); +} +``` + +### 2.5. 示例:`DeviceAppService.cs` + +```csharp +// 文件: DMS.Application/Services/DeviceAppService.cs +using AutoMapper; +using DMS.Core.Interfaces; +using DMS.Core.Models; + +namespace DMS.Application.Services; + +/// +/// 实现设备管理的应用服务。 +/// +public class DeviceAppService : IDeviceAppService +{ + private readonly IRepositoryManager _repoManager; + private readonly IMapper _mapper; + + /// + /// 构造函数,通过依赖注入获取仓储管理器和AutoMapper实例。 + /// + public DeviceAppService(IRepositoryManager repoManager, IMapper mapper) + { + _repoManager = repoManager; + _mapper = mapper; + } + + /// + /// 异步创建一个新设备及其关联的变量表和菜单(事务性操作)。 + /// + public async Task CreateDeviceWithDetailsAsync(CreateDeviceWithDetailsDto dto) + { + try + { + _repoManager.BeginTransaction(); + + var device = _mapper.Map(dto.Device); + device.IsActive = true; // 默认激活 + await _repoManager.Devices.AddAsync(device); + + // 假设 CreateDeviceWithDetailsDto 包含了变量表和菜单信息 + if (dto.VariableTable != null) + { + var variableTable = _mapper.Map(dto.VariableTable); + variableTable.DeviceId = device.Id; // 关联新设备ID + await _repoManager.VariableTables.AddAsync(variableTable); + } + + // 假设有菜单服务或仓储 + // if (dto.Menu != null) + // { + // var menu = _mapper.Map(dto.Menu); + // menu.TargetId = device.Id; + // await _repoManager.Menus.AddAsync(menu); + // } + + await _repoManager.CommitAsync(); + + return device.Id; + } + catch (Exception ex) + { + await _repoManager.RollbackAsync(); + // 可以在此记录日志 + throw new ApplicationException("创建设备时发生错误,操作已回滚。", ex); + } + } + + /// + /// 异步获取所有设备DTO列表。 + /// + public async Task> GetAllDevicesAsync() + { + var devices = await _repoManager.Devices.GetAllAsync(); + return _mapper.Map>(devices); + } + + // ... 其他方法的实现 +} +``` + +## 3. 数据传输对象 (DTOs) + +### 3.1. 设计思路与考量 + +* **职责**:DTOs (Data Transfer Objects) 是用于在不同层之间(特别是应用层和表现层之间)传输数据的简单对象。它们通常是扁平的,只包含数据属性,不包含行为。 +* **隔离**:DTOs 隔离了领域模型和UI层,避免了领域模型直接暴露给UI,从而保护了领域模型的完整性和不变性。 +* **定制化**:DTOs 可以根据UI的需求进行定制,例如,只包含UI需要显示的字段,或者将多个领域模型的字段组合成一个DTO。 + +### 3.2. 设计优势 + +* **解耦**:UI层不直接依赖领域模型,领域模型的修改不会直接影响UI层。 +* **安全性**:避免了敏感数据或不必要的复杂领域逻辑暴露给UI层。 +* **性能优化**:可以只传输UI所需的数据,减少网络传输量(对于分布式系统)。 +* **简化UI绑定**:DTOs 通常是扁平的,更适合UI的数据绑定。 + +### 3.3. 设计劣势/权衡 + +* **映射开销**:需要在领域模型和DTO之间进行映射,增加了代码量和运行时开销(尽管AutoMapper可以简化)。 +* **DTOs 膨胀**:如果每个UI视图都需要一个独立的DTO,可能导致DTO类的数量膨胀。 + +### 3.4. 示例:`DeviceDto.cs` + +```csharp +// 文件: DMS.Application/DTOs/DeviceDto.cs +namespace DMS.Application.DTOs; + +/// +/// 用于在UI上显示设备基本信息的DTO。 +/// +public class DeviceDto +{ + public int Id { get; set; } + public string Name { get; set; } + public string Protocol { get; set; } + public string IpAddress { get; set; } + public int Port { get; set; } + public bool IsActive { get; set; } + public string Status { get; set; } // "在线", "离线", "连接中..." +} +``` + +### 3.5. 示例:`CreateDeviceDto.cs` + +```csharp +// 文件: DMS.Application/DTOs/CreateDeviceDto.cs +using DMS.Core.Enums; + +namespace DMS.Application.DTOs; + +/// +/// 用于创建新设备时传输数据的DTO。 +/// +public class CreateDeviceDto +{ + public string Name { get; set; } + public ProtocolType Protocol { get; set; } + public string IpAddress { get; set; } + public int Port { get; set; } +} +``` + +## 4. AutoMapper 配置 + +### 4.1. 设计思路与考量 + +* **自动化映射**:使用 `AutoMapper` 库来自动化领域模型和DTO之间的对象映射,减少手动映射的重复代码。 +* **集中配置**:所有映射规则集中在一个 `MappingProfile` 类中进行配置。 + +### 4.2. 设计优势 + +* **减少样板代码**:显著减少了手动进行属性赋值的重复代码。 +* **提高开发效率**:开发者可以专注于业务逻辑,而不是数据转换。 +* **可维护性**:映射规则集中管理,修改和审查方便。 + +### 4.3. 设计劣势/权衡 + +* **隐式性**:映射过程是隐式的,对于不熟悉AutoMapper的开发者可能难以理解数据流向。 +* **调试难度**:当映射出现问题时,调试可能比手动映射更复杂。 +* **性能开销**:虽然AutoMapper经过优化,但在极端性能敏感的场景下,仍然可能比手动映射有轻微的性能开销。 + +### 4.4. 示例:`MappingProfile.cs` + +```csharp +// 文件: DMS.Application/Profiles/MappingProfile.cs +using AutoMapper; +using DMS.Core.Models; +using DMS.Application.DTOs; +using DMS.Core.Enums; + +namespace DMS.Application.Profiles; + +/// +/// 配置AutoMapper的映射规则。 +/// +public class MappingProfile : Profile +{ + public MappingProfile() + { + // Device 映射 + CreateMap(); + CreateMap(); + CreateMap() + .ForMember(dest => dest.Protocol, opt => opt.MapFrom(src => src.Protocol.ToString())); + + // VariableTable 映射 + CreateMap().ReverseMap(); + + // Variable 映射 + CreateMap() + .ForMember(dest => dest.DataType, opt => opt.MapFrom(src => src.DataType.ToString())); + + // MqttServer 映射 + CreateMap().ReverseMap(); + + // VariableMqttAlias 映射 + CreateMap().ReverseMap(); + + // 其他复杂DTO的映射,可能需要更详细的配置 + CreateMap(); // 示例:从复合DTO映射到领域模型 + } +} +``` \ No newline at end of file diff --git a/软件设计文档/04-基础设施层-仓储与事务.md b/软件设计文档/04-基础设施层-仓储与事务.md new file mode 100644 index 0000000..15be2a2 --- /dev/null +++ b/软件设计文档/04-基础设施层-仓储与事务.md @@ -0,0 +1,322 @@ +# 04. 基础设施层 - 仓储与事务 + +本文档详细设计了 `DMS.Infrastructure` 层中负责数据持久化和事务管理的部分。 + +## 1. 目录结构 + +``` +DMS.Infrastructure/ +├── Data/ +│ └── RepositoryManager.cs +├── Entities/ +│ ├── DbDevice.cs +│ ├── DbVariable.cs +│ ├── DbVariableMqttAlias.cs +│ └── ... (所有数据库表对应的实体) +├── Repositories/ +│ ├── BaseRepository.cs +│ ├── DeviceRepository.cs +│ └── ... (所有仓储接口的实现) +└── ... +``` + +## 2. 数据库实体 + +### 2.1. 设计思路与考量 + +* **职责**:数据库实体(Entities)是与数据库表结构直接对应的C#类。它们主要用于ORM(对象关系映射)框架(如SqlSugar)进行数据映射。 +* **与领域模型的区别**:数据库实体可能包含一些与持久化相关的特性(如 `[SugarColumn]`),或者为了数据库优化而进行的反范式设计。它们与 `DMS.Core` 中的领域模型通过AutoMapper进行映射转换。 + +### 2.2. 设计优势 + +* **ORM友好**:直接映射数据库表结构,简化了数据持久化操作。 +* **隔离持久化细节**:将数据库特有的细节(如列名、主键、外键定义)封装在这一层,不暴露给上层。 +* **性能优化潜力**:可以根据数据库的特点进行优化(如索引、视图),而不会影响领域模型。 + +### 2.3. 设计劣势/权衡 + +* **映射开销**:需要在数据库实体和领域模型之间进行映射,增加了代码量和运行时开销。 +* **可能与领域模型重复**:对于简单的CRUD操作,数据库实体和领域模型可能看起来非常相似,容易混淆。 + +### 2.4. 示例:`DbVariableMqttAlias.cs` + +```csharp +// 文件: DMS.Infrastructure/Entities/DbVariableMqttAlias.cs +using SqlSugar; + +namespace DMS.Infrastructure.Entities; + +/// +/// 数据库实体:对应数据库中的 VariableMqttAliases 表。 +/// +[SugarTable("VariableMqttAliases")] +public class DbVariableMqttAlias +{ + [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] + public int Id { get; set; } + + /// + /// 外键,指向 Variables 表的 Id。 + /// + public int VariableId { get; set; } + + /// + /// 外键,指向 MqttServers 表的 Id。 + /// + public int MqttServerId { get; set; } + + /// + /// 针对此特定[变量-服务器]连接的发布别名。 + /// + public string Alias { get; set; } +} +``` + +## 3. 事务管理 (`RepositoryManager`) + +### 3.1. 设计思路与考量 + +* **模式**:`RepositoryManager` 是工作单元(Unit of Work, UoW)模式的具体实现。它作为所有仓储的统一入口,并管理数据库事务。 +* **共享上下文**:所有通过 `RepositoryManager` 获取的仓储实例都共享同一个 `ISqlSugarClient` 实例,从而确保它们在同一个数据库会话和事务中操作。 +* **生命周期**:`RepositoryManager` 通常被注册为 `Scoped` 或 `Transient` 生命周期,确保每个业务操作都有一个独立的工作单元。 + +### 3.2. 设计优势 + +* **原子性**:确保跨多个仓储的操作(如创建设备、变量表和菜单)要么全部成功,要么全部失败,维护数据一致性。 +* **简化事务管理**:应用层无需直接与数据库事务API交互,只需调用 `BeginTransaction()`, `CommitAsync()`, `RollbackAsync()`。 +* **解耦**:应用层不直接依赖具体的仓储实现,而是依赖于 `IRepositoryManager` 这一抽象。 +* **资源优化**:在单个业务操作中,所有仓储共享同一个数据库连接,减少了连接开销。 + +### 3.3. 设计劣势/权衡 + +* **复杂性增加**:相比于直接使用仓储,引入UoW模式增加了额外的抽象层和概念。 +* **仓储依赖**:`IRepositoryManager` 接口需要列出所有它管理的仓储,当新增仓储时,需要修改此接口。 +* **懒加载**:为了避免在 `RepositoryManager` 构造时就创建所有仓储实例的开销,通常会使用懒加载(`Lazy`),这增加了少量复杂性。 + +### 3.4. 示例:`RepositoryManager.cs` + +```csharp +// 文件: DMS.Infrastructure/Data/RepositoryManager.cs +using DMS.Core.Interfaces; +using SqlSugar; +using System; + +namespace DMS.Infrastructure.Data; + +/// +/// IRepositoryManager 的 SqlSugar 实现,管理所有仓储实例和数据库事务。 +/// +public class RepositoryManager : IRepositoryManager +{ + private readonly ISqlSugarClient _db; + private readonly Lazy _lazyDevices; + private readonly Lazy _lazyVariableTables; + private readonly Lazy _lazyVariables; + private readonly Lazy _lazyMqttServers; + private readonly Lazy _lazyVariableMqttAliases; + private readonly Lazy _lazyMenus; + + /// + /// 构造函数,通过依赖注入获取 SqlSugar 客户端实例。 + /// + public RepositoryManager(ISqlSugarClient db) + { + _db = db; + // 使用 Lazy 实现仓储的懒加载,确保它们在第一次被访问时才创建。 + // 所有仓储都共享同一个 _db 实例,以保证事务的一致性。 + _lazyDevices = new Lazy(() => new DeviceRepository(_db)); + _lazyVariableTables = new Lazy(() => new VariableTableRepository(_db)); + _lazyVariables = new Lazy(() => new VariableRepository(_db)); + _lazyMqttServers = new Lazy(() => new MqttServerRepository(_db)); + _lazyVariableMqttAliases = new Lazy(() => new VariableMqttAliasRepository(_db)); + _lazyMenus = new Lazy(() => new MenuRepository(_db)); + } + + public IDeviceRepository Devices => _lazyDevices.Value; + public IVariableTableRepository VariableTables => _lazyVariableTables.Value; + public IVariableRepository Variables => _lazyVariables.Value; + public IMqttServerRepository MqttServers => _lazyMqttServers.Value; + public IVariableMqttAliasRepository VariableMqttAliases => _lazyVariableMqttAliases.Value; + public IMenuRepository Menus => _lazyMenus.Value; + + /// + /// 开始一个新的数据库事务。 + /// + public void BeginTransaction() + { + _db.BeginTran(); + } + + /// + /// 异步提交当前事务中的所有变更。 + /// + public async Task CommitAsync() + { + await _db.CommitTranAsync(); + } + + /// + /// 异步回滚当前事务中的所有变更。 + /// + public async Task RollbackAsync() + { + await _db.RollbackTranAsync(); + } + + /// + /// 释放 SqlSugar 客户端资源。通常由 DI 容器管理。 + /// + public void Dispose() + { + _db.Dispose(); + } +} +``` + +## 4. 仓储实现 + +### 4.1. 设计思路与考量 + +* **职责**:仓储(Repository)是数据访问层的一部分,负责实现 `DMS.Core` 中定义的仓储接口。它们使用ORM框架(如SqlSugar)与数据库进行实际交互,将数据库实体转换为领域模型,反之亦然。 +* **通用性**:可以有一个 `BaseRepository` 来实现通用的CRUD操作,减少重复代码。 +* **特定查询**:为每个仓储接口实现其特有的查询方法(如 `GetActiveDevicesWithDetailsAsync`)。 + +### 4.2. 设计优势 + +* **实现细节封装**:将ORM框架和数据库查询的细节封装在仓储内部,上层无需关心。 +* **可替换性**:如果需要更换ORM框架或数据库类型,只需修改仓储实现,而无需修改应用层或核心层。 +* **性能优化点**:可以在仓储层进行查询优化(如使用 `Include` 或 `Join` 预加载关联数据)。 + +### 4.3. 设计劣势/权衡 + +* **代码量**:即使有 `BaseRepository`,每个仓储仍然需要一些样板代码。 +* **复杂查询**:对于非常复杂的、跨多个聚合根的查询,有时仓储模式会显得笨拙,可能需要引入查询对象(Query Object)模式。 + +### 4.4. 示例:`BaseRepository.cs` + +```csharp +// 文件: DMS.Infrastructure/Repositories/BaseRepository.cs +using AutoMapper; +using DMS.Core.Interfaces; +using SqlSugar; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DMS.Infrastructure.Repositories; + +/// +/// 仓储基类,实现了 IBaseRepository 接口的通用 CRUD 操作。 +/// +/// 领域模型类型。 +/// 数据库实体类型。 +public abstract class BaseRepository : IBaseRepository + where TDomain : class + where TDbEntity : class, new() +{ + protected readonly ISqlSugarClient _db; + protected readonly IMapper _mapper; + + public BaseRepository(ISqlSugarClient db, IMapper mapper) + { + _db = db; + _mapper = mapper; + } + + public virtual async Task AddAsync(TDomain entity) + { + var dbEntity = _mapper.Map(entity); + await _db.Insertable(dbEntity).ExecuteCommandAsync(); + // 映射回ID,如果实体是自增ID + _mapper.Map(dbEntity, entity); + } + + public virtual async Task DeleteAsync(int id) + { + await _db.Deleteable(id).ExecuteCommandAsync(); + } + + public virtual async Task> GetAllAsync() + { + var dbEntities = await _db.Queryable().ToListAsync(); + return _mapper.Map>(dbEntities); + } + + public virtual async Task GetByIdAsync(int id) + { + var dbEntity = await _db.Queryable().InSingleAsync(id); + return _mapper.Map(dbEntity); + } + + public virtual async Task UpdateAsync(TDomain entity) + { + var dbEntity = _mapper.Map(entity); + await _db.Updateable(dbEntity).ExecuteCommandAsync(); + } +} +``` + +### 4.5. 示例:`DeviceRepository.cs` + +```csharp +// 文件: DMS.Infrastructure/Repositories/DeviceRepository.cs +using AutoMapper; +using DMS.Core.Interfaces; +using DMS.Core.Models; +using DMS.Infrastructure.Entities; +using SqlSugar; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace DMS.Infrastructure.Repositories; + +/// +/// 设备仓储的具体实现。 +/// +public class DeviceRepository : BaseRepository, IDeviceRepository +{ + public DeviceRepository(ISqlSugarClient db, IMapper mapper) : base(db, mapper) { } + + /// + /// 异步获取所有激活的S7设备,并级联加载其下的变量表和变量。 + /// + public async Task> GetActiveDevicesWithDetailsAsync(ProtocolType protocol) + { + var dbDevices = await _db.Queryable() + .Where(d => d.IsActive && d.Protocol == (int)protocol) + .Mapper(d => d.VariableTables, d => d.Id, d => d.VariableTables.Select(vt => vt.DeviceId).ToList()) + .Mapper(d => d.VariableTables.Select(vt => vt.Variables), vt => vt.Id, vt => vt.Variables.Select(v => v.VariableTableId).ToList()) + .ToListAsync(); + return _mapper.Map>(dbDevices); + } + + /// + /// 异步根据设备ID获取设备及其所有详细信息(变量表、变量、MQTT别名等)。 + /// + public async Task GetDeviceWithDetailsAsync(int deviceId) + { + var dbDevice = await _db.Queryable() + .Where(d => d.Id == deviceId) + .Mapper(d => d.VariableTables, d => d.Id, d => d.VariableTables.Select(vt => vt.DeviceId).ToList()) + .Mapper(d => d.VariableTables.Select(vt => vt.Variables), vt => vt.Id, vt => vt.Variables.Select(v => v.VariableTableId).ToList()) + .FirstAsync(); + + if (dbDevice == null) return null; + + // 手动加载 VariableMqttAlias,因为 SqlSugar 的 Mapper 可能无法直接处理多层嵌套的关联实体 + foreach (var variableTable in dbDevice.VariableTables) + { + foreach (var variable in variableTable.Variables) + { + var dbAliases = await _db.Queryable() + .Where(a => a.VariableId == variable.Id) + .Mapper(a => a.MqttServer, a => a.MqttServerId) + .ToListAsync(); + variable.MqttAliases = _mapper.Map>(dbAliases); + } + } + + return _mapper.Map(dbDevice); + } +} +``` \ No newline at end of file diff --git a/软件设计文档/05-基础设施层-后台服务与通信.md b/软件设计文档/05-基础设施层-后台服务与通信.md new file mode 100644 index 0000000..0c30e52 --- /dev/null +++ b/软件设计文档/05-基础设施层-后台服务与通信.md @@ -0,0 +1,439 @@ +# 05. 基础设施层 - 后台服务与通信 + +本文档详细设计了 `DMS.Infrastructure` 层中负责与外部世界(如PLC、MQTT Broker)进行通信的后台服务。 + +## 1. 目录结构 + +``` +DMS.Infrastructure/ +├── Services/ +│ ├── Communication/ +│ │ ├── S7DeviceAgent.cs +│ │ └── MqttPublishService.cs +│ ├── Processing/ +│ │ ├── ChangeDetectionProcessor.cs +│ │ ├── HistoryStorageProcessor.cs +│ │ └── MqttPublishProcessor.cs +│ ├── S7BackgroundService.cs +│ └── DataProcessingService.cs +└── ... +``` + +## 2. S7通信架构 (“编排者-代理”模式) + +### 2.1. 设计思路与考量 + +* **模式**:采用“编排者-代理”(Orchestrator-Agent)模式。`S7BackgroundService` 作为编排者,负责管理所有S7设备的生命周期;每个 `S7DeviceAgent` 作为代理,专门负责与**一个**S7 PLC进行所有交互。 +* **职责分离**:将设备管理(启动、停止、配置更新)与具体设备通信(连接、轮询、读写)的职责分离。 +* **并发性**:每个 `S7DeviceAgent` 独立运行,可以并行处理多个设备的通信,提高系统吞吐量。 +* **热重载**:通过消息机制,允许在运行时动态更新设备的变量配置,而无需重启整个服务。 + +### 2.2. 设计优势 + +* **高可靠性**:单个设备的通信故障不会影响其他设备的正常运行。 +* **可扩展性**:易于添加新的设备类型或通信协议,只需实现新的 `DeviceAgent`。 +* **动态配置**:支持运行时修改设备和变量配置,提高了系统的灵活性和可用性。 +* **资源隔离**:每个Agent管理自己的连接和资源,避免资源争用。 + +### 2.3. 设计劣势/权衡 + +* **复杂性增加**:引入了Agent的概念和Agent与Orchestrator之间的通信机制,增加了初期设计和实现复杂性。 +* **资源消耗**:每个Agent可能需要维护独立的连接和线程,当设备数量非常庞大时,可能会增加资源消耗。 + +### 2.4. `S7BackgroundService.cs` (编排者) + +作为 `IHostedService` 运行,负责从数据库加载激活的S7设备,并为每个设备创建和管理 `S7DeviceAgent` 实例。它还监听配置变更消息,以触发Agent的热重载。 + +```csharp +// 文件: DMS.Infrastructure/Services/S7BackgroundService.cs +using Microsoft.Extensions.Hosting; +using DMS.Core.Interfaces; +using DMS.WPF.Services; +using CommunityToolkit.Mvvm.Messaging; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using DMS.Core.Enums; +using DMS.WPF.Messages; + +namespace DMS.Infrastructure.Services; + +/// +/// S7后台服务编排者,作为IHostedService运行,管理所有S7设备的通信代理。 +/// +public class S7BackgroundService : IHostedService +{ + private readonly IRepositoryManager _repoManager; + private readonly IChannelBus _channelBus; + private readonly IMessenger _messenger; + private readonly ConcurrentDictionary _activeAgents = new(); + + /// + /// 构造函数,通过依赖注入获取所需服务。 + /// + public S7BackgroundService(IRepositoryManager repo, IChannelBus bus, IMessenger msg) + { + _repoManager = repo; + _channelBus = bus; + _messenger = msg; + // 注册配置变更消息,以便在设备或变量配置更新时通知Agent + _messenger.Register(this, async (r, m) => await HandleConfigChange(m)); + } + + /// + /// 服务启动时调用,加载所有激活的S7设备并启动其代理。 + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + // 获取所有激活的S7设备及其详细信息 + var s7Devices = await _repoManager.Devices.GetActiveDevicesWithDetailsAsync(ProtocolType.S7); + foreach (var device in s7Devices) + { + // 为每个设备创建一个S7DeviceAgent实例 + var agent = new S7DeviceAgent(device, _channelBus, _messenger); + if (_activeAgents.TryAdd(device.Id, agent)) + { + // 启动Agent的通信循环 + await agent.StartAsync(cancellationToken); + } + } + + // 启动数据处理消费者服务,它将从ChannelBus中读取数据 + var dataProcessor = new DataProcessingService(_channelBus, _messenger, _repoManager); + _ = dataProcessor.StartProcessingAsync(cancellationToken); // 在后台运行,不阻塞启动 + } + + /// + /// 处理配置变更消息,通知相关Agent更新其变量列表。 + /// + private async Task HandleConfigChange(ConfigChangedMessage message) + { + // 从数据库重新加载受影响的设备及其最新配置 + var updatedDevice = await _repoManager.Devices.GetDeviceWithDetailsAsync(message.DeviceId); + if (updatedDevice != null && _activeAgents.TryGetValue(message.DeviceId, out var agent)) + { + // 指示Agent使用新的变量列表进行热重载 + agent.UpdateVariableLists(updatedDevice.VariableTables.SelectMany(vt => vt.Variables).ToList()); + } + } + + /// + /// 服务停止时调用,停止所有活动的Agent并释放资源。 + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + foreach (var agent in _activeAgents.Values) + { + await agent.DisposeAsync(); + } + _activeAgents.Clear(); + } +} +``` + +### 2.5. `S7DeviceAgent.cs` (代理) + +负责与单个PLC建立连接、维护连接、按不同频率并行轮询变量,并将读取到的数据通过 `IChannelBus` 写入数据处理队列。 + +```csharp +// 文件: DMS.Infrastructure/Services/Communication/S7DeviceAgent.cs +using S7.Net; +using DMS.Core.Models; +using DMS.Core.Enums; +using DMS.WPF.Services; +using CommunityToolkit.Mvvm.Messaging; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using System; + +namespace DMS.Infrastructure.Services.Communication; + +/// +/// 单个S7 PLC的通信代理,负责连接、轮询和数据发送。 +/// +public class S7DeviceAgent : IAsyncDisposable +{ + private readonly Device _deviceConfig; + private readonly ChannelWriter _processingQueueWriter; + private readonly IMessenger _messenger; + private readonly Plc _plcClient; + private CancellationTokenSource _cts; // 用于控制Agent内部的轮询任务 + + // 存储按轮询级别分组的变量列表 + private List _highFreqVars = new(); + private List _mediumFreqVars = new(); + private List _lowFreqVars = new(); + + /// + /// 构造函数。 + /// + /// 设备的配置信息。 + /// 中央通道总线服务。 + /// 消息总线服务。 + public S7DeviceAgent(Device device, IChannelBus channelBus, IMessenger messenger) + { + _deviceConfig = device; + _messenger = messenger; + // 从中央总线获取指定名称的通道的写入端 + _processingQueueWriter = channelBus.GetWriter("DataProcessingQueue"); + + // 初始化S7.Net PLC客户端 + _plcClient = new Plc( + (CpuType)Enum.Parse(typeof(CpuType), _deviceConfig.Protocol.ToString()), // 根据协议类型解析CPU类型 + _deviceConfig.IpAddress, + (short)_deviceConfig.Rack, + (short)_deviceConfig.Slot + ); + } + + /// + /// 启动Agent的通信循环。 + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + await _plcClient.OpenAsync(); // 建立PLC连接 + + // 初始加载变量列表并分组 + UpdateVariableLists(_deviceConfig.VariableTables.SelectMany(vt => vt.Variables).ToList()); + + // 启动并行的轮询任务,每个轮询级别一个任务 + _ = Task.Run(() => PollingLoopAsync(_highFreqVars, 200, _cts.Token)); // 高频:200ms + _ = Task.Run(() => PollingLoopAsync(_mediumFreqVars, 1000, _cts.Token)); // 中频:1000ms + _ = Task.Run(() => PollingLoopAsync(_lowFreqVars, 5000, _cts.Token)); // 低频:5000ms + } + + /// + /// 热重载方法,用于响应配置变更,更新Agent内部的变量列表。 + /// + /// 最新的所有激活变量列表。 + public void UpdateVariableLists(List allActiveVariables) + { + // 重新分组变量 + _highFreqVars = allActiveVariables.Where(v => v.PollLevel == PollLevelType.High).ToList(); + _mediumFreqVars = allActiveVariables.Where(v => v.PollLevel == PollLevelType.Medium).ToList(); + _lowFreqVars = allActiveVariables.Where(v => v.PollLevel == PollLevelType.Low).ToList(); + // 可以考虑在这里重新启动轮询任务,或者让现有任务检测到列表变化 + } + + /// + /// 核心轮询循环,负责批量读取变量并将数据写入处理队列。 + /// + private async Task PollingLoopAsync(List varsToRead, int interval, CancellationToken token) + { + while (!token.IsCancellationRequested) + { + if (!varsToRead.Any()) // 如果没有变量需要轮询,则等待 + { + await Task.Delay(interval, token); + continue; + } + + try + { + // 使用 S7.Net Plus 的 ReadMultipleVarsAsync 批量读取变量 + // 注意:S7.Net Plus 会将读取到的值直接更新到 Variable 对象的 DataValue 属性中 + await _plcClient.ReadMultipleVarsAsync(varsToRead); + + foreach (var variable in varsToRead) + { + // 将读取到的值(包含在VariableContext中)放入数据处理队列 + var context = new VariableContext(variable, variable.DataValue); + await _processingQueueWriter.WriteAsync(context, token); + } + } + catch (Exception ex) + { + // 记录通信错误,但不中断整个Agent + _messenger.Send(new LogMessage(LogLevel.Error, ex, $"S7DeviceAgent: 设备 {_deviceConfig.Name} ({_deviceConfig.IpAddress}) 轮询错误。")); + } + await Task.Delay(interval, token); // 等待下一个轮询周期 + } + } + + /// + /// 异步释放Agent资源,包括停止轮询任务和关闭PLC连接。 + /// + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); // 取消所有内部轮询任务 + if (_plcClient.IsConnected) + { + _plcClient.Close(); // 关闭PLC连接 + } + _plcClient?.Dispose(); + } +} +``` + +## 3. 数据处理链 + +### 3.1. 设计思路与考量 + +* **模式**:采用责任链模式(Chain of Responsibility)来处理采集到的变量数据。每个处理器(Processor)负责一个单一的数据处理步骤(如变化检测、历史存储、MQTT发布)。 +* **解耦**:每个处理器都是独立的,只关心自己的处理逻辑,不关心前一个处理器如何生成数据,也不关心后一个处理器如何消费数据。 +* **可扩展性**:可以轻松地添加、移除或重新排序处理步骤,而无需修改现有代码。 + +### 3.2. 设计优势 + +* **灵活性**:可以根据业务需求动态构建不同的处理链。 +* **可维护性**:每个处理步骤独立,易于理解、测试和维护。 +* **可重用性**:单个处理器可以在不同的处理链中复用。 + +### 3.3. 设计劣势/权衡 + +* **性能开销**:每个处理器都需要额外的对象创建和方法调用开销,对于极度性能敏感的场景可能需要优化。 +* **调试复杂性**:当处理链较长时,跟踪数据流向可能变得复杂。 + +### 3.4. `DataProcessingService.cs` (消费者) + +从 `IChannelBus` 读取数据,并启动数据处理链。它作为数据处理链的入口点。 + +```csharp +// 文件: DMS.Infrastructure/Services/DataProcessingService.cs +using DMS.Core.Models; +using DMS.WPF.Services; +using CommunityToolkit.Mvvm.Messaging; +using System.Threading.Channels; +using System.Threading.Tasks; +using DMS.Infrastructure.Services.Processing; +using DMS.Core.Interfaces; +using DMS.WPF.Messages; +using NLog; + +namespace DMS.Infrastructure.Services; + +/// +/// 数据处理消费者服务,从ChannelBus中读取变量数据并启动处理链。 +/// +public class DataProcessingService +{ + private readonly ChannelReader _queueReader; + private readonly IMessenger _messenger; + private readonly IRepositoryManager _repoManager; + private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); + + /// + /// 构造函数,通过依赖注入获取所需服务。 + /// + public DataProcessingService(IChannelBus channelBus, IMessenger messenger, IRepositoryManager repo) + { + // 从中央总线获取数据处理队列的读取端 + _queueReader = channelBus.GetReader("DataProcessingQueue"); + _messenger = messenger; + _repoManager = repo; + } + + /// + /// 启动数据处理循环,持续从队列中读取数据并进行处理。 + /// + public async Task StartProcessingAsync(CancellationToken token) + { + await foreach (var context in _queueReader.ReadAllAsync(token)) + { + try + { + // 构建并执行数据处理链 + var changeDetector = new ChangeDetectionProcessor(); // 假设这些处理器是无状态的,可以直接创建 + var historyStorage = new HistoryStorageProcessor(_repoManager.VariableHistories); // 注入仓储 + var mqttPublisher = new MqttPublishProcessor(_repoManager.VariableMqttAliases, _repoManager.MqttServers, _messenger); // 注入所需服务 + + // 链式连接处理器 + changeDetector.SetNext(historyStorage).SetNext(mqttPublisher); + + // 启动处理 + await changeDetector.ProcessAsync(context); + + // 处理完成后,发送消息通知UI更新变量值 + _messenger.Send(new VariableValueUpdatedMessage(context.Variable.Id, context.CurrentValue)); + } + catch (Exception ex) + { + _logger.Error(ex, $"数据处理链执行错误,变量ID: {context.Variable.Id}"); + } + } + } +} +``` + +### 3.5. `MqttPublishProcessor.cs` + +处理器从 `Variable` 的 `MqttAliases` 集合中获取别名和目标服务器,并使用别名构建MQTT Topic。 + +```csharp +// 文件: DMS.Infrastructure/Services/Processing/MqttPublishProcessor.cs +using DMS.Core.Interfaces; +using DMS.Core.Models; +using DMS.Infrastructure.Services.Communication; +using CommunityToolkit.Mvvm.Messaging; +using System.Linq; +using System.Threading.Tasks; +using System.Text.Json; +using NLog; + +namespace DMS.Infrastructure.Services.Processing; + +/// +/// MQTT发布处理器,负责将变量值发布到关联的MQTT服务器,并使用专属别名。 +/// +public class MqttPublishProcessor : VariableProcessorBase +{ + private readonly IMqttPublishService _mqttService; + private readonly IVariableMqttAliasRepository _aliasRepository; + private readonly IMqttServerRepository _mqttServerRepository; + private readonly IMessenger _messenger; + private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); + + /// + /// 构造函数。 + /// + public MqttPublishProcessor(IMqttPublishService mqttService, IVariableMqttAliasRepository aliasRepository, IMqttServerRepository mqttServerRepository, IMessenger messenger) + { + _mqttService = mqttService; + _aliasRepository = aliasRepository; + _mqttServerRepository = mqttServerRepository; + _messenger = messenger; + } + + protected override async Task HandleAsync(VariableContext context) + { + if (!context.IsValueChanged) return; // 如果值未变化,则不发布 + + // 获取与当前变量关联的所有MQTT别名信息 + var aliases = await _aliasRepository.GetAliasesForVariableAsync(context.Variable.Id); + + if (aliases == null || !aliases.Any()) + { + return; // 没有关联的MQTT服务器,无需发布 + } + + foreach (var aliasInfo in aliases) + { + try + { + // 获取关联的MQTT服务器详细信息 + var targetServer = await _mqttServerRepository.GetByIdAsync(aliasInfo.MqttServerId); + if (targetServer == null || !targetServer.IsActive) + { + _logger.Warn($"MQTT发布失败:变量 {context.Variable.Name} 关联的MQTT服务器 {aliasInfo.MqttServerId} 不存在或未激活。"); + continue; + } + + // 使用别名构建Topic + // 示例Topic格式:DMS/DeviceName/VariableAlias + var topic = $"DMS/{context.Variable.VariableTable.Device.Name}/{aliasInfo.Alias}"; + var payload = JsonSerializer.Serialize(new { value = context.CurrentValue, timestamp = context.Timestamp }); + + await _mqttService.PublishAsync(targetServer, topic, payload); + } + catch (Exception ex) + { + _logger.Error(ex, $"MQTT发布失败:变量 {context.Variable.Name} 到服务器 {aliasInfo.MqttServerId},别名 {aliasInfo.Alias}"); + } + } + } +} +``` \ No newline at end of file diff --git a/软件设计文档/06-核心服务-中央通道总线设计.md b/软件设计文档/06-核心服务-中央通道总线设计.md new file mode 100644 index 0000000..7c6f5b0 --- /dev/null +++ b/软件设计文档/06-核心服务-中央通道总线设计.md @@ -0,0 +1,153 @@ +# 06. 核心服务 - 中央通道总线设计 + +本文档详细阐述了 `IChannelBus` 核心服务的设计与实现。该服务是整个应用程序高性能、异步通信的骨架。 + +## 1. 设计理念 + +### 1.1. 设计思路与考量 + +* **解耦通信**:在复杂的后台系统中,不同的组件(如数据采集器、数据处理器、日志记录器)之间需要进行高性能的异步通信。直接传递 `Channel` 实例或使用全局静态变量会导致紧耦合和难以管理。 +* **统一管理**:`ChannelBus` 旨在提供一个统一的、可注入的中央服务,用于创建、注册和分发命名通道(`System.Threading.Channels.Channel`)。 +* **生产者/消费者模式**:通过 `ChannelWriter` 和 `ChannelReader`,实现生产者和消费者之间的完全解耦,它们只通过约定的通道名称进行通信,无需知道对方的存在。 + +### 1.2. 设计优势 + +* **高度解耦**:生产者和消费者之间没有直接引用,它们只依赖于 `IChannelBus` 接口和通道名称。这极大地提高了模块的独立性和可维护性。 +* **高性能异步通信**:`System.Threading.Channels` 是.NET中专门为高性能异步生产者/消费者场景设计的,提供了优秀的吞吐量和低延迟。 +* **可扩展性**:可以轻松添加新的通信通道,只需定义新的通道名称和数据类型,而无需修改现有代码。 +* **集中管理**:所有通道的生命周期和实例都由 `ChannelBusService` 统一管理,避免了通道实例的混乱和泄露。 +* **易于测试**:在单元测试中,可以轻松地Mock `IChannelBus` 接口,隔离测试组件。 + +### 1.3. 设计劣势/权衡 + +* **约定依赖**:生产者和消费者必须约定好通道的名称和数据类型,如果约定不一致,可能导致运行时错误。 +* **调试复杂性**:由于高度解耦,在调试数据流时,可能需要跟踪多个组件和通道。 +* **错误处理**:通道内部的错误处理需要谨慎设计,以防止数据丢失或死锁。 + +## 2. 接口与实现 (`DMS.WPF`) + +该服务被定义在WPF项目中,因为它是一个应用级的、协调性的核心服务,可以被所有层(通过DI)访问。 + +### `IChannelBus.cs` + +```csharp +// 文件: DMS.WPF/Services/IChannelBus.cs +using System.Threading.Channels; + +namespace DMS.WPF.Services; + +/// +/// 定义了一个中央通道总线,用于在应用程序的不同部分之间创建和分发高性能的、解耦的内存消息通道。 +/// +public interface IChannelBus +{ + /// + /// 获取指定名称和类型的通道的写入器。 + /// 如果具有该名称的通道不存在,则会自动创建。 + /// + /// 通道中流动的数据类型。 + /// 通道的唯一标识名称,例如 "DataProcessingQueue"。 + /// 一个用于向通道写入数据的 ChannelWriter + ChannelWriter GetWriter(string channelName); + + /// + /// 获取指定名称和类型的通道的读取器。 + /// 如果具有该名称的通道不存在,则会自动创建。 + /// + /// 通道中流动的数据类型。 + /// 通道的唯一标识名称,例如 "DataProcessingQueue"。 + /// 一个用于从通道读取数据的 ChannelReader + ChannelReader GetReader(string channelName); +} +``` + +### `ChannelBusService.cs` + +```csharp +// 文件: DMS.WPF/Services/ChannelBusService.cs +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace DMS.WPF.Services; + +/// +/// IChannelBus的单例实现,管理应用程序中所有命名的通道。 +/// +public class ChannelBusService : IChannelBus +{ + private readonly ConcurrentDictionary _channels; + + /// + /// 构造函数,初始化通道字典。 + /// + public ChannelBusService() + { + _channels = new ConcurrentDictionary(); + } + + /// + /// 获取指定名称和类型的通道的写入器。 + /// + public ChannelWriter GetWriter(string channelName) + { + // GetOrAdd 是一个原子操作,能防止多个线程同时创建同一个通道的竞态条件。 + // 如果通道不存在,则创建新的无界通道。 + var channel = (Channel)_channels.GetOrAdd( + channelName, + _ => Channel.CreateUnbounded() + ); + return channel.Writer; + } + + /// + /// 获取指定名称和类型的通道的读取器。 + /// + public ChannelReader GetReader(string channelName) + { + // 同样使用 GetOrAdd 来确保获取到的是同一个通道实例。 + var channel = (Channel)_channels.GetOrAdd( + channelName, + _ => Channel.CreateUnbounded() + ); + return channel.Reader; + } +} +``` + +## 3. 依赖注入 (`App.xaml.cs`) + +### 3.1. 设计思路与考量 + +* **单例注册**:`IChannelBus` 必须作为**单例**注册在DI容器中。这是因为 `ChannelBusService` 内部维护着所有通道的集合,如果不是单例,每次注入都会得到一个新的 `ChannelBusService` 实例,导致通道无法共享。 + +### 3.2. 设计优势 + +* **全局可访问**:一旦注册为单例,应用程序的任何部分都可以通过DI获取 `IChannelBus` 实例,并访问共享的通信通道。 +* **资源效率**:确保只有一个 `ChannelBusService` 实例和其内部的通道集合,避免了不必要的资源创建和管理开销。 + +### 3.3. 示例代码 + +```csharp +// 文件: DMS.WPF/App.xaml.cs +private void ConfigureServices(IServiceCollection services) +{ + // ... 其他服务注册 + + // 注册中央通道总线为单例 + services.AddSingleton(); + + // ... +} +``` + +## 4. 应用场景 + +### 4.1. 数据处理队列 + +* **生产者**:`S7DeviceAgent` (在 `DMS.Infrastructure` 中) 通过 `channelBus.GetWriter("DataProcessingQueue")` 写入采集到的变量数据。 +* **消费者**:`DataProcessingService` (在 `DMS.Infrastructure` 中) 通过 `channelBus.GetReader("DataProcessingQueue")` 读取数据,并启动数据处理链。 + +### 4.2. 异步日志队列 (扩展) + +* **生产者**:任何需要记录日志的组件都可以通过 `channelBus.GetWriter("LoggingQueue")` 写入日志事件。 +* **消费者**:一个专门的后台日志服务(例如,一个 `LogConsumerService`)则负责从该通道读取日志事件,并批量写入数据库,从而进一步提升日志写入性能,并避免阻塞业务线程。 \ No newline at end of file diff --git a/软件设计文档/07-核心服务-日志记录与聚合过滤.md b/软件设计文档/07-核心服务-日志记录与聚合过滤.md new file mode 100644 index 0000000..4985b35 --- /dev/null +++ b/软件设计文档/07-核心服务-日志记录与聚合过滤.md @@ -0,0 +1,422 @@ +# 07. 核心服务 - 日志记录与聚合过滤设计 + +本文档详细阐述了基于NLog的、带有智能聚合过滤功能的日志系统设计方案。 + +## 1. 设计目标 + +### 1.1. 设计思路与考量 + +* **全面记录**:捕获日志发生的时间、级别、消息、异常、调用点(文件、方法、行号)等所有关键上下文信息,便于问题追溯和分析。 +* **持久化存储**:将日志信息存储到数据库中,实现日志的长期保存、集中管理和便捷查询。 +* **防止“日志风暴”**:在工业应用中,设备故障或网络抖动可能导致大量重复日志在短时间内爆发。传统的日志系统会因此被刷爆,导致磁盘空间耗尽,有效信息被淹没。因此,需要一个智能的聚合过滤机制。 +* **性能优化**:日志记录不应阻塞业务逻辑的执行。 + +### 1.2. 设计优势 + +* **高效排障**:详细的日志信息能极大提高问题定位和解决的效率。 +* **资源节约**:聚合过滤功能显著减少了磁盘I/O和存储空间占用,同时避免了日志系统自身的性能瓶颈。 +* **信息浓缩**:即使在高频场景下,也能保留关键的首次日志信息和事件发生频率,提供有价值的洞察。 +* **可配置性**:NLog提供了灵活的配置选项,可以根据环境(开发、测试、生产)调整日志级别、输出目标等。 + +### 1.3. 设计劣势/权衡 + +* **实现复杂性**:自定义NLog Target以实现聚合过滤功能,增加了额外的开发和维护成本。 +* **实时性损失**:聚合过滤机制意味着某些重复日志不会立即写入数据库,而是等待聚合周期结束,这可能对需要严格实时性的监控场景造成影响(但对于大多数日志分析场景是可接受的)。 +* **内存消耗**:`ThrottlingDatabaseTarget` 需要在内存中维护一个缓存来跟踪重复日志,当日志种类非常多且聚合周期较长时,可能会占用较多内存。 + +## 2. 数据库实体 (`DMS.Infrastructure`) + +### 2.1. 设计思路与考量 + +* **字段完备**:`DbLog` 实体包含了日志所需的所有关键信息,特别是 `CallSite` (调用点) 和 `AggregatedCount` (聚合计数),这些是实现高级日志分析和过滤的基础。 +* **可扩展性**:使用 `Length = -1` (对应SQL Server的 `NVARCHAR(MAX)`) 确保 `Message` 和 `Exception` 字段能够存储任意长度的文本。 + +### 2.2. 示例:`DbLog.cs` + +```csharp +// 文件: DMS.Infrastructure/Entities/DbLog.cs +using SqlSugar; +using System; + +namespace DMS.Infrastructure.Entities; + +/// +/// 数据库实体:对应数据库中的 Logs 表,用于存储应用程序日志。 +/// +[SugarTable("Logs")] +public class DbLog +{ + [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 日志记录的时间戳。 + /// + public DateTime Logged { get; set; } + + /// + /// 日志级别 (e.g., "Info", "Warn", "Error", "Debug")。 + /// + public string Level { get; set; } + + /// + /// 日志消息主体。 + /// + [SugarColumn(Length = -1)] // 映射为NVARCHAR(MAX)或类似类型 + public string Message { get; set; } + + /// + /// 异常信息,包括堆栈跟踪。如果无异常则为null。 + /// + [SugarColumn(IsNullable = true, Length = -1)] + public string Exception { get; set; } + + /// + /// 记录日志的调用点信息 (文件路径:行号)。 + /// + public string CallSite { get; set; } + + /// + /// 记录日志的方法名。 + /// + public string MethodName { get; set; } + + /// + /// (用于聚合) 此条日志在指定时间窗口内被触发的总次数。默认为1。 + /// + public int AggregatedCount { get; set; } = 1; +} +``` + +## 3. NLog 自定义Target (`DMS.Infrastructure`) + +### 3.1. 设计思路与考量 + +* **自定义Target**:NLog允许通过继承 `TargetWithLayout` 创建自定义的日志目标。这是实现复杂日志处理逻辑(如聚合过滤)的入口。 +* **内存缓存**:使用 `ConcurrentDictionary` 作为内存缓存,以 `logKey` (日志级别+消息+调用点) 为键,存储 `LogCacheEntry`。这使得我们能够快速查找和更新重复日志。 +* **定时器触发**:为每个首次出现的日志启动一个 `System.Threading.Timer`。当定时器到期时,触发 `FlushEntry` 方法,将聚合后的日志写入数据库。 +* **原子操作**:使用 `Interlocked.Increment` 和 `ConcurrentDictionary` 的原子操作确保在多线程环境下缓存的正确性。 + +### 3.2. 示例:`ThrottlingDatabaseTarget.cs` + +```csharp +// 文件: DMS.Infrastructure/Logging/ThrottlingDatabaseTarget.cs +using NLog; +using NLog.Targets; +using System.Collections.Concurrent; +using System.Threading; +using System; +using SqlSugar; +using DMS.Infrastructure.Entities; + +namespace DMS.Infrastructure.Logging; + +// 内部类,用于存储日志的缓存信息 +file class LogCacheEntry +{ + public LogEventInfo FirstLogEvent { get; set; } + public int Count { get; set; } + public Timer Timer { get; set; } +} + +/// +/// 自定义NLog Target,实现日志的聚合过滤功能,并将日志写入数据库。 +/// +[Target("ThrottlingDatabase")] +public class ThrottlingDatabaseTarget : TargetWithLayout +{ + // 缓存正在被节流的日志条目,键是日志的唯一标识,值是LogCacheEntry + private readonly ConcurrentDictionary _throttleCache = new(); + // 聚合时间窗口,例如30秒 + private readonly TimeSpan _throttleTime = TimeSpan.FromSeconds(30); + + // NLog会通过反射设置这个属性,用于获取数据库连接字符串 + [RequiredParameter] + public string ConnectionString { get; set; } + + /// + /// NLog核心写入方法,每当有日志事件发生时被调用。 + /// + /// 日志事件信息。 + protected override void Write(LogEventInfo logEvent) + { + // 构建一个唯一的键来标识这个日志源(级别 + 消息 + 调用点) + // 这样可以区分不同位置或不同内容的重复日志 + string logKey = $"{logEvent.Level}|{logEvent.FormattedMessage}|{logEvent.CallerFilePath}:{logEvent.CallerLineNumber}"; + + // 尝试从缓存中获取条目 + if (_throttleCache.TryGetValue(logKey, out var entry)) + { + // 如果存在,说明在当前聚合周期内,这条日志已经记录过一次 + // 我们只增加计数,不立即写入数据库 + Interlocked.Increment(ref entry.Count); + } + else + { + // 如果不存在,这是这条日志在当前聚合周期内的第一次出现 + var newEntry = new LogCacheEntry + { + FirstLogEvent = logEvent, + Count = 1, + }; + + // 创建一个定时器,在 _throttleTime 后触发 FlushEntry 方法 + // Timeout.Infinite 表示定时器只触发一次 + newEntry.Timer = new Timer( + callback: _ => FlushEntry(logKey), + state: null, + dueTime: _throttleTime, + period: Timeout.InfiniteTime + ); + + // 尝试将新条目原子性地添加到缓存中 + if (_throttleCache.TryAdd(logKey, newEntry)) + { + // 第一次的日志,立即写入数据库 + WriteToDatabase(logEvent, 1); + } + else + { + // 极小概率的并发情况:在TryAdd之前,另一个线程已经添加了。 + // 此时,简单地增加已存在条目的计数。 + if (_throttleCache.TryGetValue(logKey, out var existingEntry)) + { + Interlocked.Increment(ref existingEntry.Count); + } + } + } + } + + /// + /// 定时器回调方法,用于将聚合后的日志写入数据库。 + /// + /// 日志的唯一键。 + private void FlushEntry(string logKey) + { + // 从缓存中移除条目 + if (_throttleCache.TryRemove(logKey, out var entry)) + { + entry.Timer?.Dispose(); // 释放定时器资源 + + // 如果在聚合周期内有超过1次的调用,则记录一条聚合日志 + if (entry.Count > 1) + { + // 构建聚合消息 + var aggregateMessage = $"[聚合日志] 此消息在过去 {_throttleTime.TotalSeconds} 秒内共出现 {entry.Count} 次。首次消息: {entry.FirstLogEvent.FormattedMessage}"; + + // 创建一个新的LogEventInfo来记录聚合信息 + var aggregateLogEvent = new LogEventInfo( + entry.FirstLogEvent.Level, + entry.FirstLogEvent.LoggerName, + aggregateMessage + ); + // 复制其他重要属性,如调用点、异常信息等 + aggregateLogEvent.Exception = entry.FirstLogEvent.Exception; + aggregateLogEvent.CallerFilePath = entry.FirstLogEvent.CallerFilePath; + aggregateLogEvent.CallerLineNumber = entry.FirstLogEvent.CallerLineNumber; + aggregateLogEvent.CallerMemberName = entry.FirstLogEvent.CallerMemberName; + + // 将聚合日志写入数据库,并记录总次数 + WriteToDatabase(aggregateLogEvent, entry.Count); + } + } + } + + /// + /// 将 LogEventInfo 转换为 DbLog 实体并写入数据库。 + /// + /// 要写入的日志事件。 + /// 此日志事件在聚合周期内的总次数。 + private void WriteToDatabase(LogEventInfo logEvent, int count) + { + try + { + // 使用 NLog 的 Layout 渲染消息,确保所有信息都包含在内 + var message = Layout.Render(logEvent); + + var dbLog = new DbLog + { + Logged = logEvent.TimeStamp, + Level = logEvent.Level.ToString(), + Message = message, + Exception = logEvent.Exception?.ToString(), + CallSite = $"{logEvent.CallerFilePath}:{logEvent.CallerLineNumber}", + MethodName = logEvent.CallerMemberName, + AggregatedCount = count + }; + + // 使用 SqlSugar 客户端将 DbLog 插入数据库 + // 注意:这里需要一个新的 SqlSugarClient 实例,因为 NLog Target 是独立的。 + // 更好的做法是使用一个连接池或单例的 SqlSugarClient,但为了简化示例,这里直接创建。 + using (var db = new SqlSugarClient(new ConnectionConfig { ConnectionString = ConnectionString, DbType = DbType.SqlServer, IsAutoCloseConnection = true })) + { + db.Insertable(dbLog).ExecuteCommand(); + } + } + catch (Exception ex) + { + // 记录写入数据库失败的错误,通常写入内部NLog文件或控制台 + InternalLogger.Error(ex, "Failed to write log to database."); + } + } +} +``` + +## 4. NLog 配置 (`nlog.config`) + +### 4.1. 设计思路与考量 + +* **外部配置**:NLog允许通过XML文件进行配置,使得日志行为可以在不修改代码的情况下进行调整。 +* **Target注册**:通过 `` 标签注册自定义的 `ThrottlingDatabaseTarget`。 +* **规则路由**:通过 `` 标签定义日志的路由规则,例如,将所有 `Info` 级别及以上的日志写入数据库。 +* **全局上下文**:使用 `${gdc:item=connectionString}` 从NLog的全局诊断上下文获取数据库连接字符串,避免硬编码。 + +### 4.2. 示例:`nlog.config` + +```xml + + + internalLogLevel="Info" + internalLogFile="c:\temp\internal-nlog.txt"> + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## 5. 封装与初始化 + +### 5.1. `ILoggerService` (可选,但推荐) + +### 5.1.1. 设计思路与考量 + +* **封装NLog**:通过引入一个简单的 `ILoggerService` 接口,将NLog的具体实现细节封装起来,业务代码只依赖于这个抽象。 +* **统一日志接口**:提供统一的日志记录方法(如 `Info`, `Warn`, `Error`),简化业务代码中的日志调用。 + +### 5.1.2. 设计优势 + +* **解耦**:业务代码不直接依赖NLog,未来更换日志框架时,只需修改 `NLogService` 的实现。 +* **简化调用**:提供更简洁的API,减少日志记录的样板代码。 +* **可测试性**:可以轻松地Mock `ILoggerService`,便于单元测试。 + +### 5.1.3. 示例:`ILoggerService.cs` + +```csharp +// 文件: DMS.Application/Interfaces/ILoggerService.cs +namespace DMS.Application.Interfaces; + +/// +/// 应用程序的通用日志服务接口。 +/// +public interface ILoggerService +{ + /// + /// 记录信息级别日志。 + /// + /// 日志消息。 + void Info(string message); + + /// + /// 记录警告级别日志。 + /// + /// 日志消息。 + void Warn(string message); + + /// + /// 记录错误级别日志,包含异常信息。 + /// + /// 发生的异常。 + /// 可选的日志消息。 + void Error(Exception ex, string message = null); + + /// + /// 记录调试级别日志。 + /// + /// 日志消息。 + void Debug(string message); +} +``` + +### 5.1.4. 示例:`NLogService.cs` + +```csharp +// 文件: DMS.Infrastructure/Logging/NLogService.cs +using DMS.Application.Interfaces; +using NLog; +using System; + +namespace DMS.Infrastructure.Logging; + +/// +/// ILoggerService 的 NLog 实现。 +/// +public class NLogService : ILoggerService +{ + // 获取当前类的NLog Logger实例 + private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); + + public void Info(string message) => _logger.Info(message); + public void Warn(string message) => _logger.Warn(message); + public void Error(Exception ex, string message = null) => _logger.Error(ex, message); + public void Debug(string message) => _logger.Debug(message); +} +``` + +### 5.2. 初始化 (`App.xaml.cs`) + +### 5.2.1. 设计思路与考量 + +* **早期配置**:NLog需要在应用程序启动的早期阶段进行配置,特别是数据库连接字符串等全局参数。 +* **全局诊断上下文**:NLog的 `GlobalDiagnosticsContext` 提供了一种在应用程序范围内传递配置信息的方式,避免了硬编码。 + +### 5.2.2. 示例代码 + +```csharp +// 文件: DMS.WPF/App.xaml.cs +using NLog; +using System.Windows; + +namespace DMS.WPF; + +public partial class App : System.Windows.Application +{ + // ... + + protected override void OnStartup(StartupEventArgs e) + { + // 在程序启动的最开始就设置好数据库连接字符串 + // 这样 NLog 的 ThrottlingDatabaseTarget 就能获取到它 + GlobalDiagnosticsContext.Set("connectionString", "your_db_connection_string_here"); + + // ... DI容器配置和主窗口显示 + } + + // ... +} +``` \ No newline at end of file diff --git a/软件设计文档/08-WPF表现层-MVVM与响应式UI.md b/软件设计文档/08-WPF表现层-MVVM与响应式UI.md new file mode 100644 index 0000000..24efcef --- /dev/null +++ b/软件设计文档/08-WPF表现层-MVVM与响应式UI.md @@ -0,0 +1,286 @@ +# 08. WPF表现层 - MVVM与响应式UI + +本文档详细设计了 `DMS.WPF` 层的MVVM架构,并阐述了如何通过 `ItemViewModel` 和消息总线构建响应式UI,确保数据变化能实时反映到界面。 + +## 1. 核心设计模式 + +### 1.1. MVVM (Model-View-ViewModel) + +* **设计思路**:MVVM 是一种UI架构模式,旨在将UI(View)与业务逻辑和数据(Model)分离。ViewModel 作为View的抽象,负责暴露数据和命令,并处理View的交互逻辑。 +* **优势**: + * **职责分离**:View只负责显示,ViewModel负责逻辑,Model负责数据,职责清晰,降低了复杂性。 + * **可测试性**:ViewModel可以独立于View进行单元测试,无需UI框架的依赖,提高了测试效率和覆盖率。 + * **可维护性**:UI和逻辑的修改互不影响,降低了维护成本。 + * **团队协作**:UI设计师和开发者可以并行工作。 +* **劣势/权衡**: + * **学习曲线**:对于初学者来说,理解MVVM模式和数据绑定机制需要一定的学习成本。 + * **样板代码**:需要为每个View创建ViewModel,并实现 `INotifyPropertyChanged` 等接口,增加了少量样板代码(但 `CommunityToolkit.Mvvm` 可以极大简化)。 + +### 1.2. ItemViewModel 模式 + +* **设计思路**:当UI需要显示一个数据集合(如设备列表)时,集合中的每个数据项(如 `DeviceDto`)本身是“哑”的,不具备通知UI更新的能力。`ItemViewModel` 模式为集合中的每个数据项创建一个专属的ViewModel(如 `DeviceItemViewModel`),这个ViewModel“包裹”原始数据,并实现 `INotifyPropertyChanged`。当其内部属性变化时,它会通知UI更新。 +* **优势**: + * **响应式UI**:确保数据变化能实时、局部地反映到UI上,提供流畅的用户体验。 + * **解耦**:将UI更新逻辑封装在 `ItemViewModel` 内部,与原始数据DTO分离。 + * **可重用性**:`ItemViewModel` 可以在不同的列表或详细视图中复用。 +* **劣势/权衡**: + * **对象膨胀**:对于大型集合,每个数据项都创建一个 `ItemViewModel` 实例,可能会增加内存消耗。 + * **映射开销**:需要将原始数据DTO映射到 `ItemViewModel`,增加了少量代码和运行时开销。 + +### 1.3. 消息总线 (Messenger) + +* **设计思路**:消息总线(或事件聚合器)是一种发布/订阅模式的实现,用于在应用程序中解耦组件之间的通信。当后台服务(如S7通信服务)检测到设备状态变化时,它不直接调用UI层的ViewModel,而是向消息总线“发布”一条消息。任何“订阅”了该消息的ViewModel都会收到通知并做出响应。 +* **优势**: + * **高度解耦**:生产者(消息发布者)和消费者(消息订阅者)之间没有直接引用,降低了模块间的依赖性。 + * **灵活性**:可以轻松添加新的消息订阅者,而无需修改消息发布者。 + * **跨层通信**:提供了一种安全、标准的方式进行跨层(如基础设施层到表现层)通信。 +* **劣势/权衡**: + * **隐式性**:消息的发布和订阅是隐式的,可能导致代码流向难以追踪,增加了调试难度。 + * **消息管理**:如果消息类型过多或命名不规范,可能导致混乱。 + * **内存泄漏风险**:如果订阅者没有正确取消订阅,可能导致内存泄漏(`CommunityToolkit.Mvvm` 的 `WeakReferenceMessenger` 缓解了此问题)。 + +## 2. 目录结构 + +``` +DMS.WPF/ +├── Messages/ <-- 存放消息类 +│ ├── DeviceStatusChangedMessage.cs +│ └── VariableValueUpdatedMessage.cs +├── ViewModels/ +│ ├── Base/ <-- ViewModel基类 +│ │ ├── BaseViewModel.cs +│ │ └── RelayCommand.cs +│ ├── Items/ <-- ItemViewModel +│ │ ├── DeviceItemViewModel.cs +│ │ └── VariableItemViewModel.cs +│ ├── DeviceListViewModel.cs +│ └── ... +└── ... +``` + +## 3. 响应式UI实现流程 + +### 3.1. 消息定义 + +```csharp +// 文件: DMS.WPF/Messages/VariableValueUpdatedMessage.cs +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace DMS.WPF.Messages; + +/// +/// 当变量值在后台更新时,通过IMessenger广播此消息。 +/// +public class VariableValueUpdatedMessage : ValueChangedMessage +{ + public int VariableId { get; } + + public VariableValueUpdatedMessage(int variableId, object value) : base(value) + { + VariableId = variableId; + } +} +``` + +### 3.2. ItemViewModel接收消息 + +`VariableItemViewModel` 实现了 `IRecipient` 接口,当收到匹配的消息时,更新其 `Value` 属性,从而触发UI刷新。 + +```csharp +// 文件: DMS.WPF/ViewModels/Items/VariableItemViewModel.cs +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using DMS.Application.DTOs; +using DMS.WPF.Messages; + +namespace DMS.WPF.ViewModels.Items; + +/// +/// 代表变量列表中的单个变量项的ViewModel。 +/// 实现了INotifyPropertyChanged,其任何属性变化都会自动通知UI。 +/// 同时订阅VariableValueUpdatedMessage以实时更新值。 +/// +public partial class VariableItemViewModel : ObservableObject, IRecipient +{ + public int Id { get; } + + [ObservableProperty] + private string _name; + + [ObservableProperty] + private object _value; // 绑定到UI的值,当此属性改变时,UI会自动刷新 + + /// + /// 构造函数,从DTO创建,并注册消息接收器。 + /// + public VariableItemViewModel(VariableDto dto, IMessenger messenger) + { + Id = dto.Id; + _name = dto.Name; + _value = dto.InitialValue; // 初始值 + messenger.Register(this); // 注册消息接收 + } + + /// + /// 实现IRecipient接口,当接收到VariableValueUpdatedMessage消息时此方法被调用。 + /// + public void Receive(VariableValueUpdatedMessage message) + { + if (message.VariableId == this.Id) + { + // 收到匹配的消息,更新值,UI会自动刷新 + Value = message.Value; // ValueChangedMessage 的 Value 属性 + } + } +} +``` + +### 3.3. 主ViewModel管理集合 + +`VariableListViewModel` 持有一个 `ObservableCollection`,当从应用层加载数据时,将DTOs转换为 `VariableItemViewModel` 实例并添加到集合中。 + +```csharp +// 文件: DMS.WPF/ViewModels/VariableListViewModel.cs +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using DMS.Application.Interfaces; +using DMS.WPF.ViewModels.Items; +using CommunityToolkit.Mvvm.Messaging; + +namespace DMS.WPF.ViewModels; + +/// +/// 变量列表视图的ViewModel,管理VariableItemViewModel集合。 +/// +public class VariableListViewModel : BaseViewModel +{ + private readonly IVariableAppService _variableAppService; + private readonly IMessenger _messenger; + + /// + /// 绑定到UI的变量集合。 + /// + public ObservableCollection Variables { get; } = new(); + + /// + /// 构造函数。 + /// + public VariableListViewModel(IVariableAppService variableAppService, IMessenger messenger) + { + _variableAppService = variableAppService; + _messenger = messenger; + } + + /// + /// 加载变量数据。 + /// + public override async Task LoadAsync() + { + IsBusy = true; + Variables.Clear(); + try + { + var variableDtos = await _variableAppService.GetAllVariablesAsync(); // 假设有此方法 + foreach (var dto in variableDtos) + { + Variables.Add(new VariableItemViewModel(dto, _messenger)); + } + } + finally + { + IsBusy = false; + } + } +} +``` + +### 3.4. View绑定 + +XAML中的 `DataGrid` 或 `ItemsControl` 的 `ItemsSource` 绑定到 `ObservableCollection`,列表项的属性直接绑定到 `VariableItemViewModel` 的属性。 + +```xml + + + + + + + + + +``` + +## 4. 依赖注入 (`App.xaml.cs`) + +### 4.1. 设计思路与考量 + +* **标准DI**:使用 `Microsoft.Extensions.DependencyInjection` 作为标准的DI容器。 +* **Messenger注册**:`IMessenger` 必须注册为单例,确保所有组件共享同一个消息总线实例。 +* **ViewModel生命周期**:通常将主窗口的ViewModel注册为单例,而其他子视图的ViewModel注册为 `Transient` 或 `Scoped`,以确保每次导航都获得新的实例。 + +### 4.2. 示例代码 + +```csharp +// 文件: DMS.WPF/App.xaml.cs +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Windows; + +namespace DMS.WPF; + +public partial class App : System.Windows.Application +{ + private readonly IServiceProvider _serviceProvider; + + public App() + { + var services = new ServiceCollection(); + ConfigureServices(services); + _serviceProvider = services.BuildServiceProvider(); + } + + protected override void OnStartup(StartupEventArgs e) + { + // ... NLog初始化 + + var mainWindow = _serviceProvider.GetRequiredService(); + mainWindow.Show(); + + // 启动后台服务 + _serviceProvider.GetRequiredService().StartAsync(CancellationToken.None); + } + + private void ConfigureServices(IServiceCollection services) + { + // 消息总线 (关键新增) + // 使用弱引用信使,避免内存泄漏 + services.AddSingleton(WeakReferenceMessenger.Default); + + // 应用层 & 基础设施层服务注册 (示例) + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // 注册后台服务为托管服务 + services.AddHostedService(); + + // WPF UI 服务 + services.AddSingleton(); + services.AddSingleton(); // 假设有此服务 + + // ViewModels + services.AddSingleton(); // 主ViewModel通常是单例 + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + // ... 其他子视图ViewModel注册为Transient + + // Views + services.AddSingleton(); + } +} +``` \ No newline at end of file diff --git a/软件设计文档/09-WPF表现层-动态菜单与导航.md b/软件设计文档/09-WPF表现层-动态菜单与导航.md new file mode 100644 index 0000000..acf9f05 --- /dev/null +++ b/软件设计文档/09-WPF表现层-动态菜单与导航.md @@ -0,0 +1,617 @@ +# 09. WPF表现层 - 动态菜单与导航 + +本文档详细阐述了基于数据库的动态菜单和参数化导航系统的设计方案,旨在与 `iNKORE.UI.WPF.Modern` 等现代化UI框架无缝集成。 + +## 1. 设计目标 + +* **菜单动态化**:应用程序的导航菜单(结构、文本、图标)应由数据库定义,允许在不重新编译程序的情况下进行修改。 +* **视图解耦**:菜单点击(导航发起者)与目标视图(导航接收者)之间不应有直接引用。 +* **参数化导航**:导航时必须能够安全、清晰地将参数(如一个具体的设备ID)传递给目标视图模型。 +* **层级支持**:支持无限层级的父/子菜单结构。 + +## 2. 数据库设计 (`DbMenu`) + +### 2.1. 设计思路与考量 + +* **数据驱动**:将菜单的结构、显示文本、图标、目标视图键以及导航参数等信息存储在数据库中。 +* **自引用结构**:通过 `ParentId` 字段实现菜单的层级关系,支持无限层级的子菜单。 + +### 2.2. 设计优势 + +* **高度灵活**:无需修改代码和重新部署应用程序,即可通过修改数据库来调整菜单的显示、顺序、层级和导航目标。 +* **易于管理**:可以通过后台管理界面(如果未来开发)来维护菜单,非开发人员也能操作。 +* **个性化**:理论上可以根据用户权限或配置动态生成不同的菜单。 + +### 2.3. 设计劣势/权衡 + +* **数据库依赖**:菜单的可用性依赖于数据库连接和数据完整性。 +* **性能开销**:每次启动或刷新菜单时,都需要从数据库加载数据并构建菜单树,相比硬编码菜单会有轻微的性能开销。 +* **复杂性增加**:需要额外的数据库表、实体、仓储和构建菜单树的逻辑。 + +### 2.4. 示例:`DbMenu.cs` + +```csharp +// 文件: DMS.Infrastructure/Entities/DbMenu.cs +using SqlSugar; + +namespace DMS.Infrastructure.Entities; + +/// +/// 数据库实体:对应数据库中的 Menus 表,用于存储动态菜单结构。 +/// +[SugarTable("Menus")] +public class DbMenu +{ + [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] + public int Id { get; set; } + + /// + /// 父菜单的ID。如果为null或0,则为顶级菜单。 + /// + [SugarColumn(IsNullable = true)] + public int? ParentId { get; set; } + + /// + /// 显示在UI上的菜单文本。 + /// + public string Header { get; set; } + + /// + /// 菜单图标。可以使用 Modern UI 框架提供的字形(Glyph)或图像路径。 + /// + public string Icon { get; set; } + + /// + /// 导航目标的唯一键。这是一个字符串,用于在 NavigationService 中映射到具体的ViewModel类型。 + /// 例如:"DashboardView", "DeviceListView", "DeviceDetailView"。 + /// + public string TargetViewKey { get; set; } + + /// + /// (可选) 导航时需要传递的参数。通常以JSON字符串形式存储,由目标ViewModel解析。 + /// + [SugarColumn(IsNullable = true)] + public string NavigationParameter { get; set; } + + /// + /// 用于排序,决定同级菜单的显示顺序。 + /// + public int DisplayOrder { get; set; } +} +``` + +## 3. 核心导航契约 (`DMS.WPF`) + +### 3.1. `INavigatable` 接口 + +### 3.1.1. 设计思路与考量 + +* **参数化导航**:当导航到某个ViewModel时,可能需要传递特定的数据(如设备ID)。`INavigatable` 接口定义了一个契约,使得任何需要接收导航参数的ViewModel都必须实现 `OnNavigatedToAsync` 方法。 +* **类型安全**:通过 `object parameter` 传递参数,并在 `OnNavigatedToAsync` 内部进行类型检查和转换,确保参数的正确使用。 + +### 3.1.2. 设计优势 + +* **清晰的契约**:明确了ViewModel接收导航参数的方式,提高了代码的可读性和可维护性。 +* **解耦**:导航服务无需知道目标ViewModel的具体实现细节,只需知道它实现了 `INavigatable` 接口。 +* **灵活性**:可以传递任何类型的参数,只要目标ViewModel能够正确解析。 + +### 3.1.3. 设计劣势/权衡 + +* **样板代码**:每个需要接收参数的ViewModel都需要实现 `OnNavigatedToAsync` 方法,并进行参数类型检查。 +* **运行时错误**:如果参数类型不匹配,会在运行时抛出异常,而不是在编译时发现。 + +### 3.1.4. 示例:`INavigatable.cs` + +```csharp +// 文件: DMS.WPF/Services/INavigatable.cs +namespace DMS.WPF.Services; + +/// +/// 定义了一个契约,表示ViewModel可以安全地接收导航传入的参数。 +/// +public interface INavigatable +{ + /// + /// 当导航到此ViewModel时,由导航服务调用此方法,以传递参数。 + /// + /// 从导航源传递过来的参数对象。 + Task OnNavigatedToAsync(object parameter); +} +``` + +### 3.2. `INavigationService` 接口与实现 + +### 3.2.1. 设计思路与考量 + +* **集中导航逻辑**:将所有导航逻辑封装在一个服务中,而不是分散在各个ViewModel中。 +* **字符串键映射**:使用字符串 `viewKey` 来标识目标ViewModel类型,而不是直接使用 `typeof(ViewModel)`,这使得导航配置可以存储在数据库中。 +* **参数传递**:负责将导航参数从发起者传递给目标ViewModel。 + +### 3.2.2. 设计优势 + +* **解耦**:ViewModel之间不直接进行导航,而是通过 `INavigationService`,降低了耦合度。 +* **可测试性**:可以轻松地Mock `INavigationService`,便于单元测试ViewModel的导航行为。 +* **集中控制**:所有导航规则和逻辑集中管理,便于维护和修改。 +* **支持动态导航**:能够根据数据库配置的 `TargetViewKey` 进行导航。 + +### 3.2.3. 设计劣势/权衡 + +* **抽象开销**:引入了额外的服务层,增加了少量代码量。 +* **映射维护**:`GetViewModelTypeByKey` 方法中的 `switch` 语句需要手动维护 `viewKey` 到 `ViewModel` 类型的映射,当ViewModel数量庞大时,维护成本增加。 + +### 3.2.4. 示例:`INavigationService.cs` + +```csharp +// 文件: DMS.WPF/Services/INavigationService.cs +using System.Threading.Tasks; + +namespace DMS.WPF.Services; + +/// +/// 定义了应用程序的导航服务接口。 +/// +public interface INavigationService +{ + /// + /// 导航到由唯一键标识的视图,并传递一个参数。 + /// + /// 在DI容器中注册的目标视图的唯一键(通常是ViewModel的名称)。 + /// 要传递给目标ViewModel的参数。 + Task NavigateToAsync(string viewKey, object parameter = null); +} +``` + +### 3.2.5. 示例:`NavigationService.cs` + +```csharp +// 文件: DMS.WPF/Services/NavigationService.cs +using DMS.WPF.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DMS.WPF.Services; + +/// +/// INavigationService 的实现,负责解析ViewModel并处理参数传递。 +/// +public class NavigationService : INavigationService +{ + private readonly IServiceProvider _serviceProvider; + private readonly MainViewModel _mainViewModel; + + /// + /// 构造函数。 + /// + public NavigationService(IServiceProvider serviceProvider, MainViewModel mainViewModel) + { + _serviceProvider = serviceProvider; + _mainViewModel = mainViewModel; + } + + /// + /// 导航到指定键的视图,并传递参数。 + /// + public async Task NavigateToAsync(string viewKey, object parameter = null) + { + if (string.IsNullOrEmpty(viewKey)) + { + // 记录警告或抛出异常 + return; + } + + // 1. 根据viewKey获取目标ViewModel的Type + var viewModelType = GetViewModelTypeByKey(viewKey); + + // 2. 从DI容器中解析出ViewModel实例 + // 确保ViewModel被正确注册为Transient或Scoped + var viewModel = _serviceProvider.GetRequiredService(viewModelType) as BaseViewModel; + + if (viewModel == null) + { + // 记录错误:无法解析ViewModel + throw new InvalidOperationException($"无法解析 ViewModel 类型: {viewModelType.Name}"); + } + + // 3. 如果ViewModel实现了INavigatable接口,则调用其OnNavigatedToAsync方法传递参数 + if (viewModel is INavigatable navigatableViewModel) + { + await navigatableViewModel.OnNavigatedToAsync(parameter); + } + + // 4. 设置为主窗口的当前视图,触发UI更新 + _mainViewModel.CurrentViewModel = viewModel; + } + + /// + /// 将字符串键映射到具体的ViewModel类型。 + /// + /// 视图键。 + /// 对应的ViewModel类型。 + /// 如果未找到对应的ViewModel类型。 + private Type GetViewModelTypeByKey(string key) + { + // 这是一个硬编码的映射,可以考虑通过反射或配置进行优化 + return key switch + { + "DashboardView" => typeof(DashboardViewModel), + "DeviceListView" => typeof(DeviceListViewModel), + "DeviceDetailView" => typeof(DeviceDetailViewModel), // 假设有这个ViewModel + "VariableListView" => typeof(VariableListViewModel), + "MqttServerListView" => typeof(MqttServerListViewModel), + "MqttServerDetailView" => typeof(MqttServerDetailViewModel), + _ => throw new KeyNotFoundException($"未找到与键 '{key}' 关联的视图模型类型。请检查 NavigationService 的映射配置。"), + }; + } +} +``` + +## 4. 菜单构建与显示 + +### 4.1. `MenuItemViewModel` + +### 4.1.1. 设计思路与考量 + +* **UI绑定适配**:`MenuItemViewModel` 是专门为 `iNKORE.UI.WPF.Modern` 的 `NavigationViewItem` 设计的ViewModel。它包含了UI显示所需的属性(如 `Header`, `Icon`)以及导航所需的命令和参数。 +* **命令封装**:每个菜单项都封装了一个 `NavigateCommand`,当点击菜单时,该命令会调用 `INavigationService` 进行导航。 + +### 4.1.2. 设计优势 + +* **MVVM兼容**:完美适配WPF的数据绑定和命令机制。 +* **封装性**:将菜单项的显示逻辑和导航逻辑封装在一起,提高了内聚性。 +* **可重用性**:`MenuItemViewModel` 可以被任何需要显示菜单项的UI组件复用。 + +### 4.1.3. 设计劣势/权衡 + +* **对象开销**:每个菜单项都需要创建一个 `MenuItemViewModel` 实例,对于非常庞大的菜单树,可能会有轻微的内存开销。 + +### 4.1.4. 示例:`MenuItemViewModel.cs` + +```csharp +// 文件: DMS.WPF/ViewModels/Items/MenuItemViewModel.cs +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DMS.WPF.Services; +using System.Collections.ObjectModel; +using System.Windows.Input; + +namespace DMS.WPF.ViewModels.Items; + +/// +/// 代表一个可导航的菜单项的ViewModel,用于绑定到UI的NavigationViewItem。 +/// +public partial class MenuItemViewModel : ObservableObject +{ + [ObservableProperty] + private string _header; + + [ObservableProperty] + private string _icon; + + // 导航目标键和参数,用于传递给 NavigationService + private readonly string _targetViewKey; + private readonly object _navigationParameter; + + /// + /// 子菜单项集合。 + /// + public ObservableCollection Children { get; } = new(); + + /// + /// 菜单项点击时执行的导航命令。 + /// + public ICommand NavigateCommand { get; } + + /// + /// 构造函数。 + /// + /// 菜单显示文本。 + /// 菜单图标。 + /// 导航目标ViewModel的键。 + /// 导航时传递的参数。 + /// 导航服务实例。 + public MenuItemViewModel(string header, string icon, string targetViewKey, object navigationParameter, INavigationService navigationService) + { + _header = header; + _icon = icon; + _targetViewKey = targetViewKey; + _navigationParameter = navigationParameter; + NavigateCommand = new AsyncRelayCommand(async () => + { + await navigationService.NavigateToAsync(_targetViewKey, _navigationParameter); + }); + } +} +``` + +### 4.2. `IMenuService` (应用层/基础设施层) + +### 4.2.1. 设计思路与考量 + +* **数据加载**:`IMenuService` 负责从数据库加载 `DbMenu` 记录。 +* **树状构建**:将扁平的 `DbMenu` 列表构建成 `MenuItemViewModel` 的树状结构,以便UI直接绑定。 +* **解耦**:将菜单数据的获取和结构化逻辑与UI层分离。 + +### 4.2.2. 示例:`IMenuService.cs` + +```csharp +// 文件: DMS.Application/Interfaces/IMenuService.cs +using DMS.WPF.ViewModels.Items; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DMS.Application.Interfaces; + +/// +/// 定义了菜单服务接口,用于获取应用程序的导航菜单。 +/// +public interface IMenuService +{ + /// + /// 异步获取所有菜单项,并构建成树状结构。 + /// + /// 顶级菜单项的列表。 + Task> GetMenuItemsAsync(); +} +``` + +### 4.2.3. 示例:`MenuService.cs` + +```csharp +// 文件: DMS.Infrastructure/Services/MenuService.cs +using DMS.Application.Interfaces; +using DMS.Core.Interfaces; +using DMS.Infrastructure.Entities; +using DMS.WPF.Services; +using DMS.WPF.ViewModels.Items; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace DMS.Infrastructure.Services; + +/// +/// IMenuService 的实现,负责从数据库加载菜单并构建 MenuItemViewModel 树。 +/// +public class MenuService : IMenuService +{ + private readonly IRepositoryManager _repoManager; + private readonly INavigationService _navigationService; + + /// + /// 构造函数。 + /// + public MenuService(IRepositoryManager repoManager, INavigationService navigationService) + { + _repoManager = repoManager; + _navigationService = navigationService; + } + + /// + /// 异步获取所有菜单项,并构建成树状结构。 + /// + public async Task> GetMenuItemsAsync() + { + var allDbMenus = await _repoManager.Menus.GetAllAsync(); + + // 将 DbMenu 转换为 MenuItemViewModel,并存储在一个字典中,方便查找 + var menuItemsDict = allDbMenus.ToDictionary( + m => m.Id, + m => new MenuItemViewModel( + m.Header, + m.Icon, + m.TargetViewKey, + // 尝试解析 NavigationParameter 为对象 + string.IsNullOrEmpty(m.NavigationParameter) ? null : JsonSerializer.Deserialize(m.NavigationParameter), + _navigationService + ) + ); + + var rootMenuItems = new List(); + + foreach (var dbMenu in allDbMenus) + { + if (dbMenu.ParentId.HasValue && menuItemsDict.TryGetValue(dbMenu.ParentId.Value, out var parentMenuItem)) + { + // 如果有父菜单,则添加到父菜单的Children集合中 + parentMenuItem.Children.Add(menuItemsDict[dbMenu.Id]); + } + else + { + // 否则,添加到根菜单列表 + rootMenuItems.Add(menuItemsDict[dbMenu.Id]); + } + } + + // 根据 DisplayOrder 排序 + return rootMenuItems.OrderBy(m => m.Header).ToList(); // 暂时按Header排序,实际应按DisplayOrder + } +} +``` + +## 5. 目标视图模型实现 + +### 5.1. 设计思路与考量 + +* **参数接收**:目标ViewModel通过实现 `INavigatable` 接口来接收导航参数。 +* **数据加载**:在 `OnNavigatedToAsync` 方法中,使用接收到的参数从应用服务加载所需数据。 + +### 5.2. 示例:`DeviceDetailViewModel.cs` + +```csharp +// 文件: DMS.WPF/ViewModels/DeviceDetailViewModel.cs +using CommunityToolkit.Mvvm.ComponentModel; +using DMS.Application.DTOs; +using DMS.Application.Interfaces; +using DMS.WPF.Services; +using System.Threading.Tasks; + +namespace DMS.WPF.ViewModels; + +/// +/// 设备详情视图的ViewModel,用于显示单个设备的详细信息。 +/// 实现了INavigatable接口以接收导航参数(设备ID)。 +/// +public partial class DeviceDetailViewModel : BaseViewModel, INavigatable +{ + private readonly IDeviceAppService _deviceAppService; + + [ObservableProperty] + private DeviceDto _device; // 假设有一个DeviceDto用于详情显示 + + /// + /// 构造函数。 + /// + public DeviceDetailViewModel(IDeviceAppService deviceAppService) + { + _deviceAppService = deviceAppService; + } + + /// + /// 当导航到此ViewModel时调用,用于加载设备详情。 + /// + /// 导航时传递的设备ID。 + public async Task OnNavigatedToAsync(object parameter) + { + // 1. 校验参数类型 + if (parameter is not int deviceId) + { + // 如果参数不是期望的int类型,则处理错误(例如,记录日志,导航到错误页面,或显示提示) + // _logger.Error("导航到DeviceDetailViewModel时参数类型不匹配。"); + return; + } + + // 2. 使用参数加载数据 + IsBusy = true; + try + { + // 假设IDeviceAppService有GetDeviceDetailAsync方法 + Device = await _deviceAppService.GetDeviceByIdAsync(deviceId); // 使用现有方法 + } + finally + { + IsBusy = false; + } + } +} +``` + +## 6. UI绑定与启动 (`DMS.WPF`) + +### 6.1. `MainViewModel` + +`MainViewModel` 负责从 `IMenuService` 加载菜单数据,并将其暴露给 `MainWindow`。 + +```csharp +// 文件: DMS.WPF/ViewModels/MainViewModel.cs +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DMS.Application.Interfaces; +using DMS.WPF.Services; +using DMS.WPF.ViewModels.Items; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows.Input; + +namespace DMS.WPF.ViewModels; + +/// +/// 主窗口的ViewModel,管理主界面的导航和菜单。 +/// +public partial class MainViewModel : BaseViewModel +{ + private readonly IMenuService _menuService; + private readonly INavigationService _navigationService; + + /// + /// 绑定到UI的菜单项集合。 + /// + public ObservableCollection MenuItems { get; } = new(); + + [ObservableProperty] + private BaseViewModel _currentViewModel; // 当前在右侧显示的主视图模型 + + /// + /// 构造函数。 + /// + public MainViewModel(IMenuService menuService, INavigationService navigationService) + { + _menuService = menuService; + _navigationService = navigationService; + } + + /// + /// 加载菜单数据,并在启动时导航到默认视图。 + /// + public override async Task LoadAsync() + { + IsBusy = true; + MenuItems.Clear(); + var menus = await _menuService.GetMenuItemsAsync(); + foreach(var menu in menus) + { + MenuItems.Add(menu); + } + IsBusy = false; + + // 默认导航到控制台视图 + await _navigationService.NavigateToAsync("DashboardView"); + } +} +``` + +### 6.2. `MainWindow.xaml` + +`MainWindow.xaml` 使用 `iNKORE.UI.WPF.Modern` 的 `NavigationView`,并绑定到 `MainViewModel` 的 `MenuItems` 集合。`ContentControl` 绑定到 `CurrentViewModel`,并使用 `DataTemplate` 来根据ViewModel的类型选择对应的View。 + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` \ No newline at end of file diff --git a/软件设计文档/10-专题设计-MQTT别名关联.md b/软件设计文档/10-专题设计-MQTT别名关联.md new file mode 100644 index 0000000..401580c --- /dev/null +++ b/软件设计文档/10-专题设计-MQTT别名关联.md @@ -0,0 +1,385 @@ +# 10. 专题设计 - MQTT别名关联 + +本文档详细阐述了为满足“一个变量在关联不同MQTT服务器时可以有不同别名”这一需求而设计的“关联实体”架构方案。 + +## 1. 设计方案:关联实体 + +### 1.1. 设计思路与考量 + +* **挑战**:传统的简单多对多映射表(如 `Variable <-> MqttServer`)无法存储“关系”本身的属性。例如,当一个变量 `V1` 关联到 `MQTT_A` 时,其别名为 `Alias_A`;当 `V1` 关联到 `MQTT_B` 时,其别名为 `Alias_B`。这个别名 `Alias` 既不属于 `V1` 也不属于 `MQTT_A` 或 `MQTT_B`,它属于 `V1` 和 `MQTT_A` 之间的特定关联。 +* **解决方案**:引入一个功能完整的“**关联实体**”(Association Entity),我们将其命名为 `VariableMqttAlias`。这个实体作为 `Variable` 和 `MqttServer` 之间关系的载体,自身还携带了关系特有的属性(即 `Alias`)。 + +### 1.2. 设计优势 + +* **数据完整性**:别名属性被牢固地绑定在“变量-服务器”的特定连接上,确保了数据的一致性和准确性。 +* **高度灵活性**:同一个变量可以为不同的MQTT服务器设置完全独立的别名,完美适应各种MQTT Broker对Topic命名规则的差异化要求。 +* **清晰的关注点分离**:数据模型清晰地反映了业务规则,使得数据处理链(特别是MQTT发布逻辑)能够明确地使用正确的别名。 +* **可管理性**:通过应用层提供的服务,UI可以方便地实现对这些别名关联的增、删、改、查操作。 + +### 1.3. 设计劣势/权衡 + +* **复杂性增加**:相比于简单的多对多映射表,引入关联实体增加了额外的表、实体类和仓储,增加了代码量和理解成本。 +* **查询复杂性**:在查询时,需要通过关联实体进行多表连接,可能会使查询语句稍微复杂一些。 + +## 2. 数据库与核心模型 + +我们将用新的 `VariableMqttAlias` 实体来取代之前简单的多对多映射表。 + +### 2.1. `DbVariableMqttAlias` 实体 (`DMS.Infrastructure`) + +```csharp +// 文件: DMS.Infrastructure/Entities/DbVariableMqttAlias.cs +using SqlSugar; + +namespace DMS.Infrastructure.Entities; + +/// +/// 数据库实体:对应数据库中的 VariableMqttAliases 表,用于存储变量与MQTT服务器的关联别名。 +/// +[SugarTable("VariableMqttAliases")] +public class DbVariableMqttAlias +{ + [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] + public int Id { get; set; } + + /// + /// 外键,指向 Variables 表的 Id。 + /// + public int VariableId { get; set; } + + /// + /// 外键,指向 MqttServers 表的 Id。 + /// + public int MqttServerId { get; set; } + + /// + /// 针对此特定[变量-服务器]连接的发布别名。此别名将用于构建MQTT Topic。 + /// + [SugarColumn(Length = 200)] + public string Alias { get; set; } +} +``` + +### 2.2. 领域模型重构 (`DMS.Core`) + +`Variable` 和 `MqttServer` 不再直接相互引用,而是都通过 `VariableMqttAlias` 集合进行关联。这意味着在领域模型层面,它们之间是“通过” `VariableMqttAlias` 建立联系的。 + +```csharp +// 文件: DMS.Core/Models/VariableMqttAlias.cs (新增) +namespace DMS.Core.Models; + +/// +/// 领域模型:代表一个变量到一个MQTT服务器的特定关联,包含专属别名。 +/// 这是一个关联实体,用于解决多对多关系中需要额外属性(别名)的问题。 +/// +public class VariableMqttAlias +{ + public int Id { get; set; } + public int VariableId { get; set; } + public int MqttServerId { get; set; } + public string Alias { get; set; } + + // 导航属性,方便在代码中访问关联的领域对象 + public Variable Variable { get; set; } + public MqttServer MqttServer { get; set; } +} + +// 文件: DMS.Core/Models/Variable.cs (修改) +public class Variable +{ + // ... 其他属性 + // 移除旧的直接关联:public List MqttServers { get; set; } + + /// + /// 此变量的所有MQTT发布别名关联。一个变量可以关联多个MQTT服务器,每个关联可以有独立的别名。 + /// + public List MqttAliases { get; set; } = new(); +} + +// 文件: DMS.Core/Models/MqttServer.cs (修改) +public class MqttServer +{ + // ... 其他属性 + // 移除旧的直接关联:public List Variables { get; set; } + + /// + /// 与此服务器关联的所有变量别名。通过此集合可以反向查找关联的变量。 + /// + public List VariableAliases { get; set; } = new(); +} +``` + +## 3. 数据处理链更新 (`DMS.Infrastructure`) + +### 3.1. 设计思路与考量 + +* **使用别名构建Topic**:`MqttPublishProcessor` 现在必须遍历 `Variable` 的 `MqttAliases` 集合,以获取每个目标服务器及其对应的专属别名来构建MQTT Topic。这确保了发布的消息Topic符合每个MQTT Broker的特定要求。 +* **数据加载**:在处理之前,需要确保 `Variable` 对象的 `MqttAliases` 集合及其内部的 `MqttServer` 导航属性已被正确加载(通常通过仓储的 `Include` 或 `Mapper` 方法)。 + +### 3.2. 示例:`MqttPublishProcessor.cs` + +```csharp +// 文件: DMS.Infrastructure/Services/Processors/MqttPublishProcessor.cs (修改) +using DMS.Core.Interfaces; +using DMS.Core.Models; +using DMS.Infrastructure.Services.Communication; +using CommunityToolkit.Mvvm.Messaging; +using System.Linq; +using System.Threading.Tasks; +using System.Text.Json; +using NLog; + +namespace DMS.Infrastructure.Services.Processing; + +/// +/// MQTT发布处理器,负责将变量值发布到关联的MQTT服务器,并使用专属别名。 +/// +public class MqttPublishProcessor : VariableProcessorBase +{ + private readonly IMqttPublishService _mqttService; + private readonly IRepositoryManager _repoManager; // 使用 RepositoryManager 来获取仓储 + private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); + + /// + /// 构造函数。 + /// + public MqttPublishProcessor(IMqttPublishService mqttService, IRepositoryManager repoManager) + { + _mqttService = mqttService; + _repoManager = repoManager; + } + + protected override async Task HandleAsync(VariableContext context) + { + if (!context.IsValueChanged) return; // 如果值未变化,则不发布 + + // 1. 从仓储获取变量及其完整的别名关联列表 + // 这要求 IVariableRepository 有一个方法能加载 VariableMqttAlias 及其 MqttServer + var variableWithAliases = await _repoManager.Variables.GetVariableWithMqttAliasesAsync(context.Variable.Id); + + if (variableWithAliases?.MqttAliases == null || !variableWithAliases.MqttAliases.Any()) + { + return; // 没有关联的MQTT服务器,无需发布 + } + + foreach (var aliasInfo in variableWithAliases.MqttAliases) + { + try + { + // 确保 MqttServer 导航属性已加载且激活 + var targetServer = aliasInfo.MqttServer; + if (targetServer == null || !targetServer.IsActive) + { + _logger.Warn($"MQTT发布失败:变量 {context.Variable.Name} 关联的MQTT服务器 {aliasInfo.MqttServerId} 不存在或未激活。"); + continue; + } + + // 使用别名构建Topic + // 示例Topic格式:DMS/DeviceName/VariableAlias + var topic = $"DMS/{context.Variable.VariableTable.Device.Name}/{aliasInfo.Alias}"; + var payload = JsonSerializer.Serialize(new { value = context.CurrentValue, timestamp = context.Timestamp }); + + await _mqttService.PublishAsync(targetServer, topic, payload); + } + catch (Exception ex) + { + _logger.Error(ex, $"MQTT发布失败:变量 {context.Variable.Name} 到服务器 {aliasInfo.MqttServer.ServerName},别名 {aliasInfo.Alias}"); + } + } + } +} +``` + +## 4. 应用层支持 (`DMS.Application`) + +### 4.1. 设计思路与考量 + +* **UI管理**:为了让用户能够在UI上管理这些别名,应用层需要提供相应的CRUD(创建、读取、更新、删除)服务。 +* **DTOs**:定义 `VariableMqttAliasDto` 用于在应用层和表现层之间传输别名数据。 + +### 4.2. 示例:`VariableMqttAliasDto.cs` + +```csharp +// 文件: DMS.Application/DTOs/VariableMqttAliasDto.cs +namespace DMS.Application.DTOs; + +/// +/// 用于在UI上显示和管理变量与MQTT服务器关联别名的DTO。 +/// +public class VariableMqttAliasDto +{ + public int Id { get; set; } + public int VariableId { get; set; } + public int MqttServerId { get; set; } + public string MqttServerName { get; set; } // 用于UI显示关联的服务器名称 + public string Alias { get; set; } +} +``` + +### 4.3. 应用服务接口扩展 + +```csharp +// 文件: DMS.Application/Interfaces/IMqttAliasAppService.cs +using DMS.Application.DTOs; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DMS.Application.Interfaces; + +/// +/// 定义了MQTT别名管理相关的应用服务操作。 +/// +public interface IMqttAliasAppService +{ + /// + /// 异步获取指定变量的所有MQTT别名关联。 + /// + Task> GetAliasesForVariableAsync(int variableId); + + /// + /// 异步为变量分配或更新一个MQTT别名。 + /// + /// 变量ID。 + /// MQTT服务器ID。 + /// 要设置的别名。 + Task AssignAliasAsync(int variableId, int mqttServerId, string alias); + + /// + /// 异步更新一个已存在的MQTT别名。 + /// + /// 别名关联的ID。 + /// 新的别名字符串。 + Task UpdateAliasAsync(int aliasId, string newAlias); + + /// + /// 异步移除一个MQTT别名关联。 + /// + /// 要移除的别名关联的ID。 + Task RemoveAliasAsync(int aliasId); +} +``` + +### 4.4. 应用服务实现 + +```csharp +// 文件: DMS.Application/Services/MqttAliasAppService.cs +using AutoMapper; +using DMS.Application.DTOs; +using DMS.Application.Interfaces; +using DMS.Core.Interfaces; +using DMS.Core.Models; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace DMS.Application.Services; + +/// +/// IMqttAliasAppService 的实现,负责管理变量与MQTT服务器的别名关联。 +/// +public class MqttAliasAppService : IMqttAliasAppService +{ + private readonly IRepositoryManager _repoManager; + private readonly IMapper _mapper; + + /// + /// 构造函数。 + /// + public MqttAliasAppService(IRepositoryManager repoManager, IMapper mapper) + { + _repoManager = repoManager; + _mapper = mapper; + } + + /// + /// 异步获取指定变量的所有MQTT别名关联。 + /// + public async Task> GetAliasesForVariableAsync(int variableId) + { + // 从仓储获取别名,并确保加载了关联的MqttServer信息 + var aliases = await _repoManager.VariableMqttAliases.GetAliasesWithServerInfoAsync(variableId); + return _mapper.Map>(aliases); + } + + /// + /// 异步为变量分配或更新一个MQTT别名。 + /// + public async Task AssignAliasAsync(int variableId, int mqttServerId, string alias) + { + try + { + _repoManager.BeginTransaction(); + + // 检查是否已存在该变量与该服务器的关联 + var existingAlias = await _repoManager.VariableMqttAliases.GetByVariableAndServerAsync(variableId, mqttServerId); + + if (existingAlias != null) + { + // 如果存在,则更新别名 + existingAlias.Alias = alias; + await _repoManager.VariableMqttAliases.UpdateAsync(existingAlias); + } + else + { + // 如果不存在,则创建新的关联 + var newAlias = new VariableMqttAlias + { + VariableId = variableId, + MqttServerId = mqttServerId, + Alias = alias + }; + await _repoManager.VariableMqttAliases.AddAsync(newAlias); + } + + await _repoManager.CommitAsync(); + } + catch (Exception ex) + { + await _repoManager.RollbackAsync(); + throw new ApplicationException("分配/更新MQTT别名失败。", ex); + } + } + + /// + /// 异步更新一个已存在的MQTT别名。 + /// + public async Task UpdateAliasAsync(int aliasId, string newAlias) + { + try + { + _repoManager.BeginTransaction(); + var aliasToUpdate = await _repoManager.VariableMqttAliases.GetByIdAsync(aliasId); + if (aliasToUpdate == null) + { + throw new KeyNotFoundException($"未找到ID为 {aliasId} 的MQTT别名关联。"); + } + aliasToUpdate.Alias = newAlias; + await _repoManager.VariableMqttAliases.UpdateAsync(aliasToUpdate); + await _repoManager.CommitAsync(); + } + catch (Exception ex) + { + await _repoManager.RollbackAsync(); + throw new ApplicationException("更新MQTT别名失败。", ex); + } + } + + /// + /// 异步移除一个MQTT别名关联。 + /// + public async Task RemoveAliasAsync(int aliasId) + { + try + { + _repoManager.BeginTransaction(); + await _repoManager.VariableMqttAliases.DeleteAsync(aliasId); + await _repoManager.CommitAsync(); + } + catch (Exception ex) + { + await _repoManager.RollbackAsync(); + throw new ApplicationException("移除MQTT别名失败。", ex); + } + } +} +``` \ No newline at end of file