(译者注:使用EF开发应用程序的一个难点就在于对其DbContext的生命周期管理,你的管理策略是否能很好的支持上层服务 使用独立事务,使用嵌套事务,并行执行,异步执行等需求? Mehdi El Gueddari对此做了深入研究和优秀的工作并且写了一篇优秀的文章,现在我将其翻译为中文分享给大家。由于原文太长,所以翻译后的文章将分为四篇。你看到的这篇就是是它的第四篇。原文地址:http://mehdi.me/ambient-dbcontext-in-ef6/)
DbContextScope:一个简单的,正确的并且灵活的管理DbContext实例的方式
应当是来看看一种更好地管理这些DbContext
实例方式的时候了。
在下面呈现的方式依赖于DbContextScope
,它是一个定制的组件,实现了上面说到的环境上下文DbContext
方式。DbContextScope
和它依赖的相关类的源代码都放到了GitHub上面。
如果你熟悉TransactionScope
类,那么你就已经知道如何使用一个DbContextScope
了。它们在本质上十分相似——唯一的不同是DbContextScope
创建和管理DbContext
实例而非数据库事务。但是就像TransactionScope
一样,DbContextScope
是基于环境上下文的,可以被嵌套,可以有嵌套行为被禁用,也可以很好地与异步工作流协作。
下面是DbContextScope
的接口:
public interface IDbContextScope : IDisposable
{
void SaveChanges();
Task SaveChangesAsync();
void RefreshEntitiesInParentScope(IEnumerable entities);
Task RefreshEntitiesInParentScopeAsync(IEnumerable entities);
IDbContextCollection DbContexts { get; }
}
DbContextScope
的目的是创建和管理在一个代码块内使用的DbContext
实例。一个DbContextScope
因此有效的定义了一个业务事务的边界。我将在后面解释为什么我没有将其命名为“工作单元(UnitOfWork)”或者“工作单元范围(UnitOfWorkScope)”——它们拥有更广泛的使用场景。
你可以直接实例化一个DbContextScope
,你也可以依赖IDbContextScopeFactory
——它提供一个方便的方法并使用最常见的配置来创建一个DbContextScope
:
public interface IDbContextScopeFactory
{
IDbContextScope Create(DbContextScopeOption joiningOption = DbContextScopeOption.JoinExisting);
IDbContextReadOnlyScope CreateReadOnly(DbContextScopeOption joiningOption = DbContextScopeOption.JoinExisting);
IDbContextScope CreateWithTransaction(IsolationLevel isolationLevel);
IDbContextReadOnlyScope CreateReadOnlyWithTransaction(IsolationLevel isolationLevel);
IDisposable SuppressAmbientContext();
}
典型用法
使用DbContextScope
,你的典型服务方法将看起来是这样的:
public void MarkUserAsPremium(Guid userId)
{
using (var dbContextScope = _dbContextScopeFactory.Create())
{
var user = _userRepository.Get(userId);
user.IsPremiumUser = true;
dbContextScope.SaveChanges();
}
}
在一个DbContextScope
里面,你可以用两种方式访问scope
管理的DbContext
实例。你可以像下面这样通过DbContextScope.DbContexts
属性获取它们:
public void SomeServiceMethod(Guid userId)
{
using (var dbContextScope = _dbContextScopeFactory.Create())
{
var user = dbContextScope.DbContexts.Get<MyDbContext>.Set<User>.Find(userId);
[...]
dbContextScope.SaveChanges();
}
}
但那当然也是DbContextScope
在方法里面提供的唯一方式。如果你需要在其它地方(比如说仓储类)访问环境上下文DbContext
实例,你可以依赖IAmbientDbContextLocator
,像下面这样使用:
public class UserRepository : IUserRepository
{
private readonly IAmbientDbContextLocator _contextLocator;
public UserRepository(IAmbientDbContextLocator contextLocator)
{
if (contextLocator == null) throw new ArgumentNullException("contextLocator");
_contextLocator = contextLocator;
}
public User Get(Guid userId)
{
return _contextLocator.Get<MyDbContext>.Set<User>().Find(userId);
}
}
这些DbContext
实例是延迟创建的并且DbContextScope
跟踪它们以确保在它的范围内任何DbContext
派生类只会被创建一个实例。
你将注意到服务方法在整个业务事务范围内不需要知道究竟需要哪种DbContext
派生类型。它仅仅需要创建一个DbContextScope
并且在其范围内的需要访问数据库的任何组件都能获取到它们需要的DbContext
。
嵌套范围(Nesting Scopes)
一个DbContextScope
当然可以被嵌套。让我们假定你已经有一个服务方法,它将用户标记为优质用户,像下面这样:
public void MarkUserAsPremium(Guid userId)
{
using (var dbContextScope = _dbContextScopeFactory.Create())
{
var user = _userRepository.Get(userId);
user.IsPremiumUser = true;
dbContextScope.SaveChanges();
}
}
你正在实现一个新的功能,它要求能在一个业务事务内标记一组用户为优质用户。你可以像下面这样很容易的完成它:
public void MarkGroupOfUsersAsPremium(IEnumerable<Guid> userIds)
{
using (var dbContextScope = _dbContextScopeFactory.Create())
{
foreach (var userId in userIds)
{
// 通过MarkUserAsPremium()创建的子范围将加入我们的范围,
// 因此它能重用我们的DbContext实例,并且对SaveChanges()
// 的调用将没有任何作用。
MarkUserAsPremium(userId);
}
// 修改都将只有在这儿才能被保存,在顶层范围内,以确保所有的修改
// 以原子的行为要么提交要么回滚。
dbContextScope.SaveChanges();
}
}
(当然这是实现这个指定功能的一种非常不高效的方式,但是它说明了如何实现嵌套事务(范围))
这使得创建一个能组合使用多个其它多个服务方法的服务成为可能。
只读范围(Read-only scopes)
如果一个服务方法是只读的,那么在方法返回之前必须在DbContextScope
上调用SaveChanges()
方法将是痛苦的,但是如果不调用也有不妥,因为:
- 它将使代码审查和维护更困难(你究竟是有意没有调用
SaveChanges()
还是你忘了调用呢?) - 如果你开启一个显式数据库事务(我们将在后面看到如何这样做),不调用
SaveChanges()
将导致事务被回滚。数据库监控系统将通常认为事务回滚意味着应用程序错误。造成一种假的回滚不是一个好主意。
DbContextReadOnlyScope
用来解决这个问题。下面是它的接口:
public interface IDbContextReadOnlyScope : IDisposable
{
IDbContextCollection DbContexts { get; }
}
你可以像下面这样使用它:
public int NumberPremiumUsers()
{
using (_dbContextScopeFactory.CreateReadOnly())
{
return _userRepository.GetNumberOfPremiumUsers();
}
}
异步支持
DbContextScope
将如你期望的能很好的在异步执行流中工作:
public async Task RandomServiceMethodAsync(Guid userId)
{
using (var dbContextScope = _dbContextScopeFactory.Create())
{
var user = await _userRepository.GetAsync(userId);
var orders = await _orderRepository.GetOrdersForUserAsync(userId);
[...]
await dbContextScope.SaveChangesAsync();
}
}
在上面的例子中,OrderRepository.GetOrdersForUserAsync()
方法将能看到并且访问环境上下文DbContext
实例——尽管事实上它是在另一个线程而非DbContextScope
最初被创建的线程上被调用。
使这一切成为可能的原因是DbContextScope
将它自己存储在CallContext
上面的。CallContext
通过异步点自动流转。如果你对它背后的工作原理很好奇,Stephen Toub已经写过一篇关于它的优秀文章。但是如果你想要的只是使用DbContextScope
,你只需要知道:它就是能工作。
警告:当你在异步流中使用
DbContextScope
的时候,有一件事情你必须记住:就像TransactionScope
,DbContextScope
仅支持在一个单一的逻辑流中使用。
也就是说,如果你尝试在一个DbContextScope
范围内开启多个并行任务(比如说创建多个线程或者多个TPL任务),你将陷入大麻烦。这是因为环境上下文DbContextScope
将流转到你并行任务使用的所有线程。如果在这些线程中的代码需要使用数据库,它们就都将使用同一个环境上下文DbContext
实例,导致多个线程同时使用同一个DbContext
实例。
通常,在一个单独的业务事务中并行访问数据库没有什么好处除了增加复杂性。在业务事务中的任何并行操作都应当不要访问数据库。
无论如何,如果你针对需要在一个DbContextScope
里面开启一个并行任务(比如说你要通过业务事务的结果独立的执行一些后台处理),你必须在开启并行任务之前禁用环境上下文DbContextScope
,你可以像下面这样简单处理:
public void RandomServiceMethod()
{
using (var dbContextScope = _dbContextScopeFactory.Create())
{
// 使用环境上下文context执行一些代码
[...]
using (_dbContextScopeFactory.SuppressAmbientContext())
{
// 在这儿,开启的并行任务将不能使用环境上下文context.
[...]
}
// 在这儿,环境上下文将再次变为可用。
// 可以像平常一样执行更多的代码
[...]
dbContextScope.SaveChanges();
}
}
创建一个非嵌套的DbContextScope
这是一个我期望大部分应用程序永远不需要用到的高级功能。当使用它的时候要认真对待——因为它能导致一些诡异的问题并且很快导致维护的恶魔。
有些时候,一个服务方法可能需要将变化持久化到底层数据库而不管整个业务事务的结果,就像下面这些情况:
- 需要在一个全局的地方记录不应当回滚的信息——即使业务事务失败。一个典型的例子就是日志或者审计记录。
- 它需要记录一个不能回滚的操作的结果。一个典型的例子就是服务方法和非事务性的远程服务或者API交互。例如,如果你的服务方法使用Facebook API提交一个状态更新然后在本地数据库记录新创建的状态。这个记录必须被持久化即使整个业务事务因为在调用Facebook API后出现一些错误而导致的失败。Facebook API不是事务性的——你不可能去“回滚”一个Facebook API调用。那个API调用的结果将永远不会回滚。
在那种情况下,当创建一个新的DbContextScope
的时候,你可以传递DbContextScopeOption.ForceCreateNew
的值作为joiningOption
参数。这将创建一个不会加入环境上下文范围(如果存在一个的话)的DbContextScope
:
public void RandomServiceMethod()
{
using (var dbContextScope = _dbContextScopeFactory.Create(DbContextScopeOption.ForceCreateNew))
{
// 我们将创建一个新的范围(scope),即使这个服务方法
// 被另一个已经创建了它自己的DbContextScope的服务方
// 法调用。我们将不会加入它。
// 我们的范围将创建新的DbContext实例并且不会重用
// 父范围的DbContext实例。
//[...]
// 由于我们强制创建了一个新的范围,这个对SaveChanges()
// 的调用将会持久化我们的修改——不管父范围(如果有的话)
// 是否成功保存了它的变化。
dbContextScope.SaveChanges();
}
}
这样处理的最大问题是服务方法将使用独立的DbContext
实例而非业务事务中的其它DbContext
。为了避免诡异的bug和维护恶魔,下面列出了一些要服从的基本规则:
服务方法返回的持久化实体必须总是依附(Attach)在环境上下文DbContext上
如果你强制创建一个新的DbContextScope
而非加入一个已经存在的上下文环境DbContextScope
,你的服务方法必须不能返回它在新的范围(scope)创建或者获取的实体。否则将导致巨大的复杂性。
调用你服务方法的客户端代码可能是一个创建了它自己的DbContextScope
的服务方法。因此它期望它调用的所有服务方法都使用相同的环境上下文DbContextScope
(这是使用环境上下文Context
的立足点)。很自然的期望通过你的服务方法返回的实体都依附(attach)在环境上下文DbContext
上。
相反,你也可以采取下面两种策略:
不要返回持久化实体。这是最容易,最干净的方法。比如,如果你的服务方法创建一个新的领域对象,不要返回它,而是返回它的Id并且让客户端在它自己的
DbContext
上面加载这个实体(如果客户端真的需要这个实体的话)。如果你无论如何也要返回一个持久化实体的话,切换回环境上下文
DbContext
,加载实体并将其返回。
在退出时,一个服务方法必须确保对持久化对象的所有修改都已经在父范围中重现
如果你的服务方法强制创建了一个新的DbContextScope
并且在这个新的范围里面修改了持久化对象,必须确保在返回的时候父范围(如果存在的话)能“看到”这些修改。
也就是说,如果父范围的DbContext
实例已经加载了你修改过的实体在它的一级缓存中(ObjectStateManager
),你的服务方法必须刷新这些实体以确保父范围不会使用这些对象的过时版本。
DbContextScope
提供了一个快捷方法来帮助处理这个问题:
public void RandomServiceMethod(Guid accountId)
{
// 强制创建一个新范围(也就是说,我们将使用我们自己的DbContext 实例)
using (var dbContextScope = _dbContextScopeFactory.Create(DbContextScopeOption.ForceCreateNew))
{
var account = _accountRepository.Get(accountId);
account.Disabled = true;
// 由于我们强制创建了一个新的范围,这将持久化我们的变化到数据库而不管父范围的处理成功与否。
dbContextScope.SaveChanges();
// 如果这个方法的调用者已经加载过account对象到
// 它们的DbContext实例中,它们的版本现在已经变得过时了。它们将看不到这个account已经被禁用并且可能因此执行一些错误的逻辑。
// 因此需要确保我们的调用者的版本要保持更新。
dbContextScope.RefreshEntitiesInParentScope(new[] { account });
}
}
为什么命名为DbContextScope
而不是UnitOfWork
(工作单元)?
我写的DbContextScope
的第一个版本确实被命名为UnitOfWork
,这可以说是这种类型的组件最常用的名称。
但是当我尝试在现实程序中使用那个UnitOfWork
组件的时候,我一直很困惑——我应该如何使用它和它真的做了什么——尽管我是那个研究,设计和实现了它的人并且我还对它能做什么以及如何工作都了如指掌。然而,我仍然很困难并且不得不倒退一步去仔细回想这个“unit of work”怎样关联我要尝试解决的实际问题:管理我的DbContext
实例。
如果即使我——那个花了很多时间去研究,设计和实现了这个组件的人在尝试使用的时候都变得很困惑的话,要让其他人来容易的使用它——这恐怕是没什么希望了。
因此我将其重命名为DbContextScope
并且突然所有的事情都变得清晰明朗了。
使用UnitOfWork
最主要的问题我相信是在应用程序级别的,它通常没有什么意义。在一个更低的层次,比如数据库级别,一个“unit of work”是一个非常清晰并且具体的概念。下面是Martin Fowler对unit of work的定义:
维护受业务影响的对象列表,并协调变化和并发问题的解决。
在数据库级别,unit of work要表达的东西没有二义性。
然而在一个应用程序级别,一个”unit of work”是一个非常模糊的概念——它可能指所有东西,但又可能什么都不是。并且这个“unit of work”如何关联到EF是不清晰的——对于管理DbContext
实例的问题,对于我们操作的持久化对象依附到正确的DbContext
实例上的问题。
因此,任何开发人员在尝试使用一个”UnitOfWork”的时候都会搜索它的源代码去查看它究竟做了什么。工作单元(unit of work)模式的定义太过于模糊以至于在应用程序级别没什么用处。
实际上,对大部分应用程序,一个应用程序级别的“unit of work”甚至没有任何意义。许多应用程序在业务事务中不得不使用几个非事务性的服务,比如远程API或者非事务性的遗留组件。这些地方做出的修改不能被回滚。假装这些不存在是反效率的,迷惑的并且甚至更难写出正确的代码。
相反,DbContextScope
刚好完成了它需要完成的工作,不多,不少。它没有假装成别的东西。并且我发现这个简单的更名有效的减少了使用这个组件的认知负荷和去验证是否正确的使用了它。
当然,将这个组件命名为DbContextScope
就再也不能掩盖你的服务方法正在使用EF的事实。UnitOfWork
是一个非常模糊的概念——它允许抽象在底层使用的持久化机制。从你的服务层中抽象EF是否是一件好事是一个另外争论——我们在这儿就不深入它了。
直接去看看吧
放在GitHub上的源代码包括了一个demo程序来演示大部分的使用场景
DbContextScope是如何工作的
源代码已经做了很好的注释并且我鼓励你通读它。另外,Stephen Toub写的这篇放到ExecutionContext的优秀文章是必读的——如果你想要完全理解DbContextScope
中的环境上下文context
模式是如何实现的话。
延伸阅读
EF团队的项目经理Rowan Miller,他的个人博客,对于用EF开发项目的任何开发人员来说都是必须要去读的。
额外资料
哪些地方不能创建你的DbContext实例
在现实程序中经常看到的一个使用EF的反模式是将创建和释放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;
_userRepository.Save(user);
}
}
public class UserRepository : IUserRepository
{
public User Get(Guid userId)
{
using (var context = new MyDbContext())
{
return context.Set<User>().Find(userId);
}
}
public void Save(User user)
{
using (var context = new MyDbContext())
{
// [...]
// (要么将提供的实体依附在context上,要么从context加载它,
// 并且从提供的实体更新它的字段)
context.SaveChanges();
}
}
}
通过这样处理,你基本上失去了EF通过DbContext
提供的每一个功能,包括它的一级缓存,它的标识映射(Identity map),它的工作单元(unit-of-work),它的变更追踪和延迟加载功能。因为在上面的场景中,对于每一个数据库查询都将创建一个新的DbContext
实例并且随后立即就被释放掉,因此阻碍了DbContext
实例去跟踪你的整个业务事务范围内的数据的状态。
你有效的将EF简化为一个简单ORM
框架:一个将你的对象与它在数据库中的关系表现映射的工具。
这种架构对于一些应用程序是说得通的。如果你工作在这样一个应用程序,无论如何你应当首先问你自己为什么要用EF。如果你要将它作为一个简单ORM框架并且不用它提供的任何主要功能,你可能使用一个轻量级的ORM
框架(比如Dapper)会更好。因为它将会简化你的代码并且由于没有EF附加功能的开销而提供更好的性能。