【开源】OSharp框架解说系列(5.1):EntityFramework数据层设计
OSharp是什么?
OSharp是个快速开发框架,但不是一个大而全的包罗万象的框架,严格的说,OSharp中什么都没有实现。与其他大而全的框架最大的不同点,就是OSharp只做抽象封装,不做实现。依赖注入、ORM、对象映射、日志、缓存等等功能,都只定义了一套最基础最通用的抽象封装,提供了一套统一的API、约定与规则,并定义了部分执行流程,主要是让项目在一定的规范下进行开发。所有的功能实现端,都是通过现有的成熟的第三方组件来实现的,除了EntityFramework之外,所有的第三方实现都可以轻松的替换成另一种第三方实现,OSharp框架正是要起隔离作用,保证这种变更不会对业务代码造成影响,使用统一的API来进行业务实现,解除与第三方实现的耦合,保持业务代码的规范与稳定。
本文已同步到系列目录:OSharp快速开发框架解说系列
前言
数据层设计真是一个百说不厌的话题,大系统说并发量,说高性能;小系统追求开发效率,易维护性各有各的追求。
OSharp 开发框架的定位是中小系统, 数据层的开发效率与易用性的权重就比较高了,所以,使用ORM当然是首选。在 .net 环境下,有众多的闭源的开源的优秀的ORM组件,从各方便对比来看,EntityFramework 是不二之选。一提起 EntityFramework,不少同学又要蠢蠢欲动来吐槽其性能了。其实,经过几个版本的更新换代,现在的稳定版 EntityFramework 6 已经相当好用了,nuget 上截止到目前 “8,830,918 total downloads” 已经足够能说明问题了,EntityFramework 在整个 .net 世界是相当受欢迎的。不过,不管哪个技术平台,能不能用好一个技术与技术水平有很大的关系,如果没追求,随处的 select * from xxx,传说中高性能的 ado.net 也不会高到哪去。
为什么强依赖于EntityFramework
在本系列开篇《总体设计》对 OSharp 开发框架的数据存储组件的介绍时,就强调了 OSharp 将“强依赖”于 EntityFramework 这个数据访问组件。当时就有人提出了“强依赖Entity framework是个坑吧”的疑问。这里简要解释一下为什么 OSharp 不打算做成兼容各种数据访问方案(EF,NH,ado.net 等等)。我们来看看统一各个ORM需要付出的代价:
- 需要统一标准,业务层与数据层之间的数据交互载体只能是POCO数据实体,如果个别ORM需要对实体有特殊要求(如NH要求实体属性为virtual),需要在ORM实现内部进行适配转换。
- 数据交互只能是实体,这就导致了参数数据查询的所有 sql 语句都是“select * from ...”这种方式,这将给系统性能带来伤害。
- 要兼容各个ORM,数据层只能提供一些能普遍适应的 API,放弃各种 ORM 的优点与特色(如EF的linq查询)
基于上面的一些理由,我们发现要兼容各种数据访问方案,需要付出的代码是很大的,很不划算。因此,OSharp 将专注于一款 ORM,从各方面比较,EntityFramework 是一个好选择,理由如下:
- EntityFramework 是微软大力发展的一个开源项目,EF 6 在 codeplex.com 开源,EF 7 在 github.com 随 ASP.NET 5 开源。
- EntityFramework 能轻松支持各大主流数据库,只要引入相应数据库的 DataProvider 即可,能无差异的操作各大主流数据库。
- EntityFramework 支持 linq to entities 语句查询,强类型支持,高效实现查询需求。
- EntityFramework 全面封装了数据库细节,使用了大量的“约定胜于配置”的思想,使开发者不必直接对关系存储架构编程,减少代码量,减轻维护工作,并使项目可维护性更高。
为什么要封装EntityFramework
在计划使用EntityFramework来在项目中实现数据存储时,遇到的第一个问题就是:怎样来使用EntityFramework?要不要对EntityFramework进行二次封装?
反对对 EntityFramework 进行封装的同学,通常会有如下理由:
- EntityFramework 提供的API已经足够简单了,已经有了非常良好的易用性,没有再封装的必要。
- EntityFramework 内部已经实现了一个 UnitOfWork + Repository 的封装,没有必要再包装一次。
- EntityFramework 提供的API非常灵活且很有特色,封装有可能会丧失其灵活性
我认为,如果是小项目,且所有开发成员都能很好的使用 EntityFramework,在业务层中直接使用,将 EntityFramework 的灵活性发挥出来,也是非常好的。但是,直接使用 EntityFramework 的话,也有不少弊端,对于 OSharp 这样一个开发框架而言,封装就显得非常有必要了,原因如下:
- 不是所有的开发人员都对 EntityFramework 足够熟悉,封装之后能将 EntityFramework 的细节及较敏感的 API 进行包装与隐藏,使 EntityFramework 的使用更加透明易用。
- 统一的封装,有利于对业务层提供统一的 API,对业务层的代码规范非常有利。
- 封装有利于业务实体与 EntityFramework 的解耦。如果不封装,所有业务实体模型(Model)都要在上下文类中设置一个 DbSet<TEntity> 类型的实体集,将与上下文强耦合,当需求发生变化时,都要对原有代码进行修改,很不利于维护。而封装之后,所有的实体模型都是动态加载到上下文类中的,业务实体与 EntityFramework 能够完全解耦,大大增强系统的可维护性。
EntityFramework封装的常见误区
在 EntityFramework 的发展过程中,很多使用者都在抱怨 EntityFramework 性能低下,其实很多时候都是因为对 EntityFramework 没有足够的了解,走进了误区所致。那么,EntityFramework 会有哪些误区呢?这里我列几个我所了解的“坑”,欢迎补充。
错误使用返回类型,不了解 IEnumerable<T> 与 IQueryable<T> 的区别
在设计数据访问层的查询API的时候,IEnumerable<T> 和 IQueryable<T> 都可以作为集合类查询结果的返回类型,那么,这两者有什么区别呢?为什么误用的时候会造成致命的性能问题呢?
IEnumerable<T> 接口的声明为:
1 /// <summary> 2 /// 公开枚举数,该枚举数支持在指定类型的集合上进行简单迭代。 3 /// </summary> 4 public interface IEnumerable<out T> : IEnumerable
IQueryable<T> 接口的声明为:
1 /// <summary> 2 /// 提供对数据类型已知的特定数据源的查询进行计算的功能。 3 /// </summary> 4 public interface IQueryable<out T> : IEnumerable<T>, IQueryable, IEnumerable
在进行查询的时候,IEnumerable<T> 接口接受一个 Func<T, bool> 类型的委托参数: public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate); ,而 IQueryable<T> 接口接受一个 Expression<Func<T, bool>> 类型的表达式参数: public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, bool>> predicate); 。
正因为 IEnumerable<T> 接受的参数 predicate 数据类型是委托类型,所以这个参数在被调用的时候,就会立即执行查询逻辑,然后将查询的结果保存在内存中,后续的查询逻辑是完全在内存中执行的。而 IQueryable<T> 接受的参数 predicate 数据类型是表达式类型,这个参数会一直往下传递,直到被 IQueryable 中的 IQueryableProvider 类型的 Provider 属性解析成真正的查询语句(如 sql 语句),才传到数据源中进行查询动作。
所以,如果查询返回的数据集合很大的时候,使用 IEnumerable<T> 作为返回类型,会将这个数据集合立即加载到内存中,比如在设计 IRepository<T> 的 API 时,设计 IEnumerable<T> GetAll(); , IEnumerable<T> GetByPredicate(Func<T, bool> predicate); 这种 API ,是非常可怕的,如果一个表中有几十上百万的数据,也同样会把所有数据加载到内存中,可能直接就导致服务器宕机了。即使数据量不大,当并发量上来的时候,也同样会造成极大的性能问题。
过早地内存化数据
IEnumerable<T> 类型与 IQueryable<T> 类型是支持延迟的,没有真正使用数据之前,不管怎么调用,都不会执行查询,数据还是在数据库内,只有真正使用数据的时候,才会执行查询,把数据本地化到内存中。这样一来,什么时候执行本地化操作(ToArray(),ToList()等操作)就显得非常重要了,如果过早的执行本地化操作,那么就容易造成加载到内存的数据集合过于庞大,记录条数过多,造成性能问题。因此,在进行数据查询的时候,原则上应该按需获取数据,取出的数据集合就尽量的小,字段应尽量少,到数据真正使用的时候,才执行数据内存本地化操作。对于筛选部分字段的需求,linq to entities 的 select 查询匿名结果的查询方式,提供了有力的支持。
导航属性的延迟加载与循环
EntityFramework 实体模型的导航属性(即与当前表有外键关系的关联表)通常标记为 virtual,标记为 virtual 之后,相应属性的数据是具有延迟加载的特性的,只有真正用到相应属性的数据时,才会根据外键关系执行相应的查询动作,加载相应的数据。延迟加载的特性,能给系统性能带来优化,因为加载主干实体时只加载主干实体的信息,不会把关联实体的信息都加载进来,关联实体的数据只有用到的时候都会去加载。但也正是因为延迟加载,导航属性的数据是用到一次就执行一次查询动作,加载一次数据,一次还如,如果对于相同实体,需要多次用到同一个导航属性,就会产生多次重复的查询动作来加载导航属性的数据,给系统带来性能问题。例如如下的操作:
正确的做法,当需要在循环中使用导航属性时,应在循环之前加载主干实体数据时,把导航属性的数据使用 Include 查询一起加载:
数据查询方式
EntityFramework 给我们提供了直接使用 实体对象 的众多查询 API,如果我们在实现业务的时候,使用直接操作 实体对象 的方式,同样也会给系统造成性能问题。因为 EntityFramework 每执行一次查询,都会将实体的所有字段取出来,等同于每次都执行“ select * from xxx ” 的查询语句。更可怕的是导航属性的使用,因为导航属性的数据类型通常都定义为 ICollection<T> 或者 ICollection<T> 的派生类型,ICollection<T> 类型是什么?内存集合类型!也就是说,当我们直接调用 entity.NavigationProperties 这样一个导航属性的时候,会将 NavigationProperty 这个关联表的“所有数据”都加载到内存中。想想,如果这个关联表的数据几十上百万的话,那将是多么可怕的性能灾难!!
那么,怎样编写数据查询的代码呢,个人认为应该分析场景:
- 如果查询出来的数据需要进行更新,删除等操作,就使用实体查询的方式。
- 如果查询出来的数据不需要再存回到数据库中,只是为了业务判断使用,则是按需要使用 Select 进行匿名对象查询的方式来只查询出必要的实体属性。
PS:关于 EntityFramework 数据查询的更多内容,欢迎阅读 架构系列的《数据查询》篇。
怎样设计 EntityFramework 数据层
前面说了那么多误区,那怎样来设计 EntityFramework 的数据访问层 API呢?这个话题在前面的 架构系列 中也讨论过了,但为了全面了解 OSharp 开发框架,这里不免要添点新料再炒次旧饭。
业务实体模型基类 EntityBase
为了能在底层对所有的实体模型类进行统一管理,并规范实体类必要的属性设定,定义了一个如下的 实体模型基类 EntityBase<TKey>。为适应不同主键数据类型的需求,定义了一个泛型 Id 类型,在各个实际实体模型中可以设置实体的主键数据类型。
1 /// <summary> 2 /// 可持久化到数据库的数据模型基类 3 /// </summary> 4 /// <typeparam name="TKey"></typeparam> 5 public abstract class EntityBase<TKey> 6 { 7 protected EntityBase() 8 { 9 IsDeleted = false; 10 CreatedTime = DateTime.Now; 11 } 12 13 #region 属性 14 15 /// <summary> 16 /// 获取或设置 实体唯一标识,主键 17 /// </summary> 18 [Key] 19 public TKey Id { get; set; } 20 21 /// <summary> 22 /// 获取或设置 是否删除,逻辑上的删除,非物品删除 23 /// </summary> 24 public bool IsDeleted { get; set; } 25 26 /// <summary> 27 /// 获取或设置 创建时间 28 /// </summary> 29 public DateTime CreatedTime { get; set; } 30 31 /// <summary> 32 /// 获取或设置 版本控制标识,用于处理并发 33 /// </summary> 34 [ConcurrencyCheck] 35 [Timestamp] 36 public byte[] Timestamp { get; set; } 37 38 #endregion 39 40 #region 方法 41 42 /// <summary> 43 /// 判断两个实体是否是同一数据记录的实体 44 /// </summary> 45 /// <param name="obj">要比较的实体信息</param> 46 /// <returns></returns> 47 public override bool Equals(object obj) 48 { 49 if (obj == null) 50 { 51 return false; 52 } 53 EntityBase<TKey> entity = obj as EntityBase<TKey>; 54 if (entity == null) 55 { 56 return false; 57 } 58 return Id.Equals(entity.Id) && CreatedTime.Equals(entity.CreatedTime); 59 } 60 61 /// <summary> 62 /// 用作特定类型的哈希函数。 63 /// </summary> 64 /// <returns> 65 /// 当前 <see cref="T:System.Object"/> 的哈希代码。 66 /// </returns> 67 public override int GetHashCode() 68 { 69 return Id.GetHashCode() ^ CreatedTime.GetHashCode(); 70 } 71 72 #endregion 73 }
业务单元操作接口 IUnitOfWork
相比 架构系列 的 数据存储上下文管理,OSharp 的存储上下文进行了简化,OSharp 中的 IUnitOfWork 接口将直接作为 上下文类(DbContext的派生类)的一个接口而存在。其中定义了一个默认关闭的 TransactionEnabled 属性开关对“是否开启事务提交”进行管理。在默认的状态下,事务操作是关闭的(与 EntityFramework 的默认开启相反),调用 IRepository 对实体进行 增、改、删 操作,立即向数据库提交操作,这在进行单步操作的时候,很方便,不用每次都去调用一次 SaveChanges() 操作进行提交。在需要进行事务操作(同时提交多步操作,成功一起成功,失败一起失败)时,则需要将 TransactionEnabled 设置为 true,当调用 IRepository 对实体进行 增、改、删 操作时,会申请更改,但不会向数据库提交操作,需要在最后手动去调用 UnitOfWork.SaveChanges() 操作进行提交。
1 /// <summary> 2 /// 业务单元操作接口 3 /// </summary> 4 public interface IUnitOfWork : IDependency 5 { 6 #region 属性 7 8 /// <summary> 9 /// 获取或设置 是否开启事务提交 10 /// </summary> 11 bool TransactionEnabled { get; set; } 12 13 #endregion 14 15 #region 方法 16 17 /// <summary> 18 /// 提交当前单元操作的更改。 19 /// </summary> 20 /// <returns>操作影响的行数</returns> 21 int SaveChanges(); 22 23 #if NET45 24 25 /// <summary> 26 /// 异步提交当前单元操作的更改。 27 /// </summary> 28 /// <returns>操作影响的行数</returns> 29 Task<int> SaveChangesAsync(); 30 31 #endif 32 33 #endregion 34 }
实体仓储数据操作接口 IRepository
实体仓储数据操作接口 IRepository 的 API,是整个数据层设计的核心。IRepository 接口被定义为“实体类型、主键类型”的双泛型接口,实体类型限定为前面定义的实体基类 EntityBase<TKey> 的派生类,声明如下:
1 interface IRepository<TEntity, TKey> : IDependency where TEntity : EntityBase<TKey>
IRepository 中定义了两个属性:
- IUnitOfWork 类型的单元操作对象 UnitOfWork,此接口主要用于业务层执行事务操作。
- IQueryable<TEntity> 类型的 Entities,作为向业务层开放的 TEntity 实体类型的 查询数据源。OSharp 的大部分数据查询,均通过定义 IQueryable<T> 类型的扩展方法来完成。定义为 IQueryable<TEntity> 类型,即能保证 EntityFramework 组件原有的查询自由度,又能阻止业务层使用 此数据源 进行 增、改、删 等业务操作,必须调用 IRepository 中规定的 API 才能完成实体的业务操作。
在操作 API 上,IRepository 接口主要定义了如下几种 API:
普通 增、改、删 业务操作 API
普通业务操作API主要是对单个或多个实体进行的单个或批量操作API:
1 /// <summary> 2 /// 插入实体 3 /// </summary> 4 /// <param name="entity">实体对象</param> 5 /// <returns>操作影响的行数</returns> 6 int Insert(TEntity entity); 7 8 /// <summary> 9 /// 批量插入实体 10 /// </summary> 11 /// <param name="entities">实体对象集合</param> 12 /// <returns>操作影响的行数</returns> 13 int Insert(IEnumerable<TEntity> entities); 14 15 /// <summary> 16 /// 更新实体对象 17 /// </summary> 18 /// <param name="entity">更新后的实体对象</param> 19 /// <returns>操作影响的行数</returns> 20 int Update(TEntity entity); 21 22 /// <summary> 23 /// 删除实体 24 /// </summary> 25 /// <param name="entity">实体对象</param> 26 /// <returns>操作影响的行数</returns> 27 int Delete(TEntity entity); 28 29 /// <summary> 30 /// 删除指定编号的实体 31 /// </summary> 32 /// <param name="key">实体编号</param> 33 /// <returns>操作影响的行数</returns> 34 int Delete(TKey key); 35 36 /// <summary> 37 /// 删除所有符合特定条件的实体 38 /// </summary> 39 /// <param name="predicate">查询条件谓语表达式</param> 40 /// <returns>操作影响的行数</returns> 41 int Delete(Expression<Func<TEntity, bool>> predicate); 42 43 /// <summary> 44 /// 批量删除删除实体 45 /// </summary> 46 /// <param name="entities">实体对象集合</param> 47 /// <returns>操作影响的行数</returns> 48 int Delete(IEnumerable<TEntity> entities);
针对 DTO 的 增、改、删 业务操作API
在业务层实现对实体的增加,更新操作的时候,如果业务层接收的是 Dto 数据,需要对 Dto 的数据进行合法性检查,再将 Dto 通过 数据映射组件 AutoMapper 创建或更新相应类型的实体数据模型 Model,然后再按需求对 Model 的导航属性进行更新,再提交保存。在进行删除操作的时候,需要使用传入的主键 Id 检索相应的实体信息,并检查删除操作的可行性,再提交到上下文中进行删除操作,并删除其他相关数据。在这些针对实体的业务操作中,存在着很多相似的重复代码,这种重复代码的存在,会极大降低系统的可维护性。因此,在 数据仓储操作 中设计了一组专门针对 Dto 的业务操作API,利用 无返回委托 Action<T> 与 有返回委托 Func<T, RT> 来向底层传递 各实体业务操作的变化点的业务逻辑,以达到对 Dto 业务重复代码的彻底重构。
/// <summary> /// 以DTO为载体批量插入实体 /// </summary> /// <typeparam name="TAddDto">添加DTO类型</typeparam> /// <param name="dtos">添加DTO信息集合</param> /// <param name="checkAction">添加信息合法性检查委托</param> /// <param name="updateFunc">由DTO到实体的转换委托</param> /// <returns>业务操作结果</returns> OperationResult Insert<TAddDto>(ICollection<TAddDto> dtos, Action<TAddDto> checkAction = null, Func<TAddDto, TEntity, TEntity> updateFunc = null) where TAddDto : IAddDto; /// <summary> /// 以DTO为载体批量更新实体 /// </summary> /// <typeparam name="TEditDto">更新DTO类型</typeparam> /// <param name="dtos">更新DTO信息集合</param> /// <param name="checkAction">更新信息合法性检查委托</param> /// <param name="updateFunc">由DTO到实体的转换委托</param> /// <returns>业务操作结果</returns> OperationResult Update<TEditDto>(ICollection<TEditDto> dtos, Action<TEditDto> checkAction = null, Func<TEditDto, TEntity, TEntity> updateFunc = null) where TEditDto : IEditDto<TKey>; /// <summary> /// 以标识集合批量删除实体 /// </summary> /// <param name="ids">标识集合</param> /// <param name="checkAction">删除前置检查委托</param> /// <param name="deleteFunc">删除委托,用于删除关联信息</param> /// <returns>业务操作结果</returns> OperationResult Delete(ICollection<TKey> ids, Action<TEntity> checkAction = null, Func<TEntity, TEntity> deleteFunc = null);
数据查询 API
一个系统的数据层的所有 API 设计中,看似简单却又最复杂的是 数据查询API 的设计,可能的原因如下:
- 业务需求是未知的,不可预见的,很难设计出能满足各种业务需求的 数据查询API
- 业务需求是多变的,在数据层设计 数据查询API,很难保持稳定,往往也要跟随业务进行变更
- 业务需求需要的数据是千奇百怪的,数据层 设置死的API很难满足业务的需求,给多了对系统性能造成影响,给少了又不能满足业务需求
基于以上理由,可见在数据层中设计 数据查询API,是很难满足业务层对数据的需求的。那怎么办呢?答案就是不把数据查询的API设置死,把数据查询的决定权交给“真正需要数据的地方”,在哪需要数据就在哪查询,需要哪些数据、需要数据的哪一部分,业务层自己说了算,数据层只需要提供一个用于数据查询的“数据源”即可。到这里,IQueryable<T>的一切特性,几乎都是为了满足上面的需求而来的,只需在 IRepository 中对数据层开放一个“只读的” IQueryable<TEntity> 查询数据集即可,不同的数据查询需求,通过设计 IQueryable<T> 的扩展方法来完成。
上面定义的 IQueryable<T>查询数据源,能满足大部分数据查询的需求,但某些 EntityFramework 的特定查询需求,还是应该单独定义 数据查询API,以更好的保障不丢失 EntityFramework 的数据查询自由度。在这里主要定义了 通过主键查找实体、使用 Include 包含指定导航属性 的数据查询API:
1 /// <summary> 2 /// 获取 当前单元操作对象 3 /// </summary> 4 IUnitOfWork UnitOfWork { get; } 5 6 /// <summary> 7 /// 获取 当前实体类型的查询数据集 8 /// </summary> 9 IQueryable<TEntity> Entities { get; } 10 11 /// <summary> 12 /// 实体存在性检查 13 /// </summary> 14 /// <param name="predicate">查询条件谓语表达式</param> 15 /// <param name="id">编辑的实体标识</param> 16 /// <returns>是否存在</returns> 17 bool ExistsCheck(Expression<Func<TEntity, bool>> predicate, TKey id = default(TKey)); 18 19 /// <summary> 20 /// 查找指定主键的实体 21 /// </summary> 22 /// <param name="key">实体主键</param> 23 /// <returns>符合主键的实体,不存在时返回null</returns> 24 TEntity GetByKey(TKey key); 25 26 /// <summary> 27 /// 获取贪婪加载导航属性的查询数据集 28 /// </summary> 29 /// <param name="path">属性表达式,表示要贪婪加载的导航属性</param> 30 /// <returns>查询数据集</returns> 31 IQueryable<TEntity> GetInclude<TProperty>(Expression<Func<TEntity, TProperty>> path); 32 33 /// <summary> 34 /// 获取贪婪加载多个导航属性的查询数据集 35 /// </summary> 36 /// <param name="paths">要贪婪加载的导航属性名称数组</param> 37 /// <returns>查询数据集</returns> 38 IQueryable<TEntity> GetIncludes(params string[] paths);
至此,OSharp 的数据层定义基本完成,下篇我们将逐条讲解 EntityFramework 的数据层实现。
开源说明
github.com
OSharp项目已在github.com上开源,地址为:https://github.com/i66soft/osharp,欢迎阅读代码,欢迎 Fork,如果您认同 OSharp 项目的思想,欢迎参与 OSharp 项目的开发。
在Visual Studio 2013中,可直接获取 OSharp 的最新源代码,获取方式如下,地址为:https://github.com/i66soft/osharp.git
nuget
OSharp的相关类库已经发布到nuget上,欢迎试用,直接在nuget上搜索 “osharp” 关键字即可找到
系列导航
本文已同步到系列目录:OSharp快速开发框架解说系列
作者:郭明锋
Q群:MVC EF技术交流(5008599) OSharp开发框架交流(85895249)
出处:https://www.cnblogs.com/guomingfeng
声明:本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。