مفاهیم مربوط به طراحی خوب یا بد بسیار نسبی هستند. با رعایت برخی استانداردها در برنامهنویسی، میتوان یک نرمافزار کارا، قابل نگهداری و قابل تست تولید کرد. برای مثال، در برنامهنویسی شیگرا با رعایت کردن مفاهیم پایهای (ارث بری، کپسوله کردن و چند ریختی)، الگوهای مختلف طراحی و اصول SOLID میتوان یک طراحی خوب از کد ارائه داد. البته توجه به این نکته ضروری است که در برخی موارد ممکن است استفادهی بیش از حد از این مفاهیم یا بیتوجهی به آنها، منجر به طراحیهای مشکلدار شود.
در این مقاله به بیان نشانههای یک طراحی بد خواهیم پرداخت و موارد زیر را مورد بررسی قرار میدهیم:
- سختی (Rigidity): تغییر کد حتی برای یک تغییر بسیار ساده، دشوار است.
- شکنندگی (Fragility): تغییرات بسیار کوچک منجر به تولید باگهای زیاد در قسمتهای دیگری از کد شود.
- بیتحرکی (Immobility): جدا کردن کد به ماژولهای مستقل با قابلیت استفاده مجدد بسیار سخت باشد.
- چسبناکی (Viscosity): تغییر کد با حفظ ساختار فعلی آن بسیار سخت باشد.
- پیچیدگی اضافی (Needless Complexity): کد بخشهایی دارد که استفاده نمیشوند یا برای استفاده در آینده نوشته شده است.
- خوانایی کم (Poor Readability): استفاده از کدهای نامتعارف از لحاظ طراحی ظاهری یا کسبوکاری.
وقتی که چندین ماژول در یک پروژه به همدیگر وابسته باشند و تغییر در یک بخش از ماژول، منجر به انتشار تغییرات در ماژولهای دیگر شود، در این صورت کد دچار Rigidity شده است. در صورتیکه کد دچار این وضعیت شود، حتی زمان انجام کوچکترین تغییر نیز به سختی قابل تخمین است و کد تبدیل به کدِ مرده میشود.
برای مثال، در صورتیکه ارتباط بین دو کلاس در لایهی کلاسهای سطح پایین (Concrete class) باشد، در اینصورت هر تغییر در کلاس سطح پایین منجر به انتشار تغییرات در کلاسهای وابسته میشود. برای اینکار وابستگیها بایستی در لایهی کلاسهای انتراعی (Abstract class) انجام شود تا تغییر پیادهسازی، تاثیری در ساختار وابستگیها ایجاد نکند.
در زیر مثالی از یک وابستگی کلاسهای سطح پایین دیده میشود. کلاس LoggerClient وابستگی به کلاس سطح پایین FileLogger دارد. در صورتیکه بخواهیم تعیین نوع Logger را بر عهدهی کاربر بگذاریم، در این صورت کد نوشته شده به راحتی قابل استفاده نیست و بایستی تغییراتی در ساختار LoggerClient نیز ایجاد شود.
interface Logger { void log(String message, Level level); } class FileLogger implements Logger { public void log(String message, Level level) { // write to file using FileWriter / Formatter / ... } } class ConsoleLogger implements Logger { public void log(String message, Level level) { // write to console using System.out.println(...) } } class LoggerClient { private FileLogger logger; public void doWork() { logger.log("Start", Level.INFO); try { // Something may produce exception. logger.log("Try started", Level.INFO); } catch (Throwable throwable) { logger.log("Exception started", Level.SEVERE); } logger.log("Finished", Level.INFO); } }
از این رو، وابستگی LoggerClient به FileLogger را به سطح انتزاع میبریم. در این صورت کد به شکل زیر در میآید. با این کار هر تغییری در سطوح پایین هیچ تاثیری بر استفاده کنندهها نخواهد داشت.
class LoggerClient { private String loggerType; private Logger logger; public void init() { if (loggerType.equals("file")) { this.logger = new FileLogger(); } else if (loggerType.equals("console")) { this.logger = new ConsoleLogger(); } else { throw new RuntimeException("Not implemented exception"); } } public void doWork() { logger.log("Start", Level.INFO); try { // Something may produce exception. logger.log("Try started", Level.INFO); } catch (Throwable throwable) { logger.log("Exception started", Level.SEVERE); } logger.log("Finished", Level.INFO); } }
با این تغییر، کلاس LoggerClient وابسته به کلاس انتزاعی Logger شده است و اضافه کردن Logger جدید مانند DbLogger برای نوشتن لاگ در دیتابیس، تاثیری در ساختار LoggerClient نخواهد داشت و به راحتی میتوان پیادهساز Logger را برای کلاس LoggerClient تغییر داد.
پس راه حلی برای فرار از Ridigityبردن وابستگیها به سطح انتزاع میباشد.
این نشانه بسیار شبیه به Rigidity است. شکنندگی وقتی اتفاق میافتد که تغییر در یک بخش از نرمافزار منجر به ایجاد خرابی در بخشهای دیگر نرمافزار شود و این خرابیها از دید توسعهدهنده دور بمانند. اغلب این خرابیها نیز در بخشهایی اتفاق میافتند که به صورت مفهومی به تغییر انجام شده مرتبط نیست.
به صورت کلی، دو نوع Fragility وجود دارد. اولی در زمان کامپایل (back-side crash) اتفاق میافتد و در زمان تولید قابل شناسایی و رفع است و دومی در زمان اجرا(client-side crash). نتیجهی خرابی در زمان اجرا، ترس کارفرما و مدیران نسبت به تمامی تغییرات (حتی کوچک) و بیاعتمادی نسبت به تیم توسعه است. زمانیکه یک پروژه دچار شکنندگی شود، هر چه زمان بگذرد، این شکنندگی بیشتر نمود پیدا میکند و نگهداری از آن ناممکن میشود. زیرا هر تغییر برای بهبود(!)، منجر به ایجاد مشکلات جدید در بخشهای دیگری از کد میشود.
برای مثال، اگر میزان استفاده از کلاسهای Helper و توابع ایستا در یک نرمافزار زیاد باشد و عملیات حیاتی سیستم نیز به این توابع سپرده شود، در این صورت نرمافزار دچار Coupling زیاد و Cohesionکم شده است. در قطعه کد زیر، تابع authorize از کلاس Helper، به بررسی دسترسی یک نقش به منبع درخواستی آن میپردازد.
static class Helper { public static boolean authorize(Long roleId, String resource) { if (resource.equals("admin") && (roleId == 1L || roleId == 2L)) { return true; } if (resource.equals("api") && roleId == 3L) { return true; } return false; } }
در صورتیکه منابع جدید و به موازات آن، نقشهای جدید به نرمافزار اضافه شود، تابع authorizeبایستی خود را با تغییرات جدید وفق دهد. این مثال، نمونهای از شکنندگی کد است. برای کم کردن شکنندگی در این بخش از کد، بایستی کلاس جدیدی به نام Resource به نرمافزار اضافه شود که وظیفهی نگهداری اطلاعات مربوط به منبع و دسترسیهای مجاز برای آن را داشته باشد.
class Resource { private List<Long> roles = new ArrayList<Long>(); public boolean isAuthorized(Long roleId) { return roles.contains(roleId); } public void addRole(Long roleId) { roles.add(roleId); } public void removeRole(Long roleId) { roles.remove(roleId); } }
برای اضافه کردن دسترسی جدید به یک منبع، کافیست که تابع addRole برای آن منبع فراخوانی شود.
افزایش Cohesion ماژولها و انتقال مفاهیم مرتبط (نقش و منبع) به ساختار جدید (Resource)، منجر به کاهش شکنندگی میشود.
شاید تا به حال برایتان پیش آمده باشد که به عنوان یک مهندس نرمافزار، نیاز به استفاده از آنچه که قبلا در یک پروژهی دیگر از سازمان خود پیادهسازی کردهاید را داشته باشید. در این صورت تلاش میکنید بخشی که نیاز به استفادهی مجدد دارد، از دل پروژهی قبلی بیرون بکشید. ولی هزینهی بیرون کشیدن این بخش به شدت سرسامآور و ریسکی است. از این رو ترجیح میدهید که آن بخش را مجدد نوشته و از کد قبلی استفاده نکنید. در این صورت زمانیکه کسب و کار شما در آن بخش تکراری تغییری داشته باشد، مجبور خواهید بود تا در هر دو جا (قبلی و جدید) تغییرات را اعمال کنید.
یکی از راههای جلوگیری از بیتحرک شدن یک نرمافزار، جلوگیری از طراحی یک تکه (Monolithic) و تفکر ماژولار است. اینکه سیستم به ماژولهای مستقلی تقسیم شود و انتظار برود که این ماژولها در سیستمهای دیگر و به همراه ماژولهای دیگر مورد استفاده قرار میگیرند. برای نمونه، میتوان به کدی اشاره کرد که تمام منطق کسبوکارهای مستقل در هم تنیده و در کنار هم باشند و هیچ تفکیک و مرزبندی بین آنها وجود نداشته باشد. در این صورت کد دچار Immobility شده است. در مقابل، نرمافزاری که از معماری MVC برای جداسازی کسبوکار و داده استفاده میکند، احتمال دچار شدن کدِ آن به بیتحرکی، بسیار کمتر است.
البته همواره به یاد داشته باشید که برای شروع معماری یک سیستم، همیشه شروع معماری خود را از درشتدانهترین معماری شروع کنید و رفته رفته و بسته به محیطی که در آن فعالیت میکنید، به سمت معماریهای ماژولار، سرویس محور و Microservice بروید.
چسبناکی در دو حالت اتفاق میافتد. اولی چسبناکی در طراحی و دومی چسبناکی در محیط.
زمانیکه در یک پروژه، دچار یک چالش میشوید، ممکن است چند راه حل برای حل چالش داشته باشید که برخی از راهحلها طراحی فعلی پروژه را حفظ میکنند و برخی دیگر اهداف طراحی پروژه را در هم میشکنند. زمانیکه استفاده از روشهای حفظ کنندهی طراحی پروژه بیشاز روشهای دیگر هزینهبر باشند، میتوان گفت که چسبناکی طراحی بالا است. از این رو، انجام کارهای درست هزینهی بیشتری از انجام کارهای غلط در طی توسعهی پروژه دارد.
همانطور که میدانید یکی از مشکلاتی که در پروژههای بزرگ ایجاد میشود، ایجاد کلاسهای بزرگی است که چند مسئولیتی هستند و در اصطلاح به آنها God Class میگویند. کلاسهای چند مسئولیتی از جمله طراحیهایی هستند که در آنها Viscosityبالا دیده میشود. برای فائق آمدن بر این طراحی بد که منجر به چسبناکی در محیط شده است، کافیست از روشهایی همچون تقسیم و حل استفاده شود تا چندین کلاس تک مسئولیتی ایجاد شود.
برای مثال، در برخی از پروژهها دیده میشود که کلاسی نگهدارندهی تمامی مقادیر ثابت و تنظیماتی است که در ماژولهای مستقل استفاده میشوند. به همین دلیل، در طی توسعه و به دلایلی بی ربط این کلاس مورد تغییر - و حتی تغییر همزمان - قرار میگیرد. این مشکل منجر به کاهش سرعت توسعه میشود و این به دلیل Viscosity بالای طراحی است. جدا کردن مقادیر ثابت و تنظیمات هر ماژول، میتواند باعث کاهش چسبناکی طراحی شود.
مواقعی پیش میآید که شما در زمان طراحی و پیادهسازی یک نرمافزار، با دید همه جانبه و وسواس زیاد، Feature هایی را طراحی و پیادهسازی میکنید که قرار نیست در نرمافزار شما استفاده شود. بودن Feature های بدون کاربرد و اضافی در نرمافزار شما، یعنی وجود پیچیدگی اضافی که منجر به افزایش زمان توسعه، تست و نگهداری از نرمافزار میشود.
کدهای اضافه و بدون کاربرد، توابع مبهم و چند کاره در interface ها (تابعی مانند Process که میتواند پیادهسازیهای مختلف داشته باشد) و استفاده زیاد از Design Pattern ها موجب ایجاد پیچیدگی اضافی در کد و همینطور منجر به افزایش Viscosity در طراحی میشود.
این نشانه زمانی مشهود است که خواندن کد و فهم آن سخت باشد. از دلایل ظهور این نشانه، عدم انطباق کد با نیازمندیهای اجرایی (Syntax، Variable ها و Class ها) و منطق پیادهسازی شده است. برای مثال، فرض کنید که میخواهیم بر اساس یک ورودی منطقی (true / false) کسب و کار مشخصی را انجام دهیم. پیادهسازی به شکل زیر به شدت سخت فهم است.
void Process_true_false(String input) throws Exception { if (input.length() == 4) { // true.length = 4 //... } else if (input.length() == 5) { // false.length = 5 //... } else { throw new Exception("Neither true nor false, not a nice input. return."); } }
این کد چندین مشکل دارد:
1. نامگذاری تابع منافی Code Convention های عمومی است که در برنامهنویسی جاوا مورد استفاده قرار میگیرد.
2. پیادهسازی متد، بهترین و معمولترین راه نیست.
3. کد حالت استثناء به خوبی نوشته نشده است. اول اینکه نوع استثناء خیلی کلی است (Exception) و پیام نوشته شده نیز به فرمت رسمی نوشته نشده است.
از این رو، میتوان کد را به شکل زیر بازنویسی کرد.
void doLogic(boolean input) { if (input) { //... } else { //... } }
در صورتیکه وجود کدهای ناخوانا یا کم خوانا منجر به تولید باگ در سیستم میشود، میتوان به Refactor کد پرداخت.
یکی از بزرگترین مشکلات کدهای ضعیف، High coupling میباشد. اطمینان از پیادهسازی منطبق بر اصول SOLIDمیتواند شروع خوبی برای حل مشکلات کدها باشد. زمانی این مشکلات خود را بیشتر نشان میدهند که حجم زیادی از کد را کدهای ضعیف تشکیل دهند. برای حل این معضل، چند گزینه وجود دارد.
گزینهی اول دوباره نویسی (Rewrite)، گزینهی دوم بازآرایی (Refactor) و گزینهی سوم دست نزدن به کد فعلی است (که البته در صورتی که نیاز به تغییر کد باشد، این روش کاربردی نیست). با توجه به فرمایشات Martin Fowler، در صورتیکه انجام دادن کاری، شما را اذیت میکند، بیشتر انجامش دهید.[1] از این رو، در صورتیکه اصلاح کد زحمت زیادی دارد، در زمانهای نزدیکتری آنرا انجام دهید.
با توجه به نمودار بالا، در صورتیکه فاصلهی بین دو کار پر زحمت زیاد باشد، در این صورت زحمت بیشتری به شما تحمیل خواهد شد ولی اگر این کار را در فواصل زمانی کمتری انجام دهید، در این صورت میزان زحمت به شدت کاهش مییابد. از این رو اکثر تیمهای نرمافزاری میل به Continuous Integration حتی به صورت روزانه دارند.
[1] If it hurts, do it more often.
Fowler, Martin. 2011. Frequency Reduces Difficulty. 07 28. Accessed November 8, 2020. https://martinfowler.com/bliki/FrequencyReducesDifficulty.html.
Martin, Robert C. 2000. Design Principles and Design Patterns. Accessed October 28, 2020. http://www.cvc.uab.es/shared/teach/a21291/temes/object_oriented_design/materials_adicionals/principles_and_patterns.pdf.
Pearman, Brandon. 2019. Bad Design: Symptoms of bad code. Accessed October 28, 2020. http://www.devobsession.com/Post/design/symptoms-of-bad-code.
Zolotaryov, Sergey. 2017. Indicators of Problem Design. Accessed October 28, 2020. https://codingsight.com/indicators-of-problem-design/.