Day 10:多数据库支持:CRUD 与结构升级

「支持 SQL Server、PostgreSQL、MySQL、Oracle、SQLite」写在规格书上是一行字,写在框架里是一个必须先回答的问题:到底要替它们抽象什么。

两个方向都走不通:

  • 想把各家能力都包起来 → 没有尽头,每包一项就等于替五家各补一次,补到最后每项都是半成品
  • 只共用一个连接对象 → 那不叫支持多数据库,那叫每张表单的语句要自己写五份

这套框架的答案很窄,窄到只有两件事。

本篇说明:

  1. 为什么共同行为只有这两件
  2. CRUD:差异小到可以用查表吸收
  3. 结构升级:差异大到每一家真的各写一份
  4. 用哪一家由应用登记,框架一家都不带

一、为什么共同行为只有这两件

一套框架该支持几家数据库没有通用的答案,它取决于卖的是什么。只支持一家换到的是深度(那一家的原生类型、批量接口、方言特性全都能直接用)。Day 6 说过,对套装软件来说支持清单短就是少掉一整批客户,多家支持因此是入场条件。

这一层要对的是五种常用的关系数据库,不是所有能存数据的东西。这五家的数据模型是同一套(有表、有列、有行,都讲 SQL,都有 transaction),正因为底下那一层共通,「CRUD」与「让结构跟上定义」在五家上才指得到同一件事。

判准两个,要同时成立:

  • 这件事是不是每一个应用都会做
  • 它会不会一直做

拿这两把尺量下去,剩下的东西不多:

收进来 为什么
对一张表 CRUD 没有一个应用不做,每次用户按保存就做一次
让结构跟上定义 没有一个应用能躲掉,每次部署要面对一次

没收进来的:分区表、物化视图、全文检索、各家自己的分析函数、查询提示、批量导入的专用接口。每一项都很有价值,但共有一个性质:不是每个应用都需要,而需要的那些,通常也只用在少数几个位置。

拿批量导入当例子。多数数据库都有比逐笔写入快很多的专用机制,但形状没有一个对得起来(有的给你一个专属类,有的是一句特别的语句,有的要先落成文件再让数据库自己读)。要替它们设计一个共同接口,做得出来的只有最小交集,而最小交集通常等于「跟逐笔写入差不多」。包了等于没包,却多出五份要维护的实现。

这两把尺量的是「该不该由这一层做」,不是「重不重要」。分区表对一张上亿笔的单据表可能是生死问题,它只是不该由一个要同时对五家成立的接口来决定怎么做。

量完之后剩下的就是全部:框架整个对外的抽象表面就是一个接口,问一家数据库的事情只有两类(怎么替一张表生成 CRUD 语句、怎么让结构跟上定义)。第二类拆成四小件:读出现况、建新表、就地改、改不动就重建。

这条线画得窄,换到的是它画得完。一个接口、两类问题,要接一家新的数据库,看得完也写得完。如果那个接口有三十个成员,这件事在实务上就不会发生。

不过这两件工作虽然收在同一个接口里,抽象的厚度差得很远。


二、CRUD:差异小到可以用查表吸收

痛点:同一句查询在五家上长得不一样,但不一样的地方非常无聊(表名列名用什么符号包、参数怎么写、分页怎么下)。无聊,但每一句都得对,错一个就是运行时语法错误,而且是那种在测试环境用另一家数据库时完全不会出现的错误。

这些差异的性质是好的:它们是同一件事的不同写法,不是不同的事。

之所以收成一张对照表,是因为不收的代价是分散的。组语句的地方不只一处(捞清单、读单笔、算笔数、增修删各一段),每一段都自己判断现在是哪一家,就是每一段都有机会漏掉一处,而漏掉那一处只有在客户刚好用那一家数据库时才会现形。

数据库 标识符引号 参数前缀
SQL Server [sys_id] @
PostgreSQL "sys_id" @
MySQL sys_id @
Oracle "SYS_ID" :
SQLite "sys_id" @

Oracle 那一行多一件事:没加引号的标识符在 Oracle 会被折成大写,所以框架送出去的一律是加引号的大写;读回来时框架在边界上折回小写,让上面每一层看到的列名在五家上完全一样。昨天说名字全程只有一组,这是那条规则在最底层的收尾动作。

还有一处查表解不掉,因为形状真的不同:

数据库 取第 21 到 40 笔 跳过前 20 笔,之后全要
SQL Server、Oracle OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY OFFSET 20 ROWS
PostgreSQL、SQLite LIMIT 20 OFFSET 20 OFFSET 20
MySQL LIMIT 20 OFFSET 20 LIMIT 18446744073709551615 OFFSET 20

MySQL 只有在取一段区间的时候跟中间那行一样。只给起点、不给笔数时,它不接受单独出现的 OFFSET,得补一个上限值上去凑,而那串数字是它文档里指定的哨兵值,不是随手打的。

结论在五份实现的形状:框架里对应五家的那五个类内容几乎一模一样,每一个都只是把查询与删除转交给同一段共用程序,顺便告诉它「我是哪一家」。保存要用的那三句更彻底,连五份都没有,是同一个类拿着一个枚举值组出来的。

那个类是 TableSchemaCommandBuilder,构造时收下「哪一家」跟那张表的结构,三句由它组,再跟 DataSet 里的那张表包成一份交下去:

csharp 复制代码
var insertCmd = BuildInsertCommand();
var updateCmd = BuildUpdateCommand();
var deleteCmd = BuildDeleteCommand();

return new DataTableUpdateSpec()
{
    DataTable = dataTable,
    InsertCommand = insertCmd,
    UpdateCommand = updateCmd,
    DeleteCommand = deleteCmd
};

Day 9 那句「照每一行的状态决定下哪一句语句」,兑现的地方就是这里。而整个类里「现在是哪一家」只从两个地方进来:标识符怎么加引号、参数名加什么前缀,正是上面第一张表的那两列;三句语句怎么组,一个分支都没有。

差异全部沉在下面那一层:能查表的查表,剩下那一处由共用程序收掉。

这一半的抽象非常薄,而薄是好事:真要再接一家,这一半几乎不必写。


三、结构升级:差异大到每一家真的各写一份

同一句「把这一列从 20 个字改成 50 个字」,各家的差别不在写法,在做不做得到。这已经不是同一件事的不同写法,是能力本身不一样,而能力不一样的东西吸收不掉。

差异一:读出现况

五家没有一家一样:

数据库 从哪里读
SQL Server sys.* 那一组系统视图
PostgreSQL information_schema 混用 pg_catalog
MySQL INFORMATION_SCHEMA
Oracle USER_* 数据字典
SQLite sqlite_master 再配几个 PRAGMA

拆不掉的原因不在方言,在每一家怎么看待自己的结构:SQL Server 把字段默认值当成独立对象,跟字段分开存、分开命名,要读就得多接一张系统视图;SQLite 走另一个极端,它不强制类型,当初写下的 VARCHAR(50) 原样存着,框架要知道能放多长,就得把那个字符串重新拆开来读。

读回来还有一步,那一步比查询本身难。

Day 6 那条「定义里没有任何一家数据库的类型名」的代价落在这里:建表时要把框架类型翻成原生类型,比对就得把读回来的原生类型翻回去,而这个翻译是多对一的。以 MySQL 为例,Guid 存成长度 36 的定长字符列,而定长字符列本来就是字符串的一种写法;自动编号与长整数同样落在 BIGINT 上。反向翻的时候,数据库回报的只有一个 char 加一个长度、只有一个 bigint,语义在正向那一步就已经被丢掉了。

所以反向那一半不是把对照表倒过来查,是靠额外线索把语义认回来:

  • 长度刚好 36 的定长字符列 → Guid
  • 精度 19 位、小数 4 位的数值列 → 金额
  • BIGINT 再看一次身上有没有自动递增的标志

这些线索成立的前提是框架只会发出那几种写法,所以框架自己建的表一定认得回来。代价也在这个前提上:接管一张不是框架建的表,这些推断会失真,症状是比对每次都看到一个并不存在的差异。

正向的对应一张表就讲得完,反向那一半不行;一个抽象只要在中间那一步丢掉了信息,回头走就没有免费的路。

差异二:改得动改不动

Day 6 那条规则(同家族就地改、跨家族重建)在 SQLite 上几乎失效,因为它的 ALTER TABLE 动不了既有字段。于是同一个变更,在别的数据库上是几秒钟的事,在 SQLite 上是把整张表的数据拷贝一次。

框架在这里没有选择抹平,它做的是把这个差异算出来给人看(Day 6 那个干跑)。同一份定义接到不同数据库上,得到的计划可以是不同的,而要不要排维护窗口本来就该由那份计划回答。抹平反而糟糕,因为被抹掉的正是用户最需要知道的那件事。

差异三:最难发现,因为它不报错

MySQL 从 8.0.13 起规定:默认值只要不是字面值,就必须写成括号包起来的表达式。所以框架要让一个字段自动生成 Guid,送出去的是 DEFAULT (UUID())。写进去没问题。

但下一次比对时,INFORMATION_SCHEMA 把它报回来的样子是 uuid():括号不见了,函数名还变成小写。

于是照字面比对:定义说 (UUID())、数据库说 uuid(),两边不一样 → 认定有变更 → 多一道 ALTER → 运行 → 再比一次,数据库还是报 uuid() → 还是一道 ALTER。

一个永远不会收敛的升级。它不会失败、不会有错误消息,只会在每次启动时安静地多下一道语句,改一个根本没有变过的字段。

框架的解法是比对之前把两边规范化到同一形状(剥掉一层外括号、大小写不计)再比。SQLite 有同一类问题(它把建表语句原样存着、读回来也是原样),同样需要一段自己的规范化。

还有一类:字段说明

定义里每个字段都有一句给人看的说明,要存进数据库,五家给了四种答案:

数据库 怎么存字段说明
MySQL 直接写在字段定义后面
PostgreSQL、Oracle 另一句 COMMENT ON
SQL Server 挂在一组叫扩展属性的东西上
SQLite 不保存,写进去是空操作、读回来一律是空

框架对最后那一家的做法是明讲它做不到、把说明留在定义那一侧。假装存进去了才是灾难,因为读回来永远对不上,就是上面那个不收敛的循环再来一次。

这几种差异都是同一个抽象往下走时分岔出来的,而上千份定义没有一份需要知道这件事,代价是有人得替它们知道。

那个人就是这一层。读得回来、写得出去只是入门,难的是「写出去的东西读回来,还认得出它是同一个」。


四、用哪一家由应用登记,框架一家都不带

痛点:你的系统只用一家数据库,但框架宣称支持五家。于是输出目录躺着五家的驱动程序,容器镜像大一圈;某天其中一家发了安全更新,你明明一行都没用到,漏洞扫描报告照样列出来。更难处理的是版本:框架把五家驱动包进自己的依赖,等于替你把五家的版本都钉住了。

一个依赖只要它自己没有在用,就不该由它替所有用户做这个决定。

所以框架一家都不内置。应用要支持哪一家,启动时登记哪一家就好,案例的那一行出自 NorthwindBackend.cs:

csharp 复制代码
DbProviderRegistry.Register(DatabaseType.SQLite, new SqliteProviderFactory(SqliteFactory.Instance));

登记进去的是 ADO.NET 那一层的工厂,各家自己出的包。同一个文件还有一行,登记的是框架替它生成语句与读出结构的那一套,也就是前面两节整个接口;要接一家框架没内置的数据库,缺的就是那一份。

框架自己从来不登记任何一家,这句话直接写在源代码注释里。

登记发生在代码里,不在配置文件里。配置文件上写的是数据库类别(一个枚举值),不是类型名字。若让配置文件指名类型,框架就得在运行时照名字去找程序集,那等于把驱动程序的依赖从依赖清单搬进字符串,构建时反而看不到它。

这条纪律在源代码上验得到:整个框架没有任何一个项目引用那五家驱动的任何一个包。

SQLite 上为此多做了一件事:它的驱动没有提供框架写回要用的 DataAdapter,框架自己补了一个,包住抽象的工厂类型、其余成员原样转交,实例由应用在登记时交进来。需要那一家的东西,也不去引用那一家的包。

案例只用 SQLite,另外四家的包连引用都没有。选它的理由与框架设计无关:不必安装任何数据库,一个文件就跑得起来,拿来示范最省事。


抽象的厚度

五家数据库,两件事。收进来的共同点是每一个应用都要做,而且会一直做;没收进来的不是比较不重要,是它们只在特定的少数位置才需要。

两件事被抽象之后的样子差得很远:CRUD 那一半薄到五份实现几乎一样,因为各家差异是同一件事的不同写法;结构升级那一半厚到每一家各写一份,因为各家差异是能力本身不同。

抽象的厚度不是设计者决定的,是底下那些东西差多少决定的。

这一层的回报要时间才看得到。第一次交付时它是纯成本(多一层接口、多两行接线、多五份实现),回本要等到第二家客户指定了另一家数据库,而你发现上千份定义一个字都不必动。

多支持一家数据库,真正付的不是那几百行实现,是那些没有一份文档会事先告诉你的落差,而它有多少,要踩过才知道。

明天谈框架决定不自己做的那一段:SQL 交回给应用自己写的时候,框架没有一起交出去的是哪几件事。


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

iThome 原文 | HackMD 版

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

相关推荐
1-1=01 小时前
C# 特性(Attribute)
c#·.net·.netcore
IT规划师2 小时前
Redis 数据类型全解 - Bitmap 生产实战:把位视图讲准、用对、守住红线(六)
redis·.net
晶捷软件20 小时前
离散制造 ERP 选型的技术视角:从 BOM 数据结构到 ERP-MES 数据流
制造·智能制造·erp·制造业信息化
Flynn Liang1 天前
Spring AI 2.0 深度解析:ChatClient、Advisor 链与 MCP——Java 工程师的 AI 落地最优解
java·人工智能·spring·大模型·架构设计·spring ai
1-1=01 天前
C# 反射
c#·.net·.netcore
一叶星殇1 天前
版本即契约:.NET 6 WebAPI 多版本治理与 Swagger 文档分治实践
mvc·.net
Jeff-Tsai1 天前
Day 9:数据与定义分家,表单不必各自建类型
.net·架构设计·erp·definition-driven
步步为营DotNet1 天前
.NET 可观测性选型:Metrics、ActivitySource、EventSource 到底该用哪个
c#·.net·metrics·可观测性
CSharp精选营1 天前
我把 Ant Design Blazor 翻了一遍:Blazor 中后台组件库到底能不能用
.net·blazor·ant design blazor·中后台系统·mudblazor