「支持 SQL Server、PostgreSQL、MySQL、Oracle、SQLite」写在规格书上是一行字,写在框架里是一个必须先回答的问题:到底要替它们抽象什么。
两个方向都走不通:
- 想把各家能力都包起来 → 没有尽头,每包一项就等于替五家各补一次,补到最后每项都是半成品
- 只共用一个连接对象 → 那不叫支持多数据库,那叫每张表单的语句要自己写五份
这套框架的答案很窄,窄到只有两件事。
本篇说明:
- 为什么共同行为只有这两件
- CRUD:差异小到可以用查表吸收
- 结构升级:差异大到每一家真的各写一份
- 用哪一家由应用登记,框架一家都不带
一、为什么共同行为只有这两件
一套框架该支持几家数据库没有通用的答案,它取决于卖的是什么。只支持一家换到的是深度(那一家的原生类型、批量接口、方言特性全都能直接用)。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 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计