Day 14:业务对象的扩展点与重写时机

定义表达得了的行为,一行代码都不必写。表达不了的那些才需要接手:这张单据送出前要检查什么、存完之后要通知谁、删除前什么情况不准删。框架给的接手方式是继承业务对象,重写其中几个步骤。

子类依需求在对应的步骤加进这张表单自己的逻辑,难的是判断「对应的」是哪一步,以及那段程序该写在 base 的前面还是后面。方法名称只答得出顺序,DoBeforeSave 在保存之前、DoAfterSave 在保存之后,其余要由框架主动讲明。

本篇说明:

  1. 什么时候该继承业务对象,什么时候不必
  2. 一个 Save 为什么要拆成好几个步骤
  3. 六个步骤各自的使用场景
  4. 重写时,base 那一行前后的差异
  5. 一个方法要不要开放重写,判准是什么

一、什么时候该继承业务对象

Day 3 那八张表单里七张的应用代码是零行,只有订单那一张指定了自己的类型。增修删查与验证都由定义长出来,会需要写程序的是定义表达不了的那一段。

所以继承之前有三层要先问过,顺序由浅到深:

这件事能不能... 交付方式 谁改得动
写成字段上的表达式或一条规则(Day 8) 改一份定义文件 懂业务的人
写成一个挂在时点上的 plugin(Day 21) 换一个程序集 开发者
都不行,才继承业务对象 换一个程序集,改掉这张表单绑的类型 开发者

三层的差别不在难度,在交付路径:愈上面的愈能跟着定义一起走,愈下面的愈接近「回到代码那一侧」。下面那两层都得出一个程序集,而 plugin 只在固定的时点加一段,套装原本那一段一定会跑;继承手上有 base 那一行,套装那一段留不留由子类决定。这条轴线是 Day 30 的骨干,这里只取一个推论:继承是最后一层。

判别法沿用 Day 2 那条:这件事全世界的 ERP 做起来是不是都一样?一样的迟早会被收成机制,不一样的才该由应用写。订单的状态转换规则每一家都不同,那是继承的正当理由;至于总计等于明细加总,它现在写在应用里只是因为框架还没收。

以套装请假单为例,假别选特休,保存之前就要验他还剩几天、够不够这次请,而那是假单自己的逻辑,所以套装假单要继承框架的业务对象基底下来改写。定制再往下接一层:某一家客户算特休的方式跟套装不一样,就继承套装这个假单的业务对象再改写一次,把套装那一段验证换掉。同一个机制接了两层,两种理由。

继承的入口是注册表上那一行,指定 ProgId 对应到哪一个类型(Day 15 的题目)。声明之后框架建出来的就是你的子类,重写的那几个步骤会被叫到,没重写的照原样跑。


二、一个 Save 为什么要拆成好几个步骤

假设框架只给一支 Save 让人重写。应用想在保存前挡一笔数据,就得把整支重写,而那一支里有授权检查、数据范围检查、规则求值、持久化、审计写入。要加三行,得先把这五件事原样抄回来,抄漏一件就是一个安静的漏洞。

拆开之后,重写的人只接手自己那一段。写入面能动的是六个 protected virtual 步骤:

text 复制代码
Save:   DoBeforeSave   →  DoSave   →  [变更审计]  →  DoAfterSave
Delete: DoBeforeDelete →  DoDelete →  [删除审计]  →  DoAfterDelete
                            ↑
                      这两个步骤在 transaction 内

Save 的骨架节选在 FormBusinessObject.Write.cs 里,顺序一眼可读:

csharp 复制代码
DoBeforeSave(context);
plugins.RunBeforeSave(context);
// ...截取变更集(改动前/改动后)供审计使用
DoSave(context);                          // transaction 在这一句之内打开并 commit
if (auditChange) WriteChangeAudit(...);
DoAfterSave(context);
plugins.RunAfterSave(context);

DoSave 与 DoDelete 为什么各自是一条 transaction

transaction 由 Repository 在 DoSave 内部开与关,涵盖的是整笔单据:主表一句、明细有几行就有几句,全部成功才 commit,任何一句失败就整笔退回。存进去的必须是一张完整的单,不能是主表在而明细少了几行。

删除要的是同一件事的另一半:主表删掉了,底下的明细也必须跟着消失,否则留下的是一批对不到主表的明细行。这两种残缺 Day 11 都讲过,共同点是数据库不会替你挡,因为框架不建外键约束。形状也在那里:保存主表先、明细后,删除反过来。

所以保存与删除各有一步落在里面:DoSave 与 DoDelete 是六段里仅有的两个带原子性承诺的步骤,其余四段都在外面。

审计为什么被切成两半

截取改动前后的值排在 DoSave 之前,因为持久化走的是 ADO.NET 的 DataAdapter,成功之后它会把每一行标成已接受,原值与行状态一起被丢掉。写入审计那一行则在 DoSave 之后,要等数据真的存进去才有意义。

截取点的另一边也被钉住:它排在 DoBeforeSave 之后,计算列与规则填上去的值才进得了审计。一个位置被两侧同时钉住,通常就改不动了,而看代码的顺序看不出它为什么在那里。

代价是「数据写成功、审计写失败」可能发生,框架接受了这个落差。前面那个决定不是这样:整笔单据的语句要么一起算数、要么一起不算,框架不给部分成功。两个决定相反,因为坏掉的方式不同。

坏掉的后果 决定
主表与明细 留下一张明细不齐的单,而且不会报错 强制同 transaction
变更审计 少一行记录,数据本身是对的 接受非原子

原子性不是越多越好,它有价格,要逐项决定付不付。审计本身的形状是 Day 25 的题目。


三、六个步骤各自的使用场景

步骤 适合放什么
DoBeforeSave 填默认值、算跨行的合计、挡明显错误的输入
DoSave 必须与这笔数据同生共死的写入
DoAfterSave 通知、调用外部系统、丢进队列
DoBeforeDelete 依删除前的整笔数据判断准不准删
DoDelete 要跟着一起删掉的其他数据
DoAfterDelete 把删除这件事传给下游

要接手就重写这六个之一,不要重写 public 的 Save / Delete,否则就回到上一节开头那个处境:授权、数据范围、审计那几段全部得自己原样抄回来。读取那一侧同样有几个开好的位置,最常用到的是取一笔新数据时填默认值,以及替开窗选取的候选行收窄,例如只列出还在启用中的。

那全部包进 transaction 不就好了

以保存那一侧为例,各段依序要付的代价是:

若把这一段纳入 代价
DoBeforeSave 规则求值、跨表关联的查询、调用其他业务对象全部在锁内。一张表单的规则越多,锁就抓得越久
审计写入 每次保存都多一行必须同时成功的写入。轨迹那一侧出问题,变成业务单据存不进去
DoAfterSave 外部系统的延迟直接变成锁持有时间。对方超时,等于这里的连接被占着不放

这在 ERP 上特别要紧。单据之间关联得密,一次保存动到的常常不只自己那一张表,于是两个看起来无关的功能会落在同一批行上:这边的出货单还没放掉库存那几行,那边的成本计算就得等。锁持有的时间越长,这种互相卡住就越常发生。

所以拆成六段是把「必须原子」的范围压到最小,transaction 里只有写入。其余每一段都可以慢,慢不会伤到别人。

DoAfterSave 抛异常时,数据已经写进去了

在 DoAfterSave 抛异常,异常会往上抛、该次调用回报失败,但这一段开始之前数据已经写进去了。用户看到「保存失败」,数据库里那笔单据却在,而且是完整的。所以放在这里的副作用要能被重试,或交给队列而不是同步运行;同步送出一个通知然后失败,就没有任何东西可以重试(不同可靠性等级各该用哪一种,是 Day 21 的题目)。


四、base 那一行决定你看到什么

自己的逻辑写在 base 前面还是后面,看它需要什么。那一行不是形式,它决定你手上这份数据处在什么状态,而六个步骤的 base 做的事各不相同:

步骤 base 做的事 调用之后多了什么
DoBeforeSave 套用默认值与计算列表达式,再跑 BeforeSave 验证规则 算完的值。写在它前面看到的是调用端送上来的原样
DoBeforeDelete 拿删除前的快照跑 BeforeDelete 规则 同上,而且它读的是快照,不是送上来的数据
DoSave / DoDelete 整笔持久化 数据已经写进去,transaction 已经 commit
DoAfterSave / DoAfterDelete 什么都不做 没有差别,前后等价

DoBeforeSave 是最常重写的位置,也最容易放错边。计算列的值是 base 那一行填上去的,自定义检查要看得到它就得排在后面;反过来,要在规则跑之前先塞一个值进去,那才是该写在前面的东西。

同一个 context,不同位置拿得到的东西不一样

三个步骤共用一个 SaveContext,它从头传到尾,省掉每一段各自去解析 Repository 与 FormSchema。但同一个对象在不同位置的内容不同:

SaveContext 的成员 DoBeforeSave DoAfterSave
Args / DataSet / Repository / Schema 有 有
RefreshedDataSet null 持久化后回写的那一份
AffectedRows 空 每张表的变更行数

DataSet 两边都拿得到,可以做的事却相反。在前面那个位置改它,改动会跟着写进数据库;在后面那个位置改它没有任何作用,要影响调用端收到什么得改 RefreshedDataSet。同一个属性,一边是输入、一边是记录。

删除那一侧另有一份快照,内容是删除前的整笔数据(主表加明细),供 BeforeDelete 规则比对、也供审计记下改动前的内容。它有两个性质:

  • 只在真的有人要用的时候才加载。审计关着、这张表单没有删除前规则、也没有挂任何 plugin,就完全不读,直接删除那条路径因此是零查询的。
  • 判断「有没有人要用」时把 plugin 算进去,而这一项非算不可:不算的话,快照在不在就取决于审计那个开关,同一个 plugin 会在一个部署拿得到数据、换一个部署拿到 null。

第二点是扩展点设计的通则:一个扩展点手上拿得到什么,不该取决于另一个功能有没有开。


五、一个方法要不要开放重写

反过来问:框架决定要不要开一个新位置的时候,是照什么判的?

判准只有一条:有实际需求才开。现在没开的那些,多数不是不能开,是还没有人真的需要。理由在于它不可逆:扩展点是对外承诺,开放之后有人重写了就收不回来,而套装软件的维护期十年二十年起跳。

两种「现在没开」,性质不一样

一种是设计上就不开。授权、数据范围与审计那几支全部是 private,判别法是这一段的存在理由是不是「不管谁来都要跑」,是的话开放重写等于把它变成可选的。

另一种只是还没有需求,数量远比第一种多,随时可以变成扩展点。把第二种说成「框架不支持」会让人去绕路,而绕路留下的东西比一个晚一步开出来的扩展点难收拾得多。

需求来了之后,开在哪一层

开在最小的那一层。开窗选取的候选行,整段查询都可以重写,但框架另外开了一个小位置,只让你加一个条件把候选行收窄,因为多数人要的就是这个。

还有一种做法是把入口和实现分开:入口不开放重写,只负责调用另一支开放重写的实现方法,重写错那一支根本编不过。Save 与 Delete 没有这样做,所以「不要重写这一支」只能靠文档说。

所以现在开着的每一个位置,背后都有一个当时真的存在的需求;没开的那一批,等需求收敛出新的共通行为,就会再长出对应的位置。


回到 Northwind

案例的 OrderBO 重写了两个步骤,写入面的那个是 DoBeforeSave,里面做四件事。逐件问「放在这个位置对不对」,答案不一样:

这一件 要不要问数据库 放在这里
至少一行明细 否 可以,纯内存判定
主表总额等于明细加总 否 可以,纯内存计算
状态转换是否合法 是(读存量状态) 有空窗
订单编号配号 是(读当月最大值) 有空窗

前两件的输入全部在手上那份 DataSet 里,位置正确。后两件都要回头问数据库,于是读完到 DoSave 写进去之间留了一段空窗,另一个人可能已经在那段时间里把前提改掉。

订单编号那一件的形状最单纯,程序在 OrderBO 里:

csharp 复制代码
var currentMax = Repository().GetMaxOrderNumber($"ORD-{yearMonth}-%");
master["sys_id"] = OrderRules.NextOrderNumber(yearMonth, currentMax);

查当月最大值加一,两个人同时开单就会拿到同一个号码。挡下它的不是这段程序,是数据库:

sql 复制代码
CREATE UNIQUE INDEX "uk_ft_order" ON "ft_order" ("sys_id" ASC)

第二笔 INSERT 在 DoSave 内被拒绝,整笔 rollback。这个索引不是为了这件事特地加的,sys_id 是框架的系统字段,由 FormSchema 产生 TableSchema 时就自动配上了。防线确实落在写入的当下,只是它不是程序写出来的,是定义声明出来的;代价是撞号的那个用户要重存一次。

状态转换那一件没有索引可以挡:两个人同时把同一张单从草稿确认掉,两边读到的都是草稿,两边都判定合法,而分开看两次写入也都没有错,只是不该同时发生。要挡住得把「目前状态必须还是草稿」写进那句 UPDATE,案例没有做到这一层。


小结

扩展点的位置决定它的语义,而位置不会写在方法名称上:

  • Before 说的是顺序,不是保护。这里读到的值只在读的那一刻成立。
  • DoSave 与 DoDelete 是与数据同生共死的那一段,而要把一条规则放进去,靠的多半不是重写它们,是让数据库自己在写入当下判定。
  • After 之后,数据已经是别人看得到的事实了。

可以带走的判准:框架开一个扩展点的时候,除了「什么时候跑」,还欠用户一句「它在 transaction 的哪一边」。少了那一句,重写的人会照着方法名称推论,而名称推论不出边界。

这句话对所有挂在流程上的机制都成立。哪一段在里面、哪一段在外面决定的不只是性能,是那段程序能不能承诺它刚刚看到的东西还算数。

明天谈这些扩展点挂在哪一个类型上:ProgId、契约分层与类型注册表。


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

iThome 原文 | HackMD 版

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

相关推荐
IT规划师1 小时前
Redis 数据类型全解 - GEO 地理空间:附近的人、外卖、打车——背后原理与精度陷阱(九)
redis·.net
追逐时光者1 天前
一款基于 .NET 开源、免费的数据库自治诊断平台
后端·.net
追逐时光者1 天前
让 GitHub Copilot 更懂你的 .NET 代码风格:一份精简实用的 Copilot Instructions
后端·.net
Jeff-Tsai1 天前
Day 13:Session 管理与生命周期
.net·架构设计·erp·definition-driven
Lorenzo5652 天前
扫码点餐里的并发和断网:我在第一个全栈项目里做的几个决定
.net
1-1=02 天前
C# IO和序列化
c#·.net·.netcore
Jeff-Tsai2 天前
Day 12:定义与数据缓存的跨节点失效
.net·架构设计·erp·definition-driven
CSharp精选营2 天前
从控制台到「会动手」的 AI:用 Semantic Kernel 搭应用(小白教程)
c#·.net·ai agent·semantickernel·functioncalling