在EntityFramework6中管理DbContext的正確方式(4)【DbContextScope:一個簡單的,正確的並且靈活的管理DbContext例項的方式】

風靈使發表於2018-05-28

(譯者注:使用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()方法將是痛苦的,但是如果不呼叫也有不妥,因為:

  1. 它將使程式碼審查和維護更困難(你究竟是有意沒有呼叫SaveChanges()還是你忘了呼叫呢?)
  2. 如果你開啟一個顯式資料庫事務(我們將在後面看到如何這樣做),不呼叫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的時候,有一件事情你必須記住:就像TransactionScopeDbContextScope僅支援在一個單一的邏輯流中使用。

也就是說,如果你嘗試在一個DbContextScope範圍內開啟多個並行任務(比如說建立多個執行緒或者多個TPL任務),你將陷入大麻煩。這是因為環境上下文DbContextScope將流轉到你並行任務使用的所有執行緒。如果在這些執行緒中的程式碼需要使用資料庫,它們就都將使用同一個環境上下文DbContext例項,導致多個執行緒同時使用同一個DbContext例項。

通常,在一個單獨的業務事務中並行訪問資料庫沒有什麼好處除了增加複雜性。在業務事務中的任何並行操作都應當不要訪問資料庫。

無論如何,如果你針對需要在一個DbContextScope裡面開啟一個並行任務(比如說你要通過業務事務的結果獨立的執行一些後臺處理),你必須在開啟並行任務之前禁用環境上下文DbContextScope,你可以像下面這樣簡單處理:

 public void RandomServiceMethod()
        {
            using (var dbContextScope = _dbContextScopeFactory.Create())
            {
                // 使用環境上下文context執行一些程式碼
                [...]

                using (_dbContextScopeFactory.SuppressAmbientContext())
                {
                    // 在這兒,開啟的並行任務將不能使用環境上下文context.

                    [...]
                }

                // 在這兒,環境上下文將再次變為可用。

                // 可以像平常一樣執行更多的程式碼

                [...]

                dbContextScope.SaveChanges();
            }
        }

建立一個非巢狀的DbContextScope

這是一個我期望大部分應用程式永遠不需要用到的高階功能。當使用它的時候要認真對待——因為它能導致一些詭異的問題並且很快導致維護的惡魔。

有些時候,一個服務方法可能需要將變化持久化到底層資料庫而不管整個業務事務的結果,就像下面這些情況:

  1. 需要在一個全域性的地方記錄不應當回滾的資訊——即使業務事務失敗。一個典型的例子就是日誌或者審計記錄。
  2. 它需要記錄一個不能回滾的操作的結果。一個典型的例子就是服務方法和非事務性的遠端服務或者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 Fowlerunit 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附加功能的開銷而提供更好的效能。

相關文章