愛流浪的小風

技術隨手寫

使用Asp.Net MVC打造Web Api (7) - 使用EFHook寫入資料庫前自動更新系統資訊

| Comments

有了DA層的單元測試之後,我們將持續的對DA層做一些小小的擴充,讓許多可以透過統一處理的工作在背後自動的被完成,也不用擔心可能因為某些地方少做了處理,而在追問題的時候發生困擾。

我想在每個使用者都會對資料庫的欄位有獨特的見解,像是在一般公司很常見的是一定要在每個Table加上系統資訊欄位,而這些欄位會在新增、更新的時候被觸發,日後追問題的時候就可以很清楚的知道最後是誰做了改動,而這些邏輯如果在DA的每一支程式中各自實作,一定難免會有遺漏,今天我就要跟大家介紹如何使用EFHook來完成這些功能,讓大家開發時可以專注在自己目前的邏輯,而系統應該做的事情就讓他自動完成吧!

EFHook實現原理

EFHooks其實是透過在初始化EntityFramework的DBContext時,註冊所需要的Hooks

接著在Entity Framework儲存資料時,透過override SaveChanges的方式,分別依據儲存的狀態是Insert、Update或Delete,分別執行對應的Hooks,來進行想要執行的資料額外處理,因此相當適用於所有Entity都必須要遵循的規則,例如更新系統資訊欄位等等。

開始撰寫Entity Framework的Hook

為了保留我們寫的Hooks專案日後還可以繼續使用,所以我們不會將他放在DA之中,而是改為放在Utility裡面。

  1. 在Utility新增Hooks專案

  2. 在Hooks專案使用Nuget新增EFHooks,這邊要稍微注意的是要選擇Atreyu.EFHooks,因為原作者似乎已經一段時間沒有維護,而這個是從原作者Fork出來的,之前push了一個request回去作者都還有更新到nuget上

  3. 在Hooks專案定義我們希望系統自動更新的資訊欄位

    public interface ISystemInfo
    {
       string CreatedBy { get; set; }
    
       DateTime CreatedAt { get; set; }
    
       string UpdatedBy { get; set; }
    
       DateTime UpdatedAt { get; set; }
    }
    
  4. 建立Update Hook和Insert Hook,兩者分別繼承了PreInsertHook和PreUpdateHook,我們只要在觸發Hook時更新系統資訊欄位即可

    UpdateSystemInfoPreInsertHook

    public class UpdateSystemInfoPreInsertHook : PreInsertHook<ISystemInfo>
    {
        public HttpContextBase HttpContext { get; set; }
    
        public UpdateSystemInfoPreInsertHook(HttpContextBase httpContext)
        {
            this.HttpContext = httpContext;
        }
    
        public override void Hook(ISystemInfo entity, HookEntityMetadata metadata)
        {
            var userName = "Unlogin";
            if (this.HttpContext != null)
            {
                userName = this.HttpContext.User.Identity.Name;
            }
    
           entity.CreatedBy = userName;
           entity.CreatedAt = DateTime.Now;
           entity.UpdatedBy = userName;
          entity.UpdatedAt = DateTime.Now;
        }
    
        public override bool RequiresValidation
        {
            get { return false; }
        }
    }
    

    UpdateSystemInfoPreUpdateHook

    public class UpdateSystemInfoPreUpdateHook : PreUpdateHook<ISystemInfo>
    {
        public HttpContextBase HttpContext { get; set; }
    
        public UpdateSystemInfoPreUpdateHook(HttpContextBase httpContext)
        {
            this.HttpContext = httpContext;
        }
    
        public override void Hook(ISystemInfo entity, HookEntityMetadata metadata)
        {
            var userName = "Unlogin";
            if (this.HttpContext != null)
            {
                userName = this.HttpContext.User.Identity.Name;
            }
    
            entity.UpdatedBy = userName;
            entity.UpdatedAt = DateTime.Now;
        }
    
        public override bool RequiresValidation
        {
            get { return false; }
        }
    }
    

這樣我們就完成了自動更新系統欄位的邏輯,是不是很簡單呢? 而這邊比較特別的是我將HttpContextBase的實現留在了Contructor等待runtime的自動注入,而不是選擇直接使用HttpContext.Current的方式來呼叫,這是為了讓我們在單元測試方便模擬使用,而特別保留的彈性,當然若是希望可以同時通用在Windows和Web上的話,還需要進行更進一步的處理才行!

將Hooks註冊到Autofac中

  1. 由於目前我們的使用者名稱是直接從HttpContext取得,所以必須在網站的AutofacConfig.cs中註冊讓Autofac遇到Web相關的介面時,自動注入對應的實體(ex. HttpContextBase=>HttpContext)。

    修改WebSite的AutofacConfig.cs

    public class AutofacConfig
    {
        public static void Initialize()
        {
            var builder = new ContainerBuilder();
    
            builder.RegisterControllers(typeof(MvcApplication).Assembly);
    
            //// Enable inject web types, ex:HttpContext
            builder.RegisterModule(new AutofacWebTypesModule());
    
            //// Read autofac settings from config
            builder.RegisterModule(new ConfigurationSettingsReader());
    
            var container = builder.Build();
    
            DependencyResolver.SetResolver(new AutofacDependencyResolver(container));
        }
    }
    
  2. 在DA的Modules註冊Hooks,修改RepositoryModule.cs

    public class RepositoryModule : Autofac.Module
    {
        protected override void Load(ContainerBuilder builder)
        {
            //// Register Repositories
            var repository = Assembly.Load("ApiSample.DA.Repositories");
            builder.RegisterAssemblyTypes(repository).AsImplementedInterfaces();
    
            //// Register Hooks
            var hooks = Assembly.Load("ApiSample.Utility.Hooks");
            builder.RegisterAssemblyTypes(hooks).AsImplementedInterfaces();
    
            builder.RegisterType<ShopContext>().As<ShopContext>();
        }
    }
    
  3. 修改Tables專案的ShopContext,讓它在建構式的時候可以自動注入Hooks並註冊,這邊有個小技巧就是,如果你定義的介面為IEnumerable,那麼Autofac會預設注入所有實作T介面的集合,所以在這邊就可以預設注入所有的Hook,而不用一一定義了。

    public class ShopContext : HookedDbContext
    {
        /// <summary>
        /// For update-database by package management console
        /// </summary>
        public ShopContext()       
        {
        }
    
        /// <summary>
        /// For runtime
        /// </summary>
        /// <param name="hooks"></param>
        public ShopContext(IEnumerable<IPreActionHook> hooks)            
        {
            foreach (var hook in hooks)
            {
                this.RegisterHook(hook);
            }
        }
    }
    

對Hooks進行測試

接下來我們建立一個測試專案來對我們寫好的Hooks進行整合測試,實際測試看看它是否能在寫入或更新資料時自動更新對應的資料欄位,為了方便所以我們這邊直接使用之前預先建立好了Tables來連接資料庫。

  1. 在Utilities建立Hooks.Test專案,記得修改App.Config的ConnectionString到測試資料庫

    <connectionStrings>
     <add name="ApiSample.DA.Tables.ShopContext" providerName="System.Data.SqlClient" connectionString="Data Source=(LocalDb)\ApiSampleTest;Initial Catalog=ShopContextDB;Integrated Security=SSPI;AttachDBFilename=C:\LocalDB\ApiSample\ShopContextTest.mdf" />
    </connectionStrings>
    
  2. 使用Nuget加入Specflow, Specrun, RhinoMock

  3. 新增儲存資料自動更新系統資訊功能.feature.cs和儲存資料自動更新系統資訊功能步驟.cs

  4. 在儲存資料自動更新系統資訊功能步驟.cs中先撰寫每次執行Scenario都會重建資料庫已確保資料環境獨立乾淨

    private ShopContext shopContext;
    
    [BeforeScenario]
    public void ScenarioSetup()
    {
        this.shopContext = new ShopContext();
        this.shopContext.Database.Delete();
    }
    
    [AfterScenario]
    public void SecnarioTeardown()
    {
        this.shopContext.Dispose();
    }
    
  5. 在Feature檔中撰寫測試案例

    #language: zh-TW
    功能: 儲存資料自動更新系統資訊功能
        提供給 DA層
        當系統進行Insert、Update時,自動更新系統欄位
    
    背景: 
        假設 目前登入的使用者為Kirk
        並且 ShopContext當更新時會自動更新系統資訊
    
    場景: 執行新增分類後,分類儲存會自動帶入使用者名稱
        假設 新增分類資料
            | Name   |
            | Fruits |
        當 新增完畢
        那麼 資料庫中包含資料
            | Name   | CreatedBy | UpdatedBy |
            | Fruits | Kirk      | Kirk      |
    
    場景: 執行更新分類後,分類更新資訊會自動帶入使用者名稱
        假設 新增分類資料
            | Name   | 
            | Fruits | 
        假設 更換使用者為David
        當 更新分類名字為Fruit
        那麼 資料庫中包含資料
            | Name  | CreatedBy | UpdatedBy |
            | Fruit | Kirk      | David     |
    
  6. 模擬使用者登入的程式碼,這邊我們之前在Hook的Constructor預留的HttpContextBase就派上用場了,在進行測試時可以使用Mock物件注入,模擬在Web環境下取得使用者

    private HttpContextBase httpContext;
    
    [Given(@"目前登入的使用者為(.*)")]
    public void 假設目前登入的使用者為(string name)
    {
        this.httpContext = MockRepository.GenerateStub<HttpContextBase>();
        this.httpContext.User = MockRepository.GenerateStub<IPrincipal>();
        this.httpContext.User.Stub(i => i.Identity)
                             .Return(MockRepository.GenerateStub<IIdentity>());
        this.httpContext.User.Identity.Stub(i => i.Name)
                                      .Return(name);
    }
    
  7. 接下來完成將Hook在ShopContext初始化時註冊的程式碼

    [Given(@"ShopContext當更新時會自動更新系統資訊")]
    public void 假設ShopContext當更新時會自動更新系統資訊()
    {
        List<IPreActionHook> hooks = new List<IPreActionHook>();
        hooks.Add(new UpdateSystemInfoPreUpdateHook(this.httpContext));
        hooks.Add(new UpdateSystemInfoPreInsertHook(this.httpContext));
    
        this.shopContext = new ShopContext(hooks);
    }   
    
  8. 新增測試的測試程式

    [Given(@"新增分類資料")]
    public void 假設新增分類資料(Table table)
    {
        var categories = table.CreateSet<Category>();
    
        foreach (var category in categories)
        {
            this.shopContext.Categories.Add(category);
        }
    
        this.shopContext.SaveChanges();
    }
    
    [When(@"新增完畢")]
    public void 當新增完畢()
    {
        //ScenarioContext.Current.Pending();
    }
    
    [Then(@"資料庫中包含資料")]
    public void 那麼資料庫中包含資料(Table table)
    {
        var categories = this.shopContext.Categories.ToList();
    
        table.CompareToSet(categories);
    }
    
  9. 比較特別的是在更新的測試程式時,我新增完之後有模擬切換使用者,主要是用來測試是否不同人更新時,會只Update更新相關系統資訊

    [When(@"更新分類名字為(.*)")]
    public void 當更新分類資料(string name)
    {
        var category = this.shopContext.Categories.First();
    
        category.Name = name;
    
        this.shopContext.SaveChanges();
    }
    
    [Given(@"更換使用者為(.*)")]
    public void 假設更換使用者為(string name)
    {
        this.httpContext.User.Identity.BackToRecord();
        this.httpContext.User.Identity.Replay();
    
        this.httpContext.User.Identity.Stub(i => i.Name)
                                      .Return(name);
    }
    
  10. 執行測試,可以看到測試成功

本日小結

透過EFHook,我們可以將對所有DA通用的邏輯統一並封裝為一個個的Class,在需要的DbContext上注入,即可套用到所有跟此DbContext相關的Repository,從此再也不用擔心有遺漏動作的情況發生囉!關於今天的內容如果有任何問題歡迎大家一起討論喔^_^

Comments

comments powered by Disqus