Day 3:Northwind 案例的八张表单与自定义业务对象


前两天谈的都是命题,今天让案例登场。

案例是 Northwind,微软那套从 Access 年代流传到现在的示例数据库。选它的理由很现实:客户、供应商、产品、订单这几张表,多数人不需要解释就知道是什么,于是可以直接谈这个结构考验了框架什么,不必先讲这个业务在做什么。

本篇说明:

  1. 案例要能考验框架的四个条件
  2. 八张表单的三种形状,与框架的系统字段
  3. 关联为何声明在表单之间,不是数据表之间
  4. 唯一写程序的那一张,以及它为什么还配了一个 Repository
  5. 那个 Business Object 里装了什么
  6. 框架管标准流程,应用只接手它管不到的那一小段

先看做出来的样子。这是八张表单里最复杂的订单,同一份定义在四个前端上跑出来的界面。

桌面 浏览器
iOS Android

主表配一张明细,客户、员工、货运商三个字段都是开窗挑一笔回来,明细每行再挑一项产品,金额不给人填。四个前端共用同一个 UI 项目、读同一份定义,没有一张界面有对应的界面代码,差别只在最外面的那一层平台外壳。


一、案例要能考验框架什么

框架的示范几乎都长一样:一张单表,字段排一排,增修删查会动。这种示范什么都证明不了,因为所有框架都做得到。真正会坏的地方,一个都不在那张示范上。

一个能当验证用的案例,至少得同时具备四个条件:

  • 表单要有难有易:纯主表、带跨表关联的主表、主表配明细的单据都要有。只在最简单那一种上成立的框架,就只是一个表单生成器。
  • 关联不能只有一种走法:一张表指向另一张是基本款,还要有两张表互相指向、应用的表指向框架自己的表,才逼得出关联机制怎么设计。
  • 要有一张得加入自定义逻辑的表单:宣称「大部分不用写程序」的框架,案例里若一行代码都没有,那不是证明主张,是回避主张。
  • 要跑在多个前端上:布局定义号称不绑 UI 框架,同一份定义没有真的长出两种以上界面,这句话就不算数。

Northwind 四项都撑得住。它的复杂度落在一个好位置:够复杂到能考验机制,又够小到能整套摊开来逐项对账。


二、八张表单,三种形状

形状 张数 表单
纯主表(字段、清单、CRUD,无关联无规则) 4 分类、供应商、客户、货运商
带跨表关联的主表 3 产品(→ 供应商、分类)、部门(→ 员工,作为经理)、员工(→ 部门)
主表配明细的单据 1 订单

部门与员工互相指向,是刻意留的环状关联。

八张表单对应九张数据表(订单占两张)。九张里有两张是框架提供的表:部门与员工。权限与组织机制会用到它们,应用把定义拷贝过来直接用(框架的表与应用的表怎么区分、各自落在哪个数据库,Day 11 会专门谈)。

这九张表不是 Northwind 原本的形状

借的是业务案例与示范数据,不是数据表结构。原本的 Northwind 拿业务字段当主键(CustomerID='ALFKI'、整数产品编号、订单明细的复合主键),这套应用一个都没沿用。

换上的是框架的系统字段,而且框架的机制就挂在这几个名字上:

字段 框架拿来做什么
sys_rowid 跨表 JOIN、UPDATE / DELETE 的 WHERE、审计、数据范围权限、前端点击一行,比对的都是这一列
sys_master_rowid 明细怎么捞、整笔怎么一起存
sys_no 主键与默认排序
sys_id 建表时自动配唯一索引,业务代码的唯一性由此担保
sys_name 开窗选取时显示的那一列

所以这几个名字不能改,这不是命名惯例的问题。上面那几件事,框架全靠它们去认。

两个设计决定与它们的代价:

  • 主键与关联键分开:主键是自动递增的 sys_no,关联键是 Guid 型的 sys_rowid。关联键要不会变(代码改了,旧单据不能断),也要在写进数据库之前就存在(整笔订单一次存,明细得先指到主表)。代价是多一个唯一索引,键也比较胖。
  • 明细不用复合主键。主表明细的形状若各家不一样,整笔访问的机制就无法对所有表单通用。

还有一项代价比较重:既有系统要接进来得先改数据表结构,不是把定义写一写就能跑在原本的数据库上。


三、关联声明在表单之间,不是数据表之间

传统做法里关联是数据表的事:订单表放一个客户字段,加一条外键指到客户表主键。这条声明能表达的刚好就是字面那么多,这一列的值必须在那一列里找得到。

框架把关联挪到另一个层次:

<FormField FieldName="customer_rowid" Caption="Customer" DbType="Guid" RelationProgId="Customer">
  <RelationFieldMappings>
    <FieldMapping SourceField="sys_id" DestinationField="ref_customer_id" />
    <FieldMapping SourceField="sys_name" DestinationField="ref_customer_name" />
  </RelationFieldMappings>
</FormField>

RelationProgId="Customer" 指的是「客户」这支程序,不是 ft_customer 这张表。整段声明从头到尾没有出现表名,要 JOIN 到哪一张表,是框架拿这个代号去查客户那份 FormSchema 才知道的。

指向表单和指向数据表,能接上的东西差很多:

  • 一张数据表只知道自己有哪些字段
  • 一份 FormSchema 还知道:清单显示哪几列、开窗挑选时给人看什么、哪些字段能查询、它自己要不要再加条件(例如只列启用中的供应商)

所以写完上面那一段,四件事同时到位:

  1. 这个字段在界面上自动变成开窗选取的编辑器
  2. 选中一笔之后客户代号与名称自动填回
  3. 查询清单时框架自己组出 JOIN 把这两列捞回来
  4. 数据库存的是关联键

传统做法这四件分记在三个地方(外键在数据库、选取界面设置在前端、JOIN 在查询层),三处都可能对不起来。

数据库里没有外键

框架不建外键约束,订单那三个关联字段在数据库里只有索引。

这是把关联挪到定义层的必然结果:关联的权威在定义层,数据库只是它投影出来的产物。真要在数据库也建一份外键,那份声明就有两个来源,而两个来源迟早分歧。

代价:数据库不会替你挡孤儿数据。有人直接下 SQL 删掉一个客户,指向它的订单不会被拦下来。愿不愿意接受,取决于你的数据库是不是只有这个应用在写。「定义是唯一真相」这个假设要一路贯彻到数据库层,不能一半交给定义、一半交给约束。

显示列不是权威值

ref_customer_id 与 ref_customer_name 这两个字段在数据表里并不存在。

  • 订单物理表只放客户的关联键
  • 代号与名称是查询时由服务器 JOIN 出来的
  • 前端开窗挑完会把值写进手上那份数据,只为了界面立刻看得到,保存时不会落进数据库
  • 客户改了名字,订单上显示的值下次读取就跟着对

典型取舍:每次查询多一次 JOIN,换到的是界面上永远是现在的值。代价是要有一个明确的规定说「哪一边是权威」,而这个规定必须由框架来定,不能让每张表单各自决定。

这套应用声明了几处

实测八处:订单主表三处(客户、员工、货运商)、订单明细每行一处(产品)、产品两处(供应商、分类)、员工一处(部门)、部门一处(经理)。

Day 1 说的「三处跨表关联」是订单主表上的那三处,后面对账用的也是这个范围。

这八处里有一处跨过一条线:订单指向的员工是前面那两张框架表之一。订单上的业务员就是框架的员工,应用不必再建一张自己的员工表。

环状关联为什么留着

部门指向员工(谁是经理)、员工指向部门,在数据模型上是一个环。刻意留着,因为凡是「从定义产生东西」的机制遇到环都要有明确处理方式:

  • 构建期产生强类型类 → 环会逼出类型初始化顺序问题
  • 运行时依定义动态组查询 → 环只影响那一次查询要 JOIN 几层

这正是 Day 1「产生的是代码还是对象」在数据模型上的投影。


四、订单:唯一写了程序的那一张

八张表单里七张的应用代码是零行。注册表上这七张只写了 ProgId 与显示名称,代表走框架默认流程。只有订单那一行指定了两个类型:

<ProgramItem ProgId="Order" DisplayName="Orders"
             BusinessObject="...OrderBO, Bee.Northwind.Server"
             Repository="...OrderRepository, Bee.Northwind.Server" />

两个类型各管一段:Business Object(以下简称 BO)放这张表单的业务逻辑,Repository 放它的数据访问。

那个 Repository 存在的理由只有两句 SQL:读出订单目前存在数据库里的状态,以及查出本月最大的订单编号。其余 CRUD 仍由框架依定义组出来。

把这两句放进 Repository 而不是 BO,不只是分层洁癖:BO 得自己指名连哪一个数据库,Repository 则由 FormSchema 的分类加上目前的 session 解析出来,自动走到正确那一家公司的数据库,写的人不必知道系统里有几家公司。


五、那个 Business Object 里装了什么

订单的 BO 重写两个方法,保存之前那一步做了四件事:

  1. 检查至少要有一行明细
  2. 把明细金额加总写回主表总计
  3. 产生订单编号
  4. 检查状态转换是否合法

四件看起来都像「业务逻辑」,但拿 Day 2 那条判别法(全世界的 ERP 做起来是不是都一样)去切,只有最后一件是。

前三件都是通则。单据至少要有一行明细、总计等于明细加总,这是约定俗成的做法;订单编号是前缀加年月加流水号,这种取号原则多数 ERP 都做成可设置的机制。三件应该都能收敛为机制,它们现在写在应用里是因为框架还没收。

最后一件才是业务判断。状态从草稿到确认到出货,不能跳阶、不能回头,确认后明细锁住。这条规则每一家都不一样(有些公司确认后还能改数量,有些多两个中间状态),该由应用写。

那条界线移动过

BO 一开始做的不只这四件,有两批东西已经搬出去了:

  • 明细每行的金额以前在程序里算,现在是字段上的一行表达式
  • 客户与产品必填、数量大于零这三条检查以前在程序里,现在是 FormSchema 里的三条规则
<FormField FieldName="amount" DbType="Currency" NumberKind="Amount" ReadOnly="true"
           ValueExpression="quantity * unit_price * (1 - discount)" />

搬过去之后省的不只是那几行:表达式在用户改动数量或折扣的当下就重算,于是前端不必为了「金额要实时跳动」再写一次同样的计算。同一个算式,一处声明,两端都算得出来。

还有一件走得更远:能改它的人也跟着换了。金额怎么算现在写在定义里,要调整就是改一份定义文件,不必回到代码那一侧。每有一件事从程序搬进定义,动得了它的人就往需求端靠一步:原本得排进开发排期的东西,变成懂业务的人看得懂、也改得动的一行算式。

所以「哪些该写程序」这条线不是一开始画定的,它会随框架的声明能力往前推。刚才那四件里,前三件迟早会被收走,剩下的只有最后那一件。

留在代码里的东西,一部分本来就该在那里,一部分只是还没被接管。混在一起看不出差别,分开看才知道下一步该收哪一个。


六、框架管标准流程,应用只接手它管不到的

框架管的是每张表单都一样的事:查清单、开一笔、添加、修改、删除、开窗挑选、保存前验证、把数据送到前端。这些事的形状不随表单改变,会变的只是字段有哪些,而字段就写在定义里。控管也一样:谁能访问、哪些数据看得到、每次变更是谁在什么时候改了什么。

所以那七张表单,应用一行代码都没写。

讲到这里通常会冒出一个疑虑,而且是该冒出来的:流程都标准化了,那弹性呢?

这一题比「框架能做多少」重要得多。订标准不难,难的是订完之后还留不留得住例外。留不住,第一次碰到特殊需求就得整条流程自己刻,前面省的全部还回去。案例里碰到这件事的就是订单那一张。

接手有两种方式:

方式 机制 性质
继承后改写 框架的 BO 把整条流程切成一连串可重写的步骤(取一笔新数据、保存之前、保存本身、保存之后,删除同样切法),应用只改需要的那几步。订单只动了两步 取代,一支程序一个
挂插件 框架在保存与删除的前后开了四个时点让外部逻辑挂进去 叠加,可叠多个,定制那条链接在套装那条后面,不动原程序

两种方式的共同点才是对那个疑虑的回答:接手的是流程里的一小段,不是整条流程。重写「保存之前」不会让你连 SQL 怎么组、数据怎么送到前端都得自己来。

弹性不在于框架什么都做得到,而在于你只需要处理跟机制做法不同、或机制还没支持的那一部分。标准流程与例外能不能共存,看的就是这个颗粒度:切得太粗,例外一出现就得整条重写;切得够细,标准的部分就继续替你做。

回到 Day 1 那句话

Day 1 说:整套 Northwind 做完,八张表单里只有一张需要自己写程序。那一张就是订单,而它需要接手不是因为比较复杂,是因为它碰到了框架现行机制管不到的那几件。案例需要有这样一张表单,否则「大部分不用写程序」这句话等于没被验证过。

而处理它们,重写框架切出来的那两步就够了,不必离开那条标准流程。这个空间是必要的,机制不能为了把流程标准化就把弹性收掉。ERP 里的特例有很多不是需求写坏了,是那家公司的组织与作业惯例本来就长那样,不会因为框架订了标准就消失(框架把接手的深度切成几段,Day 30 会整个摊开)。

这就是 Definition-Driven 想达到的状态:不是不用写程序,是只在真正需要判断的地方写,而且写的时候只接手那一小段。

明天开始进定义层,先把十三种定义文件逐一交代清楚。


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

iThome 原文 | HackMD 版

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

文章摘自:https://www.cnblogs.com/jeff-tsai/p/23479685/day-03-northwind-case