Chuyển đến nội dung chính

Command Query Separation

Lần đầu tiên mình bắt đầu khám phá design pattern CQRS (Command Query Responsibility Segregation), mình phải thừa nhận rằng nó đôi chút khó khăn. Nhưng đến khi mình đối mặt với những thách thức thực tế trong dự án cá nhân, mình mới thấu hiểu giá trị quan trọng của CQRS. Điều làm mình ấn tượng là CQRS không phải là một khái niệm quá rối bời. Ngược lại, khi bạn tiếp tục làm việc và trau dồi kinh nghiệm, bạn sẽ dần dần thích ứng và làm quen với nó.

Như Lỗ Tấn đã nói, "Trên đời này làm gì có đường, người ta đi mãi thì thành đường thôi.". Vì vậy, giai đoạn bắt đầu tìm hiểu luôn là quan trọng nhất. Nếu bắt đầu mọi thứ quá phức tạp từ đầu, có thể các bạn sẽ cảm thấy mất hứng thú và không muốn tiếp tục đọc các phần sau.

Bài viết này gồm nhiều phần, đầu tiên là mình nói về Command Query Separation, sau đó mới là phần Command Query Responsibility Segregation. Mục đích chính là chúng ta đi từ cách tiếp cận đơn giản đến phức tạp.

Command Query Separation

CQS là một nguyên tắc thiết kế mẫu nơi mà việc gửi lệnh (command) và truy vấn (query) phải được tách biệt hoàn toàn. Nguyên tắc này đề xuất rằng mỗi phương thức trong hệ thống phần mềm sẽ là hoặc một lệnh (command) hoặc một truy vấn (query), nhưng không bao giờ cả hai. Điều này giúp đảm bảo tính rõ ràng và dễ bảo trì của mã nguồn.

Ý tưởng cơ bản là chia  các method của 1 đối tượng thành 2 loại:

  • Command (Lệnh): Được sử dụng để thực hiện các thay đổi dữ liệu, như thêm, sửa, xóa.
  • Query (Truy vấn): Được sử dụng để đọc dữ liệu, không gây ảnh hưởng đến trạng thái của hệ thống.
Bạn có thể tham khảo bài viết của bác Martin Fowler tại đây

Giả sử bạn muốn viết 1 BookService, thông thường bạn sẽ có các hàm như sau:

interface IBookService
{
    void Edit(string name);
    Book GetById(long id);
    IEnumerable<Book> GetAllBooks();
}

Ở đây bạn có 2 loại method Read và Write

  • Hàm Edit không trả về giá trị, chỉ cập nhật Book theo name. Đây là hàm thay đổi dữ liệu (mutable data) => command
  • Hàm Get không thay đổi trạng thái (do not mutable the state). Đây là hàm get data => query

Nếu bạn phân tách ra 2 service IBookQueryService và IBookCommandService thì việc quản lý source code trở nên dễ dàng và dễ hiểu hơn.

interface IBookCommandService
{
    void Edit(string name);
}

interface IBookQueryService
{
    Book GetById(long id);
    IEnumerable<Book> GetAllBooks();
}

Một số sai lầm thường gặp

Mình rút ra từ kinh nghiệm mình làm nên nhờ các bạn góp ý thêm

Không phân biệt rõ ràng giữa Command và Query

Sai lầm
interface IBookService
{
    void Edit(string name);
    Book GetById(long id);
    IEnumerable<Book> GetAllBooks();
}
Cách khắc phục
interface IBookCommandService
{
    void Edit(string name);
}

interface IBookQueryService
{
    Book GetById(long id);
    IEnumerable<Book> GetAllBooks();
}
Quiz
Hàm nào dưới đây là hàm Command hợp lệ
1. void getComment (int commentId);
2. Job createJob (Job job);
3. List<Comment> postComment (Comment comment);
4. void approveComment (int commentId);
=> Hàm 4

Hàm nào là hàm Query hợp lệ

1. Task<List<Comment>> GetAllCommentAsync();
2. Task<Comment> GetCommentByIdAsync(int commentId);
3. Task GetAllCommentAsync();
4. Task<Comment> GetAllCommentByIdAsync(int commentId);
=> Hàm 1, 2, 4

Không tuân thủ nguyên tắc Single Responsibility

public class CommentCommandService
{
    // ...
    public async Task PostCommentAndPostToSlack(string name, string url, string content)
    {
        var comment = await this.commentRepo.PostComment(name, url, content);
        
        await this.slackService.SendMessage($@"
            New comment posted:
                => Name: {name}
                => Url: {url}/commentId/{comment.Id}
                => Content: {content}
        ");
    }
}
Khắc phục
public class CommentCommandService
{
    public async Task PostComment(string name, string url, string content)
    {
        await this.commentRepo.PostComment(name, url, content);
    }
	
	public async Task PostToSlack(PostComment comment)
    {
        await this.slackService.SendMessage(comment);
    }
}

Nên sử dụng DTO

DTO (Data transfer object): là các class đóng gói data để chuyển giữa client - server hoặc giữa các service trong microservice. Mục đích tạo ra DTO là để giảm bớt lượng info không cần thiết phải chuyển đi, và cũng tăng cường độ bảo mật.

public class ProductService
{
    public Product GetProductDetails(int productId)
    {
        //...
        return product;
    }
}
Khắc phục
public class ProductQueryService
{
    public ProductDTO GetProductDetails(int productId)
    {
        //...
        return productDto;
    }
}

CQS và CRUD: Sự đồng hành trong thiết kế MVC

Trong lập trình hướng đối tượng, Command Query Separation (CQS) là một design pattern thường gặp, tách biệt các thao tác thay đổi dữ liệu (command) khỏi các thao tác truy vấn dữ liệu (query). Mối liên hệ giữa CQS và CRUD (Create, Read, Update, Delete) trong MVC vô cùng chặt chẽ, mang lại nhiều lợi ích cho việc thiết kế ứng dụng.

CRUD theo cách thông thường:

  • Create: Tạo một bản ghi mới trong database.
  • Read: Truy vấn và lấy dữ liệu từ database.
  • Update: Cập nhật thông tin một bản ghi hiện có.
  • Delete: Xóa một bản ghi khỏi database.

Phân loại CRUD thành Command và Query: 

  1. CRUD Commands: Create, Update, Delete
  2. CRUD Queries: READ

Trong kiến trúc MVC truyền thống, các hành động CRUD thường được trộn lẫn trong các controller, action method, và view model. Nếu bạn sử dụng REST API thì sẽ phân chia theo HTTP Method

  1. HTTP Commands: POST, PUT, DELETE, PATCH
  2. HTTP Queries: GET

Tương ứng bạn sẽ có các hàm trong Use-Case Design.

Ví dụ:

  1. Commands: CreatePost, UpdatePost, DeletePost, PostComment, UpdateComment
  2. Queries: GetPostById, GetAllPosts, GetCommentById, GetAllCommentsForPost

Xây dựng hệ thống sử dụng CQS Pattern

Bạn có thể tham khảo source code ở bài viết: https://www.dotnetcurry.com/patterns-practices/1461/command-query-separation-cqs

Ở bài viết trên, tác giả đã nói khác rõ về cách implement 1 project console application sử dụng CQS như thế nào. Mình bổ sung thêm 1 số ý khác

Đầu tiên, chúng ta sẽ nói về việc chia tách ứng dụng ra thành nhiều component. Nếu ứng dụng của bạn chỉ có vài dòng code như sau thì việc sử dụng CQS sẽ không có nhiều ý nghĩa

//resolve context
var _context = container.Resolve<ApplicationDbContext>();

//save some books if there are none in the database
if (!_context.Books.Any())
{
    _context.Books.Add(new Book()
    {
        Authors = "Andrew Hunt, David Thomas",
        Title = "The Pragmatic Programmer",
        InMyPossession = true,
        DatePublished = new DateTime(1999, 10, 20),
    });

    _context.Books.Add(new Book()
    {
        Authors = "Robert C. Martin",
        Title = "The Clean Coder: A Code of Conduct for Professional Programmers",
        InMyPossession = false,
        DatePublished = new DateTime(2011, 05, 13),
    });

    _context.SaveChanges();

    _Log.Info("Books saved..");
}

_Log.Info("Retrieving all books the NON CQS Way..");

foreach (var _book in _context.Books)
{
    _Log.InfoFormat("Title: {0}, Authors: {1}, InMyPossession: {2}", _book.Title, _book.Authors, _book.InMyPossession);
}

Bạn nên phân ra làm mô hình 3 lớp chuẩn, ở đây mình phân ra làm 2 lớp để đơn giản hóa vấn đề: Console - Business - Database. Tầng business sẽ chứa các services liên quan tới việc xử lý logic.

Ở tầng Business, bạn sẽ chia ra làm 2 thành phần: Service Interfaces (cung cấp API) và Services (implement).

Tới bước này bạn thực hiện việc thêm Query Dispatchers để thực hiện gọi Query hay execute Command tương ứng.


Query Dispatcher
/// <summary>
/// Dispatches a query and invokes the corresponding handler
/// </summary>
public interface IQueryDispatcher
{
	/// <summary>
	/// Dispatches a query and retrieves a query result
	/// </summary>
	/// <typeparam name="TParameter">Request to execute type</typeparam>
	/// <typeparam name="TResult">Request Result to get back type</typeparam>
	/// <param name="query">Request to execute</param>
	/// <returns>Request Result to get back</returns>
	TResult Dispatch<TParameter, TResult>(TParameter query)
		where TParameter : IQuery
		where TResult : IResult;

	/// <summary>
	/// Dispatches a query and retrieves am async query result
	/// </summary>
	/// <typeparam name="TParameter">Request to execute type</typeparam>
	/// <typeparam name="TResult">Request Result to get back type</typeparam>
	/// <param name="query">Request to execute</param>
	/// <returns>Request Result to get back</returns>
	Task<TResult> DispatchAsync<TParameter, TResult>(TParameter query)
		where TParameter : IQuery
		where TResult : IResult;
}

/// <summary>
/// Passed around to all allow dispatching a command and to be mocked by unit tests
/// </summary>
public interface ICommandDispatcher
{
	/// <summary>
	/// Dispatches a command to its handler
	/// </summary>
	/// <typeparam name="TParameter">Command Type</typeparam>
	/// <typeparam name="TResult"></typeparam>
	/// <param name="command">The command to be passed to the handler</param>
	TResult Dispatch<TParameter, TResult>(TParameter command) where TParameter : ICommand where TResult : IResult;


	/// <summary>
	/// Dispatches an async command to its handler
	/// </summary>
	/// <typeparam name="TParameter">Command Type</typeparam>
	/// <typeparam name="TResult"></typeparam>
	/// <param name="command">The command to be passed to the handler</param>
	Task<TResult> DispatchAsync<TParameter, TResult>(TParameter command) where TParameter : ICommand where TResult : IResult;
}
Implement Dispatcher
public class QueryDispatcher : IQueryDispatcher
{
	private readonly IComponentContext _Context;

	public QueryDispatcher(IComponentContext context)
	{
		_Context = context ?? throw new ArgumentNullException(nameof(context));
	}

	public TResult Dispatch<TParameter, TResult>(TParameter query)
		where TParameter : IQuery
		where TResult : IResult
	{
		//Look up the correct QueryHandler in our IoC container and invoke the retrieve method

		var _handler = _Context.Resolve<IQueryHandler<TParameter, TResult>>();
		return _handler.Retrieve(query);
	}

	public async Task<TResult> DispatchAsync<TParameter, TResult>(TParameter query)
		where TParameter : IQuery
		where TResult : IResult
	{ 
		//Look up the correct QueryHandler in our IoC container and invoke the retrieve method

		var _handler = _Context.Resolve<IQueryHandler<TParameter, TResult>>();
		return await _handler.RetrieveAsync(query);
	}
}
public class CommandDispatcher : ICommandDispatcher
{
	private readonly IComponentContext _context;

	public CommandDispatcher(IComponentContext context)
	{
		_context = context ?? throw new ArgumentNullException(nameof(context));
	}

	public TResult Dispatch<TParameter, TResult>(TParameter command) where TParameter : ICommand where TResult : IResult
	{
		//Look up the correct CommandHandler in our IoC container and invoke the Handle method

		var _handler = _context.Resolve<ICommandHandler<TParameter, TResult>>();
		return _handler.Handle(command);
	}

	public async Task<TResult> DispatchAsync<TParameter, TResult>(TParameter command) where TParameter : ICommand where TResult : IResult
	{
		//Look up the correct CommandHandler in our IoC container and invoke the async Handle method

		var _handler = _context.Resolve<ICommandHandler<TParameter, TResult>>();
		return await _handler.HandleAsync(command);
	}
}
Handler
/// <summary>
/// Base interface for command handlers
/// </summary>
/// <typeparam name="TParameter"></typeparam>
/// <typeparam name="TResult"></typeparam>
public interface ICommandHandler<in TParameter, TResult> where TParameter : ICommand
    where TResult : IResult
{
    /// <summary>
    /// Executes a command handler
    /// </summary>
    /// <param name="command">The command to be used</param>
    TResult Handle(TParameter command);

    /// <summary>
    /// Executes an async command handler
    /// </summary>
    /// <param name="command">The command to be used</param>
    Task<TResult> HandleAsync(TParameter command);
}

/// <summary>
/// Base interface for query handlers
/// </summary>
/// <typeparam name="TParameter">Request type</typeparam>
/// <typeparam name="TResult">Request Result type</typeparam>
public interface IQueryHandler<in TParameter, TResult> where TResult : IResult where TParameter : IQuery
{
	/// <summary>
	/// Retrieve a query result from a query
	/// </summary>
	/// <param name="query">Request</param>
	/// <returns>Retrieve Request Result</returns>
	TResult Retrieve(TParameter query);

	/// <summary>
	/// Retrieve a query result async from a query
	/// </summary>
	/// <param name="query">Request</param>
	/// <returns>Retrieve Request Result</returns>
	Task<TResult> RetrieveAsync(TParameter query);
}
Implement Handler
public abstract class QueryHandler<TParameter, TResult> : IQueryHandler<TParameter, TResult>
    where TResult : IResult, new()
    where TParameter : IQuery, new()
{
    protected readonly ILog Log;
    protected ApplicationDbContext ApplicationDbContext;

    protected QueryHandler(ApplicationDbContext applicationDbContext)
    {
        ApplicationDbContext = applicationDbContext;
        Log = LogManager.GetLogger(GetType().FullName);
    }

    public TResult Retrieve(TParameter query)
    {
        var _stopWatch = new Stopwatch();
        _stopWatch.Start();

        TResult _queryResult;

        try
        {
            //do authorization and validatiopn

            //handle the query request
            _queryResult = Handle(query);
            
        }
        catch (Exception _exception)
        {
            Log.ErrorFormat("Error in {0} queryHandler. Message: {1} \n Stacktrace: {2}", typeof(TParameter).Name, _exception.Message, _exception.StackTrace);
            //Do more error more logic here
            throw;
        }
        finally
        {
            _stopWatch.Stop();
            Log.DebugFormat("Response for query {0} served (elapsed time: {1} msec)", typeof(TParameter).Name, _stopWatch.ElapsedMilliseconds);
        }


        return _queryResult;
    }

    public async Task<TResult> RetrieveAsync(TParameter query)
    {
        var _stopWatch = new Stopwatch();
        _stopWatch.Start();

        Task<TResult> _queryResult;

        try
        {
            //do authorization and validatiopn

            //handle the query request
            _queryResult = HandleAsync(query);

        }
        catch (Exception _exception)
        {
            Log.ErrorFormat("Error in {0} queryHandler. Message: {1} \n Stacktrace: {2}", typeof(TParameter).Name, _exception.Message, _exception.StackTrace);
            //Do more error more logic here
            throw;
        }
        finally
        {
            _stopWatch.Stop();
            Log.DebugFormat("Response for query {0} served (elapsed time: {1} msec)", typeof(TParameter).Name, _stopWatch.ElapsedMilliseconds);
        }


        return await _queryResult;
    }

    /// <summary>
    /// The actual Handle method that will be implemented in the sub class
    /// </summary>
    /// <param name="request"></param>
    /// <returns></returns>
    protected abstract TResult Handle(TParameter request);

    /// <summary>
    /// The actual async Handle method that will be implemented in the sub class
    /// </summary>
    /// <param name="request"></param>
    /// <returns></returns>
    protected abstract Task<TResult> HandleAsync(TParameter request);

}
public abstract class CommandHandler<TRequest, TResult> : ICommandHandler<TRequest, TResult>
    where TRequest : ICommand
    where TResult : IResult, new()
{
    protected readonly ILog Log;
    protected ApplicationDbContext ApplicationDbContext;

    protected CommandHandler(ApplicationDbContext context)
    {
        ApplicationDbContext = context;
        Log = LogManager.GetLogger(GetType().FullName);
    }


    public TResult Handle(TRequest command)
    {
        var _stopWatch = new Stopwatch();
        _stopWatch.Start();
        
        TResult _response;

        try
        {
            //do data validation
            //do authorization

            _response = DoHandle(command);
        }
        catch (Exception _exception)
        {
            Log.ErrorFormat("Error in {0} CommandHandler. Message: {1} \n Stacktrace: {2}", typeof(TRequest).Name, _exception.Message, _exception.StackTrace);

            throw;
        }
        finally
        {
            _stopWatch.Stop();
            Log.DebugFormat("Response for query {0} served (elapsed time: {1} msec)", typeof(TRequest).Name, _stopWatch.ElapsedMilliseconds);
        }

        return _response;
    }

    public async Task<TResult> HandleAsync(TRequest command)
    {
        var _stopWatch = new Stopwatch();
        _stopWatch.Start();

        Task<TResult> _response;

        try
        {
            //do data validation
            //do authorization

            _response = DoHandleAsync(command);
        }
        catch (Exception _exception)
        {
            Log.ErrorFormat("Error in {0} CommandHandler. Message: {1} \n Stacktrace: {2}", typeof(TRequest).Name, _exception.Message, _exception.StackTrace);

            throw;
        }
        finally
        {
            _stopWatch.Stop();
            Log.DebugFormat("Response for query {0} served (elapsed time: {1} msec)", typeof(TRequest).Name, _stopWatch.ElapsedMilliseconds);
        }

        return await _response;
    }

    // Protected methods
    protected abstract TResult DoHandle(TRequest request);

    protected abstract Task<TResult> DoHandleAsync(TRequest request);
}
Còn đây là Source code từ dotnetcurry: Mediafire

Tham khảo

Command Query Separation

Command Query Separation (CQS) - A simple but powerful pattern

Command Query Separation | Object-Oriented Design Principles w/ TypeScript 

Microservices using MediatR on .Net Core 3.1 with exception handling Triển khai CQRS Pattern với MediatR trong .NET Core

CQRS – Simple architecture 

Nhận xét

Bài đăng phổ biến từ blog này

[ASP.NET MVC] Authentication và Authorize

Một trong những vấn đề bảo mật cơ bản nhất là đảm bảo những người dùng hợp lệ truy cập vào hệ thống. ASP.NET đưa ra 2 khái niệm: Authentication và Authorize Authentication xác nhận bạn là ai. Ví dụ: Bạn có thể đăng nhập vào hệ thống bằng username và password hoặc bằng ssh. Authorization xác nhận những gì bạn có thể làm. Ví dụ: Bạn được phép truy cập vào website, đăng thông tin lên diễn đàn nhưng bạn không được phép truy cập vào trang mod và admin.

ASP.NET MVC: Cơ bản về Validation

Validation (chứng thực) là một tính năng quan trọng trong ASP.NET MVC và được phát triển trong một thời gian dài. Validation vắng mặt trong phiên bản đầu tiên của asp.net mvc và thật khó để tích hợp 1 framework validation của một bên thứ 3 vì không có khả năng mở rộng. ASP.NET MVC2 đã hỗ trợ framework validation do Microsoft phát triển, tên là Data Annotations. Và trong phiên bản 3, framework validation đã hỗ trợ tốt hơn việc xác thực phía máy khách, và đây là một xu hướng của việc phát triển ứng dụng web ngày nay.

Tổng hợp một số kiến thức lập trình về Amibroker

Giới thiệu về Amibroker Amibroker theo developer Tomasz Janeczko được xây dựng dựa trên ngôn ngữ C. Vì vậy bộ code Amibroker Formula Language sử dụng có syntax khá tương đồng với C, ví dụ như câu lệnh #include để import hay cách gói các object, hàm trong các block {} và kết thúc câu lệnh bằng dấu “;”. AFL trong Amibroker là ngôn ngữ xử lý mảng (an array processing language). Nó hoạt động dựa trên các mảng (các dòng/vector) số liệu, khá giống với cách hoạt động của spreadsheet trên excel.