Робърт К. Мартин създаде набор от насоки за проектиране на софтуер, известни като принципите на SOLID. Тези насоки помагат на програмистите да създават надеждни, устойчиви на промени приложения, като същевременно намаляват разходите за актуализации. Ще разгледаме как да използваме тези принципи в JavaScript в тази статия и ще предоставим примерни кодове, за да илюстрираме нашите точки.

Какви са принципите на SOLID?

  • Принцип на единна отговорност
  • Принцип отворено-затворено
  • Принцип на заместване на Лисков
  • Принцип на разделяне на интерфейса
  • Принцип на инверсия на зависимостта

Принцип на единна отговорност

И така, какъв е принципът на единната отговорност? Единичен отговорен принцип е един от петте SOLID принципа и се прилага към класове, компоненти и микроуслуги. Правилото, което ни насочва към този принцип е: „класовете трябва да имат единствена отговорност — един клас не трябва да се променя по повече от една причина.“

Нека разгледаме прост пример, за да разберем този принцип. Следният кодов фрагмент на JavaScript има клас с име UserSettingsи една функция, която ще промени потребителските настройки, и друга, която ще провери идентификационните данни на потребителя.

//BAD
class UserSettings {
 constructor (user) {
   this.user = user;
 }

 changeSettings (settings) {
   if (this.verifyCredentials()) {
     //..
   }
 }

 verifyCredentials () {
   //..
 }
}

Както споменах по-горе в SRP един клас трябва да има една единствена отговорност. В този случай UserSettingsclass се използва за две различни отговорности, за промяна на настройките на потребителя и за проверка на идентификационните данни на потребителя. Вместо да използваме един клас за множество отговорности, ще го разделим на два различни класа (UserSettings и UserAuth). Единият клас е за промяна на настройките, а другият ще проверява потребителските идентификационни данни.

//Good
class UserAuth {
 constructor (user) {
   this.user = user;
 }

 verifyCredentials () {
   //..
 }
}

class UserSettings {
 cosntructor (user) {
   this.user = user;
   this.auth = new UserAuth(user);
 }

 changeSettings (settings) {
   if (this.auth.verifyCredintials()) {
     //..
   }
 }
}

Принцип отворено-затворено

OCP е вторият принцип от принципите на SOLID и казва: в обектно-ориентираното програмиране, принципът отворено–затворен (OCP) гласи „софтуерни обекти (класове, модули, функции и т.н. )трябва да бъде отворен за разширение, но затворен за модификация”; това означава, че такъв обект може да позволи неговото поведение да бъде разширено, без да модифицира своя изходен код.

По-долу имаме клас, който се използва за изчисляване на заплатите на разработчиците в една компания.

class ManageDeveloperSalaries {
 constructor() {
   this.salaryRates = [
     { id: 1, seniority: 'intermediate', rate: 200 },
     { id: 2, seniority: 'senior', rate: 300 },
   ];
 }

 calculateSalaries(devId, hoursWorked) {
   let salaryObject = this.salaryRates.find((o) => o.id === devId);
   return hoursWorked * salaryObject.rate;
 }
}

const salary = new ManageDeveloperSalaries();
console.log("Salary : ", salary.calculateSalaries(1, 100));

Да предположим, че в тази компания имаме нов разработчик със старшинство младши. Чрез OCP не можем да модифицираме масива salaryRates, вместо това, като модифицираме масива, ще разширим функционалност на класа ManageDeveloperSalariesчрездобавяне на нов метод, който ще добави нова ставка на заплатата за младши разработчици.

class ManageDeveloperSalaries {
 constructor() {
   this.salaryRates = [
     { id: 1, seniority: 'intermediate', rate: 200 },
     { id: 2, seniority: 'senior', rate: 300 },
   ];
 }

 calculateSalaries(devId, hoursWorked) {
   let salaryObject = this.salaryRates.find((o) => o.id === devId);
   return hoursWorked * salaryObject.rate;
 }

addSalaryRate(id, seniority, rate) {
   this.salaryRates.push({ id: id, seniority: seniority, rate: rate });
 }
}

const salary = new ManageDeveloperSalaries();
console.log("Salary : ", salary.calculateSalaries(1, 100));

Принцип на заместване на Лисков

Принципът на заместване на Лисков (LSP) е основен принцип в обектно-ориентираното програмиране, кръстен на Барбара Лисков. Той гласи, че ако дадена програма използва базов клас, тя трябва да може да замени екземпляри на този базов клас с екземпляри на който и да е от неговите производни класове, без да се засяга коректността на програмата.

С други думи, всеки дъщерен клас трябва да може да замени своя родителски клас, без да причинява неочаквано поведение или да нарушава договорите, дефинирани от родителския клас. Този принцип гарантира, че поведението на базовия клас се запазва в неговите производни класове, насърчавайки повторното използване на кода, разширяемостта и устойчивостта.

Нека разгледаме лош пример, който нарушава принципа на заместване на Лисков:

class Bird {
  fly() {
    // common implementation for flying
  }
}

class Ostrich extends Bird {
  fly() {
    throw new Error("Ostriches cannot fly!");
  }
}

В този пример класът Bird има метод, наречен fly(), който представлява обичайното поведение на летене за различни видове птици. Въпреки това, когато става дума за клас Ostrich, той нарушава LSP, като хвърля грешка в своя метод fly(). Правейки това, класът Ostrich не успява да се държи според очакванията за общ клас Bird. Ако трябваше да заменим екземпляр Ostrich с екземпляр Bird във всяка част от кода, която разчита на способността за летене, това би довело до неочаквани грешки или неправилно поведение.

Нека разгледаме пример, който отговаря на принципа на заместване на Лисков:

class Shape {
  calculateArea() {
    // common implementation for calculating area
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  calculateArea() {
    return Math.PI * this.radius * this.radius;
  }
}

В този пример класът Shape дефинира метод, наречен calculateArea(), който изчислява площта на фигура. Класовете Rectangle и Circle наследяват от Shape и заместват метода calculateArea() с техните специфични реализации. Според LSP можем безопасно да заменим екземпляри на Shape с екземпляри на Rectangle или Circle, без това да повлияе на коректността на програмата. Това придържане към LSP позволява полиморфизъм и гъвкавост в кода.

Принцип на разделяне на интерфейса

Друго правило в обектно-ориентираното програмиране, Принципът на разделяне на интерфейса (ISP), набляга на създаването на унифицирани, фини интерфейси. Според това изявление клиентите не трябва да разчитат на интерфейси, които не използват.

С по-прости думи, този принцип предполага, че интерфейсите трябва да бъдат специфични за нуждите на клиентите, които ги използват, вместо да имат големи, монолитни интерфейси, които обхващат всички възможни поведения. Като поддържаме интерфейсите фокусирани и детайлни, можем да предотвратим ненужни зависимости и да гарантираме, че клиентите зависят само от методите, от които действително се нуждаят.

Нека да видим пример, който не отговаря на принципа за разделяне на интерфейса:

// Interface
class Machine {
  print() {
    throw new Error("Method not implemented");
  }

  scan() {
    throw new Error("Method not implemented");
  }

  fax() {
    throw new Error("Method not implemented");
  }
}

class AllInOnePrinter implements Machine {
  print() {
    // Print the document
  }

  scan() {
    // Scan the document
  }

  fax() {
    // Fax the document
  }

В този пример имаме Machine интерфейс, който включва методи за печат, сканиране и изпращане на факс. Класът AllInOnePrinter реализира този интерфейс, но може да принуди клиентите да зависят от методи, от които не се нуждаят. Например, ако клиент иска само да отпечата документ, той все още е принуден да има достъп до методите за сканиране и изпращане на факс.

Това нарушава принципа за разделяне на интерфейса, тъй като клиентите са принудени да зависят от методи, които не изискват. Вместо това трябва да разделим големия интерфейс на по-малки, по-специфични интерфейси, позволявайки на клиентите да разчитат само на методите, от които се нуждаят.

Нека да видим пример, който отговаря на принципа за разделяне на интерфейса:

// Interface
class Printable {
  print() {
    throw new Error("Method not implemented");
  }
}

class Document implements Printable {
  print() {
    // Print the document
  }

  edit() {
    // Edit the document
  }
}

class Photo implements Printable {
  print() {
    // Print the photo
  }

  zoom() {
    // Zoom in/out the photo
  }
}

В този пример имаме Printable интерфейс, който дефинира един метод print(). Класовете Document и Photo реализират този интерфейс и предоставят свои собствени специфични реализации за метода print(). Всеки внедряващ клас зависи само от методите, изисквани от интерфейса.

Следвайки принципа за разделяне на интерфейса, ние избягваме ситуация, при която клиенти, които трябва само да отпечатват документи, са принудени да зависят от методи, свързани с манипулиране на снимки (като zoom()). Клиентите могат да разчитат на интерфейса Printable, без да се натоварват с ненужна функционалност. Този принцип насърчава по-модулна и гъвкава структура на кода.

Принцип на инверсия на зависимостта

Последният от петте принципа Принцип на инверсия на зависимостта. Принципът на инверсия на зависимостта (DIP) е принцип в обектно-ориентираното програмиране, който подчертава значението на отделянето на модули на високо ниво от модули на ниско ниво чрез въвеждане на абстракционен слой. Той гласи, че модулите от високо ниво не трябва да зависят директно от модулите от ниско ниво; и двете трябва да зависят от абстракции.

Сега нека разгледаме лош пример, който нарушава принципа на инверсия на зависимостта:

// High-level module
class UserManager {
  constructor() {
    this.userRepository = new UserRepository();
  }

  getUser(userId) {
    return this.userRepository.getUser(userId);
  }
}

// Low-level module
class UserRepository {
  getUser(userId) {
    // Retrieve the user from the database
  }
}

В този пример класът UserManager директно създава екземпляр на UserRepository, използвайки ключовата дума new. Това нарушава принципа на инверсия на зависимостта, тъй като модулът от високо ниво е тясно свързан с модула от ниско ниво. Ако искахме да променим внедряването на базата данни или да използваме различен източник на данни, ще трябва да модифицираме класа UserManager, което го прави негъвкав и по-труден за поддръжка.

Сега нека разгледаме добър пример, който удовлетворява принципа на инверсия на зависимостта:

// High-level module
class UserManager {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  getUser(userId) {
    return this.userRepository.getUser(userId);
  }
}

// Low-level module
class UserRepository {
  getUser(userId) {
    // Retrieve the user from the database
  }
}

В този пример UserManager е модул от високо ниво, който трябва да извлече потребителска информация. Вместо пряка зависимост от UserRepository имплементацията (модул от ниско ниво), тя зависи от абстракция, която в този случай е UserRepository клас.

Класът UserManager приема екземпляр на UserRepository като параметър на конструктора. Разчитайки на абстракцията, той може да извика метода getUser(), без да знае конкретните подробности за изпълнението. Това позволява гъвкавост, тъй като различни реализации на интерфейса UserRepository (като фалшиво хранилище за тестване или различна реализация на база данни) могат лесно да бъдат заменени, без да се засяга класът UserManager.

Следвайки принципа на инверсия на зависимостта, можем да постигнем хлабаво свързване между модулите, да подобрим разширяемостта на кода и да улесним тестването и поддръжката. Абстракциите играят решаваща роля в разделянето на модулите на високо и ниско ниво и насърчаването на по-модулна и гъвкава архитектура.