初識CQRS

軟體架構

關於CQRS

最近筆者在研究CQRS Pattern,在整潔架構或是DDD都會看到CQRS部分…

在過往筆者都是三層式架構開發,都會透過Repository Pattern來做資料異動和查詢,那都是會用的是相同實體,在一般業務系統當中,問題不大,隨業務需求增長,系統可能會逐步浮現性能問題,在DB當中可以做到讀寫分離,但在系統開發來說,讀寫方面混合在一起,會浮現出問題。

在CQRS當中,系統分為兩大部分,Command和Query的設計思維。

  1. 在寫資料庫的部分叫做Command:改變某一個物件或整個系統狀態。
  2. 讀取資料庫的叫做Query:回傳值但不會改變物件的狀態。

在實際操作中很容易把這兩個區分出來。

主要是可以讓命令和查詢分開處理,讓系統獨立優化讀取和寫,對於血的部分,可以引入Event Sourcing和複雜業務邏輯來保持資料一致性與完整性。

讀取部分可以透過設計出高效能查詢,同時也可以使用改善效能的技術,例如快取,提高系統性能和快速回應。

CQRS實戰場景

不是每個系統都能用CQRS,稍微評估需求,資料庫一致性,通常是用場景會是讀寫操作非常頻繁時,效能要求高的分散式架構,需要有職責釐清提高安全性和可擴展性,CQRS就能派上用場,同時CQRS也可以魂和其他模式和技術一起使用,例如Event Sourcing、DDD、微服務架構,都跟CQRS連結再一起,可以進一步提升系統效能、伸縮性,可維護性。

CQRS與三層架構差別

三層架構是放在同一個層次處理讀取和寫入,CQRS這是分開來,這樣好處可以優化讀取和寫入的操作,提升效能和擴展性。

單一資料庫CQRS設計

單一資料庫的CQRS,是在Command中做執行使用案例,修改實體狀態,透過ORM框架將實體保存到資料庫,例如:Entity Framework。

 

雙資料庫CQRS

兩個資料庫一個用讀取一個用寫,命令部分針對寫Insert Update Delete做優化資料庫,Query針對Query的DB操作,並進行同步。

Event Sourcing CQRS

透過Event Sourcing來進行,實體發生的狀態,做為快照來進行儲存,透過事件的時間來保存儲存,這一款是比較複雜的CQRS。

 

個人看完CQRS,它要求的技術門檻其實還滿高的….

CQRS實做初體驗

EF Core指令轉移這邊就省略…開始進行CQRS初體驗!

在Model建立

 public class Product
 {
     public int Id { get; set; }
     public string Name { get; set; }
     public string Description { get; set; }
     public int Quantity { get; set; }
     public decimal Price { get; set; }
    
 }

在專案當中建置Data資料夾並建立LocalDBContext

    public partial class LocalDBContext:DbContext
    {
        public LocalDBContext(DbContextOptions<LocalDBContext> options) : base(options)
        {
        }

        public DbSet<Product> Products { get; set; }
    }

分別建立Features和底下的Command和Queries和Repository資料夾

    public interface IProductRepository
    {
        Task<IEnumerable<Product>> GetProducts();
        Task<Product> GetById(int id);
        Task<Product> CreateProduct(Product product);
        Task<int> UpdateProduct(Product player);
        Task<int> DeleteProduct(Product player);
    }
 public class ProductRepository:IProductRepository
 {
     private readonly LocalDBContext _context;

     public ProductRepository(LocalDBContext context)
     {
         _context = context;
     }

     public async Task<IEnumerable<Product>> GetProducts()
     {
         return await _context.Product.ToListAsync();
     }

     public async Task<Product> GetById(int id)
     {
         return await _context.Product.FirstOrDefaultAsync(x => x.Id == id);
     }

     public async Task<Product> CreateProduct(Product product)
     {
         _context.Product.Add(product);
         await _context.SaveChangesAsync();
         return product;
     }

     public async Task<int> UpdateProduct(Product product)
     {
         _context.Product.Update(product);
         return await _context.SaveChangesAsync();
     }

     public async Task<int> DeleteProduct(Product product)
     {
         _context.Product.Remove(product);
         return await _context.SaveChangesAsync();
     }
 }

先安裝 MediatR並在Command分別三支程式

 public class CreateProductCommand: IRequest<Product>
 {
     public string Name { get; set; }
     public string Description { get; set; }
     public int Quantity { get; set; }
     public decimal Price { get; set; }
     public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Product>
     {
         private readonly IProductRepository _productRepository;

         public CreateProductCommandHandler(IProductRepository productRepository)
         {
             _productRepository = productRepository;
         }

         public async Task<Product> Handle(CreateProductCommand command, CancellationToken cancellationToken)
         {
             var product = new Product()
             {
                Name = command.Name,
                Description = command.Description,
                Quantity = command.Quantity,
                Price = command.Price
             };
             return await _productRepository.CreateProduct(product);
         }
     }
 }
  public class DeleteProductCommand: IRequest<int>
  {
      public int Id { get; set; }

      public class DeleteProductCommandHandler : IRequestHandler<DeleteProductCommand, int>
      {
          private readonly IProductRepository _productRepository;

          public DeleteProductCommandHandler(IProductRepository productRepository)
          {
              _productRepository = productRepository;
          }

          public async Task<int> Handle(DeleteProductCommand command, CancellationToken cancellationToken)
          {
              var product = await _productRepository.GetById(command.Id);
              if (product == null)
              {
                  return default;
              }
              return await _productRepository.DeleteProduct(product);
          }
      }
  }
 public class UpdateProductCommand: IRequest<int>
 {
     public int Id { get; set; }
     public string Name { get; set; }
     public string Description { get; set; }
     public int Quantity { get; set; }
     public decimal Price { get; set; }
     public class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand, int>
     {
         private readonly IProductRepository _productRepository;

         public UpdateProductCommandHandler(IProductRepository productRepository)
         {
             _productRepository = productRepository;
         }

         public async Task<int> Handle(UpdateProductCommand command, CancellationToken cancellationToken)
         {
             var product = await _productRepository.GetById(command.Id);
             if (product == null)
                 return default;
             product.Name = command.Name;
             product.Description = command.Description;
             product.Quantity = command.Quantity;
             product.Price = command.Price;
             return await _productRepository.UpdateProduct(product);
         }
     }
 }

在Queries分別新增GetAllProductQuery和GetProductByIdQuery

 public class GetAllProductQuery: IRequest<IEnumerable<Product>>
 {
     public class GetAllPlayersQueryHandler : IRequestHandler<GetAllProductQuery, IEnumerable<Product>>
     {
         private readonly IProductRepository _productRepository;

         public GetAllPlayersQueryHandler(IProductRepository productRepository)
         {
             _productRepository = productRepository;
         }

         public async Task<IEnumerable<Product>> Handle(GetAllProductQuery query, CancellationToken cancellationToken)
         {
             return await _productRepository.GetProducts();
         }

     }
 }
    public class GetProductByIdQuery:IRequest<Product>
    {
        public int Id { get; set; }

        public class GetPlayerByIdQueryHandler : IRequestHandler<GetProductByIdQuery, Product>
        {
            private readonly IProductRepository _productRepository;

            public GetPlayerByIdQueryHandler(IProductRepository productRepository)
            {
                _productRepository = productRepository;
            }

            public async Task<Product> Handle(GetProductByIdQuery query, CancellationToken cancellationToken)
            {
                return await _productRepository.GetById(query.Id);
            }
        }
    }

最後新增一個ProductController

    public class ProductController : Controller
    {
        private readonly IMediator _mediator;

        public ProductController(IMediator mediator)
        {
            _mediator = mediator;
        }

        public async Task <IActionResult> Index()
        {
            return View(await _mediator.Send(new GetAllProductQuery()));
        }
        public async Task<IActionResult> Detail(int id)
        {
            return View(await _mediator.Send(new GetProductByIdQuery() { Id = id }));
        }
        public async Task<IActionResult> Create()
        {
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create(CreateProductCommand command)
        {
            try
            {
                if (ModelState.IsValid)
                {
                    await _mediator.Send(command);
                    return RedirectToAction(nameof(Index));
                }
            }
            catch (Exception ex)
            {
                ModelState.AddModelError("", "Unable to save changes.");
            }
            return View(command);
        }

        public async Task<IActionResult> Edit(int id)
        {
            return View(await _mediator.Send(new GetProductByIdQuery() { Id = id }));
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(int id, UpdateProductCommand command)
        {
            if (id != command.Id)
            {
                return BadRequest();
            }

            try
            {
                if (ModelState.IsValid)
                {
                    await _mediator.Send(command);
                    return RedirectToAction(nameof(Index));
                }
            }
            catch (Exception ex)
            {
                ModelState.AddModelError("", "Unable to save changes.");
            }
            return View(command);
        }

        [HttpGet]
        public async Task<IActionResult> Delete(int id)
        {
            try
            {
                await _mediator.Send(new DeleteProductCommand() { Id = id });
            }
            catch (Exception ex)
            {
                ModelState.AddModelError("", "Unable to delete. ");
            }

            return RedirectToAction(nameof(Index));
        }

    }

分別加入檢視

@model CQRSMVCProductDemo.Models.Product

@{
    ViewData["Title"] = "Create";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Create Player</h1>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Create" method="post">
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Description" class="control-label"></label>
                <input asp-for="Description" class="form-control" />
                <span asp-validation-for="Description" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Quantity" class="control-label"></label>
                <input asp-for="Quantity" class="form-control" />
                <span asp-validation-for="Quantity" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Price" class="control-label"></label>
                <input asp-for="Price" class="form-control" />
                <span asp-validation-for="Price" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
                <a asp-action="Index" class="btn btn-secondary">Back to List</a>
            </div>
        </form>
    </div>
</div>
@model CQRSMVCProductDemo.Models.Product

@{
    ViewData["Title"] = "Details";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>@Model.Name</h1>
<hr />
<div>

    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Id)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Id)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Description)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Description)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Quantity)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Quantity)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Price)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Price)
        </dd>
    </dl>
</div>
<div>
    @Html.ActionLink("Edit", "Edit", new { id = Model.Id }, new { @class = "btn btn-primary" })
    <a asp-action="Index" class="btn btn-secondary">Back to List</a>
</div>
@model CQRSMVCProductDemo.Models.Product

@{
    ViewData["Title"] = "Edit";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Edit Player</h1>

<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Edit" method="post">
            <input asp-for="Id" class="form-control" type="hidden" />
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Description" class="control-label"></label>
                <input asp-for="Description" class="form-control" />
                <span asp-validation-for="Description" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Quantity" class="control-label"></label>
                <input asp-for="Quantity" class="form-control" />
                <span asp-validation-for="Quantity" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Price" class="control-label"></label>
                <input asp-for="Price" class="form-control" />
                <span asp-validation-for="Price" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
                <a asp-action="Index" class="btn btn-secondary">Back to List</a>
            </div>
        </form>
    </div>
</div>
@model IEnumerable<CQRSMVCProductDemo.Models.Product>

@{
    ViewData["Title"] = "Index";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<div class="row">
    <div class="col">
        <h1>Players</h1>
    </div>
    <div class="col text-right">
        <a asp-action="Create" class="btn btn-success">Create New</a>
    </div>
</div>

<br />
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Id)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Description)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Quantity)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Price)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Id)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Name)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Description)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Quantity)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Price)
                </td>
                <td>
                    @Html.ActionLink("Edit", "Edit", new { id = item.Id }, new { @class = "btn btn-primary" })
                    @Html.ActionLink("Details", "Detail", new { id = item.Id }, new { @class = "btn btn-secondary" })
                    @Html.ActionLink("Delete", "Delete", new { id = item.Id }, new { @class = "btn btn-danger" })
                </td>
            </tr>
        }
    </tbody>
</table>
//注入連線字串與Repository
string connString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<LocalDBContext>(options => options.UseSqlServer(connString));

builder.Services.AddScoped<IProductRepository, ProductRepository>();

後續筆者實際開發的時候,搭配CQRS結合Clean Architecture,做到符合SRP的原則之後,有發現到開發速度上和維護上真的一致性,因為過往在開發階層架構,還沒辦法拆到這麼細,因為階層架構的領域邏輯部分,還是會把CRUD擠在一塊,但採用Clean Architecture+CQRS之後,發現閱讀程式碼和維護與開發上,一致性的效益浮現出來,但是初期開發這個架構是很龜毛…會依據領域邏輯的使用案例來做開發。

日後我們再談談關於Clean Architecture部分。

元哥的筆記