模块开发
使用 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": "Guid", "IsNullable": false, "IsKey": true, "IsGenerated": false },
{ "PropertyName": "name", "ColumnName": "name", "ValueKind": "String", "IsNullable": false, "MaxLength": 200 },
{ "PropertyName": "quantity", "ColumnName": "quantity", "ValueKind": "Int32", "IsNullable": false },
{ "PropertyName": "updatedBy", "ColumnName": "updated_by", "ValueKind": "Guid", "IsNullable": false },
{ "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 uuid PRIMARY KEY,name varchar(200) NOT NULL,quantity integer NOT NULL DEFAULT 0,updated_by uuid 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 binary(16) NOT NULL PRIMARY KEY,name varchar(200) NOT NULL,quantity int NOT NULL DEFAULT 0,updated_by binary(16) 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 uniqueidentifier NOT NULL PRIMARY KEY,name nvarchar(200) NOT NULL,quantity int NOT NULL CONSTRAINT df_inventory_quantity DEFAULT(0),updated_by uniqueidentifier 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)"
])
];
}
ID 和内部引用由应用产生 UUID v7,不设置数据库 UUID 默认值。完整规范见原生 UUID v7 标识符规范。发布过的 `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`、`registerRespondentTool`、`extendActionApi`、`extendRuntimeApi`、`registerMessages`、`configureApp`。可信模块页面可复用 `components.FormReader`;离线包在联网时调用 `preloadFormComponents(schema)`,即可解析需要缓存的懒加载控件 chunk,而不依赖 Vite 文件名。可信宿主还可传入只存在于运行时的 `fileHandler`(`stage`、`preview`、`remove`)和 `optionHandler.query`;未传处理器时控件保持普通在线 API,且处理器绝不能保存到表单 JSON。选项处理器必须返回标准 `{ items, total, offset, limit, hasMore, nextCursor }` 页面结构,并执行快照版本与过期检查。必须在断网时启动的路由使用 `meta.public`;它只跳过浏览器路由守卫,包/数据端点仍须由服务器要求相应认证策略。
自定义控件必须在 `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` 目录。
---
源代码目录与程序集边界
目的
仓库现在按程序集所有权组织。每个源文件只被一个项目编译;Host 通过 ProjectReference 使用公共代码,不再以链接源码的方式重复编译。程序集名称和公开命名空间保持不变,所以已部署模块仍引用 `FormPlatform.Sdk.dll` 与 `FormPlatform.Extension.Abstractions.dll`。
目录
| 路径 | 生成物 | 职责 |
|---|---|---|
| `src/FormPlatform.Host/` | `FormPlatform.dll` | ASP.NET Core Host、官方领域服务、端点、配置、Vue 应用和开发期模块包 |
| `src/FormPlatform.Sdk/` | `FormPlatform.Sdk.dll` | 公共 ORM、动态实体/查询/事务 API、Trigger/Action 契约、迁移、表单 Store 契约及共享媒体/错误契约 |
| `src/FormPlatform.Extension.Abstractions/` | `FormPlatform.Extension.Abstractions.dll` | 稳定的模块 manifest 与生命周期契约 |
| `src/FormPlatform.Licensing/` | `FormPlatform.Licensing.dll` | 许可证文档的加密/签名基础组件 |
| `samples/FormPlatform.Extension.Sample/` | 示例模块 | 使用 SDK 和 Abstractions 的最小参考模块 |
| `tests/` | 测试程序集 | PostgreSQL 与 SQL Server 集成测试 |
| `src/FormPlatform.Host/Modules/` | 运行时包 | 本地开发 Host 自动发现的可信模块 |
仓库根目录的 `FormPlatform.slnx` 是 IDE 与 CI 的统一解决方案入口。
常用命令
# 从仓库根目录运行 Host
dotnet run --project src/FormPlatform.Host/FormPlatform.csproj
# 构建 Host 与所有官方项目
dotnet build FormPlatform.slnx
# 手工构建浏览器客户端
cd src/FormPlatform.Host/ClientApp
npm ci
npm run build
standalone 与 DNN 打包脚本仍位于 `deploy/`,并会自动使用新的 Host 项目。第三方模块部署脚本应传入 Host 目录,例如 `-FormPlatformDirectory C:\src\FormPlatform\src\FormPlatform.Host`。
规则
1. 第三方需要编译引用的代码放入 `src/FormPlatform.Sdk` 或 `src/FormPlatform.Extension.Abstractions`,不要放入 Host。
2. 模块只能引用 SDK 与 Abstractions,不能引用 `FormPlatform.dll`。
3. 保持 `Program.cs` 为组合根;Host 专属端点放在 `src/FormPlatform.Host/Hosting/Endpoints`。
4. 不要通过 `Compile Include=... Link=...` 将 SDK 源码重新链接到 Host 编译。
5. `src/FormPlatform.Host/Modules` 是运行时包目录,不是编写模块源码的目录。
---
FormPlatform 私有化部署与可信二次开发
1. 目标与适用范围
私有化客户得到的是编译后的 FormPlatform 服务器程序和 Vue 生产文件,
而不是 FormPlatform 的 `.cs`、`.vue` 等源码。客户作为可信的二次开发者,
可以让自己的代码在 FormPlatform 应用进程中运行,并且能够:
- 修改部署目录中的 `appsettings.json`,增加自己的配置;
- 使用公开的 ORM、Data Model、表单存储和元数据 API;
- 注册 Server Action、Trigger、DI 服务和 Hosted Service;
- 编写任意 Minimal API,为控件、外部系统或业务流程提供数据;
- 编写自己的 Vue 控件,并加入 Form Designer 控件区;
- 扩展客户端 Action API、Form Runtime API、i18n 和 Vue Router;
- 对 Vue app、Router 和 Pinia执行高级初始化。
这个方案解决的是“不交付源码”,不是运行时安全隔离。服务器模块与
FormPlatform 在同一个进程中运行,拥有 FormPlatform 服务账号所拥有的权限,
因此只能安装可信、经过审查的模块。
2. 私有化交付内容
平台供应方建议只交付:
1. `FormPlatform` 的 Release/Publish 输出;
2. `wwwroot` 中编译后的 Vue 生产文件;
3. 供服务器二次开发引用的 `FormPlatform.Sdk.dll`;
4. `FormPlatform.Extension.Abstractions.dll` 或对应的 NuGet 包;
5. 公开 API 的 XML 文档;
6. 本文档和示例扩展。
不应交付:
- FormPlatform 的 `.cs`、`.vue` 等源文件;
- source map;
- Git 仓库和提交历史;
- `obj`、构建缓存等中间文件;
- 不对外发布的 PDB;
- 签名私钥、数据库密码和其它秘密。
需要明确:.NET IL 和发送到浏览器的 JavaScript 都可以被反编译或重新格式化。
混淆、程序集签名和许可证机制只能提高逆向成本,不能保证绝对不可读。任何密码、
密钥和真正需要保密的算法都不应放在浏览器端。
3. 服务器扩展模块
服务器扩展实现 SDK 中的 `IFormPlatformSdkModule`。它有四个生命周期入口:
1. `ConfigureConfiguration`:加入客户自己的配置源;
2. `ConfigureServices`:注册 DI、ORM 服务、Action、Trigger 和后台任务;
3. `ConfigureApplication`:注册可信 ASP.NET Core middleware;
4. `MapEndpoints`:注册 Minimal API。
执行顺序为:
标准 appsettings
→ 客户模块配置源
→ 重新应用 Development User Secrets / 应用生产秘密文件
→ FormPlatform 内置服务
→ 客户服务
→ Build
→ FormPlatform 异常处理、身份认证和授权 middleware
→ 客户 middleware
→ FormPlatform API
→ 客户 API
→ SPA fallback
3.1 客户项目引用
客户项目不需要 FormPlatform 源码。它只引用供应方提供的编译文件:
<ItemGroup>
<Reference Include="FormPlatform.Sdk"
HintPath="..\sdk\FormPlatform.Sdk.dll"
Private="false" />
<Reference Include="FormPlatform.Extension.Abstractions"
HintPath="..\sdk\FormPlatform.Extension.Abstractions.dll"
Private="false" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
`Private="false"` 表示构建客户模块时,不要把平台 DLL 再复制一份到客户模块包中。
部署环境已经拥有与该版本相匹配的平台 DLL。
源码仓库中的 `FormPlatform.Extension.Sample` 使用 ProjectReference,只是为了方便
平台开发者同时维护示例;真正交给客户的项目应采用上面的文件引用或私有 NuGet。
3.2 完整模块骨架
using FormPlatform.DataAccess.Triggers;
using FormPlatform.Extension.Abstractions;
using FormPlatform.Sdk;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public sealed class CustomerModule : IFormPlatformSdkModule
{
public void ConfigureConfiguration(
ConfigurationManager configuration,
IHostEnvironment environment)
{
configuration.AddJsonFile(
Path.Combine(
environment.ContentRootPath,
"Modules",
"Customer",
"appsettings.customer.json"),
optional: true,
reloadOnChange: true);
}
public void ConfigureServices(
IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment)
{
services.Configure<CustomerOptions>(
configuration.GetSection("Customer"));
services.AddSingleton<CustomerService>();
services.AddSingleton<IServerActionsProvider, CustomerActions>();
services.AddHostedService<CustomerBackgroundService>();
}
public void ConfigureApplication(WebApplication application)
{
// 可选:加入客户 middleware。
// FormPlatform 的异常处理、身份认证和授权 middleware 已经位于它前面。
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var api = endpoints.MapGroup("/api/customer")
.RequireAuthorization();
api.MapGet("/items", async (
CustomerService service,
CancellationToken ct) =>
Results.Ok(await service.LoadAsync(ct)));
}
}
客户注册发生在平台内置服务注册之后,因此可以使用标准 ASP.NET Core DI 规则:
- 同一个接口注册多个实现时,可以通过 `IEnumerable<T>` 获取全部实现;
- 对普通单一服务,后注册的实现通常会成为 `GetRequiredService<T>()` 的结果;
- 如果客户有意替换平台服务,需要严格测试版本兼容性。
4. 模块目录与加载方式
每个客户模块是 `Modules` 下的独立目录:
Modules/
Customer/
module.json
Customer.Module.dll
Customer.Module.deps.json
appsettings.customer.json
Other.Private.Dependency.dll
client/
index.js
customer.css
customer-icon.svg
`module.json` 示例:
{
"entryAssembly": "Customer.Module.dll",
"type": "Customer.Module.CustomerModule"
}
- `entryAssembly` 是模块入口程序集,只允许指向当前模块目录中的文件;
- `type` 是实现 `IFormPlatformSdkModule` 的完整类型名;
- 一个 descriptor 必须准确解析出一个模块入口;
- 模块可以携带自己的私有依赖 DLL。
部署应用的 `appsettings.json` 配置:
{
"FormPlatformExtensions": {
"ModulesDirectory": "Modules",
"ClientModules": [
"/extension-assets/Customer/index.js"
]
},
"Customer": {
"ApiBaseUrl": "https://customer.example/api",
"PageSize": 50
}
}
安装或更换服务器 DLL 后必须重启 .NET 应用。客户 JSON 可以设置
`reloadOnChange: true`,但配置变化能否即时反映,还取决于客户代码使用的是
`IOptionsMonitor<T>` 还是启动时读取一次的 Options。
模块按 `module.json` 完整路径稳定排序。如果一个客户模块需要有意覆盖另一个模块
的服务,可使用带序号的目录名,例如:
Modules/010-Customer/
Modules/020-Customer-Overrides/
5. 在模块中使用 ORM
FormPlatform ORM 是普通的公开 DI 服务。客户可以注入:
- `IEntityModelResolver`:按名称解析 Data Model;
- `IDynamicRepository`:动态实体查询和增删改;
- `IUnitOfWorkFactory`:建立事务;
- `IJoinQueryService`:关联查询;
- `IAggregateQueryService`:聚合查询;
- `IFormDataMapper`:表单数据和实体之间映射;
- `IFormStore`:读取或保存表单结构和 metadata。
查询示例:
var model = await models.ResolveAsync("Customer", ct);
await using var work = await workFactory.BeginAsync("Business", ct);
var rows = await repository.QueryAsync(
model,
new QuerySpec(
Filter: new FilterCondition(
"IsActive",
FilterOperator.Equal,
true),
OrderBy: [new SortTerm("Name")],
Offset: 0,
Limit: 50,
Select: ["Id", "Name", "Email"]),
work,
ct);
await work.CommitAsync(ct);
建议始终:
- 使用 Data Model 的逻辑属性名,不拼接数据库列名;
- 使用 ORM Filter 和参数化查询,不拼接 SQL 值;
- 明确设置 `Select`,只读取真正需要的字段;
- 用 `IUnitOfWorkFactory` 管理事务;
- 为客户 API 设置合理的分页上限;
- 不把数据库异常原文直接返回匿名客户端。
客户可以在部署的 `appsettings.json` 中通过 `DataAccess` 配置 Data Source 和
Entity,也可以继续使用系统的 Data Model 管理功能。
模块配置加载后,Development User Secrets 会重新应用,Production 的外部秘密
JSON也会最后应用,因此模块普通配置不会意外覆盖数据库密码等秘密。
6. 扩展 Server Action 和 Trigger
实现现有的 `IServerActionsProvider`:
public sealed class CustomerActions : IServerActionsProvider
{
public IReadOnlyCollection<string> FormActions =>
["SubmitCustomerRequest"];
public IReadOnlyCollection<string> TriggerActions =>
["SetCustomerAuditFields"];
public ValueTask<FormActionResult> ExecuteFormActionAsync(
string action,
FormActionContext context,
CancellationToken cancellationToken = default)
{
// context.Form、context.Data、context.UserId、context.Options
// 在这里调用客户服务并返回控件需要的数据和通知。
return ValueTask.FromResult(new FormActionResult(context.Data));
}
public ValueTask<TriggerResult> ExecuteTriggerAsync(
string action,
EntityModel model,
List<dynamic> entities,
TriggerExecutionContext context,
JsonElement? options,
CancellationToken cancellationToken = default)
{
// 可在 BeforeInsert、BeforeUpdate 等 Trigger 中修改实体、
// 返回 validation 或终止处理链。
return ValueTask.FromResult(TriggerResult.Success());
}
}
在模块中注册:
services.AddSingleton<IServerActionsProvider, CustomerActions>();
现有 `ServerActionRegistry` 会自动收集客户实现,不需要远程协议、API Key 或
Action 白名单。Action 名称必须在所有 Provider 中保持唯一,否则应用启动时会
拒绝建立含糊的 Action 映射。
7. 编写客户 API
客户可在 `MapEndpoints` 中直接使用 ASP.NET Core Minimal API:
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var api = endpoints.MapGroup("/api/customer/orders")
.RequireAuthorization("Administration");
api.MapGet("/", QueryOrdersAsync);
api.MapPost("/", SaveOrderAsync);
api.MapDelete("/{id}", DeleteOrderAsync);
}
这些 API 可以:
- 为 AsyncSelect、Tree、DataGrid 或自定义控件提供数据;
- 接收表单或控件提交的数据;
- 调用 FormPlatform ORM;
- 调用客户自己的数据库、ERP、CRM 或其它服务;
- 返回标准 JSON、文件或流。
客户 API不会自动获得授权规则。应根据用途显式调用:
.AllowAnonymous()
或:
.RequireAuthorization()
.RequireAuthorization("Administration")
不要因为 API 位于 `/api/customer` 下就假设它自然受到保护。
8. 客户端扩展入口
FormPlatform 在挂载 Vue app 之前读取:
GET /api/platform/client-extensions
然后依次加载 `FormPlatformExtensions:ClientModules` 中的 JavaScript。
标准 ES module 的默认导出应提供 `install(api)`:
export default {
install(api) {
// 在这里注册控件和客户端扩展。
}
}
可用 API:
- `api.Vue`:FormPlatform 正在使用的同一个 Vue 运行时;
- `api.registerControl(definition)`:注册控件和 Designer 信息;
- `api.extendActionApi(name, implementation)`:扩展客户端 Action API;
- `api.extendRuntimeApi(callback)`:扩展每个 Form Runtime;
- `api.registerMessages(owner, messages)`:注册 i18n 消息;
- `api.registerRoute(route)`:增加 Vue Router 路由;
- `api.configureApp(callback)`:在 mount 前配置 Vue app、Router 和 Pinia。
扩展文件通过下面的 URL读取:
/extension-assets/{模块目录名}/{client目录下的文件路径}
例如:
/extension-assets/Customer/index.js
/extension-assets/Customer/customer.css
服务器只会从 `Modules/Customer/client` 读取允许的 Web 文件类型,并阻止路径跳出
模块目录。
`ClientModules` 只列 JavaScript 入口,不会自动把单独的 CSS 文件加入页面。客户
构建可以把样式注入到生成的 JavaScript 中,或者由入口模块明确加载
`/extension-assets/Customer/customer.css`。不要假设把 CSS 复制到 `client` 目录后
它会自动生效。
9. 编写自定义控件
最小控件示例:
export default {
install(api) {
const { h } = api.Vue
const CustomerPicker = {
props: {
value: { type: String, default: '' },
designerMode: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
validationClass: { type: String, default: '' }
},
emits: ['update:value'],
setup(props, { emit }) {
return () => h(
'button',
{
type: 'button',
disabled: props.readOnly,
class: ['customer-picker', props.validationClass],
onClick: () => emit('update:value', 'customer-1')
},
props.value || 'Choose customer')
}
}
api.registerControl({
type: 'customerPicker',
component: CustomerPicker,
group: 'controls',
label: 'Customer Picker',
icon: '◆',
description: 'Selects a customer',
dataComponent: true,
create: () => ({
props: {
value: '',
placeholder: 'Choose customer'
}
}),
events: [
{ name: 'onChange', label: 'Value changed' }
]
})
}
}
主要设置:
| 属性 | 作用 |
|---|---|
| `type` | 保存到表单 JSON 中的唯一控件类型。不能覆盖平台现有类型。 |
| `component` | Vue component;也可以配合 `loader: true` 传入异步 loader。 |
| `group` | Designer 控件区分组,如 `controls`、`containers`、`collections`。 |
| `label`、`icon`、`description` | 控件区展示信息。 |
| `dataComponent` | 是否参与表单数据、Mapping、验证和提交。 |
| `create` | 拖入设计区时建立默认组件 JSON 的函数。 |
| `propertyEditor` | 可选的 General 属性编辑 Vue component。 |
| `events` | 该控件在 Events tab 中支持的事件。 |
注册后,该控件会自动用于:
- Form Viewer;
- Preview;
- Designer 设计区;
- Designer 控件区;
- 表单数据 runtime和验证系统(`dataComponent: true` 时)。
9.1 自定义 General 属性编辑器
`propertyEditor` 接收当前 `component`,通过 `update` 事件返回修改:
const CustomerPickerProperties = {
props: {
component: { type: Object, required: true }
},
emits: ['update'],
setup(props, { emit }) {
return () => h('input', {
value: props.component.props?.placeholder || '',
onInput: event => emit('update', {
props: {
...props.component.props,
placeholder: event.target.value
}
})
})
}
}
然后在 `registerControl` 中设置:
propertyEditor: CustomerPickerProperties
客户只需要实现 General tab;平台现有的 Style、Events、Tooltip 和 Other tab 会
继续出现。
10. 扩展 Action API
api.extendActionApi(
'openCustomer',
({ context }, customerId) => {
return context.api.navigate(`/customers/${customerId}`)
})
注册后可从表单 Action Code 使用:
export default {
async onOpenCustomer({ api, data }) {
await api.openCustomer(data.customerId)
}
}
扩展 Action API 不能覆盖现有 API 名称。这可以避免客户模块意外改变
`notify`、`fetch`、`navigate`、`setValue` 等平台行为。
11. 扩展 Form Runtime API
api.extendRuntimeApi(runtime => {
runtime.customerValue = propertyName => {
const component = runtime.components().find(
item => item.other?.propertyName === propertyName)
return component ? runtime.data[component.id] : undefined
}
})
每次建立新的 Form Runtime 时都会执行这个 callback。活动表单准备完成后,
Action Code 可通过下面的方式调用:
const value = context.api.runtime?.customerValue('customerId')
不要直接替换 runtime 的核心方法。更稳妥的方式是增加有明确命名空间的方法,
例如:
runtime.customer = {
getValue() {},
refresh() {}
}
12. 扩展 i18n、Router 和 Vue app
注册多语言消息:
api.registerMessages('customer-module', {
en: {
'customer.title': 'Customers'
},
'zh-CN': {
'customer.title': '客户'
}
})
增加页面路由:
api.registerRoute({
path: '/customer/reports',
name: 'customer-reports',
component: CustomerReports,
meta: {
requiresAuth: true,
roles: ['Administrator']
}
})
高级 Vue 初始化:
api.configureApp(({ app, router, pinia, api }) => {
app.provide('customerApiBase', '/api/customer')
})
这些 callback 在 Vue mount 前执行。客户路由应继续使用系统已经采用的
`requiresAuth`、`roles` 等路由 metadata,服务器 API仍必须独立执行授权检查。
13. 使用 `.vue` SFC 开发控件
客户可以正常使用 `.vue` 文件开发,然后只交付构建后的 JavaScript 和 CSS。
为了避免网页中出现第二套 Vue runtime,客户构建应把 `vue` externalize 到
FormPlatform 暴露的 Vue 实例。
Vite/Rollup 示例:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: 'src/index.js',
name: 'CustomerControls',
formats: ['iife']
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'window.FormPlatform.Extensions.Vue'
}
}
}
}
})
IIFE 入口直接调用:
window.FormPlatform.Extensions.registerControl({
type: 'customerPicker',
component: CustomerPicker,
// ...
})
这种自注册 bundle 可以没有 ES module `default export`。FormPlatform loader 支持:
- `export default { install(api) {} }`;
- 加载时通过 `window.FormPlatform.Extensions` 自行注册的 IIFE。
客户保留 `.vue` 源码和 Vite 工程,只把生成的 JS/CSS 复制到
`Modules/Customer/client`。
14. 安全与运维要求
服务器模块是完全可信代码,因此必须遵守:
1. 只允许部署管理员安装和更新 DLL;
2. 不提供通过网页上传服务器 DLL 的功能;
3. `Modules` 目录不应给予 FormPlatform 服务账号写权限;
4. 客户模块代码必须经过审核;
5. FormPlatform 服务账号和数据库账号采用最小权限;
6. 客户 API必须设置明确的授权;
7. 不在日志中写密码、Token、问卷答案等敏感数据;
8. 不在客户 JavaScript 中放秘密;
9. 更新平台 Runtime 前,应测试全部已安装客户模块;
10. 更新 DLL 后通过受控方式重启应用。
模块自己的秘密仍应使用:
- Development User Secrets;
- 环境变量;
- systemd credentials;
- IIS 外部秘密 JSON;
- 项目现有的生产秘密文件加载机制。
不要把真实秘密放入:
- 普通 `appsettings.json`;
- `module.json`;
- `client/*.js`;
- Git 仓库。
15. 版本兼容策略
建议平台供应方发布明确版本的 SDK/runtime 组合:
FormPlatform Runtime 1.2.0
FormPlatform.Extension.Abstractions 1.2.0
Client Extension API 1.2.0
基本规则:
- 增加兼容 API:提升 minor version;
- 修复内部实现且不改变契约:提升 patch version;
- 删除、重命名或改变公开 API:提升 major version;
- 每个发布版本附带兼容性说明;
- 客户模块构建时固定 SDK 版本;
- 生产升级前运行客户模块的 API/contract tests。
16. 示例位置
完整示例位于:
samples/FormPlatform.Extension.Sample/
其中包括:
- `SampleModule.cs`:配置、DI、ORM、API、Server Action 和 Trigger;
- `module.json`:服务器模块入口;
- `appsettings.sample.json`:客户模块配置;
- `client/sample-control.js`:控件、属性编辑器、Action API 和 Runtime API。
测试示例时,把示例构建输出中的模块文件放到:
Modules/Sample/
并在部署应用的 `appsettings.json` 中加入:
{
"FormPlatformExtensions": {
"ModulesDirectory": "Modules",
"ClientModules": [
"/extension-assets/Sample/sample-control.js"
]
}
}
服务器 DLL 安装完成后重启 .NET 应用,再刷新浏览器即可。客户控件是启动时动态
加载的,不需要重新构建 FormPlatform Vue 前端。只修改客户 JavaScript/CSS 时,
替换对应文件并刷新浏览器即可;修改服务器 DLL 时才需要重启服务器。单纯编辑
数据库中的普通表单结构也不需要重新编译客户模块。