پرش به مطلب اصلی

گره‌های پنهان در معماری نرم‌افزار

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

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

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

اما پرسش اصلی همین‌جاست:

آیا مشکل حل شد، یا فقط صدای هشدار را خاموش کردیم؟

این نوشته درباره‌ی همین هشدارهای کوچک است؛ هشدارهایی که گاهی از مسئله‌ای عمیق‌تر خبر می‌دهند: وابستگی چرخه‌ای. یعنی جایی که چند بخش از نرم‌افزار به‌جای آنکه با مرزهای روشن با هم همکاری کنند، آن‌قدر به هم گره می‌خورند که هر کدام برای فهمیده‌شدن یا اجراشدن، به دیگری نیاز دارد.

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

نموداری مفهومی از وابستگی‌های چرخه‌ای در طراحی نرم‌افزار

ایده‌ی اصلی

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

یک مثال ساده؛ جایی که گره آرام شکل می‌گیرد

فرض کنیم سامانه‌ای داریم با دو بخش ساده:

  • بخش کاربر، برای ساختن و مدیریت حساب‌ها؛
  • بخش پیام‌رسانی، برای فرستادن پیام‌ها.

در آغاز، همه‌چیز طبیعی است. بخش کاربر پس از ساختن حساب، باید پیام خوشامدگویی بفرستد. پس به بخش پیام‌رسانی نیاز دارد.

بخش کاربر → بخش پیام‌رسانی

کمی بعد، بخش پیام‌رسانی برای ساختن متن پیام، به نام و وضعیت کاربر نیاز پیدا می‌کند. پس آن هم به بخش کاربر وابسته می‌شود.

بخش پیام‌رسانی → بخش کاربر

حالا اگر این دو خط را کنار هم بگذاریم، با چرخه روبه‌رو می‌شویم:

بخش کاربر → بخش پیام‌رسانی → بخش کاربر

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

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

نشانه‌ی خطر

اگر برای فهمیدن یک بخش، مجبور می‌شویم بخش دیگری را بخوانیم و آن بخش هم دوباره ما را به بخش نخست برمی‌گرداند، احتمالاً با یک گره طراحی روبه‌رو هستیم، نه فقط یک مشکل محلی در کد.

وابستگی چرخه‌ای یعنی چه؟

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

اگر «الف» برای کار کردن به «ب» نیاز داشته باشد، پیکان از «الف» به «ب» می‌رود:

الف → ب

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

الف → ب → پ → الف

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

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

معماری فقط انتخاب چارچوب، پایگاه داده یا ابزار استقرار نیست. بخش مهمی از معماری این است که بدانیم کدام بخش‌ها حق دارند به کدام بخش‌ها وابسته باشند، و کدام وابستگی‌ها باید ممنوع، محدود یا دست‌کم آشکار باشند.

چرا این چرخه‌ها دردسرساز می‌شوند؟

وابستگی چرخه‌ای همیشه برنامه را همان لحظه خراب نمی‌کند. گاهی خطر آن آرام‌تر و پنهان‌تر است. کد کار می‌کند، اما کم‌کم سخت‌تر فهمیده می‌شود، سخت‌تر آزموده می‌شود و سخت‌تر تغییر می‌کند.

۱. فهم کد سنگین‌تر می‌شود

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

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

۲. تغییرها اثر موجی پیدا می‌کنند

وقتی دو بخش به هم قفل شده‌اند، تغییر در یکی ممکن است دیگری را هم بشکند؛ حتی اگر از نظر مفهومی انتظار چنین اثری نداشته باشیم.

این همان جایی است که توسعه‌دهنده‌ها کم‌کم محتاط می‌شوند. نه چون کد حتماً بزرگ است، بلکه چون معلوم نیست تغییر یک بخش، کجای دیگر را تحت تأثیر قرار می‌دهد.

۳. آزمون‌پذیری کم می‌شود

آزمودن یک بخش وقتی ساده است که بتوان آن را جدا ساخت و اجرا کرد. اما وقتی «الف» برای ساخته‌شدن به «ب» نیاز دارد و «ب» هم به «الف»، جداسازی سخت می‌شود.

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

۴. راه‌اندازی سیستم حساس می‌شود

در برخی زبان‌ها و چارچوب‌ها، ترتیب ساخته‌شدن اجزا مهم است. چرخه‌ها این ترتیب را شکننده می‌کنند. در پایتون، این شکنندگی هنگام واردسازی دیده می‌شود. در سامانه‌های بزرگ‌تر، ممکن است هنگام راه‌اندازی سرویس‌ها، ساختن وابستگی‌ها یا بارگذاری تنظیمات خودش را نشان دهد.

نکته‌ی مهم

خطر وابستگی چرخه‌ای فقط در «خطا دادن» نیست. گاهی برنامه خطا نمی‌دهد، اما طراحی آن چنان درهم می‌شود که هر تغییر کوچک با ترس و هزینه همراه است.

نمونه‌ی ملموس در پایتون

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

این نمونه را ببینیم:

users.py
from notifications import send_welcome_message


class User:
pass


def create_user():
user = User()
send_welcome_message(user)
return user
notifications.py
from users import User


def send_welcome_message(user: User):
print("Welcome!")

در نگاه اول، هر دو واردسازی طبیعی به نظر می‌رسند. فایل users.py برای فرستادن پیام به notifications.py نیاز دارد. فایل notifications.py هم برای اشاره به نوع کاربر، User را از users.py وارد کرده است.

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

راه‌حل سریع ممکن است این باشد که واردسازی دوم را به درون تابع ببریم یا برای اشاره‌های نوعی از روش‌هایی مثل TYPE_CHECKING استفاده کنیم. این روش‌ها گاهی درست و لازم‌اند. اما نباید پرسش اصلی را پنهان کنند:

آیا پیام‌رسانی واقعاً باید کلاس کامل کاربر را بشناسد؟

شاید کافی باشد فقط نام و رایانامه‌ی کاربر را بگیرد. شاید باید یک قرارداد کوچک‌تر تعریف شود. شاید هم مفهوم مشترکی وجود دارد که باید از هر دو ماژول بیرون کشیده شود.

پرسش طراحی

وقتی با واردسازی چرخه‌ای روبه‌رو می‌شویم، نخستین پرسش نباید این باشد که «چطور خطا را دور بزنم؟» پرسش بهتر این است: «چرا این دو ماژول تا این اندازه به جزئیات هم وابسته‌اند؟»

آیا هر چرخه‌ای بد است؟

نه. پاسخ دقیق‌تر این است: چرخه‌ها یکسان نیستند.

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

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

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

پس پرسش درست این نیست:

آیا هر چرخه‌ای بد است؟

پرسش دقیق‌تر این است:

این چرخه چه چیزهایی را به هم گره زده، چه زمانی فعال می‌شود، و چه هزینه‌ای به فهم، آزمون و تغییر سیستم تحمیل می‌کند؟

نگاه معماری نرم‌افزار

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

چه چیزی مجاز است به چه چیزی وابسته باشد؟

رابرت مارتین در بحث طراحی مؤلفه‌ها از اصلی به نام اصل وابستگی‌های بدون چرخه یاد می‌کند. مضمون این اصل ساده است: گراف وابستگی میان مؤلفه‌ها نباید چرخه داشته باشد.

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

  • آیا مسئولیت‌ها درست جدا شده‌اند؟
  • آیا یک مفهوم مشترک باید به جای سومی منتقل شود؟
  • آیا یکی از بخش‌ها باید به قرارداد وابسته باشد، نه به پیاده‌سازی مشخص؟
  • آیا جهت وابستگی با جهت تغییرهای واقعی سیستم سازگار است؟

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

با چرخه‌ها چه کنیم؟

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

۱. مفهوم مشترک را بیرون بکشیم

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

به‌جای این:

users.py → notifications.py → users.py

می‌توانیم به این برسیم:

users.py          → shared.py
notifications.py → shared.py

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

۲. به قرارداد وابسته شویم، نه به جزئیات

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

هرچه وابستگی کوچک‌تر و دقیق‌تر باشد، تغییرهای آینده کم‌هزینه‌تر می‌شوند.

۳. جهت وابستگی را برگردانیم

گاهی جهت وابستگی اشتباه است. برای نمونه، منطق دامنه نباید مستقیماً به ابزار پیام‌رسانی وابسته شود. شاید بهتر باشد دامنه فقط یک رویداد تولید کند: «کاربر ساخته شد». سپس بخش دیگری آن رویداد را بشنود و پیام بفرستد.

دامنه → رویداد
شنونده‌ی رویداد → پیام‌رسانی

در این طراحی، دامنه از جزئیات پیام‌رسانی بی‌خبر می‌ماند.

۴. واردسازی محلی را با احتیاط به کار ببریم

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

واردسازی محلی می‌تواند درمان باشد؛ اما گاهی فقط مسکن است.

معیار تصمیم

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

چرخه‌ها را باید دید، نه فقط حذف کرد

هدف این نیست که هر چرخه‌ای را بی‌درنگ ممنوع کنیم. هدف این است که چرخه‌ها پنهان نمانند.

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

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

جمع‌بندی

وابستگی چرخه‌ای را نه باید بیش از اندازه بزرگ کرد، نه باید ساده از کنار آن گذشت.

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

پرسش پایانی این است:

آیا این چرخه حاصل یک نیاز آگاهانه و کنترل‌شده است، یا نشانه‌ای از مرزبندی مبهم میان مسئولیت‌ها؟

اگر پاسخ روشن باشد، شاید چرخه‌ای کوچک پذیرفتنی باشد. اما اگر پاسخ مبهم بماند، همان چرخه‌ی کوچک می‌تواند سرنخی باشد از گره‌ای پنهان در طراحی نرم‌افزار.

برای بازبینی کد

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

منابع پیشنهادی برای مطالعه‌ی بیشتر

  • Robert C. Martin, Clean Architecture: A Craftsman’s Guide to Software Structure and Design, 2017.
  • Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices, 2002.
  • Mark Richards and Neal Ford, Fundamentals of Software Architecture: An Engineering Approach, 2020.
  • Neal Ford, Rebecca Parsons, and Patrick Kua, Building Evolutionary Architectures: Support Constant Change, 2017.
  • Python Documentation, The import system.
  • Python Documentation, Programming FAQ: Circular imports.
  • mypy Documentation, Annotation issues at runtime.