هذه ستكون ملخص لمقالة بسيطة عن مبادئ الـ SOLID كنت قد نشرتها على مدونتي هنا https://tabarani.tk/article... مع شرح تفصيلي أكثر وأمثلة أخرى
مبادئ الـ SOLID ليست قوانين صارمة يجب عليك اتباعها بشكل مطلق
بل هي مجرد مجرد أفكار تساعدك على كتابة الكود بشكل منظم وسلس
وكل مبدأ يركز على فكرة معينة ويهدف لجعل الكود سهل التعديل عليه على قدر المستطاع
وجعله أكثر قابلية لتغير وتعديل واختباره وايجاد الأخطاء وسهل القراءة
وأفكار التي يركز عليها الـ SOLID من وجه نظري هي تمهيد قوي لتعلم وفهم الـ Design Patterns بشكل أفضل وأسهل
أهم النقاط التي عليك معرفتها عن كل مبدأ
Single Responsibility
يركز على تقسيم الكلاسات إلى وحدات وظيفية مستقلة أي أن كل كلاس يجب أن يخدم على وظيفة واحدة فقط
مثل كلاس الـ UserService يجب أن يكون مسؤول عن كل شيء يتعلق بالمستخدم فقط
ولا يجب أن يكون مسؤول عن أي شيء آخر مثل الـ OrderService أو الـ ProductService أو غيرها
كلاس الـ NotificationService يجب أن يكون مسؤول عن إرسال الإشعارات فقط وهكذا … إلخ
كل الدوال التي في الكلاس يجب أن تكون متعلقة بالوظيفة الرئيسية للكلاس التي تنتمي له
بمعنى لا تقوم بعمل دالة تدعى sendOTP في كلاس الـ AuthService لأنه ليس مسؤول عن ذلك
لاكن يمكنك أن تنشيء object من EmailService داخل الـ AuthService وتستدعي الدالة sendOTP من خلال هذا الـ object هكذا:
class AuthService { constructor(private emailService: EmailService) {} public signup(email: string, password: string) { // signup logic ... this.emailService.sendOTP(email); } }
Open/Closed
يركز على تقليل التعديل على الكلاسات والدوال على قدر المستطاع عن طريق جعله مفتوحة للتوسيع ومغلقة للتعديل
بمعنى أنك يجب أن تكون قادرًا على إضافة ميزات جديدة دون الحاجة لتعديل الكود الحالية، طالما الكود يعمل فلا تعدل عليه
يمكنك أن تطبقه على أكثر من شكل وحالة سواء على مستوى الدوال أو على مستوى الكلاس ككل
على مستوى الدالة إذا كانت تعتمد على أكثر من نوع مختلفة من شيء معين مثل نوع المنتج او رتبة المستخدم
وتجد نفسك تقوم بعمل كود مختلف ليناسب كل نوع وتقوم بعمل if else لكل نوع
هنا إذا كنت تستطيع جعل الدالة تستقبل هذا الشيء الذي يضم أنواع مختلفة وتستقبل كـ object ويكون interface أو abstract class
ثم ترمي مسؤولية الـ implementation على الكلاسات الفرعية التي سيمثلها هذا الـ object
فبدلًا من
class PermissionService { public isAllowTo(user: User, action: string) { if (user.role === 'admin') { return true; } else if ( user.role === 'editor' && action === 'read' && action === 'write' && action === 'edit' ) { return true; } else if (user.role === 'viewer' && action === 'read') { return true; } else { return false; } } }
تجعله هكذا
class PermissionService { public isAllowTo(user: IUser, action: string) { return user.isAllowTo(action); } } interface IUser { isAllowTo(action: string): boolean; } class Admin implements IUser { public isAllowTo(action: string) { return true; } } class Editor implements IUser { public isAllowTo(action: string) { return action === 'read' || action === 'write' || action === 'edit'; } } class Viewer implements IUser { public isAllowTo(action: string) { return action === 'read'; } } // ... ContentCreator, MediaBuyer, etc.
أو لو كانت الدالة تقوم بواجبها على أكمل شكل ولا تريد أن تعدلها أبدًا
أو لو كان الكلاس ككل يقوم بواجبه بالشكل المطلوب واختبرته وكتبت أكثر من unit test وكل شيء يعمل بشكل جيد
ولا تريد تعديله لكن تريدان تضيف عليه او تضيف أشياء جديدة للدوال
فيمكنك وراثة هذا الكلاس وتقوم بعمل override للدوال التي تريد تغيرها وتضيف الأشياء التي تريدها
وتبقي الكلاس الأساسي كما هو دون تعديل
Liskov Substitution
يركز على تقليل المشاكل التي قد تحدث عند استبدال كلاس بآخر يرث منه
فهو ينص على أن يكون الكلاس الفرعي قادرًا على القيام بنفس الوظائف التي يقوم بها الكلاس الأساسي دون ان ينقص منه شيء
وأن لا يحدث أي مشاكل عند استبدال الكلاس الأساسي بالكلاس الفرعي لأنه بطبيعة الحال يرث منه ويرث كل شيء يقوم به
فالمبدأ يحسن منطقنا في استخدامنا لمفهوم الوراثة وبناء الـ interface المختلفة بشكل منطقي ونحدد من ينتمي لمن وهل يصلح أن يكون الكلاس الفرعي يرث من الكلاس الأساسي أم لا
بمعنى هل من المنطقي أن ينتمي كلاس مثل Student إلى عائلة Employee ؟ بالطبع لا
لأن الـ Employee قد يحتوى على أمور لن يستطيع الـ Employee القيام بها مثل work أو salary وغيرها لأن الـ Employee متخصص وليس عام
لكن هل الـ Student يمكن أن ينتمي إلى عائلة Person ؟ بالطبع نعم
بشرط أن يقدم Person يحتوي على الأمور الأساسية فقط والعامة التي ستتواجد في جميع الأشخاص دون استثناء
أو قد يكون لديك كلاسات مثل CoffeeMachine و TeaMachine و CacaoMachine يقومان ببناء وتنفيذ كل شيء في interface يدعى IMachine بشكل كامل بدون نقصان
لكن أحد الكلاسات قرر تنفيذ دالة معينة بشكل خاطئ
بمعنى أنه قد يكون هناك دالة تدعى makeZeroSugarCup وهي دالة داخل الـ IMachine تقوم بعمل كوب ما بدون سكر
ثم تجد كلاس الـ CoffeeMachine يقوم بتنفيذ هذه الدالة بشكل خاطئ وبكل بجاحة يضيف عليه سكر
إذا فتلك الدالة منطقيًا لا تتبع مبدأ الـ Liskov لأن أي شخص يقوم بالتعامل مع IMachine
فهذا الشخص يتوقع أن الدالة makeZeroSugarCup تقوم بعمل كوب بدون سكر وليس بسكر ويقوم ببناء تطبيقه على هذا الأساس
ثم يتفاجيء أن هناك مشكلة غير متوقعة بسبب أن object عندما يكون من نوع CoffeeMachine يقوم بإضافة سكر ويفسد عليه كل شيء
function makeCupWithoutSugar(machine: IMachine) { return machine.makeZeroSugarCup(); } let coffeeMachine = new CoffeeMachine(); let teaMachine = new teaMachine(); let cacaoMachine = new CacaoMachine(); makeCupWithoutSugar(teaMachine); // it will work fine makeCupWithoutSugar(cacaoMachine); // it will work fine makeCupWithoutSugar(coffeeMachine); // Unexpected bug, it will add sugar
صاحب الدالة makeCupWithoutSugar لم يتوقع أن يحدث هذا الأمر لأنه يعتمد على الـ interface الـ IMachine ويتوقع أن يكون كل شيء على ما يرام
الآن سيسهر الليلة ليبحث عن هذا الخطأ الخفي الغير متوقع
فالمبدأ الـ Liskov Substitution قد يتم مخالفته عن طريق عدم بناء الدالة وتنفيذها أو عن طريقة تنفيذها لكن بشكل خاطئ من حيث المنطق
Interface Segregation
يركز على تقسيم الـ interface الكبير إلى عدة interface صغيرة وكل واحدة تركز على شيء معين
بمعنى أنك لا تجعل الـ interface يحتوي على العديد من الأمور التي قد لا تحتاجها كل الكلاسات
بحيث لا تجبر من يقوم ببناء الـ interface على استخدام متغيرات أودوال لا يحتاجها
فإذا كان لديك interface يدعى IPerson ويحتوي على العديد من الأمور مثل:
interface IPerson { name: string; age: number; workplace: string; salary: number; school: string; grade: number; getSchool(): string; getGrade(): number; getWorkplace(): string; getSalary(): number; }
هل تعتقد أن كلاسات مثل الـ Student يستطيع بناء IPerson واستخدام كل هذه الأمور؟ بالطبع لا
لأن الـ Student ليس لديه مكان عمل أو راتب ولا يحتاجها فلما يتم اجباره ؟
هنا نحتاج إلى تقسيم IPerson إلى IBasicPerson و IWorkablePerson و IEducablePerson وغيرها
interface IBasicPerson { name: string; age: number; } interface IWorkablePerson { workplace: string; salary: number; getWorkplace(): string; getSalary(): number; } interface IEducablePerson { school: string; grade: number; getSchool(): string; getGrade(): number; }
ثم تجعل كل كلاس يبني ما يريده
class Student implements IBasicPerson, IEducablePerson { public name: string; public age: number; public school: string; public grade: number; public getSchool() { return this.school; } public getGrade() { return this.grade; } } class Employee implements IBasicPerson, IWorkablePerson { public name: string; public age: number; public workplace: string; public salary: number; public getWorkplace() { return this.workplace; } public getSalary() { return this.salary; } }
Dependency Inversion
يركز على منع الاعتماد المباشر بين الكلاسات بمعنى أنه لا يأتي كلاس معين يعتمد على كلاسات محددة بذاتها
مثل أن يعتمد كلاس مثل NotificationService ويعتمد على كلاسات بعينها مثل EmailService و SMSService ويعتمد على هذه الأنواع بشكل مباشر
class NotificationService { public emailService: EmailService; public smsService: SMSService; constructor() { this.emailService = new EmailService(); this.smsService = new SMSService(); } public sendEmail() { this.emailService.send(); } public sendSMS() { this.smsService.send(); } }
هذا الذي تراه الآن هو يتعارض مع المبدأ لأن الـ NotificationService يعتمد على كلاسات محددة بذاتها
ولأنه فرضًا لو تخلينا عن أحد هذه الكلاس أوأضفنا أنواع جديدة من الكلاسات فماذا ستفعل ؟
هل ستذهب وتعدل الـ NotificationService ليعتمد على هذه الكلاسات الجديدة؟ وتزيل القديم التي لم تعد تحتاجها؟
بالطبع لا، لذا يجب أن تجعل الـ NotificationService يعتمد على الـ interface أو abstract class بدلاً من الكلاسات المحددة بذاتها
هذا interface قد يدعى INotifiableProvider ويحتوي على دالة أساسية مثل notify
ثم تجعل الكلاس NotificationService يعتمد على object من هذا الـ interface بدلاً من الكلاسات المحددة بذاتها
interface INotifiableProvider { notify(): void; } class NotificationService { public notifiableProvider: INotifiableProvider; constructor(notifiableProvider: INotifiableProvider) { this.notifiableProvider = notifiableProvider; } public sendNotification() { this.notifiableProvider.notify(); } }
الآن يمكنك أن تقوم بإنشاء كلاسات تبني الـ INotifiableProvider مثل EmailService و SMSService و PushNotificationService و WhatsAppService وغيرها
وترسل ما تريده إلى الـ NotificationService وتجعله يقوم بالعمل بشكل طبيعي
let emailService = new EmailService(); let notificationService = new NotificationService(emailService); notificationService.sendNotification(); /////////////////////////////////////// let smsService = new SMSService(); let notificationService = new NotificationService(smsService); notificationService.sendNotification(); /////////////////////////////////////// let pushNotificationService = new PushNotificationService(); let notificationService = new NotificationService(pushNotificationService); notificationService.sendNotification();
الآن يمكنك أن تزيل أو تضيف أي خدمة دون الحاجة لتعديل أي شيء في الـ NotificationService