Day 9:数据与定义分家,表单不必各自建类型

一次请求走前端 → API → Business Object → Repository → 数据库,每一层都要拿到同一笔数据。多数系统在这里放的是自己写的类型:一张表一个 Entity、前后端各一份 DTO、中间再一层 Mapper 设置。

这套框架放的是两样东西:一个数据容器(DataSet),加上一份跟它平行的定义。数据在容器里,描述数据的那些话在 FormSchema 里。分家换到一件事:八张表单也好、上千张也好,中间没有多出任何一个类型。

本篇说明:

  1. 为什么是数据容器,不是强类型
  2. 不写 DTO,是把数据与定义拆开
  3. 跨层传的是数据,加上它的描述
  4. 编译期验不了的,靠什么验(两站)
  5. 付这些代价,是为了上千张表单

Day 1 数过「订单加一个预计到货日」落到十三个地方,其中四格是 Entity、DTO、Mapper 与前端类型。这四格为什么可以是空的,就是这五节合起来的答案。


一、为什么是数据容器,不是强类型

痛点:用户要求「订单多一个预计到货日」,没有计算、没有联动。你打开 Entity 加一个属性、打开 DTO 加一个、打开 Mapper 加一条、打开前端类型再加一个。四个文件,一次编译,一次部署。

这个字段在业务上什么事都没做,但它在类型系统里留下了四份签名。

再乘以一千张表,乘以每年几十次这种需求。

跨层要不要用强类型容器长年有两派各自的理由,这里不判高下。强类型换到的东西很明确(编译器会替你看名字),而那正是这里要付的代价。这一节只回答:这套框架在这个位置需要的是什么样的东西。

答案是一个在编译的时候还不知道自己有几个字段的容器,理由两个:

  • 形状什么时候才确定:一张表单有哪些字段写在定义里,而定义是数据,运行时才被读进来。编译那一刻,「订单有哪些字段」这个事实并不存在。而且它还会继续变,这份数据本来就是为了让顾问、工具甚至用户都改得动。
  • 类型得在哪一刻存在:Day 2 分过一条线,构建期产出代码、运行时产出对象。跨层容器若是强类型,那份类型就得在构建期存在,于是每次改定义都要重新生成、编译、部署。前面那些衍生做得再干净,只要最后一公里需要一个编译期的类型,那条交付路径就整条长回来了。

Day 1 说「改定义到生效没有编译打包部署」,这句话的成败有一半押在这一层上。


二、不写 DTO,是把数据与定义拆开

第二个痛点没那么刺眼,但更花时间。

一个字段真正要交代的事远不只「叫什么、什么类型」:必不必填?清单上显不显示?宽度多少?是不是金额、留几位小数?指向客户那张表单,选完要把哪几个值回填到哪几个字段?开窗挑选时要不要只列启用中的?

这些话在一份 DTO 里没有现成的位置。类型能表达的就是属性名与属性类型,其余全部得靠一叠标注往上堆,而读这些话的又不是同一层。于是它们散开:

这件事 散到哪里
必填规则 验证层
清单字段 前端设置
关联 数据库的外键
显示格式 某个共用的工具方法

散开的代价不是文件变多,是没有一处是权威。同一件事在好几个地方各自声明一次,任何两处都可能对不起来,而对不起来的时候没有人会报错。

框架的做法是把两件事拆开:数据走容器,描述数据的话走定义。

拆开之后描述就不必再挤进类型系统。Day 2 把一个字段能声明的东西分成六类(结构、行为、呈现、语义、关联、安全),而一个属性本身写得出来的只有第一类的头两样:字段名与数据类型。

这六类里,关联要多说几句。Day 3 讲过关联声明在表单之间,指向的是「客户」这个程序。一个类型可以持有另一个类型的引用,但那个引用只说得出「我指向它」,说不出清单显示哪几列、开窗给人看什么、哪些字段可查询、要不要再加条件。表单之间的关系比类型之间的关系宽,硬用类型表达,多出来的那几件事就会被挤到类型以外的地方去。

拆开还有一个回报:

DTO 上的描述只有编译器读得懂;抽离出来的描述,谁都读得懂。


三、跨层传的是数据,加上它的描述

「不写 DTO」不代表把数据丢成一包没有形状的东西。形状还在,只是它不是类型,而是跟着数据一起走的一份描述。

送出去的时候,一张表格带的是:

  • 表格自己的名字
  • 一份字段清单
  • 每一行的值

收到的人不需要事先认识它。因为描述跟着数据一起到,所以没有「类型对不上」这种事;也因为每一层拿到的是同一个东西,所以从数据库读出来到送进前端控件,没有一次「把 A 转成 B」。少掉的不只是那几行 Mapper 设置,是那几行会出的所有错。

容器里可以不只一张表格,而 ERP 几乎每一张单据都需要这件事:

  • 订单那个容器装两张表,主表一张、明细一张,明细靠 sys_master_rowid 挂回主表
  • 打开一张订单,主表与明细一起回来;按下保存,一起送出去
  • 要送几张表格、哪一张是主表、明细靠哪一列挂回去,都是 FormSchema 已经讲过的事

每一行身上还带着两样东西:它是添加 / 改过 / 删掉 / 没动过,以及它原本的值。这让保存那一趟不必附带任何说明。用户改了主表两个字段、删掉一行明细、又加了一行,送出去的就是这个容器本身,服务器照每一行的状态决定下哪一句语句。

代价 Day 2 已经记在适用边界:每次响应都带着容器里那份字段清单一起送。一张订单值得,每秒上万笔的事件流不值得。


四、编译期验不了的,靠什么验

数据与定义拆开之后,两边的接点是字段名称,而字段名称是字符串。

row["amout"] 少打一个字母,编译器一声不吭,要到那一行真的被运行才丢出异常。更麻烦的是失败的形状不只一种:清单字段打错一个字,界面上只是少一列,同一个名字送进查询却会在组 SQL 时直接失败;开窗挑选的字段打错则是从头到尾安静,那一列不会出现,日志也是干净的。

同一个名字在不同层写法不一致会更难查,而有些前端控件的数据绑定是区分大小写的。容器里的列名因此一律小写,跟定义文件、数据库字段、算式标识符、前端绑定键对齐,名字全程只有一组,大小写差异这一类错误就没有发生的余地。

编译期那道关卡补不回来,也不必假装补得回来。想在编译期检查一个运行时才决定的形状,这个要求本身就是矛盾的。

能做的是把发现错误的时机往前挪,挪到形状已经确定的那一刻。定义写完,形状就确定了。往前挪有两站。

第一站:构建期

框架把这些检查做在 Bee.Analyzers 这个项目里,包进定义层的包一起发布,引用就生效,不必额外安装。其中大半读的不是代码,是定义文件本身那些 XML。

十九条分四组:

  1. 单一份定义自己有没有矛盾
  2. 好几份定义之间对不对得起来
  3. 代码有没有守住框架惯例
  4. 参与传输的类型形状对不对

下面两个是实际跑出来的。把订单清单字段里的 ref_customer_name 打成 ref_customer_nmae:

复制代码
error BEE1004: FormSchema 'Order' lists 'ref_customer_nmae' in ListFields,
but the schema declares no such field. The list layout drops the unknown
column, but the query keeps it: building the SELECT throws
InvalidOperationException at run time. Fix: correct the name, or declare
the field.

消息有三部分:哪里错了、不修的话运行时会怎么失败、以及修法。中间那句最该有,因为运行时会坏成什么样子,决定的正是这个错误好不好查。

第二个对应 Day 6 那个代价(FormSchema 加了字段、TableSchema 忘了补):

复制代码
error BEE2006: FormSchema 'Order' declares persisted field 'required_date'
on table 'ft_order', but the table schema has no such column. Queries
touching that field fail at run time. Fix: add the column to the table
schema, or mark the field Type="VirtualField" if it is not persisted.

两次构建都停在定义文件那一行上。Day 1 那个「加一个字段要改三处」漏掉其中一处的下场,现在是一次构建失败,而不是一张上线后的问题单。

第二站:集成测试

第一站拦的是定义自己写错。拦不到的是「定义都对,但这张单跑出来的结果不对」,那要靠数据。做法:

  1. 由定义加上既有数据,导出各类数据的 Excel 导入格式。这张表要有哪几列、每列什么类型、哪些是关联带进来的、哪些是算出来不必填的,定义里本来就有答案。
  2. 在 Excel 里把测试数据整理完整,一类数据一个文件。这一步是关键:整理的人不必是写程序的人。一张订单要涵盖几种客户、几种折扣组合、哪些边界值非试不可,这些是懂业务的人最清楚,而 Excel 是他们本来就在用的东西。
  3. 测试依序导入,基本数据先、单据后,顺序就是实际操作的顺序。数据喂给 API 或直接喂给 BO,两个入口收到的容器是同一个形状。
  4. 最后取几张关键报表,把结果转成 Excel,跟一份已知正确的 Excel 逐格对。

前面三步只证明数据进得去,最后那一步才问结果对不对。头尾各钉一次,中间那一整段都在被验,而不必为其中每一步各写一个断言。

这一套写得成一个工具,是因为描述被抽离出来了。它是一份程序读得到的数据,向 API 要一次就拿得到,所以工具不必先认得任何一张表单,加表单、改字段它也不必跟着动。改版之后整批重跑一次,就知道有没有坏。

这条路刻意跳过前端,理由不是省事。界面上的控件是框架自己的一组,字段用哪个控件是照定义算出来的,同一种类型的字段在每张表单上长出来的是同一个控件、走同一段程序。

于是错误的分布形状变了:控件某个行为坏掉,坏的是全面,不是某一张表单。

Day 7 说渲染层是一个收敛点,手写界面一次错一张、渲染层一次错全部。那句话当时讲的是风险,在测试上是另一面:一次错全部的东西藏不住,随便开一张表单就撞得到。逐张点界面,点到的是同一段程序的第 N 次运行。


五、付这些代价,是为了上千张表单

一套完整的套装 ERP 业务数据表动辄数千张,表与表高度牵连。在这个量级上「每张表单配一组类型」不是麻烦,是管不动;Definition-Driven 要付的那些代价,也只有在这个量级上才值得付。

第一个回报是加一张表单不必写程序:三份新文件加三行登记就长得出来。案例八张表单有七张是这样来的,数据一路从数据库走到界面,中间经过的层一个不少,而应用一行都没写;剩下的那一张是订单,它多写了几条定义表达不了的规则。

第二个回报是成本从乘法变成加法。加一个字段,FormSchema 加一行、TableSchema 加一行、FormLayout 加一行,然后数据库多一列、CRUD 语句多一列、容器多一列、送到前端的字段清单多一列、界面多一个控件。这条路上没有一个中间类型。Day 1 那十三格里的四格,在这个模式下不是比较好改,是根本不存在。

加一条关联、加一个前端也是加法。类型的世界里每多一条关联就要决定它怎么表达、什么时候加载、谁维护;这里只是在定义上多声明一段,带出来的显示列在容器里多几个列。Day 7 说多支持一个端要付一个渲染层,而那个端不必自备一份类型,因为它收到的数据自己带着描述。


分家的三件事,与那笔账

数据与定义分家,表单不必各自建类型,这句话真正说的不是「省掉几个文件」。它是三件事的总和:

  • 数据容器的形状由定义决定,所以它不需要在编译期就存在
  • 描述跟着数据一起走,所以中间每一层都不必再认识它一次
  • 描述被抽离之后,那几类话不再只有编译器读得懂

三件缺一件,分家就分不成。

分家是有代价的。编译器少替你看了一类东西,这笔账没有消失,只是挪到前面那两站去付:能从定义本身看出来的错,构建的时候就拦下来;拦不住的,用同一份定义把数据准备好跑一遍,最后拿报表逐格对。

一个设计诚不诚实,看的不是它有没有代价,是它知不知道自己的代价落在哪里。

明天谈这些数据最后怎么进到数据库:一套框架怎么在不预先认识任何一家数据库驱动程序的前提下,还替每一家生成得出 CRUD 语句,也改得动它们的结构。


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

iThome 原文 | HackMD 版

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

相关推荐
步步为营DotNet1 小时前
.NET 可观测性选型:Metrics、ActivitySource、EventSource 到底该用哪个
c#·.net·metrics·可观测性
CSharp精选营2 小时前
我把 Ant Design Blazor 翻了一遍:Blazor 中后台组件库到底能不能用
.net·blazor·ant design blazor·中后台系统·mudblazor
IT规划师2 小时前
Redis 数据类型全解 - ZSet:两个编码,七种玩法(五)
redis·.net
rockey62714 小时前
AScript之字符串模板
c#·.net·script·expression·动态脚本
玩泥巴的1 天前
源生成与 AOT 就绪的微信生态 .NET SDK:结构、能力边界与 API 定位方法
ai·微信·.net·agent·aot·roslyn
1-1=01 天前
C# 泛型
c#·.net·.netcore
有志冬瓜1 天前
.NET Framework全部版本离线下载安装教程
安全·.net·软件需求
IT规划师1 天前
Redis 数据类型全解 - Set 的三个真相:去重、标签、共同好友(四)
redis·.net