数据层设计真是一个百说不厌的话题,大系统说并发量,说高性能;小系统追求开发效率,易维护性各有各的追求。
OSharp 开发框架的定位是中小系统, 数据层的开发效率与易用性的权重就比较高了,所以,使用ORM当然是首选。在 .net 环境下,有众多的闭源的开源的优秀的ORM组件,从各方便对比来看,EntityFramework 是不二之选。一提起 EntityFramework,不少同学又要蠢蠢欲动来吐槽其性能了。其实,经过几个版本的更新换代,现在的稳定版 EntityFramework 6 已经相当好用了,nuget 上截止到目前 “8,830,918 total downloads” 已经足够能说明问题了,EntityFramework 在整个 .net 世界是相当受欢迎的。不过,不管哪个技术平台,能不能用好一个技术与技术水平有很大的关系,如果没追求,随处的 select * from xxx,传说中高性能的 ado.net 也不会高到哪去。
〇、前言
一、目录
二、为什么要封装EntityFramework
三、EntityFramework封装的常见误区
四、怎样设计 EntityFramework 数据层
五、开源说明
系列导航
在本系列开篇《总体设计》对 OSharp 开发框架的数据存储组件的介绍时,就强调了 OSharp 将“强依赖”于 EntityFramework 这个数据访问组件。当时就有人提出了“强依赖Entity framework是个坑吧”的疑问。这里简要解释一下为什么 OSharp 不打算做成兼容各种数据访问方案(EF,NH,ado.net 等等)。我们来看看统一各个ORM需要付出的代价:
基于上面的一些理由,我们发现要兼容各种数据访问方案,需要付出的代码是很大的,很不划算。因此,OSharp 将专注于一款 ORM,从各方面比较,EntityFramework 是一个好选择,理由如下:
在计划使用EntityFramework来在项目中实现数据存储时,遇到的第一个问题就是:怎样来使用EntityFramework?要不要对EntityFramework进行二次封装?
反对对 EntityFramework 进行封装的同学,通常会有如下理由:
我认为,如果是小项目,且所有开发成员都能很好的使用 EntityFramework,在业务层中直接使用,将 EntityFramework 的灵活性发挥出来,也是非常好的。但是,直接使用 EntityFramework 的话,也有不少弊端,对于 OSharp 这样一个开发框架而言,封装就显得非常有必要了,原因如下:
在 EntityFramework 的发展过程中,很多使用者都在抱怨 EntityFramework 性能低下,其实很多时候都是因为对 EntityFramework 没有足够的了解,走进了误区所致。那么,EntityFramework 会有哪些误区呢?这里我列几个我所了解的“坑”,欢迎补充。
在设计数据访问层的查询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 这个关联表的“所有数据”都加载到内存中。想想,如果这个关联表的数据几十上百万的话,那将是多么可怕的性能灾难!!
那么,怎样编写数据查询的代码呢,个人认为应该分析场景:
PS:关于 EntityFramework 数据查询的更多内容,欢迎阅读 架构系列的《数据查询》篇。
前面说了那么多误区,那怎样来设计 EntityFramework 的数据访问层 API呢?这个话题在前面的 架构系列 中也讨论过了,但为了全面了解 OSharp 开发框架,这里不免要添点新料再炒次旧饭。
为了能在底层对所有的实体模型类进行统一管理,并规范实体类必要的属性设定,定义了一个如下的 实体模型基类 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 }
相比 架构系列 的 数据存储上下文管理,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 的 API,是整个数据层设计的核心。IRepository 接口被定义为“实体类型、主键类型”的双泛型接口,实体类型限定为前面定义的实体基类 EntityBase<TKey> 的派生类,声明如下:
1 interface IRepository<TEntity, TKey> : IDependency where TEntity : EntityBase<TKey>
IRepository 中定义了两个属性:
在操作 API 上,IRepository 接口主要定义了如下几种 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 数据,需要对 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设置死,把数据查询的决定权交给“正在需要数据的地方”,在哪需要数据就在哪查询,需要哪些数据、需要数据的哪一部分,业务层自己说了算,数据层只需要提供一个用于数据查询的“数据源”即可。到这里,IQueryable<T>的一切特性,几乎都是为了满足上面的需求而来的,只需在 IRepository 中对数据层开放一个“只读的” IQueryable<TEntity> 查询数据集即可,不同的数据查询需求,通过设计 IQueryable<T> 的扩展方法来完成。
上面定义的 IQueryable<T>查询数据源,能满足大部分数据查询的需求,但某些 EntityFramework 的特定查询需求,还是应该单独定义 数据查询API,以更好的保障不丢失 EntityFramework 的数据查询自由度。在这里主要定义了 通过主键查找实体、使用 Include 包含指定导航属性 的数据查询API:
1 /// <summary> 2 /// 实体存在性检查 3 /// </summary> 4 /// <param name="predicate">查询条件谓语表达式</param> 5 /// <param name="id">编辑的实体标识</param> 6 /// <returns>是否存在</returns> 7 bool ExistsCheck(Expression<Func<TEntity, bool>> predicate, TKey id = default(TKey)); 8 9 /// <summary> 10 /// 查找指定主键的实体 11 /// </summary> 12 /// <param name="key">实体主键</param> 13 /// <returns>符合主键的实体,不存在时返回null</returns> 14 TEntity GetByKey(TKey key); 15 16 /// <summary> 17 /// 获取贪婪加载导航属性的查询数据集 18 /// </summary> 19 /// <param name="path">属性表达式,表示要贪婪加载的导航属性</param> 20 /// <returns>查询数据集</returns> 21 IQueryable<TEntity> GetInclude<TProperty>(Expression<Func<TEntity, TProperty>> path); 22 23 /// <summary> 24 /// 获取贪婪加载多个导航属性的查询数据集 25 /// </summary> 26 /// <param name="paths">要贪婪加载的导航属性名称数组</param> 27 /// <returns>查询数据集</returns> 28 IQueryable<TEntity> GetIncludes(params string[] paths);
至此,OSharp 的数据层定义基本完成,下篇我们将逐条讲解 EntityFramework 的数据层实现。
OSharp项目已在github.com上开源,地址为: https://github.com/i66soft/osharp ,欢迎阅读代码,欢迎 Fork,如果您认同 OSharp 项目的思想,欢迎参与 OSharp 项目的开发。
在Visual Studio 2013中,可直接获取 OSharp 的最新源代码,获取方式如下,地址为:https://github.com/i66soft/osharp.git
OSharp的相关类库已经发布到nuget上,欢迎试用,直接在nuget上搜索 “osharp” 关键字即可找到