معرفی اصول solid با مثال هایی از کد C# .net core

معرفی اصول solid با مثال هایی از کد C# .net core

مقدمه

کلمه SOLID از ۵ حرف تشکیل شده که هر حرف بیان کننده یکی از اصول آن هست:

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

نیاز به اصول طراحی

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

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

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

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

مقدمه ای به اصول solid

اصول solid اصول طراحی خیلی رایج در دنیای برنامه نویسی شی گرا هستند و برنامه نویس ها تلاش می کنند تا از این اصول در کد خود با هدف افزایش انعطاف پذیری و نگهدار پذیری کد خود استفاده کنند (یعنی در نهایت نرم افزار بهتری بنویسیند). این اصول زیر مجموعه ای از مجموعه اصول تعریف شده توسط مهندس و مدرس نرم افزار آمریکایی رابرت مارتین است.

با وجود اینکه این اصول برای چندین سال قبل است اما هنوز هم برای توسعه نرم افزار قوی، قابل تکیه و منعطف بسیار مهم هستند.

در این نوشتار قصد دارم هر اصل رو با جزییات توضیح بدم تا فهم بهتری از اون پیدا کنید.

Single Responsibility Principle

تعریف

هر ماژول نرم افزاری یا کلاس باید فقط و فقط یک دلیل برای تغییر داشته باشد.

شرح

وقتی که ما شروع به کد نویسی می کنیم کلاس هایی را اضافه می کنیم تا بتوانیم وظیفه ای که به دست گرفتیم را کامل کنیم. این اصل به ما می گوید که در زمان ساخت کلاس ها مطمئن شوید که یک کلاس تنها یک مسئولیت دارد یعنی فقط یک وظیفه انجام می دهد. یک کلاس نباید چند کار انجام دهد یعنی نباید بیشتر از یک مسئولیت داشته باشد.

اگر بیش از یک مسئولیت یا وظیفه به یک کلاس اضافه کنید با توابع در هم تنیده ای مواجه می شویم که باعث می شوند کد قابلیت نگهداری کمتری داشته باشد و در زمان اصلاح یک تابع خاص در کلاس با پیچیدگی های زیادی روبرو باشیم.

البته این به این معنی نیست که کلاسی که می نویسیم باید تعداد خط کد کمی داشته باشد، ما میتونیم کلاسی داشته باشیم که اعضا و متدهای خیلی زیادی داشته باشد اما همه آنها مرتبط با یک مسئولیت مشخص باشند. البته اکثر مواقع تک مسئولیتی بودن منجر به داشتن کلاس های کوچک میشن.

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

استفاده

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

public class OrderService
{
    public string CreateOrder(string OrderDetails) 
    {
        string OrderId = "";
        //Code to Create Order
        return OrderId; 
    }
        
    public bool MakePayment(string OrderId) 
    { 
        //Code to Make Payment
        return true; 
    }
        
    public bool GenerateInvoice(string OrderId) 
    { 
        //Code to Generate Invoice
        return true; 
    }
        
    public bool EmailInvoice(string OrderId) 
    { 
        //Code to Email Invoice
        return true; 
    }
}


این کلاس از نظر عملیاتی هیچ اشکالی نداره، یعنی اگر به درستی پیاده سازی بشه همه وظایفه رو به درستی انجام میده. اما این کلاس از اصل SRP پیروی نمی کنه چون بیش از یک مسئولیت به این کلاس دادیم. این کلاس توابع ایجاد سفارش، پرداخت سفارش، تولید فاکتور و ارسال ایمیل به مشتری رو انجام میده. واضحه که بیش از یک مسئولیت به کلاس دادیم.
با این روش نگهداری از کلاس سخت میشه، وقتی نیاز به تغییر عملکرد یک بخش میشه این تغییر ممکنه روی عملکرد سایر بخش ها هم تاثیر گذار باشه. بعلاوه فقط تغییر در عملکرد ایمیل نیازمند تست همه توابع در کلاس است (ایجاد سفارش، پرداخت و ارسال فاکتور) چون اینها در هم تنیده و بدون جدایی نگرانی (Sepraation of Concern) هستند.
حالا بیاید کد داخل کلاس را به صورت زیر refactor کنیم.
public class OrderService
{
    public string CreateOrder(string OrderDetails)
    {
        string OrderId = "";
        //Code to Create Order
        return OrderId;
    }
}

public class PaymentService
{
    public bool MakePayment(string PaymentDetails)
    {
        //Code to Make Payment
        return true;
    }
}

public class InvoiceService
{
    public bool GenerateInvoice(string InvoiceDetails)
    {
        //Code to Generate Invoice
        return true;
    }
}

public class EmailService
{
    public bool EmailInvoice(string EmailDetails)
    {
        //Code to Email Invoice
        return true;
    }
}
حالا به جای یک کلاس ما ۴ کلاس اضافه کردیم، یعنی برای هر مسئولیت یک کلاس و کد را بین کلاس ها تقسیم کردیم تا آنها پیاده سازی کنند. حالا ما ۴ کلاس برای پیاده سازی  عملکردها داریم، یعنی ایجاد سفارش، پرداخت سفارش، تولید فاکتور و ارسال ایمیل فاکتور.
تغییرات بالا بر اساس اصل SRP در اصول Solid هستند. این تغییر پیاده سازی ، فهم ، ویرایش و تست کد را ساده تر می کند.

مزایا

  • طراحی و پیاده سازی کلاس های تک مسئولیتی ساده تر است
  • با محدود کردن عملکرد یک کلاس باعث ترویج SoC می شود
  • با توجه به اینکه برای هر عملکرد یک کلاس داریم که شرح و فهم آن ساده تر است خوانایی را بهبود می دهد
  • از آنجایی که تغییر در عملکرد تاثیری روی عملکرد سایر بخش ها ندارد نگهداری کد بهتر می شود
  • با توجه به اینکه عملکرد واحد در کلاس پیچیدگی را در زمان نوشتن test case ها برای کلاس کاهش می دهد تست پذیری بهبود می یابد
  • ایزوله کردن عملکردها در کلاس های مجزا کمک می کند تا  تغییرات را به همان کلاس محدود شوند و با رسیدن خواسته های جدید و ویرایش کد تعداد باگ ها کاهش پیدا کنند.
  • دیباگ کردن خطاها ساده تر است یعنی اگر در عملکرد ارسال ایمیل خطایی داشته باشیم می دانید که باید به سراغ کدام کلاس برید.
  • بعلاوه می توان از کد نوشته شده در جاهای دیگر هم استفاده مجدد کرد یعنی اگر شما کلاس عمکرد ایمیل را توسعه داده باشید از همین کلاس برای بخش ثبت نام، ارسال ایمیل OTP، فراموشی پسورد و غیره هم استفاده کنید

مثالی از دنیای واقعی

شما می توانید پیاده سازی SRP را در کتابخانه های فریم ورک .net ببنیید. به طوری که عملکردها به صورت فضای نامی و کلاس تفکیک شده اند. در اینجا کلاس های مجزا برای عملکردهای متفاوت در کتابخانه های .net core وجود دارد.

برای یک مثال واقعی بیاید نگاهی به پیاده سازی یک web api action برای ارسال ایمیل بندازیم.

[ApiController]
[Route("[controller]")]
public class EmailController : ControllerBase
{
    IEmailService _emailService = null;
    public EmailController(IEmailService emailService)
    {
        _emailService = emailService;
    }

    [HttpPost]
    public bool SendEmail(EmailData emailData)
    {
        return _emailService.SendEmail(emailData);
    }
}
-------
public class EmailService : IEmailService
{
    EmailSettings _emailSettings = null;
    public EmailService(IOptions<EmailSettings> options)
    {
        _emailSettings = options.Value;
    }

    public bool SendEmail(EmailData emailData)
    {
        try
        {
            MimeMessage emailMessage = new MimeMessage();

            MailboxAddress emailFrom = new MailboxAddress(_emailSettings.Name, _emailSettings.EmailId);
            emailMessage.From.Add(emailFrom);

            MailboxAddress emailTo = new MailboxAddress(emailData.EmailToName, emailData.EmailToId);
            emailMessage.To.Add(emailTo);

            emailMessage.Subject = emailData.EmailSubject;

            BodyBuilder emailBodyBuilder = new BodyBuilder();
            emailBodyBuilder.TextBody = emailData.EmailBody;
            emailMessage.Body = emailBodyBuilder.ToMessageBody();

            SmtpClient emailClient = new SmtpClient();
            emailClient.Connect(_emailSettings.Host, _emailSettings.Port, _emailSettings.UseSSL);
            emailClient.Authenticate(_emailSettings.EmailId, _emailSettings.Password);
            emailClient.Send(emailMessage);
            emailClient.Disconnect(true);
            emailClient.Dispose();

            return true;
        }
        catch(Exception ex)
        {
            //Log Exception Details
            return false;
        }
    }
}


در اینجا می توان دید که منطق ارسال ایمیل چگونه به کلاسی به نام EmailService اضافه شده و این کلاس در نهایت در کلا س EmailController برای ارسال ایمیل استفاده شده است.

ما می توانستیم این کد را به خود کنترلر هم اضافه کنیم که در این صورت برای کلاس دو عملکرد داشتیم یعنی هندل کردن منطق routing و منطق ارسال ایمیل. هر تغییری در عملکرد ایمیل منجر به تغییر کنترلر نیز می شد. بدون یک کلاس مجزا اگر شما خواسته ای جدید به نام تاییدیه ی ارسال ایمیل داشتید نیاز بود تا کد را به خود کنترلر اضافه کنید. این می تواند روی کنترلر، منطق ایمیل و منطق تاییدیه که جدیدا نوشتیم اثر بگذارد.

اما با استفاده از اصل SRP می توان تاثیر یک کلاس جدید برای تاییدیه را محدود کرد و این کلاس جدید را در کنترلر برای تولید تاییدیه استفاده کرد. با این کار نیازی به ویرایش کلاس سرویس ایمیل هم نیست.

سخن پایانی

اصل SRP یکی از رایج ترین و محبوب ترین اصول طراحی برای دستیابی به اهداف شی گرایی است. با استفاده از اصل SRP می توانیم وابستگی بین عملکردها را کاهش داده و بنابراین کد خط را برای پیاده سازی feature های جدید در طول زمان بهتر مدیریت کنیم.

Open/Closed Principle

تعریف

یک کلاس یا ماژول نرم افزاری باید برای extend باز و برای ویرایش بسته باشد.

کلاسی که می نویسیم باید به اندازه کافی منعطف باشد که نیازی به تغییر آن نباشد (برای تغییر بسته باشد) مگر زمانی که باگ داشته باشیم اما زمانی که یک feature جدید اضافه می شود (باز برای گسترش) نیازی به تغییر کد موجود نباشد و بتوان کد جدید را اضافه کرد.

توضیح

این اصل می گوید که باید بتوان عملکرد را درون کلاس بدون تغییر کد موجود درون کلاس گسترش داد یعنی باید بتوان رفتار نرم افزار را بدون تغییر پیاده سازی موجود آن گسترش داد.

این اصل مشخص می کند که کلاس خود را طوری طراحی کنید که feature های جدید را با اضافه کردن کد جدید به نرم افزار اضافه کنید نه با ویرایش کد موجود. تغییر ندادن کد فعلی این مزیت را دارد که باگ های جدیدی به کدهایی که دارن خوب کار میکنن اضافه نمی کنید.

باز برای گسترش به این معنی است که شما باید پیاده سازی کد خود را طوری انجام داده باشید که بتوانید از ارث بری برای پیاده سازی عملکرد جدید در برنامه خود استفاده کنید. طراحی شما باید به گونه ای باشد که به جای تغییر کلاس موجود بتوان کلاس جدیدی را اضافه کرد که از کلاس پایه مشتق شده باشد و کد جدیدی را به این کلاس مشتق شده اضافه کند.

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

استفاده

کلاس زیر یک گزارش به فرمت html تولید می کند

public class Report
{
    public bool GenerateReport()
    {
        //Code to generate report in HTML Format
        return true;
    }
}

خواسته اولیه تولید گزارش به فرمت html بوده است پس این کد به خوبی کار می کرده است. حالا برای شما درخوستی می آید که نیاز دارد به فرمت json هم گزارش تولید شود. حالا کد اصلاح شده به صورت زیر خواهد بود.

public class Report
{
    public bool GenerateReport()
    {
        //Code to generate report in HTML Format

        //Code to generate report in JSON Format
        return true;
    }
}

ما کد را تغییر دادیم که بر خلاف اصل OCP است که مشخص می کرد کلاس باید گسترش یابد نه اینکه تغییر کند. حالا کد را طوری مینویسم که مطابق با اصل OCP باشد

public interface IGenerateReport
{
    bool GenerateReport();
}

public class GenerateHTMLReport : IGenerateReport
{
    public bool GenerateReport()
    {
        //Code wot Generate HTML Report
        return true;
    }
}

در کد بالا از ارث بری واسط استفاده کردیم و یک واسط برای تولید توابع گزارش اضافه کردیم. ما واسط را در کلاس GenerateHTMLReport پیاده سازی کردیم تا یک کدی را برای تولید گزارشات در فرمت html اضافه کنیم. حالا پیاده سازی فرمت json را هم اضافه می کنیم.

public interface IGenerateReport
{
    bool GenerateReport();
}

public class GenerateHTMLReport : IGenerateReport
{
    public bool GenerateReport()
    {
        //Code wot Generate HTML Report
        return true;
    }
}

public class GenerateJSONReport : IGenerateReport
{
    public bool GenerateReport()
    {
        //Code to Generate JSON Report
        return true;
    }
}

همانطور که می بینید اضافه کردن feature جدید به جای تغییر کد موجود کد موجود را گسترس داد (با اضافه کردن کلاس GenerateJSONReport که واسط IGenerateReport را پیاده سازی می کند)

مزایا

  • ارث بری از طریق واسط کمک می کند تا به اتصال سست بین کلاس هایی که واسط را پیاده سازی می کنند دست پیدا کنیم
  • برای اضافه کردن feature جدید کد موجود را تغییر نمی دهیم یعنی وقتی کد جدید باگ پیدا می کند feature های موجود را دست نمی زنیم

مثال دنیای واقعی

مثال واقعی اصل OCP در اصول solid در پیاده سازی فریم ورک های logging نمود پیدا می کند. ما یک فریم ورک Logging به برنامه اضافه می کنیم و از میان مقاصد زیادی که در دسترس  هستند مقصد خود را انتخاب می کنیم (مثل فایل، دیتابیس یا کلود).

این مقاصد با استفاده از ارث بری واسط با در نظر داشتن ذهنیت OCP پیاده سازی میکنیم. در آینده نیز ممکن است مقاصد جدیدی اضافه شود که بایستی اصل بسته برای تغییر و باز برای گسترش رعایت شده باشد.

همچنین پیاده سازی برنامه واقعی یک عمکرد بارگزاری تراکنش (سفارش، دسترسی کاربر یا کاربران و غیره) است که شما فایل هایی را در فرمت xml برای پردازش می گیرید و در دیتابیس ذخیره می کنید. برای پیاده سازی این عملکرد کد زیر را به برنامه اضافه می کنیم.

public interface IUploadOrderFile
{
    object ProcessOrderFile();
}

public class UploadXMLOrderFile : IUploadOrderFile
{
    public object ProcessOrderFile()
    {
        object orderObj = null;
            
        //Parse XML File to DTO Object

        return orderObj;
    }
}

public class UploadProcess
{
    public bool UploadFile()
    {
        IUploadOrderFile orderFile = new UploadXMLOrderFile();
        Object orderObj = orderFile.ProcessOrderFile();

        //Validate Records

        //Save Records

        return true;
    }
}

حالا اگر ما درخواست جدیدی برای پذیرش فایل با فرمت json نیز بگیریم کد را به صورت زیر تغییر میدهیم.

public class UploadJSONOrderFile : IUploadOrderFile
{
    public object ProcessOrderFile()
    {
        object orderObj = null;

        //Parse JSON File to DTO Object

        return orderObj;
    }
}

public class UploadProcess
{
    public bool UploadFile()
    {
        IUploadOrderFile orderFile = null;

        //if XML File
        {
            orderFile = new UploadXMLOrderFile();
        }

        //if JSON File
        {
            orderFile = new UploadJSONOrderFile();
        }

        Object orderObj = orderFile.ProcessOrderFile();

        //Validate Records

        //Save Records

        return true;
    }
}

ما بیش از یک کلاس اضافه کردیم تا فایل های JSON را پارس کند و بسته به فایل ورودی از نوع XML یا json می توانیم از کلاس مناسب برای پارس کردن استفاده کنیم. با این روش قادر هستیم تا نوع فایل json را بردون تغییر کلاسی که نوع فایل xml برای آپلود هندل می کند هندل کنیم.

سخن پایانی

اصل OCP یکی از مهمترین اصول طراحی در solid است چون ارث بری واسط را ترویج می کند که در دستیابی به اتصال سست کمک می کند و همچنین کمک می کند تا عمکلرد موجود را دست نخورده نگاه داریم.

گاهی اوقات نیاز داریم تا کد موجود را برای پیاده سازی feature جدید تغییر دهیم اما طراحی کد به این روش باعث می شود تا پیاده سازی عملکرد های جدید تغیر کد موجود را به صفر یا حداقل ممکن برساند.

Liskov Substitution Principle

تعریف

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

این اصل توصیه می کند که شما باید کلاس های مشتق شده را طوری بنویسید که هر کلاس فرزند (مشتق شده) به طور عالی در جایی که کلاس والد (کلاس پایه) استفاده شده بدون تغییر در رفتار قابل جایگزینی باشد.

توضیح

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

لذا هر تابع کلاس پایه که توسط کلاس مشتق شده استفاده شده باید امضای یکسانی داشته باشد یعنی باید همان مقادیر ورودی را بگیرد و مقدار یکسانی را نیز برگرداند. تابع در کلاس مشتق شده نباید قوانین سخت گیرانه تری را اجرا کند زیرا اگر توسط شی ای از کلاس پایه فراخوانی شود موجب بروز مشکل می شود.

این اصل در واقع گسترشی از اصل OCP است که از ارث بری پشتیبانی می کند و اصل LSP این ارث بری را یه گام رو به جلو می برد و مشخص می کند که کلاس مشتق شده می تواند کلاس پایه را گسترش دهد اما باید رفتار یکسانی داشته باشد.

این اصل بیشتر روی رفتار کلاس های پایه و مشتق شده تمرکز دارد تا ساختار این کلاس ها.

استفاده

کلاس زیر یک تابع دارد که connection string دیتابیس را از یک فایل json می خواند و مقدار آن را عینا بر می گرداند

public class ReadParameters
{
    public virtual string GetDbConnString()
    {
        string dbConn = "Connection String From JSON File";

        //Read json setting file to get Connection String

        dbConn = ParseServerDetails(dbConn);

        return dbConn;
    }

    public string ParseServerDetails(string DbConn)
    {
        return DbConn + " - Parsed";
    }
}

بعد از مدتی ما یک خواسته جدید داریم که connection string را از یک فایل XML بخواند . پس یک کلاس دیگه اضافه می کنیم.

public class ReadParametersFromXML : ReadParameters
{
    public override string GetDbConnString()
    {
        string dbConn = "Connection String From XML File";

        //Read XML file to get Connection String

        dbConn = ParseServerDetails(dbConn);

        return dbConn;
    }
}

حالا کد زیر را نگاه کنید که در آن شی از کلاس پایه ایجاد کرده ایم سپس آن را با شی از کلاس مشتق شده جابه جا کرده ایم تا connection string را از فایل بخواند.

class Program
{
    static void Main(string[] args)
    {
        ReadParameters readParameters = new ReadParameters();
        Console.WriteLine(readParameters.GetDbConnString());

        readParameters = new ReadParametersFromXML();
        Console.WriteLine(readParameters.GetDbConnString());

        Console.ReadKey();
    }
}

بعد از اجرای کد بالا در خروجی کد زیر را می بینم.

Solid Principles - Liskov Substitution Principle

ما می توانیم در تصویر بالا ببینیم که اگر کلاس پایه را با کلاس مشتق شده جاگیزین کنیم آنگاه connection string دیتابیس به جای فایل json از فایل xml بر میگردد. حالا اگر entry ها برای connection string دیتابیس در فایل json و xml متفاوت باشد این جا به جایی میتواند موجب تغییر رفتار شود. این بر خلاف LSP در اصول solid است.

حالا بیاید کد بالا را تغییر دهیم.

public abstract class ReadParameters
{
    public abstract string GetDbConnString();

    public string ParseServerDetails(string DbConn)
    {
        return DbConn + " - Parsed";
    }
}

public class ReadParametersFromXML : ReadParameters
{
    public override string GetDbConnString()
    {
        string dbConn = "Connection String From XML File";

        //Read XML file to get Connection String

        dbConn = ParseServerDetails(dbConn);

        return dbConn;
    }
}

public class ReadParametersFromJSON : ReadParameters
{
    public override string GetDbConnString()
    {
        string dbConn = "Connection String From JSON File";

        //Read XML file to get Connection String

        dbConn = ParseServerDetails(dbConn);

        return dbConn;
    }
}

حالا کد بالا را ببینید که ما شی کلاس پایه را اعلان کردیم اما شی از کلاس مشتق شده ایجاد کردیم تا connection string را از فایل بخواند.

static void Main(string[] args)
{
    ReadParameters readParameters = new ReadParametersFromXML();
    Console.WriteLine(readParameters.GetDbConnString());

    readParameters = new ReadParametersFromJSON();
    Console.WriteLine(readParameters.GetDbConnString());

    Console.ReadKey();
}

بعد از اجرای برنامه کنسول بالا خروجی زیر را میگیریم.

Liskov Substitution Principle

ما پیاده سازی را بر اساس اصل LSP با abstract کردن کلاس پایه و تعریف یک تابع GetDbConnString به صورت abstract و override کردن آن در کلاس مشتق شده اصلاح کردیم.

مزایا

  • اگر کسی به اشتباه کلاس پایه را با کلاس مشتق شده جایگزین کند کد به مشکل نمی خورد و رفتار آن تغییر نمی کند.
  • کلاس های مشتق شده به آسانی می توانند برای متدهایی که آنها را پشتیبانی نمی کنند throw exception برگردانند.

مثال دنیای واقعی

مثال واقعی پیاده سازی برای اصل LSP در خیلی از دامنه ها دیده می شود. بیاید مثالی از دامنه بیمه را مرور کنیم که ما یک بیمه برای عمر و غیر عمر صادر می کنیم. در غیر-عمر ما بیمه وسیله نقلیه را داریم و در زیر مجموعه بیمه وسیله نقلیه ما چندین دسته بندی مثل بیمه ماشین شخصی، بیمه وسیله دوچرخ، بیمه وسیله تجاری و غیره را داریم.

بیاید ببینیم چطوری میشه کلاس ها را برای بیمه وسیله نقلیه با استفاده از اصل LSP طراحی و پیاده سازی کرد.

در کد زیر یک کلاس MotorInsurance به صورت abstract داریم و کلاس هایی که از آن مشتق شده اند.

public abstract class MotorInsurance
{
    public abstract bool IssuePolicy();

    public virtual bool GetPassengerCover()
    {
        return false;
    }
}

public class TwoWheelerInsurance : MotorInsurance
{
    public override bool IssuePolicy()
    {
        //Logic to Issue & Generate Policy

        return true;
    }
}

public class PrivateCarInsurance : MotorInsurance
{
    public override bool IssuePolicy()
    {
        //Logic to Issue & Generate Policy

        return true;
    }
}

public class CommercialVehicleInsurance : MotorInsurance
{
    public override bool IssuePolicy()
    {
        //Logic to Issue & Generate Policy

        return true;
    }

    public override bool GetPassengerCover()
    {
        return true;
    }
}

ما اشیایی از کلاس های بالا ایجاد کردیم و آنها را در متد اصلی به صورت زیر استفاده میکنیم.

class Program
{
    static void Main(string[] args)
    {
        MotorInsurance motorInsurance = new PrivateCarInsurance();
        Console.WriteLine("PrivateCarInsurance => PassengerCover => " + motorInsurance.GetPassengerCover());

        motorInsurance = new TwoWheelerInsurance();
        Console.WriteLine("TwoWheelerInsurance => PassengerCover => " + motorInsurance.GetPassengerCover());

        motorInsurance = new CommercialVehicleInsurance();
        Console.WriteLine("CommercialVehicleInsurance => PassengerCover => " + motorInsurance.GetPassengerCover());

        Console.ReadKey();
    }
}

بعد از اجرای کد بالا خروجی به صورت زیر است

Solid Principles

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

سخن پایانی

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

هر چند هیچ ابزاری در تضمین اینکه این اصل کمک نمی کند، شما باید چک های یا بازبینی های کد را دستی انجام دهید  کد را تست کنید تا مطمئن شوید که اصل LSP را نقض نمی کنید.

Interface Segregation Principle

تعریف

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

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

توضیح

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

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

واسطی که توسط کلاس پیاده سازی می شود باید خیلی مرتبط به مسئولیتی باشد که توسط کلاس پیاده سازی می شود. در طول طراحی واسط ها باید SRP را در نظر داشته باشیم.

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

استفاده

در قطعه کد زیر یک واسط عمومی داریم که توسط بیش از یک کلاس پیاده سازی شده است.

public interface IUtility
{
    bool LogData(string logdata);
    string GetDbConnStringFromConfig();
    bool SaveTransaction(object tranData);
    object GetTransaction(string tranID);
}

بیاید واسط فوق را توسط کلاس های مختلف پیاده سازی کنیم.

public class ConfigParameters : IUtility
{
    public string GetDbConnStringFromConfig()
    {
        string dbConn = string.Empty;

        //Read Connection String From Config

        return dbConn;
    }

    public object GetTransaction(string tranID)
    {
        throw new NotImplementedException();
    }

    public bool LogData(string logdata)
    {
        throw new NotImplementedException();
    }

    public bool SaveTransaction(object tranData)
    {
        throw new NotImplementedException();
    }
}
--------
public class Logger : IUtility
{
    public bool LogData(string logdata)
    {
        //Log data to File

        return true;
    }

    public string GetDbConnStringFromConfig()
    {
        throw new NotImplementedException();
    }

    public object GetTransaction(string tranID)
    {
        throw new NotImplementedException();
    }

    public bool SaveTransaction(object tranData)
    {
        throw new NotImplementedException();
    }
}
------
public class TransactionOperations : IUtility
{
    public object GetTransaction(string tranID)
    {
        Object objTran = new object();
        //Retrieve Transaction

        return objTran;
    }

    public bool SaveTransaction(object tranData)
    {
        //Save Transaction

        return true;
    }

    public string GetDbConnStringFromConfig()
    {
        throw new NotImplementedException();
    }
    public bool LogData(string logdata)
    {
        throw new NotImplementedException();
    }
}

ما واسط utility را در کلاس های مختلف پیاده سازی کردیم. در طول پیاده سازی کلاس ها ما اصل SRP در اصول solid را دنبال کردیم به طوریکه می توان دید که در هر کلاس همه توابع واسط را پیاده سازی نکرده ایم. همه توابع در واسط مرتبط با همه کلاس ها نیستند. برای مثال، کلاس logger فقط نیاز به پیاده سازی توابع مرتبط با لاگ کردن دارد و به طور مشابه کلاس config paramter فقط نیاز به تابع عملیات مرتبط با config دارد.

پیاده سازی فوق نقض اصل ISP است که مشخص می کند کلاس ها نباید روی پیاده سازی متدهایی تمرکز کنند که از آنها استفاده نمی کنند. پس بیاید پیاده سازی بالا را با لحاظ اصول solid اصلاح کنیم.

واسط تکی را به چندین واسط کوچک تر تقسیم می کنیم.

public interface IConfigOperations
{
    string GetDbConnStringFromConfig();
}

public interface ILogging
{
    bool LogData(string logdata);
}

public interface ITransactionOperations
{
    bool SaveTransaction(object tranData);
    object GetTransaction(string tranID);
}

حالا که واسط ها را تعریف کردیم بیاید این واسط ها را در کلاس هایی که داریم پیاده سازی کنیم.

public class ConfigParameters : IConfigOperations
{
    public string GetDbConnStringFromConfig()
    {
        string dbConn = string.Empty;

        //Read Connection String From Config

        return dbConn;
    }
}

public class Logger : ILogging
{
    public bool LogData(string logdata)
    {
        //Log data to File

        return true;
    }
}

public class TransactionOperations : ITransactionOperations
{
    public object GetTransaction(string tranID)
    {
        Object objTran = new object();
        //Retrieve Transaction

        return objTran;
    }

    public bool SaveTransaction(object tranData)
    {
        //Save Transaction

        return true;
    }
}

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

مزایا

  • با پیاده سازی واسط های کوچکتر می توانیم مسئولیت ها را جداسازی کنیم
  • با پیاده سازی واسط های کوچکتر می توانیم مسئولیت ها را بین چندین واسط توزیع کنیم و لذا به انتزاع دست پیدا کنیم
  • کلاس ها می توانند از واسط های مرتبط استفاده کنند و لذا توابعی را پیاده سازی کنند که توسط کلاس ها نیاز هستند. لذا قادر هستیم تا کلاس را با بیرون گذاشتن کدی که در کلاس استفاده ای نمیشود تمیز نگه داریم.

مثال دنیای واقعی

مثال مورد نظر در این بخش پلتفرم eCommerce است که در آن یک order entry option بهمراه چندین option برای پرداخت سفارش وجود دارد. به جای پیاده سازی یک واسط بزرگ برای option های پرداخت ما واسط پرداخت را به واسط های کوچکتر بر حسب نوع پرداخت می شکنیم.

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

public interface IPaymentOnline
{
    bool MakePaymentByCC(double amount);
    bool MakePaymentByBank(double amount);
}

public interface IPaymentOffline
{
    bool MakePaymentByCash(double amount);
}

همانطور که در تصویر بالا مشخص است واسط Payment را به دو واسط کوچکتر برای رویه های پرداخت آنلاین و نقدی شکسته ایم. کلاسی که پرداخت آفلاین را هندل می کند نیازی به توابع پرداخت آنلاین ندارد لذا می توان به دو واسط تقسیم کرد.

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

public abstract class Order
{
    public string OrderId { get; set; }

    public string CreateOrder(object orderObject)
    {
        return OrderId;
    }

    public object GetOrderDetails(string orderId)
    {
        object OrderDetails = new object();

        return OrderDetails;
    }
}

public class OrderWithOnlinePayment : Order, IPaymentOnline
{
    public bool MakePaymentByBank(double amount)
    {
        //Payment Workflow as per Online internet banking

        return true;
    }

    public bool MakePaymentByCC(double amount)
    {
        //Payment Workflow as per Credit Card Payment

        return true;
    }
}

public class OrderWithCashPayment : Order, IPaymentOffline
{
    public bool MakePaymentByCash(double amount)
    {
        //Make enteries that payment needs to be collected in cash on delivery

        return true;
    }
}

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

همچنین یک کلاس abstract در نظر گرفتیم که توابع مشترک برای سفارشات را نگه می دارد که برای همه سفارشات صرف نظر از نوع پرداخت (آنلاین یا آفلاین) مناسب است.

سخن پایانی

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

Dependency Inversion Principle

کلاس های سطح بالا نباید به کلاس های سطح پایین وابسته باشند در عوض هر دو باید وابسته به abstraction باشند.

abstraction نباید به جزییات وابسته باشد بلکه جزییات باید وابسته به انتزاع باشد.

این اصل توصیه می کند که باید بین کلاس های سطح بالا و سطح پایین اتصال سست برقرار باشد و برای دستیابی به این اتصال سست موله ها باید وابسته به انتزاع باشند. به زبان ساده می گوید که وابستگی به کلاس های abstract / واسط ها و نه به انواع concrete.

توضیح

اصل DIP در اصول solid به عنوان Inversion of control یا (IoC) هم شناخته می شود. این اصل در ابتدا IoC نامیده می شد اما Martin Fowler به جای آن Dependency Injection یا Dependency Inversion را به جای آن انتخاب کرد.

این اصل به طور ساده می گوید که شما باید انتزاع بین کلاس های سطح بالا و سطح پایین ارائه کنید که به شما اجازه بدهد تا کلاس های سطح بالا و سطح پایین را از هم مجزا کنیم.

اگر کلاس ها به یکدیگر وابسته باشند آنگاه با هم ارتباط تنگاتنگ دارند. زمانی که کلاس ها ارتباط تنگاتنگ داشته باشند تغییر در هر کلاس منجر به تغییر در همه کلاس های وابسته به آن نیز می شود. در عوض کلاس های سطح پایین باید قراردادهایی را با استفاده از یک واسط یا کلاس های abstract پیاده سازی کنند و کلاس های سطح بالا باید با استفاده از این قراردادها به انواع concrete دسترسی پیدا کنند.

این اصل مرتبط با سایر اصل ها در اصول solid است یعنی اگر شما هر دو اصل OCP و LSP را در کد خود پیروی کنید به طور غیر مستقیم اصل DIP را پیروی کرده اید.

استفاده

کلاس order وابسته به order repository برای عملیات دیتابیس است.

public class OrderRepository
{
    public bool AddOrder(object orderDetails)
    {
        //Save Order to Database

        return true;
    }

    public bool ModifyOrder(object orderDetails)
    {
        //Modify Order Details in Database

        return true;
    }

    public object GetOrderDetails(string orderId)
    {
        object orderDetails = new object();

        //Get Order Details from Database for given oderId

        return orderDetails;
    }
}
------
public class Order
{
    private OrderRepository _orderRepository = null;

    public Order()
    {
        _orderRepository = new OrderRepository();
    }

    public bool AddOrder(object orderDetails)
    {
        return _orderRepository.AddOrder(orderDetails);
    }

    public bool ModifyOrder(object orderDetails)
    {
        return _orderRepository.ModifyOrder(orderDetails);
    }

    public object GetOrderDetails(string orderId)
    {
        return _orderRepository.GetOrderDetails(orderId);
    }
}

همانطور که در بالا می بینید ما یک شی از کلاس order repository در کلاس order ایجاد کردیم و وابستگی مستقیم وجود دارد یعنی اتصال قوی بین دو کلاس. این نقص اصل DIP در اصول solid است که مشخص می کند کلاس ها باید با استفاده از abstraction اتصال سست داشته باشند.

بیاید کد را با رعایت اصل DIP دوباره نویسی کنیم و یک انتزاع بین آنها در نظر بگیریم.

public interface IOrderRespository
{
    bool AddOrder(object orderDetails);
    bool ModifyOrder(object orderDetails);
    object GetOrderDetails(string orderId);
}
------
public class OrderRepository : IOrderRespository
{
    public bool AddOrder(object orderDetails)
    {
        //Save Order to Database

        return true;
    }

    public bool ModifyOrder(object orderDetails)
    {
        //Modify Order Details in Database

        return true;
    }

    public object GetOrderDetails(string orderId)
    {
        object orderDetails = new object();

        //Get Order Details from Database for given oderId

        return orderDetails;
    }
}
------
public class Order
{
    private IOrderRespository _orderRepository = null;

    public Order(IOrderRespository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public bool AddOrder(object orderDetails)
    {
        return _orderRepository.AddOrder(orderDetails);
    }

    public bool ModifyOrder(object orderDetails)
    {
        return _orderRepository.ModifyOrder(orderDetails);
    }

    public object GetOrderDetails(string orderId)
    {
        return _orderRepository.GetOrderDetails(orderId);
    }
}

همانطور که در کد بالا مشخص است ما یک واسط برای order respository ایجاد کردیم و آن را در کلاس order repository پیاده سازی کردیم. در کلاس order به جای ایجاد مستقیم شی برای order repository ما از انتزاع استفاده کردیم یعنی واسط order repository. حالا در کلاس order ما می توانیم از هر کلاسی که واسط order repositoty را پیاده سازی کند استفاده کنیم.

حالا می توانیم از کد بالا به صورت زیر استفاده کنیم.

Order order = new Order(new OrderRepository());

از کد بالا می توانیم متوجه شویم که زمانی که از اشیا برای کلاس order شی ایجاد می کنیم نمونه هایی از کلاس order repository را نیز از طریق متد سازنده پاس می کنیم. لذا به جای اینکه کلاس order را به یک کلاس order repository وابسته کنیم آن را به یک واسط وابسته می کنیم و وابستگی را پاس می کنیم یعنی کلاس order repository را به کلاس order پاس می کنیم.

با این روش می توانیم هر کلاسی را که واسط را برای order repository پیاده سازی می کند به کلاس order برای عملیات دیتابیس پاس کنیم. برای مثال اگر فردا برنامه داشته باشیم که جزییات سفارش را در یک فایل ذخیره کنیم آنگاه می توانیم یک order repository جدید با عملیات فایل پیاده سازی کنیم و آن را به کلاس order پاس کنیم. لذا کلا سorder جزییات را بدون هیچ تغییری در کلاس order در فایل ذخیره خواهد کرد.

مزایا

  • کلاس ها وابسته به انتزاع هستند و نه انواع concrete
  • کلاس های سطح بالا و سطح پایین اتصال سست دارند
  • مادامی که قراردادها را تغییر نداده اید تغییر در یک کلاس منجر به تغییر کلاس دیگری نخواهد شد
  • چون کلاس ها وابسته به انتزاع هستند تغییر در یک کلاس منجز به شکستن کلاس دیگر نخواهد شد

مثال دنیای واقعی

مثال دنیاب واقعی برای DIP تست های واحد خودکار برنامه با استفاده از هر فریم ورک تستی مثل NUnit، xUnit و غیره است که Dependency Injection برای پاس کردن وابستگی های مختلف (stub و mock) به کلاس برای تست کردن مولفه استفاه شده اند هستند.

سخن پایانی

این پنجمین و آخرین اصل در اصول Solid است و یکی از مهمتری اصول طراحی در برنامه نویسی امروز. این اصل وابستگی بین موجودیت ها را با تعریف روشی که در آن دو کلاس با استفاده از انتزاع ارتباط سست دارند حذف می کند.

زمانی که یک کلاس جزییات زیادی در رابطه با کلاس دیگر می داند این ریسک وجود دارد که تغییر در یک کلاس منجر به شکستن کلاس دیگر شود لذا کلاس های سطح بالا و سطح پایین باید تا جای ممکن ارتباط سست داشته باشند.

سوالات رایج در مصاحبه های استخدامی در رابطه با اصول solid

در اینجا لیستی کاملی از سوالات مصاحبه که در رابطه با اصول solid پرسیده می شوند را آورده ام. پاسخ به این سوالات در توضیحات بالا آمده است.

  1. به دانش خودتان در رابطه با اصول solid چه امتیازی می دهید؟
  2. آیا تا به حال از اصول solid در کد خود استفاده کرده اید؟
  3. اصول solid چیا هستن و آنها را تعریف کنید
  4. اصل SRP چیست؟ (آن را تعریف کنید)
  5. اصل OCP چیست؟ (آن را تعریف کنید)
  6. اصل LSP چیست؟ (آن را تعریف کنید)
  7. اصل ISP چیست؟ (آن را تعریف کنید)
  8. اصل DIP چیست؟ (آن را تعریف کنید)
  9. مثال های واقعی از هر اصل در اصول solid بزنید
  10. کجای کد خود تا حالا از اصول solid استفاده کردید و دلیل استفاده شما چی بوده؟
  11. فکر می کنید که این اصول solid مهم هستند و اگر اینطوره چرا فکر می کنید مهم هستن؟
  12. با رعایت اصول solid در کدتون به چه چیزی دست پیدا می کنید؟

خلاصه

در این مقاله در رابطه با اینکه اصول solid چی هستند توضیح دادیم و هر اصل را به جز (تعریف، توضیح، استفاده، مزایا استفاده و مثال هایی از دنیای واقعی) بیان کردیم. اصول solid در اثراتی که روی کد می گذارند با هم همپوشانی دارند. اصول solid به ما کمک می کنند تا کد با وابستگی سست بنویسم که کمتر مستعد خطا باشد.

 

منبع

این مقاله از روی مقاله زیر ترجمه شده است.

https://procodeguide.com/design/solid-principles-with-csharp-net-core/#OpenClosed_Principle