從壹開始微服務 [ DDD ] 之七 ║項目第一次實現 & CQRS初探


前言

哈嘍大家周五好,我們又見面了,感謝大家在這個周五讀我的文章,經過了三周的時間,當然每周兩篇的速度的情況下,咱們簡單說了下DDD領域驅動設計的第一部分,主要包括了,《項目入門DDD架構淺析》,《領域、子領域、限界上下文》,《DDD使用意義》,《實體與值對象》,《聚合與聚合根》這五部分內容,主要的是以解釋為主,舉例子Code為輔的形式,總體來說還是得到一些肯定的,也是我最大的動力了。

上邊這五個知識點是DDD領域驅動設計的第一部分 —— D領域;

從今天開始,咱們就說說DDD的第二個D,就是領域服務+領域命令的CQRS,這些偏重動作的一部分;

最后就是第三部分,通過 領域事件、事件源與事件回溯,配合着權限管理,再統一說一下DDD,這一系列就是結束了。

其實通過我看到這里,我發現了,我們在設計DDD的時候,重要的是思路,重要的是在如何進行領域設計,而不是在框架和技術上面,有時候就算是三層也能配和着實現領域設計,之前有小伙伴說到我些的是OOP,嗯,希望等系列寫完就可以稍微不一樣一些吧。

今天我們的主要工作,就是把前幾天在講述概念的同時,對搭建的項目進行第一次的合圍,能運行起來,當然這里還會涉及到之前我們第一個系列的知識,我們也進行復習下,比如:DI依賴注入、EFCore、Automapper數據傳輸對象,當然還有前幾篇文章中的 實體和值對象的部分概念 , 如果您是第一次看我的文章,可能這些今天不會詳細說明,可以去我的第一個系列開始學習,好啦,馬上開始今天的講解。

 

零、今天實現天青色的部分

 

 一、項目運行、復習系列一相關知識

1、Automapper定義Config配置文件

 1、我們在項目應用層Christ3D.Application 的 AutoMapper 文件夾下,新建AutoMapperConfig.cs 配置文件,

    /// <summary>
    /// 靜態全局 AutoMapper 配置文件
    /// </summary>
    public class AutoMapperConfig
    {
        public static MapperConfiguration RegisterMappings()
        {
            //創建AutoMapperConfiguration, 提供靜態方法Configure,一次加載所有層中Profile定義 
            //MapperConfiguration實例可以靜態存儲在一個靜態字段中,也可以存儲在一個依賴注入容器中。 一旦創建,不能更改/修改。
            return new MapperConfiguration(cfg =>
            {
                //這個是領域模型 -> 視圖模型的映射,是 讀命令
                cfg.AddProfile(new DomainToViewModelMappingProfile());
                //這里是視圖模型 -> 領域模式的映射,是 寫 命令
                cfg.AddProfile(new ViewModelToDomainMappingProfile());
            });
        }
    }

 

 這里你可能會問了,咱們之前在 Blog.Core 前后端分離中,為什么沒有配置這個Config文件,其實我實驗了下,不用配置文件我們也可以達到映射的目的,只不過,我們平時映射文件Profile 比較少,項目啟動的時候,每次都會調取下這個配置文件,你可以實驗下,如果幾十個表,上百個數據庫表,啟動會比較慢,可以使用創建AutoMapperConfiguration, 提供靜態方法Configure,一次加載所有層中Profile定義,大概就是這個意思,這里我先存個疑,有不同意見的歡迎來說我,哈哈歡迎批評。

 

2、上邊代碼中  DomainToViewModelMappingProfile 咱們很熟悉,就是平時用到的,但是下邊的那個是什么呢,那個就是我們 視圖模型 -> 領域模式 的時候的映射,寫法和反着的是一樣的,你一定會說,那為啥不直接這么寫呢,

 

你的想法很棒!這種平時也是可以的,只不過在DDD領域驅動設計中,這個是是視圖模型轉領域模型,那一定是對領域模型就行命令操作,沒錯,就是在領域命令中,會用到這里,所以兩者不能直接寫在一起,這個以后馬上會在下幾篇文章中說到。

 

 

3、將 AutoMapper 服務在 Startup 啟動

在 Christ3D.UI.Web 項目下,新建 Extensions 擴展文件夾,以后我們的擴展啟動服務都寫在這里。

新建 AutoMapperSetup.cs 

    /// <summary>
    /// AutoMapper 的啟動服務
    /// </summary>
    public static class AutoMapperSetup
    {
        public static void AddAutoMapperSetup(this IServiceCollection services)
        {
            if (services == null) throw new ArgumentNullException(nameof(services));
            //添加服務
            services.AddAutoMapper();
            //啟動配置
            AutoMapperConfig.RegisterMappings();
        }
    }

 

2、依賴注入 DI

 之前我們在上個系列中,是用的Aufac 將整個層注入,今天咱們換個方法,其實之前也有小伙伴提到了,微軟自帶的 依賴注入方法就可以。

因為這一塊屬於我們開發的基礎,而且也與數據有關,所以我們就新建一個 IoC 層,來進行統一注入

1、新建 Christ3D.Infra.IoC 層,添加統一注入類 NativeInjectorBootStrapper.cs

更新:已經把該注入文件統一放到了web層:

 

     public static void RegisterServices(IServiceCollection services)
     {

            // 注入 Application 應用層
            services.AddScoped<IStudentAppService, StudentAppService>();
          

            // 注入 Infra - Data 基礎設施數據層
            services.AddScoped<IStudentRepository, StudentRepository>();
            services.AddScoped<StudyContext>();//上下文

      }

具體的使用方法和我們Autofac很類型,這里就不說了,相信大家已經很了解依賴注入了。

 

 

 

2、在ConfigureServices 中進行服務注入

 // .NET Core 原生依賴注入
 // 單寫一層用來添加依賴項,可以將IoC與展示層 Presentation 隔離
 NativeInjectorBootStrapper.RegisterServices(services);

 

3、EFCore Code First

1、相信大家也都用過EF,這里的EFCore 也是一樣的,如果我們想要使用 CodeFirst 功能的話,就可以直接對其進行配置,

    public class StudyContext : DbContext
    {
        public DbSet<Student> Students { get; set; }

        /// <summary>
        /// 重寫自定義Map配置
        /// </summary>
        /// <param name="modelBuilder"></param>
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            //對 StudentMap 進行配置
            modelBuilder.ApplyConfiguration(new StudentMap());
                        
            base.OnModelCreating(modelBuilder);
        }

        /// <summary>
        /// 重寫連接數據庫
        /// </summary>
        /// <param name="optionsBuilder"></param>
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            // 從 appsetting.json 中獲取配置信息
            var config = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json")
                .Build();

            //定義要使用的數據庫
            //正確的是這樣,直接連接字符串即可
            //optionsBuilder.UseSqlServer(config.GetConnectionString("DefaultConnection"));
            //我是讀取的文件內容,為了數據安全
            optionsBuilder.UseSqlServer(File.ReadAllText(config.GetConnectionString("DefaultConnection")));
        }
    }

 

2、然后我們就可以配置 StudentMap 了,針對不同的領域模型進行配置,但是這里有一個重要的知識點,請往下看:

    /// <summary>
    /// 學生map類
    /// </summary>
    public class StudentMap : IEntityTypeConfiguration<Student>
    {
        /// <summary>
        /// 實體屬性配置
        /// </summary>
        /// <param name="builder"></param>
        public void Configure(EntityTypeBuilder<Student> builder)
        {
            //實體屬性Map
            builder.Property(c => c.Id)
                .HasColumnName("Id");

            builder.Property(c => c.Name)
                .HasColumnType("varchar(100)")
                .HasMaxLength(100)
                .IsRequired();

            builder.Property(c => c.Email)
                .HasColumnType("varchar(100)")
                .HasMaxLength(11)
                .IsRequired();

            builder.Property(c => c.Phone)
                .HasColumnType("varchar(100)")
                .HasMaxLength(20)
                .IsRequired();

            //處理值對象配置,否則會被視為實體
            builder.OwnsOne(p => p.Address);
           
            //可以對值對象進行數據庫重命名,還有其他的一些操作,請參考官網
            //builder.OwnsOne(
            //    o => o.Address,
            //    sa =>
            //    {
            //        sa.Property(p => p.County).HasColumnName("County");
            //        sa.Property(p => p.Province).HasColumnName("Province");
            //        sa.Property(p => p.City).HasColumnName("City");
            //        sa.Property(p => p.Street).HasColumnName("Street");
            //    }
            //);


            //注意:這是EF版本的寫法,Core中不能使用!!!
            //builder.Property(c => c.Address.City)
            //     .HasColumnName("City")
            //     .HasMaxLength(20);
            //builder.Property(c => c.Address.Street)
            //     .HasColumnName("Street")
            //     .HasMaxLength(20);


            //如果想忽略當前值對象,可直接 Ignore
            //builder.Ignore(c => c.Address);
        }
    }

 

重要知識點:

我們以前用的時候,都是每一個實體對應一個數據庫表,或者有一些關聯,比如一對多的情況,就拿我們現在項目中使用到的來說,我們的 Student 實體中,有一個 Address 的值對象,值對象大家肯定都知道的,是沒有狀態,保證不變性的一個值,但是在EFCore 的Code First 中,系統會需要我們提供一個 Address 的主鍵,因為它會認為這是一個表結構,如果我們為 Address 添加主鍵,那就是定義成了實體,這個完全不是我們想要的,我們設計的原則是一切以領域設計為核心,不能為了數據庫而修改模型。

如果把 Address 當一個實體,增加主鍵,就可以Code First通過,但是這個對我們來說是不行的,我們是從領域設計中考慮,需要把它作為值對象,是作為數據庫字段,你也許會想着直接把 Address 拆開成多個字段放到 Student 實體類中作為屬性,我感覺這樣也是不好的,這樣就達不到我們領域模型的作用了。

我通過收集資料,我發現可以用上邊注釋的方法,直接在 StudentMap 中配置,但是我失敗了,一直報錯

//builder.Property(c => c.Address.City)
// .HasColumnName("City")
// .HasMaxLength(20);

The property 'Student.Address' is of type 'Address' which is not supported by current database provider. Either change the property CLR type or ignore the property using the '[NotMapped]' attribute or by using 'EntityTypeBuilder.Ignore' in 'OnModelCreating'.

本來想放棄的時候,還是強大的博客園博文功能,讓我找到一個大神,然后我參考官網,找到了這個方法。https://docs.microsoft.com/en-us/ef/core/modeling/owned-entities

builder.OwnsOne(p => p.Address);//記得在 Address 值對象上增加一個 [Owned] 特性。

 

3、Code First 到數據庫

我們可以通過以下nuget 命令來控制,這里就不細說了,相信大家用的很多了

//1、初始化遷移記錄 Init 自定義
Add-Migration Init

//2、將當前 Init 的遷移記錄更新到數據庫
update-database Init 

 然后就可以看到我們的的數據庫已經生成:

 

以后大家在遷移數據庫的時候,可能會遇到問題,因為本項目有兩個上下文,大家可以指定其中的操作

 

 

4、添加頁面,運行

1、到這里我們就已經把整體調通了,然后新建 StudentController.cs ,添加 CURD 頁面

 //還是構造函數注入
 private readonly IStudentAppService _studentAppService;

 public StudentController(IStudentAppService studentAppService)
 {
     _studentAppService = studentAppService;
 }

 // GET: Student
 public ActionResult Index()
 {
     return View(_studentAppService.GetAll());
 }

 

2、運行項目,就能看到結果

這個時候,我們已經通過了 DI 進行注入,然后通過Dtos 將我們的領域模型,轉換成了視圖模型,進行展示,也許這個時候你會發現,這個很正常呀,平時都是這么做的,也沒有看到有什么高端的地方,聰明的你一定會想到更遠的地方,這里我們是用領域模型 -> 視圖模型的DTO,也就是我們平時說的查詢模式,

那有查詢,肯定有編輯模式,我們就會有 視圖模型,傳入,然后轉換領域模型,中間當然還有校驗等等(不是簡單的視圖模型的判空,還有其他的復雜校驗,比如年齡,字符串),這個時候,如果我們直接用 視圖模型 -> 領域模型的話,肯定會有污染,至少會把讀和寫混合在一起,

 public void Register(StudentViewModel StudentViewModel)
 {
     //這里引入領域設計中的寫命令 還沒有實現
     //請注意這里如果是平時的寫法,必須要引入Student領域模型,會造成污染

     _StudentRepository.Add(_mapper.Map<Student>(StudentViewModel));
 }

那該怎么辦呢,這個時候CQRS 就登場了!請往下看。

 

二、CQRS 讀寫分離初探

 從上邊的問題中,我們發現,在DDD領域驅動設計中,我們是一起以領域模型為核心的,這個時候出現了幾個概念:

1、DDD中四種模型

如果你是從我的系列的第一篇開始讀,你應該已經對這兩個模型很熟悉了,領域模型,視圖模型,當然,還有咱們一直開發中使用到的數據模型,那第四個是什么呢?

  1. 數據模型:面向持久化,數據的載體。
  2. 領域模型:面向業務,行為的載體。
  3. 視圖模型:面向UI(向外),數據的載體。
  4. 命令模型:面向UI(向內),數據的載體。

這個命令模型Command,就是解決了我們的 視圖模型到領域模型中,出現污染的問題。其他 命令模型,就和我們的領域模型、視圖模型是一樣的,也是一個數據載體,這不過它可以配和着事件,進行復雜的操作控制,這個以后會慢慢說到。

如果你要問寫到哪里,這里簡單說一下,具體的搭建下次會說到,就是在我們的 應用層 AutoMapper 文件夾下,我們的 ViewModelToDomainMappingProfile.cs

 public class ViewModelToDomainMappingProfile : Profile
 {
     public ViewModelToDomainMappingProfile()
     {
         //這里以后會寫領域命令,所以不能和DomainToViewModelMappingProfile寫在一起。
         //學生視圖模型 -> 添加新學生命令模型
         CreateMap<StudentViewModel, RegisterNewStudentCommand>()
             .ConstructUsing(c => new RegisterNewStudentCommand(c.Name, c.Email, c.BirthDate));
         //學生視圖模型 -> 更新學生信息命令模型
         CreateMap<StudentViewModel, UpdateStudentCommand>()
             .ConstructUsing(c => new UpdateStudentCommand(c.Id, c.Name, c.Email, c.BirthDate));
     }

 

2、傳統 CURD 命令有哪些問題

1、使用同一個對象實體來進行數據庫讀寫可能會太粗糙,大多數情況下,比如編輯的時候可能只需要更新個別字段,但是卻需要將整個對象都穿進去,有些字段其實是不需要更新的。在查詢的時候在表現層可能只需要個別字段,但是需要查詢和返回整個實體對象。

2、使用同一實體對象對同一數據進行讀寫操作的時候,可能會遇到資源競爭的情況,經常要處理的鎖的問題,在寫入數據的時候,需要加鎖。讀取數據的時候需要判斷是否允許臟讀。這樣使得系統的邏輯性和復雜性增加,並且會對系統吞吐量的增長會產生影響。

3、同步的,直接與數據庫進行交互在大數據量同時訪問的情況下可能會影響性能和響應性,並且可能會產生性能瓶頸。

4、由於同一實體對象都會在讀寫操作中用到,所以對於安全和權限的管理會變得比較復雜。

這里面很重要的一個問題是,系統中的讀寫頻率比,是偏向讀,還是偏向寫,就如同一般的數據結構在查找和修改上時間復雜度不一樣,在設計系統的結構時也需要考慮這樣的問題。解決方法就是我們經常用到的對數據庫進行讀寫分離。 讓主數據庫處理事務性的增,刪,改操作(Insert,Update,Delete)操作,讓從數據庫處理查詢操作(Select操作),數據庫復制被用來將事務性操作導致的變更同步到集群中的從數據庫。這只是從DB角度處理了讀寫分離,但是從業務或者系統上面讀和寫仍然是存放在一起的。他們都是用的同一個實體對象。

要從業務上將讀和寫分離,就是接下來要介紹的命令查詢職責分離模式。

 

3、什么是 CQRS 讀寫分離

以下信息來自@寒江獨釣的博文,我看着寫的很好:

CQRS最早來自於Betrand Meyer(Eiffel語言之父,開-閉原則OCP提出者)提到的一種 命令查詢分離 (Command Query Separation,CQS) 的概念。其基本思想在於,任何一個對象的方法可以分為兩大類:

  • 命令(Command):不返回任何結果(void),但會改變對象的狀態。
  • 查詢(Query):返回結果,但是不會改變對象的狀態,對系統沒有副作用。

根據CQS的思想,任何一個方法都可以拆分為命令和查詢兩部分,比如:

  public StudentViewModel Update(StudentViewModel StudentViewModel)
  {
      //更新操作
      _StudentRepository.Update(_mapper.Map<Student>(StudentViewModel));

      //查詢操作
      return _mapper.Map<StudentViewModel>(_StudentRepository.GetById(StudentViewModel.Id));
  }

 

這個方法,我們執行了一個命令即對更新Student,同時又執行了一個Query,即查詢返回了Student的值,如果按照CQS的思想,該方法可以拆成Command和Query兩個方法,如下:

 public StudentViewModel GetById(Guid id)
 {
     return _mapper.Map<StudentViewModel>(_StudentRepository.GetById(id));
 }


 public void Update(StudentViewModel StudentViewModel)
 {
     _StudentRepository.Update(_mapper.Map<Student>(StudentViewModel));
 }

 

操作和查詢分離使得我們能夠更好的把握對象的細節,能夠更好的理解哪些操作會改變系統的狀態。當然CQS也有一些缺點,比如代碼需要處理多線程的情況。

CQRS是對CQS模式的進一步改進成的一種簡單模式。 它由Greg Young在CQRS, Task Based UIs, Event Sourcing agh! 這篇文章中提出。“CQRS只是簡單的將之前只需要創建一個對象拆分成了兩個對象,這種分離是基於方法是執行命令還是執行查詢這一原則來定的(這個和CQS的定義一致)”。

CQRS使用分離的接口將數據查詢操作(Queries)和數據修改操作(Commands)分離開來,這也意味着在查詢和更新過程中使用的數據模型也是不一樣的。這樣讀和寫邏輯就隔離開來了。

使用CQRS分離了讀寫職責之后,可以對數據進行讀寫分離操作來改進性能,可擴展性和安全。如下圖:

4、CQRS 的應用場景

在下場景中,可以考慮使用CQRS模式:

  1. 當在業務邏輯層有很多操作需要相同的實體或者對象進行操作的時候。CQRS使得我們可以對讀和寫定義不同的實體和方法,從而可以減少或者避免對某一方面的更改造成沖突;
  2. 對於一些基於任務的用戶交互系統,通常這類系統會引導用戶通過一系列復雜的步驟和操作,通常會需要一些復雜的領域模型,並且整個團隊已經熟悉領域驅動設計技術。寫模型有很多和業務邏輯相關的命令操作的堆,輸入驗證,業務邏輯驗證來保證數據的一致性。讀模型沒有業務邏輯以及驗證堆,僅僅是返回DTO對象為視圖模型提供數據。讀模型最終和寫模型相一致。
  3. 適用於一些需要對查詢性能和寫入性能分開進行優化的系統,尤其是讀/寫比非常高的系統,橫向擴展是必須的。比如,在很多系統中讀操作的請求時遠大於寫操作。為適應這種場景,可以考慮將寫模型抽離出來單獨擴展,而將寫模型運行在一個或者少數幾個實例上。少量的寫模型實例能夠減少合並沖突發生的情況
  4. 適用於一些團隊中,一些有經驗的開發者可以關注復雜的領域模型,這些用到寫操作,而另一些經驗較少的開發者可以關注用戶界面上的讀模型。
  5. 對於系統在將來會隨着時間不段演化,有可能會包含不同版本的模型,或者業務規則經常變化的系統
  6. 需要和其他系統整合,特別是需要和事件溯源Event Sourcing進行整合的系統,這樣子系統的臨時異常不會影響整個系統的其他部分。

 

 這里我只是把CQRS的初衷簡單說了一下,下一節我們會重點來講解 讀寫分離 的過程,以及命令是怎么配合着 Validations 進行驗證的。

 

三、結語

今天暫時就寫到這里吧,通過今天的學習,我們復習了第一系列中的依賴注入DI、DTO數據傳輸對象以及EFCore 的相關操作,重點說明了下,我們在DDD領域驅動設計中,如何在領域實體和值對象中,通過Code First生成數據庫,並且強調了在領域設計中,一切要以領域模型為核心。最后簡單引入了 CQRS 讀寫分離模式的簡單概念,我會在下一節繼續深入對其進行研究。

 

四、GitHub & Gitee

https://github.com/anjoy8/ChristDDD

https://gitee.com/laozhangIsPhi/ChristDDD 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM