一套 ERP 几乎不存在装了就用这回事。同一套定义交付给三家公司,这家要把「客户」叫成「经销商」,那家的订单界面要少三个字段,还有一家保存前要多一道信用额度检查。三件事的分量差了一个量级,但只要它们全都被表达成代码,就会走同一条交付路径。
Day 1 那张对照表里,定义驱动之下定制的表现形式是「叠加在标准定义之上的一层数据」。这一层挂在公司上:用户进的是哪一家公司,就决定了叠上去的是哪一份定制。
本篇说明:
- 哪几份定义可以定制,哪两份永远不行
- 四种叠加粒度,各自为什么是那一种
- 两层各自只读,合成只发生在查找的那一刻
- 定制化代码从哪里来,叠加又发生在哪一端
一、可以定制的是哪几份定义
定制化代码是公司数据上的一个字段,跟着用户进的那家公司走。多租户与集团多公司因此都适用,那是层级选在公司的结果,不是目的。而公司对代码是多对一:集团旗下几家共用一份定制就指同一个代码,各自要不一样的业务逻辑就各指一个。
定制有两个面向:套装已经有的程序怎么变得不一样,以及客户要一支套装没有的程序。这一层回答的是前者,后者不走这一层。
框架的做法是把差异放进另一个根目录。一套部署有一组套装定义放在 DefinePath,每一个定制化代码在 CustomizePath 底下有自己的文件夹,文件夹名称就是那个代码,里面只放差异的部分。
text
{DefinePath}/ ← 套装定义(节选)
FormSchema/ TableSchema/ FormLayout/ Language/
ProgramSettings.xml MenuSettings.xml PluginSettings.xml
{CustomizePath}/{customizeId}/ ← 这个代码的差异,只有这五份
FormLayout/ Language/
ProgramSettings.xml MenuSettings.xml PluginSettings.xml
Day 4 数过框架有十三种定义,进得了定制层的只有其中五种。
这五份各自能改什么:
| 定义文件 | 能改的部分 | 叠加粒度 | 要交付程序集吗 |
|---|---|---|---|
Language |
字段标题、表单名称、消息文字,以及选项集的文字 | 文字是 key 级,选项集是整份取代 | 否 |
FormLayout |
界面上出现哪些字段、怎么排 | 整份取代 | 否 |
MenuSettings |
菜单怎么分组、放哪些程序 | 整份取代 | 否 |
ProgramSettings |
一个程序的整体行为,以及它怎么读写自己的数据 | ProgId 级,其下属性级 | 是 |
PluginSettings |
在既有保存或删除流程上多一步 | ProgId 级相加 | 是 |
最后一列是这张表真正的分界线。Language、FormLayout、MenuSettings 这三份改的是定义本身,交付方式就是把几个 XML 文件放进那个代码的文件夹;ProgramSettings 与 PluginSettings 这两份的内容是类型名称,类型要能加载,程序集就得进到主机的 bin。前者是换一份数据,后者仍然是一次部署。
能用轻的就别用重的。改一个标题落在语言数据上,改一张界面的排法落在布局文件上,多一道保存前的检查落在一个 plugin 上,要换掉一支程序整体的行为才轮到定制 BO。同一道检查改用定制 BO 重写也达得到,代价是多一个要跟着每次框架升级维护的类。
不能定制的那两份,以及它推出来的限制
FormSchema 与 TableSchema 永久排除:FormSchema 同时驱动数据库结构与验证规则,不只驱动界面,逐定制分歧会让物理 schema 裂开。
排除的机制不是一份黑名单,而是三个地方各自没有那条路。定制层的读取接口上根本没有取 FormSchema 的方法;只读存储对这两个 getter 直接抛 NotSupportedException;定制版的路径类也没有重写那两条路径,就算有人绕过前两道,也算不出一个指向定制文件夹的文件名。
这条裁决推出一个必须先纳入规划的限制:定制不能多一个字段。定制能有的是既有字段的不同标题、一张把那个字段藏起来的界面、一个对它处理方式不同的 BO,或一个负责填它的 plugin。某一家公司真的需要自己的数据时,那是一次对所有人的 schema 变更,用不到的地方让那个字段空着。
同一条线还带走了规则。Day 8 谈的声明式默认值、计算列与验证规则住在 FormSchema 里,所以那些规则对每一家公司都一样,要让判断不一样只能走 plugin 或定制 BO。
二、四种叠加粒度,各自为什么是那一种
两份同类型的定义摆在一起,「合起来」不是一个明确的动作。同一个问题在布局上跟在语言上会得到完全不同的答案,所以粒度不是留给调用端决定的参数,它被硬编码在叠加方法本身:
| 粒度 | 用在 | 查找的语义 |
|---|---|---|
| 整份取代 | FormLayout、MenuSettings、语言的选项集 |
定制有就整份用定制的,套装那一份完全不看 |
| key 级 | 语言文字 | 定制声明了那个 key 就用定制值,其余每一个 key 都来自套装 |
| ProgId 级,其下属性级 | 定制 BO、定制 Repository | 定制只重写它指名的绑定,留空的沿用套装 |
| ProgId 级相加 | 业务 plugin | 套装那一串先跑,定制那一串接在后面 |
整份取代用在没有直观合并答案的东西上。一份布局是一个排法,部分合并答不出「这一区块移走了,底下的字段跟不跟着走」;一组选项集只有整组看才有意义,逐项合并会让顺序、以及「没被列到的那一项是什么意思」两件事同时变模糊。要改一组选项,就得把那组应该有的项目全部列出来。
key 级用在彼此无关的东西上。一个字段标题跟另一个字段标题之间没有任何关系,定制文件里只放要改的那几个 key 就够了:其余所有标题仍然来自套装资源,套装日后添加的翻译也会自己传过来。
属性级是为了一个不会被回报的损失而存在的。ProgramSettings 里一行程序项目带着两个彼此独立的绑定,一个指 BO、一个指 Repository。若叠加是整行取代,一份只想换掉 BO 的定制会连带把套装原本绑的 Repository 一起清掉,而空字符串在这里是合法的「用框架默认」而不是错误,所以框架不会抛异常、不会记警告,症状是那个程序安静地换了一套数据访问行为。
xml
<!-- Customize/acme/ProgramSettings.xml -->
<ProgramSettings>
<Items>
<ProgramItem ProgId="Order" BusinessObject="Acme.Erp.OrderBO, Acme.Erp" />
</Items>
</ProgramSettings>
套装那一行的 Repository 与显示名称仍然生效。要刻意让某个绑定退回框架自己的类型,正确的做法是显式指名那个类型,不是把属性留空。
相加是唯一不做选择的粒度,只用在 plugin 上。绑定与步骤不是同一种东西:绑定说的是「这个程序就是这个类型」,只能有一个,定制要换就得挤掉套装那个;plugin 只是流程上多一步,两层各加各的不冲突。
代价是定制停不掉套装的 plugin,框架刻意没给拿掉某一个的语法。要移除套装那一步,得继承 BO 重写它。plugin 有哪些时点、每个时点拿得到什么,是这一章后面的题目。
整份取代是双向的
定制了某张布局,那一份定制就完整拥有它。套装日后在 FormSchema 添加的字段,指向那个代码的公司都不会看到,框架既不合并也不警告。
这是设计意图而不是缺口。Day 7 那张表里,走 FormLayout 那一种写的是哪些字段不显示、明细显示哪几列,全由那份文件说了算。定制层里排了一份布局,动的就是那份文件;FormSchema 多一个字段,并不等于那些公司从此就该显示它。要让它出现在那些界面上是一个决定,执行方式是去改那份定制布局文件。菜单同理。
三、两层各自只读,合成只发生在查找的那一刻
最直观的实现是启动时把两层合成一份完整定义、缓存起来,之后整个系统当成单层在读。挡住这条路的是 Day 12 那个前提:缓存给出来的是共用实例,不是副本。
套装那一层整个进程只有一份,定制那一层由指向同一个代码的所有 session 共用一份,动了哪一份,就等于动到所有正在读它的公司。想先合成再缓存,写法只有两种:把定制的内容写回套装那一份,或是另外存一份合好的。前者会让一家公司的定制落到别家头上,后者是多一份缓存要顾,两层任何一边变了都得记得让它跟着失效。
所以两层各自缓存、各自只读,叠加发生在读那一份定义的当下。定制那一层的隔离用的是既有机制:一个定制化代码配一个缓存容器,容器背后的存储只认 {CustomizePath}/{customizeId}/ 底下的文件,没有那个文件就回 null,不回头去找套装那一层,也不把套装的内容混进来。「这个代码没有定制」与「它定制成空的」因此一直分得开。
四种粒度里有三种不合成任何东西:整份取代与 key 级是在两个现成的值之间挑一个,相加产出的是一串类型名称、两层的定义对象都原封不动。唯一会把两层的内容合成一个新对象的是属性级那一种,节选自 CustomizeOverlay.cs:
csharp
if (customizeItem == null) { return baseItem; }
if (baseItem == null) { return customizeItem; }
// WARNING: every ProgramItem property must be merged here. One that is forgotten falls
// back to whole-entry replacement for that property alone.
return new ProgramItem(progId, Prefer(customizeItem.DisplayName, baseItem.DisplayName))
{
BusinessObject = Prefer(customizeItem.BusinessObject, baseItem.BusinessObject),
Repository = Prefer(customizeItem.Repository, baseItem.Repository),
};
只有一层声明那个 ProgId 时,直接回那一层自己的实例,没有东西要合;两层都声明时建一个新的,两层的缓存实例都不动。
四、定制化代码从哪里来,叠加又发生在哪一端
运行时那一半 Day 13 已经建立:定制化代码是进公司那一段填进 session 的六个值之一,跟着公司同进同出。这里补两件事。
session 是唯一来源。服务端每一个要用到定制的地方都自己去问 session,而定制化代码在 API 的参数上刻意不存在(节选自 BusinessObject.cs):
csharp
protected string GetCurrentCustomizeId()
{
if (AccessToken == Guid.Empty)
return string.Empty;
return SessionInfoService.Get(AccessToken)?.CustomizeId ?? string.Empty;
}
理由在这个值本身:它选的是要读哪一个代码的定制文件,接受调用端传进来等于任何人都能读任何一家公司的定制。
代码为空就整条短路:还没登录、还没进公司,或这个部署根本没有定制,第一步就直接走套装层,不问定制层、不探任何文件,跟这个功能不存在时走的是同一条路。
同一套规则,两端各跑一次
叠加规则被抽成一个没有存储、没有缓存、没有 session、没有 DI 依赖的纯决策组件,服务端与客户端跑的是同一份代码。
text
套装层缓存(全进程一份,只读)───┐
├─→ 叠加,依粒度择一或相加 ─→ 生效值
定制层缓存(一个代码一份,只读)─┘
服务端不先叠,送出去的是原始定义:表单布局与语言资源各有「套装」与「定制」两支 API,客户端把两层都取回来,再用同一个决策组件挑。挑这一步排在客户端组装一张表单定义的路径上:取回原始定义、本地化、挑布局、套标题,每一个前端都照同样的顺序走一次。
MenuSettings 是异常,服务端挑好才送:它的粒度是整份取代,两层都送过去,客户端也只是二选一。
代价是同一套叠加规则得在两端各跑一次。因为两端跑的是同一个组件,照这条路径组定义的前端不会跟服务端推导出两套答案;自己另外组的前端就没有这个保证,它的叠加规则会跟服务端各自演化。共用出去的是「怎么挑」,不是「挑谁」,套的是哪一份定制始终是服务端依 session 做的决定。
回到 Northwind
案例的公司数据填了定制化代码 northwind-demo,定制层底下有一份订单布局。套装那份的主表区块排了八个字段,定制这一份只排五个(Order.FormLayout.xml):
xml
<LayoutField FieldName="sys_id" Caption="Order No" />
<LayoutField FieldName="order_date" Caption="Order Date" />
<LayoutField FieldName="customer_rowid" Caption="Customer" ControlType="ButtonEdit" DisplayFields="ref_customer_id,ref_customer_name" />
<LayoutField FieldName="status" Caption="Status" ControlType="DropDownEdit" />
<LayoutField FieldName="total_amount" Caption="Total Amount" ReadOnly="true" />
这家公司不自己出货、也不记业务员,所以业务员、货运商、运费三列不排进界面。片段里那些英文标题运行时不生效,标题的来源是语言资源,不是布局文件;界面上那个「经销商」就来自同一个代码底下的另一份定制,而那一份走的是 key 级。
整份取代的代价就在同一个文件里:明细那一段一行都没有改,定制文件仍然得把它整段抄一次。少抄的话不是「沿用套装的明细」,是那张表单从此没有明细。
字段本身并没有消失。FormSchema 与数据表照旧,那三个字段还在,只是这一份布局不显示它们。一个换掉整张界面排法的需求,在定制层里就是整份抄一次;粒度选在整份上换到的是这个:你完整拥有它,也完整维护它。
小结
定制层真正在回答的问题是:不一样的地方要落在哪一份定义上,以及那个决定挂在哪一层。落点决定了交付方式,也决定了这一份定制跟套装之间还剩多少关系。
- 十三种定义里有五种进得了定制层,三份只改数据、两份指名类型所以仍要交付程序集;
FormSchema与TableSchema永久排除,推论是定制不能多一个字段 - 四种粒度各自对应两个问题:这种东西部分合并有没有意义,以及两层同时声明时该挤掉一个还是两个都算
- 两层各自只读,叠加只发生在读那一份定义的当下;四种粒度里只有属性级会合成出新对象,而两层的缓存实例都不动
- 定制化代码挂在公司数据的一个字段上,多租户与集团多公司因此落在同一套机制里;它只从 session 来、调用端指定不了,失效的方向因此落在安全那一边,代码为空就整条短路
最该带走的是粒度那一节。粒度真正决定的不是定制能改多细,是套装日后改了、这一份定制跟不跟得上。key 级、属性级与相加这三种,套装之后添加的翻译、添加的绑定、添加的 plugin,定制过的照样拿得到;整份取代那一种拿不到,而且不会有任何地方提醒你。
这不是一路选细就好的问题,布局与菜单本来就没有部分合并的直观答案,选粗是唯一答得出来的选项。能做的是知道自己买了什么:每一次选择粒度,都是在决定这一份定制未来要从套装的演进里分到多少。
明天谈这一层里最常被用到的那一份:一套要卖到多个地区的系统,多语言为什么不走 .resx,而是走定义文件。
本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计