حالت تاریک
فصل ۱۱ — تفکیک دغدغهها
تفکیک دغدغهها (separation of concerns) بهعنوان «اصل طراحی برای جدا کردن برنامهٔ کامپیوتری به بخشهای متمایز بهگونهای که هر بخش یک دغدغهٔ جدا را برطرف کند» تعریف میشود.[^1]
تفکیک دغدغهها قدرتمندترین اصل طراحی در کار خودم است. همهجا به کار میبرم.
توصیف عامیانهٔ تفکیک دغدغهها این است: «یک class، یک چیز. یک method، یک چیز.» جملهٔ خوبی است، اما به برنامهنویسان تابعی اجازهٔ نادیده گرفتنش را نمیدهد.
این دربارهٔ وضوح و تمرکز در کد و سیستمهایمان است. یکی از تکنیکهای کلیدی برای بهبود مدولاریته، انسجام و انتزاع در سیستمهایی که میسازیم و در نتیجه کمینهسازی مؤثر coupling است.
تفکیک دغدغهها در همهٔ سطوح granularity عمل میکند. اصل مفیدی در مقیاس کل سیستمها و هم در سطح توابع منفرد است.
تفکیک دغدغهها واقعاً همانجور ایدهای نیست که انسجام و مدولاریته. آن دو ویژگی کد هستند و اگرچه میتوانیم بگوییم کدی «تفکیک دغدغهٔ خوبی» دارد، واقعاً میگوییم «چیزهای نامرتبط دورند و چیزهای مرتبط نزدیکاند.» تفکیک دغدغهها در واقع نگاه خاصی به مدولاریته و انسجام است.
تفکیک دغدغهها عمدتاً تکنیکی است که میتوانیم اتخاذ کنیم تا coupling را کاهش دهیم و انسجام و مدولاریته کد و سیستمهایمان را بهبود بخشیم.
با این حال، این اهمیت رویکرد من به طراحی را کمرنگ میکند. تفکیک دغدغهها محرک بنیادی انتخابهای طراحی خوب برای من است. به من اجازه میدهد کد و معماری سیستمهایی که میسازم را تمیز، متمرکز، قابلترکیب، انعطافپذیر، کارآمد، مقیاسپذیر و باز به تغییر نگه دارم — و چیزهای خوب دیگری هم.
تعویض پایگاه داده
وقتی صرافی مالیمان را ساختیم، انضباطهای مهندسی شرحدادهشده در این کتاب را اتخاذ کردیم. در واقع این تجربه مرا به نوشتن این کتاب وادار کرد. صرافیمان فوقالعاده بود — بهترین codebase سیستم بزرگی که تا به حال روی آن کار کرده یا دیدهام.
رویکرد سختگیرانهای به تفکیک دغدغهها از توابع منفرد تا معماری enterprise اتخاذ کردیم. میتوانستیم business logic بنویسیم که اصلاً از محیطش خبر نداشت، کاملاً قابلآزمون بود، از remote callهایی که آگاه بود انجام نمیداد، دادهای که آگاه بود ثبت نمیکرد، آدرس همکارانش را نمیدانست و نگران امنیت، مقیاسپذیری یا تابآوری خود نبود.
این serviceها میتوانستند اینطور کار کنند چون همهٔ این رفتارها جای دیگری مراقبت میشدند؛ آنها دغدغههای دیگر سیستم بودند. بخشهایی که این serviceها را به core logic ارائه میدادند از کسبوکاری که در آن فعالیت میکردند خبر نداشتند و نمیدانستند کدی که برایش service میدهند چه میکند.
در نتیجه، service متمرکز بر domain در این سیستم بهطور پیشفرض امن، پایدار، highly available، مقیاسپذیر، تابآور و بسیار پرکارایی بود.
یک روز تصمیم گرفتیم شرایط تجاری با vendor پایگاه دادهٔ رابطهایمان را دوست نداریم. از این پایگاه داده برای ذخیرهٔ بخشی از محتوای data warehouseمان — انبار دادهٔ بزرگی که سریع رشد میکرد و جزئیات تاریخی تاریخچهٔ سفارش و سایر مقادیر business-critical را نگه میداشت — استفاده میکردیم.
یکی از سیستمهای مدیریت پایگاه دادهٔ رابطهای (RDBMS) متنباز را دانلود کردیم، به repository وابستگیهایمان کپی کردیم، deployment آن را script کردیم و چند تغییر ساده در کدی که با RDBMS تعامل داشت دادیم. این ساده بود بهخاطر تفکیک دغدغهها در معماریمان. سپس تغییر را به deployment pipeline تحویل مستمرمان submit کردیم. چند آزمون fail شد؛ خطا را ردیابی و مشکلات را رفع کردیم و نسخهٔ جدید را به pipeline commit کردیم. همهٔ آزمونهای deployment pipeline در تلاش دوم pass شدند، پس میدانستیم تغییرمان برای release امن است. تغییراتمان دفعهٔ بعدی که سیستم چند روز بعد release شد به production مستقر شد.
کل این داستان یک صبح طول کشید!
بدون تفکیک دغدغهٔ خوب، این ماهها یا سالها طول میکشید و احتمالاً اصلاً به آن فکر نمیکردند.
بیایید مثال سادهای ببینیم. در فصل قبل سه مثال کد برای حل همان مسئله نشان دادم؛ Listing 11.1 آنها را دوباره نشان میدهد.
python
def add_to_cart1(self, item):
self.cart.add(item)
conn = sqlite3.connect('my_db.sqlite')
cur = conn.cursor()
cur.execute('INSERT INTO cart (name, price)
values (item.name, item.price)')
conn.commit()
conn.close()
return self.calculate_cart_total();
def add_to_cart2(self, item):
self.cart.add(item)
self.store.store_item(item)
return self.calculate_cart_total();
def add_to_cart3(self, item, listener):
self.cart.add(item)
listener.on_item_added(self, item)در فصل قبل اینها را در زمینهٔ انسجام بحث کردیم، اما اصلی که برای نتیجهٔ منسجمتر به کار بردم تفکیک دغدغهها بود.
در مثال اول بد، add_to_cart1، تفکیک اصلاً وجود ندارد. این کد تمرکز اصلی تابع — افزودن چیزی به سبد — را با جزئیات عجیب نحوهٔ ذخیره در پایگاه دادهٔ رابطهای درهم میآمیزد. سپس بهعنوان side effect نوعی مجموع محاسبه میکند. ناخوشایند!
مثال دوم، add_to_cart2، پیشرفت قابلتوجهی است. اکنون ذخیرهسازی را آغاز میکنیم اما نحوهٔ کارش مهم نیست. میتوانیم تصور کنیم «store» به class داده شده و میتواند هر چیزی باشد. این کد در نتیجه بسیار انعطافپذیرتر است. با این حال هنوز میداند ذخیرهسازی و محاسبهٔ مجموع سبد درگیرند.
مثال سوم تفکیک دغدغهٔ کاملتری را نشان میدهد. در این مثال کد رفتار اصلی را اجرا میکند — افزودن چیزی به سبد — و فقط سیگنال میدهد چیزی اضافه شده. این کد نمیداند و اهمیت نمیدهد بعد چه میشود. کاملاً از ذخیرهسازی و محاسبهٔ مجموع جدا شده. این کد در نتیجه بسیار منسجمتر و مدولارتر است.
واضح است که مثل هر تصمیم طراحی، انتخاب وجود دارد. استدلال میکنم add_to_cart1 فقط بد است. تفکیک دغدغهها آن را رد میکند. اصل راهنما این است که مخلوطی از پیچیدگی ضروری و تصادفی است. یعنی چگونگی و کجای ذخیرهٔ چیزی به رفتار اصلی سبد خریدی که میخواهیم بسازیم ربطی ندارد. میخواهیم خط واضحی، تفکیکی، بین کدی که با پیچیدگی ضروری و کدی که با پیچیدگی تصادفی سر و کار دارد.
تفاوت بین دو مثال بعدی ظریفتر است. بیشتر مسئلهٔ زمینه و انتخاب است. ترجیح شخصی من بهشدت به نفع add_to_cart3 است. انعطافپذیرترین راهحل است. شاید انتخاب کنم تفکیک را با listener تزریقشده به متد انجام دهم، اما خیلی دوست دارم مفهوم ذخیرهسازی را از core domain حذف کردهام.
این کدی است که معمولاً مینویسم. به نظرم نسخه ۲ add_to_cart هنوز دغدغهها را قاطی میکند. قطعاً فکر میکنم store_item انتزاع بهتری از connection و SQL است، اما خود مفهوم هنوز در قلمرو پیچیدگی تصادفی است. اگر چیزی در سبد خرید واقعی بگذارید، نیازی به «persist» کردنش ندارید!
نسخه ۳ بیشترین آزادی انتخاب را با جریمهٔ واقعی اندک به من میدهد. انتقاد معتبر این رویکرد این است که نمیتوانید ببینید ذخیرهسازی ممکن است در جریان باشد، اما این واقعاً آنچه در این لحظه برای این قطعه کد مهم است نیست. ذخیرهسازی side effect نحوهٔ کار کامپیوترمان است، نه رفتار اصلی افزودن چیزی به سبد خرید. در مثال سوم بهوضوح میبینیم وقتی آیتمی اضافه میشود و میبینیم چیز دیگری ممکن است در حال وقوع باشد؛ فقط اهمیت نمیدهیم چه. اگر اهمیت دهیم، میتوانیم برویم ببینیم.
لحظهای دربارهٔ قابلیت آزمون هر یک از این متدها فکر کنید. نسخه ۱ برای آزمون وحشتناک خواهد بود. به پایگاه داده نیاز داریم، پس آزمون سخت برقرار میشود و احتمالاً بسیار شکننده و کند است؛ یا پایگاه داده shared است و مستعد تغییر خارج از آزمون، یا در setup آزمون ساخته میشود و هر اجرای آزمون بسیار کند است. هر دو نسخهٔ دیگر را میتوان بهراحتی و کارآمد با fakeها آزمون کرد.
استدلال اصلی علیه نسخه ۳ این است که کمتر روشن است چه میگذرد. قطعاً موافقم وضوح فضیلت است. اما این واقعاً مسئلهٔ زمینه است. به کدی نگاه میکنم که مسئول افزودن آیتم به سبد است. چرا باید بداند بعد چه میشود؟
این تمرکز بر تفکیک دغدغهها به بهبود مدولاریته و انسجام این کد کمک کرده. بسته به اینکه همکاری چند بخش — listenerهایی که نتایج را ذخیره یا مجموع محاسبه میکنند — چقدر مهم است، میتوانیم برقراری صحیح آن روابط را جای دیگر آزمون کنیم.
پس «تصویر کامل» فقط بهخاطر نگاه در جای اشتباه مبهم است. اگر آن دیدگاه سادهلوحانه از دنیا را بگیریم، آیا collection عمومی که برای نمایندگی سبد استفاده میکنیم هم نباید از ذخیرهسازی و مجموعها خبر داشته باشد؟ البته نه!
یکی از دلایلی که تفکیک دغدغهها را بهعنوان اصل راهنما اینقدر ارزش مینهیم این است که به من یادآوری میکند تمرکزم را کوچک نگه دارم. وقتی به هر بخشی نگاه میکنید و بدون فکر زیاد میفهمید آن بخش چه میکند، به کدی که نوشتهام افتخار میکنم. اگر بیش از چند ثانیه باید مطالعه کنید، شکست خوردهام. شاید باید بفهمید آن بخش چطور توسط بخشهای دیگر استفاده میشود، اما آن بخشهای دیگر دغدغههای خود را دارند و ترجیح میدهم آنها را به همان وضوح بیان کنم.
Dependency Injection
ابزار بسیار مفیدی برای دستیابی به تفکیک دغدغهٔ خوب، dependency injection است.
dependency injection جایی است که وابستگیهای یک قطعه کد بهعنوان پارامتر به آن داده میشوند، نه اینکه توسط خودش ساخته شوند.
در مثال کمی بیشازحد استفادهشدهٔ ما، در add_to_cart1 اتصال به پایگاه داده صریحاً داخل متد ساخته و باز میشود. پس فرصتی برای استفاده از جایگزین نیست. به این پیادهسازی خاص tightly coupled هستیم، حتی به این نمونهٔ مشخص نامگذاریشده از پایگاه داده. اگر store در نسخه ۲ بهعنوان پارامتر constructor پاس داده شود، فوراً جهشی در انعطافپذیری است. میتوانیم هر چیزی که store_item را پیادهسازی کند بدهیم.
در نسخه ۳ متد add_to_cart، listener میتواند هر چیزی باشد که on_item_added را پیادهسازی کند.
این تغییر ساده در رابطهٔ بین رفتارهای این کد قابلتوجه است. در مورد اول، نسخه ۱، کد همهٔ آنچه نیاز دارد را میسازد، پس به یک پیادهسازی واحد و مشخص deeply coupled است. این کد از طراحی انعطافناپذیر است. در دیگران، کد همکار با اجزای دیگر سیستم است، پس کم میداند و کم اهمیت میدهد چطور عمل میکنند.
dependency injection اغلب بهاشتباه عملکرد ابزار یا framework برداشت میشود، اما اینطور نیست. dependency injection کاری است که در اکثر زبانها — قطعاً در هر زبان OO یا تابعی — بهصورت native میتوانید انجام دهید و رویکرد قدرتمندی به طراحی است. حتی در اسکریپتهای پوستهٔ Unix هم بهخوبی دیدهام.
dependency injection ابزار فوقالعادهای برای کمینهسازی coupling به سطح مناسب و مفید است، اما همچنین راه مؤثری برای ایجاد خط مرزبندی بین دغدغههاست. دوباره تأکید میکنم چقدر این ایدهها درهمتنیدهاند. نه بهخاطر تکرار، بلکه چون ویژگیهای عمیق و مهمی از نرمافزار و توسعهٔ نرمافزار را توصیف میکنیم؛ پس وقتی از جهات مختلف به این مسائل نزدیک میشویم، ناگزیر تلاقی میکنند.
تفکیک پیچیدگی ضروری و تصادفی
راه مؤثری برای بهبود کیفیت طراحی، تفکیک دغدغهها به شیوهٔ خاصی است — یعنی تفکیک پیچیدگی ضروری سیستمهایمان از تصادفی. اگر مفهوم پیچیدگی «ضروری» و «تصادفی» برایتان جدید است، این ایدههای مهمیاند که اول در مقالهٔ مشهور Fred Brooks «There is No Silver Bullet» که قبلاً در این کتاب ذکر شد توصیف شدند.
پیچیدگی ضروری پیچیدگی ذاتی حل مسئلهای است که میخواهید حل کنید — چگونه ارزش حساب بانکی محاسبه شود، چگونه آیتمهای سبد خرید جمع زده شوند، یا حتی چگونه مسیر فضاپیم محاسبه شود. برطرف کردن این پیچیدگی ارزش واقعی سیستم ماست.
پیچیدگی تصادفی همهٔ چیزهای دیگر است — مسائلی که بهعنوان side effect انجام کار مفید با کامپیوترها مجبوریم حل کنیم. چیزهایی مثل persistence داده، نمایش روی صفحه، clustering، برخی جنبههای امنیت… در واقع هر چیزی که مستقیماً به حل مسئلهٔ دست روی ندارد.
فقط بهخاطر «تصادفی» بودن به معنای بیاهمیت نیست؛ نرمافزار روی کامپیوتر اجرا میشود، پس برخورد با محدودیتها و واقعیتهای آن مهم است، اما اگر سیستمی بسازیم که در برخورد با پیچیدگی تصادفی فوقالعاده باشد اما پیچیدگی ضروری نداشته باشد، بهتعریف بیفایده است! پس به نفع ماست که بدون نادیده گرفتن، پیچیدگی تصادفی را کمینه کنیم.
رویکرد مؤثری برای بهبود طراحیها از طریق تفکیک دغدغهها، تمرکز بسیار روشن بر تفکیک دغدغههای پیچیدگی تصادفی و ضروری سیستمهایمان است.
میخواهم منطق سیستمم که به رانندگی ماشین اهمیت دارد از منطقی که میداند چطور اطلاعات روی صفحه نمایش دهد جدا باشد، منطق ارزیابی معامله از نحوهٔ ذخیره یا ارتباط آن معامله جدا باشد.
شاید واضح به نظر برسد، اما مهم است و بهنظر من اکثر کدها اینطور نوشته نمیشوند. بیشتر کدی که میبینم مردم مینویسند بهوضوح این دو کلاس مسئولیت را قاطی میکند. رایج است business logic با کد نمایش و جزئیات persistence در میان منطقی که باید با core domain (پیچیدگی ضروری) سیستم سر و کار داشته باشد مخلوط شود.
این باز هم جایی است که تمرکز بر قابلیت آزمون کد و سیستمهایمان میتواند در بهبود طراحی کمک بزرگی کند.
Listing 10.1 این را بهوضوح نشان داد. این کد بهجز به شیوهٔ سادهلوحانهٔ پیچیده واقعاً قابلآزمون نیست. البته میتوانستم آزمونی بنویسم که اول فایلی در مکان مشخصی روی دیسک به نام words.txt بسازد، سپس کد را اجرا کند و نتیجه را در فایل دیگری در مکان مشخص به نام sorted.txt بگردد، اما این کند، آزاردهنده پیچیده و آنقدر به محیطش coupled است که با rename فایلها یا جابهجایی مکانشان بهراحتی آزمون را میشکست. سعی کنید این آزمون را موازی با خودش یا چیز مرتبط اجرا کنید و سریع با مشکلات ناخوشایند روبهرو میشوید!
اکثر کار جاری در Listing 10.1 حتی بهطور مبهم به رفتار مهمی از کد ربطی ندارد. این تقریباً همه پیچیدگی تصادفی در قلب کدی است که باید روی کار مهمتری متمرکز باشد — در این مورد مرتبسازی مجموعهای از کلمات.
Listing 10.2 انسجام را بهبود داد اما هنوز بهعنوان واحد قابلآزمون نیست. همان مشکلات Listing 10.1 را در این زمینه دارد.
Listing 11.2 مثالی از تلاش برای بهبود این کد صرفاً از منظر تفکیک پیچیدگی تصادفی از ضروری است. برای کد واقعی نامهایی مثل «essential» یا «accidental» انتخاب نمیکنم؛ فقط برای وضوح مثال است.
java
public interface Accidental
{
String[] readWords() throws IOException
boolean storeWords(List<String> sorted) throws IOException
}
public class Essential
{
public boolean loadProcessAndStore(Accidental accidental) throws IOException
{
List<String> sorted = sortWords(accidental.readWords());
return accidental.storeWords(sorted);
}
private List<String> sortWords(String[] words)
{
List<String> sorted = Arrays.asList(words);
sorted.sort(null);
return sorted;
}
}با فرض پیادهسازی توابع پیچیدگی تصادفی outlined در interface «Accidental» در Listing 11.2، این کد دقیقاً همان کار Listings 10.1 و 10.2 را انجام میدهد، اما بهتر است.
با تفکیک دغدغهها — در این مورد با استفاده از «درز» (seam) بین پیچیدگی تصادفی و ضروری مسئلهای که حل میکنیم — چیزها را بهطور قابلتوجهی بهبود دادهایم. این کد خواناتر، متمرکزتر روی مسئلهٔ مهم و در نتیجه بسیار انعطافپذیرتر است. اگر بخواهیم «کلمات» را از جای دیگری غیر از فایل در مکان مشخص روی دستگاه مشخص بدهیم، میتوانیم. اگر بخواهیم کلمات مرتبشده را جای دیگری ذخیره کنیم، میتوانیم.
این هنوز کد عالی نیست. میتوانستیم تفکیک دغدغه را بیشتر بهبود دهیم تا تمرکز و decoupling از نظر خوانایی و هم در سطح فنی بهتر شود.
Listing 11.3 چیزی نزدیکتر نشان میدهد؛ قطعاً میتوان دربارهٔ برخی انتخابهای نامگذاری من — که بیشتر به زمینه بستگی دارد — بحث کرد، اما صرفاً از منظر تفکیک دغدغهها امیدوارم تفاوت بسیار بزرگی بین کد Listing 10.1 و Listings 11.2 و 11.3 ببینید. حتی در این نمونهٔ ساده، با پیروی از این اصول طراحی خوانایی، قابلیت آزمون، انعطافپذیری و سودمندی کد را بهبود دادیم.
java
public interface WordSource
{
String[] words();
}
public interface WordsListener
{
void onWordsChanged(List<String> sorted);
}
public class WordSorter
{
public void sortWords(WordSource words, WordsListener listener)
{
listener.onWordsChanged(sort(words.words()));
}
private List<String> sort(String[] words)
{
List<String> sorted = Arrays.asList(words);
sorted.sort(null);
return sorted;
}
}تفکیک پیچیدگی ضروری و تصادفی نقطهٔ شروع خوبی برای رسیدن به کد با تفکیک دغدغهٔ بهتر است. ارزش زیادی در این رویکرد هست، اما میوهٔ دستچین است. دغدغههای مخلوط دیگر چطور؟
اهمیت DDD
میتوانیم طراحیهایمان را از منظر حوزهٔ مسئله هم هدایت کنیم. اگر رویکرد تکاملی و افزایشی به طراحی بگیریم، میتوانیم طوری کار کنیم که بهخوبی مراقب لحظاتی باشیم که دغدغههای جدید را شناسایی کنیم — دغدغههایی که ممکن است در غیر این صورت در طراحیهایمان بهنادرست قاطی شوند.
Listing 11.4 کد Python نشان میدهد. در آن سعی میکنم نسخهای از بازی کودکان Battleship بسازم که در آن ناوگان حریف را غرق میکنیم. به نقطهای در طراحی رسیدهام که شروع به زیر سؤال بردن آن کردهام.
python
class GameSheet:
def __init__(self):
self.sheet = {}
self.width = MAX_COLUMNS
self.height = MAX_ROWS
self.ships = {}
self._init_sheet()در GameSheet که ناحیهٔ بازی، شبکهٔ مربعها را نمایندگی میکند، به نقطهای رسیدهام که میخواهم کشتی به sheet اضافه کنم.
از Test-Driven Development (TDD) برای ساخت این کد استفاده کردم و در این نقطه مجموعهٔ رو به رشدی از آزمونها در GameSheetTest متمرکز بر پیچیدگیهای افزودن کشتی داشتم. از ۱۱ آزمون، ۶ تا روی آزمون اینکه آیا مجاز به قرار دادن کشتی روی GameSheet بودم متمرکز بودند. شروع به افزودن کد validation به GameSheet کرده بودم و حدود ۹ یا ۱۰ خط کد در سه تابع اضافی داشتم.
دربارهٔ طراحی این کد و آزمونهایی که آن را پشتیبانی میکردند ناآرام بودم. هر دو در اندازه و پیچیدگی در حال رشد بودند، نه زیاد، اما به اندازهٔ کافی که شروع به جستجوی آنچه اشتباه بود کنم. سپس فهمیدم اشتباه تفکیک دغدغه میکنم. مشکل این بود که طراحیام یک مفهوم مهم را کاملاً از دست داده بود.
GameSheet مسئول موقعیت کشتیها و قوانین بازی بود. داشتن «و» در توصیف class یا method علامت هشدار است. میگوید دو دغدغه دارم نه یکی. در این مورد بهسرعت برایم واضح شد مفهوم «قوانین» را در پیادهسازی از دست دادهام. کد و آزمونها را refactor کردم و class جدیدی به نام Rules استخراج کردم. Listing 11.5 نشان میدهد افزودن Rules چیزها را چطور ساده میکند.
python
class GameSheet:
def __init__(self, rules):
self.sheet = {}
self.width = MAX_COLUMNS
self.height = MAX_ROWS
self.rules = rules
self._init_sheet()
def add_ship(self, ship):
self.rules.assert_can_add_ship(ship)
ship.orientation.place_ship(self, ship)
self._ship_added(ship)این فوراً GameSheet را ساده کرد. نیاز به نگهداشتن مجموعهای از Ships توسط sheet را حذف کرد و نه یا ده خط منطق validation را که فقط آغاز تکامل کد متمرکز بر اعتبارسنجی تطابق با قوانین بود.
در نهایت این تغییر انعطافپذیری بیشتری در طراحی برای آینده داد، اجازه داد منطق GameSheet و Rules را مستقل از یکدیگر بهتر آزمون کنم و بهعنوان side effect احتمالی درهایی به کار با نسخههای مختلف Rules روزی باز کرد. نگران آن قوانین آیندهای نبودم. کار اضافی برای پشتیبانی از قوانین جدید خیالی انجام ندادم، اما اکنون «درزی» در کد بود که شاید در آینده مفید شود و در عین حال در دنیای واقعی و عملگرایانهٔ حال، آزمون بهتر کد و بهبود طراحی را ممکن کرد. همهٔ اینها با تمرکز ساده بر تفکیک دغدغهها هدایت شد.
استفاده از مسئلهای که حل میکنید برای تعریف خطوط مرزبندی منطقی در کد، واقعاً جوهرهٔ تفکیک دغدغههاست. این در سطوح مختلف granularity درست است. میتوانیم با bounded contextها شروع کنیم تا ماژولهای (یا serviceهای) درشتبافت را در طراحی شناسایی کنیم و سپس با یادگیری بیشتر دربارهٔ مسئله و بینش بیشتر به خوانایی یا نبود آن، طراحی را در طول زمان پالایش کنیم.
یکی از کلیدها این است که تحمل بسیار پایینی برای پیچیدگی حفظ کنیم. کد باید ساده و خوانا باشد و بهمحض اینکه شروع به سخت شدن احساس کرد، مکث کنید و به دنبال راههای سادهسازی و روشنسازی بخش جلویتان بگردید.
در مثال outlined در Listings 11.4 و 11.5، چیزی که مرا نگران طراحی کرد احتمالاً فقط به ده خط کد و چند test case برمیگشت که بعداً تصمیم گرفتم در جای اشتباهاند. این یکی از دلایلی است که تفکیک دغدغهها را اینقدر ارزش مینهیم. مکانیزمی به من میدهد تا بسیار زود در فرایند، مشکلاتی را که اگر واکنش نشان ندهم به مدولاریتهٔ کمتر و انسجام ضعیف در طراحیهایم منجر میشوند تشخیص دهم.
قابلیت آزمون
این رویکرد به تکامل افزایشی طراحی کد، با مراقبت از تفکیک دغدغهٔ ضعیف، از طریق آزمون تقویت میشود. همانطور که توصیف کردم، dependency injection میتواند طراحیها را بهبود دهد، اما ابزار قدرتمندتر — شاید بنیادیتر — برای برقراری تفکیک دغدغهٔ مؤثر، قابلیت آزمون است.
میتوانیم از قابلیت آزمون سیستمهایی که میسازیم برای هدایت کیفیت به شیوهای استفاده کنیم که چیز کمی، فراتر از استعداد و تجربه، میتواند.
اگر تلاش کنیم کدمان آسان آزمون شود، باید دغدغهها را جدا کنیم وگرنه آزمونهایمان تمرکز ندارند. آزمونهایمان هم پیچیدهتر میشوند و تکرارپذیر و قابلاتکا کردنشان سخت است. تلاش برای کنترل متغیرها تا بتوانیم آزمون کنیم ما را تشویق میکند سیستمهایی بسازیم که ویژگیهای کیفیت بالا در نرمافزار را نشان دهند: مدولاریته، انسجام، تفکیک دغدغهها، پنهانسازی اطلاعات و loose coupling.
Ports & Adapters
هدف ما در تمرکز بر تفکیک دغدغهها بهبود مدولاریته و انسجام سیستمهایمان است. این به نوبهٔ خود سیستمهایمان را بهطور کلی کمتر tightly coupled میکند. مدیریت مناسب coupling در سیستمهایمان باید یکی از تمرکزهای اصلی طراحیهایمان باشد و این در هر سطح granularity درست است.
یک سطح که شاید واضحترین و باارزشترین است، همان درزهایی در کد است که یک «دغدغه» با دیگری تعامل دارد. اینها جاهایی در سیستمهایمان هستند که همیشه باید بیشتر مراقبت کنیم.
بیایید مثال سادهای ببینیم (Listing 11.6). کدی داریم که میخواهد چیزی ذخیره کند — در این مورد در Amazon AWS S3 bucket. کدی داریم که هرچه میخواهیم ذخیره کنیم پردازش میکند و کدی که خود ذخیرهسازی را فراخوانی میکند، که شروع خوبی برای تفکیک دغدغههای پردازش و ذخیرهسازی است.
برای کار این کد، جایی setup شده تا s3client را initialize کند تا جزئیات لازم حساب مالک bucket و غیره را بداند. آن کد را عمداً نشان ندادهام؛ مطمئناً میتوانید چند راه مختلف تصور کنید که s3client به این نقطه رسیده. برخی تفکیک دغدغهٔ بهتر یا بدتر نشان میدهند. در این مورد فقط روی آنچه در این تابع داریم تمرکز کنیم.
java
void doSomething(Thing thing) {
String processedThing = process(thing);
s3client.putObject("myBucket," "keyForMyThing," processedThing);
}همانطور که هست، کد Listing 11.6 از دو منظر مختلف نوشته شده. چنین کدی را مدام میبینیم، اما لحظهای فکر کنیم. اینجا دو تمرکز بسیار متفاوت و دو سطح انتزاع بسیار متفاوت در دو خط کد است.
خط اول متمرکز بر کاری است که در دنیای تابع/متد معنا دارد؛ شاید «process(thing)» در زمینهٔ کسبوکار معنا دارد؛ مهم نیست، جز اینکه این ظاهراً تمرکز، بخش ضروری این کد است. کاری است که میخواهیم انجام شود و از آن منظر نوشته شده. خط دوم، اه، غریبه است. مزاحمی است که پیچیدگی تصادفی را به قلب منطق ما ریخته.
یک نگاه به انسجام این است که در یک دامنهٔ خاص، سطح انتزاع باید سازگار بماند. پس اگر سازگاری را اینجا بهبود دهیم چه؟ Listing 11.7 حتی اگر فقط یک class و یک متد را rename کردهایم، از این منظر بهبود بزرگی است.
java
void doSomething(Thing thing) {
String processedThing = process(thing);
store.storeThings("myBucket," "keyForMyThing," processedThing);
}حالا پیامدهایی از تغییر Listing 11.6 به 11.7 وجود دارد. با سازگارتر کردن «فراخوانی ذخیره» با سایر ایدههای این تابع، انتزاع را افزایش دادهایم. طراحی را هم به جهت دیگری هل دادهایم.
به کدی که نشان ندادم یادآوری کنید؛ با این تغییر ساده، دستهای از پیادهسازیها برای آن initialization اشتباه شدهاند. اگر ذخیرهسازی را اینطور abstract کنیم، معنا ندارد همهٔ آن initialization در دامنهٔ این class یا ماژول باشد. بهتر است کاملاً externalize شود.
پس همهٔ آن initialization را جای دیگر پنهان میکنم. یعنی میتوانم آن را بهصورت abstract، جدا از این کد آزمون کنم. یعنی اگر dependency injection را برای supply کردن store انتخاب کنم، میتوانم این کد را بدون store واقعی آزمون کنم. یعنی میتوانم خارج از این کد انتخاب کنم کجا چیزها را ذخیره کنم، انواع مختلف store را در زمینههای مختلف بدهم پس کد انعطافپذیرتر است.
میتوانید انتزاع جدید را port، یا برداری که اطلاعات از آن جریان مییابد، بدانید. تصمیم polymorphic کردن port کاملاً با شما و شرایط کدتان است، اما حتی وقتی نکنید، این کد بهتر است. بهتر است چون تفکیک دغدغه را بهبود دادهاید، با حفظ سطح انتزاع سازگارتر انسجام را بهبود دادهاید و هم خوانایی و هم نگهداشتپذیری را بهبود دادهاید.
پیادهسازی concrete این port یک adapter است که بهعنوان سرویس ترجمه عمل میکند، ایدهها را از — در این مثال — زمینهٔ «things» به زمینهٔ «AWS S3 Storage» ترجمه میکند.
بعد از این تغییر، کدمان از S3 خبر ندارد؛ حتی نمیداند S3 استفاده میشود.
ایدهٔ کلیدی این است که کد از چارچوب مرجع سازگارتر نوشته شده. آن انتزاع سازگارتر را حفظ میکند.
آنچه اینجا توصیف کردم گاهی الگوی Ports & Adapters نامیده میشود، گاهی وقتی در سطح service یا زیرسیستم اعمال شود hexagonal architecture.
ارزش این در طراحی بسیار قابلتوجه است. تقریباً هرگز نیست که کدتان به هر جزئیات APIای که مصرف میکند اهمیت دهد. تقریباً همیشه با زیرمجموعهای از چنین APIهایی سر و کار دارید. portی که میسازید فقط باید حداقل زیرمجموعهای که انتخاب کردهاید استفاده کنید را expose کند، پس تقریباً همیشه نسخهٔ سادهتری از APIای است که با آن تعامل دارید.
مشکل نوشتن کتابی دربارهٔ کد این است که برای انتقال ایدهها، مثالهای کد باید کوچک و ساده باشند وگرنه ایدهها در پیچیدگی کد گم میشوند. اما وقتی میخواهید بهبود سادگی را نشان دهید چه؟
پس با من همراه باشید. تصور کنید کل سیستمی طبق Listing 11.6 نوشته شده: دهها، صدها، شاید هزاران تعامل از طریق s3client. سپس Amazon interface سرویس S3 را — یا حداقل کتابخانهٔ client Java — ارتقا میدهد. نسخه ۲ مدل برنامهنویسی متفاوتی دارد، پس باید دهها، صدها یا هزاران خط کد را تغییر دهیم تا از client library جدید بهره ببریم.
اگر abstraction خودمان، Port & Adapter خودمان برای S3 را ساختهایم که فقط — و فقط — آنچه کدمان نیاز دارد انجام میدهد، احتمالاً میتوانیم در بیش از یک جای کد استفاده کنیم. شاید همهجا، شاید موارد پیچیدهتر جداگانه Port & Adapter دیگری داشته باشیم. در هر صورت، تلاش نگهداری را بهطور قابلتوجهی کاهش دادهایم. میتوانستیم adapter را کاملاً برای client library جدید بازنویسی کنیم. روی کدی که از آن استفاده میکند اثری نمیگذاشت.
این رویکرد بسیاری از اهداف طراحی خوب را تجسم میکند. با کار برای مدیریت پیچیدگی، کدمان را در برابر تغییر — حتی تغییر غیرمنتظره یا غیرقابلپیشبینی — هم عایق میکنیم.
چه زمانی Ports & Adapters را اتخاذ کنیم
وقتی مردم دربارهٔ رویکرد Ports & Adapters صحبت میکنند، معمولاً در زمینهٔ لایهٔ ترجمه در مرزهای بین serviceها (یا ماژولها) صحبت میکنند.
این توصیهٔ خوبی است. در کتاب Domain Driven Design، Eric Evans توصیه میکند:[^2]
همیشه اطلاعاتی که بین Bounded Contextها عبور میکند ترجمه کنید.
در طراحی سیستم از serviceها، من و دیگران توصیه میکنیم serviceها را با bounded context همتراز کنیم. این coupling را کمینه و مدولاریته و انسجام serviceهایمان را بهبود میدهد.
ترکیب این دو توصیه، راهنمای سادهای پیشنهاد میکند: «همیشه اطلاعاتی که بین serviceها جریان مییابد ترجمه کنید»، یا به عبارت دیگر «همیشه بین serviceها با Ports & Adapters ارتباط برقرار کنید».
وقتی جملهٔ قبلی را مینوشتم، اول «قاعده» نوشتم نه «راهنما» و سریع اصلاح کردم. نمیتوانم با وجدان این را قاعده بنامم، چون گاهی corner caseهایی قاعده را میشکنند. با این حال توصیهٔ قوی من این است که بهعنوان موضع پیشفرض فرض کنید همهٔ تبادلات اطلاعات بین serviceها — هرچه ماهیت فنی API باشد — از طریق adapter ترجمه میشوند.
این به معنای adapter زیاد یا پیچیده نیست، اما از منظر طراحی هر service یا ماژول باید دیدگاه خودش از دنیا را داشته باشد و آن دیدگاه را دفاع کند. اگر اطلاعاتی ارسال شود که آن دیدگاه را بشکند، برای کد مشکل جدی است.
میتوانیم کدمان را دو روش محافظت کنیم: adapterای که چیزها را هنگام رسیدن به لبههای سیستم به دیدگاه ما ترجمه میکند و ورودیها را تا حدی که اهمیت میدهیم validate میکند؛ یا چیزهایی که به آنها اعتماد نداریم wrap کنیم و نادیده بگیریم تا سیستمهایمان را از تغییرات مشکوک خارجی محافظت کنیم.
اگر سیستم messaging از نوعی مینویسیم، چیزهایی هست که باید بدانیم و چیزهایی که قطعاً نباید بدانیم.
احتمالاً باید بدانیم چه کسی پیام فرستاده و کجا میرود. احتمالاً باید بدانیم پیام چقدر بزرگ است و شاید اگر مشکلی بود retry کنیم یا نه. قطعاً نباید بدانیم پیام چه میگوید! آن فوراً technicalities messaging را به semantics مکالمهای که messaging برایش استفاده میشود coupled میکند و طراحی بسیار ضعیفی است.
شاید واضح به نظر برسد، اما کد زیادی میبینم که دقیقاً این اشتباه را میکند. اگر سیستم messaging میساختم، محتوای پیام را در packetی از نوعی wrap میکردم که سیستم messaging را از محتوای packetها — خود پیامها — عایق کند.
API چیست؟
این کمی به فلسفهٔ طراحی میرود: API چیست؟ برای تعریف عملی استدلال میکنم:
application programming interface (API) همهٔ اطلاعاتی است که به مصرفکنندگان service یا libraryای که آن API را expose میکند نمایان است.
این با آنچه برخی توسعهدهندگان وقتی از اصطلاح API استفاده میکنیم فکر میکنند متفاوت است.
با گذشت زمان معنای اصطلاح «API» تغییر کرده. بخشی احتمالاً بهخاطر موفقیت رویکرد REST در ساخت serviceهاست. رایج است — حداقل در گفتگوهای غیررسمی با توسعهدهندگان — اصطلاح «API» بهعنوان مترادف «Text over HTTP» استفاده شود. این قطعاً یک شکل API است، اما فقط یکی؛ شکلهای بسیار بیشتری هست.
بهطور دقیق هر وسیلهٔ ارتباط بین بخشهای مختلف کد، برای پشتیبانی از نوعی برنامهنویسی، API است. اینجاست که فکر کردن به اطلاعاتی که کدمان با آن تعامل دارد مهم است.
لحظهای تصور کنید تابعی آرگومان جریان دودویی داده میگیرد. API چیست؟
آیا فقط signature تابع است؟ شاید، اگر تابع جریان دودویی را بهعنوان black-box میبیند و هرگز داخل stream نگاه نکند، بله، signature تابع coupling آن با callerها را تعریف میکند.
اما اگر تابع به هر شکلی با محتوای جریان دودویی تعامل کند، آن بخشی از قراردادش است. سطح تعامل درجهٔ coupling با اطلاعات stream را تعریف میکند.
اگر هشت بایت اول stream برای encode کردن طول استفاده شوند و این تنها چیزی است که تابع از stream میداند یا اهمیت میدهد، signature تابع بهعلاوهٔ معنای هشت بایت اول و نحوهٔ encode طول در آنها «API» است.
هرچه تابع از محتوای جریان بایت بیشتر بداند، بیشتر به آن coupled است و surface area API بزرگتر است. تیمهای زیادی میبینم که واقعیت این را نادیده میگیرند که ساختارهای داده در ورودیهایی که کدشان میفهمد و پردازش میکند بخشی از public API آن کد است.
adapterهایمان باید با کل API سر و کار داشته باشند. اگر یعنی ترجمه یا حداقل validate محتوای جریان دودویی ورودی، پس همینطور. جایگزین این است که کدمان وقتی کسی stream اشتباه بفرستد بشکند. این متغیری است که میتوانیم کنترل کنیم.
طراحی با فرض اینکه همیشه Ports & Adapters در این نقاط ارتباط بین ماژولها و serviceها اضافه میکنیم، موضع پیشفرض بسیار قویتری از نبود آن است. حتی اگر adapter فقط placeholder آینده باشد، داشتن آن placeholder فرصتی میدهد اگر ماهیت API به هر شکلی تغییر کرد بدون بازنویسی همهٔ کدمان با آن کنار بیاییم.
این مدل کلاسیک Ports & Adapters است. توصیه میکنم در سطوح fine-grainedتر هم فکر کنید. منظورم این نیست همیشه ترجمههای صریح بنویسید، اما ایدهٔ حفظ سطح انتزاع سازگار در هر قطعه کد، هرچقدر کوچک (Listing 11.6 را ببینید)، خوبی است.
بهعنوان موضع پیشفرض یا راهنما توصیه میکنم همیشه Ports & Adapters جایی اضافه کنید که کدی که با آن صحبت میکنید در دامنهٔ ارزیابی متفاوتی است، مثل repo یا deployment pipeline متفاوت. موضع دفاعیتر در این موقعیتها کد را دوباره قابلآزمونتر و در برابر تغییر مقاومتر میکند.
استفاده از TDD برای هدایت تفکیک دغدغهها
قبلاً توصیف کردم چطور ایدههای طراحی برای بهبود قابلیت آزمون کدمان کیفیت را بهبود میدهند — نه فقط به معنای سادهٔ «کار میکند یا نه»، بلکه به معنای عمیقتر ساختن نوعی کیفیت در محصولاتمان که آنها را قادر به نگهداشت و توسعهٔ مداوم میکند.
اگر کدمان را با ایدههای تفکیک دغدغهها بهعنوان اصل راهنما طراحی کنیم، از جمله حفظ سطح انتزاع سازگار در هر زمینهٔ دادهشده، حتی کوچک، درهای تغییر افزایشی را باز میگذاریم. حتی اگر هنوز جزئیات نحوهٔ ارتباط، ذخیره یا تعامل بهطور کلی را نمیدانیم، میتوانیم کد بنویسیم و پیشرفت کنیم. بعداً با یادگیری بیشتر میتوانیم کدی که نوشتیم را به شیوههایی که هنگام نوشتنش فکر نکرده بودیم استفاده کنیم. این رویکرد اجازه میدهد رویکرد تکاملیتری به طراحی بگیریم و سیستمها را گامبهگام، با تعمیق درکمان، به نسخههای بسیار پیچیدهتر و توانمندتر در آینده رشد دهیم.
TDD قدرتمندترین ابزاری است که برای دستیابی به آن قابلیت آزمون میتوانیم به کار ببریم. با هدایت همهٔ توسعه از منظر آزمون، تمرکز طراحیهایمان را بهشدت تغییر میدهیم.
بهطور خاص در زمینهٔ تفکیک دغدغهها، آزمونهایمان نوشتن سختتر میشود هرچه دغدغهها در دامنهٔ یک آزمون بیشتر قاطی شوند. اگر توسعه را حول آزمون سازماندهیم و توسعه را از طریق آزمون هدایت کنیم، خیلی زودتر در فرایند با هزینهها و مزایای تصمیمات طراحیمان روبهرو میشویم.
این بازخورد سریعتر طبیعتاً خوب است و فرصتی میدهد نقص طراحی را خیلی زودتر از هر تکنیک دیگر — جز اینکه فقط باهوشتر از آنچه هستیم باشیم، که کنترل محدودی روی آن داریم — ببینیم. باهوش بودن اشکالی ندارد، اما بهترین راه «باهوشتر شدن» کار به شیوههای باهوشتر است که واقعاً هدف این کتاب است. TDD یکی از آن «شیوههای باهوشتر» مهم است.
خلاصه
تفکیک دغدغهها قطعاً ویژگی کد باکیفیت است. اگر دو قطعه کد دقیقاً همان کار را انجام دهند و یکی تفکیک دغدغهٔ خوب و دیگری نه، اولی قابلفهمتر، قابلآزمونتر، قابلتغییرتر و انعطافپذیرتر است.
تفکیک دغدغهها همچنین آسانترین heuristic طراحی برای اتخاذ در این مجموعه است.
میتوانیم دربارهٔ مدولاریته یا انسجام برخی کد یا سیستم بحث کنیم. همانطور که میبینید، این ایدهها را بسیار مهم میدانم، اما در نهایت اندازهگیریشان تا حدی ذهنی است. احتمالاً روی مثالهای بد توافق میکنیم، اما در حد نهایی تعریف مدولاریته یا انسجام ایدهآل احتمالاً مشکل داریم.
تفکیک دغدغهها متفاوت است. اگر ماژول، class یا تابع شما بیش از یک کار انجام دهد، دغدغههایتان واقعاً جدا نیستند. نتیجه این است که تفکیک دغدغهها ابزار فوقالعادهای است که ما را بهطور قطعی به سمت طراحی نرمافزار بهتر هدایت میکند.
[^1]: منبع: Wikipedia، https://en.wikipedia.org/wiki/Separation_of_concerns
[^2]: Domain Driven Design کتابی از Eric Evans است که نحوهٔ مدلسازی حوزهٔ مسئله در نرمافزار را بهعنوان اصل راهنمای طراحی توصیف میکند. ببینید https://amzn.to/2WXJ94m.