Day 5 划过定义驱动那条路的边界:跨表关联一律单字段等值、接的一定是物理数据表。报表、统计、批量导入超出这个范围,SQL 就自己写,任何框架都得留这条路。问题出在交界处:「这一段我不做」跟「这一段你全部自己来」是两回事,而开一个可以塞任意 SQL 字符串的入口,等于把它们写成同一件事。代价不会当场出现,它出现在第二个数据库、第一段自己拼的 WHERE、第一句插在保存中途的查询。
框架在这个交界处放的是 DbAccess,应用要自己下语句时的唯一入口:一句 SQL、几个要传的值,没有连接对象、没有要记得关的东西、没有一句字符串串接。它省掉的是你不必知道的事,省不掉的有三件,本篇逐一说明:
- 连到哪一个数据库
- 查询条件不是一段字符串,是一棵树
- Transaction 由谁开,由谁关
这三件的共同性质:它们横切在每一句 SQL 上面,答案却不在那句 SQL 里。
一、连到哪一个数据库
痛点:写一句查询,第一行代码就得回答「连到哪里」。单一数据库没有这个问题,但一套系统同时给好几家公司用的时候,数据就分散在好几个数据库上,而其中一个还会随着现在是哪一家公司而变。把答案写在那句查询旁边,等于把部署的形状写进业务代码里。
框架要的答案是一个数据库代号。部署那一侧有一份清单,一个代号对到一组连接信息,这一段在案例的 DatabaseSettings.xml 里:
xml
<DatabaseItem Id="company"
DatabaseType="SQLite" ConnectionString="Data Source=northwind.db;Cache=Shared" />
清单长什么样是部署环境的事实,跟应用怎么写没有关系:FormSchema 里没有出现过任何一个数据库,业务代码里也没有出现过任何一个代号。
谁拥有这张表,跟它住在哪里是两件事
这套框架的数据表有两种前缀:框架自己的表是 st_,应用的业务表是 ft_。
前缀回答的是谁拥有这张表,数据库分类回答的是它住在哪里,两个问题的答案推导不出彼此。
决定性的例子在框架自己的定义里:内置的部门、员工、角色、角色授权、用户角色绑定,每一张都是框架前缀,却都落在公司的数据库,因为一家公司的员工就是那家公司的数据。前缀没有因此失效,它回答的是升版时框架动哪一半,只是回答不了位置。把「谁拥有」跟「放在哪里」绑成同一件事,上面那几张表就对不上了。
三类数据库各放什么
框架把数据库分成三类:
| 分类 | 放的是什么 |
|---|---|
common |
用户、session、公司清单、定义保存、跨节点的缓存通知 |
company |
一家公司自己的数据:业务表,加上部门、员工、角色那几张 |
log |
操作、登录与变更的记录 |
分成几类,跟实际上有几个数据库是两回事。三类可以共用一个数据库,也可以各自独立;company 在多公司部署下是一家一个,log 还可以再依年份切开,当年可写、往年只读。这些全写在 DatabaseItem 那一份清单里,一份 FormSchema 都不必跟着改。
路由发生在哪里
框架把它声明在类型上。Repository 在构造时就交代自己读写哪一类:框架的系统类直接写定 common 或 log,绑表单的那个由 FormSchema 自己声明,两者都在那一刻解析成一个物理数据库代号。
common 与 log 这两类不必问 session,代号是固定的,只有 company 要先知道现在进到哪一家。这个不对称是刻意的:登录本身发生在还没有 session 的时候,而登录成功与失败都要留下记录。
服务两家公司时,company 这一类就是两笔:
xml
<DatabaseItem Id="company001" CategoryId="company"
DatabaseType="SQLServer" ConnectionString="Server=db01;Database=northwind001" />
<DatabaseItem Id="company002" CategoryId="company"
DatabaseType="SQLServer" ConnectionString="Server=db01;Database=northwind002" />
进到第一家,Order 那份 FormSchema 声明的分类是 company,Repository 解出来的代号就是 company001;换第二家进来,同一份代码、同一份定义,解出来的是 company002。中间那一跳由公司自己的数据带着:st_company 上有一列记着这家公司用哪一个代号。
两个决定各挡掉一种麻烦:
- 声明在类型上 → 看这个类型就知道它动哪一个数据库,不必去翻它每个方法。
- 构造时就解析 → 路由错了,在碰到任何一笔数据之前就失败。session 过期、还没进任何公司、公司数据查不到,三种都在对象建出来的那一刻丢出来。
应用那一侧看到的就是这两行的差别:
csharp
CreateDbAccess() // 代号由框架解出来的
CreateDbAccess("company001") // 你自己回答「连哪里」
第二行框架也提供,因为总有应用真的知道自己要连哪一个。第一行没有任何地方可以填错,第二行有。
为什么是代号,不是连接字符串
上面两行的共同点容易滑过去:它们拿的都是数据库代号,不是连接字符串。整个框架的数据访问,没有一个入口是收连接字符串的。两个后果:
- 第一个前面已经讲完了:代号在构造的当下就解得出来,连接字符串只能由调用端自己捧着。
- 第二个在连接池上。ADO.NET 的连接池以连接字符串为键分开,两条字符串只要差一个字(多一个空白、参数顺序不同、有没有写某个选项)就是两个连接池,各自算各自的上限。所以「每个调用端自己组连接字符串」的代价是连接池会依调用端的手气裂成好几份,而症状出现在数据库那一侧:连接数莫名偏高,应用这边却觉得自己没开几条。
框架把它收成一份:连接信息(数据库种类、驱动工厂、连接字符串)依代号缓存,同一个代号永远是同一条字符串、也就永远是同一个连接池;设置改了就整批清掉重读。
框架自己不做连接池。真正的连接池是各家驱动的,框架只做两件事:不制造多余的连接字符串、开了的连接要还回去。第二件在两侧长得不一样:
csharp
// 开发者这一侧:看得到的只有这一行
var status = CreateDbAccess().ExecuteScalar("SELECT status FROM ft_order WHERE sys_rowid = {0}", rowId);
// 框架那一侧:每次运行开一条,离开这个范围就还回连接池
using var scope = CreateScope();
每次运行都是「开一条、用完还回去」,不是抓着一条连接不放。单机开发完全感觉不到,几十个并行请求进来的时候,连接池才轮得过来。
二、查询条件不是一段字符串,是一棵树
痛点:查询界面八个字段,用户只填两个,你要组出只含那两个条件的 WHERE。写法通常是字符串串接加一串条件判断。串接是注入的来源,而注入真正的麻烦不在它难修,在于每加一个查询界面就有机会重新犯一次。
框架把查询条件做成一个模型,而且这个模型住在定义层。位置比内容重要:定义层是其他每一层都认得的那一层,所以用户填的条件、API 契约传的条件、BO 收到的条件、Repository 拿到的条件、五家数据库各自组语句时读的条件,从头到尾是同一个类型,中间一次转换都没有。
模型本身很小,只有两种东西:
csharp
var filter = FilterGroup.All(
FilterCondition.Equal("status", "Confirmed"),
FilterCondition.Between("order_date", from, to),
FilterGroup.Any(
FilterCondition.Equal("ref_customer_id", customerId),
FilterCondition.In("employee_rowid", myTeam)));
- 单一条件:字段名、比较方式、一个值(范围比较另外带第二个值)。比较方式是封闭清单:等于、不等于、四种大小比较、相似比对、属于集合、介于两值之间、开头是、结尾是、包含。
- 群组:
All与Any,而群组本身也可以是另一个群组的成员。
做成树不是为了好看:条件真正的形状本来就是树,拉平成字符串之后就只剩一段文字,而每一个下游都得自己再解析一次才拿得回结构。
一棵树上的节点也不一定来自同一个地方:用户填的是一批,权限那一层算出的数据范围是另一批,两批接在同一棵树上;跨越前后端边界时还有一层会逐个节点走过去,把时间值换算过来。同一个模型有好几个彼此不相干的生产者与消费者,而它们之间不需要任何转接。
值走参数,字段名走定义
这棵树翻成 SQL 的时候,值跟字段名走的是两条不同的路:
- 值一律变成参数:每遇到一个值就交给参数收集器,换回一个参数名字放进语句,值本身永远不会出现在 SQL 文字中。
- 字段名不能这样处理(数据库不接受用参数当字段名),所以走另一条:先对照定义换成那张表的哪一列,再套上那一家的标识符引号,名字里原本就有的引号字符也在这一步一并转义。删除那条路径更严格,名字不在该表字段清单里直接拒绝,指向别张表、或指向不是物理列的显示列,同样拒绝。
参数化挡得住值,挡不住字段名。
一个只做了参数化、字段名照样串进去的实现,从外面看已经像是处理过了。
不让调用端把 SQL 送进来,跟不让调用端把算式送进来是同一条线:两者都等于在服务器上开一个运行入口。所以调用端能表达的东西是一棵树,不是一段字符串。
应用自己下语句那一侧也一样。值的位置一律用占位符标出来:
csharp
CreateDbAccess().ExecuteScalar(
"SELECT MAX(sys_id) FROM ft_order WHERE sys_id LIKE {0}", "ORD-202608-%");
送出去的那一刻,{0} 被换成一个参数名:SQL Server 上是 @p0,Oracle 上是 :p0,差的就是昨天那张对照表的参数前缀那一列。ORD-202608-% 整串挂在那个参数上。
这个接口收的是值,不是拼好的字符串:要把值串进语句文字里,你得先绕过这个方法。Day 5 那句 UPDATE 变更整行所有字段,不是只挑改动过的那几个,跟这里是同一个形状:用设计让一整类写法不成立,比用规范要求大家别那样写可靠得多。
三、Transaction 由谁开,由谁关
痛点:一张单据存下去不是一句 SQL。主表一句,明细有几行就有几句,这些句子要么一起算数、要么一起不算。不在同一个 transaction 里,主表写进去了而其中一句失败,留下的就是一张明细不齐的单。这种幽灵数据不会有任何提示,要等下次打开它才发现,而数据库也不会替你挡(Day 3 讲过框架不建外键约束)。这不是特例,是每一次保存。
框架驱动的保存自己开一条 transaction:主表先、明细后,全部成功才提交,任何一句失败就整批回滚。删除反过来,明细先,理由相同,不留下对不起来的另一半。应用看不到那一条,也不必管。
需要自己回答的只有手写那一段,框架给两个入口。多句一起下的时候装成一批,transaction 一样由框架开与关:
csharp
var batch = new DbBatchSpec { UseTransaction = true };
batch.Commands.Add(new DbCommandSpec(DbCommandKind.NonQuery,
"UPDATE ft_product SET units_in_stock = units_in_stock - {0} WHERE sys_rowid = {1}", qty, productRowId));
batch.Commands.Add(/* 这一批的其余语句 */);
CreateDbAccess().ExecuteBatch(batch);
已经有一条在跑的时候(例如保存中途要再写一笔什么),把那条 transaction 交进去,语句就跑在它上面:
csharp
CreateDbAccess().Execute(spec, transaction);
让第二个入口成立的,是第一节末尾「开了的连接要还回去」背后那条完整的规则:
连接如果是框架自己开的,用完就关;如果是外面交给它的,只负责在必要时打开,用完不关,因为它不拥有那条连接。
前半句管的是连接还不还得回连接池,后半句管的是手写语句能不能并进既有那一条。同一条规则同时回答了这两个问题,而它们平常看起来毫不相干。
这里只讲机制,不讲语义。扩展点该摆在 transaction 的哪一边、摆错会有什么后果、要让所有扩展点都在里面又要付什么代价,是 Day 14 整篇的题目。
回到 Northwind
八张表单里只有订单需要应用接手,而数据访问那一侧只有两句 SQL,都在 OrderRepository 里。前面两节各用了其中一句;读数据库里的订单状态那一句,同时带着第一节的代号与第二节的占位符。
这一整个类里:
- 没有连接字符串、没有数据库代号、没有连接对象
- 没有一个
try/finally - 没有一句字符串串接
有的只有一句 SQL、一个 {0} 和一个要传的值。
一句 SQL,加一个代号
应用最后写下去的就是一句 SQL 加几个要传的值。连哪一个数据库由一个代号回答,而代号在 Repository 被建出来的那一刻就解完了;代号后面那组连接信息由框架收成一份,同一个代号永远对到同一个连接池;查询条件是一棵树,值走参数;transaction 由框架开与关,要并进去的时候把手上那一条交给它。
三件事都还在,只是不在你写的那一行里,而它们本来就不该在那里。连哪一个数据库是部署形状的事,条件长什么样是前后端契约的事,跑在哪一条 transaction 里是调用上下文的事,没有一件属于那一句 SQL。写查询的人只回答跟他的查询有关的问题,其余的每一次都会拿到同一个答案。
明天换一个方向:那上千份定义本身,每次请求都重读一次的话,前面十天省下来的成本会在这里全部还回去。
本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计