Принципы SOLID — это набор программных разработок, представленных Робертом К. Мартином. Эти принципы помогают разработчикам создавать надежные, удобные в сопровождении приложения при минимальных затратах на внесение изменений. В этой статье мы обсудим, как использовать эти принципы в JavaScript, и продемонстрируем их на примерах кода.

Принципы SOLID — это набор программных разработок, представленных Робертом С. «Дядей Бобом» Мартином. Эти принципы помогают разработчикам создавать надежные, удобные в сопровождении приложения при минимальных затратах на внесение изменений.

Хотя принципы SOLID часто используются в объектно-ориентированном программировании, мы можем использовать их и в других языках, таких как JavaScript. В этой статье мы обсудим, как использовать принципы SOLID в JavaScript, и продемонстрируем их на примерах кода.

Каковы принципы SOLID?

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

Класс, модуль или функция должны отвечать только за одного актора. Таким образом, у него должна быть одна и только одна причина для изменения.

Принцип единой ответственности — один из самых простых принципов SOLID. Однако разработчики часто неправильно это понимают, думая, что модуль должен делать только одну вещь.

Давайте рассмотрим простой пример, чтобы понять этот принцип. Следующий фрагмент кода JavaScript содержит класс с именем ManageEmployee и несколько функций для управления сотрудниками.

class ManageEmployee {

  constructor(private http: HttpClient)
  SERVER_URL = 'http://localhost:5000/employee';

  getEmployee (empId){
     return this.http.get(this.SERVER_URL + `/${empId}`);
  }

  updateEmployee (employee){
     return this.http.put(this.SERVER_URL + `/${employee.id}`,employee);
  }

  deleteEmployee (empId){
     return this.http.delete(this.SERVER_URL + `/${empId}`);
  }

  calculateEmployeeSalary (empId, workingHours){
    var employee = this.http.get(this.SERVER_URL + `/${empId}`);
    return employee.rate * workingHours;
  }

}

Предыдущий код на первый взгляд выглядит совершенно нормально, и многие разработчики будут следовать тому же подходу без каких-либо проблем. Однако, поскольку он отвечает за двух акторов, этот класс нарушает принцип единой ответственности. Функции getEmployee(), updateEmployee() и deleteEmployee() напрямую связаны с управлением персоналом, а функция calculateEmployeeSalary() связана с управлением финансами.

В будущем, если вам понадобится обновить функцию для отдела кадров или финансового отдела, вам придется изменить класс ManageEmployee, затрагивающий обоих акторов. Таким образом, класс ManageEmployee нарушает принцип единой ответственности. Вам нужно будет разделить функциональные возможности, связанные с отделами кадров и финансами, чтобы сделать код совместимым с принципом единой ответственности. Следующий пример кода демонстрирует это.

class ManageEmployee {

  constructor(private http: HttpClient)
  SERVER_URL = 'http://localhost:5000/employee';

  getEmployee (empId){
     return this.http.get(this.SERVER_URL + `/${empId}`);
  }

  updateEmployee (employee){
     return this.http.put(this.SERVER_URL + `/${employee.id}`,employee);
  }

  deleteEmployee (empId){
     return this.http.delete(this.SERVER_URL + `/${empId}`);
  }

}

class ManageSalaries {

  constructor(private http: HttpClient)
  SERVER_URL = 'http://localhost:5000/employee';

  calculateEmployeeSalary (empId, workingHours){
    var employee = this.http.get(this.SERVER_URL + `/${empId}`);
    return employee.rate * workingHours;
  }

}

Принцип открытия-закрытия

Функции, модули и классы должны быть расширяемыми, но не модифицируемыми.

Это важный принцип, которого следует придерживаться при реализации крупномасштабных приложений. В соответствии с этим принципом мы должны иметь возможность легко добавлять новые функции в приложения, но не должны вносить критические изменения в существующий код.

Например, предположим, что мы реализовали функцию calculateSalaries(), которая использует массив с определенными рабочими ролями и почасовыми ставками для расчета заработной платы.

class ManageSalaries {
  constructor() {
    this.salaryRates = [
      { id: 1, role: 'developer', rate: 100 },
      { id: 2, role: 'architect', rate: 200 },
      { id: 3, role: 'manager', rate: 300 },
    ];
  }

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

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

Прямая модификация массива зарплаты будет нарушать принцип открытости-закрытости. Например, предположим, что вам нужно расширить расчет заработной платы для новой роли. В этом случае вам нужно создать отдельный метод для добавления ставок заработной платы в массив зарплатных ставок без внесения изменений в исходный код.

class ManageSalaries {
  constructor() {
    this.salaryRates = [
      { id: 1, role: 'developer', rate: 100 },
      { id: 2, role: 'architect', rate: 200 },
      { id: 3, role: 'manager', rate: 300 },
    ];
  }

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

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

const mgtSalary = new ManageSalaries();
mgtSalary.addSalaryRate(4, 'developer', 250);
console.log('Salary : ', mgtSalary.calculateSalaries(4, 100));

Принцип подстановки Лисков

Пусть P(y) — доказуемое свойство объектов y типа A. Тогда P(x) должно быть истинным для объектов x типа B, где B является подтипом A.

В Интернете вы найдете разные определения принципа замещения Лискова, но все они подразумевают одно и то же. Проще говоря, принцип Лисков гласит, что мы не должны заменять родительский класс его подклассами, если они создают неожиданное поведение в приложении.

Например, рассмотрим класс с именем Animal, который включает функцию с именем eat().

class Animal{
  eat() {
    console.log("Animal Eats")
  }
}

Теперь я расширим класс Animal до нового класса Bird с функцией fly().

class Bird extends Animal{
  fly() {
    console.log("Bird Flies")
  }
}

var parrot = new Bird();
parrot.eat();
parrot.fly();

В предыдущем примере я создал объект с именем parrot из класса Bird и вызвал оба метода eat() и fly(). Поскольку попугай способен на оба этих действия, расширение класса Animal до класса Bird не нарушает принцип Лискова.

Теперь давайте расширим класс Bird и создадим новый класс с именем Ostrich.

class Ostrich extends Bird{
  console.log("Ostriches Do Not Fly")
}

var ostrich = new Ostrich();
ostrich.eat();
ostrich.fly();

Это расширение класса Bird нарушает принцип Лискова, поскольку страусы не могут летать — это может привести к неожиданному поведению приложения. Лучший способ решить эту проблему — расширить класс Ostrich из класса Animal.

class Ostrich extends Animal{

  walk() {
    console.log("Ostrich Walks")
  }

}

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

Клиентов не следует заставлять зависеть от интерфейсов, которые они никогда не будут использовать.

Этот принцип связан с интерфейсами и фокусируется на разбиении больших интерфейсов на более мелкие. Например, предположим, что вы идете в автошколу, чтобы научиться водить машину, и они дают вам большой набор инструкций по вождению автомобилей, грузовиков и поездов. Поскольку вам нужно только научиться водить машину, вся остальная информация вам не нужна. Автошкола должна разделить инструкции и просто дать вам инструкции, относящиеся к автомобилям.

Поскольку JavaScript не поддерживает интерфейсы, трудно применить этот принцип в приложениях на основе JavaScript. Однако для реализации этого мы можем использовать композиции JavaScript. Композиции позволяют разработчикам добавлять функциональные возможности к классу без наследования всего класса. Например, предположим, что существует класс DrivingTest с двумя функциями startCarTest и startTruckTest. Если мы расширим класс DrivingTest для CarDrivingTest и TruckDrivingTest, нам придется заставить оба класса реализовать функции startCarTest и startTruckTest.

Class DrivingTest {
  constructor(userType) {
    this.userType = userType;
  }

  startCarTest() {
    console.log(“This is for Car Drivers”’);
  }

  startTruckTest() {
    console.log(“This is for Truck Drivers”);
  }
}

class CarDrivingTest extends DrivingTest {
  constructor(userType) {
    super(userType);
  }

  startCarTest() {
    return “Car Test Started”;
  }

  startTruckTest() {
    return null;
  }
}

class TruckDrivingTest extends DrivingTest {
  constructor(userType) {
    super(userType);
  }

  startCarTest() {
    return null;
  }

  startTruckTest() {
    return “Truck Test Started”;
  }
}

const carTest = new CarDrivingTest(carDriver );
console.log(carTest.startCarTest());
console.log(carTest.startTruckTest());

const truckTest = new TruckDrivingTest( ruckdriver );
console.log(truckTest.startCarTest());
console.log(truckTest.startTruckTest());

Однако эта реализация нарушает принцип разделения интерфейса, поскольку мы заставляем оба расширенных класса реализовывать обе функции. Мы можем решить эту проблему, используя композиции для присоединения функций к требуемым классам, как показано в следующем примере.

Class DrivingTest {
  constructor(userType) {
    this.userType = userType;
  }
}

class CarDrivingTest extends DrivingTest {
  constructor(userType) {
    super(userType);
  }
}

class TruckDrivingTest extends DrivingTest {
  constructor(userType) {
    super(userType);
  }
}

const carUserTests = {
  startCarTest() {
    return ‘Car Test Started’;
  },
};

const truckUserTests = {
  startTruckTest() {
    return ‘Truck Test Started’;
  },
};

Object.assign(CarDrivingTest.prototype, carUserTests);
Object.assign(TruckDrivingTest.prototype, truckUserTests);

const carTest = new CarDrivingTest(carDriver );
console.log(carTest.startCarTest());
console.log(carTest.startTruckTest()); // Will throw an exception

const truckTest = new TruckDrivingTest( ruckdriver );
console.log(truckTest.startTruckTest());
console.log(truckTest.startCarTest()); // Will throw an exception

Теперь carTest.startTruckTest(); вызовет исключение, поскольку функция startTruckTest() не назначена классу CarDrivingTest.

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

Модули более высокого уровня должны использовать абстракции. Однако они не должны зависеть от низкоуровневых модулей.

Инверсия зависимостей — это разделение вашего кода. Следование этому принципу даст вам гибкость для масштабирования и изменения вашего приложения на самых высоких уровнях без каких-либо проблем.

Что касается JavaScript, нам не нужно думать об абстракциях, поскольку JavaScript — это динамический язык. Однако нам нужно убедиться, что модули более высокого уровня не зависят от модулей более низкого уровня.

Давайте рассмотрим простой пример, чтобы объяснить, как работает инверсия зависимостей. Предположим, вы использовали API электронной почты Yahoo в своем приложении, и теперь вам нужно изменить его на API Gmail. Если вы реализовали контроллер без инверсии зависимостей, как в следующем примере, вам необходимо внести некоторые изменения в контроллер. Это связано с тем, что несколько контроллеров используют API Yahoo, и вам нужно найти каждый экземпляр и обновить его.

class EmailController { 
  sendEmail(emailDetails) { 
    // Need to change this line in every controller that uses YahooAPI.const response = YahooAPI.sendEmail(emailDetails); 
    if (response.status == 200) { 
       return true;
    } else {
       return false;
    }
  }
}

Принцип инверсии зависимостей помогает разработчикам избежать таких дорогостоящих ошибок, перемещая в этом случае часть обработки API электронной почты на отдельный контроллер. Затем вам нужно только изменить этот контроллер всякий раз, когда происходят изменения в API электронной почты.

class EmailController { 
  sendEmail(emailDetails) { 
    const response = EmailApiController.sendEmail(emailDetails);   
    if (response.status == 200) { 
       return true;
    } else {
       return false;
    }
  }
}

class EmailApiController {
  sendEmail(emailDetails) {
    // Only need to change this controller. return YahooAPI.sendEmail(emailDetails);
  }
}

Заключение

В этой статье мы обсудили важность принципов SOLID в разработке программного обеспечения и то, как мы можем применить эти концепции в приложениях JavaScript. Разработчикам важно понимать и использовать эти основные концепции в наших приложениях. Иногда преимущества этих принципов могут быть неочевидны при работе с небольшими приложениями, но вы наверняка почувствуете разницу, которую они имеют, когда начнете работать над крупномасштабным проектом.