|
| 1 | +--- |
| 2 | +title: 消失的历史记录 |
| 3 | +date: 2026-03-30 21:10:53 |
| 4 | +categories: 技术架构 |
| 5 | +tags: [C#, WPF, MongoDB, 工业软件, 架构解析] |
| 6 | +--- |
| 7 | + |
| 8 | +## 序言:消失的历史记录 |
| 9 | + |
| 10 | +在半导体设备控制系统(基于 **Cimetrix CCF** 框架)的开发中,我们常遇到一个诡异的现象:通过 U 盘直接拷贝到本地文件夹的配方(Recipe),第一次打开时虽然能看到步骤和参数,但“配方历史(History)”却是一片空白;只有手动点击一次“保存”后,历史记录才会神奇地显现。 |
| 11 | + |
| 12 | +通过对全栈代码的深度追踪,我们还原了一场关于**物理肉体(磁盘文件)与数据库灵魂(Mongo索引)**的同步之旅。这个链条完美展示了 MVVM 模式、WCF 跨进程通信、IoC 依赖注入 以及 Repository 仓储模式 是如何串联协作的。 |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +## 一、 架构全链路解析:从点击到落盘 |
| 17 | + |
| 18 | +这条调用链条展示了工业级软件如何通过多层架构实现界面与数据的彻底解耦。 |
| 19 | + |
| 20 | +### 1. 表现层 (View Layer) |
| 21 | +界面渲染与事件触发 |
| 22 | +* **触发点**:`Live visual tree` 中的 `btnSave` 按钮。 |
| 23 | +* **代码实现**: |
| 24 | + ```xml |
| 25 | + <Button x:Name="btnSave" |
| 26 | + Style="{StaticResource ButtonHeaderStyle}" |
| 27 | + Content="Save" |
| 28 | + Visibility="{Binding RecipeEditorVisibility}" |
| 29 | + Command="{Binding SaveCommand}" /> |
| 30 | + ``` |
| 31 | +* **架构意义**:WPF 界面通过命令绑定(Command Binding)驱动逻辑。构造函数中的 `ViewModel = viewModel;` 建立了 View 与 ViewModel 的契合。WPF 的 XAML 界面通过数据绑定(Data Binding)将界面上的“Save”按钮与后台 ViewModel 中的 SaveCommand 命令死死绑定。界面不知道什么是数据库,它只知道“有人点了我,我就去喊 SaveCommand”。 |
| 32 | + |
| 33 | +### 2. 逻辑层 (ViewModel Layer) |
| 34 | +业务逻辑与命令封装 |
| 35 | +* **核心类**:`class RecipeEditorScreenViewModel : BaseFAViewModel` |
| 36 | +* **指令封装**:`SaveCommand = new GUICommand<string>(DoSave);` |
| 37 | +* **业务逻辑**: |
| 38 | + ```csharp |
| 39 | + private void DoSave(object obj) |
| 40 | + { |
| 41 | + Recipe.LastRevisionTime = DateTime.Now; // 更新时间戳 |
| 42 | + var xml = GetFullRecipeXml(); // 组装物理 XML 内容 |
| 43 | + Save(xml); // 进入保存流 |
| 44 | + MessageBox.Show("Saved Successfully!"); |
| 45 | + } |
| 46 | + ``` |
| 47 | +* **架构意义**:ViewModel 负责状态管理与数据封装,它不关心数据存哪,只负责“准备好要存的数据”。ViewModel 接管了控制权。在 DoSave 中,系统将当前配方的最新状态打包生成了 XML 文件(准备存入本地磁盘),并组装好了配方的元数据档案。此时,所有的活儿都还在 UI 进程的内存里打转。 |
| 48 | + |
| 49 | +### 3. 服务通信层 (Service / WCF Layer) |
| 50 | +跨进程通信与端点隔离 |
| 51 | +* **入口**:`var proxy = ServiceClientsFactory.CreateGUIOperationService();` |
| 52 | +* **中转站**:`class GUIOperationService : WcfServiceHost, IGUIOperationService` |
| 53 | +* **架构意义**:UI 进程与后台服务进程分离。通过 WCF 代理,UI 将数据包发送给具备更高权限的服务端进程,实现进程级隔离。这是整个架构的分水岭!工业设备软件绝不允许 UI 直接操作数据库。UI 通过代理工厂(ServiceClientsFactory)拿到了一个 WCF 客户端代理,把数据像打包发快递一样,通过网络(或命名管道)发送给了运行在后台的 GUIOperationService 服务进程。 |
| 54 | + |
| 55 | +### 4. 数据仓储层 (Repository Layer) 物理持久层 (Database Layer) |
| 56 | +依赖倒置与数据持久化 |
| 57 | +* **接口调用**:`var recorder = IOC.Service.Container.Resolve<IRecipeIndexItemRepository>();` |
| 58 | +* **架构意义**:利用 **IoC (依赖注入)** 容器动态解析仓储。这使得系统可以轻松切换存储介质(如从 MongoDB 切换到 SQL),而无需修改上层逻辑。后台服务收到包裹后,并没有直接写死“我要连 MongoDB”。它转身向 IoC 容器(控制反转容器)要了一个“实现了配方仓储接口的工具人”。系统配置在这个时候生效,甩给了它一个专门对接 MongoDB 的实例(MongoDBRecipeIndexItemRepository)。这种设计叫依赖倒置,哪怕明天你们决定把 MongoDB 换成 SQL Server,前面的所有代码一行都不用改。 |
| 59 | + |
| 60 | +* **具体实现**:`class MongoDBRecipeIndexItemRepository` |
| 61 | +* **底层指令**: |
| 62 | +```csharp |
| 63 | +public void UpdateItem(RecipeIndexItem item) |
| 64 | +{ |
| 65 | + itemCollection.FindOneAndReplace<RecipeIndexItem, RecipeIndexItem>( |
| 66 | + x => x.Name == item.Name, |
| 67 | + item, |
| 68 | + new FindOneAndReplaceOptions<RecipeIndexItem, RecipeIndexItem>() { IsUpsert = true } |
| 69 | + ); |
| 70 | +} |
| 71 | +``` |
| 72 | +* **架构意义**:这是链路的终点。`IsUpsert = true` 保证了数据库的“自愈”能力。链条的终点!这行代码拿着刚才历经千辛万苦传过来的配方索引对象,对 MongoDB 执行了原子操作。最精妙的 IsUpsert = true 发挥了**有则更新,无则插入(建档)**的神奇作用。 |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +## 二、 证据确凿! |
| 77 | +所有的线索都白纸黑字地写在你的 RecipeEditorScreenViewModel.cs 代码里。我们可以从代码中明确看到,当你选中或双击打开一个配方时,系统是如何兵分两路,分别提取“本地文件(XML参数)”和“数据库记录(History历史)”的。 |
| 78 | +### 证据一:读取本地磁盘文件(获取 Steps 和参数) |
| 79 | +当你选中一行配方时,代码会调用 LoadRecipe(RecipeIndexItem item) 方法。请看这个方法的前几行:GetRecipe 就是去拿物理文件的。解析出来的 doc (XDocument) 就是你在界面上看到的那些工艺参数和步骤。这一步完全不碰历史记录。 |
| 80 | +``` csharp |
| 81 | +private bool LoadRecipe(RecipeIndexItem item) |
| 82 | +{ |
| 83 | + if (string.IsNullOrEmpty(item.Name)) return false; |
| 84 | + |
| 85 | + // 【铁证 1】:调用底层服务,通过文件路径(item.Name)去读本地的物理文件,返回字节流 |
| 86 | + var content = OIClient.Service.GetRecipe(item.Name); |
| 87 | + |
| 88 | + // ... 省略校验代码 ... |
| 89 | + |
| 90 | + // 把字节流解码成字符串,并解析成 XML 文档! |
| 91 | + var encoder = new ASCIIEncoding(); |
| 92 | + var recipe = encoder.GetString(content); |
| 93 | + var doc = XDocument.Parse(recipe); |
| 94 | + |
| 95 | + // ... 接着就是把 XML 里的 Steps 解析出来 ... |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +### 证据二:读取 MongoDB 里的元数据(获取权限与冻结状态) |
| 100 | +那 MongoDB 的数据是在哪读的呢?其实,传进来的参数 RecipeIndexItem item 本身,就是从 MongoDB 里查出来的完整数据实体(对应你之前截图里的那条 JSON)! |
| 101 | + |
| 102 | +请看双击打开配方的方法 DoRecipeDoubleClickCommand(RecipeIndexItem item):界面的权限控制(能否编辑)和冻结状态(FrozenRecipe),直接取自这个代表 MongoDB 记录的 item 对象。 |
| 103 | +``` csharp |
| 104 | +private void DoRecipeDoubleClickCommand(RecipeIndexItem item) |
| 105 | +{ |
| 106 | + // 此时 item 已经包含了从 MongoDB 拉取的 Role, AccessItems, History 等数据 |
| 107 | + // ... |
| 108 | + if (UpdateRecipeOperation(item) && LoadRecipe(item)) // 这里去调了上面的读文件方法 |
| 109 | + { |
| 110 | + // 【铁证 2】:直接从这个 MongoDB 实体对象中提取数据,赋给界面的属性 |
| 111 | + currentRole = item.Role == null ? OIClient.Service.CurrentUser.Group : item.Role; |
| 112 | + FrozenRecipe = item.Frozened; // 从数据库对象里读取“是否冻结” |
| 113 | + |
| 114 | + UpdateRecipeCompare(item); // 核心:传递给历史记录 |
| 115 | + SwitchToEditMode(); |
| 116 | + } |
| 117 | +} |
| 118 | +``` |
| 119 | +### 证据三:将 MongoDB 的历史记录丢给 UI 显示 |
| 120 | +最后,我们来看看历史记录(History)是怎么跑到界面上去的。紧接着上面的代码,它调用了 UpdateRecipeCompare(item):RecipeHistoryScreenViewModel 拿到这个对象后,就会去读里面的 History 数组来渲染表格。如果这是 U 盘刚拷进来的文件,后台扫描到文件后捏造了一个临时的 recipeItem 壳子让它能显示在列表里,但由于 Mongo 里没数据,它的 History 就是 null。如果是正常保存过的,这个 recipeItem 就是从 MongoDB 里完整查出来的,History 数组里就有数据,界面也就有显示了。 |
| 121 | +``` csharp |
| 122 | +private void UpdateRecipeCompare(RecipeIndexItem recipeItem) |
| 123 | +{ |
| 124 | + RecipeCompareScreenViewModel.MasterRecipeIndexItem = recipeItem; |
| 125 | + |
| 126 | + // 【铁证 3】:把代表 MongoDB 记录的整个对象,塞给了专门负责显示历史记录的 ViewModel! |
| 127 | + RecipeHistoryScreenViewModel.RecipeIndexItem = recipeItem; |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +--- |
| 132 | + |
| 133 | +## 三、 真相大白:为何保存后历史记录才出现? |
| 134 | + |
| 135 | +针对“U 盘强塞文件”场景,同步逻辑如下: |
| 136 | + |
| 137 | +1. **“黑户”状态**:强行拷贝 .rcp 文件到本地,只更新了物理磁盘,没有触发上述链条的第三步到第五步。MongoDB(第四层)对此一无所知。 |
| 138 | +2. **第一次打开**:UI 层(第一层)只能读到本地磁盘的 XML 参数,但当它向 WCF 服务(第三层)索要 MongoDB(第四层)里的 History 时,服务层只能返回空(null),导致界面历史记录空白。 |
| 139 | +3. **点下保存(触发自愈)**:你的点击激活了上面的完整链条。数据一路狂奔直达底层,第五步的 IsUpsert = true 强制给这个“黑户”配方在 MongoDB 中新建了档案。。 |
| 140 | +4. **最终刷新**:DoSave 代码最后立刻刷新界面,重新走了一遍读操作。这一次,从 MongoDB 里顺利取回了刚刚建立的新档案,History 数据显现,物理世界与数据库世界彻底恢复同步! |
| 141 | + |
| 142 | +## 结语 |
| 143 | + |
| 144 | +这一条从 `btnSave` 到 `FindOneAndReplace` 的调用链,体现了工业软件追求的高内聚、低耦合。它告诉我们:在复杂的系统中,看到的“现象”往往只是冰山一角,而底层的架构逻辑才是决定数据流转的隐形双手。 |
0 commit comments