Мы часто сталкиваемся с аббревиатурой S.O.L.I.D в области объектно-ориентированного программирования. Фактически, эти 5 принципов составляют основу любой объектно-ориентированной разработки кода. Но прежде чем мы перейдем к четкому пониманию этих принципов и научимся их использовать, давайте пересмотрим некоторые основные концепции объектно-ориентированного проектирования/программирования.

Что такое объектно-ориентированный дизайн?

Объектно-ориентированный дизайн — это модель программирования, основанная на базовых строительных блоках, называемых объектами. Каждый объект имеет две функции: атрибуты (данные) и поведение (методы/функции). Атрибуты содержат фактические данные типа. (например, строка, целое число, значение с плавающей запятой или ссылка на другой объект). С другой стороны, метод отвечает за изменение состояния этих атрибутов. Таким образом, метод или функция на самом деле являются поведением объекта, поскольку они определяют, как данные ведут себя. при вызове определенного метода.

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

Давайте вспомним, как класс и его объекты выглядят в Java:

class Employee {
   String employeeId;
   String name;
   int age;
   double salary;
   public promoteEmployee(double percentIncrement) {
     salary += (salary * percentIncrement)/100;
   }
}

Приведенный выше класс Employee определил несколько атрибутов, таких как employeeId, имя, возраст и зарплата. У каждого сотрудника (или каждого объекта) будет свой набор значений для этого класса. Также определен метод promoteEmployee, который меняет состояние объекта (и увеличивает его зарплату на определенный процент). Этот метод определяет поведение атрибута salary.

Теперь, когда основные концепции объектно-ориентированного программирования освежены в нашей памяти, давайте вернемся к некоторой базовой терминологии, которая будет использоваться для определения основ S.O.L.I.D.

Слабая связь

Два класса называются слабо связанными, когда их связь определяется абстракцией, а не реальными объектами.

Давайте посмотрим на пример кода на основе Java 8+, чтобы было понятнее:

public interface AuthenticationService{
  public User login(String username, String password);
  default void logout(User user) {
     // code to clear the session
     // and log the user out
  }
}

В приведенном выше примере показан интерфейс AuthenticationService с абстрактным методом для регистрации пользователя с использованием имени пользователя и пароля. Здесь также показана реализация по умолчанию функции выхода из системы для пользователя (поскольку мы уже знаем, что Java 8 теперь допускает реализацию методов по умолчанию в интерфейсах😎и мы не не нужно их реализовывать. Хотя при желании можем)

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

Допустим, мы определяем два класса, которые реализуют этот интерфейс: UserAuthenticationService и AdminAuthentication service. Код для этих классов будет выглядеть так.

class UserAuthenticationService implements AuthenticationService{
   public User login(String username, String password) {
      //general user specific code to login
   }
}
class AdminAuthenticationService implements AuthenticationService {
   public User login(String username, String password) {
      //admin user specific code to login
      //this code may connect to a different table
      //which holds only admin users
   }
}

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

public class LoginController {
   
   @Inject
   public AuthenticationService authService;
   public ResponseEntity loginUser (UserData userData) {
      authService.login(userData.getUsername(),userData.getPwd());
      // some other code to return the response
   }
}

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

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

Если бы фактическая реализация была связана, ее назвали бы сильной связью, что НЕ является хорошим шаблоном проектирования.

Высокая сплоченность

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

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

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

Теперь, когда концепции возвращаются в разум, давайте разберемся с 5 S.O.L.I.D. Принципы объектно-ориентированного проектирования.

5 столпов объектно-ориентированного дизайна

S — Принцип единой ответственности: у каждого класса должна быть одна обязанность.

O — Принцип открытости/закрытости: класс должен быть открыт для расширения, но закрыт для модификации.

L — Принцип подстановки Лисков: объекты подклассов должны иметь возможность заменять объекты суперкласса.

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

D — Принцип инверсии зависимостей: класс, использующий объект другого класса путем ассоциации, должен быть связан во время выполнения (слабая связь).

Давайте углубимся в эти принципы один за другим:

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

Есть известная цитата Стива Джобса:

Не пытайся сделать все, делай одно дело хорошо.

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

Давайте посмотрим на следующий фрагмент кода, который создает учетную запись и отправляет приветственное письмо пользователю, когда учетная запись успешно создана.

class AccountService {
    public User login(String username, String password) {
      //some code to login
    }
    public void logout(User user) {
      //some code to log the user out
    }
    public void createAccount(String... someParameters) {
     //code to take information and persist in the database
     sendEmail(name, email);
     sendSMS(name, phoneNumber);
   }
   public void sendEmail(String name, String email) {
     String message = "Hi " + name + ", thanks for creating an"
                         + "account on our website";
     //code to connect to the SMTP server and send the email 
     //with the above message
   }
}

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

Для каждого из этих сценариев не оптимально писать метод sendEmail с одним и тем же кодом для подключения к SMTP-серверу и отправки почты. Единственными переменными, которые будут меняться в электронном письме, будут сообщение, тема и идентификатор электронной почты. Процесс подключения к SMTP-серверу — его адрес, порт и другие данные останутся прежними. Приведенный выше дизайн привел бы к большому дублированию кода. Копирование и вставка одного и того же кода в любом месте также имеет свои недостатки. Например, если вы переключаетесь на нового SMTP-провайдера и вам нужно изменить способ передачи данных на SMTP-сервер, вам придется изменить его во ВСЕХ местах, где присутствует повторяющийся код. Все это происходит потому, что в дизайне приложения нет классов, у которых есть Single Responsibility.

Как мы можем сделать это лучше? Посмотрим

Во-первых, мы создаем класс EmailService, единственной обязанностью которого является отправка электронной почты получателям с тремя переменными: email (получатель, которому отправляется электронное письмо), тема и тело (HTML-содержимое электронного письма, которое может содержать HTML-текст и изображения). Этот класс EmailService может использоваться любыми другими службами для отправки электронных писем (например, забытый пароль или уведомления).

class EmailService {
  public void sendEmail(String email, String subject, String body) {
     //code to connect to SMTP Server and send email
  }
  // the class could have other helper methods too, but only the   
  // responsibilities related to sending email.
}

Затем мы реорганизуем класс AccountService, чтобы избавиться от функций электронной почты и сохранить функции, относящиеся только к учетным записям. Так как этот класс должен отправлять электронные письма в случае создания учетной записи, мы ВСТАВЛЯЕМ EmailService и используем sendEmail. метод следующим образом.

class AccountService {
    @Inject
    private EmailService emailService;
    public User login(String username, String password) {
      //some code to login
    }
    public void logout(User user) {
      //some code to log the user out
    }
    public void createAccount(String... someParameters) {
      String body= //some code to create email message    
      String subject = "Welcome "+name;      
      emailService.sendEmail(email, subject, body);
   }
 
}

Таким образом, мы получаем два класса, каждый со своими обязанностями, простой в обслуживании, очень связанный, слабо связанный и повторно используемый!

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

Принцип Open-Closed достаточно прост. Класс должен быть открыт для расширения, но закрыт для модификации. Давайте посмотрим на пример, чтобы увидеть, что это значит и ПОЧЕМУ этот принцип крут!

Давайте посмотрим на следующий фрагмент кода, который показывает пример кода при создании отчета в формате CSV и XML соответственно.

class ReportGenerator{
    public File generateReport(String reportType) {
       if(reportType.equalsIgnoreCase("CSV")) { 
          //Code for generating CSV Report
       } else if(reportType.equalsIgnoreCase("XML")) {
          //Code for generating XML report
       } else {
          //Invalid Input
       }
}

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

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

Учитывая принцип открытости-закрытости, мы переделываем класс следующим образом.

public interface ReportGenerator {
   public File generateReport();
}
class CSVReportGenerator implements ReportGenerator {
  @Override
  public File generateReport() {
    // code for generating CSV Report
  }
}
class XMLReportGenerator implements ReportGenerator {
  @Override
  public File generateReport() {
    // code for generating XML Report
  }
}

В приведенном выше сценарии интерфейс ReportGenerator предоставляет функцию generateReport(), которая реализуется специализированными классами, созданными для создания отчета такого типа, как CSVReportGenerator & XMLReportGenerator. Если нам нужно добавить генератор отчетов типа PDF, нам не нужно изменять какой-либо из существующих выше классов.

Мы можем просто реализовать общедоступный интерфейс и написать собственный код для создания отчетов в формате PDF. Пример:

public class PDFReportGenerator implements ReportGenerator {
   @Override
   public File generateReport() {
      //code for generating PDF report
   }
}

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

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

Например, есть класс с именем Super, который имеет подтип Sub, тогда объекты класса Super должны иметь возможность быть заменены объектами подп. Например,

Super referenceVariable = new Sub();

В java из-за безопасности типов вы не можете присвоить случайный объект ссылочной переменной класса. Выдает ошибку времени компиляции!

Например, Test отсутствует в иерархии Super. Поэтому, поскольку приведенный ниже оператор нарушает принцип подстановки Лискова, это ошибка времени компиляции.

Super referenceVariable = new Test();

Интересно, что на этом принцип подстановки Лискова не заканчивается. Существуют проблемы программирования, которые могут нарушать этот принцип на уровне проектирования.

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

Давайте посмотрим на следующую структуру класса:

Взглянув на приведенную выше структуру, вы увидите карту интерфейса, которая определяет определенные операции. Классы CreditCard и DebitCard реализуют этот интерфейс со своей собственной реализацией validate( ), авторизация() и дебет(). Странным является реализация RewardsCard. Для этого не требуется метод authorize(), который используется только для кредитных и дебетовых карт. Что делать в таком случае? — Мы либо оставляем реализацию пустой, либо выбрасываем исключение в методе.

Затем он добавляет новый общедоступный метод addPoints(), который добавляет баллы в RewardsCard всякий раз, когда пользователь делает покупки. Этот метод изначально не был частью интерфейса. Теперь один метод в контракте не реализован (и бесполезен), а с другой стороны, открыт для использования новый общедоступный метод. Хотя эти структуры классов будут компилироваться, мы столкнемся с проблемами, когда попытаемся сделать:

Card card = new RewardsCard(.. some params ..); //compiles
card.validate(); //compiles
card.authorize();//compiles but changes expected behavior. Violation
card.addPoints(10); //compilation FAILS!

См. строки 4 и 5 в приведенном выше коде. Хотя иерархия правильная и компилируется, мы сталкиваемся с проблемами, когда вызываем неиспользуемую реализацию в строке (4) и используем переменную суперкласса для вызова метода, которого не было в контракте интерфейса в строке (5). Оба эти утверждения нарушают принцип Лискова.

В таком случае мы переделываем классы по-другому:

При изменении дизайна классов мы разбиваем интерфейс Cardна два интерфейса AuthorizableCard, которые требуют авторизации и повторного использования Cardосновные методы проверки и списанияинтерфейса. Новый контракт для CreditCard и DebitCard теперь заключен с AuthorizableCard. В этом случае все методы реализованы правильно и новые общедоступные методы не добавляются.

AuthorizableCard card = new CreditCard(... params ...);
card.validate(); //compiles & implemented
card.authorize(amount); //compiles and implemented
card.debit(amount); //compiles and properly implemented

Аналогичным образом интерфейс PointsCard также расширяет базовые функции Card. У класса RewardsCard теперь есть контракт с PointsCard, который содержит addPointsметод. Он выполняет контракт и сохраняет поведение PointsCard. Это также хорошо для расширяемости, например, если нам нужно настроить и добавить новые типы классов PointsCard (например, BronzePointsCard, SilverPointsCardи GoldPointsCard), мы просто реализуем Интерфейс PointsCard и обновить реализацию addPoints() в этих классах. В целом поведение остается прежним: при вызове addPoints() некоторые точки добавляются на карту.

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

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

Этот принцип довольно прост. Например, вы можете взглянуть на описанный выше принцип замещения Лискова и на то, как интерфейс Card был разделен на RewardsCard и AuthorizableCard.

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

Класс, использующий объект другого класса по ассоциации, должен быть связан во время выполнения.

Мы уже знаем о концепции слабой связи (пересмотрите пример слабой связи, упомянутый выше, прежде чем продолжить), давайте посмотрим пример того, как слабая связь выполняется в контексте инверсии зависимостей:

@Controller
@RequestMapping("/admin")
public class AdminLoginController {
   @Inject
   private AuthenticationService authService;
@PostMapping("/login")
public void loginAdminUser(UserDetails userDetails) {
 authService.login(userDetails.getUsername(), userDetails.getPwd());
 //return response
}

В приведенном выше случае мы написали контроллер, который берет имя пользователя и пароль из запроса пользователя всякий раз, когда POSTзапрос делается на /admin/login. Компонент AuthenticationService преобразуется в AdminAuthenticationService.

Аналогичным образом, когда выполняется запрос POST к /user/login с именем пользователя и паролем, тот же компонент разрешается в UserAuthenticationService.

@Controller
@RequestMapping("/user")
public class UserAccountController {
   @Inject
   private AuthenticationService authService;
@PostMapping("/login")
public void loginAdminUser(UserDetails userDetails) {
 authService.login(userDetails.getUsername(), userDetails.getPwd());
 //return response
}

Вопрос в том, как настроить, какая реализация bean-компонента должна загружаться во время выполнения и когда? Один из многих способов сделать это — использовать @ConditionalOnPropertyаннотацию.

Допустим, у вас есть приложение Spring, которое работает в двух рабочих режимах: admin и user. Если профиль admin запущен, вы хотите инициализировать AdminAuthenticationServiceи если пользовательскогопрофиля, вы хотите инициализировать UserAuthenticationService. Свойство spring, соответствующее текущему профилю пользователя, — spring.profile.active.

Поэтому в классе AdminAuthenticationServiceмы определяем

@Service 
@ConditionalOnBean(prefix="spring.profiles" name="active" havingValue="admin")
class AdminAuthenticationService {
   ..... code ....
}

и в UserAuthenticationService мы определяем:

@Service 
@ConditionalOnBean(prefix="spring.profiles" name="active" havingValue="user")
class UserAuthenticationService {
   ..... code ....
}

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