|
1 | 1 | ## Context |
2 | 2 |
|
3 | | -Modulus 框架支持模块化扩展,但当前构建流程将 Host 和模块分别输出到不同位置: |
4 | | -- Host: `artifacts/` |
5 | | -- Modules: `artifacts/Modules/` |
| 3 | +Modulus 框架当前启动流程: |
| 4 | +1. 扫描模块目录(`{AppBaseDir}/Modules/`、用户目录) |
| 5 | +2. 读取每个模块的 `extension.vsixmanifest` |
| 6 | +3. 逐个与数据库比对,不存在则安装 |
| 7 | +4. 从数据库加载已启用模块 |
6 | 8 |
|
7 | | -在 DEBUG 模式下,Host 从解决方案根目录的 `artifacts/Modules/` 加载模块。但在 RELEASE 模式下,Host 期望从 `{AppBaseDir}/Modules/` 加载内置模块,该目录当前为空。 |
| 9 | +**问题**:每次启动都执行目录扫描和 manifest 解析,即使模块没有变化。 |
8 | 10 |
|
9 | | -**目标**: 框架 Owner 可声明哪些模块作为内置模块发布,运行即包含。 |
| 11 | +**目标**:模块加载的唯一来源是数据库,内置模块通过 EF 迁移预置。 |
10 | 12 |
|
11 | 13 | ## Goals / Non-Goals |
12 | 14 |
|
13 | 15 | **Goals**: |
14 | | -- 配置文件声明内置模块列表 |
15 | | -- 构建时自动复制内置模块到 Host 输出目录 |
16 | | -- 内置模块标记为系统模块(不可卸载) |
| 16 | +- 内置模块数据通过 EF 迁移管理 |
| 17 | +- 提供 CLI 工具自动生成模块数据迁移 |
| 18 | +- 简化运行时启动流程 |
| 19 | +- 合并现有迁移,提供干净起点 |
17 | 20 |
|
18 | 21 | **Non-Goals**: |
19 | | -- 模块版本锁定(使用当前构建版本) |
20 | | -- 内置模块的独立更新机制 |
21 | | -- 内置模块的签名验证 |
| 22 | +- 用户安装模块仍使用现有流程(`InstallFromPathAsync`) |
| 23 | +- 不修改模块打包格式(`.modpkg`) |
| 24 | +- 开发模式(DEBUG)可保留目录扫描便于调试 |
22 | 25 |
|
23 | 26 | ## Decisions |
24 | 27 |
|
25 | | -### 1. 配置文件格式 |
| 28 | +### 1. 合并现有迁移 |
26 | 29 |
|
27 | | -**Decision**: 使用 `bundled-modules.json` 配置文件。 |
| 30 | +**Decision**: 将 8 个现有迁移合并为 `InitialCreate`,作为干净起点。 |
28 | 31 |
|
29 | | -```json |
| 32 | +**步骤**: |
| 33 | +1. 删除所有现有迁移文件 |
| 34 | +2. 删除本地数据库 |
| 35 | +3. 重新生成 `InitialCreate` 迁移(包含当前完整 schema) |
| 36 | +4. 迁移中可包含 Host 内置模块(`HostModules`)的种子数据 |
| 37 | + |
| 38 | +### 2. EF 迁移管理模块数据 |
| 39 | + |
| 40 | +**Decision**: 使用 EF Core 迁移框架,但通过**强类型接口和扩展方法**避免硬编码。 |
| 41 | + |
| 42 | +#### 2.1 数据模型接口 |
| 43 | + |
| 44 | +```csharp |
| 45 | +// Modulus.Infrastructure.Data/Seeding/IModuleSeedData.cs |
| 46 | +public interface IModuleSeedData |
30 | 47 | { |
31 | | - "$schema": "./bundled-modules.schema.json", |
32 | | - "modules": [ |
33 | | - "EchoPlugin", |
34 | | - "ComponentsDemo" |
35 | | - ] |
| 48 | + string Id { get; } |
| 49 | + string Name { get; } |
| 50 | + string Version { get; } |
| 51 | + string? Description { get; } |
| 52 | + string? Author { get; } |
| 53 | + string Path { get; } |
| 54 | + bool IsSystem { get; } |
| 55 | + bool IsEnabled { get; } |
| 56 | + MenuLocation MenuLocation { get; } |
| 57 | + ModuleState State { get; } |
| 58 | +} |
| 59 | + |
| 60 | +public interface IMenuSeedData |
| 61 | +{ |
| 62 | + string Id { get; } |
| 63 | + string ModuleId { get; } |
| 64 | + string DisplayName { get; } |
| 65 | + string Icon { get; } |
| 66 | + string Route { get; } |
| 67 | + MenuLocation Location { get; } |
| 68 | + int Order { get; } |
36 | 69 | } |
37 | 70 | ``` |
38 | 71 |
|
39 | | -**位置**: 每个 Host 项目目录下(如 `src/Hosts/Modulus.Host.Avalonia/bundled-modules.json`) |
| 72 | +#### 2.2 迁移辅助扩展方法 |
40 | 73 |
|
41 | | -**Alternatives considered**: |
42 | | -- MSBuild ItemGroup (`<BundledModule>`) - 需要复杂的 MSBuild 集成 |
43 | | -- appsettings.json 配置节 - 运行时配置,不适合构建时使用 |
| 74 | +```csharp |
| 75 | +// Modulus.Infrastructure.Data/Seeding/MigrationBuilderExtensions.cs |
| 76 | +public static class MigrationBuilderExtensions |
| 77 | +{ |
| 78 | + public static void InsertModule(this MigrationBuilder builder, IModuleSeedData module) |
| 79 | + { |
| 80 | + builder.InsertData( |
| 81 | + table: nameof(ModulusDbContext.Modules), |
| 82 | + columns: new[] |
| 83 | + { |
| 84 | + nameof(ModuleEntity.Id), |
| 85 | + nameof(ModuleEntity.Name), |
| 86 | + nameof(ModuleEntity.Version), |
| 87 | + // ... 使用 nameof() 确保编译时检查 |
| 88 | + }, |
| 89 | + values: new object?[] { module.Id, module.Name, module.Version, ... }); |
| 90 | + } |
| 91 | + |
| 92 | + public static void InsertMenu(this MigrationBuilder builder, IMenuSeedData menu) { ... } |
| 93 | + public static void DeleteModule(this MigrationBuilder builder, string moduleId) { ... } |
| 94 | + public static void UpdateModuleVersion(this MigrationBuilder builder, string moduleId, string newVersion) { ... } |
| 95 | +} |
| 96 | +``` |
44 | 97 |
|
45 | | -### 2. 构建流程 |
| 98 | +#### 2.3 生成的迁移示例 |
46 | 99 |
|
47 | | -**Decision**: 新增 `BundleModules` Nuke 目标,在 `BuildModule` 后执行。 |
| 100 | +```csharp |
| 101 | +// CLI 生成的迁移 - 强类型、无硬编码 |
| 102 | +public partial class SeedModule_AddEchoPlugin : Migration |
| 103 | +{ |
| 104 | + protected override void Up(MigrationBuilder migrationBuilder) |
| 105 | + { |
| 106 | + migrationBuilder.InsertModule(new ModuleSeed |
| 107 | + { |
| 108 | + Id = "EchoPlugin", |
| 109 | + Name = "Echo Plugin", |
| 110 | + Version = "1.0.0", |
| 111 | + Description = "Demo echo functionality", |
| 112 | + Author = "AGIBuild", |
| 113 | + Path = "Modules/EchoPlugin/extension.vsixmanifest", |
| 114 | + IsSystem = true, |
| 115 | + IsEnabled = true, |
| 116 | + MenuLocation = MenuLocation.Main, |
| 117 | + State = ModuleState.Ready |
| 118 | + }); |
| 119 | + |
| 120 | + migrationBuilder.InsertMenu(new MenuSeed |
| 121 | + { |
| 122 | + Id = "EchoPlugin.Main", |
| 123 | + ModuleId = "EchoPlugin", |
| 124 | + DisplayName = "Echo", |
| 125 | + Icon = "MessageCircle", |
| 126 | + Route = "echo", |
| 127 | + Location = MenuLocation.Main, |
| 128 | + Order = 100 |
| 129 | + }); |
| 130 | + } |
| 131 | + |
| 132 | + protected override void Down(MigrationBuilder migrationBuilder) |
| 133 | + { |
| 134 | + migrationBuilder.DeleteModule("EchoPlugin"); |
| 135 | + } |
| 136 | +} |
48 | 137 |
|
49 | | -``` |
50 | | -nuke build |
51 | | - │ |
52 | | - ├── Restore |
53 | | - ├── BuildApp → artifacts/*.dll |
54 | | - ├── BuildModule → artifacts/Modules/{ModuleName}/ |
55 | | - └── BundleModules → 复制到 artifacts/Modules/ (最终位置) |
| 138 | +// file-scoped 实现类 |
| 139 | +file record ModuleSeed : IModuleSeedData { ... } |
| 140 | +file record MenuSeed : IMenuSeedData { ... } |
56 | 141 | ``` |
57 | 142 |
|
58 | | -**BundleModules 逻辑**: |
59 | | -1. 读取目标 Host 的 `bundled-modules.json` |
60 | | -2. 对于每个声明的模块名: |
61 | | - - 从 `artifacts/Modules/{ModuleName}/` 复制到 `artifacts/Modules/` |
62 | | -3. 模块已在正确位置,无需额外复制(当前构建输出已是 `artifacts/Modules/`) |
| 143 | +**优势**: |
| 144 | +- 复用 EF 迁移版本管理、回滚能力 |
| 145 | +- **强类型接口** + `nameof()` 确保编译时检查 |
| 146 | +- 避免硬编码表名/列名 |
| 147 | +- 统一管理 Schema + 数据迁移 |
63 | 148 |
|
64 | | -**注意**: 由于当前 `BuildModule` 已将模块输出到 `artifacts/Modules/`,`BundleModules` 主要用于: |
65 | | -- 验证配置的模块确实存在 |
66 | | -- 未来支持选择性打包(仅打包声明的模块) |
| 149 | +### 3. CLI 命令 |
67 | 150 |
|
68 | | -### 3. 发布包结构 |
| 151 | +**Decision**: `modulus add-module-migration` |
69 | 152 |
|
70 | | -``` |
71 | | -artifacts/ |
72 | | -├── Modulus.Host.Avalonia.exe |
73 | | -├── Modulus.Host.Avalonia.dll |
74 | | -├── appsettings.json |
75 | | -├── ... (Host 依赖) |
76 | | -└── Modules/ |
77 | | - ├── EchoPlugin/ |
78 | | - │ ├── extension.vsixmanifest |
79 | | - │ ├── EchoPlugin.Core.dll |
80 | | - │ └── EchoPlugin.UI.Avalonia.dll |
81 | | - └── ComponentsDemo/ |
82 | | - ├── extension.vsixmanifest |
83 | | - └── *.dll |
84 | | -``` |
| 153 | +**自动生成逻辑**: |
| 154 | +1. 扫描 `src/Modules/` 目录,解析所有 manifest |
| 155 | +2. 分析现有迁移,确定已种子的模块 |
| 156 | +3. 检测差异(新增/版本更新/删除) |
| 157 | +4. 生成 EF 迁移类 |
| 158 | + |
| 159 | +**命名约定**: |
| 160 | +- 文件名自动生成:`{timestamp}_SeedModule_{Action}.cs` |
| 161 | +- 例:`20251210143052_SeedModule_AddEchoPlugin.cs` |
| 162 | + |
| 163 | +**示例输出**: |
| 164 | +```bash |
| 165 | +$ modulus add-module-migration |
| 166 | + |
| 167 | +Scanning modules... |
| 168 | + ✓ EchoPlugin v1.0.0 (new) |
| 169 | + ✓ ComponentsDemo v1.2.0 (updated) |
85 | 170 |
|
86 | | -### 4. 运行时行为 |
| 171 | +Generating migration: 20251210143052_SeedModule_Update.cs |
| 172 | + + INSERT EchoPlugin |
| 173 | + ~ UPDATE ComponentsDemo |
87 | 174 |
|
88 | | -**现有代码已支持** (`App.axaml.cs`): |
| 175 | +Done! |
| 176 | +``` |
| 177 | + |
| 178 | +### 4. 启动流程简化 |
89 | 179 |
|
| 180 | +**Before**: |
90 | 181 | ```csharp |
91 | | -#if !DEBUG |
92 | | -// Production: Load from {AppBaseDir}/Modules/ |
93 | | -var appModules = Path.Combine(AppContext.BaseDirectory, "Modules"); |
94 | | -if (Directory.Exists(appModules)) |
| 182 | +await db.Database.MigrateAsync(); |
| 183 | + |
| 184 | +var installer = scope.ServiceProvider.GetRequiredService<SystemModuleInstaller>(); |
| 185 | +foreach (var dir in moduleDirectories) |
95 | 186 | { |
96 | | - moduleDirectories.Add(new ModuleDirectory(appModules, IsSystem: true)); |
| 187 | + await installer.InstallFromDirectoryAsync(dir.Path, dir.IsSystem, hostType); |
97 | 188 | } |
98 | | -#endif |
| 189 | + |
| 190 | +var enabledModules = await db.Modules.Where(m => m.IsEnabled).ToListAsync(); |
99 | 191 | ``` |
100 | 192 |
|
101 | | -- 内置模块自动标记为 `IsSystem: true` |
102 | | -- 系统模块在 UI 中不显示卸载按钮 |
103 | | -- `SystemModuleInstaller` 自动注册到数据库 |
| 193 | +**After**: |
| 194 | +```csharp |
| 195 | +await db.Database.MigrateAsync(); // EF 迁移自动应用模块数据 |
| 196 | +
|
| 197 | +// 用户安装模块目录仍需扫描(非内置) |
| 198 | +if (userModulesDirectory != null && Directory.Exists(userModulesDirectory)) |
| 199 | +{ |
| 200 | + await installer.InstallFromDirectoryAsync(userModulesDirectory, isSystem: false, hostType); |
| 201 | +} |
104 | 202 |
|
105 | | -### 5. 模块冲突处理 |
| 203 | +var enabledModules = await db.Modules.Where(m => m.IsEnabled).ToListAsync(); |
| 204 | +``` |
| 205 | + |
| 206 | +**Release 模式**: |
| 207 | +- 内置模块:通过 EF 迁移预置,无需扫描 |
| 208 | +- 用户模块:保留目录扫描(`%APPDATA%/Modulus/Modules/`) |
106 | 209 |
|
107 | | -**场景**: 用户安装了与内置模块同 ID 的模块。 |
| 210 | +**Debug 模式**: |
| 211 | +- 可保留 `artifacts/Modules/` 扫描,便于开发调试 |
108 | 212 |
|
109 | | -**Decision**: 系统模块优先,用户模块被忽略。 |
| 213 | +### 5. 迁移目录结构 |
110 | 214 |
|
111 | | -**原理**: |
112 | | -- 系统模块路径 (`{AppBaseDir}/Modules/`) 先于用户路径 (`%APPDATA%/Modulus/Modules/`) 加载 |
113 | | -- 相同 ID 的模块只加载第一个 |
| 215 | +``` |
| 216 | +src/Shared/Modulus.Infrastructure.Data/Migrations/ |
| 217 | +├── 20251210000000_InitialCreate.cs # Schema + Host 模块种子 |
| 218 | +├── 20251210000000_InitialCreate.Designer.cs |
| 219 | +├── 20251210143052_SeedModule_AddEchoPlugin.cs # 模块数据 |
| 220 | +├── 20251210143052_SeedModule_AddEchoPlugin.Designer.cs |
| 221 | +├── 20251211092134_SeedModule_AddComponentsDemo.cs # 模块数据 |
| 222 | +└── ModulusDbContextModelSnapshot.cs |
| 223 | +``` |
114 | 224 |
|
115 | 225 | ## Risks / Trade-offs |
116 | 226 |
|
117 | 227 | | 风险 | 影响 | Mitigation | |
118 | 228 | |------|------|------------| |
119 | | -| 配置模块名拼写错误 | 构建时无模块 | BundleModules 验证并报错 | |
120 | | -| 内置模块与用户模块冲突 | 用户模块被忽略 | 文档说明优先级规则 | |
121 | | -| 发布包体积增大 | 分发文件变大 | 可接受,按需配置 | |
| 229 | +| 迁移合并需要清除现有数据库 | 开发环境需重建 | 仅影响开发,提供清晰文档 | |
| 230 | +| CLI 生成迁移需要解析 manifest | 依赖 manifest 格式 | 复用现有 VsixManifestReader | |
| 231 | +| 开发模式仍需目录扫描 | DEBUG 和 RELEASE 行为不同 | 可接受,便于开发 | |
122 | 232 |
|
123 | 233 | ## Open Questions |
124 | 234 |
|
125 | | -1. 是否需要支持条件打包(如仅 Release 配置打包)? |
126 | | - - 暂定:不需要,DEBUG 和 RELEASE 共用配置 |
| 235 | +1. 是否在 `InitialCreate` 中包含默认内置模块种子? |
| 236 | + - 建议:是,将 HostModules(Settings 等)作为初始种子 |
127 | 237 |
|
| 238 | +2. 用户安装模块是否也迁移到 EF 迁移方式? |
| 239 | + - 建议:否,保持运行时安装流程 |
0 commit comments