Triển khai pattern Specification trong C#


Mẫu Specification không phải là một thứ gì mới vì có rất nhiều triển khai trên Internet rồi. Trong bài viết này tôi muốn thảo luận về trường hợp sử dụng pattern và so sánh với 1 số cách triển khai phổ biến khác.

Mẫu specification là gì?

Specification là một pattern cho phép chúng ta đóng gói một vài các thông tin về nghiệp vụ vào trong một đơn vị code và có thể sử dụng lại chúng trong những chỗ khác nhau. Giúp cho code của chúng ta tăng tính sử dụng lại và dễ đọc hiểu, dễ bảo trì hơn.

Các trường hợp sử dụng mẫu này sẽ là ví dụ dễ hiểu nhất. Chúng ta hãy xem class sau nhé:

public class Movie : Entity

{

    public string Name { get; }

    public DateTime ReleaseDate { get; }

    public MpaaRating MpaaRating { get; }

    public string Genre { get; }

    public double Rating { get; }

}

 

public enum MpaaRating

{

    G,

    PG13,

    R

}

Giờ hãy để người dùng tìm một vài các bộ phim mới để xem nha. Để triển khai, bạn có thể thêm method trong repository class như sau:

public class MovieRepository

{

    public IReadOnlyList<Movie> GetByReleaseDate(DateTime minReleaseDate)

    {

        /* … */

    }

}

Nếu ta cần tìm theo đánh giá hoặc thể loại, chúng ta có thể viết thêm vào phương thức khác như:

public class MovieRepository

{

    public IReadOnlyList<Movie> GetByReleaseDate(DateTime maxReleaseDate) { }



    public IReadOnlyList<Movie> GetByRating(double minRating) { }



    public IReadOnlyList<Movie> GetByGenre(string genre) { }

}

Những điều kiện này có vẻ phức tạp hơn khi chúng ta kết hợp các điều kiện tìm kiếm, nhưng chúng ta vẫn ổn phải không nào? Vậy thì chỉ cần làm một method Find để tìm tất cả các điều kiện đó thôi và trả về một kết quả thống nhất:

public class MovieRepository

{

    public IReadOnlyList<Movie> Find(

        DateTime? maxReleaseDate = null,

        double minRating = 0,

        string genre = null)

    {

        /* … */

    }

}

Và tất nhiên, chúng ta có thể luôn thêm các điều kiện tìm kiếm khác vào phương thức này vẫn ok.

Vấn đề xảy ra là khi chúng ta không chỉ cần tìm kiếm dữ liệu trong database mà còn muốn kiểm tra nó trong bộ nhớ. Ví dụ, chúng ta muốn kiểm tra xem một bộ phim có được phép chiếu cho trẻ em trước khi bán vé không? Chúng ta cần kiểm tra như sau:

public Result BuyChildTicket(int movieId)

{

    Movie movie = _repository.GetById(movieId);



    if (movie.MpaaRating != MpaaRating.G)

        return Error(“The movie is not eligible for children”);



    return Ok();

}

Nếu muốn tìm kiếm trong DB tất cả các bộ phim đạt yêu cầu trên, chúng ta cần làm một method tương tự như sau:

public class MovieRepository

{

    public IReadOnlyList<Movie> FindMoviesForChildren()

    {

        return db

            .Where(x => x.MpaaRating == MpaaRating.G)

            .ToList();

    }

}

Vấn đề với đoạn code này là vi phạm nguyên tắc DRY (Don’t repeat yourself) là những gì xem xét cho một bộ phim cho trẻ em giờ được để ở 2 nơi khác nhau: phương thức BuyChildTicket và MovieRepository. Vấn đề này có thể được giải quyết bằng cách sử dụng pattern Sepcification. Chúng ta có thể tạo ra class mới có thể phân biệt các loại phim khác nhau. Chúng ta có thể sử dụng lại class này cả 2 kịch bản:

public Result BuyChildTicket(int movieId)

{

    Movie movie = _repository.GetById(movieId);



    var spec = new MovieForKidsSpecification();



    if (!spec.IsSatisfiedBy(movie))

        return Error(“The movie is not eligible for children”);



    return Ok();

}

public class MovieRepository

{

    public IReadOnlyList<Movie> Find(Specification<Movie> specification)

    {

        /* … */

    }

}

Cách tiếp cận này không chỉ giúp loại bỏ nghiệp vụ bị trùng lặp, nó cũng cho phép kết hợp nhiều các nghiệp vụ lại bởi nhiều Specification. Giúp cho việc dễ dàng cài đặt các điều kiện tìm kiếm phức tạp và kiểm tra dữ liệu.

Có 3 trường hợp chính sử dụng Specification Pattern:

  • Tìm kiếm dữ liệu trong DB. Tìm kiếm các bản ghi thỏa mãn với điều kiện.
  • Kiểm tra các đối tượng trong bộ nhớ. Nói cách khác, kiểm tra xem một đối tượng có phù hợp với điều kiện không
  • Tạo mới một thể hiện thỏa mãn điều kiện. Điều này hữu ích trong các kịch bản khi bạn không quan tâm đến nội dung thực tế của các thể hiện nhưng vẫn cần thông tin các thuộc tính

Chúng ta sẽ thảo luận 2 trường hợp trong hầu hết các trường hợp dùng đến.

Triển khai sơ khởi

Chúng ta sẽ triển khai pattern specification với một phiên bản đầu tiên, sau đó sẽ làm chúng tốt dần lên.

Giải pháp đầu tiên là khi chúng ta gặp vấn đề được mô tả ở trên sử dụng C# expression. Để tạo một expression, chúng sẽ cần một triển khai của Specification Pattern. Chúng ta có thể dễ dàng định nghĩa trong code và sử dụng chúng cả 2 kịch bản:

// Controller

public void SomeMethod()

{

    Expression<Func<Movie, bool>> expression = m => m.MpaaRating == MpaaRating.G;

    bool isOk = expression.Compile().Invoke(movie); // Exercising a single movie

    var movies = _repository.Find(expression); // Getting a list of movies

}



// Repository

public IReadOnlyList<Movie> Find(Expression<Func<Movie, bool>> expression)

{

    return db

        .Where(expression)

        .ToList();

}

Vấn đề của các tiếp cận này là tuy răng chúng ta đã nhóm nghiệp vụ lại thành một nơi duy nhất (khai báo một biến cho biểu thức), nhưng việc trừu tượng hoá không tốt lắm. Các biến không có phù hợp lắm cho các thông tin nghiệp vụ quan trọng. Trình bày nghiệp vụ theo cách này khó để sử dụng lại và dẫn đến việc trùng lặp trên toàn ứng dụng. Cuối cùng, chúng ta lại gặp phải vấn đề như lúc đầu.

Để khắc phục cho việc triển khai này là tạo một specification generic class:

public class GenericSpecification<T>

{

    public Expression<Func<T, bool>> Expression { get; }



    public GenericSpecification(Expression<Func<T, bool>> expression)

    {

        Expression = expression;

    }



    public bool IsSatisfiedBy(T entity)

    {

        return Expression.Compile().Invoke(entity);

    }

}

// Controller

public void SomeMethod()

{

    var specification = new GenericSpecification<Movie>(

        m => m.MpaaRating == MpaaRating.G);

    bool isOk = specification.IsSatisfiedBy(movie); // Exercising a single movie

    var movies = _repository.Find(specification); // Getting a list of movies

}

// Repository

public IReadOnlyList<Movie> Find(GenericSpecification<Movie> specification)

{

    return db

        .Where(specification.Expression)

        .ToList();

}

Bản này cơ bản là có chung hạn chế như trước, chỉ khác biệt là giờ chúng ta có một class bọc bên ngoài biểu thức. Vẫn có vấn đề với việc sử dụng lại, chúng ta phải tạo một thể hiện của nó và sau đó chia sẻ thể hiện này với nhiều nơi khác nhau. Thiết kế này không giúp ích nhiều cho DRY.

Tóm lại như sau: Generic specification là một cách giải quyết chưa tốt. Nếu một specification cho phép bạn chỉ ra một điều kiện điều kiện tuỳ ý, nó chỉ nên trở thành một nơi chứa các thông tin được truyền vào bởi các thành phần khác và không nên trực tiếp giải quyết các nghiệp vụ đóng gói.

Strongly-typed specifications

Làm sao chúng ta giải quyết vấn đề này? Đó là cách dùng specification chúng ta hard code nghiệp vụ một chút và không thể sửa chúng ở bên ngoài.

Cách để chúng ta triển khai nghiệp vụ:

public abstract class Specification<T>

{

    public abstract Expression<Func<T, bool>> ToExpression();



    public bool IsSatisfiedBy(T entity)

    {

        Func<T, bool> predicate = ToExpression().Compile();

        return predicate(entity);

    }

}

public class MpaaRatingAtMostSpecification : Specification<Movie>

{

    private readonly MpaaRating _rating;



    public MpaaRatingAtMostSpecification(MpaaRating rating)

    {

        _rating = rating;

    }



    public override Expression<Func<Movie, bool>> ToExpression()

    {

        return movie => movie.MpaaRating <= _rating;

    }

}

// Controller

public void SomeMethod()

{

    var gRating = new MpaaRatingAtMostSpecification(MpaaRating.G);

    bool isOk = gRating.IsSatisfiedBy(movie); // Exercising a single movie

    IReadOnlyList<Movie> movies = repository.Find(gRating); // Getting a list of movies

}

// Repository

public IReadOnlyList<T> Find(Specification<T> specification)

{

    using (ISession session = SessionFactory.OpenSession())

    {

        return session.Query<T>()

            .Where(specification.ToExpression())

            .ToList();

    }

}

Với các tiếp cận này, chúng ta sẽ đưa nghiệp vụ vào một class và làm nó dễ sử dụng hơn. Không cần giữ nó trong một thể hiện nào: tạo một đối tượng specification độc lập không làm cho nghiệp vụ bị trùng lặp.

Cũng như vậy, nó thực sự dễ để kết hợp các specification sử dụng các toán tử điều kiện như And, Or hoặc Not. Chúng ta có thể làm như sau:

public abstract class Specification<T>

{

    public Specification<T> And(Specification<T> specification)

    {

        return new AndSpecification<T>(this, specification);

    }



    // And also Or and Not methods

}

public class AndSpecification<T> : Specification<T>

{

    private readonly Specification<T> _left;

    private readonly Specification<T> _right;



    public AndSpecification(Specification<T> left, Specification<T> right)

    {

        _right = right;

        _left = left;

    }



    public override Expression<Func<T, bool>> ToExpression()

    {

        Expression<Func<T, bool>> leftExpression = _left.ToExpression();

        Expression<Func<T, bool>> rightExpression = _right.ToExpression();



        BinaryExpression andExpression = Expression.AndAlso(

            leftExpression.Body, rightExpression.Body);



        return Expression.Lambda<Func<T, bool>>(

            andExpression, leftExpression.Parameters.Single());

    }

}

And this is a usage example:

var gRating = new MpaaRatingAtMostSpecification(MpaaRating.G);

var goodMovie = new GoodMovieSpecification();

var repository = new MovieRepository();



IReadOnlyList<Movie> movies = repository.Find(gRating.And(goodMovie));

Bạn có thể theo dõi source code đầy đủ tại on Github.

Trả về IQueryable<T> từ một repository

Một câu hỏi liên quan đến việc specification pattern là: có thể trả về một IQueryable<T> từ một repository không? Chúng có dễ dàng cho phép các client có thể truy vấn dữ liệu theo cách mà họ muốn? Ví dụ chúng ta cần thêm một phương thức vào repository như sau:

// Repository

public IQueryable<T> Find()

{

    return session.Query<T>();

}

Sau đó sử dụng nó trong controller để tạo ra một điều kiện tìm kiếm:

// Controller

public void SomeMethod()

{

    List<Movie> movies = _repository.Find()

        .Where(movie => movie.MpaaRating == MpaaRating.G)

        .ToList();

}

Cách tiếp cận này về cơ bản giống điểm yếu của chúng ta lúc đầu khi sử dụng sepcification: chúng ta vi phạm nguyên tắc DRY do bị trùng lặp nghiệp vụ. Kỹ thuật này không giúp chúng ta nhóm nghiệp vụ thống nhất tại 1 nơi.

Điểm hạn chế thứ hai là chúng ta làm cho việc truy vấn dữ liệu bị rò rỉ ra ngoài repository. Triển khai IQueryable<T> phụ thuộc lớn vào LinQ Provider sử dụng đằng sau, các client code nên biết đó là một câu truy vấn có thể không biên dịch ra được SQL.

Và cuối cùng, chúng ta có thể lại vi phạm nguyên lắc LSP. Các IQueryable được thực thi lazy, chúng ta cần giữ connection mở trong khi nghiệp vụ được thực thi. Nếu không, phương thức sẽ bị lỗi. Nhân tiện, một triển khai của IEnumerable về cơ bản cũng bị vấn đề tương tự, cách tốt nhất để khắc phục vấn đè này là trả về một IReadOnlyList hoặc IReadOnlyCollection interface.

Source code

Các bạn có thể tham khảo mã nguồn trên Github

Tổng quát

  • Đừng sử dụng C# Expression như một Specification Pattern, chúng không cho phép bạn đặt nghiệp vụ ở 1 nơi duy nhất.
  • Đừng trả về Iqueryable<T> từ repository, chúng sẽ tạo ra vấn đề với DRY và LSP và rò rỉ cơ sở dữ liệu ra ngoài tầng nghiệp vụ.


Trích nguồn từ: (enterprisecraftsmanship.com)