Day 15:API 契约分层与类型注册表

昨天谈的扩展点,全部挂在某一个具体的 Business Object 上。往前推一步的问题是:一个调用进到后端,框架怎么知道要建哪一个类型、那个类型收到的参数又长什么样子。

这两件事在多数项目里都是硬编码的。类型分派写成一个 switch,参数类型直接拿传输用的那一份给业务逻辑用。上千张表单的系统付不起这两笔:前者每加一支程序就要改代码重新部署,后者让 client 引用得到业务逻辑层的内部属性。

本篇说明:

  1. 契约接口、API 类型与 BO 类型三层各自的职责,以及它们的依赖方向
  2. ProgId 作为类型注册表的键
  3. 注册表为何是单层摊平的清单,菜单为何另立一份定义
  4. BO 与 Repository 两轴解析不到类型时的处置,以及为什么不留退路

一、同一份属性清单,两个互不相识的实现

同一个 API 方法的参数,送出去的那一份与 BO 用的那一份,需求不一样。送出去的那一份要能被序列化、要被 client 引用;BO 那一份可能还带着 client 看不到的内部属性,例如一个只在 BO 之间传递的标志。做成同一个类型,client 就得引用业务逻辑程序集,而业务逻辑也得跟着传输格式一起演进。

框架的切法是把属性清单抽成契约接口,两边各自实现:

层 程序集 形状 谁用
契约接口 IXxxRequest / IXxxResponse Bee.Api.Contracts 只有只读属性,不含任何序列化标记 两边各自实现它
API 类型 XxxRequest / XxxResponse Bee.Api.Core 继承 ApiRequest / ApiResponse,负责序列化送出 client 与传输层
BO 类型 XxxArgs / XxxResult Bee.Business 不带序列化机制,可加契约以外的内部属性 BO 方法签名

以表单保存为例,ISaveRequest、SaveRequest 与 SaveArgs 三个声明摊开来是这样:

csharp 复制代码
// Bee.Api.Contracts.Form:属性契约,唯一真实来源
public interface ISaveRequest
{
    DataSet? DataSet { get; }
}

// Bee.Api.Core.Messages.Form:送出去的那一份
public class SaveRequest : ApiRequest, ISaveRequest
{
    public DataSet? DataSet { get; set; }
}

// Bee.Business.Form:BO 方法签名用的那一份
public class SaveArgs : BusinessArgs, ISaveRequest
{
    public DataSet? DataSet { get; set; }
}

三层之间的依赖方向是这样:

text 复制代码
Bee.Api.Contracts
    ├── Bee.Api.Core ── Bee.Api.Client
    └── Bee.Business

Bee.Api.Core 与 Bee.Business 彼此不依赖,各自只认得最底下那一层。Day 2 谈过依赖方向本身就是一条检查线,这里是同一条线的另一个位置:哪一天 BO 的参数类型非得引用 API 程序集才编得过,分层就已经破了,而加一个项目引用是完全合法的动作,编译器不会有任何意见。

三层之间的黏合刻意不是注册。进来的方向靠反射:框架找到 BO 上要调用的那个方法,把收到的内容转成它声明的参数类型。回去的方向靠命名惯例:BO 返回 LoginResult,框架据此找出 LoginResponse 把属性抄过去,解析结果以类型为键缓存,每个类型只解析一次。

text 复制代码
{Action}Result  →  {Action}Response

代价写在惯例本身:没有任何东西在检查它。名称对不上这个形状的返回类型不会被转换,框架把 BO 那个对象原样交给序列化,问题要到那个方法第一次被调用时才浮出来。换到的是添加一个 API 方法时不必到任何一处登记。

也不是每个方法都要三个类型。契约接口存在的理由是让两边共用同一份属性清单,所以纯粹在 BO 之间流转、不对外开放的方法只要一个 XxxArgs,没有契约接口也没有 API 类型。最多就是这三个。

第四种类型,它不离开服务端

昨天那三个保存步骤共用的 SaveContext,以及删除那一侧对应的 DeleteContext,都不在上面那三层里,命名也刻意不同。判别法是一句话:跨层传输用 Args / Result,单一次调用的各段之间共享状态用 Context。

XxxArgs 装的是调用端送进来的内容,XxxResult 的值会被抄进送回去的那一份,两者本身不碰序列化,只承载 client 看得到的数据。XxxContext 则不会被送出去,它装的是已经解析好的 Repository 与 FormSchema、删除前的整笔快照、前一段产生给后一段用的输出,其中不乏不该让 client 看到的服务端对象。

分开命名换到两件事。一是中间状态若放在 Args 上,结构上就是可以被调用端填的,而下一段程序分不出这个值是服务端算出来的还是调用端送进来的。二是 context 是一个对象而不是一组签名,所以「多给各段一个共享的值」是添加一个属性,不是改签名,而改签名会让所有既有的重写一次编译失败。扩展点的签名要能长期不动,共享的东西就不能长在签名上。

二、ProgId 是注册表的键

一个请求带着 ProgId 进来,框架要拿它换到一个 BO 的类型。注册表登记的是那个对象,至于它背后有没有一张表单,是另一件事。这个对应写在代码里也行,写在定义文件里也行,差别在于加一支程序要不要重新部署。

框架的参照来源是 COM+ 的注册模型:以注册表项注册 ProgID 与它对应的组件类型,一个 ProgID 代表一个独立的功能或程序。这个模型有一个常被忽略的特征:注册表只管「ProgID 换到哪一个类型」,完全不管这个程序在菜单的哪个位置。

框架延续了这个模型,一个 ProgId 绑定一个 BO,于是有三个直接推论:

  1. 框架自己的 SystemBusinessObject 与 LogBusinessObject 也在里面,它们不对应任何一张表单
  2. Repository 比照办理,同一个 ProgId 底下同时绑定它的 BO 与它的 Repository
  3. 定制化以 ProgId 为单位重写类型,套装与定制各自独立(机制留给 Day 19)

Day 13 提过所有 BO 共用同一个构造函数签名,工厂不必先知道要建的是哪一个家族。原因在这里:工厂手上只有一个 ProgId 和一份注册表,家族是解析出来的结果,不是它的输入。

保留字 ProgId 也在里面

System、AuditLog 与 AuditRule 也是 ProgId,跟其他程序一样注册在注册表里、走同一条解析路径,传输层没有为它们留任何分支。连 SystemBusinessObject 都走同一条定制路径,换的单位一样是 ProgId。

代价是启动时的悬崖。注册表是唯一来源,缺项就解析不到,而这几笔是框架自己的东西,没有道理要求每一个 host 手写进自己的 ProgramSettings.xml。解法沿用 COM+ 的另外一半:组件安装的时候自己写注册表。框架在启动时逐笔检查保留字,缺哪一笔补哪一笔,然后验证每一笔都解析得到可用的类型。

逐笔而不是逐文件,是因为用「文件存不存在」当判断,会正好跳过那些已经有 ProgramSettings.xml、只是里面没有 System 的部署。写回文件则只决定下一次启动要不要再做一次,没登记的保留字本来就解析得到框架自己的类型,所以只读部署写不进去也照样起得来。

三、注册表摊平,菜单分家

注册表的前提是 ProgId 是唯一的键。这份清单因此是单层摊平的,唯一性由键机制本身保证:集合以 ProgId 为键(不分大小写),重复的项目在加载的当下就被挡下来,查找是一次 key lookup。分类的概念只留在菜单定义里,两边不会不同步。

菜单另立一份定义是另一个决定,理由与 Day 4 谈 DatabaseSettings 与 DbCategorySettings 为什么分成两份是同一条:

类型注册表 菜单定义
读者 只有 server 需要 只有 client 需要
内容 程序集限定类型名 排序、标题、可见性
送不送出去 远程取定义时直接挡下 本来就要送到 client
结构 单层摊平,ProgId 为键 多层树,节点另有全树唯一的 Id

类型名不送给 client 这件事不是额外加的防护,是分家自然的结果:client 要的是菜单那一份,用不到注册表,于是它可以比照 SystemSettings 与 DatabaseSettings 一起被挡在远程取定义之外。

分家还挡掉一个具体问题。client 建菜单时遍历的是定义给它的每一个项目,没有「这支程序不该出现在菜单上」这个概念,菜单若直接长在注册表上,System 与 AuditLog 就会变成两个菜单项,要回避得替注册表也加一个可见性标志或保留一个分类。同样是保留字的 AuditRule 却该上菜单,它是一张给人维护的表单。同一份清单里有的要出现、有的不能出现,那就不是同一份清单。

四、一个 ProgId 绑两个类型,两个都不留退路

注册表里写的是程序集限定类型名,也就是一段字符串。打错字、程序集没有部署到 host、类型改名之后忘了同步,都会让解析拿不到类型。框架的处置一律是直接抛,差别只在什么时候抛:

绑定 类型载不到或基底不符 什么时候发现
保留字 ProgId 的 BusinessObject 直接抛,而且启动时就先验一次 部署当下,host 起不来
一般 ProgId 的 BusinessObject 直接抛 这支程序第一次被调用
任何 ProgId 的 Repository 直接抛 这支程序第一次读写数据

不留退路,考量的不是严不严格,是故障的面貌。退回一个框架默认的类型,换到的只有「看起来还在跑」:FormBusinessObject 的构造函数接受任何 ProgId,它会构造成功,于是那支程序安静地表现得很通用,而 Order 本来该有的那些自定义方法一支都不在。调用端收到的是一句「找不到这个方法」,真正的成因却在注册表那一行字符串上。

Repository 那一轴更不能退:绑定打错,这支程序的读写就改跑作者刻意替换掉的通用 SQL,故障要等到数据已经错了才浮出来。保留字那一道则把发现的时机往前挪到部署当下,System 绑错就是登录这条路整条不通,与其让 host 带着一个错的绑定开始接请求,不如让它起不来。

Repository 这一轴为什么不能完全 ProgId 化

「一个 ProgId 一个 BO 一个 Repository」在表单轨完全成立,在框架轨不成立。取得 Repository 的工厂因此是两个方法而不是一个,IRepositoryFactory 上的两行是这样:

csharp 复制代码
T CreateFormRepository<T>(Guid accessToken, string progId) where T : class, IDataFormRepository;
T Create<T>(Guid accessToken = default) where T : class;

两个破口撑开了这道分岔。一是 System 这一个 ProgId 对应到多个 Repository,SystemBusinessObject 在同一个 ProgId 底下要用到 session、用户、API 密钥好几张系统表,给了 ProgId 也决定不了该回哪一个。二是有一批消费者不是 BO,也拿不到 BO 那套上下文:清理过期 session 的后台服务没有请求、没有 session、没有令牌;解析员工上下文的那一段跑在 session 还说不出自己在哪一家公司的时候,连要读哪一个数据库都得由参数带进来,而且单一方法里就要用两个 Repository。替它们发明一个 ProgId 不会让它们比较像 BO。

这一点完全不影响 BO 轴:一个 ProgId 对一个 BO 类型依然成立。两个方法都是泛型,所以添加一张系统表不需要动这个接口,而被它取代的前身每加一张就长一个方法。

回到 Northwind

案例的注册表是十一笔,ProgramSettings.xml 的项目全在这里:

xml 复制代码
<ProgramSettings>
  <Items>
    <ProgramItem ProgId="System" DisplayName="System" />
    <ProgramItem ProgId="Category" DisplayName="Categories" />
    <ProgramItem ProgId="Supplier" DisplayName="Suppliers" />
    <ProgramItem ProgId="Customer" DisplayName="Customers" />
    <ProgramItem ProgId="Shipper" DisplayName="Shippers" />
    <ProgramItem ProgId="Product" DisplayName="Products" />
    <ProgramItem ProgId="Department" DisplayName="Departments" />
    <ProgramItem ProgId="Employee" DisplayName="Employees" />
    <ProgramItem ProgId="Order" DisplayName="Orders" BusinessObject="Bee.Northwind.Server.BusinessObjects.OrderBO, Bee.Northwind.Server" Repository="Bee.Northwind.Server.Repositories.OrderRepository, Bee.Northwind.Server" />
    <ProgramItem ProgId="AuditLog" DisplayName="AuditLog" BusinessObject="Bee.Business.AuditLog.LogBusinessObject, Bee.Business" />
    <ProgramItem ProgId="AuditRule" DisplayName="Audit Rules" />
  </Items>
</ProgramSettings>

八张表单里只有订单那一笔两列都填了,其余七张两列全空,于是全部落在框架默认的 FormBusinessObject 与 DataFormRepository。Day 3 说只有一张需要应用接手,这份文件里对应的就是那七笔什么都没填的项目。昨天出现过的那一句取得订单 Repository 的程序,类型的来源正是 Repository 那一列。

保留字那三笔也在这份清单上:System 与 AuditRule 没填任何类型,各自解析到框架自己的那一个,AuditLog 则把那个名字写了出来。保留字纳入注册表换到的就是这个:要接手其中一支,填上 BusinessObject 那一列就好。

小结

类型注册表要回答的问题只有一个:这次调用由哪一个类型处理。

  • 契约接口决定属性有哪些,API 类型与 BO 类型各自决定怎么被送出去、怎么被业务逻辑使用,两边互不相识
  • ProgId 是那个键,一个调用进来,类型由注册表决定,不由代码里的分支决定
  • 摊平成一层,是为了让唯一性由结构保证,而不是靠人记得别把同一个 ProgId 写两次

可以带走的判准:一个类型注册表真正要设计的不是「找得到的时候怎么办」,是「找不到的时候,故障会以什么面貌出现」。三个绑定给的是同一个答案,因为退路能换到的只有「看起来还在跑」;差别只在什么时候发现,保留字在部署当下,其余在第一次用到那一刻。

明天谈这些调用是以什么协议送进来的:为什么是 JSON-RPC 而不是 REST。


本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。

iThome 原文 | HackMD 版

系列完整目录:ERP 架构师笔记:定义驱动的框架设计

相关推荐
1-1=023 分钟前
C# 数据结构和特殊类型
数据结构·c#·.net·.netcore
Flynn Liang27 分钟前
Apache Superset 重要特性详解(三):语义层 / 权限 / 缓存 / 嵌入的实战最佳实践(2026 版)
大数据·clickhouse·数据可视化·架构设计·superset·数据分析平台
IT规划师1 小时前
Redis 数据类型全解 - Stream 深度:消费者组、Pending 队列与死信处理(十)
redis·.net
Jeff-Tsai1 天前
Day 14:业务对象的扩展点与重写时机
.net·架构设计·erp·definition-driven
IT规划师1 天前
Redis 数据类型全解 - GEO 地理空间:附近的人、外卖、打车——背后原理与精度陷阱(九)
redis·.net
追逐时光者2 天前
一款基于 .NET 开源、免费的数据库自治诊断平台
后端·.net
追逐时光者2 天前
让 GitHub Copilot 更懂你的 .NET 代码风格:一份精简实用的 Copilot Instructions
后端·.net
Jeff-Tsai2 天前
Day 13:Session 管理与生命周期
.net·架构设计·erp·definition-driven