(译者注:使用EF开发应用程序的一个难点就在于对其DbContext的生命周期管理,你的管理策略是否能很好的支持上层服务 使用独立事务,使用嵌套事务,并行执行,异步执行等需求? Mehdi El Gueddari对此做了深入研究和优秀的工作并且写了一篇优秀的文章,现在我将其翻译为中文分享给大家。由于原文太长,所以翻译后的文章将分为四篇。你看到的这篇就是是它的第三篇。原文地址:http://mehdi.me/ambient-dbcontext-in-ef6/)
环境上下文DbContext vs 显式DbContext vs 注入DbContext
在任何基于EF项目之初的一个关键决定就是你的代码如何传递DbContext
实例到下面真正访问数据库的方法里面。
就像我们在上面看到的,创建和释放DbContext
的职责属于顶层服务方法。数据访问代码,就是那些真正使用DbContext
实例的代码,可能经常在一个独立的部分里面——可能深入在服务实现类的一个私有方法里面,也可能在一个查询对象里面或者一个独立的仓储层里面。
顶层服务方法创建的DbContext
实例需要找到一个传递到这些方法的方式。
这儿有三个想法来让数据访问代码访问DbContext
:环境上下文DbContext
,显式DbContext
或者注入DbContext
。每一种方式都有它们各自的优缺点,让我们来逐个分析。
显式DbContext
它看起来是怎么样的
使用显式DbContext
方法,顶层服务创建一个DbContext
实例然后通过一个方法的参数传递至数据访问的部分。在一个传统的包含服务层和仓储层的三层架构中,大概看起来就是这样:
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
public UserService(IUserRepository userRepository)
{
if (userRepository == null) throw new ArgumentNullException("userRepository");
_userRepository = userRepository;
}
public void MarkUserAsPremium(Guid userId)
{
using (var context = new MyDbContext())
{
var user = _userRepository.Get(context, userId);
user.IsPremiumUser = true;
context.SaveChanges();
}
}
}
public class UserRepository : IUserRepository
{
public User Get(MyDbContext context, Guid userId)
{
return context.Set<User>().Find(userId);
}
}
(在这个故意为之的示例里面,仓储层的作用当然是完全无意义的。在一个真实的应用程序中,你将期望仓储层更加饱满。另外,如果你真的不想让你的服务直接依赖EF,你可以抽象你的DbContext为“IDbContext”之类的并且通过一个抽象工厂来创建它。)
优点
这种方式是到目前为止而且永远也是最简单的方式。它使得代码非常简单易懂而且易于维护——即使对于那些对代码不是很熟悉的开发人员来说也是这样的。
这儿没有任何神奇的地方。DbContext
实例不会凭空创建。它是在一个清晰的明显的地方被创建——如果你好奇DbContext
来源于哪儿的话你也可以通过调用栈非常容易的找到。
缺点
这种方式最主要的缺点是它要求你去污染所有你的所有仓储方法(如果你有一个仓储层的话),同样你的大多服务方法也会有一个强制的DbContext
参数(或者某种类型的IDbContext
抽象——如果你不希望绑定到具体实现的话——但是问题仍然存在)。所以你可能会看到某些方法注入模式的应用。
你的仓储层方法要求提供一个显式的DbContext
作为参数也不是什么大问题。实际上,它甚至可以看着已经好事——因为他消除了潜在的歧义——就是这些查询究竟用的哪一个DbContext
。
但是对于服务层情况就大不一样了。因为大部分你的服务方法都不会用DbContext,尤其是你将数据访问代码隔离在一个查询对象或者仓储层里面的时候。因此,这些服务方法提供了一个DbContext参数的目的仅仅是为了将他们传递到下层真正需要用到DbContext的方法里面。
这很容易变得十分丑陋。尤其是你的应用程序需要使用多个DbContext
的时候,将导致你的服务方法要求两个甚至更多的DbContext
参数。这将混淆你的方法的契约——因为你的服务方法现在强制要求一个它们既不需要也不会用而仅仅是为了满足底层方法依赖的参数。
Jon Skeet写了一篇关于显式DbContext vs 隐式DbContext的文章,但没有提供一个好的解决方案。
然而,这种方法的超级简单性还是很难被其它方法打败的。
环境上下文DbContext
它看起来是怎么样的
NHibernate用户应当是对这种方式非常熟悉——因为环境上下文模式(ambient context pattern)是在NHibernate
世界里面管理NHibernate
的Session
(它相当于EF的DbContext
)的主要方式。NHibernate
甚至对该模式有内置支持,叫做上下文session(contextual session)。
在.NET自身,这种模式也是用得相当广泛。你可能已经用过HttpContext.Current
或者TransactionScope
,两者都是依赖于环境上下文模式。
使用这种模式,顶层服务方法不仅创建用于当前业务事务的DbContext
,而且还要将其注册为环境上下文DbContext
。然后数据访问代码就可以在需要时候获取这个环境上下文DbContext
了。再也不需要传递DbContext
实例。
Anders Abel已经写过一篇文章——简单实现环境上下文DbContext——它依赖ThreadStatic
变量来存储DbContext
。去看看吧——它比听起来都还要更简单。
优点
这种方式的优点是显然的。你的服务和仓储方法现在对DbContext
参数已经自由了(也就是说服务和仓储方法不需要DbContext
作为参数了)——让你的接口更加干净并且你的方法契约更加清晰——因为它们现在只需要获取他们真正需要使用的参数了。再也不需要遍地传递DbContext
实例了。
缺点
无论如何这种方式引入了一定程度的魔法——它让代码更难理解和维护。当看到数据访问代码的时候,不一定容易发现环境上下文DbContext
来自于哪儿。你不得不希望在调用数据访问代码之前某人已经将它注册了。
如果你的应用程序使用多个DbContext
派生类,比如,如果你连接多个数据库或者如果你将领域模型划分为几个独立的组,那么对于顶层服务来说就很难知道应当创建和注册哪些DbContext
了。使用显式DbContext
,数据访问方法要求提供它们需要的DbContext
作为参数,因此就不存在歧义的可能。但是使用环境上下文方式,顶层服务方法必须知道下层数据访问代码需要哪种DbContext
类型。我们将在后面看到一些解决这个问题的十分干净的方式。
最后,我在上面链接的环境上下文DbContext
例子只能在单线程模型很好的工作。如果你打算使用EF的异步查询功能的话,它就不能工作了。在一个异步操作完成后,你很可能发现你自己已经在另外一个线程——不再是之前创建DbContext
的线程。在许多情况下,它意味着你的环境上下文DbContext
将消失。这个问题可以解决,但是它要求一些深入的理解——在.NET世界里面如何多线程编程,TPL和异步工作背后的原理。我们将在文章最后看到这些。
注入DbContext
它看起来是怎么样的
最后一种比较重要的方式,注入DbContext
方式经常被各种文章和博客提及用来解决DbContext
生命周期的问题。
使用这种方式,你可以让DI
容器管理DbContext
的生命周期并且在任何组件(比如仓储对象)需要的时候就注入它。
看起来就是这样的:
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
public UserService(IUserRepository userRepository)
{
if (userRepository == null) throw new ArgumentNullException("userRepository");
_userRepository = userRepository;
}
public void MarkUserAsPremium(Guid userId)
{
var user = _userRepository.Get(userId);
user.IsPremiumUser = true;
}
}
public class UserRepository : IUserRepository
{
private readonly MyDbContext _context;
public UserRepository(MyDbContext context)
{
if (context == null) throw new ArgumentNullException("context");
_context = context;
}
public User Get(Guid userId)
{
return _context.Set<User>().Find(userId);
}
}
然后你需要配置你的DI
容器以使用合适的生命周期策略来创建DbContext
实例。你将发现一个常见的建议是对于Web
应用程序使用一个PerWebRequest
生命周期策略,对于桌面应用使用PerForm
生命周期策略。
优点
好处与环境上下文DbContext
策略相似:代码不需要到处传递DbContext
实例。这种方式甚至更进一步:在服务方法里面根本看不到DbContext
。服务方法完全不知道EF的存在——第一眼看起来可能很好,但很快就会发现这种策略会导致很多问题。
缺点
不管这种策略有多流行,它确切是有非常重大的缺陷和限制。在采纳之前先了解它是非常重要的。
太多魔法
这种方式的第一个问题就是太依赖魔法。当需要保证你的数据——你最珍贵的资产的正确性和一致性的时候,“魔法”不是你想听到太频繁的一个词。
这些DbContext
实例来自于哪里?业务事务的边界如何定义和在哪儿定义?如果一个服务方法依赖两个不同的仓储类,那么这两个仓储式都访问同一个DbContext
实例呢还是它们各自拥有自己的DbContext
实例?
如果你是一个后端开发人员并且在开发基于EF的项目,那么想要写出正确代码的话,就必须知道这些问题的答案。
答案并不明显,它需要你详细查看DI
容器的配置代码才能发现。就像我们前面看到的,要正确设置这些配置不是第一眼看上去那么容易,相反,它可能是非常复杂而且容易出错的。
不清晰的业务事务边界
可能上面示例代码最大的问题是:谁负责提交修改到数据库?也就是谁调用DbContext.SaveChanges()
方法?它是不清晰的。
你可以仅仅是为了调用SaveChanges()
方法而将DbContext
注入你的服务方法。那将是更令人费解和容易出错的代码。为什么服务方法在一个既不是它创建的又不是它要使用的DbContext
对象上调用SaveChanges()
方法呢?它将保存什么修改?
另外,你也可以在你的所有仓储对象上定义一个SaveChanges()
方法——它仅仅委托给底层的DbContext
。然后服务方法在仓储对象上调用SaveChanges()
方法。这也将是非常具有误导性的代码——因为他暗示着每一个仓储对象实现了它们自己的工作单元并且可以独立于其它仓储对象持久化自己的修改——这显然不是正确的,因为他们实际上是用的都是同一个DbContext
实例。
有些时候你会看到还有一种方式:让DI
容器在释放DbContext
实例之前调用SaveChanges()
方法。这是一个灾难的方法——值得一篇文章来描述。
简短来说,DI
容器是一种基础设施组件——它对它管理的组件的业务逻辑一无所知。相反,DbContext.SaveChanges()
方法定义了一个业务事务的边界——也就是说它是以业务逻辑为中心的。混合两种完全不相关的概念在一起将会引起很多问题。
话虽如此,如果你知道“仓储层已死(Repository is Dead)”运动。谁来调用DbContext.SaveChanges()
方法根本不是问题——因为你的服务方法将直接使用DbContext
实例。它们因此很自然的成为调用SaveChanges()
方法的地方。
当你使用注入DbContext
策略的时候,不管你的应用程序的架构模式,你将还会遇到一些其它的问题。
强制你的服务变成有状态的
一个值得注意的地方是DbContext
不是一个服务。它是一个资源,一个需要释放的资源。通过将它注入到你的数据访问层。你将使那一层的所有上层——很可能就是整个应用程序,都变成有状态的。
这当然不是世界末日但它却肯定会让DI
容器的配置变得更复杂。使用无状态的服务将提供巨大的灵活性并且使得配置它们的生命周期变得不会出错。一旦你引入状态化的服务,你就得认真考虑你服务的生命周期了。
注入DbContext
这种方式在项目刚开始的时候很容易使用(PerWebRequest
或者Transient
生命周期都能很好的适应简单的web应用),但是控制台应用程序,Window服务等让它变得越来越复杂了。
阻止多线程
另外一个问题(相对前一个来说)将不可避免的狠咬你一口——注入DbContext
将阻止你在服务中引入多线程或者某种并行执行流的机制。
请记住DbContext
(就像NHibernate
中的Session
)不是线程安全的。如果你需要在一个服务中并行执行多个任务,你必须确保每个任务都使用它们自身的DbContext
实例,否则应用程序将在运行时候崩溃。但这对于注入DbContext
的方式来说是不可能的事情因为服务不能控制DbContext
实例的创建。
你怎么修复这个缺陷呢?答案是不容易。
你的第一直觉可能是将你的服务方法修改为依赖DbContext
工厂而非直接依赖DbContext
。这将允许它们在需要的时候创建它们自己的DbContext
实例。但这样做将会有效地推翻注入DbContext
这种观点。如果你的服务通过一个工厂创建它们自己的DbContext
实例,这些实例再也不能被注入了。那将意味着服务将显式传递这些DbContext
实例到下层需要它们的地方(比如说仓储层)。这样你又回到了之前我们讨论的显式DbContext
策略了。我可以想到一些解决这些问题的方法——但所有这些方法感觉起来像不寻常手段而不是干净并且优雅的解决方案。
另外一种解决这个问题的方式就是添加更多复杂的层,引入一个像RabbitMQ
的中间件并且让它为你分发任务。这可能行得通但也有可能行不通——完全取决于为什么你需要引入并发性。但是在任何情况下,你可能都不需要也不想要附加的开销和复杂性。
使用注入DbContext
的方式,你最好限制你自己只使用单线程代码或者至少是一个单一的逻辑执行流。这对于大部分应用程序都是完美的,但是在特定情况下,它将变成一个很大的限制。