ORM架构不是一张图,是一条路。 你只有走在路上,才知道下一步该怎么走。
ORM架构不是一张图,是一条路。你只有走在路上,才知道下一步该怎么走。
——从2004年的一条SQL,到今天的IEntity契约与异步门面
作者:长江支流 日期:2026-08-08
关键字:架构、ORM、IEntity、重构、跨语言
关注本博客,开源轻量架构与ORM、即将上传到csdn代码仓https://gitcode.com/ZeroORM和同名Github,敬请关注!
一、2004年:重复劳动催生第一个接口
2004年,.NET Framework 1.1,没有泛型,没有依赖注入。
我每天做的事很简单:写SQL,拼SQL,再写SQL。INSERT、UPDATE、DELETE、SELECT,每个表都要写一遍。表多了,代码就多了;字段多了,拼的字符串就长了。
后来我发现,不管操作哪个表,无非就是四样东西:表名、主键、字段列表、字段值。
我写了一个简单的抽象类,让每个实体自己告诉我这四样东西。当时没有泛型,我就用IList;没有依赖注入,我就用抽象方法让子类自己提供IExeSql执行器——这就是今天依赖注入的前身。
publicclassEntityTest:WebMIS.Data.EntityAccess.DBEntity{privateint_ID=-1;privatestring_Name="test";publicintID{get{return_ID;}set{_ID=value;}}publicstringName{get{return_Name;}set{_Name=value;}}publicEntityTest():base("TableNameOfEntityTest","ID"){}publicoverrideIListGetFields(){returnnewstring[]{"ID","Name"};}publicoverrideIListGetFieldValues(){returnnewobject[]{_ID,_Name};}publicoverrideIListGetPrimaryKeyValues(){returnnewstring[]{"ID"};}// ★ 当时直接依赖 DataRowpublicoverridevoidLoadFrom(System.Data.DataRowentityDataRow){_ID=int.Parse(entityDataRow["ID"].ToString());_Name=entityDataRow["Name"].ToString();}}配套一个EntityManager来管理实体的所有操作,执行增删改查。这段代码现在看很原始,但当时它解决了问题:写一次实体,所有CRUD操作自动完成。
二、2007年:被两个问题逼着往前走
2007年,我遇到了两个新问题:
第一个问题:加字段就要重新编译。
客户说“加一个字段”。你要改实体类、改GetFields()、改LoadFrom(),然后重新编译、重新部署。有没有办法不改代码、不重新编译就能升级?
第二个问题:100个表写100个实体类?
如果每个表都要写一个实体类,每个类都要实现GetFields()、GetFieldValues()、LoadFrom(),那这个框架就回到了原来的老路。能不能让程序自动生成实体?
当时我给出的答案是:用XML描述实体,运行时解析。结构改变,只需要手动或自动更新XML,不需要重新编译。
这个想法后来变成了XmlMapEntity——一个由XML配置驱动的动态实体。它不是一开始就设计好的,是被问题“逼”出来的。
我在2007年的博客里写了三篇文章:
- 《架构是什么?架构就是实践》
- 《架构是什么?架构就是总结》
- 《架构是什么?架构就是创新》
这三篇文章的核心观点是:架构是实践,架构是总结,架构是创新。
但当时我写这些文章的时候,其实只做了一件事——把实际遇到的问题记下来,然后想办法解决。
三、关键转折:从DataRow隔离到跨语言
早期版本中,LoadFrom直接依赖DataRow:
publicoverridevoidLoadFrom(DataRowentityDataRow){_ID=int.Parse(entityDataRow["ID"].ToString());_Name=entityDataRow["Name"].ToString();}后来我发现,DataRow是ADO.NET的一部分,而ADO.NET在不同版本中行为不完全一致。更麻烦的是,如果将来跨语言(Java、ArkTS),根本没有DataRow这个东西。一旦依赖DataRow,这条跨语言的路就彻底堵死了。
我加了一层隔离:
publicinterfaceIEntityFieldProvider{objectGetValue(stringfieldName);}LoadFrom不再依赖DataRow,只依赖这个接口:
publicoverridevoidLoadFrom(IEntityFieldProvidersource){_ID=Convert.ToInt32(source.GetValue("ID"));_Name=source.GetValue("Name")?.ToString();}底层不管是DataReader、DataRow、ResultSet、JsonObject还是其他什么,只要实现了IEntityFieldProvider,就能填充实体。
这就是“依赖隔离”的思路:框架只依赖接口,不依赖具体实现。这个原则后来贯穿了整个用宝框架的设计——IDataAccessExecutor隔离数据库操作、IEntityParser隔离配置解析、IOrmProvider隔离具体ORM引擎。每一个接口的存在,都是为了防止“换一个环境就带不过去”。
四、弃用重资产、拥抱轻量化的设计原则
正因为当年面对DataRow依赖的困境,我逐步建立了一条明确的设计原则:
| 层级 | 弃用的重资产 | 替换方案 | 设计原则 |
|---|---|---|---|
| 数据填充 | DataRow | IEntityFieldProvider | 框架只依赖接口,不依赖具体实现 |
| 数据库操作 | SqlCommand/DbCommand | IDataAccessExecutor | 隔离数据库差异,跨库不跨逻辑 |
| 实体映射 | EF/DataSet | IMapEntity<TKey> | 实体自描述,不依赖任何ORM框架 |
| 配置驱动 | 硬编码实体类 | XmlMapEntity+ 解析器 | 配置驱动,不改代码,不重新编译 |
这不是技术偏好,而是生存策略——用宝框架的代码要能在.NET、Java、ArkTS三个环境里跑,必须保持接口抽象,必须放弃任何“某个平台独有”的东西。EF很好,但它只属于.NET;DataRow很方便,但它带不到Java;DataSet很强大,但它跨不了语言。我们不是在做“又一个ORM框架”,而是在构建一个“能跨语言迁移的轻量化数据访问基座”。
五、今天:从接口到契约,从同步到异步
从2004年到现在,二十多年过去了。
当初那个没有泛型的IEntityMap接口,今天变成了支持复合主键的IEntity<TKey>,只保留Id属性,是最底层的实体契约:
publicinterfaceIEntity<TKey>{TKeyId{get;set;}}当初那个手动实现GetFields()的实体基类,今天演变成了三个并存的实现方式:
| 实体类 | 元数据来源 | 适用场景 |
|---|---|---|
AttributeMapEntity | 特性反射([Table]/[Column]) | 静态实体,编译时确定 |
ManualMapEntity | 构造函数手动传入 | 手写映射,灵活可控 |
XmlMapEntity | 解析器运行时注入 | 动态实体,配置驱动 |
当初那个用IList凑合用的接口,今天变成了跨语言通用的IMapEntity<TKey>:
publicinterfaceIMapEntity<TKey>:IEntity<TKey>{stringTableName{get;}string[]PrimaryKeys{get;}string[]GetFields();object[]GetFieldValues();object[]GetPrimaryKeyValues();voidLoadFrom(IEntityFieldProvidersource);}当初那个用抽象方法让子类提供IExeSql的注入方式,今天变成了标准的依赖注入,并通过门面模式对外只暴露IEntity接口:
publicinterfaceIOrmProvider<TKey>{voidSetEntity<T>(Tentity)whereT:IEntity<TKey>;TKeyInsert();intUpdate();boolDelete();IEntity<TKey>GetById(TKeyid);IDataList<IEntity<TKey>>GetList(IQueryParametersquery);intGetTotal(IQueryParametersquery);}而为了应对高并发场景,我们又增加了异步接口,与同步接口并行存在:
publicinterfaceIOrmAsyncProvider<TKey>{TaskSetEntityAsync<T>(Tentity)whereT:IEntity<TKey>;Task<TKey>InsertAsync();Task<int>UpdateAsync();Task<bool>DeleteAsync();Task<IEntity<TKey>>GetByIdAsync(TKeyid);Task<IDataList<IEntity<TKey>>>GetListAsync(IQueryParametersquery);Task<int>GetTotalAsync(IQueryParametersquery);}同步接口保持跨语言通用(C#/Java/ArkTS),异步接口作为C#专属高并发优化。两者互不干扰,相辅相成。
六、架构演进的核心思想:从继承到接口,从接口到契约
纵观这二十多年的演进,核心就一句话:
接口解决的是“谁来定义”的问题,契约解决的是“为什么能替换”的问题。
| 阶段 | 形式 | 解决的问题 |
|---|---|---|
| 抽象类 | 实体继承基类,提供元数据 | 让实体自己描述自己 |
| 接口 | 实体实现接口,框架依赖接口 | 让实体和框架解耦 |
| 契约 | 接口跨语言统一,三端一致 | 让实体能在不同平台间迁移 |
因为底层只依赖IEntity,所以上层的所有功能——ORM提供者、万能控制器、门面封装、跨语言迁移——都可以围绕这个契约自由生长,而不受具体实体实现的限制。
七、实体访问与ORM提供者
IOrmProvider 将底层复杂的操作封装,让使用者更方便。IMapEntityAccess 已经能完成所有 CRUD 操作,但它执行具体类型 TEntity,IOrmProvider 在它之上包了一层,把所有复杂细节藏起来,只留给调用方一个简单的接口——设置实体,然后操作。这就是门面设计模式的价值。
区别对比
| 维度 | IMapEntityAccess<TEntity> | IOrmProvider<TKey> |
|---|---|---|
| 层级 | ORM 层(数据访问层) | 门面层(对外接口层) |
| 状态管理 | 有状态,增删改依赖Entity属性 | 有状态,增删改依赖SetEntity设置的实体 |
| 实体设置方式 | Entity属性直接赋值 | SetEntity<T>(T entity)方法 |
| 查询方法 | DoRead/GetTotalCount需显式传参 | GetList/GetTotal基于已设置的实体 |
| 面向对象 | 面向具体实体类型TEntity | 面向接口IEntity<TKey> |
| 返回类型 | 具体类型(TEntity、IDataList<TEntity>) | 接口类型(IEntity<TKey>、IDataList<IEntity<TKey>>) |
| 使用场景 | Access 层内部使用 | 对外暴露给 Controller,隐藏内部实现 |
关系总结
// IMapEntityAccess:增删改依赖 Entity 属性,查询显式传参publicinterfaceIMapEntityAccess<TEntity>{TEntityEntity{get;set;}// ← 设置实体intInsert();// ← 操作 EntityintUpdate();// ← 操作 EntityintDelete();// ← 操作 EntityboolFillByPK();// ← 操作 EntityIDataList<TEntity>DoRead(IQueryParametersquery,TEntityentity);// 显式传参intGetTotalCount(IQueryParametersquery,TEntityentity);// 显式传参}// IOrmProvider:所有操作都基于 SetEntity 设置的实体publicinterfaceIOrmProvider<TKey>{voidSetEntity<T>(Tentity)whereT:IEntity<TKey>;// 设置实体TKeyInsert();// ← 基于已设置的实体intUpdate();// ← 基于已设置的实体boolDelete();// ← 基于已设置的实体IEntity<TKey>GetById(TKeyid);// ← 基于已设置的实体IDataList<IEntity<TKey>>GetList(IQueryParametersquery);// ← 基于已设置的实体intGetTotal(IQueryParametersquery);// ← 基于已设置的实体}本质区别
IMapEntityAccess和IOrmProvider都是有状态的,区别在于:
| 对比项 | IMapEntityAccess | IOrmProvider |
|---|---|---|
| 实体设置方式 | 属性赋值Entity = entity | 方法调用SetEntity(entity) |
| 查询参数 | 查询需显式传entity,因为同一个 Access 实例可能被复用查不同实体 | 查询基于已设置的实体,因为 Provider 设计为“设置一次,复用多次” |
| 对外暴露 | TEntity具体类型 | IEntity<TKey>接口 |
一句话总结
IMapEntityAccess是“会记住当前实体的执行器”,IOrmProvider是“会记住当前实体的门面”。区别在于:一个暴露具体类型,一个暴露接口;一个内部用,一个对外用。✅
八、给后来者的一句话
不要迷信于别人的架构,它有参考价值,但决不会是一成不变的。
任何一个架构,在一定条件下可能是优秀的架构,而在另一个条件和应用中,可能它就是个有缺陷的架构。
架构不是一张图,是一条路。你只有走在路上,才知道下一步该怎么走。
如果你现在写的代码让你觉得“重复”“繁琐”“改起来很麻烦”,那说明你正在经历架构演进的第一步——实践。
接下来你要做的,就是总结和创新。
架构是实践,架构是总结,架构是创新。
附录:架构演进时间线
| 时间 | 里程碑 | 说明 |
|---|---|---|
| 2004年 | IEntityMap+EntityManager | .NET 1.1,无泛型,无DI,靠抽象方法注入执行器 |
| 2007年 | XML配置驱动思想 | 不编译程序,改XML升级 |
| 2007年 | IEntityFieldProvider | 隔离DataRow依赖,为跨语言铺路 |
| 2010年代 | IEntity<TKey>+IMapEntity<TKey> | 泛型支持,复合主键 |
| 现在 | IOrmProvider<TKey>门面 | 对外只暴露IEntity,隐藏内部实现 |
| 现在 | IOrmAsyncProvider<TKey> | 同步+异步双接口,覆盖高并发 |
附录:有人问实体访问和ORM提供者好像基本相类似为何不统一
这个问题问到根子上了。这两个接口有重叠但不完全对应,是因为它们的职责不同。
直接回答
| 对比项 | IMapEntityAccess<TEntity> | IOrmProvider<TKey> |
|---|---|---|
| 定位 | ORM 数据访问层(执行者) | 业务门面层(封装者) |
| 返回 Insert | int(影响行数) | TKey(插入后的主键) |
| 返回 Update | int(影响行数) | int(影响行数) |
| 返回 Delete | int(影响行数) | bool(是否成功) |
| 查询方法 | DoRead显式传参 | GetList基于已设置的实体 |
| 获取总数 | GetTotalCount显式传参 | GetTotal基于已设置的实体 |
| 单条查询 | FillByPK(填充当前实体) | GetById(返回新实体) |
为什么不统一?
因为使用场景不一样。
IMapEntityAccess是数据访问层,它关心的是“数据库操作的结果”——插入了多少行、删除是否影响到了数据。所以它返回int是直白的。IOrmProvider是业务门面层,它关心的是“业务操作的结果”——插入后新记录的主键是什么、删除是否成功。业务层通常不需要知道影响行数,只需要知道“成没成”。
举个例子:
// Access 层:我需要知道插入了多少行introws=access.Insert();// 1 表示成功,0 表示失败// Provider 层:我需要知道新记录的 IDstringid=provider.Insert();// "PROD-001",业务层需要这个 ID 做后续操作Update两者都返回int,是因为更新操作确实有“0 行更新”的情况——记录不存在或没有变化,这在业务层是有用的。
Delete在 Access 层返回int,在 Provider 层返回bool,是因为业务层通常只关心“删除成功与否”,不需要知道影响行数。
一句话总结
IMapEntityAccess告诉调用者“操作执行得怎么样”,IOrmProvider告诉调用者“操作产生了什么结果”。一个面向执行细节,一个面向业务结果。
