مبادئ الـ SOLID لجعل كودك صلب كالحديد
السلام عليكم ورحمة الله وبركاته
الفهرس
المقدمة
هذه ستكون مقالة بسيطة سنشرح فيها مبادئ الـ SOLID
التي تساعدنا على كتابة الكود بشكل منظم وسلس
وبالطبع أنا سأفترض أنك تعرف وتفهم مبادئ الـ OOP
لأننا سنطبق الـ SOLID
على الـ OOP
الـ SOLID
هي 5
مبادئ أساسية تهدف لجعل الكود سهل التعديل عليه على قدر المستطاع
وكل مبدأ يركز على فكرة معينة تساعد على جعل الكود أكثر قابلية لتغير وتعديل وسهل القراءة وكل تلك الأمور التي يتغنى بها الجميع
الـ SOLID
مجرد أفكار فقط لا أكثر تتعرف عليها وتحاول توظيفها وليس تطبيقات ثابتة تتعلمها مثل الـ Design Patterns
يمكنك أن نقول أن الـ SOLID
هي أفكار ومبادئ والـ Design Patterns
هي تطبيقات حقيقية لحل مشاكل كبيرة واقعية
تطبيق مبادئ الـ SOLID
ليس بالأمر السهل ولكنه ليس بالأمر الصعب أيضًا
قد تضطر لتعديل الكثير من الأشياء وتغير الكثير من الأفكار والتصورات التي كنت تعتقد أنها صحيحة
لتصل إلى أقرب شكل يناسبك ويناسب الفريق الذي تعمل معه
سأحاول بالطبع شرح كل مبدأ والفكرة العامة الذي يركز عليها مع بعض الأمثلة العملية البسيطة لكود يناقض المبدأ وكود آخر يوافق المبدأ
مبدأ الـ Single Responsibility
ونبدأ مع أولهم وهو الـ Single Responsibility
هو مبدأ يهتم بجمع مجموعة من الأكواد التي تتشارك في نفس الوظيفة ثم توحيدها تحت مظلة واحدة وتكون مسؤولة عن تلك الوظيفة
لكي تسهل اعادة استخدامها مجددًا في أماكن مختلفة
والمبدأ ينص على أن الكلاس الواحد يجب أن يكون مسؤول عن شيء ووظيفة واحدة
بالتالي كل الدوال التي بداخل هذا الكلاس يجب أن تخدم هذه الوظيفة التي يقوم بها هذا الكلاس
وطالما أن الكلاس يكون مسؤول عن وظيفة واحدة فقط فأنت عندما تريد إضافة شيء ما او تعدل شيء ما في الموضوع الفلاني فستذهب إلى الكلاس المسؤول عن ها الموضوع المعين
بمعنى إذا كان لديك كلاس يدعى EmailService
ما الذي يخطر على بالك عندما تقرأ اسم الكلاس ؟
- هل يخطر على بالك أنه يقوم بإحضار بيانات المستخدمين ؟ بالطبع لا
- هل يخطر على بالك أنه يقوم بحساب كم نسبة شراء المستخدمين لمنتج معين ؟ أكيد لا
- هل يخطر على بالك أنه يقوم بتخزين إحصائيات زيارات المستخدمين في قاعدة البيانات ؟ أكيد لا
ما الذي يجعلك متأكدًا أن كلاس EmailService
لا يقوم بتلك الأمور
بسبب أنه من الواضح من اسمه أنه يقوم بعمل بعض الأمور المتعلقة بالبريد الإلكتروني
أمور مثل إرسال رسائل عبر البريد الإلكتروني إلى المستخدمين مثل:
- إرسال رمز التحقق
OTP
لتأكيد حسابه وبريده الإلكتروني - إرسال رسائل تنبيهية ومعلومات عن حالة الحساب
- تقوم بتجهيز وإحضار شكل الـ
html
التي سيتم إرساله - دالة تقوم بتفقد بشيء
- ... إلخ
فعندما تريد تعديل طريقة ارسالك للرسائل عبر البريد الالكتروني فأنت تلقائيًا تدرك أن الدالة المسؤولة عن هذا الأمر موجودة في كلاس الـ EmailService
أو عندما تريد عمل unit test
للكلاس EmailService
فأنت ستركز على شيء واحد فقط وهو الوظيفة الاساسية لكل دالة التي ستحوم حول فكرة ووظيفة كلاس الـ EmailService
ماذا عن كلاس يدعى ProductService
؟
- دالة تقوم بإحضار منتجات أو البحث عن منتجات معينة
- دالة تقوم بتطبيق بعض الخصومات على المنتج
- دالة تقوم بحساب السعر الكلي لمجموعة من المنتجات
- دالة تقوم ... بشيء خاص له علاقة بالمنتج ... إلخ
أظن أن الفكرة وصلت
مثال يناقض المبدأ
سنفترض أنك لديك كلاس يدعى UserService
وسأعيد عليك السؤال عندما تقرأ اسم الكلاس ما الذي يخطر على بالك ؟
سيخطر على بالك دوال مثل getAllUsers
, getOneUser
, updateOneUser
... وإلى آخره من الدوال التي تخدم فكرة الـ UserService
class UserService {
getAllUsers() {
// get all users
// ...
}
getOneUser(id: string) {
// get one user
// ...
}
signUp(email: string, password: string) {
// check email and password
// sign up the user
// ...
}
login(email: string, password: string) {
// check email and password
// log in the user
// ...
}
getHTMLTemplate(type: string) {
// get html template
// ...
}
sendOTP(email: string, otp: string) {
// get html template
// send otp to email
// ...
}
}
لماذا هذا الكلاس يخالف المبدأ ؟
أريدك أن تحلل ما وظيفة الكلاس الأساسية وأريدك أن تحلل الدوال التي به هل هي تخدم الوظيفة الأساسية الذي يتبناها الكلاس ؟
ستجد أن الإجابة بالطبع لا، والسبب بكال وضوح أنه الكلاس مسؤول عن وظائف مختلفة
- الكلاس بالفعل به دوال مسؤولة عن الـ
UserService
مثلgetAllUsers
وgetOneUser
وتحقق الوظيفة الأساسية للكلاس - لكن أيضًا به دوال مسؤولة عن الـ
Auth
مثلsignIn
وlogin
- وبه دوال مسؤولة عن الـ
Email
مثلsendOTP
وgetHTMLTemplate
ستجد أن الكلاس البسيط UserService
ربنا فتح عليه وأصبح مسؤولة عن وظائف متعددة في آن واحد
فتراه مسؤول عن كل ما يتعلق بالمستخدم
وتراه مسؤول عن الأمور الصلاحيات وتسجيل الأمان
وتراه مسؤول عن إرسال رسائل عبر البريد الإلكتروني
وهذا بالطبع يخالف مبدأ الـ Single Responsibility
وهو أن كل كلاس يجب أن يكون مسؤول عن وظيفة واحدة
مثال يوافق المبدأ
الحل بكل بساطة أننا ننشيء كلاسات متعددة وكل كلاس يكون مسؤول عن وظيفة واحدة
- يمكننا أن نبقي الكلاس
UserService
كما هو ويكون بهgetAllUsers
وgetOneUser
التي تحقق الوظيفة الأساسية للكلاس
class UserService {
getAllUsers() {
// get all users
// ...
}
getOneUser(id: string) {
// get one user
// ...
}
// updateOneUser, deleteOneUser, getUserProfile ... etc.
}
- يمكننا أن ننشيء كلاس
AuthService
به الدوال المسؤولة عن الـAuth
مثلsignIn
وlogin
وresetPassword
وverifyAccount
و ... إلخ
class AuthService {
signUp(email: string, password: string) {
// check email and password
// sign up the user
// ...
}
login(email: string, password: string) {
// check email and password
// log in the user
// ...
}
// resetPassword, verifyAccount, forgotPassword ... etc.
}
- وننشيء كلاس
EmailService
به الدوال المسؤولة عن التعامل مع البريد الالكتروني مثلsendOTP
وgetHTMLTemplate
وsendEmailMessage
و ... إلخ
class EmailService {
getHTMLTemplate(type: string) {
// get html template
// ...
}
sendOTP(email: string, otp: string) {
// get html template
// send otp to email
// ...
}
sendEmailMessage(email: string, message: string) {
// get html template
// send email message
// ...
}
// ... etc.
}
فوائد المبدأ
هكذا كل كلاس يكون مسؤول عن وظيفة واحدة ومن يريد شيء يمكننه استدعاءه من الكلاس المسؤول عن هذا الشيء
فيمكن لكلاس الـ AuthService
أن ينشيء object
من EmailService
ليستخدم أي دالة يريدها متى يشاء كـ sendOTP
أو sendEmailMessage
أو ... إلخ
class AuthService {
private emailService: EmailService;
constructor() {
const emailService = new EmailService();
}
signUp(email: string, password: string) {
// check email and password
// sign up the user
// ...
this.emailService.sendOTP(email, '123456');
}
verifyAccount(email: string) {
// check email
// verifying the account ...
// ...
this.emailService.sendEmailMessage(
email,
'Verified the account successfully'
);
}
// ... etc.
}
لاحظ أن الـ AuthService
يقوم فقط بالتركيز على وظائفه الأساسية وإن أراد شيء ما من الـ EmailService
يقوم باستدعاء فقط دون أن يهتم بالتفاصيل الخاصة بالـ EmailService
والدوال التي به
وأي كلاس يحتاج شيء ما من الـ EmailService
سيقوم فقط باستدعاء الدالة التي يريدها منه
فمثلا هنا دالة signUp
كانت تريد أن ترسل OTP
فبكل بساطة قامت باستدعاء الكلاس المسؤول عن ارسال الرسائل EmailService
وقامت بتنفيذ الدالة التي ارادتها sendOTP
ودالة أخرى verifyAccount
قامت بتنفيذ الدالة sendEmailMessage
لإرسال رسالة معينة
فهنا الـ AuthService
لم يقم بإنشاء دالة sendOTP
أو sendEmailMessage
عنده بل قام باستدعائها من الكلاس المسؤول عنها وهو الـ EmailService
هكذا نحن وحدنا وظائف كل كلاس وكل كلاس يحتوي على الدوال التي تخدم وظيفته وأي كلاس يحتاج شيء ما من كلاس آخر معين سيقوم فقط باستدعاء الكلاس الآخر المسؤول عن هذا الشيء
وإن حدثت مشكلة ما في الـ EmailService
أو تريد تعديل إحدى دواله فأنت ستقوم بالتعديل في مكان واحد فقط وهو الكلاس EmailService
دون أن تأثر أو تعدل شيء في باقي الكلاسات الأخرى كـ AuthService
وغيره
فأنت تعدل فقط في الكلاس المسؤول عن هذا الشيء
الأمور هكذا أصبحت منظمة أكثر وتسهل علينا أمور عديدة في كتابة الكود وتبسيطه وتعديله وكل تلك الأمور الجميلة التي يتغنى بها الجميع
مبدأ الـ Open/Closed
اسم Open/Closed
هو اختصار لـ Open for extension and closed for modification
ومعناه أن الكلاس قابل ليتم وراثته وممنوع التعديل عليه
فمبدأ الـ Open/Closed
يركز على أننا لدينا كلاس أساسي يقوم بتنفيذ وظيفته على أكمل وجب
المبدأ يقول لك ألا تقوم بالتعديل على هذا الكلاس الأساسي طالما هو يقوم بتنفيذ وظيفته
وإذا أردت إضافة وظائف جديدة للكلاس كدوال جديدة أو مميزات جديدة قم بوراثته في كلاس آخر جديد وقم بعمل ما تريده فيه
المبدأ يشجع على استخدام الـ Interface
أو Abstract Class
لتعريف الدوال الأساسية والأمور الأساسية التي ستكون عامة عند الجميع
ولو أراد كلاس معين إضافة شيء جديد خاص به أو تعديل شيء ما، يقوم بعمل implement
للـ Interface
أو extend
للـ Abstract Class
ويقوم بإضافة وتعديل ما يريده
ستقول لي أن هذا اشبه بالأمور التي تعلمناها في مقالة الـ Inheritance
والـ Polymorphism
هذا صحيح لأنه كما قلنا أن الـ SOLID
هي أفكار ومبادئ لا أكثر وأنت تستخدمها وتطبقها على قدر المستطاع بالطريقة التي تناسبك
والـ Inheritance
والـ Polymorphism
هما أحد الأفكار التي تساعدك على تطبيق مبدأ الـ Open/Closed
مثال يناقض المبدأ
فتخيل معي كلاس يدعى ProductService
وهو كلاس يهتم بالمنتجات بشكل عام
تخيل معي أن هذا الكلاس لديه دالة تدعى calculateProductPrice
تقوم بحساب المنتجات وتطبيق بعض الخصومات بحسب نوع المنتج
فلو كان من منتجات الألبان يتم خصم مبلغ معين ولو كان منتج أجهزة كهربائية يتم تطبيق خصم مختلف
الأمر سيشبه شيء كهذا
class ProductService {
calculateProductPrice(product: Product) {
if (product.type === 'food') {
return product.price - 10;
} else if (product.type === 'electric') {
return product.price - 20;
} else if (product.type === 'clothes') {
return product.price - 30;
}
// else if (...) { }
// else if (...) { }
// ... etc.
}
// ...
}
الآن كلاس الـ ProductService
قد يكون كلاس جميل ويطبق مبدأ الـ Single Responsibility
بشكل جيد
لكن به مشكله صغيرة وهي أنه يحتوي على دالة calculateProductPrice
التي تقوم بتطبيق الخصومات على المنتجات بحسب نوعه
وكما تلاحظ فالدالة تحتوي على if
و else if
وهذا يعني أنه يمكن أن يكون هناك تعديلات كثيرة على الدالة وسنضع if
و else if
كل مرة
بالتالي فالكلاس في هذه الدالة لا يطبق مبدأ الـ Open/Closed
لأنه سيتم تعديل هذه الدالة على مدار الساعة بشكل دائم
والمبدأ ينص على أننا يجب أن نجعل الأشياء قابلة لتوسع لكن في نفس الوقت لا يتم تعديل الدالة مجددًا
لكن كيف نجعل الدالة calculateProductPrice
قابلة للتوسع وفي نفس اللحظة ا نقوم بتعديلها ؟
مثال يوافق المبدأ
الحل بكل بساطة هو أن نقوم بعمل Interface
أو Abstract Class
يكون به الدوال العامة لكل منتج
وكل نوع منتجات معينة سيقوم بعمل implement
لهذا الـ Interface
أو extend
للـ Abstract Class
ويقوم بتعديل وإضافة ما يريده
فمثلا سنقوم بعمل Interface
يدعى IProduct
يحتوي على دالة calculatePrice
وسنقوم بعمل implement
لهذا الـ Interface
في كلاسات المنتجات المختلفة مثل FoodProduct
, ElectricProduct
, ClothesProduct
وغيرها
وكل كلاس سيقوم بعمل implement
للدالة calculatePrice
بالطريقة التي يريدها
interface IProduct {
calculatePrice(): number;
}
class FoodProduct implements IProduct {
calculatePrice() {
return this.price - 10;
}
}
class ElectricProduct implements IProduct {
calculatePrice() {
return this.price - 20;
}
}
class ClothesProduct implements IProduct {
calculatePrice() {
return this.price - 30;
}
}
الآن نستطيع أن نذهب للدالة calculateProductPrice
التي كان بها مشكلة الـ if
و else if
class ProductService {
calculateProductPrice(product: Product) {
if (product.type === 'food') {
return product.price - 10;
} else if (product.type === 'electric') {
return product.price - 20;
} else if (product.type === 'clothes') {
return product.price - 30;
}
// else if (...) { }
// else if (...) { }
// ... etc.
}
// ...
}
ونقوم بتعديلها لتقوم بالتعامل مع الـ IProduct
وتستدعي دالة calculatePrice
منه
class ProductService {
calculateProductPrice(product: IProduct) {
return product.calculatePrice();
}
// ...
}
هكذا الآن الدالة calculateProductPrice
أصبحت تقوم بتطبيق الخصومات على المنتجات بحسب نوعها بدون الحاجة للـ if
و else if
وأيضًا أصبحت تطبق مبدأ الـ Open/Closed
بامتياز
الآن الدالة تعتمد على الـ IProduct
وهو الـ Interface
الذي يحتوي على دالة calculatePrice
وأيًا الـ object
الذي يتم إرساله للدالة سيقوم بتطبيق الدالة calculatePrice
بالطريقة التي يريدها هذا الـ object
سواء كان هذا الـ object
من نوع FoodProduct
أو ElectricProduct
أو ClothesProduct
أو غيرها
وإن أردت إضافة نوع منتجات جديدة بخصومات مختلفة فقط قم بعمل implement
للـ IProduct
وقم بتعديل الدالة calculatePrice
بالطريقة التي تريدها
دون أن تقوم بتعديل أي شيء في الدالة calculateProductPrice
في الـ ProductService
الدالة أصبحت كما ينص المبدأ دالة مفتوحة دائمًا للتوسع واستقبال أي نوع مهما كان وأصبحت مغلقة تمامًا بحيث لن يتم تعديلها مجددًا
يمكنك أن تتطبق فكرة المبدأ بطرق مختلفة سواء على مستوى الدوال أول على مستوى الكلاسات
لأن المبدأ مجرد فكرة وأنت توظفها بالشكل الذي يناسبك
يمكن أن يكون لديك كلاس يدعى EmployeeService
به دوال متعددة تخدم وظيفته وأنت اختبرتها وتأكدت منها والكلاس يقوم بواجبه على أكمل شكل ممكن
وأنت الآن مضطر لإضافة بعض المميزات الجديدة فمثلا تريد أن تضيف إلى الـ EmployeeService
خانة لتخزين عدد الساعات الاضافية التي يعملها بعض الموظفين خارج اطار ساعات عمله الطبيعة
وتريد إضافة دوال جديدة تحسب وتخدم على فكرة الساعات الجديدة وحساب راتبه وتلك الأمور
فبدلًا من أن تقوم بتعديل الكلاس الأساسي EmployeeService
يمكنك أن ترثه في كلاس جديد يدعى SlaveEmployeeService
وتقوم بتعديل وإضافة ما تريده فيه
هكذا حققت المبدأ بطريقة مختلفة على مستوى الكلاسات بحيث أنك ثبت الكلاس الأساسي EmployeeService
ولم تقم بالتعديل عليه وقمت بعمل كلاس جديد SlaveEmployeeService
وقمت بتعديل وإضافة ما تريده فيه
هكذا ينص المبدأ لكن طبقناه بشكل آخر Open to extension and closed to modification
مبدأ الـ Liskov Substitution
هذا المبدأ يقوم بتعريف وتحسين كيف ومتى نقوم بعملية الوراثة بشكل صحيح
أو كيف نقوم ببناء والتعامل مع أي الـ interface
بطريقة صحيحة
بمعنى أن مبدأ الـ Liskov
جاء ليعلمنا كيف نستعمل الوراثة في الكلاسات والـ interface
بشكل صحيح
ينص مبدأ الـ Liskov Substitution
على أنه إذا كان لديك كلاس أساسي وكلاسات ترث منه أو تقوم ببناءه، فيجب أن يكون الـ object
من الكلاس الأساسي قابل للتبديل مع أي object
من الكلاسات التي ترثه أو تبنيه دون نقصان
قد تقول لي الآن بأن هذا ما يحدث عندما يرث كلاس ما كلاس آخر فأنه سوف يرثه دون نقصان
هذا ما نراه في مفهوم الـ Inheritance
بالفعل أو عندما نقوم بعمل implement
لـ interface
، إذًا أين المشكلة ؟
المشكلة أنه أحيانًا قد تجد كلاس يرث من كلاس معين لكن لا يستطيع تنفيذ دالة موجودة في الكلاس الأساسي
أو لا يستطيع عمل override
لها لانه لا يمتلك الوظيفة التي تقدمها هذه الدالة
فمثلا لو كان لديك Interface
يدعى IBird
يمثل الطيور وبه دوال عديدة ومنها دالة fly
ثم قمنا بإنشاء كلاس يدعى Penguin
أي بطريق وجعلناه يقوم بعمل implement
للـ IBird
هنا ستجد أن الـ Penguin
لا يستطيع تنفيذ دالة fly
التي يقدمها الـ IBird
لأن البطريق لا يستطيع الطيران
إذا كان الـ object
لا يقوم بعمل implementation
لدالة واحدة من الـ Interface
فهذا يدل أن نوع هذا الـ object
لا يستحق أن يكون تابع لهذا الـ Interface
إذا فتلك العلاقة ما بين الـ Penguin
والـ IBird
لا تتبع مبدأ الـ Liskov Substitution
لأن الـ Penguin
لا يستطيع تنفيذ دالة fly
التي يقدمها الـ IBird
أن ننشيء interface
مخصص للطيور التي تطير IFlyableBird
و interface
آخر للطيور التي لا تسطيع الطيران IUnFlyableBird
الـ Liskov
يعطيك شرط معين لتقيم العلاقة بين كلاس وكلاس آخر أو كلاس و Interface
وتقيس هل هذه العلاقة صحيحة أم لا أو هل سيتم تطبيقها بشكل صحيح أم لا
وهكذا تبدأ في تنظيم الكلاسات والـ interface
التي لديك بشكل منطقي وصحيح
مثال يناقض المبدأ
لنفترض أننا نمتلك كلاس عادي جدًا جميل يدعى Product
يحتوى على بعض المتغيرات والدوال المتعلقة بالمنتجات بشكل عام
مثل عنوان المنتج تصنيفه وتاريخ الصلاحية
أنا جعلته كلاس عادي للتبسيط لكن يمكنك تخيل نفس المثال على Abstract Class
أو Interface
class Product {
constructor(
private title: string,
private price: number,
private expiredDate: Date
) {}
public getTitle(): string {
return this.title;
}
public getPrice(): number {
return this.price;
}
public getExpiredDate(): Date {
return this.expiredDate;
}
}
ثم لنتخيل أننا نملك دالة تدعى printProductInfo
تستقبل object
من نوع الـ Product
وتقوم بطباعة بعض المعلومات عنه
function printProductInfo(product: Product) {
console.log({
title: product.getTitle(),
price: product.getPrice(),
expiredDate: product.getExpiredDate(),
});
}
هذه الدالة تستقبل object
من نوع الـ Product
ثم تستدعي الدوال التي تتوقعها من الـ Product
مثل getTitle
و getPrice
و getExpiredDate
وركز على كلمة التي تتوقعها
لأن هذا هو مربط الفرس في الموضوع
لنقل أننا لدينا أنواع من المنتجات مثل ألبان ولحوم وملابس وأجهزة كهربائية وغيرها
class MilkProduct extends Product {
constructor(title: string, price: number) {
// set expired date to 7 days from now
let expirationDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
super(title, price, expirationDate);
}
}
class MeatProduct extends Product {
constructor(title: string, price: number) {
// set expired date to 30 days from now
let expirationDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
super(title, price, expirationDate);
}
}
let milkProduct = new MilkProduct('حليب جهينة', 12);
let meatProduct = new MeatProduct('لحم بقري', 350);
لا يوجد أي مشاكل حتى الآن، فقط قمنا بعمل كلاسات جديدة ترث من الـ Product
وهما MilkProduct
و MeatProduct
ووضعنا في كل منهما تاريخ صلاحية مختلف
ثم أنشأنا منتجات منهما مثل milkProduct
و meatProduct
الآن دعونا نستخدم الدالة printProductInfo
لطباعة بعض المعلومات عن المنتجات
printProductInfo(milkProduct);
/*
{
title: 'حليب جهينة',
price: 12,
expiredDate: 2024-01-08T00:00:00.000Z
}
*/
printProductInfo(meatProduct);
/*
{
title: 'لحم بقري',
price: 350,
expiredDate: 2024-01-30T00:00:00.000Z
}
*/
الأمور جيدة ولا تحتوي على أي مشاكل كلا الـ MilkProduct
والـ MeatProduct
يقومان بوراثة Product
دون مشاكل أو نقصان
لكن أنظر إلى ماذا سيحدث عندما ننشيء كلاس خاص بالمنتجات الملابس أو الأجهزة الكهربائية
class ClothesProduct extends Product {
constructor(title: string, price: number) {
// clothes do not have an expiration date
super(title, price, null);
}
// clothes can't use getExpiredDate method
// so we need to override it and throw an error
public getExpiredDate(): Date {
throw new Error('Clothes does not have an expired date');
}
}
let clothesProduct = new ClothesProduct('قميص رجالي', 150);
printProductInfo(clothesProduct); // Error: Clothes does not have an expired date
هنا ستظهر مشكلة كلاس الـ ClothesProduct
يقوم بعمل بوراثة الـ Product
لكن الملابس لا تمتلك تاريخ صلاحية
بالتالي لن يستطيع وراثة أو تنفيذ الدالة getExpiredDate
بشكل صحيح
والدالة printProductInfo
تتوقع أن تكون الدالة getExpiredDate
موجودة في أي object
ينتمي إلى الـ Product
وتستطيع استدعائها بدون مشاكل وتنفذ ما هو متوقع منها
لكنها استقبلت clothesProduct
وهو من نوع ClothesProduct
والذي هو بالفعل من نوع Product
لكنه لا يستطيع تنفيذ الدالة getExpiredDate
بالتالي عندما حاولت الدالة printProductInfo
استدعاء الدالة getExpiredDate
وجدت تستقبل خطأ لم تتوقع ولم يتم ارجاع التاريخ الصلاحية كما كان متوقعًا
وهذا يعني أن العلاقة بين الـ ClothesProduct
والـ Product
لا تتبع مبدأ الـ Liskov Substitution
لأن الـ ClothesProduct
لا يستطيع تنفيذ دالة getExpiredDate
التي يقدمها الـ Product
سترى نفس المشكلة عندما تنشيء كلاس للأجهزة الكهربائية وغيرها
ملحوظة
: قد يكون كلاس يرث كلاس آخر وينفذ كل شيء فيه لكن ينفذها بطريقة تتعارض مع الفكرة الأساسية للكلاس الأساسي
فمثلًا دالة موجودة في الكلاس الأساسي ترجع أرقام موجبة فقط ثم يأتي كلاس يرث منه ويجعل الدالة ترجع أرقام سالبة فقط، كهذا هو خالف الشرط الأساسي لاننا نتوقع من الكلاس الأساسي أرقام موجبة
لكن لو كانت الدالة في الكلاس الأساسي ترجع أرقام موجبة وسالبة ثم أتى كلاس آخر ورث منه وجعل الدالة ترجع أرقام موجبة فقط فلا يوجد مشاكل
لأننا في المثال الثاني نتوقع في الكلاس الأساسي أرقام بشكل عام سواء موجبة وسالبة ولو حصلنا على أرقام سالبة فقط من الكلاس الذي يرثه فهذا ضمن توقعنا لذا فهو يوافق مبدأ الـLiskov
أما في المثال الأول فأننا نتوقع أرقام موجبة فقط في الكلاس الأساسي فعندما نحصل على أرقام سالبة من الكلاس الذي يرثه فهذا خارج عن توقعنا ويعارض مبدأ الـLiskov
مثال يوافق المبدأ
الحل قد يكون في تغير جزري للكود سواء بتغير الـ Product
أو بإنشاء interface
مختلفة لكل نوع من المنتجات
مثلا IFoodProduct
و IClothesProduct
و IElectricProduct
وغيرها
نحن سنجعل الأمور بسيطة أولًا يمكننا إبقاء Product
وجعله Abstract Class
ليمثل الشكل العام للمنتجات والمتغيرات العامة التي تحتويها
abstract class Product {
constructor(private title: string, private price: number) {}
public getTitle(): string {
return this.title;
}
public getPrice(): number {
return this.price;
}
public abstract printInfo(): void;
}
قمنا بعمل تغيرات بسيطة على الـ Product
وتخلصنا من الـ expirationDate
لأنها اتضح لنا ان ليس كل المنتجات لها تاريخ صلاحية
ثم قمنا بإنشاء دالة printInfo
داخل الـ Product
وجعلناها abstract
لكي يتم تنفيذها في كل كلاس يرث من الـ Product
قد تقوم أنت بتغيرات جزرية وهذا وارد لكل تصل لشكل أفضل وتنظيم مقبول للكود الخاص بلك
الآن لنعرف الكلاسات التي ستمثل المنتجات المختلفة
class MilkProduct extends Product {
private expiredDate: Date;
constructor(title: string, price: number) {
super(title, price);
// set expired date to 7 days from now
this.expirationDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
}
public printInfo(): void {
console.log({
title: this.getTitle(),
price: this.getPrice(),
expiredDate: this.expiredDate,
});
}
}
let milkProduct = new MilkProduct('حليب جهينة', 12);
milkProduct.printInfo();
/*
{
title: 'حليب جهينة',
price: 12,
expiredDate: 2024-01-08T00:00:00.000Z
}
*/
لاحظ كيف أنشأنا المتغير expirationDate
داخل كلاس الـ MilkProduct
فقط
ثم قمنا بتعديل الدالة printInfo
لتطبع المعلومات الخاصة بالـ MilkProduct
بشكل صحيح
وهكذا يمكنك أن تنشئ كلاسات أخرى للمنتجات الأخرى مثل اللحوم والملابس والأجهزة الكهربائية وغيرها ونعرف
class ClothesProduct extends Product {
private brand: string;
constructor(title: string, price: number, brand: string) {
super(title, price);
this.brand = brand;
}
public printInfo(): void {
console.log({
title: this.getTitle(),
price: this.getPrice(),
brand: this.brand,
});
}
}
let clothesProduct = new ClothesProduct('قميص رجالي', 150, 'Sutra');
clothesProduct.printInfo();
/*
{
title: 'قميص رجالي',
price: 150,
brand: 'Sutra'
}
*/
لاحظ أن كلاس منتجات الملابس ClothesProduct
لم يعد به أي مشاكل لأنه لم يعد مجبرًا على استعمال أو تنفيذ getExpiredDate
كما في السابق لانه لم تعد ضمن الـ Product
وأصبح لديه دالة printInfo
الخاصة به والتي تقوم بطباعة المعلومات الخاصة بالملابس
وهكذا يمكنك أن تنشئ كلاسات أخرى للمنتجات الأخرى بدون أي مشاكل
مبدأ الـ Interface Segregation
هذا المبدأ يقول لك أنه يجب أن تقوم بتقسيم الـ interface
الخاص بك إلى عدة interface
صغيرة ومتعددة بدلاً من interface
كبير وواحد
لأن مشكلة الـ interface
أنه يجبرك على استخدام متغيرات وتنفيذ دوال قد لا تريدها
وهذا وارد في الـ interface
الكبير الذي يضم العديد من الدوال والمتغيرات فستجد نفسك مجبور على تنفيذ واستخدام أشياء لا تريدها
لذا فالمبدأ يركز على تقسيم الـ interface
الكبير إلى عدة interface
صغيرة وكل واحدة تركز على شيء معين
تخيل معي أنك قمت بعمل interface
يدعى Person
وقمت بتعريف بعض المتغيرات والدوال التي يجب أن يحتويها كل object
ينتمي لعائلة interface
الـ Person
ووضعت فيه متغيرات ودوال مثل name
, workplace
, salary
, getName
, getWorkplace
, getSalary
وغيرها
تخيل أنك تريد استخدام هذا الـ interface
في كلاسات مختلفة مثل Employee
, Customer
, Admin
, Student
وغيرها
وتجد نفسك مجبرًا على تنفيذ واستخدام الدوال التي لا تحتاجها في كل كلاس
مثل workplace
و salary
لا تحتاجها في الكلاسات Customer
و Student
لانهم ليس لديهم مكان عمل او راتب
ولكنك مجبر على تنفيذها لأنها موجودة في الـ interface
الـ Person
تلك المشكلة ظهرت معنا في مبدأ الـ Liskov Substitution
عندما كان كلاس ClothesProduct
مجبر على تنفيذ دالة getExpiredDate
برغم أنه لا يحتاجها
لذا فالمبدأ يهتم بتلك المشكلة لكن من ناحية الـ interface
بتقسيمه إلى عدة interface
صغيرة ليمنع تلك المشكلة ولا يتم اجبار الكلاسات على تنفيذ واستخدام الدوال التي لا تحتاجها
يمكننا أن نقول أن تحقيق مبدأ الـ Interface Segregation
يساعد على تحقيق مبدأ الـ Liskov Substitution
وتفادي مشاكله إلى حد ما
لكن مبدأ Interface Segregation
يركز على تقسيم الـ interface
بشكل عام وتجنب الإجبار على استخدام الدوال التي لا تحتاجها
مثال يناقض المبدأ
تخيل معين أننا لدينا interface
يدعى IMailData
يحتوي على العديد من الأمور التي قد نستخدمها في إرسال أي شيء عبر البريد الإلكتروني
interface IMailData {
from: string;
to: string;
senderEmail: string;
receiverEmail: string;
subject: string;
message: string;
otp: string;
otpExpiredAt: Date;
attachments: string[];
// ... etc.
}
ثم لنفترض أننا لدينا دوال تستقبل هذا الـ interface
مثل sendReport
, sendInfo
, sendOTP
class EmailService {
public sendInfo(mailData: IMailData) {
// I don't need otp or attachments
}
public sendReport(mailData: IMailData) {
// I don't need otp
}
public sendOTP(mailData: IMailData) {
// I don't need attachments
}
// ... etc.
}
لاحظ أن كل دالة تستقبل نفس الـ interface
وهو يحتوي على العديد من الأمور التي قد لا تحتاجها كل الدوال
لكن بسبب interface
الكبير فأنت مجبر على ارسال كل البيانات التي يريدها حتى لو لم تستخدمها داخل الدالة
فسينتهي بك الأمر بكود يبدو كهذا
let emailService = new EmailService();
emailService.sendInfo({
from: 'Ahmed El-Tabarani';
to: 'إلى من يهمه الأمر';
senderEmail: 'eltabaraniahmed@gmail';
receiverEmail: '[email protected]';
subject: 'مقالة جديدة';
message: 'مقالة عن مبادئ يُقال أنها مهمة';
attachments: null; // i don't need it
otp: null; // I don't need it
otpExpiredAt: null; // I don't need it
// ... etc.
})
لاحظ أننا قمنا بإرسال كامل البيانات التي يطلبها من الـ interface
بشكل اجباري
لذا احيانًا قد تجد ترسل null
للبيانات التي لا تحتاجها
مثال يوافق المبدأ
الحل بسيط وهو تقسيم الـ interface
الكبير إلى عدة interface
صغيرة وكل واحدة تركز على شيء معين
interface IBasicMailData {
from: string;
to: string;
senderEmail: string;
receiverEmail: string;
subject: string;
message: string;
}
interface IOTPData extends IBasicMailData {
otp: string;
otpExpiredAt: Date;
}
interface IReportMailData extends IBasicMailData {
attachments: string[];
}
ثم يمكنك استخدام الـ interface
التي تحتاجها فقط داخل كل دالة
class EmailService {
public sendInfo(infoData: IBasicMailData) {
// now i have all the data i need in IBasicMailData
}
public sendReport(reportData: IReportMailData) {
// now i have all the data i need in IReportMailData
}
public sendOTP(otpData: IOTPData) {
// now i have all the data i need in IOTPData
}
// ... etc.
}
let emailService = new EmailService();
emailService.sendInfo({
from: 'Ahmed El-Tabarani';
to: 'إلى من يهمه الأمر';
senderEmail: 'eltabaraniahmed@gmail';
receiverEmail: '[email protected]';
subject: 'مقالة جديدة';
message: 'مقالة عن مبادئ يُقال أنها مهمة';
})
هكذا أصبح لديك القدرة على إرسال البيانات التي تحتاجها فقط داخل كل دالة دون الحاجة لإرسال البيانات الأخرى التي لا تحتاجها
وهذا يعني أنك تقوم بتطبيق مبدأ الـ Interface Segregation
بشكل صحيح
وهو كما ترى مبدأ بسيط وجميل يساعدك على إنشاء interface
صغيرة تخدمك في جزئية معينة دون أن يتم اجبارنا على استخدام البيانات التي لا نحتاجها
يمكنك أن تتخيل مبدأ الـ Interface Segregation
على أنه مبدأ الـ Single Responsibility
على مستوى الـ interface
مبدأ الـ Dependency Inversion
هذا المبدأ يركز على منع الاعتماد المباشر بين الكلاسات
بل يجب أن تعتمد على الـ Interface
أو الـ Abstract Class
بمعنى أنه يجب أن تعتمد على الـ Interface
بدلاً من الكلاسات المحددة بذاتها
فإن كان هناك كلاس يدعى NotificationService
ويعتمد على كلاس EmailService
و SMSService
بشكل مباشر فهذا خطأ
لأنه فرضًا أننا تخلينا عن الـ EmailService
أو أضفنا أنواع جديدة من الخدمات مثل PushNotificationService
أو WhatsAppService
هل ستذهب وتعدل الـ NotificationService
ليعتمد على الخدمات الجديدة؟ ماذا لو اضفنا 10
خدمات هل ستنشيء object
لكل خدمة داخل الـ NotificationService
؟
بالطبع لا، لذا يجب أن تجعل الـ NotificationService
يعتمد على الـ interface
أو abstract class
بدلاً من الكلاسات المحددة بذاتها
هذا interface
قد يدعى INotifiableProvider
ويحتوي على دالة أساسية مثل notify
ثم تجعل الكلاس NotificationService
يعتمد على object
من هذا الـ interface
بدلاً من الكلاسات المحددة بذاتها
ثم تقوم بإنشاء كلاسات تبني هذا الـ interface
الـ INotifiableProvider
مثل EmailService
و SMSService
و PushNotificationService
و WhatsAppService
وغيرها
هكذا يمكنك أن تضيف أي خدمة جديدة دون الحاجة لتعديل الـ NotificationService
الأمر مشابه لمبدأ الـ Open/Closed
بعض الشيء لكنه يركز على الاعتماد المباشر بين الكلاسات وفصلها
مثال يناقض المبدأ
تخيل أن لديك كلاس يدعى OrderService
مسؤول عن طلبات المنتجات
وهذا الكلاس يعتمد على كلاسات محددة بذاتها مثل FoodProductService
و ClothesProductService
و ElectricProductService
class OrderService {
public foodProductService: FoodProductService;
public clothesProductService: ClothesProductService;
public electricProductService: ElectricProductService;
constructor() {
foodProductService = new FoodProductService();
clothesProductService = new ClothesProductService();
electricProductService = new ElectricProductService();
}
public orderFoodProduct() {
foodProductService.order();
}
public orderClothesProduct() {
clothesProductService.order();
}
public orderElectricProduct() {
electricProductService.order();
}
}
let orderService = new OrderService();
// it will create three objects
// FoodProductService, ClothesProductService, ElectricProductService
// but we need only need FoodProductService here
orderService.orderFoodProduct();
لاحظ أن الـ OrderService
يعتمد على كلاسات محددة بذاتها مثل FoodProductService
و ClothesProductService
و ElectricProductService
فهو يقوم بإنشاء object
من كل كلاس داخل الـ constructor
ويعتمد عليها
ويقوم بعمل دالة مخصصة للتعامل مع كل منتج بشكل منفصل
أظنك لاحظت العديد من المشاكل هنا منها
- ماذا إن أردنا استخدام
object
منFoodProductService
فقط، لما يتم إنشاءobject
منClothesProductService
وElectricProductService
ونحن قد لا نحتاجهما - ماذا إن أردنا استخدام منتجات أخرى مثل
BooksProductService
هل سنقوم بإضافةobject
جديد لكل منتج جديد؟ ودوال أخرى مخصصة له ونعدل هنا وهناك ؟ - ماذا إن أردنا الاستغناء عن نوع معين مثل
ClothesProductService
هل سنذهب إلىOrderService
ونقوم بالتخلص من كل شيء متعلق به ونعدل الكلاسات والدوال ؟
هنا العلاقة بين الـ OrderService
وبين الـ FoodProductService
والـ ClothesProductService
والـ ElectricProductService
لا تتبع مبدأ الـ Dependency Inversion
لأن الـ OrderService
يعتمد على هذه الكلاسات اعتماد كلي وبشكل مباشر
مثال يوافق المبدأ
الحل بسيط وهو جعل الـ OrderService
يعتمد على interface
بدلاً من الكلاسات المحددة بذاتها
يمكن للـ OrderService
أن يستقبل object
من الـ interface
من خلال الـ constructor
ويعتمد عليه
ويمكنك أن تنشئ كلاسات تنفذ هذا الـ interface
ثم عندما تستدعي الـ OrderService
تقوم بإرسال object
من كلاس المنتجات التي تريده فقط
لنقم بإنشاء interface
يدعى IProductService
ويحتوي على دالة order
interface IProductService {
order(): void;
// ... etc.
}
الآن يمكنك أن تنشئ كلاسات تنفذ هذا الـ interface
مثل FoodProductService
و ClothesProductService
و ElectricProductService
وغيرها
class FoodProductService implements IProductService {
public order(): void {
console.log('Ordering food product');
}
}
class ClothesProductService implements IProductService {
public order(): void {
console.log('Ordering clothes product');
}
}
class ElectricProductService implements IProductService {
public order(): void {
console.log('Ordering electric product');
}
}
الآن يمكننا تعديل الـ OrderService
ليعتمد على الـ IProductService
الـ interface
بدلاً من الكلاسات المحددة بذاتها
class OrderService {
public productService: IProductService;
constructor(productService: IProductService) {
this.productService = productService;
}
public orderProduct() {
this.productService.order();
}
}
الآن يمكنك أن تنشئ object
من الـ OrderService
وترسل أي object
من ينتمي إلى عائلة الـ IProductService
let foodProductService = new FoodProductService();
let orderService = new OrderService(foodProductService);
orderService.orderProduct(); // Ordering food product
///////////////////////////////////////
let clothesProductService = new ClothesProductService();
let orderService = new OrderService(clothesProductService);
orderService.orderProduct(); // Ordering clothes product
///////////////////////////////////////
let electricProductService = new ElectricProductService();
let orderService = new OrderService(electricProductService);
orderService.orderProduct(); // Ordering electric product
لاحظ كيف أننا عندما ننشيء object
من الـ OrderService
نقوم بإرسال فقط object
من الكلاس المنتج الذي نريده
ويكون الـ OrderService
يعتمد على الـ IProductService
بغض النظر عن نوعه، اعتمادية مجردة من أي تفاصيل
هكذا يمكنك أن تضيف أي خدمة جديدة في كالاس جديد دون الحاجة لتعديل أي شيء في الـ OrderService
هذا يجعل الكود أكثر مرونة وسهولة في الصيانة
خاتمة وملخص مع أمثلة مختلفة
تذكر أن مبادئ الـ 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