你的位置:首页 > ASP.net教程

[ASP.net教程]在EntityFramework6中管理DbContext的正确方式——1考虑的关键点(外文翻译)


(译者注:使用EF开发应用程序的一个难点就在于对其DbContext的生命周期管理,你的管理策略是否能很好的支持上层服务 使用独立事务,使用嵌套事务,并行执行,异步执行等需求? Mehdi El Gueddari对此做了深入研究和优秀的工作并且写了一篇优秀的文章,现在我将其翻译为中文分享给大家。由于原文太长,所以翻译后的文章将分为四篇。你看到的这篇就是是它的第一篇。原文地址:http://mehdi.me/ambient-dbcontext-in-ef6/)

关于DbContext

这不是第一篇写关于如何管理基于EntityFramework应用程序的DbContext的生命周期的文章。实际上,这儿已经有非常多的讨论这个话题的文章了。

对于许多应用程序,上面这些文章呈现的解决方案(基本上都是利用DI容器对每一个Web请求注入一个DbContext的实例)能够很好的工作。它们的优点也是非常简单——至少第一眼看上去是这样的。

然而,对于某些特定的应用程序,这些解决方案天生的缺陷产生了问题。那就是一些功能变得无法实现或者需要求助于增加复杂的结构或增加丑陋的算法来处理DbContext的创建和管理。

下面是一些真实世界的应用程序的示例,它们促使我重新思考管理DbContext的方式:

   ●一个应用程序包含 ASP.NET MVC和WebAPI构建的Web应用程序。它也可能包含Console和Windows Services构建的后台服务,包含任务调度服务以及那些处理来自于MSMQ和RabbitMQ的消息的服务。我在上面链接的大部分文章都假定所有服务运行在Web请求的上下文里面,但这里涉及的情况显然不是这样。

   ●它从多个数据库读写数据,这些数据库包括一个主数据库,一个从数据库,一个报表数据库和一个日志数据库。它的领域模型被分为几个独立的组,每一个组有自己的DbContext类型,假定只有一个DbContext类型在这儿无论如何也是行不通的。

   ●它非常依赖第三方的远程API,比如Facebook,Twitter或者LinkedIn的API,然而这些API并不支持事务。许多用户操作要求在返回结果给用户之前要先进行几个远程API调用。我在上面链接的大部分文章都假定“一个Web请求只包含一个业务事务”,它应该要么被提交要么被回滚,很显然这条规则在这儿不适用。因为一个远程API调用失败并不意味着我们能神奇的“回滚”之前任何一个已经完成的远程API调用的结果。(例如:当你使用Facebook API向Facebook提交一个状态更新时,你不能因为后续操作失败而回滚向Facebook的状态更新)。因此,在这类应用程序中,一个用户操作经常要求我们执行多个业务事务,每个事务都能独立的持久化。(你可能会争辩说也许有某种方式去重新设计整个系统以避免我们遇到这种情况——当然这是可能的。但如果程序原本就设计成那样,并且运行得很好而且我们不得不处理这种情况呢?)

   ●许多服务都严重并行化,要么借助于异步IO或者(更常见地)通过TPL库提供Task.Run()或者Parallel.Invoke()方法将任务分发到多个线程上去执行。在这种场景下,管理DbContext的大部分常见方法都不管用了。

在这篇文章中,我将深入介绍关于DbContext生命周期管理的各个部分。我们将看到解决这个问题的几种常见策略的优缺点。最后,我们将总结出一个管理DbContext生命周期的策略,它能应对上面提到的各种挑战。

当然,世上没有完美的解决方案。但是在文章的最后,你将拥有为你特定的应用程序做出明智决定的工具和知识。

这是一篇非常长而且详细的文章,它需要一定的时间去阅读和消化。对于基于EntityFramework的应用程序,你选择用来管理DbContext生命周期的策略将是一个重要的决定——它将对你的程序的正确性、可维护性、扩展

性产生重大影响。因此,这值得我们多花一些时间认真考虑后再做出选择。

本文中要用到的术语

在这篇文章中,我将多次提到“服务”这个词,它不是指的远程服务(REST或者其它),相反,它是指服务对象——也就是你经常放置业务逻辑实现的地方——这些对象负责执行业务规则和定义业务事务边界。

当然,你的代码基可能用的不同的名字,这取决于你创建应用程序架构所使用的设计模式。因此,我所说的“服务”对你来说也可能叫做“工作流(workflow)”,“协调器(orchestrator)”,“执行者(executor)”,“interactor”,“命令(command)”,“处理者(handler)”或者其它一些名字。

更不用说还有很多应用程序根本就没有定义一个合适的地方来存放业务逻辑,而是随便放在一个地方,比如说MVC应用程序里面的控制器(controllers)。

但是这些都和我们讨论的话题无关——当我说“服务”的时候,就是指存放业务逻辑的地方,它可以是一个随便的控制器(controller)方法或者是一个分层架构中的服务类。

考虑的关键点

 当制定或者评估一个管理DbContext生命周期策略的时候,记住它要支持的关键场景和功能是非常重要的。

 下面是一些我认为对大部分程序都很重要的东西。

你的服务必须控制业务服务的边界(但不是必需控制DbContext的生命周期)

可能管理DbContext的主要难点是理解DbContext的生命周期与业务事务的生命周期这二者之间的差异和关联。

 DbContext实现了工作单元模式:

     维护受业务影响的对象列表,并协调变化和并发问题的解决。

 在实践中,当你用DbContext实例去加载,更新,添加和删除可持久化的实体时,它会在内存中跟踪这些变化。除非你调用SaveChanges()方法,否则它不会将这些变化持久化到底层数据库。

 一个服务方法,就像上面定义的,它将负责定义业务事务的边界。

 这样的结果就是:

    ●一个服务方法必须用同一个DbContext实例贯穿整个业务事务,这样才能跟踪对可持久化对象的所有修改,并且将这些修改以原子的方式要么提交到底层数据库要么回滚。

    ●你的服务必须是系统中唯一负责调用DbContext.SaveChanges()方法的组件。如果系统的其它模块(比如仓储(repsitory)方法)调用SaveChanges()方法会产生什么结果呢?它将导致提交部分变化,使你的数据处于一种不一致的状态。

    ●SaveChanges()方法必需是在每个业务事务的最后刚好调用一次。如果在业务事务的中间调用也可能会导致不一致的,部分提交的状态。

 一个DbContext实例是可以跨越多个(顺序的)业务事务的。一旦一个业务事务已经完成并且调用DbContext.SaveChanges()方法持久化了所有的修改,那么我们在下一个业务事务中重用同一个DbContext是完全可能的。

 也就是说,DbContext实例的生命周期没有必要和一个单独的业务事务生命周期绑定在一起。

独立于业务事务的生命周期来管理DbContext实例的生命周期的优缺点

示例

独立于业务事务生命周期来管理DbContext实例的生命周期的一个常见场景就是Web应用。常见的处理方式是:DbContext实例在每一个请求开始的时候就被创建,然后在这个Web请求的执行过程中被所有的服务调用,并且在请求结束时被释放掉。

优点

下面是关于你为什么要将DbContex实例的生命周管理从业务事务生命周期管理分离开的两个重要原因。

     ●潜在的性能提升。每一个DbContext实例都维护了一个从数据库加载的对象的一级缓存。当你通过主键查询一个实体时,DbContext将优先从一级缓存获取它,在获取不到时,才会尝试从数据库查询。取决于你的数据查询模式,在多个顺序的业务事务中重用同一个DbContext将会因为一级缓存而导致更少的数据库查询。

     ●更多使用延迟加载的场景。如果服务返回可持久化对象(而不是view models或者其它DTOs)那么你就可以利用这些对象的延迟加载功能,加载这些实体的DbContext实例的生命周期必须超越业务事务的范围。如果服务在返回实体对象之前就释放掉了DbContext实例,那么在这些返回对象上触发的任何延迟加载属性都将失败(是否使用延迟加载功能是另一种争论,本文不做深入讨论)。在我们的Web应用示例中,延迟加载常用于服务层返回到控制器动作方法(controller action method)的实体上。这种情况下,服务方法用来加载实体的DbContext实例的生命周期将在整个Web请求过程中(或者至少持续到动作方法的结束)保持激化状态。

保持DbContext超越业务事务范围都处于激活状态带来的问题

虽然跨越多个业务事务重用同一个DbContext是可以的,但是DbContext的生命周期还是应该保持得短一些。它的一级缓存最终会过时,并导致并发问题。如果你的应用程序使用乐观并发策略的话,那么业务事务将会因为DbUpdateConcurrencyException而失败。在Web应用中使用“一个web请求一个DbContext实例”这种策略通常是可以的——因为Web请求时间通常很短。但是在桌面应用中,经常被建议使用的策略是使用“一个窗口(form)一个DbContext实例”,但这会经常出现问题——因此在采纳前应多考虑。

需要注意的是如果你用悲观并发策略的话,那么你不能跨越多个业务事务重用同一个DbContext实例。正确地实现悲观并发策略牵涉到在整个DbContext实例的生命周期中都要以正确的数据库隔离级别保持一个激活的数据库事务——这将防止你在独立的业务事务中独立的提交或者回滚数据。

在超过一个业务事务中重用同一个DbContext实例也可能会导致灾难性的bug——服务方法可能意外的提交了来自上一个失败的业务事务的修改。

最后,在你的服务方法的外面来管理DbContext实例的生命周期会倾向于把你的应用程序和一个制定的基础架构绑定在一起——从长远来看,这使你的应用程序更加不灵活并且更难演进和维护。

例如,对于一个刚开始很简单的Web应用程序来说,它依赖于“一个Web请求创建一个DbContext实例”的策略来管理DbContext的生命周期时,这很容易掉进一个圈套——在控制器(controller)或者视图(view)中使用延迟加载功能或者在服务方法之间传递可持久化对象——假定这些场景都会使用同一个DbContext实例。当不可避免地要引入多线程或者转移这些操作到后台WindowsService去的时候,这些精心设计的沙堡就崩塌了——因为这儿没有更多的Web请求来绑定DbContext实例了。

因此,建议避免独立于业务事务来管理DbContext实例的生命周期。应当在每一个服务方法内部创建它们自己的DbContext实例,并在业务事务结束的时候释放该实例。

这将防止在服务外面使用延迟加载(也可以让服务方法返回传输对象而非可持久化对象来防止在服务外面使用延迟加载)。另外,最好不要传递可持久化对象给服务——因为这些对象没有依附在服务将要使用的DbContext上。虽然有这么多限制,但从长远来看,它将给我们带来很好的灵活性和可维护性。

你的服务必须控制数据库事务的范围和隔离级别

如果你的应用程序使用的数据库提供的事务支持ACID四个要素(如果你用的是EntityFramework,那么肯定就是了),那么你的服务控制数据库事务的范围和隔离级别就很有必要了,否则你就不能写出正确的代码。

我们将在后面看到,EntityFramework将所有的写操作用一个显示数据库事务打包在一起——默认情况下,将用READ COMMITTED 隔离级别——也就是SQL Server的默认设置——这能适合大部分业务事务。尤其是你依赖于乐观并发策略去检测和避免”更新冲突“的情况,更是如此。

无论如何,大部分应用程序仍然需要为某些特定的操作使用其它的隔离级别。

比如,执行报表查询的场景,你可能会觉得脏读不是问题从而选择使用READ UNCOMMITTED隔离级别——这样可以消除锁竞争。

并且有的业务规则可能要去使用REPEATABLE  READ 或者 SERIALIZABLE 隔离级别(尤其是你的项目使用悲观并发策略的话)。这些场景就需要服务显示控制事务范围了。

管理DbContext的方式应当独立于系统架构

软件系统架构和设计模式会随着时间进化以适应新的业务规则和负载升级。

你肯定不想因为选择的管理DbContext生命周期的策略绑定在一个特定的架构而限制你对其进行升级。

管理DbContext的方式应当独立于应用程序类型

虽然现在大部分应用程序都开始于一个Web应用程序,但是你选择用来管理DbContext生命周期的策略不应当假定你的服务方法只会在基于Web请求的上下文中被调用。一般来说,你的服务层(如果有的话)应当独立于使用它的应用程序的类型。

在应用程序启动不久后,你就可能需要创建命令行工具去执行运维任务或者创建Windows服务来处理定时任务或者需要长时间运行的后台操作。当这种情况发生的时候,你期望能够为你的控制台应用程序或者Windows服务程序引用你的服务所在的程序集。你肯定不愿看到需要完全重构管理DbContext实例的方式后你的服务才能被不通的应用程序类型使用。

管理DbContext的方式应当支持多种DbContext派生类

如果你的应用程序需要访问多个数据库(比如报表数据库,日志/审计数据库)或者你将你的领域模型分离成多个聚合组,那么你就将有多个DbContext派生类。

对于NHibernate用户来说,这就相当于管理多个SessionFactory实例。

无论你选择哪种策略都应当能让服务选择需要的DbContext类型。

管理DbContext的方式应当能支持EF6提供的异步工作流

在.NET 4.5中,ADO.NET引入了支持异步数据库查询的功能。随后异步支持也被包括在EntityFramework6中——它允许你使用一个完整的异步工作流来读写数据。

无需多说,无论你选择什么来管理DbContext,但它必须能很好地与EF异步功能协调工作。

(译者注:下一篇,我们将看到DbContext的一些默认行为)