وقتی CRUD دیگر زبان مناسبی برای مسئله نیست

· 6 دقیقه مطالعه

مهندسی معماری نرم‌افزار #architecture #cqrs #cqs #dotnet #csharp

بیشتر برنامه‌های وب با یک الگوی آشنا شروع می‌شوند: داده را می‌گیریم، در یک فرم نشان می‌دهیم، کاربر آن را تغییر می‌دهد و در پایان ذخیره می‌کنیم.

این الگو برای خیلی از کارها کاملاً مناسب است. مثلاً تغییر نام نمایشی کاربر، تنظیمات یک تیم یا نگهداری یک فهرست داخلی کوچک. ساختنش سریع است، همه آن را می‌شناسند و معمولاً دردسر اضافه‌ای هم ندارد.

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

جریان آشنای DTO رفت‌وبرگشتی

یک برنامه‌ی وب معمولی اغلب چنین مسیری دارد:

Mermaid diagram

رابط کاربری یک DTO می‌خواهد، فیلدهای آن را نمایش می‌دهد و پس از فشردن دکمه‌ی ذخیره، نسخه‌ی تغییرکرده‌اش را به سرور برمی‌گرداند. سرور داده را به مدل داخلی‌اش نگاشت می‌کند، اعتبارسنجی‌های لازم را انجام می‌دهد و تغییر را ذخیره می‌کند. گرگ یانگ این رفت‌وبرگشت DTO را نقطه‌ی شروع بحث خود درباره‌ی CQRS می‌داند.[1]

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

مسئله این است که یک update عمومی، همیشه گویا هم نیست.

فرض کنید در صفحه‌ی موجودی، فیلد Status قابل ویرایش است. کاربر مقدار آن را به Deactivated تغییر می‌دهد، دلیلی می‌نویسد و ذخیره می‌کند.

سرور یک InventoryItemDto جدید می‌گیرد و می‌بیند وضعیت تغییر کرده است. حالا باید معنی این تغییر را حدس بزند:

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

این هدفِ پنهان باید جایی جبران شود. گاهی به شکل شرط‌های بیشتر در سرویس update ظاهر می‌شود، گاهی در رابط کاربری می‌ماند و گاهی تبدیل می‌شود به دستورالعملی که به کاربر می‌گوید کدام فیلدها را با هم تغییر دهد. یانگ به همین نکته اشاره می‌کند: وقتی کلاینت داده‌ی تغییرکرده می‌فرستد، زبان سیستم به Create، Read، Update و Delete محدود می‌شود.[1]

کد باید معنای تغییر را از مقایسه‌ی وضعیت قبل و بعد بازسازی کند.

یک مدل، دو نیاز متفاوت

همان مدلی که قرار است تغییرها را پردازش کند، معمولاً برای queryها هم به کار می‌رود.

در مسیر write، مدل باید از قوانین کسب‌وکار محافظت کند. شاید لازم باشد داده‌ی مرتبطی را بخواند تا بفهمد تغییر مجاز است یا نه و تراکنش را سازگار نگه دارد. مثلاً سفارش نباید قبل از تأیید پرداخت ارسال شود، فرقی هم ندارد درخواست از کدام صفحه یا API آمده است.

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

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

راهنمای CQRS مایکروسافت هم همین تفاوت را یکی از دلایل جداسازی read و write می‌داند: شکل داده و نیازهای عملکردی update معمولاً با query یکسان نیست.[3]

کوچک‌ترین جداسازی مفید: CQS

Command–Query Separation یا CQS با یک قاعده‌ی ساده برای API شروع می‌شود:

CQS به دیتابیس جدید یا سرویس جدا نیاز ندارد. می‌تواند با نامی روشن‌تر شروع شود:

DeactivateInventoryItem(itemId, reason);

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

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

Stack.Pop مجاز است

CQS یک حالت پیش‌فرض است، نه یک آزمون خلوص. Stack.Pop هم stack را تغییر می‌دهد و هم آیتم حذف‌شده را برمی‌گرداند. جدا کردن آن به دو فراخوانی جداگانه، اغلب اوقات کد معمولی را بدتر می‌کند، برای همین فاولر آن را یک استثنای منطقی می‌داند.[2]

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

وقتی CQS به CQRS می‌رسد

CQRS همین تمایز را در ساختار برنامه هم جاری می‌کند. مسیر write commandها و قوانین کسب‌وکار را پردازش می‌کند. مسیر read داده‌ای را برمی‌گرداند که برای صفحه، گزارش یا مصرف‌کننده‌ی API آماده شده است.[3]

این حرف به معنی دو دیتابیس، message broker، Event Sourcing یا microservice نیست. اولین شکل مفید CQRS می‌تواند فقط دو مسیر جدا برای command و query در یک برنامه با یک دیتابیس باشد.[3]

فرمی که فقط نام نمایشی یا locale ترجیحی را تغییر می‌دهد همچنان می‌تواند یک update ساده باشد. CRUD اغلب کم‌هزینه‌ترین راه‌حل درست برای چنین کاری است.

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

Sources

  1. Greg Young, CQRS Documents
  2. Martin Fowler, "Command Query Separation"
  3. Microsoft Learn, "CQRS pattern"
اشتراک‌گذاری در LinkedIn