در این آموزش میخواهیم در مورد اصل دوم SOLID که همان اصل Open-Closed است صحبت کنیم. باید بگویم که هر کسی مسئول نامگذاری این اصول شد، مطمئناً به توسعه دهندگان کم تجربه و تازه کار فکر نمیکرد. در مورد نام اصل Open-Closed هم همینطور است. الباقی اصولی که در آینده بررسی خواهیم کرد هم از نظر نامگذاری حتی عجیب و غریبتر از اصل دوم هستند.
صرف نظر از این نامگذاریهای عجیب، بیایید به تعریفی که همه برای این اصل روی آن اتفاق نظر دارند، نگاهی بیاندازیم:
کلاس ها باید برای بسط داده شدن(Extend) باز باشند اما برای اصلاح( Change) بسته.
آه! بله، من هم وقتی برای اولین بار با این تعریف مواجه شدم، خوشحال نشدم. اما با گذشت زمان وقتی کاربرد این اصل را فهمیدم، آن را تحسین میکنم. آنچه این اصل می خواهد بگوید این است: کدی که یکبار نوشته میشود نباید نیاز به تغییر پیدا کند.
از نظر فلسفی، چنین چیزی عالی است. اگر کد تغییر نکند، قابل پیش بینی باقی می ماند و باگ های جدید پیدا نمیشوند. اما چگونه ممکن است حتی رویای کدی را ببینیم که هرگز تغییر نمیکند؟ در حالی که تمام کاری که ما بهعنوان توسعهدهنده انجام میدهیم این است که همیشه لیستی از تغییرات را در پروژه اعمال کنیم؟
خب، اولاً، اصل به این معنا نیست که اجازه نداریم حتی یک خط از کد را تغییر بدهیم. چنین چیزی فقط در خواب و خیال ممکن خواهد بود. دنیا تغییر میکند، کسبوکار تغییر میکند، و بنابراین، کد نیز تغییر خواهد کرد. در واقع منظور این است که ما امکان تغییر کد را تا حد ممکن محدود کنیم. همانطور که بالاتر اشاره شد، چنین کاری را باید این گونه انجام دهیم:
کلاس ها باید برای بسط داده شدن(Extend) باز باشند اما برای اصلاح( Change) بسته.
“Extension” در اینجا به معنای استفاده مجدد است، چه استفاده مجدد به شکل کلاس های فرزند باشد که عملکرد را از یک کلاس والد به ارث می برد، یا کلاس های دیگری که یک نمونه از کلاس را در خود ذخیره می کنند و متدهای آن را فراخوانی می کنند.
بنابراین، بیایید به سوال میلیون دلاری برگردیم: چگونه کدی بنویسیم که از تغییرات جان سالم به در ببرد؟
اینجا جایی است که ترس و دلهره ایجاد میشود. زیرا هیچ کس برای این سوال پاسخ روشنی ندارد. در برنامه نویسی شیگرا، تا کنون چندین تکنیک برای دستیابی به این هدف کشف و توسعه داده شده است. درست مانند همین اصول SOLID که در حال مطالعه آن هستیم تا الگوهای رایج طراحی، الگوهای سازمانی، الگوهای معماری و غیره. اما با این حال، هیچ پاسخ کاملی وجود ندارد. بنابراین یک توسعهدهنده باید بالا و بالاتر رفته و تا جایی که میتواند به دنبال انجام بهترین کار باشد تا در نهایت بتواند ابزارهای زیادی برای پاسخگویی به این سوال جمعآوری کند.
حال بیایید به یکی از این تکنیک ها نگاه کنیم. فرض کنید باید قابلیتی را به پروژهی لاراولی خود اضافه کنیم تا یک محتوای HTML داده شده (شاید یک فاکتور) را به یک فایل PDF تبدیل کند و در پاسخ درخواستمان، یک فایل pdf را برای بارگیری به ما بدهد. همچنین فرض کنید که اشتراک پولی یک سرویس فرضی به نام MilkyWay را داریم که از محتوای HTML تولید PDF میکند. ممکن است در نهایت یک متد Controller مانند این بنویسیم:
class InvoiceController extends Controller {
public function generatePDFDownload(Request $request) {
$pdfGenerator = new MilkyWay();
$pdfGenerator->apiKey = env('MILKY_WAY_API_KEY');
$pdfGenerator->setContent($request->content); // HTML format
$pdfFile = $pdfGenerator->generateFile('invoice.pdf');
return response()->download($pdfFile, [
'Content-Type' => 'application/pdf',
]);
}
}
در نمونه کد بالا، اعتبار سنجی درخواست و موارد دیگر را کنار گذاشتهایم تا روی موضوع اصلی تمرکز کنیم. متوجه خواهید شد که این متد در پیروی از اصل Single Responsibility به خوبی عمل می کند. به این صورت که سعی نمی کند محتوای HTML ارسال شده به آن را پردازش کند و یک PDF ایجاد کند (در واقع، حتی نمی داند که به آن HTML داده شده است.) در عوض، این مسئولیت را به کلاس تخصصی MilkyWay میسپارد و هر آنچه را که به عنوان پاسخ از این کلاس به دست میآورد، به عنوان فایل خروجی برای دانلود ارائه میکند. اما یک مشکل جزئی وجود دارد!
یک بار دیگر به کد درون متد generatePDFDownload مربوط به InvoiceController نگاه کنید. این متد بیش از حد به کلاس MilkyWay وابسته است و تمام تنظیمات مربوط به این کلاس، در این متد مقداردهی میشوند. حال اگر نسخه بعدی MilkyWay رابطش را تغییر دهد، چه خواهد شد؟ قطعا متد ما از کار خواهد افتاد و اگر بخواهیم روزی از سرویس دیگری استفاده کنیم، باید به معنای واقعی کلمه یک جستجوی کامل در تمام پروژه انجام دهیم و تمام کدهایی را که به MilkyWay ارجاع میدهند، تغییر دهیم. این حالت بسیار بدی است. چرا؟ زیرا احتمال اشتباه را تا حد زیادی افزایش می دهد و بار سنگینی بر دوش کسب و کار میگذارد (زمانی که توسعه دهنده برای مرتب کردن آشفتگی صرف می کند).
این همه اتلاف زمان به این دلیل است که ما متدی را ایجاد کردیم که برای تغییر بسته نبود.
برای خروج از این وضعیت، ما میتوانیم روش کدنویسی خود را به این صورت تغییر دهیم:
program to interfaces, not implementations.
بله، می دانم، این یکی دیگر از آن مفاهیم شیگرایی است که بار اول بی معنی به نظر میرسد. اما آنچه می گوید این است که کد ما باید به type های یک چیز وابسته باشد، نه خود آن چیز خاص! در ادامه و با اصلاح کد، مفهوم این مواردی که به ظاهر گنگ هستند را درک خواهیم کرد. پس بیایید مثال خود را با این روش اصلاح کنیم. باید خود را از وابستگی به کلاس MilkyWay رها کنیم، و در عوض به یک کلاس عمومی که یک نوع انتزاعی از کلاس PDF است، وابسته باشیم.
حال چه ابزارهایی در PHP برای ایجاد انواع مختلف از یک چیز داریم؟ به طور کلی، ما Inheritance و Interfaces داریم. در مورد مثال ما ایجاد یک کلاس والد برای همهی کلاسهای PDF ایده خوبی نخواهد بود، زیرا تصور انواع مختلف موتورها/سرویسهای تولید PDF که رفتار مشابهی داشته باشند، دشوار است. شاید همهی آنها متد setContent() را مشترکا دارا باشند، اما حتی در آن متد هم، فرآیند دریافت محتوا برای هر کلاسِ سرویس PDF متفاوت است، بنابراین نوع بندی همه چیز در یک سلسله مراتب وراثت، اوضاع را بدتر می کند.
با درک این موضوع، بیایید از تنها راه باقی مانده که استفاده از Interface است کمک بگیریم. یک Interface ایجاد میکنیم که مشخص کند میخواهیم همهی کلاسهای تولید PDF ما شامل چه متدهایی باشند:
interface PDFGeneratorInterface {
public function setup(); // API keys, etc.
public function setContent($content);
public function generatePDF($fileName = null);
}
از طریق این رابط، ما می گوییم که انتظار داریم تمام کلاس های PDF ما حداقل سه متد بالا را با همان آرگمانهای ورودی مشترک، داشته باشند. حال، اگر سرویسی که می خواهیم استفاده کنیم (در مورد مثال ما MilkyWay) از این رابط پیروی نمی کند، وظیفه ما این است که کلاسی بنویسیم که این کار را انجام دهد. یک طرح تقریبی از نحوه نوشتن کلاس wrapper برای سرویس MilkyWay به شرح زیر است:
class MilkyWayPDFGenerator implements PDFGeneratorInterface {
public function __construct() {
$this->setup();
}
public function setup() {
$this->generator = new MilkyWay();
$this->generator->api_key = env('MILKY_WAY_API_KEY');
}
public function setContent($content) {
$this->generator->setContent($content);
}
public function generatePDF($fileName) {
return $this->generator->generateFile($fileName);
}
}
و دقیقاً به این ترتیب، هر زمان که یک سرویس PDF جدیدی داشته باشیم، یک کلاس wrapper برای آن می نویسیم. در نتیجه، تمام آن کلاس ها از رابط IPFGenerator پیروی میکنند و هر کجا نیاز باشد، میتوان از این کلاسها بجای یکدیگر استفاده کرد. زیرا همهی آنها نوعهای مختلف رابط PDFGeneratorInterface هستند.
بنابراین، چگونه همه اینها به اصل Open-Closed و لاراول مرتبط است؟
برای رسیدن به جواب باید دو مفهوم کلیدی دیگر را بدانیم: نحوهی bind کردن به Laravel Container و یک تکنیک بسیار رایج به نام تزریق وابستگی (Dependency Injection). شاید با خود بگویید آه خدای من، باز هم کلمات بزرگ! اما تزریق وابستگی به سادگی به این معنی است که به جای اینکه خودتان اشیاء کلاس ها را ایجاد کنید، آنها را در آرگومان های تابع ذکر کنید و ابزاری به طور خودکار آنها را برای شما ایجاد کند. این روش شما را از نوشتن کدهایی مانند $account = new Account(); رها میکند و کد را قابل آزمایشتر می کند. این “ابزاری” که من به آن اشاره کردم تحت عنوان Service Container در دنیای لاراول معرفی شده است.
در حال حاضر، فقط به آن به عنوان ابزاری نگاه کنید که می تواند نمونه های کلاس جدیدی را برای ما ایجاد کند. بیایید ببینیم چگونه این کمک را انجام میدهد.
در مثال ما، میتوانیم در پروژهی لاراول خود، فایل AppServiceProvider.php را باز کنیم و در متد boot آن، چیزی شبیه این بنویسیم:
$this->app->bind('App\Interfaces\PDFGeneratorInterface', 'App\Services\PDF\MilkyWayPDFGenerator');
چیزی که ما در کد بالا اضافه کردیم، به این مفهوم است که هر زمان که شخصی درخواست یک نمونه از PDFGeneratorInterface کرد، نمونهای از کلاس MilkyWayPDFGenerator را ایجاد کن و به او تحویل بده. و بعد از این همه کش و قوس، به جایی می رسیم که همه چیز سر جای خودش قرار می گیرد و اصل Open-Closed در عمل پیاده سازی میشود!
حال با دانشی که کسب کردیم، می توانیم InvoiceController را به این صورت بازنویسی کنیم:
class InvoiceController extends Controller {
public function generatePDFDownload(Request $request, PDFGeneratorInterface $generator) {
$generator->setContent($request->content);
$pdfFile = $generator->generatePDF('invoice.pdf');
return response()->download($pdfFile, [
'Content-Type' => 'application/pdf',
]);
}
}
به تفاوت ایجاد شده توجه کردید؟
ابتدا، ما نمونه کلاس تولید کنندهی PDF خود را در آرگومان تابع دریافت می کنیم. همانطور که قبلاً در مورد آن صحبت شد، این کار توسط Service Container لاراول انجام میشود و نمونه ایجاد شده به Controller ما منتقل می شود. همانطور که مشهود است، کد نیز تمیزتر شده و خبری از کلیدهای API و غیره نیست. اما مهمتر از همه، هیچ اثری از کلاس MilkyWay هم وجود ندارد. این یک مزیت عالی برای خوانایی بیشتر کد دارد. کسی که برای اولین بار آن را میخواند، گیج نمیشود.
بزرگترین فایده اصل Open-Closed چیست؟
با استفاده از این اصل، کد ما در برابر توسعه (Extention) باز و در برابر تغییر(Change) بسته است. اجازه بدهید مثالی کاربردی در این خصوص بیان نمایم. فرض کنید فردا روز، احساس میکنیم که اشتراک پولی سرویس MilkyWay بسیار گران است (یا همانطور که اغلب اتفاق می افتد، پشتیبانی مشتری آنها بد شده است). در نتیجه، ما سرویس دیگری به نام SilkyWay را امتحان میکنیم و در نتیجه میخواهیم تولید PDF را به آن منتقل کنیم. اکنون تنها کاری که باید انجام دهیم این است که یک کلاس wrapper جدید برای SilkyWay که رابط PDFGeneratorInterface را implement میکند، بنویسیم. همچنین bind را در کد AppServiceProvider.php به کلاس جدید تغییر دهیم:
$this->app->bind('App\Interfaces\PDFGeneratorInterface', 'App\Services\PDF\SilkyWayPDFGenerator');
فقط همین!!
هیچ چیز دیگری نیاز به تغییر ندارد، زیرا برنامه ما بر اساس یک رابط (PDFGeneratorInterface) به جای یک کلاس مشخص، نوشته شده است. وقتی الزامات تجاری تغییر کرد، یک کلاس جدید اضافه شد (کلاس wrapper) و تنها یک خط کد تغییر کرد. پس از آن، همه چیز دست نخورده باقی ماند و در نتیجه کل تیم می توانند با اطمینان به خانه بروند و با آرامش بخوابند.
پس اگر می خواهید آرام بخوابید، از اصل Open-Closed پیروی کنید!