مرتضی دلیل
مرتضی دلیل
خواندن ۱۱ دقیقه·۵ سال پیش

آشنایی با Clean Architecture : پیاده سازی بر اساس UseCase ها (قسمت ششم)

قسمت اول : مقدمات
قسمت دوم : مبانی معماری
قسمت سوم : نگاهی به معماری سنتی سه لایه
قسمت چهارم : اجزای Clean Architecture
قسمت پنجم : پیاده سازی بر اساس سرویس ها
قسمت ششم : پیاده سازی بر اساس UseCase ها (شما در حال خواندن این مقاله هستید)
قسمت هفتم : آشنایی با CQRS


پیاده سازی معماری Cleanرا به کمک سرویس ها دیدیم. تصویر معروف این معماری را به خاطر بیاورید.

پیام اصلی این معماری این است :

لایه های درونی هیچ اطلاعی از لایه های بیرونی ندارند و این ویژگی را به کمک Dependency Injection به راحتی میتوان پیاده کرد.

در بخش قبل دیدم که لایه مرکزی یعنی Core که حاوی Doman (یا همان Entity ها) بود هیچ اطلاعی از اینکه چطور دارد استفاده میشود نداشت. سرویس های موجود در این لایه فقط ارائه دهنده خدماتی بودند که ورود و خروجشان را نمی‌دانستند.

آنچه Uncle Bob's به عنوان Clean Architecture در لایه Core معرفی کرده شامل UseCase ها و Entityهاست(که ما به Domain میشناسیم؛ به شکل قراردادی مدل های دیتابیسی را Entity می‌گوییم).
اول ببینیم سرویس‌ها معمولا در ساختاری که در مقاله قبل تعریف شد به مرور بزرگ و بزرگ تر می‌شوند. اینجکشن های درون یک سرویس زیاد شده و گاهی مدیریت آنها سخت میشود.

یوزکیس(UseCase) یا Interactor یک جایگزین برای سرویس نیست بلکه مرکز اصلی پیاده سازی لاجیک محسوب می‌شود. ما یک پروژه را برای یک هدف دنبال میکنیم و قرار است نرم افزار ما مسائل دنیای واقعی را در راستای این هدف حل کند. هر مسئله را میتوان یک UseCase دانست با ورودی مشخص و خروجی قابل محاسبه. با این دیدگاه یک سرویس که حاوی متدهای مختلف برای برآورده کردن نیازهاست به چندین کلاس UseCase شکسته میشود.

مثلا یک UseCase وظیفه دارد آبجکت Post را به جدول Post اضافه کند. در این مسیر باید ابتدا بسنجد که آیا این آبجکت از قبل وجود داشته یا نه اگر وجود داشت اکسپشن مناسب تولید کند و اگر نداشت ایجادش کند.

ما قبلا همین منطق را مثلا در متدی به نام Add در سرویس PostService ایجاد میکردیم ولی در روش فعلی باید یک UseCase برای اینکار ایجاد کنیم که کاملا ایزوله باشد. حتا ترجیح این است که هندل کننده ی این روال هیچ نوع ریترنی نداشته باشد و از ورودی، ظرفی را دریافت کند و آن ظرف را پُر کند.

میخواهیم api برای add کردن یک Post ایجاد کنیم. اینبار از روش UseCaseی جلو میرویم.

اما چطور UseCase بنویسیم؟

باید یک سری محدودیت ها و قواعدی برای نوشتن UseCase طراحی کنیم که برنامه نویس ملزم به رعایت آن باشد. مثلا اینکه ورودی چگونه باشد و خروجی به چه شکل تولید شود.

اینترفیس زیر را ایجاد میکنیم.

https://gist.github.com/mortezadalil/7dba95524b9ccf92ba2d42a306bab00e

این اینترفیس جنریک است و دو تایپ به عنوان TUseCaseRequest و TUseCaseResponse در آن استفاده میشود که ارتباط بین این دو تایپ نیز توسط IUseCaseRequest تعریف شده است.

https://gist.github.com/93b823efaabfb81949b92b794ecb788f

این اینترفیس هم جنریک است و تایپ جنریکی که با آن کار میکند به شکل out معرفی شده است. Out حالتی است که پارامتر حتما باید توسط متد تغییر کند. این یعنی چه؟

همانطور که متدهای موجود در سرویس‌ها ورودی و خروجی دارند، این موضوع برای UseCase هم صادق است. هر UseCase را میتوان معادل یک متد در سرویس دانست. اگر ورودی UseCase را به عنوان Request و خروجی آن را Response در نظر بگیریم این جریان دیتایی را میتوان یک نوع ارتباط مسیجی تعریف کرد.

میخواهیم یک api برای Add کردن یک Post بنویسیم. این Api یک UseCase به نام AddPostUseCase را صدا میزند. ورودی این UseCase مدلی به نام AddPostRequest است که مشخصات زیر را دارد:

https://gist.github.com/954baba5fafe83d902545769036c1720

همانطور که میبینیم از IUseCaseRequest ارث بری کرده و به دلیل خاصیت این اینترفیس مجبور است یک مدل به عنوان Response نیز تعریف کنیم. فرض کنید AddPostResponse مدل زیر است.

https://gist.github.com/5038acd341a7844faa23b19a36eba82c

تا اینجای کار روند ورودی و خروجی دیتا را تعریف کردیم.

برای نوشتن متد UseCas کلاسی به نام AddPostUseCase تعریف میکنیم که باید IAddPostUseCase را پیاده سازی کند. این اینترفیس را به شکل زیر تعریف میکنیم

https://gist.github.com/cd2f96077edf2b110fa1dc022971af09

این اینترفیس از IUseCaseRequestHandler ارث بری میکند و مشخص است که به جای in و out چه مدل هایی باید قرار گیرد. کلاس پیاده سازی این اینترفیس با مدل ورودی و خروجی که پیش از این تعریف کردیم کار میکند پس به شکل فوق تعریف شده است.

بر اساس آنچه در IUseCaseRequestHandler تعریف شده کلاس هایی که هر اینترفیس ارث بری شده از این اینترفیس را پیاده کنند مجبورند متد HandleAsync را داشته باشند که مدلی به نام message را از نوع Request تحویل گرفته و چیزی از تایپ IOutputPort را که جنریکی از تایپ Response است میسازند!

از عبارت میسازند به جای تحویل میدهد استفاده کردم چون UseCase ها مثل Service ها ریترن به درد بخور ندارند؛ یعنی نتیجه مجموعه لاجیکی که روی مقادیر ورودی اعمال میشود در ظرفی به نام outputPort ریخته میشود.

https://gist.github.com/ca82e3440372a8e31c97eb62d2041d3d

این ظرف از کجا می آید؟ کدام متد از کدام کلاس قرار است IOutputPort را پیاده کند که ما Handle آن را صدا بزنیم؟

اینجاست که Presenter وارد عمل میشود. برخلاف حالت سرویسی که در مقاله قبل توضیح دادیم، ظهور Presenter در روش فعلی بسیار واضح و موثر است. ما میتوانیم یک کلاس پرزنتر به نام PostApiPresenter تعریف کنیم که جنریک باشد و آبجکت خروجیِ یوزکیس را بگیرد و هر بلایی خواست سرآن بیاورد تا برای نمایش آماده شود. این کلاس وظیفه ساختار دهی به خروجی را نیز دارد و میتوانیم همانکاری را که قبلا در BaseController انجام دادیم در اینجا انجام دهیم.

این کلاس به شکل زیر است:

https://gist.github.com/92c7d33259e7cea4d5127386e0ef4fa4

پرزنتر ما در اینجا وظیفه جیسون کردن خروجی را بر عهده دارد و از IOutputPort باید ارث بری کند، چون آبجکتی از پرزنتر به عنوان پارامتر دوم هر UseCase به آن ارسال میشود تا response را بگیرد. به پارامتر ورودی دوم در UseCase دقت کنید.

کد زیر یک UseCase برای add کردن یک post جدید را نشان میدهد. آرگومان دوم چیزی از جنس IOutputPort است. ظرفی که قرار است توسط این UseCase با خروجی مناسب پر شود. پرزنتر همین ظرف است.

https://gist.github.com/027bf465af1cb4f8b98c8d9bd205cd11

می‌توان پرزنتر را به شکل عمومی تر نیز تعریف کرد. پرزنتری که ما ساختیم با توجه به نام آن، در سطح یک کنترلر قابل استفاده است. مثلا برای کنترلر Post بر اساس خروجیهای مختلفی که در اکشن هایش داریم، این پرزنتر میتواند استفاده شود، چون جنریک است.
مثلا اگر متد HTTPPOST مینویسیم و خروجی ما قرار است از جنس AddPostResponse باشد کافیست که PostApiPresenter<AddPostResponse> را به کنترلر اینجکت و از آن به شکل زیر استفاده کنیم.

https://gist.github.com/06952f6d265e020103a2a3b5398e47b0

دو پراپرتی addPostUseCase_ و addApiPresenter_ مقادیر اینجکت شده در کنترلر هستند که در این اکشن استفاده شده اند.

چند نکته :

  • در این مقاله برای سادگی ولیدیشن ورودی را لحاظ نکردیم، می‌توانید برای آن راهکاری از طریق BaseController یا Presenter و یا یک middleware جنرال بیاندیشید.
  • همه اکشن های ما همین دو بخش را خواهند داشت:
    - صدا زدن هندلر مرتبط.
    - ارسال پاسخ از طریق return
  • پرزنتر ما به ساده ترین شکل ممکن تعریف شده است. برای اینکه بتوانید خروجیهای یکسان با قابلیت نمایش خطا و پیام داشته باشید (مانند آنچه در بخش قبل در BaseController ساختیم) باید پرزنتر خود را مجهز به امکانات بیشتری کنید. مثلا از یک کلاس جنرال ارث بری کند که بتواند پراپرتی های بیشتری در اختیار UseCase قرار دهد تا به شکل مناسب تری پر شود. در ادامه این موضوع را خواهیم دید.
  • آنچه در کانستراکتور کنترلر اینجکت شده حتما باید در startup تعریف و رجیستر شود.
  • الزامی به ایجاد ویومدل برای هر ریکوئست نیست. مثلا برای متد HttpDelete ورودی یک شناسه است و ویومدلی وجود ندارد.

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

https://gist.github.com/76adcabb991ce5da4d712152c4a74fa3

این مدل حاوی data و خطا نیست. یعنی خروجی یک api اگر حاوی خطا یا دیتا باشد این مدل به تنهایی کافی نیست. باید یک کلاس جدید از این مدل ارث بری کنیم که جنریک باشد و دیتای خروجی یا خطا را نیز شامل شود. این کلاس و کلاس Error ی که در آن استفاده شده را نیز در لایه Core ایجاد میکنیم.

https://gist.github.com/0e90d71ba66559d58dbeda7c1b30d687
https://gist.github.com/3251b24bd37f4d59e826c7bb2b272d23

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

  • برای زمانی که لیستی از خطاها وجود دارد. طبیعتا success برابر با false است و مسیج دیفالت هم نداریم مگر اینکه در هندلر مسیج تعریف کنیم.
  • برای زمانی که دیتای خروجی داریم و success را برابر با true در نظر میگیریم و مسیج دیفالت هم نداریم مگر اینکه در هندلر مسیج تعریف کنیم.
  • برای حالتی که دیتا و خطا نداریم و خروجی فقط میتواند یک پیام به همراه مقدار success باشد.

حالا این کلاس را در پرزنتر استفاده میکنیم.

https://gist.github.com/37c63ee964451ddf4d81b23f1b50d157

با این تغییر اکشن ما به شکل زیر دچار خطا میشود

علت خطا تفاوت جنس پرزنتر و آرگومان دوم UseCase است.
پرزنتر از جنس IOutputPort<GenericResponse<T>> است. پس مجبوریم اینترفیس یوزکیس را اصلاح کنیم و تایپ خروجی GenericResponse را اضافه کنیم.

خطای دیگری در اولین تایپ میبینیم. AddPostRequest هم به شکل زیر باید اصلاح شود.

مشکل HandleAsync در کنترلر برطرف میشود.

حالا به پیاده سازی بیزنس در UseCase میپردازیم.

https://gist.github.com/9701c9022dda01ed6ec44ed3d0567665

کافیست متد Add را در ریپازیتوری درست کنیم. این متد ورودی از نوع دامین میگیرد و خروجی آن آبجکت کاملی از نوع دامین است. (که id آن پس از اضافه شدن به دیتابیس مشخص شده)

حالا addedItem برای یوزکیس یک آبجکت دامینی حساب میشود و باید آن را به خروجی مورد نظرش map کند. اینکار را به شکل دستی در مثال فوق انجام دادیم تا outputObject را بسازیم.

در مرحله آخر ouputObject را به handle میدهیم که به اصطلاح "ظرف پرزنتر را پر کند" و در سمت اکشن ContentResult داشته باشیم.

چند نکته :

  • پارامتر ورودی message در usecase از جنس AddPostRequest است و منطق ما در یوزکیس ما را مجبور کرد که از دو پراپرتی آن استفاده نکنیم. یعنی تاریخ ها در خود بیزنس مشخص شده اند و نه ورودی. پس مدل AddPostRequest را به شکل زیر اصلاح میکنیم.

https://gist.github.com/e19efc4a8b49091951d087f630254909

  • ما عملیات saveChange را به خود ریپازیتوری محول کردیم که منطقا این کار باید در یوزکیس انجام شود و نباید در ریپازیتوری صورت گیرد (مراجعه کنید به تعریف ریپازیتوری) ولی معمولا در ریپازیتوری انجام میشود!
  • برای راحتی، ایرادی ندارد که خروجی متد Add در ریپازیتوری از نوع AddPostResponse می‌بود تا عملیات مپِ اضافه در یوزکیس انجام نمیشد؛ اما در این مثال یک متد Add نسبتا روتین نوشته ایم. منطقی این بود که متد Add در ریپازیتوری فقط Id را برمیگرداند. شاید خیلی منطقی تر این بود که ما در یک ریپازیتوری جنرال و جنریک متد Add را برای همه entity ها به شکل جنریک پیاده سازی می‌کردیم. فراموش نکنید این مقاله و این پروژه جنبه آموزشی دارد.
https://gist.github.com/b430ace6a33044542e6b978e175547f5
  • برای مپ کردن بهتر است از automapper استفاده کنید که تعداد خطوط کدهای شما کمتر شود و توسعه و عیب یابی راحت تری داشته باشید.
  • دقت کنید متدهای ریپازیتوری مدل های دامین را به عنوان ورودی میگیرد و مدل های دامین (یا در مواقعی Dto) را به عنوان خروجی برمیگرداند. یعنی ریپازیتوری باید محافظی برای انتیتی های دیتابیسی باشد و عملیات ساده مثل اضافه کردن، حذف کردن، ویرایش کردن، جوین زدن و ایجاد دامین جدید و ... را انجام دهد و حاوی بیزنس های محاسباتی و پیچیده نباشد.
  • سوالی که مطرح است این است که آیا UseCase جایگزین سرویس هاست؟ جواب هم بله هست و هم خیر.
    بله بخاطر این است که وظیفه ای که به سرویس ها در مقاله قبل محول شد عینا شبیه وظیفه ای است که در اینجا به UseCase ها سپرده ایم. اما خیر بخاطر این است که UseCase جایگزین Service نیست. فرض کنید که میخواهید یک لاجیک پیچیده بنویسید که مجموعه ای از چند متد است و در نهایت یک متد این روند را صدا میزند. اگر قرار باشد این تک متد را یک UseCase صدا بزند، متد را باید در کجا قرار دهیم!؟ اگر چند UseCase به این متد احتیاج داشته باشند چه کار کنیم؟ راه حل در اینجا همان سرویس ها هستند. باید متدهای مشترک را در سرویس ها بنویسیم. سرویس ها بر خلاف متدهای ریپازیتوری میتوانند لاجیک پیچیده داشته باشند. البته تا زمانی که منطق پیچیده شما فقط در یک مکان مورد استفاده قرار میگیرد آنرا به سرویس منتقل نکنید و در همان UseCase مربوطه پیاده کنید.

خب ؛ حالا که ساختار مشخص شد برای نوشتن یوزکیس جدید Delete به شکل زیر عمل میکنیم.

https://gist.github.com/b797b97a75cbc2724be864395a3ca78f

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

چند نکته در مورد روند کار DeleteUseCase

  • اینکه ابتدا چک کردیم که آیا شناسه وجود دارد میتوانست به شکل دیگری باشد و وجود آبجکت را چک کنیم و پس از اینکه آبجکت پیدا شد با متد Remove مربوط به EntityFramework آنرا پاک می‌کردیم. در اینجا با هدف آموزشی، یک متد برای بررسی وجود آیتم در دیتابیس نوشته شده و در صورت عدم وجود، یک اکسپشن تولید شد. در صورتی که وجود داشته باشد آن را به کمک متد دیگری در ریپازیتوری پاک می‌کنیم.
  • خروجی این متد یک بولین است. مسیج خروجی هم خالی است.
  • در catch ، دو حالت در نظر گرفتیم. خطاهای هندل نشده به شکل "خطا در دریافت اطلاعات" نمایش داده میشود و خطاهای هندل شده مسیج کاستوم خود را به پرزنتر می‌دهند.

در این مقاله ساختار یک پروژه بر اساس Clean Architecture معرفی شد. این پروژه را میتوانید در اینجا ببینید.
الان نوبت شماست که توانایی های خودتون رو نشون بدید. اکستنشن متد بنویسید، میدل ویرهای مختلف تعریف کنید. کاری کنید که با کمترین زحمت بیشترین نتیجه را بگیرید. چه کارهایی بلدید؟

  • برای لاگ های خود ساختار تعریف کنید و در پرزنتر جنرال آنرا جاسازی کنید. از Nlog استفاده کنید. Elastic را به آن متصل کنید و از کوچکترین خطا صرف نظر نکنید. Elmah هم گزینه خوبی برای لاگ کردن خطاهاست.
  • اکسپشن ها را به کمک یک middleware هندل کنید تا از تکرار try/catch ها رها شوید. البته خیلیها دوست دارند خطاهای هر useCase را در همان UseCase ببینند و هندل کنند. شاید تست و عیب یابی طولانی مدت در این حالت ساده تر باشد.
  • تعریف سرویس های اینجکت شده خود را در startup سامان دهید. آیا میتوانید کاری کنید هر کلاسی با عبارت Presenter تمام میشد بصورت خودکار رجیستر شود؟ برای UseCase ها هم میتوانید؟
  • برای token و claim ساختار یکپارچه تعریف کنید و بطور کلی تشخیص هویت را سازماندهی کنید.
  • اکشن فیلترها را فراموش نکنید میتوانید مدلهای ورودی خود را به جای middlaware با اکشن فیلتر کنترل کنید. اما آیا جایی به درد می خورد؟
  • از swagger استفاده کنید.
  • از Automapper بهره بگیرید.

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

آموزش clean architectureusecaseبرنامه نویسیasp core 3
برنامه نویس و علاقمند به برنامه نویسی، سینما، فلسفه و هر چیزی که هیجان انگیز باشد. در ویرگول از روزمرگیهای مرتبط با علاقمندیهام خواهم نوشت. در توئیتر و جاهای دیگر @mortezadalil هستم.
شاید از این پست‌ها خوشتان بیاید