Hossein Mobasher
Hossein Mobasher
خواندن ۱۱ دقیقه·۴ سال پیش

چارچوب‌های وارونگی کنترل و الگوهای آن

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

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


مسأله: سامانه پایش - Monitoring System

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

فرض کنید که سرویس اعلان یک نمونه از ارسال پیام کوتاه ایجاد و استفاده کند. در این حین، ممکن است بسته به کسب و کار جاری پروژه، نیاز به تغییر استراتژی ارسال پیام به وجود آید. در این صورت، چه باید کرد؟ در صورتی‌که ما تنها دو سرویس ارسال با پیام و ارسال با ایمیل را داشته باشیم، در این صورت بایستی جاهایی از کد که ارجاع به سرویس ارسال با پیام دارند با سرویس ارسال با ایمیل تعویض شوند که بسیار هزینه‌بر و نیاز به استقرار مجدد پروژه دارد!


وارونگی کنترل - Inversion of Control

بزرگترین عیب در تفکر بالا، انتشار تغییراتی است که منجر به تحمیل فشار و هزینه‌ی زیاد به پروژه و کسب و کار می‌شود. از این رو به بیان اصل DIP از SOLID می‌پردازیم. با توجه به اصل DIP، بایستی وابستگی بین کلاس‌ها به سطح انتزاع – کلاس‌های abstract یا interface ها - برسد و کلاس‌ها نباید به هم وابستگی سطح پایین –کلاس‌های concrete - داشته باشند. از این رو استفاده از الگوهایی مانند Strategy بسیار مهم و حیاتی به نظر می‌رسد. همان‌طوری که در الگوی استراتژی اشاره شده است، بایستی الگوریتم‌های با امضای مشترک - در این‌جا ارسال پیام - زیر یک انتزاع مشترک – در این‌جا interface - تعریف شوند. از این رو ساختار اولیه‌ی زیر شکل می‌گیرد.

public interface MessageSender {     void sendMessage(String message, String destination); }

حال دو روش بیان شده به شکل زیر قابل پیاده‌سازی هستند.

ارسال پیام کوتاه

public class SMSMessageSender implements MessageSender {     private SMSProvider provider; // Service of SMS provider company     public void sendMessage(String message, String destination) {         // Validation, Log, ...         provider.sendSMS(message, destination);     } }

ارسال ایمیل

public class EmailMessageSender implements MessageSender {     private EmailProvider provider; // OS service to send email     public void sendMessage(String message, String destination) {         // Validation, Log, ...         provider.sendEmail(&quotEmail Title&quot, message, destination);     } }

با توجه به تغییرات انجام شده، کافیست که مصرف‌کننده‌ها وابستگی خود را به سطح MessageSender برسانند تا اصل DIP رعایت شود. استفاده از این وابستگی نیازمند تخصیص یکی از دو روش بالاست. دو راه حل برای انتخاب روش پیاده‌سازی وجود دارد. اولی ساخت شئ پیاده‌ساز توسط هر کلاس مصرف‌کننده و دومی برون سپاری تولید شئ پیاده‌ساز است.

قبل از آن‌که به ادامه‌ی بحث قبل بپردازیم، ابتدا اصل برنامه‌نویسی Inversion of Control (وارونگی کنترل) را بیان می‌کنیم. در برنامه‌نویسی سنتی، برنامه‌نویس با استفاده از دستورات شرطی، حلقه‌ها، فراخوانی توابع مختلف و غیره، مسیر کامل اجرای برنامه‌ را مشخص می‌کرد و نیازهای مختلف برنامه را با تولید کتابخانه‌های مختلف و استفاده از آن‌ها پاسخ می‌گفت. نمونه‌ای از این برنامه‌ها، برنامه‌ی مبتنی بر خط فرمان زیر است که کاربر مشخص می‌کند که چه زمانی، چه اطلاعاتی دریافت و نمایش داده شود. از این رو کنترل کامل جریان توسط کدی که برنامه‌نویس نوشته است، مدیریت می‌شود.

Scanner scanner = new Scanner(System.in); System.out.println(&quotEnter firstName:&quot); String firstName = scanner.nextLine(); System.out.println(&quotEnter lastName:&quot); String lastName = scanner.nextLine(); System.out.printf(&quotYour name is %s %s&quot, firstName, lastName); scanner.close();

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

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

در روش دوم، کلاس‌های مصرف کننده فرض می‌کنند که شئ پیاده‌ساز به آن‌ها داده می‌شود و تمرکز آن‌ها فقط پیاده‌سازی کسب و کار خود خواهد بود. از طرفی، مسئولیت تولید شئ پیاده‌ساز و تزریق آن به کلاس‌های وابسته، به یک ساختار دیگر سپرده می‌شود. در این روش، کنترل ساخت و ارائه‌ی اشیاء معکوس شده و به ساختار جانبی – IoC Container - واگذار شده است.

تزریق وابستگی - Dependency Injection

همان‌طوری که گفته شد، وظیفه‌ی کنترل اشیاء به IoC Container ها سپرده می‌شود. اما چگونه؟ این Container ها یک سری قرارداد وضع می‌کنند تا با رعایت این قراردادها در سرویس‌های مختلف، ماژول Assembler (ماژول تزریق) بتواند پیاده‌سازی مناسبی از یک انتزاع را در جای مناسب تزریق کند. در شکل زیر ارتباط بین ماژول تزریق با سرویس اعلان و ارسال پیام دیده می‌شود.

نمودار وابستگی سامانه پایش با Assembler
نمودار وابستگی سامانه پایش با Assembler

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

از آن‌جایی که این چارچوب‌ها فقط مناسب‌ترین پیاده‌سازی یک انتزاع را پیدا می‌کنند، نام Inversion of Control برای آن‌ها بسیار کلی و مبهم به نظر می‌رسد. از این رو ارائه‌دهندگان این چارچوب‌ها تصمیم گرفتند تا نام Dependency Injection را برای این چارچوب‌ها استفاده کنند. در ادامه به معرفی انواع مختلف تزریق وابستگی خواهیم پرداخت.

تزریق از طریق سازنده کلاس

اولین روش، استفاده از سازنده‌ی کلاس (Constructor Injection) است که در آن، تمام وابستگی‌های یک کلاس به عنوان آرگومان‌های ورودی به آن داده می‌شود.

private MessageSender sender; public MonitoringService(MessageSender sender) { this.sender = sender; }

در این روش، ماژول تزریق برای ساخت شئ، از کلاس‌های پیاده‌ساز موجود استفاده می‌کند و آن‌ها را به سازنده‌ی کلاس‌ها ارسال می‌کند. معرفی پیاده‌سازها به تزریق‌کننده می‌تواند با استفاده از یک فایل پیکربندی (مانند spring-bean.xml) یا قرارداد داخل کد (مانند Spring Annotation) اتفاق بیفتد. در چارچوب Spring، از هر دوی این روش‌ها برای معرفی اشیاء و تزریق آن‌ها استفاده می‌شود.

تزریق از طریق توابع Setter

روش Setter Injection مشابه روش اول است ولی برای تزریق وابستگی‌ها در محل وابستگی توابع set برایشان تعریف می‌شود. در زیر یک نمونه دیده می‌شود.

private MessageSender sender; public void setSender(MessageSender sender) { this.sender = sender; }

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

تزریق از طریق Interface Injection

در این روش انتزاع‌هایی برای تزریق وابستگی‌ها ساخته می‌شود و هر جایی که نیاز به وابستگی‌ها باشد، این انتزاع‌ها مورد استفاده قرار می‌گیرند. برای مثال، interface زیر برای تزریق شئ از جنس T مورد استفاده قرار می‌گیرد.

public interface Injectable<T> { void inject(T t); }

حال کافیست تا این interface در کلاس‌های وابسته اضافه و از شئ ارسال شده به آن استفاده شود. این بخش از کار،‌ مشابه روش دوم است.

public class MonitoringService implements Injectable<MessageSender> private MessageSender sender; @Override public void inject(MessageSender sender) { this.sender = sender; }

تنها بخش از حلقه‌ی گمشده در این روش، نحوه‌ی ایجاد اشیاء تزریق شونده است. تزریق وابستگی در این روش با کمک گرفتن از الگوی طراحی Visitor انجام می‌شود و کلاس‌های پیاده‌ساز بایستی این انتزاع عمومی را پیاده‌سازی کنند. نمونه‌ی این انتزاع در interface زیر دیده می‌شود.

public interface Injector { void inject(Object target); }

در زیر نحوه‌ی اضافه شدن این interface به پیاده‌ساز دیده می‌شود.

public class SMSMessageSender implements MessageSender, Injector @Override public void inject(Object target) { ((Injectable<MessageSender>) target).inject(this); }

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

Container container = new Container(); container.registerComponent(&quotMessageSender&quot, SMSMessageSender.class); container.registerComponent(&quotMonitoringService &quot, MonitoringService.class); container.registerInjector(Injector.class, container.lookup(&quotMessageSender&quot)); container.start();

این قطعه کد از دو بخش اصلی تشکیل شده است. بخش اول، معرفی کلاس‌های پیاده‌ساز و مصرف‌کننده و بخش دوم، معرفی سرویس Injector به Container است. پس از این که Container شروع به کار کند، با فراخوانی تابع inject کلاس SMSMessageSender سرویس MonitoringService را مقداردهی می‌کند.

از بین روش‌های معرفی‌شده، روش تزریق از طریق سازنده، روش بسیار مطمئن‌تری است. زیرا زمانی یک شئ ساخته می‌شود که تمام وابستگی‌های آن فراهم باشد، در غیر این‌صورت شئ ساخته نخواهد شد و برنامه قابل اجرا نخواهد بود. در روش دوم و سوم، شئ همواره ساخته می‌شود ولی ممکن است یکی از وابستگی‌های آن ساخته نشود و در این صورت شئ ناقصی خواهیم داشت که رفتار قطعی نشان نخواهد داد. در چارچوب Spring می‌توان از @Required یا @Autowired برای فراخوانی حتمی توابع set استفاده کرد ولی در این‌صورت کدتان وابسته به چارچوب خواهد شد. همچنین در روش دوم و سوم، ایجاد ارجاع چرخشی - Circular Reference - به صورت ضمنی و غیر قابل تشخیص است و ممکن است منجر به تولید خطاهای غیرقابل پیش‌بینی شود در حالی‌که در روش اول این موضوع صریح است و منجر به خطای زمان تفسیر می‌شود.


الگوی Service Locator

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

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

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

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

پیاده‌سازی متداول

این الگو، از بخش‌های اصلی Service Locator، Cache و Initializer تشکیل می‌شود. جزء Initializer در واقع یک Factory Method است که نام سرویس را می‌گیرد و شئ‌ای متناظر برای آن سرویس می‌سازد. Cache بخشی است که اشیاء ساخته شده در Initializer را نگه می‌دارد تا از ساخت مجدد آن‌ها جلوگیری کند و مجدد مورد استفاده قرار دهد.

بخش Service Locator، هسته‌ی اصلی این الگو است که شئ‌ای از Cache را در خود دارد و متد getService آن، ابتدا در Cache دنبال سرویس می‌گردد و در صورتی‌که سرویس تا به حال ساخته نشده باشد از Initializer Factory استفاده می‌کند تا شئ را ساخته و برگرداند. در شکل زیر نمودار وابستگی مساله سامانه پایش با استفاده از الگوی Service Locator دیده می‌شود.

نمودار وابستگی مسأله سامانه پایش با ServiceLocator
نمودار وابستگی مسأله سامانه پایش با ServiceLocator

کلام آخر

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

  • جدا شدن اجرای یک سرویس از پیاده‌سازی آن
  • سهولت در تغییر پیاده‌سازی سرویس مورد استفاده (چه به صورت آنلاین و چه به صورت آفلاین)
  • افزایش تست‌پذیری کد نهایی و تزریق سرویس‌های Mock به جای سرویس‌های اصلی

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


منابع

iocdipdi
شاید از این پست‌ها خوشتان بیاید