Day 19:定制层的范围与叠加粒度

一套 ERP 几乎不存在装了就用这回事。同一套定义交付给三家公司,这家要把「客户」叫成「经销商」,那家的订单界面要少三个字段,还有一家保存前要多一道信用额度检查。三件事的分量差了一个量级,但只要它们全都被表达成代码,就会走同一条交付路径。

Day 1 那张对照表里,定义驱动之下定制的表现形式是「叠加在标准定义之上的一层数据」。这一层挂在公司上:用户进的是哪一家公司,就决定了叠上去的是哪一份定制。

本篇说明:

  1. 哪几份定义可以定制,哪两份永远不行
  2. 四种叠加粒度,各自为什么是那一种
  3. 两层各自只读,合成只发生在查找的那一刻
  4. 定制化代码从哪里来,叠加又发生在哪一端

一、可以定制的是哪几份定义

定制化代码是公司数据上的一个字段,跟着用户进的那家公司走。多租户与集团多公司因此都适用,那是层级选在公司的结果,不是目的。而公司对代码是多对一:集团旗下几家共用一份定制就指同一个代码,各自要不一样的业务逻辑就各指一个。

定制有两个面向:套装已经有的程序怎么变得不一样,以及客户要一支套装没有的程序。这一层回答的是前者,后者不走这一层。

框架的做法是把差异放进另一个根目录。一套部署有一组套装定义放在 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 铁人赛参赛文章,原文为繁体中文。

iThome 原文 | HackMD 版

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

相关推荐
Jeff-Tsai1 小时前
Day 20:多语言定制与资源结构
.net·架构设计·erp·definition-driven
泓度科技14 小时前
多租户数据隔离怎么做 单库单表架构方案
架构设计·多租户·缓存策略·数据隔离·权限管控
known2 天前
你的账,是补出来的还是做出来的
blazor·erp·known
Jeff-Tsai2 天前
Day 18:错误契约与用户可见消息
.net·架构设计·erp·definition-driven
IT规划师2 天前
HybridCache-缓存架构决策框架落地
架构·.net
Jeff-Tsai3 天前
Day 17:Connector 与调用端的职责收敛
.net·架构设计·erp·definition-driven
Flynn Liang4 天前
同一需求三套写法:用 Spring AI、LangChain4j、AgentScope Java 各实现一个带工具调用的 RAG 助手(实战对比)
java·人工智能·大模型·架构设计·spring ai·langchain4j·agentscope
IT规划师4 天前
NET10 高并发架构契约与落地实现
架构·.net
公子小六4 天前
基于.NET的Windows窗体编程之WinForms数据持久化(1)
windows·microsoft·c#·.net·winforms