SOLID в JavaScript

Внедрение парадигмы ООП популяризировало ключевые концепции программирования, такие как наследование, полиморфизм, абстракция и инкапсуляция. ООП быстро стала широко распространенной парадигмой программирования, реализованной на нескольких языках, таких как Java, C++, C#, JavaScript и других.

С течением времени система ООП становилась все более сложной, но ее программное обеспечение оставалось устойчивым к изменениям. Чтобы улучшить расширяемость программного обеспечения и снизить жесткость кода, Роберт К. Мартин (он же Дядя Боб) в начале 2000-х годов внедрил принципы SOLID.

SOLID - это аббревиатура, состоящая из набора принципов (принцип единой ответственности, принцип открытости-закрытости, принцип замещения по Лискову, принцип разделения интерфейсов и принцип обращения зависимостей), которые помогают разработчикам программного обеспечения разрабатывать и писать поддерживаемый, масштабируемый и гибкий код. Его цель? Повысить качество программного обеспечения, разрабатываемого в соответствии с парадигмой объектно-ориентированного программирования (ООП).

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

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

Первая буква в SOLID обозначает принцип единой ответственности. Этот принцип предполагает, что класс или модуль должен выполнять только одну роль.

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

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

class Person {
    constructor(name, age, height, country){
      this.name = name
      this.age = age
      this.height = height
      this.country = country
  }
  getPersonCountry(){
    console.log(this.country)    
  }
  greetPerson(){
    console.log("Hi " + this.name)
  }
  static calculateAge(dob) { 
    const today = new Date(); 
    const birthDate = new Date(dob);

    let age = today.getFullYear() - birthDate.getFullYear(); 
    const monthDiff = today.getMonth() - birthDate.getMonth();

    if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { 
      age--; 
    }
    return age; 
  } 
}

Приведенный выше код, кажется, в порядке, не так ли? Не совсем. Пример кода нарушает принцип единой ответственности. Вместо того, чтобы быть единственной моделью, на основе которой могут быть созданы другие экземпляры Person, класс Person также выполняет другие функции, такие как calculateAge, greetPerson и getPersonCountry.

Эти дополнительные обязанности, выполняемые классом Person, затрудняют изменение только одного аспекта кода. Например, если вы попытаетесь реорганизовать calculateAge, вам также может потребоваться реорганизовать модель Person. В зависимости от того, насколько компактна и сложна наша кодовая база, может быть сложно перенастроить код, не вызвав ошибок.

Давайте попробуем исправить ошибку. Мы можем разделить обязанности на разные классы, вот так:

class Person {
    constructor(name, dateOfBirth, height, country){
      this.name = name
      this.dateOfBirth = dateOfBirth
      this.height = height
      this.country = country
  }
}

class PersonUtils {
  static calculateAge(dob) { 
    const today = new Date(); 
    const birthDate = new Date(dob);

    let age = today.getFullYear() - birthDate.getFullYear(); 
    const monthDiff = today.getMonth() - birthDate.getMonth();

    if(monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { 
      age--; 
    }
    return age; 
  } 
}

const person = new Person("John", new Date(1994, 11, 23), "6ft", "USA"); 
console.log("Age: " + PersonUtils.calculateAge(person.dateOfBirth));

class PersonService {
  getPersonCountry(){
    console.log(this.country)    
  }
  greetPerson(){
    console.log("Hi " + this.name)
  }
}

Как вы можете видеть из приведенного выше примера кода, мы разделили наши обязанности. Класс Person теперь является моделью, с помощью которой мы можем создать новый объект person. А у класса PersonUtils есть только одна функция - вычислять возраст человека. Класс PersonService обрабатывает приветствия и показывает нам страну каждого человека.

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

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

Прежде чем мы перейдем к следующему принципу, следует отметить, что соблюдение SRP не означает, что каждый класс должен содержать строго один метод или функциональность.

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

Принцип "Открыто-закрыто" (OCP)

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

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

Принцип OC может быть реализован в JavaScript с помощью функции наследования классов ES6+.

Следующие фрагменты кода иллюстрируют, как реализовать принцип "Открыто-закрыто" в JavaScript, используя вышеупомянутое ключевое слово ES6+ class:

class Rectangle { 
  constructor(width, height) {
    this.width = width; 
    this.height = height; 
  } 
  area() { 
  return this.width * this.height; 
  } 
} 

class ShapeProcessor { 
    calculateArea(shape) { 
    if (shape instanceof Rectangle) { 
    return shape.area(); 
    } 
  }
}  
const rectangle = new Rectangle(10, 20); const shapeProcessor = new ShapeProcessor(); console.log(shapeProcessor.calculateArea(rectangle)); 

Приведенный выше код работает нормально, но он ограничен вычислением только площади прямоугольника. Теперь представьте, что для вычисления требуется новое требование. Допустим, например, что нам нужно вычислить площадь круга. Для этого нам пришлось бы изменить класс shapeProcessor. Однако, следуя стандарту JavaScript ES6+, мы можем расширить эту функциональность для учета областей новых фигур без необходимости изменения класса shapeProcessor.

Мы можем сделать это вот так:

class Shape {
  area() {
    console.log("Override method area in subclass");
  }
}

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

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

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

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

class ShapeProcessor {
  calculateArea(shape) {
    return shape.area();
  }
}

const rectangle = new Rectangle(20, 10);
const circle = new Circle(2);
const shapeProcessor = new ShapeProcessor();

console.log(shapeProcessor.calculateArea(rectangle));
console.log(shapeProcessor.calculateArea(circle));

В приведенном выше фрагменте кода мы расширили функциональность класса Shape, используя ключевое слово extends. В каждом подклассе мы переопределяем реализацию метода area(). Следуя этому принципу, мы можем добавлять больше фигур и областей обработки без необходимости изменять функциональность класса ShapeProcessor.

Почему OCP так важен?

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

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

Принцип подстановки Лискова гласит, что объект подкласса должен иметь возможность заменять объект суперкласса без нарушения кода. Давайте разберем, как это работает на примере: если L является подклассом P, то объект из L должен заменить объект из P, не нарушая работу системы. Это просто означает, что подкласс должен иметь возможность переопределять метод суперкласса таким образом, чтобы не нарушать работу системы.

На практике принцип замещения Лискова обеспечивает соблюдение следующих условий:

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

Пришло время проиллюстрировать принцип подстановки Лискова примерами кода на JavaScript. Посмотри:

class Vehicle {
  OnEngine(){
    console.log("Engine is steaming!")
  }
}

class Car extends Vehicle {
  // you can invoke the super class OnEngine method and implement how Cars On engine
}
class Bicycle extends Vehicle {
  OnEngine(){
    throw new Error("Bicycles technically don't have an engine")
  }
}

const myCar = new Car();
const myBicycle = new Bicycle();

myCar.OnEngine();
myBicycle.OnEngine();

В приведенном выше фрагменте кода мы создали два подкласса (велосипед и автомобиль) и один суперкласс (транспортное средство). Для целей этой статьи мы реализовали единый метод (OnEngine) для суперкласса.

Одним из основных условий для LSP является то, что подклассы должны переопределять функциональность родительских классов, не нарушая код. Помня об этом, давайте посмотрим, как фрагмент кода, который мы только что видели, нарушает принцип подстановки Лискова. В действительности, у автомобиля есть двигатель, и он может его включать, но у велосипеда технически его нет, и, следовательно, он не может его включить. Таким образом, велосипед не может переопределить метод OnEngine в классе Vehicle, не нарушив код.

Теперь мы определили раздел кода, который нарушает принцип замены по Лискову. Класс Car может переопределить функциональность OnEngine в суперклассе и реализовать ее таким образом, чтобы она отличалась от других транспортных средств (например, самолета), и код не нарушался. Класс Car удовлетворяет принципу замещения Лискова.

В приведенном ниже фрагменте кода мы проиллюстрируем, как структурировать код в соответствии с принципом подстановки Лискова:

class Vehicle { 
  move() {
   console.log("The vehicle is moving."); 
  } 
}

Вот базовый пример класса транспортных средств с общей функциональностью move. Существует общее мнение, что все транспортные средства двигаются; они просто перемещаются с помощью разных механизмов. Один из способов, которым мы собираемся проиллюстрировать LSP, - это переопределить метод move() и реализовать его таким образом, чтобы он отображал, как будет двигаться конкретное транспортное средство, например, автомобиль.

Для этого мы собираемся создать класс Car, который расширяет класс Vehicle и переопределяет метод перемещения в соответствии с движением автомобиля, вот так:

class Car extends Vehicle {
  move(){
    console.log("Car is running on four wheels")
  }
}

Мы все еще можем реализовать метод перемещения в другом классе транспортных средств - например, в самолете.

Вот как бы мы это сделали:

class Airplane extends Vehicle {
    move(){
      console.log("Airplane is flying...")
  }
}

В этих двух приведенных выше примерах мы проиллюстрировали ключевые понятия, такие как наследование и переопределение методов.

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

Давайте немного займемся домашним хозяйством и соберем все вместе, вот так:

class Vehicle { 
  move() {
   console.log("The vehicle is moving."); 
  } 
}

class Car extends Vehicle {
  move(){
    console.log("Car is running on four wheels")
  }
  getSeatCapacity(){
  }
}

class Airplane extends Vehicle {
    move(){
      console.log("Airplane is flying...")
  }
}

const car = new Car();
const airplane = new Airplane();

car.move() // output: Car is running on four wheels

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

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

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

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

JavaScript, в отличие от языков программирования на основе C, таких как Java, не имеет встроенной поддержки интерфейсов. Однако существуют методы, с помощью которых интерфейсы реализуются в JavaScript.

Интерфейсы - это набор сигнатур методов, которые должен реализовывать класс.

В JavaScript вы определяете интерфейс как объект с именами сигнатур методов и функций, например, так:

const InterfaceA = {
  method: function (){}
}

Чтобы реализовать интерфейс на JavaScript, создайте класс и убедитесь, что он содержит методы с теми же именами и сигнатурами, которые указаны в интерфейсе:

class LogRocket {
  method(){
    console.log("This is a method call implementing an interface”)
  }
}

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

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

Предполагая, что у нас есть принтер, сканер и факс, давайте создадим интерфейс, определяющий функции этих объектов:

const printerInterface = {
  print: function(){
  }
}

const scannerInterface = {
  scan: function(){
  }
}

const faxInterface = {
    fax: function(){
  }
}

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

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

Если мы хотим реализовать базовый принтер, который может печатать только документы, мы можем просто реализовать метод print() через интерфейс printerInterface, например, так:

class Printer {
  print(){
    console.log(“printing document”)
  }
}

Этот класс реализует только интерфейс Printer. Он не реализует методы сканирования или отправки факсов. Следуя принципу разделения интерфейсов, клиент - в данном случае, класс принтеров - снизил свою сложность и повысил производительность программного обеспечения.

Принцип инверсии зависимостей (DIP)

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

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

Соблюдение DIP упрощает поддержку, расширение и масштабирование кода, тем самым предотвращая ошибки, которые могут возникнуть из-за изменений в коде. Разработчикам рекомендуется использовать слабую, а не жесткую связь между классами. Как правило, придерживаясь подхода, при котором абстракции ставятся во главу угла, а не прямые зависимости, команды получают возможность гибко адаптироваться и добавлять новые функциональные возможности или изменять старые компоненты, не вызывая сбоев в работе ripple.

В JavaScript мы можем реализовать DIP, используя подход внедрения зависимостей, например, так:

class MySQLDatabase {
  connect() {
    console.log('Connecting to MySQL database...');
  }
}

class MongoDBDatabase {
  connect() {
    console.log('Connecting to MongoDB database...');
  }
}

class Application {
  constructor(database) {
    this.database = database;
  }

  start() {
    this.database.connect();
  }
}

const mySQLDatabase = new MySQLDatabase();
const mySQLApp = new Application(mySQLDatabase);
mySQLApp.start(); 

const mongoDatabase = new MongoDBDatabase();
const mongoApp = new Application(mongoDatabase);
mongoApp.start(); 

В приведенном выше базовом примере класс Application является высокоуровневым модулем, который зависит от абстракции базы данных. Мы создали два класса баз данных: MySQLDatabase и MongoDBDatabase. Базы данных представляют собой низкоуровневые модули, и их экземпляры внедряются в среду выполнения приложения без изменения самого приложения.

Вывод

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

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