1. 程式人生 > >從壹開始微服務 [ DDD ] 之七 ║專案第一次實現 & CQRS初探

從壹開始微服務 [ 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

     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'.

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