از لایههای سنتی تا انقلاب Ports & Adapters
معماری، هنر مدیریت وابستگیها
در توسعه نرمافزار، معماری چیزی نیست جز تصمیماتی که تغییر دادن آنها سخت است. هدف اصلی هر معماری، مدیریت پیچیدگی و کنترل وابستگیها (Dependencies) است. در طول دهههای گذشته، ما از ساختارهای سادهای شروع کردیم که با پیچیدگی کسبوکار و فضای مسئله، کم آوردند و به سمت مدلهایی حرکت کردیم که هسته اصلی سیستم (Domain) را از دنیای بیرونی (Database, UI, External APIs) جدا میکنند. اما چرا این سفر طولانی را طی کردیم؟ و آیا معماریهای مدرن واقعاً راه حل هستند یا فقط پیچیدگی را در جای دیگری پنهان کردهاند؟
بیش از ادامه در نظر داشته باشید:
سبک معماری لایهای(Layered-Style Software Architecture) را با معماری 3 یا 4 لایهای اشتباه نگیرید. در اینجا استایل اشاره به نوعی از معماری نرمافزار دارد که لایهبندی بر اساس مسئولیتها نقش کلیدی در آن دارد. از معماریای این سبک میتوان به معماریهای کلاسیکتر مثل 3 یا 4 لایه، و معماریهای مدرنتر که مرکز ثقل آنها دومین (domain centric) هستند، از جمله hexagonal/ports & adapters یا clean architecture یا onion اشاره کرد. در ادامه این نوشته من فقط از hexagonal/ports & adapter صحبت میکنیم. اما در نظر داشته باشید که در عمل هر سه معماری hexagonal/ports & adapters و clean architecture و onion هیچ تفاوتی با هم ندارند، جز در جزئیاتی بسیار ناچیز و قابل چشمپوشی است. شیطان در جزئیات است!
عصر معماری لایهای سنتی (Traditional Layered-Style Software Architectures)
معماری لایهای سنتی که در دهههای ۹۰ و ۲۰۰۰ میلادی شکوفا شد، بر پایه اصل تقسیم وظایف (Separation of Concerns) بنا شده بود. ایدههای لایهبندی جهت کنترل کردن پیچیدگی و جزئیات، با این شرایط که هر چه از سلسله مراتب لایهها به سمت بالا حرکت کنید، جزئیات بیشتر پوشیده شده ومفاهیم abstract میشوند چیز جدیدی حداقل در دنیای نرمافزار و شبکه نبود و مسبوق به سابقه بود. به عنوان مثال میتوان به مدل معروف شبکه 7 لایه و مدل موخرتر آن یعنی مدل 4لایه شبکه اشاره کرد.
اصول یکسان و ساده بود. جهت کنترل و سوار شدن بر پیچیدگی، تقسیم وظایفی انجام میشد. هر لایه بخشی از وظایف را انجام میداد. از سرویسهای لایه دقیقا پایینتر خود(بجز آخرین لایه) استفاده میکرد و با آن تعامل داشت و به لایه بالاتر خود(به غیر از بالاترینم لایه) سرویس میداد. سرویسدهی و تعاملات هر لایه با لایه بالا و پایین خودش بر اساس یکسری قرارداد(contract) بود. این قراردها از سرازیر شدن جزئیات(Leaking Unwanted Details) ناخواسته یک لایه به لایه دیگر جلوگیری میکرد. بدین ترتیب لایهها با پایبند بودن به قرارداد و بدون درگیر شدن با جزئیات ناخواسته میتوانستد کار خود را انجام دهند.
معماری لایهی کلاسیک معمولاً این معماری شامل ۳ یا ۴ لایه بود و البته هست:
- Presentation Layer (UI): مسئول نمایش دادهها و دریافت ورودی از کاربر.
- Application/Service Layer: مسئول هماهنگی جریان کار (Workflow) و مدیریت تراکنشها.
- Domain/Business Layer: جایی که قوانین بیزنس قرار داشتند. معروف بود به لایه BAL.
- Data Access/Persistence/Infrastructure Layer: مسئول تعامل با پایگاه داده. معروف بود به لایه DAL.
توجه داشته باشید: وقتی از لایه چهارم در بالا و در ادامه صحبت میکنیم، منظور فقط لایه دسترسی به دیتابیس نبود. در حقیقت تمامی کارها و پیادهسازی مربوط به ارتباط برنامه با دنیای بیرون، و سرویسهای زیرساختی همسطح همدیگر در این لایه قرار میگیرند. در ادامه هرجا از data access layer صحبت شد، توجه داشته باشید، که سرویسهایی از جمله notification نیز در همین سطح در لایههای معماری کلاسیک لایه قرار میکیرند.
مزایا
مزایای بسیاری میتوان برای این سبک معماریها برشمرد. از جمله:
- سادگی و یادگیری سریع: برای اکثر پروژهها، این ساختار بسیار شهودی است.
- جداسازی اولیه: جداسازی منطق نمایش از منطق ذخیرهسازی، اولین قدم برای نظم بخشیدن به کد بود.
- سرعت توسعه اولیه: در پروژههای کوچک، سرعت پیادهسازی در این مدل بسیار بالاست.
- تست پذیر بودن هر لایه بصورت مستقل: یکی از ایدههای بنیادین این سبک از معماریها همانطور که بالا اشاره شد، جداسازی مسئولیتها و استقلال دادنبه هر لایه بود. تا اینکه هر لایه بتواند و بصورت مستقل و بدون اجبار به دانستن جزئیات ناخواسته لایههای بالاتر و پایینتر خود عمل کند. همین ایده باعث میشد که لایه پتانسیل تست مستقل برای خود داشته باشد.
چالشها و نقاط ضعف معماری لایهای سنتی
بزرگترین مشکل معماریهای کلاسیک لایهای، جهت وابستگی (Dependency Direction) بود. در مدلهای کلاسیک، وابستگیها معمولاً به سمت پایین (نهایتا به سمت لایه دیتا) جریان داشتند. اجازه بدید این موضوع را کمی بیشتر باز بکنم. شاه کلید فرگشت در معماری سبک لایههای، و حرکت از مدلهای کلاسیکتر به معماریهای مدرنتر با محوریت دومین، در همین موضوع جهت وابستگی نهفته است.
اما در اینجا منظور از جهت وابستگی چیست؟ خب در بالا اشاره کردم که اصل جداسازی و تقسیم مسئولیتها بین لایه بدین صورت بود که هر لایه یک مسئولیت مشخصی بر عهده داشت، و تمامی لایهها هم از این قانون پیروی میکردند که هر لایه تنها و فقط از لایه دقیقا زیرین خود را سرویس میگیرد، و همینطور به تنهایی لایهای که سرویس میدهد لایهای که بالا سر آن لایه است. به عنوان مثال لایه دومین فقط از لایه زیر خود یعنی دیتااکسس سرویس میگرفت، و تنها به لایهی اپلیکیشن سرویس، سرویس میداد. این قانون سختگیرانه یک استثنا داشت که در ادامه همین مقاله اشاره شده است.
اجازه بدید یک مثال اجرا کنیم. درخواستی از سمت کاربر برای ثبت یک سفارش دریافت شده است. درخواست http request ابتدا وارد لایه presentation میشود. این لایه پس از دریافت درخواست کاربر و اطمینان از امن بودن درخواست، http request را به مدل مورد نیاز برای لایه application service تبدیل کرده، و سرویس مورد نظر در این لایه را صدا میزند. Application service وظیفه اجرای الگوریتم ثبت سفارش را انجام میدهد، شبیه یک رهبر ارکستر(orchestrator). برای اینکه مطمئن شویم تمام قوانین کسبوکار توسط کاربر رعایت شدهاند، entity از لایه domain صدا زده میشود(یا خود order و یا یک domain service یا یک factory در لایه دومین). در هر صورت پس از دریافت درخواست توسط لایه domain و اعمال تمام قوانین کسبوکاری، یک order با وضعیت صحیح(مثلا Placed) به لایه application service برگردانده میشود. حالا نوبت به ذخیره سفارش درون دیتابیس است.
بر اساس جداسازی وظیفهها، این کار بر عهده پایینترین لایه یعنی data access گذاشته شده است. میدانیم که قانون سختگیرانه اربتاط بین لایهها، اجازه نمیداد که یک لایه مستقیما دولایه زیرین خود را صدا بزند. بهمین دلیل Application service مجدد سرویسی مثل saveOrderBusinessLogic از لایه domain service صدا میزد تا order ایجاد شده را ذخیره کند. saveOrderBusinessLogic باید ابتدا order که با مدل و زبان لایه دومین دریافت کرده است، را به کلاس orderDb در لایه Data access که نگاشت جدول order در این لایه است تبدیل کند. سپس باید سرویسهای ارتباطی با دیتابیس را مستقیم از لایه data access صدا بزند.
اما چالش چه بود؟ لایه domain باید مستقیما، کلاس پیادهسازی کننده ارتباط به یک دیتابیس و وندور خاص (concrete implementation) را استفاده میکرد. شما نمیتوانستید که پیادهسازی لایه domain را بصورت انتزاع از آن بنویسید. اگر interface و یا abstract classی مثل IOrderDataAccess یا OrderAccessAbstract برای انتزاع از جزئیات دیتابیس خاصی مینوشتید، مجبور بودید که آنها را در همان لایه data access قرار دهید. چرا که لایه data access طبق قانون ارتباط لایهها حق این را نداشت که به لایه بالاتر از خود رفرنس داشته باشد. حتی اگر اینکار را انجام میدادید شما دچار تسلسل وابستگی (circular dependency) میشدید. مثلا اگر این IOrderDataAccess را ر لایه application service قرار میدادید، خطای تسلسل وابستگی بصورت زیر رخ میداد:
Application service => domain/business logic => data access => application service
Data Access و Infrastructure تبدیل به نقطه ثقل معماری شده بودند
اگر سناریو مطرح شده در بالا را دنبال کنید، متوجه خواهید شد، که تمام لایهها در این معماری، بصورت مستقیم مثل domain layer و یا غیر مستقیم مثل application service به data access layer وابسته هستند. هر تغییر کوچک یا بزرگ در این لایه، مثلا تغییر دیتابیس، یا تغییرات در اسکیمای دیتابیس و یا بروزرسانی و یا تغییر ORM بصورت مستقیم باعث شکستگی(fragility) سایر لایهها میشود. به عبارت دیگر، تغییرات در این لایه بهدلیل حساسیت بسیار بالای سایر لایهها نسبت به خود، به یک سطح سکون شدیدی میرسید.
چالش بزرگتر اما این بود که این مرکز ثقل شدن data access به این نتیجه منجر میشد که یکی از مهمترین مزیتهای این معماری، یعنی مستقل بودن لایهها و همینطور تستپذیر بودن آنها، به سادگی از دست برود. در یک نرمافزار میتوانیم پیادهسازیهای هر فیچر رو به دو بخش بههم مرتبط ولی مستقل تقسیم کنیم. به عنوان مثال، در سناریوی ثبت سفارش که در بالا اشاره شد، بخشی از پیادهسازی که مربوط به ایجاد سفارش و حصول اطمینان از اینکه تمام قوانین کسبوکاری توسط کاربر رعایت شدهاند. اینها کاملا به دومین وابسته(domain dependent) هستند، و تغییرات خیلی کمی دارند. در مقابل بخشی از پیادهسازی که مربوط به درخواست http request کاربر و یا ذخیره و بازیابی سفارش در دیتابیس است، غیر وابسته به دومین(domain independent) هستند(این جمله را با احتیاط البته بخوانید!). این بخشها که وظایفی که توسط data access layer به عنوان مرکز ثقل معماری هم شامل آنها میشود، میتوانند براحتی دستخوش تغییرات شوند. و ناچارا گاها منجر به تغییراتی ناخواتسه و اجباری در سایر لایهها نیز میشد.
Sinkhole Anti-Pattern
این در واقع یک اثر جانبی قوانین سختگیرانه ارتباط لایهها بود. گاها دیده میشد که برای برخی از سناریوها، برخی لایهها فقط درخواستی را از لایهی بالاتر گرفته و بدون اینکه کاری بر روی آن انجام دهند، تحویل لایه پایینتر میدادند. مثلا افراد به این نتیجه رسیده بودند که بهتر است لایه domain مستقیما با data access ارتباط نداشته باشد و application service وظیفه واکشی(fetch) و ذخیره(save) را بر عهده داشته باشد. اما اگر برنامهنویس قصد داشد که قوانین ارتباطی لایهها را همانطور که بیان شده بودند(as-is) دنبال کند، لایه application service اجازه دسترسی به data access رو نداشت و ناچارا باید از طریق domain layer اینکار را انجام میداد. در اینحالت و در این سناریو domain layer هیچ کار انجام نمیداد جز دریافت درخواست از لایه بالاتر و تحویل و صدا زدن سرویس مورد نظر از data access. کاری که عملی بیخود و زمانبر بود.
بههمین خاطر یک بازنگری در قانون ارتباط لایهها انجام شد، بدین صورت که:
همچنان جهت وابستگیها بین لایهها از بالا به پایین است و هیچ لایهای حق ندارد به لایه بالاتر از خودش وابستگی داشته باشد. اما لایهها میتوانستند در صورت نیاز دولایه پایینتر از خود را نیز به عنوان وابستگی مستقیم(direct dependency) داشته باشند. تفسیرهای بعدی از همین باز گذاشتن دست توسعه دهندگان منجر شد به اینکه، افراد میتوانستند از لایه presentation هم لایهی data access را صدا بزند. یا از لایه presentation لایه domain را صدا بزنند و عملا لایه application service رو حذف کنند! اینکار آنقدری در وهله اول جذاب بود که حتی لایههای domain نیز حذف میشد و presentation مستقیما تمامی منطق را پیادهسازی کرده و نهایتا از طریق data access layer با دیتابیس ارتباط برقرار میکرد.
پارادوکس DDD و معماریهای اولیه
یک پرسش بسیار هوشمندانه و نقادانه وجود دارد: اگر معماریهای مدرن مانند Hexagonal برای حل مشکلات وابستگی به سرویسهای زیرساختی(Infrastructure Services) از جمله دیتابیس آمدهاند، پس چرا خودِ اریک اونس در کتاب اولیه DDD از همان معماریهای لایهای سنتی استفاده میکرد؟
واقعیت این است که در سالهای اولیه ظهور DDD، تمرکز بیشتر بر مدلسازی دامنه (Modeling) بود تا ساختار فنی. بسیاری از پیادهسازیهای اولیه DDD، از همان ساختار `Controller -> Service -> Repository -> Entity` استفاده میکردند.
اما سوال دیگری که اینجا ممکن است مطرح شود این است که چرا این اتفاق افتاد؟ دلیل این امر این بود که،چون در آن زمان، هدف اصلی، درک مفاهیم `Aggregate` و `Value Object` بود. توسعهدهندگان فکر میکردند با جدا کردن لایه Domain، مشکل حل شده است. اما آنها متوجه شدند که اگر لایه Domain همچنان به `Repository` (که ماهیت آن وابستگی به دیتابیس است) وابسته باشد، استقلال دامنه (Domain Autonomy) یک توهم است. در واقع، DDD در آن روزها یعنی اوایل 2003 بیشتر محتوا را اصلاح کرد، اما ساختار و جهت وابستگی را تغییر نداد. این همان نقطهای بود که باعث شد نیاز به معماریهای مدرنتر احساس شود.
البته خیلی هم نباید سخت گرفت. یکی از دلایل مهم دیگر این بود که بر روی زماین در آن زمان، معماری غالب و جاافتاده و البته محبوب همان معماری کلاسیک لایهای بود. اکثر منابع و کتابها هم جهت ارائه مثالها از همان معماری استفاده میکردند. طبیعی بود که کتاب DDD هم از این قاعده مستثنا نباشد.
انقلاب معماریهای مدرن (Ports & Adapters / Hexagonal)
آلیستر کوکبرن (Alistair Cockburn) –از نویسندگان بیانیه چابکی، با معرفی Hexagonal Architecture که بعدها بنا به دلیل اینکه شکل شش گوشه بیش از مورد توجه قرار گرفت به معماری Ports & Adapters نیز معروف است، سعی در این داشت که مرکز ثقل معماری را از شر data access و بصورت کلی هر مسئولیتی که به نوعی در تقسیم بندی بالا domain dependent نبوده و جرئیات محسوب میشد، آزاد کند. او پیشنهاد داد که ما نباید خیلی به لایهها و قوانین سختگیرانه آن فکر کنیم، بلکه باید به مرزها (Boundaries) فکر کنیم.
آلیستر بر همین مبنا، جداسازی مسئولیت(separation of concern) را خیلی ساده ولی قدرتمند انجام داد. او پیشنهاد داد که صرفنظر از تعداد لایهها به ازای هر سناریو/فیچر که به use case هم معروف است، بخشی از پیادهسازی آن که وابسته به دومین(domain dependent) است به عنوان دنیای داخلی(Inside World) و مابقی، همه به عنوان دنیای خارج(Outside World) در نظر گرفت. جهت ارتباط این دو دنیا، قراداد() در قالب interface تعریف میشد، که خود interface در دنیای داخلی و پیادهسازی آن در دنیای بیرونی انجام میشد. بدین ترتیب دنیای بیرون عملا به دنیای داخل وابسته میشد. برای مشخص شدن مرزبندی بین این دنیا، آلیستر از شکل و متافور ششگوشه(Hexagon) استفاده کرد. اضلاع این شکل در واقع همان قراردادها یا مکانیزمهای ارتباطی دنیای داخل و بیرون بودند. بدلیل اینکه شش ضلع، این تلقی(بیشتر مسخره) را ایجاد کرده بود که باید همیشه 6 مکانیزم ارتباطی داشته باشیم، یا حداکثر تعداد interfaceها برای ارتباط دنیای داخل و بیرون باید 6تا باشد، بعدها آلیستر اسم این معماری را به Ports & Adapters تغییر داد. اتفاقا متافور بهتری نیز انتخاب کرده بود.
اما در مورد جزئیات دنبای داخل، هیچ محدودیتی وجود نداشت. متداول این بود که شامل دو لایه Application Service و Domain باشد.
دنیای بیرون هم هر آن چیزی که مربوط به ارتباط دنیای بیرون با کاربران، یا سایر سرویسها یا مکانیزمها ذخیرهسازی و دیتابیس بود را شامل میشد. به عنوان مثال کنترلرهای REST، یا Data Accessها یا Notification Service و Message Broker ها و …
جهت ارتباط هم همیشه از بیرون، یا دنیای بیرون، به داخل، یا دنیای داخل بود. بدین ترتیب مرکز ثقل برنامه، به لایه domain تفویض شده بود. این لایه هم از جزئیات پیادهسازیهای سرویسهای زیرساختی آزاد میشد. نتیجه اینکه شما میتوانستید براحتی کل منطق برنامه تست کامل کنید.
مفهوم اصلی: هسته در برابر جزئیات و محیط پیرامونی
در این بخش بصورت خیلی کلی و خلاصه، به معرفی مفاهیم در این معماری Ports & Adapters خواهم پرداخت.
در این معماری، هسته سیستم (Domain) کاملاً بیطرف و بینیاز از دنیای بیرون است. هسته نمیداند آیا ورودی از طریق HTTP است یا CLI، و نمیداند آیا خروجی در SQL ذخیره میشود یا در یک فایل متنی.
- The Core (Domain): شامل تمام منطق بیزنس، قوانین و مدلها. این بخش هیچ وابستگی به هیچ کتابخانه خارجی (حتی ORMها) دارد.
- Ports (Interfaces): پورتها در واقع قراردادها (Interfaces) هستند که در هسته تعریف میشوند.
- Driving Ports (Inbound): اینترفیسهایی که دنیای بیرون از طریق آنها با هسته صحبت میکند ، مثل `OrderService`.
- Driven Ports (Outbound): اینترفیسهایی که هسته برای انجام کارهای جانبی تعریف میکند، مثل `OrderRepository`.
- Adapters (Implementation): آداپتورها پیادهسازیهای واقعی هستند که در دنیای بیرون قرار دارند.
- Driving Adapters: مثل یک `REST Controller` که درخواست را میگیرد، به پورت تبدیل میکند و به هسته میفرستد.
- Driven Adapters: مثل یک `SqlOrderRepository` که پیادهسازی پورتِ ذخیرهسازی است و با دیتابیس حرف میزند.
چرا این مدل مشکلات سنتی را حل میکند؟
Inversion of Control (IoC): در معماری کلاسیک لایهای، هسته به دیتابیس وابسته بود. در معماری Hexagonal، دیتابیس به هسته وابسته است. هسته یک اینترفیس (Port) تعریف میکند و دیتابیس باید آن را پیادهسازی کند تا بتواند با سیستم کار کند.
تستپذیری 100درصد (Testability): حالا شما میتوانید هسته را بدون هیچ وابستگی، با استفاده از آداپتورهای درونی (In-memory Adapters) تست کنید. تستها بسیار سریع، سبک و واقعی هستند.
قابلیت تعویض (Replaceability): اگر بخواهید سیستم را از REST به GraphQL تغییر دهید، شما فقط یک آداپتور را عوض میکنید؛ هسته حتی متوجه تغییر هم نمیشود.
مقایسه تحلیلی و نقد منصفانه
بیایید با نگاهی بیطرفانه، این دو رویکرد را مقایسه کنیم:
| ویژگی | معماری لایهای سنتی | معماری Ports & Adapters |
| مرکز ثقل | پایگاه داده (Data-Centric) | دومین (Domain-Centric) |
| جهت وابستگی | از بالا به پایین (Top-Down) | به سمت مرکز (Inward) |
| پیچیدگی کد | پایین (کد کمتر، Boilerplate کمتر | بالاتر (نیاز به پورتها، آداپتورها و نگاشتها) |
| تستپذیری | دشوار (وابستگی به زیرساخت) | بسیار آسان (استقلال کامل) |
| مناسب برای… | پروژههای CRUD و ساده | سیستمهای پیچیده و بیزنسمحور |
نقدی بری معماری Ports and Adapters: آیا همیشه بهتر است؟
هیچ مفهوم و موضوعی در نرمافزار بدون سبک سنگین کردن(Trade-Off) وجود ندارد. معماری Ports & Adapterهم از این قاعده مستثنا نیست و در همه سناریوها بهترین انتخاب برای معماری سیستم نخواهد بود. اگر بخواهیم کمی از جنبه نقادانه به این معماری نگاه کرد، میتوان به موارد زیر اشاره کرد.
تعدد کلاسها (Boilerplate): شما برای هر موجودیت، نیاز به یک مدل دامنه، یک مدل دیتابیس، یک مدل API و چندین نگاشتگر (Mapper) بین آنها دارید. این یعنی نوشتن کد بسیار بیشتر.
منحنی یادگیری: تیمهای توسعه باید درک عمیقی از مفهوم Inversion of Control و تفکیک مدلها داشته باشند.
پیچیدگی در پیمایش کد: پیدا کردن مسیر اجرای یک درخواست در میان پورتها و آداپتورها برای یک توسعهدهنده تازهکار بسیار دشوار است.
اما باید تاکید کرد که ما از معماری لایهای سنتی به سمت معماریهای مدرن حرکت کردیم، نه به این دلیل که لایهها بد بودند، بلکه به این دلیل که جهت وابستگی در آنها اشتباه بود.
چه زمانی از کدام استفاده کنیم؟
از معماری لایهای سنتی استفاده کنید اگر:
پروژه شما عمدتاً یک رابط کاربری برای مدیریت دادههاست (CRUD)، پیچیدگی بیزنس ناچیز است و سرعت توسعه اولویت اول است. در اینجا، سعی نکنید چرخ را دوباره اختراع کنید؛ از پیچیدگیهای مدرن دوری کنید.
از معماری Ports & Adapters استفاده کنید اگر:
شما در حال ساخت یک محصول هستید که قوانین بیزنس آن پیچیده است، قرار است سالها عمر کند، و احتمال تغییر در زیرساختها (دیتابیس، پروتکلهای ارتباطی) وجود دارد. در اینجا، هزینه اضافی کدنویسی (Boilerplate)، در عوض با امنیت، تستپذیری و پایدگی در بلندمدت جبران میشود.
معماری، یک نسخه واحد برای همه (One-size-fits-all) نیست. معماری عالی، معماریای است که هزینه (Cost) را با منفعت (Benefit) در سطح درست مدیریت کند. هدف ما حذف وابستگی به دیتابیس نیست، هدف ما محافظت از ارزش اصلی سازمان یعنی Domain Knowledge است. اگر معماری شما اجازه میدهد که بیزنس بدون درگیر شدن با جزئیات تکنولوژی رشد کند، شما به معماری رسیده اید.