三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

ORM架构不是一张图,是一条路。 你只有走在路上,才知道下一步该怎么走。

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();}

底层不管是DataReaderDataRowResultSetJsonObject还是其他什么,只要实现了IEntityFieldProvider,就能填充实体。

这就是“依赖隔离”的思路:框架只依赖接口,不依赖具体实现。这个原则后来贯穿了整个用宝框架的设计——IDataAccessExecutor隔离数据库操作、IEntityParser隔离配置解析、IOrmProvider隔离具体ORM引擎。每一个接口的存在,都是为了防止“换一个环境就带不过去”。

四、弃用重资产、拥抱轻量化的设计原则

正因为当年面对DataRow依赖的困境,我逐步建立了一条明确的设计原则:

层级弃用的重资产替换方案设计原则
数据填充DataRowIEntityFieldProvider框架只依赖接口,不依赖具体实现
数据库操作SqlCommand/DbCommandIDataAccessExecutor隔离数据库差异,跨库不跨逻辑
实体映射EF/DataSetIMapEntity<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>
返回类型具体类型(TEntityIDataList<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);// ← 基于已设置的实体}

本质区别

IMapEntityAccessIOrmProvider都是有状态的,区别在于:

对比项IMapEntityAccessIOrmProvider
实体设置方式属性赋值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 数据访问层(执行者)业务门面层(封装者)
返回 Insertint(影响行数)TKey(插入后的主键)
返回 Updateint(影响行数)int(影响行数)
返回 Deleteint(影响行数)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告诉调用者“操作产生了什么结果”。一个面向执行细节,一个面向业务结果。

← 返回列表