الگوی Specification: پیاده‌سازی در سی شارپ

الگوی Specification: پیاده‌سازی در سی شارپ
فهرست مقاله [نمایش]

    الگوی Specification موضوع جدیدی نیست و پیاده‌سازی‌های زیادی از آن در اینترنت موجود است. در این مقاله، می‌خواهم کاربردهای این الگو را بررسی کنم و چند پیاده‌سازی رایج آن را با یکدیگر مقایسه کنم.

    ۱. الگوی Specification چیست؟

    الگوی Specification الگویی است که به ما این امکان را می‌دهد تا بخشی از دانش دامنه را در یک واحد مستقل به نام Specification کپسوله کرده و از آن در بخش‌های مختلف کد استفاده مجدد داشته باشیم.

    کاربردهای این الگو به بهترین شکل با یک مثال قابل توضیح است. فرض کنید کلاسی به شکل زیر در مدل دامنه ما وجود دارد:

    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
    }

    حالا فرض کنید کاربران می‌خواهند برخی از فیلم‌های نسبتاً جدید را پیدا کنند تا تماشا کنند. برای پیاده‌سازی این قابلیت، می‌توانیم متدی به کلاس ریپازیتوری اضافه کنیم، به این صورت:

    public class MovieRepository
    {
        public IReadOnlyList<Movie> GetByReleaseDate(DateTime minReleaseDate)
        {
            /* ... */
        }
    }

    اگر نیاز به جست‌وجو بر اساس امتیاز یا ژانر داشته باشیم، می‌توانیم متدهای دیگری را هم معرفی کنیم:

    public class MovieRepository
    {
        public IReadOnlyList<Movie> GetByReleaseDate(DateTime maxReleaseDate) { }
     
        public IReadOnlyList<Movie> GetByRating(double minRating) { }
     
        public IReadOnlyList<Movie> GetByGenre(string genre) { }
    }

    زمانی که تصمیم می‌گیریم معیارهای جست‌وجو را ترکیب کنیم، وضعیت کمی پیچیده‌تر می‌شود، اما هنوز در موقعیت خوبی قرار داریم. می‌توانیم یک متد واحد به نام Find معرفی کنیم که تمام معیارهای ممکن را مدیریت کند و یک نتیجه جست‌وجوی یکپارچه بازگرداند:

    public class MovieRepository
    {
        public IReadOnlyList<Movie> Find(
            DateTime? maxReleaseDate = null, 
            double minRating = 0, 
            string genre = null)
        {
            /* ... */
        }
    }

    و البته، همیشه می‌توانیم معیارهای دیگری را نیز به متد اضافه کنیم.

    مشکلات زمانی شروع می‌شود که نه تنها نیاز داریم داده‌ها را در پایگاه داده جست‌وجو کنیم، بلکه باید آن‌ها را در حافظه نیز اعتبارسنجی کنیم. به عنوان مثال، ممکن است بخواهیم بررسی کنیم که آیا یک فیلم خاص برای کودکان مناسب است یا نه قبل از اینکه بلیت آن را بفروشیم، بنابراین یک اعتبارسنجی به شکل زیر معرفی می‌کنیم:

    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();
    }

    اگر همچنین نیاز به جست‌وجو در پایگاه داده برای پیدا کردن تمام فیلم‌هایی که همان معیار را دارند، داشته باشیم، باید متدی مشابه به شکل زیر معرفی کنیم:

    public class MovieRepository
    {
        public IReadOnlyList<Movie> FindMoviesForChildren()
        {
            return db
                .Where(x => x.MpaaRating == MpaaRating.G)
                .ToList();
        }
    }

    مشکل این کد این است که اصل DRY (Don't Repeat Yourself) نقض شده است، زیرا دانش دامنه در مورد اینکه چه چیزی باید یک فیلم مناسب برای کودکان در نظر گرفته شود، در دو مکان مختلف پراکنده است: در متد BuyChildTicket و در کلاس MovieRepository. در اینجا است که الگوی Specification می‌تواند به ما کمک کند. می‌توانیم یک کلاس جدید معرفی کنیم که دقیقاً می‌داند چگونه انواع مختلف فیلم‌ها را از هم تمایز دهد. سپس می‌توانیم از این کلاس در هر دو سناریو استفاده کنیم:

    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)
        {
            /* ... */
        }
    }

    این رویکرد نه تنها تکرار دانش دامنه را از بین می‌برد، بلکه امکان ترکیب چندین Specification را نیز فراهم می‌کند. این امر به نوبه خود به ما کمک می‌کند تا معیارهای جست‌وجو و اعتبارسنجی پیچیده‌تری را به راحتی تنظیم کنیم.

    الگوی Specification سه کاربرد اصلی دارد:

    جست‌وجو در پایگاه داده: یعنی پیدا کردن رکوردهایی که با مشخصات داده‌شده هم‌خوانی دارند.

    اعتبارسنجی اشیاء در حافظه: به عبارت دیگر، بررسی اینکه آیا شیء بازیابی‌شده یا ساخته‌شده با مشخصات تطابق دارد یا خیر.

    ایجاد یک نمونه جدید که با معیارها هم‌خوانی داشته باشد: این کاربرد در سناریوهایی مفید است که شما به محتوای واقعی نمونه‌ها اهمیت نمی‌دهید، ولی هنوز نیاز دارید که ویژگی‌های خاصی داشته باشند.

    ما دو کاربرد اول را بررسی خواهیم کرد، چرا که در تجربه من، این‌ها بیشتر رایج هستند.

    ۲. پیاده‌سازی ساده

    ابتدا با یک پیاده‌سازی ساده از الگوی Specification شروع می‌کنیم و سپس به پیاده‌سازی بهتری می‌پردازیم.

    اولین راه‌حلی که به ذهن می‌رسد زمانی که با مشکل فوق مواجه می‌شویم، استفاده از عبارت‌های سی شارپ (expressions) است. در واقع، به‌طور قابل‌توجهی، این‌ها خود یک پیاده‌سازی از الگوی Specification هستند. می‌توانیم به راحتی یکی از آن‌ها را در کد تعریف کنیم و در هر دو سناریو از آن استفاده کنیم، مانند این:

    // Controller
    public void SomeMethod()
    {
        Expression<Func<Movie, bool>> expression = m => m.MpaaRating == MpaaRating.G;
        bool isOk = expression.Compile().Invoke(movie); // بررسی یک فیلم
        var movies = _repository.Find(expression); // گرفتن لیستی از فیلم‌ها
    }
     
    // Repository
    public IReadOnlyList<Movie> Find(Expression<Func<Movie, bool>> expression)
    {
        return db
            .Where(expression)
            .ToList();
    }

    اما مشکل این روش این است که اگرچه دانش دامنه در مورد چگونگی دسته‌بندی فیلم‌های کودکانه در یک مکان (متغیر expression) جمع‌آوری می‌شود، اما انتزاعی که انتخاب کرده‌ایم مناسب نیست. متغیرها به هیچ‌وجه مکان مناسبی برای نگهداری چنین اطلاعات مهمی نیستند. دانش دامنه‌ای که به این صورت نمایش داده می‌شود، به سختی قابل استفاده مجدد است و به دلیل این ویژگی، در سراسر برنامه تکرار می‌شود. در نهایت، ما به همان مشکلی می‌رسیم که از ابتدا داشتیم.

    یک تغییر در این پیاده‌سازی ساده معرفی یک کلاس Specification عمومی است:

    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); // بررسی یک فیلم
        var movies = _repository.Find(specification); // گرفتن لیستی از فیلم‌ها
    }
    
    // Repository
    public IReadOnlyList<Movie> Find(GenericSpecification<Movie> specification)
    {
        return db
            .Where(specification.Expression)
            .ToList();
    }

    این نسخه اساساً همان معایب را دارد، تنها تفاوت این است که در اینجا یک کلاس «wrapper» بر روی عبارت داریم. هنوز برای استفاده مناسب از این Specification، باید یک نمونه از آن را یک‌بار ایجاد کرده و سپس آن را به شکلی در سراسر کد به اشتراک بگذاریم. این طراحی کمک زیادی به رعایت DRY نمی‌کند.

    این به ما یک نتیجه‌گیری مهم می‌دهد: Specificationهای عمومی یک رویه بد هستند. اگر یک Specification به شما اجازه دهد که یک شرط دلخواه را مشخص کنید، این تنها یک ظرف برای اطلاعاتی می‌شود که از سوی مشتری به آن منتقل می‌شود و مشکل اصلی کپسوله‌سازی دانش دامنه را حل نمی‌کند. این گونه Specificationها عملاً هیچ دانش واقعی‌ای ندارند.

    ۳. Specificationهای Strongly-Typed

    چگونه می‌توانیم بر این مشکل غلبه کنیم؟ راه‌حل اینجا استفاده از Specificationهای Strongly-Typed است. یعنی Specificationهایی که در آن‌ها دانش دامنه به‌طور سخت‌افزاری کدگذاری شده است، به طوری که تغییر آن از بیرون تقریباً غیرممکن باشد.

    در اینجا نحوه پیاده‌سازی آن را در عمل نشان می‌دهیم:

    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;
        }
    }

    کنترلر:

    public void SomeMethod()
    {
        var gRating = new MpaaRatingAtMostSpecification(MpaaRating.G);
        bool isOk = gRating.IsSatisfiedBy(movie); // بررسی یک فیلم
        IReadOnlyList<Movie> movies = repository.Find(gRating); // گرفتن لیستی از فیلم‌ها
    }

    ریپازیتوری:

    public IReadOnlyList<T> Find(Specification<T> specification)
    {
        using (ISession session = SessionFactory.OpenSession())
        {
            return session.Query<T>()
                .Where(specification.ToExpression())
                .ToList();
        }
    }

    با این رویکرد، ما دانش دامنه را به سطح کلاس منتقل می‌کنیم که این کار استفاده مجدد را بسیار راحت‌تر می‌کند. دیگر نیازی به پیگیری نمونه‌های Specification نداریم: ایجاد Specificationهای اضافی منجر به تکرار دانش دامنه نمی‌شود، بنابراین می‌توانیم این کار را به راحتی انجام دهیم.

    همچنین، ترکیب Specificationها با استفاده از متدهای And، Or و Not بسیار ساده است. در اینجا نحوه انجام آن را نشان می‌دهیم:

    public abstract class Specification<T>
    {
        public Specification<T> And(Specification<T> specification)
        {
            return new AndSpecification<T>(this, specification);
        }
     
        // همچنین می‌توان از متدهای Or و Not نیز استفاده کرد
    }
    
    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());
        }
    }
    
    var gRating = new MpaaRatingAtMostSpecification(MpaaRating.G);
    var goodMovie = new GoodMovieSpecification();
    var repository = new MovieRepository();
     
    IReadOnlyList<Movie> movies = repository.Find(gRating.And(goodMovie));

    شما می‌توانید کد کامل و نمونه‌های استفاده را در GitHub  ببینید.

    ۴. بازگشت IQueryable<T> از ریپازیتوری

    سوالی که به طور جزئی به الگوی Specification مرتبط است این است که: آیا ریپازیتوری‌ها نمی‌توانند فقط یک IQueryable<T> برگردانند؟ آیا بهتر نیست به کلاینت‌ها اجازه بدهیم که داده‌ها را از پایگاه داده به روش دلخواه خود جست‌وجو کنند؟ به عنوان مثال، می‌توانیم متدی به ریپازیتوری اضافه کنیم به شکل زیر:

    // Repository
    public IQueryable<T> Find()
    {
        return session.Query<T>();
    }

    سپس می‌توانیم از آن در کنترلر استفاده کرده و معیارهای واقعی را به صورت adhoc مشخص کنیم:

    // Controller
    public void SomeMethod()
    {
        List<Movie> movies = _repository.Find()
            .Where(movie => movie.MpaaRating == MpaaRating.G)
            .ToList();
    }

    این رویکرد اساساً همان معایب پیاده‌سازی اولیه الگوی Specification را دارد: این روش باعث می‌شود که اصل DRY (Don’t Repeat Yourself) نقض شود، زیرا دانش دامنه تکرار می‌شود. این تکنیک به ما هیچ چیزی در زمینه‌ی تجمیع این دانش در یک منبع معتبر نمی‌دهد.

    دومین معایب این روش این است که مفاهیم پایگاه داده از ریپازیتوری‌ها به بیرون نشت می‌کنند. پیاده‌سازی IQueryable<T> به شدت به LINQ provider ای که در پشت صحنه استفاده می‌شود بستگی دارد، بنابراین کد کلاینت باید آگاه باشد که ممکن است پرس‌وجوهایی وجود داشته باشند که نتوانند به SQL تبدیل شوند.

    و در نهایت، احتمال نقض اصل LSP (Liskov Substitution Principle) نیز وجود دارد. IQueryableها به صورت Lazy ارزیابی می‌شوند، بنابراین باید ارتباطات زیربنایی را در طول تراکنش‌های تجاری باز نگه داریم. در غیر این صورت، متد با یک استثناء مواجه خواهد شد. جالب اینجاست که پیاده‌سازی‌هایی که با IEnumerables انجام می‌شوند نیز دقیقاً همین مشکل را دارند، بنابراین بهترین راه‌حل برای حل این مشکل بازگرداندن رابط‌های IReadOnlyList یا IReadOnlyCollection است.

     

    خلاصه

    از expressions های سی شارپ به عنوان پیاده‌سازی الگوی Specification استفاده نکنید، زیرا این کار اجازه نمی‌دهد که دانش دامنه را در یک منبع معتبر و متمرکز جمع‌آوری کنید.

    از بازگرداندن IQueryable<T> از ریپازیتوری‌ها خودداری کنید، زیرا این کار مشکلاتی در زمینه‌ی نقض اصل DRY و LSP ایجاد می‌کند و نگرانی‌های پایگاه داده را به منطق برنامه تزریق می‌کند.

    اطلاعات نویسنده

    ارسال دیدگاه

    برای افزودن دیدگاه خود، نیاز است ابتدا وارد حساب کاربری‌تان شوید


    دیدگاه کاربران

    آموزش پیشنهادی باگتو


    course image

    آموزش Design Patterns در #C

    2,900,000 تومان


    اطلاعات بیشتر

    course image

    ستارگان میکروسرویس(microservices)

    9,900,000 تومان

    3,960,000 تومان


    اطلاعات بیشتر

    }