模块开发
使用 manifest、版本化 SDK 契约、迁移、endpoint 和可选 Vue 扩展建立可维护的产品模块。
FormPlatform 模块开发 Step-by-Step
English: MODULE_DEVELOPMENT_STEP_BY_STEP.md
本教程从空目录建立一个独立 `Inventory` 模块。它包含配置、三数据库 migration、ORM Data Model、领域服务、API、Server Action、客户端 i18n 和部署脚本。模块与 Host 源码分离,只引用发布的 SDK。
1. 什么时候需要模块
只有表单布局、字段和事件变化时,不需要模块。需要独立数据库表、服务器 API、Server Action/Trigger、可复用控件、独立路由或客户专有逻辑时使用模块。模块是可信同进程代码,拥有完整服务器权限;不可信代码应放到独立服务。
2. 前置条件与目录
先构建/发布 FormPlatform,使 SDK 目录至少包含:
FormPlatform.Sdk.dll
FormPlatform.Sdk.xml
FormPlatform.Extension.Abstractions.dll
FormPlatform.Extension.Abstractions.xml
建议同级目录:
workspace/
FormPlatform/
Inventory/
InventoryModule.csproj
module.json
appsettings.inventory.json
InventoryModuleEntry.cs
InventoryMigrations.cs
InventoryService.cs
InventoryActions.cs
client/
index.js
inventory.css
deploy.ps1
deploy.bat
uninstall.ps1
uninstall.bat
3. 建立项目
`InventoryModule.csproj`:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>1.0.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>InventoryModule</AssemblyName>
<RootNamespace>InventoryModule</RootNamespace>
<GenerateDependencyFile>true</GenerateDependencyFile>
<FormPlatformSdkDirectory Condition="'$(FormPlatformSdkDirectory)' == ''">..\FormPlatform\src\FormPlatform.Host\bin\Debug\net10.0</FormPlatformSdkDirectory>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<Reference Include="FormPlatform.Sdk" HintPath="$(FormPlatformSdkDirectory)\FormPlatform.Sdk.dll" Private="false" />
<Reference Include="FormPlatform.Extension.Abstractions" HintPath="$(FormPlatformSdkDirectory)\FormPlatform.Extension.Abstractions.dll" Private="false" />
</ItemGroup>
<ItemGroup>
<None Update="module.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.inventory.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="client\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
`Private=false` 很重要:Host 提供唯一 SDK/Abstractions,模块包不应复制私有版本。
4. 建立 module.json
{
"manifestVersion": 1,
"name": "Inventory",
"version": "1.0.0",
"entryAssembly": "InventoryModule.dll",
"type": "InventoryModule.InventoryModuleEntry",
"sdk": { "minimum": "1.0.0", "maximumExclusive": "2.0.0" },
"platform": { "minimum": "1.0.0", "maximumExclusive": "2.0.0" },
"navigation": [
{ "token": "inventory-management", "titleKey": "menu.inventory.management", "icon": "📦", "isGroup": true },
{ "token": "inventory-items", "titleKey": "menu.inventory.items", "target": "/inventory", "icon": "📋", "parentToken": "inventory-management" }
],
"ownedSystemForms": []
}
`name` 只能包含字母数字、`.`、`_`、`-`,最多 100 字符。JSON version 必须与程序集 Major/Minor/Build 一致。兼容范围是左闭右开。
`navigation` 是模块完整的声明式菜单所有权清单。每项都有稳定的 `token`、`titleKey` 和 `icon`;普通链接还要提供应用相对 `target`。菜单分组使用 `isGroup: true`;其子链接使用 `parentToken`。宿主会补充缺失项,并把模块拥有的子链接移动到声明的父项下。`ownedSystemForms` 只列出模块可重新生成的固定系统表单 ID;即使模块暂时不拥有菜单或表单,也要保留显式空数组。
5. 模块配置和 ORM Entity
`appsettings.inventory.json`:
{
"Inventory": { "DefaultPageSize": 25, "MaximumPageSize": 200 },
"DataAccess": {
"Entities": {
"InventoryItem": {
"Schema": "public",
"TableName": "inventory_item",
"Attributes": [
{ "PropertyName": "id", "ColumnName": "id", "ValueKind": "String", "IsNullable": false, "IsKey": true, "IsGenerated": true, "CanWrite": false, "MaxLength": 36 },
{ "PropertyName": "name", "ColumnName": "name", "ValueKind": "String", "IsNullable": false, "MaxLength": 200 },
{ "PropertyName": "quantity", "ColumnName": "quantity", "ValueKind": "Int32", "IsNullable": false },
{ "PropertyName": "updatedBy", "ColumnName": "updated_by", "ValueKind": "String", "IsNullable": false, "MaxLength": 36 },
{ "PropertyName": "updatedAt", "ColumnName": "updated_at", "ValueKind": "DateTimeOffset", "IsNullable": false, "CanWrite": false }
]
}
}
}
}
模块配置在 Core 绑定 `DataAccessOptions` 之前加入,因此 Entity 会进入同一个 Registry。Data Model 名称是稳定逻辑契约,不要把 UI label 当名称。
6. 建立三 provider migration
using FormPlatform.DataAccess.Migrations;
namespace InventoryModule;
public sealed class InventoryMigrations : IDatabaseMigrationModule
{
public string Name => "Inventory";
public string DataSource => "Management";
public IReadOnlyList<ModuleDatabaseMigration> Migrations { get; } =
[
new("001_create_inventory_item",
[
"CREATE TABLE IF NOT EXISTS inventory_item (id varchar(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,name varchar(200) NOT NULL,quantity integer NOT NULL DEFAULT 0,updated_by varchar(36) NOT NULL,updated_at timestamptz(0) NOT NULL DEFAULT CURRENT_TIMESTAMP)",
"CREATE INDEX IF NOT EXISTS ix_inventory_item_name ON inventory_item(name)"
],
[
"CREATE TABLE IF NOT EXISTS inventory_item (id varchar(36) PRIMARY KEY DEFAULT (UUID()),name varchar(200) NOT NULL,quantity int NOT NULL DEFAULT 0,updated_by varchar(36) NOT NULL,updated_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),INDEX ix_inventory_item_name(name))"
],
[
"IF OBJECT_ID(N'inventory_item',N'U') IS NULL CREATE TABLE inventory_item (id varchar(36) CONSTRAINT df_inventory_id DEFAULT(CONVERT(varchar(36),NEWID())) PRIMARY KEY,name nvarchar(200) NOT NULL,quantity int NOT NULL CONSTRAINT df_inventory_quantity DEFAULT(0),updated_by varchar(36) NOT NULL,updated_at datetimeoffset NOT NULL CONSTRAINT df_inventory_updated DEFAULT(SYSUTCDATETIME()))",
"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name=N'ix_inventory_item_name' AND object_id=OBJECT_ID(N'inventory_item')) CREATE INDEX ix_inventory_item_name ON inventory_item(name)"
])
];
}
发布过的 `001` 不能修改。下一次变化新增 `002_...`。真实 MySQL migration 要考虑索引已存在时的条件执行,不能假定所有 DDL 都支持 `IF NOT EXISTS`。
7. 编写领域服务
服务使用 SDK 的 ORM,不直接依赖 Host implementation。典型方法接收当前 user id 和 `CancellationToken`,在服务器端应用 owner/权限 filter。写入时使用一项 unit of work 完成检查和 mutation,并 commit。
伪代码:
public sealed class InventoryService(IUnitOfWorkFactory workFactory, IDynamicRepository repository)
{
public async Task<object> CreateAsync(string userId, InventorySaveRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new PlatformApiException(400, "inventory.nameRequired", "inventory.nameRequired", "Name is required.");
await using var work = await workFactory.BeginAsync("Management", ct);
var row = new DynamicEntity("InventoryItem");
row["name"] = request.Name.Trim();
row["quantity"] = request.Quantity;
row["updatedBy"] = userId;
var saved = await repository.InsertAsync(work, row, ct);
await work.CommitAsync(ct);
return saved;
}
}
具体 Repository/Query 类型以 SDK XML 文档和 Todo/ResourceBooking 示例为准。不要把请求的 sort/filter 字符串直接变成 SQL。
8. Server Action/Trigger provider
需要让表单 Event/Trigger 调用服务器逻辑时实现 `IServerActionsProvider`,注册稳定 action 名。Action 返回结构化成功/失败结果;业务失败使用 `PlatformApiException` 或 SDK Trigger result,不抛裸字符串协议。
Action 必须重新读取身份,不相信客户端传入的 `userId`。参数中的 `{property}` substitution 在客户端/提交链解析后仍需服务器类型验证。
9. 实现模块入口
using FormPlatform.DataAccess.Migrations;
using FormPlatform.DataAccess.Triggers;
using FormPlatform.Extension.Abstractions;
using FormPlatform.Sdk;
namespace InventoryModule;
public sealed class InventoryModuleEntry : IFormPlatformSdkModule
{
public void ConfigureConfiguration(ConfigurationManager configuration, IHostEnvironment environment)
{
var directory = Path.GetDirectoryName(typeof(InventoryModuleEntry).Assembly.Location)!;
configuration.AddJsonFile(Path.Combine(directory, "appsettings.inventory.json"), optional: false, reloadOnChange: true);
}
public void ConfigureServices(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment)
{
services.Configure<InventoryOptions>(configuration.GetSection("Inventory"));
services.AddSingleton<InventoryService>();
services.AddSingleton<IServerActionsProvider, InventoryActions>();
services.AddSingleton<IDatabaseMigrationModule, InventoryMigrations>();
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var api = endpoints.MapGroup("/api/inventory/items").RequireAuthorization();
api.MapGet("/", InventoryEndpoints.QueryAsync);
api.MapPost("/", InventoryEndpoints.CreateAsync);
api.MapPut("/{id}", InventoryEndpoints.UpdateAsync);
api.MapDelete("/{id}", InventoryEndpoints.DeleteAsync);
}
}
四个 hook 的顺序是 Configuration → Services → Application → Endpoints。只实现需要的 hook。中间件会影响整个 Host,应非常谨慎。
10. API 规则
- API 使用 `/api/{module}/...` 命名空间,页面路由使用 `/{module}/...`。
- 每个 endpoint 显式应用授权 policy。
- 成功使用标准 200/201/204;创建响应包含资源 ID/Location。
- 可复用错误抛 `PlatformApiException`,以便 Host 生成含 traceId 的统一响应。
- `KeyNotFoundException` 可由 Host 转换为 404;不要返回自定义 `{message}`。
- 所有异步方法透传 RequestAborted/CancellationToken。
11. 系统表单还是客户端页面
CRUD/编辑流程优先建立 Data Model、系统表单和 DataGrid。复杂日历、拖拽看板或图形可做独立 Vue/JS 路由,但编辑细节仍可跳到普通表单。ResourceBooking 是混合模式示例。
系统表单 initializer 应幂等:不存在时建立;若代码拥有该表单则做语义同步;允许用户长期编辑的表单不要每次启动覆盖。系统文案保存 `@inventory.key`。
12. 客户端扩展
`client/index.js` 导出默认对象:
export default {
install(api) {
api.registerMessages('Inventory', {
en: { inventory: { title: 'Inventory' } },
'zh-CN': { inventory: { title: '库存' } }
})
api.registerRoute({
path: '/inventory',
component: () => import('/extension-assets/Inventory/inventory-page.js')
})
api.extendActionApi('refreshInventory', async () =>
window.FormPlatform.API.fetch('/api/inventory/items'))
}
}
实际 extension API 支持:`registerControl`、`registerComponentEvents`(由 control definition events 触发)、`registerRoute`、`extendActionApi`、`extendRuntimeApi`、`registerMessages`、`configureApp`。
自定义控件必须在 `designerMode` 下使用模拟数据且不请求 API。HTTP 调用复用 `window.FormPlatform.API.fetch`,它自动处理 cookie、语言和统一错误。控件状态使用 Vue `ref/reactive/computed`;只在加载 stylesheet、焦点或第三方库时进行受控 DOM 操作。
13. 让 Host 加载客户端模块
部署静态文件到 `Modules/Inventory/client` 后,在 Host 配置:
{
"FormPlatformExtensions": {
"ModulesDirectory": "Modules",
"ClientModules": [
"/extension-assets/Inventory/index.js"
]
}
}
客户端扩展在 Vue mount/router initial navigation 之前加载,因此直接访问模块路由可以正确匹配。
14. 构建与部署
dotnet build InventoryModule.csproj -c Debug `
-p:FormPlatformSdkDirectory="C:\path\FormPlatform\bin\Debug\net10.0"
部署目录至少包含 DLL、deps.json、module.json、模块配置和 client 资产:
FormPlatform/Modules/Inventory/
InventoryModule.dll
InventoryModule.deps.json
module.json
appsettings.inventory.json
client/index.js
client/inventory.css
使用脚本复制明确文件,避免把 SDK/Host DLL 复制进模块目录。重启 Host 才会重新加载服务器 DLL;纯客户端文件刷新即可,但浏览器缓存可能需要 hard reload。
Windows 部署启动器规范
每个面向 Windows 的模块都必须同时提供 `deploy.ps1` 和 `deploy.bat`。`deploy.ps1` 是唯一的、适用于自动化/CI 的部署实现;`deploy.bat` 是给命令提示符或资源管理器使用的轻量启动器。它固定脚本目录、原样转发全部参数,并将 PowerShell 的退出码返回给调用方:
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*
exit /b %ERRORLEVEL%
例如管理员可直接执行 `deploy.bat -Configuration Release -FormPlatformDirectory C:\inetpub\FormPlatform`,无需手工拼写 PowerShell 命令。部署逻辑只应写在 `deploy.ps1`,不要在批处理文件中复制一份。
卸载规范
每个模块还必须提供 `uninstall.ps1` 和 `uninstall.bat`,并采用同样的包装器规则。常规卸载应在 Host 停止后,只删除 `FormPlatform/Modules/<module-name>`。`-RemoveMenuEntries` 删除声明的叶子 token,随后只在分组为空时删除声明的分组;绝不会删除管理员后来加入的链接。`-RemoveSystemForms` 只删除 `ownedSystemForms`。这两个参数会先写入由下次启动时 Host 通过 `IFormStore` 执行的 provider-neutral 清理请求。默认必须保留数据库表和 migration 历史:静默删除业务数据会让以后重新安装不一致,或造成不可恢复的数据丢失。允许提供清除数据参数,但必须是明确的破坏性参数(例如 `-RemoveDatabase`)、要求显式提供 Provider 和连接字符串、删除模块 schema,并作为一次有意的清除操作删除相应 migration 历史。PostgreSQL/SQL Server 可以使用事务;MySQL 的 DDL 会隐式提交,因此必须在首个失败处停止并报告。绝不能从 secrets 文件自动推断用于破坏性操作的凭据。
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0uninstall.ps1" %*
exit /b %ERRORLEVEL%
15. 调试顺序
1. 构建 Host,确认 SDK/Abstractions DLL 是当前版本。
2. 用同一 SDK 路径构建模块。
3. 检查输出程序集 version 与 `module.json`。
4. 部署完整包并重启 Host。
5. 启动日志确认 migration 和 module load。
6. 直接调用 API,检查认证和统一错误。
7. 查看 `/api/platform/client-extensions` 是否列出 client URL。
8. 浏览器 Network 确认 JS/CSS 200、route 未被 SPA fallback 抢走。
9. Designer 验证控件模拟数据,Viewer 验证真实 API。
常见错误:
- `TypeLoadException`:模块仍引用 Host 或旧 SDK,重新构建部署。
- manifest name/version 错:修正 JSON并确保匹配程序集。
- Entity 未定义:模块配置未加载、DataAccess 路径错误或名称不一致。
- migration checksum 不符:不要编辑已执行 migration,恢复原文并新增 ID。
- API 401/403:检查 cookie scheme、policy 和 owner/deployment 条件。
- 路由回主页:客户端扩展没有在 router 初始化前注册或资产 URL 错。
16. 版本升级
修复不改公共契约可升 patch;兼容新增升 minor;破坏 SDK/模块契约升 major。每次发布同步 csproj version、module.json、SDK/Host 范围、变更日志和所有模块 CI。模块需要新 SDK 时提高 minimum;不要无依据扩大 maximum。
17. 完成检查清单
- 模块只引用 SDK/Abstractions,`Private=false`。
- manifest、程序集和兼容范围一致。
- migration 三 provider 完整且不可变。
- ORM Entity 与 schema、类型、generated/write flags 一致。
- API 授权、owner filter、CancellationToken 和统一错误齐全。
- Action/Trigger 不信任客户端身份。
- 控件 Designer 无真实 API,Viewer/Preview value 一致。
- i18n 同时提供英文和中文 fallback。
- 部署包不夹带 Host/SDK 私有副本。
- PostgreSQL integration 与关键 Playwright 流程已增加。
完整 Todo 源码级教程 可单独阅读;混合式参考项目位于同级 `ResourceBooking` 目录。