الگوی CQRS و MediatR در ASP.NET Core – ساخت سیستم‌های مقیاس‌پذیر

الگوی CQRS و MediatR در ASP.NET Core – ساخت سیستم‌های مقیاس‌پذیر
فهرست مقاله [نمایش]

     CQRS یا همان جداسازی مسئولیت فرمان و پرس‌وجو (Command Query Responsibility Segregation) به شما کمک می‌کند سیستم‌هایی بسیار تمیز، ساده و در عین حال مقیاس‌پذیر بسازید. در این مقاله، به بررسی این الگو می‌پردازیم و با استفاده از کتابخانه MediatR در ASP.NET Core الگوی CQRS را پیاده‌سازی کرده و یک برنامه‌ی CRUD ساده و مرتب ایجاد می‌کنیم.

    در این مقاله قرار است یک ASP.NET Core Web API بسازیم که قابلیت‌های CRUD داشته باشد و از الگوی CQRS به همراه کتابخانه MediatR استفاده کند. در بین الگوهای طراحی مختلف، CQRS یکی از پرکاربردترین‌هاست؛ زیرا کمک می‌کند معماری سیستم‌ها با اصول معماری تمیز (Clean Architecture) هم‌خوان باشد. به‌زودی مقاله‌ای کامل‌تر درباره‌ی معماری تمیز منتشر خواهم کرد، که بهترین روش برای سازمان‌دهی راهکارهای NET. است. پس بیایید شروع کنیم!

    CQRS چیست؟

    CQRS مخفف Command Query Responsibility Segregation است. این الگو یک روش معماری نرم‌افزار است که عملیات نوشتن (Commands) و خواندن (Queries) را از هم جدا می‌کند. در معماری CQRS، مدل نوشتن و مدل خواندن مستقل از هم طراحی می‌شوند و هر کدام برای هدف خود بهینه‌سازی شده‌اند. این جداسازی باعث ساده‌تر شدن کد و مقیاس‌پذیری بهتر سیستم می‌شود؛ مخصوصاً در برنامه‌های بزرگ و پیچیده‌ای که الگوهای خواندن و نوشتن آن‌ها با هم تفاوت زیادی دارند.

    ایده‌ی CQRS از اصل جداسازی فرمان و پرس‌وجو (Command and Query Separation) که توسط برتراند مایر مطرح شد، گرفته شده است. تعریف آن در ویکی‌پدیا این‌طور بیان شده است:

    هر متد باید یا یک فرمان باشد که عملی انجام می‌دهد، یا یک پرس‌وجو که داده‌ای برمی‌گرداند، اما هر دو نباشد. به بیان ساده، «پرسیدن یک سؤال نباید نتیجه را تغییر دهد». به صورت رسمی‌تر، متدها فقط زمانی باید مقداری برگردانند که شفافیت مرجعی داشته باشند و هیچ اثر جانبی ایجاد نکنند.

    چرا الگوی سنتی کافی نیست؟

    در معماری‌های سنتی، معمولاً برای عملیات خواندن و نوشتن از یک مدل داده یا DTO (Data Transfer Object) مشترک استفاده می‌شود. این رویکرد برای عملیات ساده CRUD (ایجاد، خواندن، به‌روزرسانی و حذف) مناسب است، اما وقتی نیازها پیچیده‌تر می‌شوند، محدودیت‌های آن آشکار می‌شود.

    به عنوان مثال، ممکن است برای عملیات به‌روزرسانی، ویژگی‌هایی لازم باشد که برای عملیات خواندن اهمیتی ندارند. همین تفاوت باعث می‌شود در سناریوهای موازی، احتمال از دست رفتن داده‌ها یا ناهماهنگی به‌وجود بیاید. در چنین شرایطی توسعه‌دهنده مجبور می‌شود از یک DTO مشترک برای کل طول عمر برنامه استفاده کند، مگر اینکه DTO دیگری معرفی کند که خودش معماری را پیچیده‌تر و ناپایدار می‌کند.

    مزیت اصلی CQRS

    ایده‌ی اصلی در CQRS این است که برنامه برای اهداف مختلف، از مدل‌های متفاوت استفاده کند. به زبان ساده:

    یک مدل برای به‌روزرسانی رکوردها،

    یک مدل دیگر برای درج رکورد جدید،

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

    این جداسازی انعطاف‌پذیری زیادی به سیستم می‌دهد و امکان مدیریت ساده‌تر سناریوهای پیچیده را فراهم می‌کند. با CQRS دیگر مجبور نیستید از یک DTO برای همه‌ی عملیات CRUD استفاده کنید، بلکه می‌توانید برای هر عملیات، مدلی مناسب و بهینه داشته باشید.

    📌 در ادامه، یک نمودار از الگوی CQRS آورده می‌شود که این جداسازی را به‌صورت تصویری نشان می‌دهد.

     

    CQRS

    به عنوان مثال، در یک برنامه CRUD می‌توانیم عملیات API را به دو دسته تقسیم کنیم:

    Commands (فرمان‌ها):

    ایجاد (Write / Create)

    به‌روزرسانی (Update)

    حذف (Delete)

    Queries (پرس‌وجوها):

    گرفتن یک آیتم (Get)

    گرفتن لیست آیتم‌ها (List)

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

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

    همچنین در صورت نیاز، می‌توان عملیات نوشتن و خواندن را حتی روی دیتابیس‌های جداگانه انجام داد. اما در مثال ساده‌ی ما، تنها از یک دیتابیس In-Memory استفاده می‌کنیم تا ساختار کلی را نشان دهیم.

    به این ترتیب، جریان‌های کاری مربوط به نوشتن و خواندن داده‌ها به‌طور منطقی از هم جدا می‌شوند.

     

    مزایای CQRS

    استفاده از الگوی CQRS در برنامه‌تان مزایای زیادی دارد. برخی از مهم‌ترین آن‌ها عبارت‌اند از:

    ۱. ساده‌سازی مدل‌های داده (Streamlined DTOs)

    CQRS باعث می‌شود برای هر عملیات، یک مدل داده‌ی مستقل داشته باشید. این موضوع باعث افزایش انعطاف‌پذیری و کاهش پیچیدگی می‌شود.

    ۲. مقیاس‌پذیری (Scalability)

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

    ۳. بهبود عملکرد (Performance Enhancement)

    از آنجایی که معمولاً عملیات خواندن چندین برابر نوشتن هستند، CQRS این امکان را می‌دهد که خواندن داده‌ها را بهینه کنید؛ مثلاً با استفاده از کش‌هایی مانند Redis یا MongoDB.

    ۴. بهبود هم‌زمانی و پردازش موازی (Concurrency & Parallelism)

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

    ۵. افزایش امنیت (Enhanced Security)

    جداسازی خواندن و نوشتن باعث ایجاد مرزهای شفاف‌تر می‌شود. این امر امکان پیاده‌سازی کنترل دسترسی دقیق‌تر را فراهم می‌کند و امنیت کلی سیستم را بالا می‌برد.

    معایب CQRS

    افزایش پیچیدگی و حجم کد

    پیاده‌سازی CQRS معمولاً باعث افزایش چشمگیر حجم کد می‌شود. دلیل آن هم نیاز به مدیریت مدل‌ها و هندلرهای جداگانه برای خواندن و نوشتن است. این مسئله ممکن است نگهداری و رفع اشکال سیستم را سخت‌تر کند.

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

    الگوی CQRS با MediatR در ASP.NET Core  Web API

    بیایید یک پروژه ASP.NET Core Web API بسازیم تا پیاده‌سازی الگو را نمایش دهیم و درک بهتری از CQRS Pattern پیدا کنیم. من راه‌حل پیاده‌سازی‌شده را در GitHub و در مخزن مربوط به سری .NET قرار خواهم داد. خوشحال می‌شوم اگر این مخزن را ستاره بزنید.

    ما چند Minimal API endpoint خواهیم داشت که عملیات CRUD را برای یک موجودیت (Entity) به نام Product انجام می‌دهند. یعنی ایجاد (Create)، حذف (Delete)، به‌روزرسانی (Update) و خواندن (Read) رکوردهای محصول از پایگاه داده. در اینجا از Entity Framework Core به‌عنوان ORM برای دسترسی به داده‌ها استفاده می‌کنیم. برای این نمونه‌ی آموزشی، به یک پایگاه داده واقعی متصل نمی‌شویم و به جای آن از InMemory Database برنامه استفاده خواهیم کرد.

    نکته: ما در اینجا از هیچ الگوی معماری پیشرفته‌ای استفاده نمی‌کنیم، اما سعی می‌کنیم کد تمیز (Clean Code) بنویسیم. محیط توسعه (IDE) من Visual Studio 2022 Community است.

    راه‌اندازی پروژه

    Visual Studio را باز کنید.

    یک پروژه‌ی جدید ASP.NET Core Web API بسازید.

    نصب پکیج‌های مورد نیاز

    پکیج‌های زیر را از طریق Package Manager Console به پروژه‌ی API خود اضافه کنید:

    Install-Package Microsoft.EntityFrameworkCore
    Install-Package Microsoft.EntityFrameworkCore.InMemory
    Install-Package MediatR

    ساختار راه‌حل (Solution Structure)

    ما برای نمایش معماری تمیز (Clean Architecture) اسمبلی‌های جداگانه ایجاد نمی‌کنیم، اما کد را در همان اسمبلی با ساختار منظم سازماندهی خواهیم کرد. عملیات CRUD از طریق پوشه‌ها از هم جدا می‌شوند. این رویکرد تقریباً شبیه به معماری Vertical Slice Architecture مینیمال است.

    دامنه (Domain)

    ابتدا مدل دامنه را می‌سازیم. یک پوشه‌ی جدید با نام Domain ایجاد کنید و یک کلاس #C با نام Product بسازید:

    public class Product
    {
        public Guid Id { get; set; }
        public string Name { get; set; } = default!;
        public string Description { get; set; } = default!;
        public decimal Price { get; set; }
    
        // سازنده بدون پارامتر برای EF Core
        private Product() { }
    
        public Product(string name, string description, decimal price)
        {
            Id = Guid.NewGuid();
            Name = name;
            Description = description;
            Price = price;
        }
    }

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

    EFCore DbContext

    می‌رسیم به بخش داده‌ها. همان‌طور که اشاره شد، از EF Core و InMemory Database استفاده خواهیم کرد. از آنجایی که پکیج‌های موردنیاز را قبلاً نصب کرده‌ایم، حالا بیایید یک DbContext بسازیم تا بتوانیم با منبع داده تعامل داشته باشیم. همچنین در زمان راه‌اندازی برنامه، مقداری داده‌ی اولیه (Seed Data) را به پایگاه داده‌ی InMemory اضافه می‌کنیم.

    ایجاد DbContext

    یک پوشه‌ی جدید با نام Persistence ایجاد کنید و یک کلاس جدید به نام AppDbContext اضافه کنید:

    public class AppDbContext : DbContext
    {
        public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
        {
            Database.EnsureCreated();
        }
    
        public DbSet<Product> Products { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>().HasKey(p => p.Id);
    
            modelBuilder.Entity<Product>().HasData(
                new Product("iPhone 15 Pro", "Apple's latest flagship smartphone with a ProMotion display and improved cameras", 999.99m),
                new Product("Dell XPS 15", "Dell's high-performance laptop with a 4K InfinityEdge display", 1899.99m),
                new Product("Sony WH-1000XM4", "Sony's top-of-the-line wireless noise-canceling headphones", 349.99m)
            );
        }
    
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseInMemoryDatabase("codewithmukesh");
        }
    }

    توضیحات

    در سازنده‌ی DbContext، با دستور Database.EnsureCreated() مطمئن می‌شویم که پایگاه داده ساخته شده است. این موضوع به درج داده‌های اولیه‌ای که در تابع OnModelCreating تعریف کرده‌ایم نیز کمک می‌کند.

    در تابع OnModelCreating:

    کلید اصلی (Primary Key) جدول Product را به عنوان Id مشخص می‌کنیم.

    داده‌های اولیه (Seed Data) برای موجودیت Product اضافه می‌کنیم، مثل iPhone 15 Pro، Dell XPS 15 و Sony WH-1000XM4.

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

    ثبت DbContext در DI Container

    حالا باید DbContext را در Dependency Injection Container ثبت کنیم. فایل Program.cs را باز کرده و کد زیر را اضافه کنید:

    builder.Services.AddDbContext<AppDbContext>();

     الگوی Mediator

    در برنامه‌های ASP.NET Core، کنترلرها یا Minimal API endpoints بهتر است صرفاً روی مدیریت درخواست‌های ورودی، مسیردهی آن‌ها به سرویس‌ها یا اجزای منطق کسب‌وکار مناسب، و برگرداندن پاسخ تمرکز کنند. سبک و متمرکز نگه داشتن کنترلرها به داشتن یک کد تمیز و قابل‌فهم کمک می‌کند.

    یک رویکرد درست این است که منطق پیچیده‌ی کسب‌وکار، اعتبارسنجی داده‌ها و سایر وظایف سنگین را به کلاس‌های سرویس یا کتابخانه‌های جداگانه منتقل کنیم. این تفکیک مسئولیت‌ها باعث می‌شود قابلیت نگهداری (Maintainability)، تست‌پذیری (Testability) و مقیاس‌پذیری (Scalability) برنامه افزایش یابد.

    با دنبال کردن این رویکرد، همچنین به اصل Single Responsibility Principle (SRP) پایبند خواهید بود و کنترلرهایتان تمیز، متمرکز و آسان برای نگهداری باقی می‌مانند.

    نقش الگوی Mediator

    الگوی Mediator نقش مهمی در کاهش وابستگی میان اجزای یک برنامه ایفا می‌کند. این کار با ایجاد ارتباط غیرمستقیم بین اجزا از طریق یک شیء واسط (Mediator Object) انجام می‌شود. نتیجه‌ی این کار، کدی سازمان‌یافته‌تر و قابل مدیریت‌تر است، زیرا منطق ارتباطات در یک مکان متمرکز می‌شود.

    در زمینه‌ی CQRS (تفکیک مسئولیت فرمان و پرس‌وجو)، الگوی Mediator به‌ویژه مفید است. چون در CQRS عملیات‌های خواندن (Read) و نوشتن (Write) جدا شده‌اند، Mediator می‌تواند به‌عنوان پل ارتباطی بین بخش فرمان و پرس‌وجو عمل کند.

    با استفاده از Mediator در کنار CQRS، می‌توانید به یک معماری تمیزتر برسید؛ جایی که فرمان‌ها جدا از پرس‌وجوها مدیریت می‌شوند، و این منجر به سیستمی مقیاس‌پذیرتر و قابل‌نگهداری‌تر خواهد شد.

    کتابخانه MediatR

    MediatR یک کتابخانه‌ی محبوب در دات‌نت است که به شما کمک می‌کند الگوی Mediator را بدون هیچ وابستگی اضافی پیاده‌سازی کنید.

    MediatR در واقع یک سیستم پیام‌رسانی درون‌پردازشی است.

    از انواع مختلف الگوها پشتیبانی می‌کند:

    Request/Response

    Command

    Query

    Notification

    Event

    MediatR همیشه برای من انتخاب اول است وقتی یک پروژه‌ی جدید دات‌نت را شروع می‌کنم! این ابزار فوق‌العاده پیاده‌سازی الگوی Mediator را ساده می‌کند و به من کمک می‌کند کدی تمیز و سازمان‌یافته داشته باشم، با معماری سست‌وابسته‌تر (Loosely Coupled).

    ثبت MediatR در پروژه

    از آنجایی که پکیج لازم را قبلاً نصب کرده‌ایم، حالا باید هندلرهای MediatR را به DI Container برنامه اضافه کنیم. فایل Program.cs را باز کنید و کد زیر را اضافه نمایید:

    builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));

    این دستور همه‌ی هندلرهای MediatR موجود در اسمبلی فعلی را ثبت می‌کند. اگر پروژه‌تان را به چند اسمبلی گسترش دهید، باید اسمبلی‌ای که هندلرها در آن قرار دارند را مشخص کنید.

    در معماری‌های تمیز (Clean Architecture)، این هندلرها معمولاً در لایه‌ی Application قرار می‌گیرند.

    پیاده‌سازی عملیات CRUD

    واژه‌ی CRUD در واقع مخفف Create، Read، Update، Delete است. این‌ها اجزای اصلی RESTful APIها محسوب می‌شوند. بیایید ببینیم چگونه می‌توانیم با استفاده از رویکرد CQRS آن‌ها را پیاده‌سازی کنیم.

    ابتدا در مسیر اصلی پروژه یک پوشه با نام Features/Products ایجاد کنید و درون آن زیرفولدرهایی برای Queries، DTOs و Commands بسازید.

    پوشه بندی cqrs

    معماری Vertical Slice

    هرکدام از این فولدرها کلاس‌ها و سرویس‌های مربوط به خود را در بر خواهند گرفت.

    پوشه‌ی Feature: Vertical Slice Architecture
    این یک نمونه‌ی ساده از معماری Vertical Slice (VSA) است؛ رویکردی که در آن قابلیت‌ها (Features) بر اساس پوشه‌ها سازمان‌دهی می‌شوند.

    به‌عنوان مثال:

    همه چیز مربوط به ایجاد محصول (Product Creation) در مسیر
    Features/Product/Commands/Create
    قرار خواهد گرفت.

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

    سازماندهی بهتر کد

    خوانایی بالاتر

    سهولت در نگهداری

    به‌ویژه در پروژه‌های بزرگ، این ساختار بسیار سودمند خواهد بود.

     

    DTO

    در APIهای مربوط به Query (مانند Get و List)، ما داده‌ها را بر اساس یک DTO (Data Transfer Object) برمی‌گردانیم.

    در مسیر Features/Product/DTOs یک کلاس جدید با نام ProductDto ایجاد کنید:

    public record ProductDto(Guid Id, string Name, string Description, decimal Price);

    💡 نکته سریع:
    برای تعریف Data Transfer Objectها بهتر است از record استفاده کنید، زیرا به‌طور پیش‌فرض Immutable (تغییرناپذیر) هستند!

    حالت اول: استفاده از کلاس معمولی

    public class GetTodoResponse
    {
        public Guid? Id { get; set; }
        public string Title { get; set; }
        public string Notes { get; set; }
    }

    حالت دوم: استفاده از رکورد (Record)

    public record GetTodoResponse(Guid? Id, string Title, string Notes);

    وقتی می‌خواهیم یک DTO (Data Transfer Object) بسازیم، هدف فقط انتقال داده بین لایه‌های مختلف برنامه است. در این شرایط:

    اگر از کلاس معمولی استفاده کنیم، پراپرتی‌ها معمولاً با get; set; تعریف می‌شوند، یعنی مقدارشان بعداً می‌تواند تغییر کند.

    اما اگر از Record استفاده کنیم، شیء به‌صورت پیش‌فرض Immutable (تغییرناپذیر) خواهد بود، یعنی پس از ساخت نمی‌توان مقدار پراپرتی‌ها را تغییر داد.

    این باعث می‌شود:

    داده‌ها فقط برای انتقال استفاده شوند (و نه منطق اضافی).

    جریان داده‌ها یک‌طرفه و ساده بماند.

    کد تمیزتر و خواناتر شود.

    به همین دلیل، برای تعریف DTOها در سی‌شارپ مدرن، معمولاً رکوردها انتخاب بهتری هستند.

    کوئری‌ها (Queries)

    ابتدا بیایید روی ساخت کوئری‌ها و Query Handlerها تمرکز کنیم. همان‌طور که گفته شد، این بخش شامل دو قسمت خواهد بود: Get و List.

    Get endpoint یک مقدار GUID مشخص دریافت کرده و شیء Product Dto مربوطه را برمی‌گرداند.

    در حالی که عملیات List یک لیست از اشیای Product Dto را بازمی‌گرداند.

    لیست همه محصولات (List All Products)

    در مسیر Features/Product/Queries/List/ دو کلاس با نام‌های ListProductsQuery و ListProductsQueryHandler ایجاد کنید.

    public record ListProductsQuery : IRequest<List<ProductDto>>;

    هر شئ Query / Command از اینترفیس IRequest<T> در کتابخانه‌ی MediatR ارث‌بری خواهد کرد، که در آن T شئ بازگشتی است. در این مورد، خروجی ما List<ProductDto> خواهد بود.

    سپس نیاز داریم که Handlerهای مربوط به کوئری را ایجاد کنیم. اینجا است که ListProductsQueryHandler وارد عمل می‌شود. توجه کنید هر زمان که endpoint مربوط به LIST فراخوانی شود، این Handler فعال خواهد شد.

    public class ListProductsQueryHandler(AppDbContext context) : IRequestHandler<ListProductsQuery, List<ProductDto>>
    {
        public async Task<List<ProductDto>> Handle(ListProductsQuery request, CancellationToken cancellationToken)
        {
            return await context.Products
                .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price))
                .ToListAsync();
        }
    }

    در سازنده‌ی اصلی این Handler، یک نمونه از AppDbContext برای دسترسی به داده‌ها تزریق می‌کنیم. همچنین، همه‌ی Handlerها اینترفیس IRequestHandler<T, R> را پیاده‌سازی می‌کنند؛ که در آن T همان درخواست ورودی (که در اینجا Query است) و R پاسخ (که در اینجا یک لیست از محصولات است) خواهد بود.

    این اینترفیس از ما می‌خواهد متد Handle را پیاده‌سازی کنیم. ما به‌سادگی از DbContext استفاده می‌کنیم تا موجودیت Product را به لیستی از DTOها با مقادیر ID، Name، Description و Price نگاشت کنیم. این لیست بازگردانده خواهد شد.

    وقتی همه Handlerهای خود را ایجاد کردیم، به سراغ نوشتن Minimal Endpointها خواهیم رفت.

    دریافت محصول با شناسه (Get Product By ID)

    در مسیر Features/Product/Queries/Get/ دو کلاس با نام‌های GetProductQuery و GetProductQueryHandler ایجاد کنید.

    public record GetProductQuery(Guid Id) : IRequest<ProductDto>;

    این record query یک پارامتر از نوع GUID خواهد داشت که از سمت کلاینت ارسال می‌شود. این ID برای کوئری گرفتن محصولات از پایگاه داده استفاده خواهد شد.

    public class GetProductQueryHandler(AppDbContext context)
        : IRequestHandler<GetProductQuery, ProductDto?>
    {
        public async Task<ProductDto?> Handle(GetProductQuery request, CancellationToken cancellationToken)
        {
            var product = await context.Products.FindAsync(request.Id);
            if (product == null)
            {
                return null;
            }
            return new ProductDto(product.Id, product.Name, product.Description, product.Price);
        }
    }

    در متد Handle، ما از ID برای گرفتن محصول از پایگاه داده استفاده می‌کنیم. اگر نتیجه خالی باشد، مقدار null بازگردانده می‌شود که نشان‌دهنده‌ی عدم یافتن محصول است. در غیر این صورت، داده‌های محصول به یک شئ ProductDto نگاشت داده می‌شود و برگردانده خواهد شد.

     

    دستورات (Commands)

    حالا که کوئری‌ها و Query Handlerها را ساختیم، بیایید به سراغ Commands برویم.

    ایجاد محصول جدید (Create New Product)

    در مسیر Features/Product/Commands/Create/ دو فایل زیر را ایجاد کنید:

    public record CreateProductCommand(string Name, string Description, decimal Price) : IRequest<Guid>;

    ابتدا خود Command را تعریف می‌کنیم که مقادیر Name، Description و Price را دریافت می‌کند. توجه کنید که این شئ Command قرار است شناسه (ID) محصول تازه ایجادشده را برگرداند.

    public class CreateProductCommandHandler(AppDbContext context) : IRequestHandler<CreateProductCommand, Guid>
    {
        public async Task<Guid> Handle(CreateProductCommand command, CancellationToken cancellationToken)
        {
            var product = new Product(command.Name, command.Description, command.Price);
            await context.Products.AddAsync(product);
            await context.SaveChangesAsync();
            return product.Id;
        }
    }

    در مرحله‌ی بعد، Handler به‌سادگی یک مدل دامنه‌ی Product را از داده‌های ورودی Command ایجاد کرده و آن را در پایگاه داده ذخیره می‌کند. در نهایت، شناسه‌ی محصول تازه ایجادشده بازگردانده می‌شود.
    توجه کنید که تولید مقدار GUID در سازنده‌ی شئ دامنه (Domain Object Constructor) انجام می‌شود.

    حذف محصول با شناسه(Delete Product By ID)

    در مرحله‌ی بعد، قابلیتی برای حذف محصول از طریق مشخص کردن ID خواهیم داشت. برای این کار، کلاس‌های زیر را در مسیر Features/Product/Command/Delete/ ایجاد کنید:

    public record DeleteProductCommand(Guid Id) : IRequest;

    Handler مربوط به Delete مقدار ID را دریافت کرده، رکورد محصول را از پایگاه داده واکشی می‌کند و تلاش می‌کند آن را حذف کند. اگر محصول با ID مشخص‌شده یافت نشود، به‌سادگی از کد Handler خارج می‌شود.

    public class DeleteProductCommandHandler(AppDbContext context) : IRequestHandler<DeleteProductCommand>
    {
        public async Task Handle(DeleteProductCommand request, CancellationToken cancellationToken)
        {
            var product = await context.Products.FindAsync(request.Id);
            if (product == null) return;
            context.Products.Remove(product);
            await context.SaveChangesAsync();
        }
    }

    به‌روزرسانی محصول (Update Product)

    این بخش به‌عنوان تمرین برای شما باقی گذاشته می‌شود. این قسمت بهترین فرصت است تا خودتان دست به کدنویسی بزنید:

    در ریپازیتوری (Repository) مربوطه، Update Command و Handler آن را اضافه کنید.

    همچنین باید یک API Endpoint برای به‌روزرسانی جزئیات محصول ایجاد کنید.

    برای نحوه‌ی پیاده‌سازی Endpointها می‌توانید به بخش بعدی مراجعه کنید.

    Minimal API Endpoints

    اکنون که تمام Commandها/Queryها و Handlerها را ساخته‌ایم، بیایید آن‌ها را به API Endpointهای واقعی متصل کنیم. در این مثال از Minimal API استفاده می‌کنیم، اما می‌توانید از روش سنتی با Controllers هم استفاده کنید.

    فایل Program.cs را باز کنید و کدهای زیر را برای مپ‌کردن Endpointها اضافه کنید:

    app.MapGet("/products/{id:guid}", async (Guid id, ISender mediatr) =>
    {
        var product = await mediatr.Send(new GetProductQuery(id));
        if (product == null) return Results.NotFound();
        return Results.Ok(product);
    });
    
    app.MapGet("/products", async (ISender mediatr) =>
    {
        var products = await mediatr.Send(new ListProductsQuery());
        return Results.Ok(products);
    });
    
    app.MapPost("/products", async (CreateProductCommand command, ISender mediatr) =>
    {
        var productId = await mediatr.Send(command);
        if (Guid.Empty == productId) return Results.BadRequest();
        return Results.Created($"/products/{productId}", new { id = productId });
    });
    
    app.MapDelete("/products/{id:guid}", async (Guid id, ISender mediatr) =>
    {
        await mediatr.Send(new DeleteProductCommand(id));
        return Results.NoContent();
    });

    نکات مهم

    ما از اینترفیس ISender در MediatR برای ارسال Commandها/Queryها به Handlerهای ثبت‌شده استفاده می‌کنیم.

    می‌توانید از IMediator هم استفاده کنید، اما ISender سبک‌تر است و برای اکثر سناریوهای ساده Request-Response کفایت می‌کند.

    در صورتی که نیاز به قابلیت‌های پیشرفته‌تر مانند Notificationها داشته باشید، استفاده از IMediator منطقی‌تر خواهد بود.

    بررسی Endpointها

    GET – در مسیر /products/{id}

    پارامتر ورودی یک GUID است.

    یک Query با این ID ساخته شده و از طریق MediatR ارسال می‌شود.

    اگر نتیجه خالی باشد، پاسخ 404 Not Found بازگردانده می‌شود.

    در غیر این صورت، محصول معتبر برگردانده خواهد شد.

    GET – در مسیر /products

    هیچ پارامتری ندارد.

    تمام محصولات موجود در پایگاه داده بازگردانده می‌شوند.

    (در آینده می‌توان پارامترهایی مثل PageSize، PageNumber و قابلیت‌های مرتب‌سازی و جست‌وجو اضافه کرد).

    POST – در مسیر /products

    یک CreateProductCommand دریافت می‌کند.

    این Command از طریق MediatR به Handler مربوطه ارسال می‌شود.

    اگر شناسه محصول خالی باشد، BadRequest بازگردانده می‌شود.

    در غیر این صورت، پاسخ 201 Created همراه با شناسه محصول جدید برمی‌گردد.

    DELETE – در مسیر /products/{id}

    یک ID به عنوان ورودی دریافت می‌شود.

    این ID به DeleteProductCommandHandler ارسال می‌شود.

    در پایان پاسخ NoContent بازگردانده خواهد شد، بدون توجه به نتیجه‌ی حذف.

     

    💡 همچنین می‌توانید Endpoint مربوط به Update را در همین‌جا اضافه کنید تا عملیات CRUD کامل شود.

     

    تست Endpointها با Swagger

    اکنون پیاده‌سازی ما کامل شده است. بیایید آن را با استفاده از Swagger تست کنیم! برنامه‌ی ASP.NET Core خود را Build و Run کنید و سپس رابط کاربری Swagger را باز کنید.

    تست Endpointها با Swagger

    اولین تست مربوط به Endpoint لیست است. این تست برای بررسی داده‌های اولیه (Seed Data) که قبلاً در پایگاه داده InMemory تعریف کردیم، انجام می‌شود.

    [
      {
        "id": "c2537bef-235d-4a72-9aaf-f5cf1ff2d080",
        "name": "iPhone 15 Pro",
        "description": "Apple's latest flagship smartphone with a ProMotion display and improved cameras",
        "price": 999.99
      },
      {
        "id": "93cfebdb-b3fb-415d-9aba-024cad28df5c",
        "name": "Dell XPS 15",
        "description": "Dell's high-performance laptop with a 4K InfinityEdge display",
        "price": 1899.99
      },
      {
        "id": "2fde15c1-48cb-4154-b055-0a96048fa392",
        "name": "Sony WH-1000XM4",
        "description": "Sony's top-of-the-line wireless noise-canceling headphones",
        "price": 349.99
      }
    ]

    ایجاد یک محصول جدید (Create Product)

    در مرحله‌ی بعد، یک محصول جدید ایجاد می‌کنیم. Payload ورودی:

    {
      "name": "Tesla Model Y",
      "description": "Tesla Model Y",
      "price": 45000
    }

    پاسخ سرور پس از ایجاد موفق محصول:

    {
      "id": "4eb60a75-1dfa-401d-8f65-c4750457d19d"
    }

    دریافت محصول با ID (Get By ID)

    از شناسه‌ی محصولی که تازه ایجاد کردیم، برای تست Get By ID Endpoint استفاده می‌کنیم:

    {
      "id": "4eb60a75-1dfa-401d-8f65-c4750457d19d",
      "name": "Tesla Model Y",
      "description": "Tesla Model Y",
      "price": 45000
    }

    همان‌طور که می‌بینید، پاسخ دقیقاً مطابق انتظار است.

    تست سایر Endpointها

    به همین شکل، می‌توانید DELETE Endpoint را نیز تست کنید تا مطمئن شوید محصول حذف می‌شود. همچنین می‌توانید UPDATE Endpoint (که در بخش تمرین پیاده‌سازی کرده‌اید) را امتحان کنید تا تغییرات در محصول اعمال شوند.

    🔜 در ادامه، ویژگی‌های مهم‌تری از کتابخانه‌ی MediatR را بررسی خواهیم کرد.

     

    اعلان‌های MediatR – سیستم‌های رویدادمحور بدون وابستگی (Decoupled Event-Driven Systems)

    تا اینجا، ما به الگوی درخواست-پاسخ (request-response) در MediatR نگاه کرده‌ایم که شامل یک Handler برای هر درخواست است. اما اگر درخواست‌های شما به چندین Handler نیاز داشته باشند چه؟ برای مثال، هر بار که یک محصول جدید ایجاد می‌کنید، نیاز به یک منطق برای افزودن/تنظیم موجودی دارید، و یک Handler دیگر برای انجام یک عمل تصادفی X. برای ساختن یک سیستم پایدار/قابل اعتماد، باید مطمئن شوید که این Handlerها بدون وابستگی (decoupled) بوده و به صورت جداگانه اجرا می‌شوند.

    اینجاست که اعلان‌ها (notifications) وارد می‌شوند. هر زمان که نیاز دارید چندین Handler به یک رویداد واکنش نشان دهند، اعلان‌ها بهترین گزینه هستند!

    ما کد موجود خود را کمی تغییر خواهیم داد تا این موضوع را نشان دهیم. همان‌طور که اشاره شد، این قابلیت را درون CreateProductCommandHandler اضافه می‌کنیم تا یک اعلان منتشر کند، و دو Handler دیگر را در برابر اعلان جدید ثبت کنیم.

    ابتدا، یک پوشه جدید با نام Notifications ایجاد کنید و رکورد زیر را اضافه نمایید.

    public record ProductCreatedNotification(Guid Id) : INotification;

    توجه داشته باشید که این رکورد از INotification در کتابخانه MediatR ارث‌بری خواهد کرد.

    همان‌طور که اشاره شد، ما دو Handler ایجاد خواهیم کرد که به این اعلان subscribe کنند. در همان پوشه، این فایل‌ها را اضافه کنید.

    public class StockAssignedHandler(ILogger<StockAssignedHandler> logger) : INotificationHandler<ProductCreatedNotification>
    {
        public Task Handle(ProductCreatedNotification notification, CancellationToken cancellationToken)
        {
            logger.LogInformation($"handling notification for product creation with id : {notification.Id}. assigning stocks.");
            return Task.CompletedTask;
        }
    }
    public class RandomHandler(ILogger<RandomHandler> logger) : INotificationHandler<ProductCreatedNotification>
    {
        public Task Handle(ProductCreatedNotification notification, CancellationToken cancellationToken)
        {
            logger.LogInformation($"handling notification for product creation with id : {notification.Id}. performing random action.");
            return Task.CompletedTask;
        }
    }

    من قصد ندارم وارد جزئیات عملکرد هر یک از این Handlerها شوم. برای مقاصد نمایشی، فقط پیام‌های ساده‌ی Log اضافه کرده‌ام که نشان می‌دهند Handlerها فعال شده‌اند. بنابراین ایده این است که هر زمان یک محصول جدید ایجاد شود، یک اعلان ارسال خواهد شد که StockAssignedHandler و RandomHandler ما را فعال خواهد کرد.

    برای انتشار اعلان، کد POST endpoint خود را به شکل زیر تغییر خواهم داد.

    app.MapPost("/products", async (CreateProductCommand command, IMediator mediatr) =>
    {
        var productId = await mediatr.Send(command);
        if (Guid.Empty == productId) return Results.BadRequest();
        await mediatr.Publish(new ProductCreatedNotification(productId));
        return Results.Created($"/products/{productId}", new { id = productId });
    });

    در خط شماره 5، ما از رابط MediatR استفاده خواهیم کرد تا یک اعلان جدید از نوع ProductCreatedNotification ایجاد کرده و آن را در حافظه منتشر کنیم. این کار به نوبه‌ی خود Handler ثبت‌شده در برابر این اعلان را فعال خواهد کرد. بیایید ببینیم این در عمل چگونه کار می‌کند.

    اگر API را اجرا کرده و یک محصول جدید ایجاد کنید، پیام‌های Log زیر را روی سرور مشاهده خواهید کرد.

    ProductCreatedNotification رابط MediatR    Blog      Cqrs And Mediatr In Aspnet Core  20 min read Updated on May 14, 2024 CQRS and MediatR in ASP.NET Core

    همان‌طور که می‌بینید، هر دوی Handlerها به صورت موازی اجرا می‌شوند. این موضوع در هنگام ساخت سیستم‌های رویدادمحور بدون وابستگی (decoupled event-based systems) می‌تواند بسیار مهم باشد.

    همین! این مقاله به پایان رسید. امیدوارم از محتوای آن لذت برده باشید!

    در مقاله‌ی بعدی، درباره‌ی Pipeline Behavior در MediatR صحبت خواهیم کرد و با ترکیب FluentValidation، IExceptionHandler و CQRS یک سیستم مقاوم خواهیم ساخت!

    خلاصه

    ما پیاده‌سازی و تعریف CQRS را بررسی کردیم.

    الگوی Mediator و کتابخانه‌ی MediatR را پوشش دادیم.

    پیاده‌سازی Entity Framework Core، معماری Vertical Slice و بخش‌های دیگر را توضیح دادیم.

    علاوه بر این، الگوهای Request/Response و همچنین Notifications در MediatR را بررسی کردیم.

    اگر چیزی از قلم افتاده یا بخشی از راهنما برایتان واضح نبوده، لطفاً در بخش نظرات اعلام کنید.

     

    سوالات متداول (FAQ)

    CQRS مخفف چیست؟
    CQRS مخفف Command Query Responsibility Segregation است. این یک الگوی طراحی است که عملیات‌های خواندن (Read) و نوشتن (Write) را در یک برنامه از هم جدا می‌کند.

    آیا پیاده‌سازی CQRS باعث کند شدن برنامه می‌شود؟
    خیر، پیاده‌سازی CQRS ذاتاً باعث کندی برنامه نمی‌شود. هرچند ممکن است به کدنویسی بیشتری نیاز داشته باشد، اما در صورت پیاده‌سازی صحیح، می‌تواند با بهینه‌سازی فراخوانی‌های منبع داده، حتی کارایی را بهبود دهد.

    آیا CQRS یک الگوی طراحی مقیاس‌پذیر است؟
    بله، CQRS با هدف مقیاس‌پذیری طراحی شده است و آن را به گزینه‌ای مناسب برای برنامه‌هایی تبدیل می‌کند که نیاز به مقیاس‌پذیری مؤثر دارند.

     

    این مقاله توسط تیم تحریریه باگتو برای برنامه‌نویسان فارسی‌زبان ترجمه شده است تا دسترسی به جدیدترین الگوهای طراحی نرم‌افزار ساده‌تر شود. به باور ما، الگوی CQRS یکی از رویکردهای ارزشمند در معماری نرم‌افزار است که می‌تواند شفافیت، مقیاس‌پذیری و نگهداری پروژه‌ها را به‌طور چشمگیری بهبود دهد. البته استفاده از این الگو باید آگاهانه و متناسب با نیاز پروژه صورت گیرد؛ چرا که در سیستم‌های کوچک و ساده، ممکن است سربار اضافی ایجاد کند. در مجموع، ما CQRS را ابزاری قدرتمند می‌دانیم که برای پروژه‌های پیچیده و در حال رشد، انتخابی هوشمندانه است.

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

    ارسال دیدگاه

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


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

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


    course image

    آموزش Design Patterns در #C

    2,900,000 تومان


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

    course image

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

    9,900,000 تومان

    3,960,000 تومان


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

    }