Задача этой статьи только одна - попробовать уложить принципы SOLID на понятных «бытовых» примерах, а уже потом посмотреть, как оно может работать на практике - в коде.
Итак, SOLID - это 5 принципов, которые используются при разработке приложений. На каждый принцип по букве:
1. S — Single Responsibility Principle (Принцип единственной ответственности)
Определение: Каждый класс должен выполнять только одну задачу.
Пример из жизни:
Например, мы купили шкаф для одежды.
Хранение одежды - это его основная и единственная задача.
Потом мы решили хранить там не только вещи, но и инструменты. Это усложнило назначение шкафа и поиск конкретной вещи стал медленнее (вещей-то больше)
Потом мы решили хранить в этом же шкафу еще и продукты. Теперь шкаф выполняет сразу несколько абсолютно разных задач
Результат: шкаф больше не справляется с одной конкретной задачей и начинает терять свою основную функцию, превращаясь в беспорядочную «помойку».
Пример из разработки:
Например, у нас есть класс, в котором происходят действия с пользователями: их сохранение, получение, удаление. Ну, что-нибудь вроде такого:
@Service
@RequiredArgsConstructor
public class PersonService {
private final PersonRepository personRepository;
@Transactional
public void createPerson(Person person) {
personRepository.save(person);
}
public Person getPerson(UUID personId) {
return personRepository.getById(personId);
}
@Transactional
public void deletePerson(UUID personId) {
personRepository.deleteById(personId);
}
Такой класс не нарушает принцип единственной ответственности, т.к. отвечает за операции только с одной сущностью - пользователем. А вот если я в этот же класс решу добавить управление заказами пользователя, то выйдет что-то вроде:
@Service
@RequiredArgsConstructor
public class PersonOrderService {
private final PersonRepository personRepository;
private final OrderRepository orderRepository;
@Transactional
public void createPerson(Person person) {
personRepository.save(person);
}
public Person getPerson(UUID personId) {
return personRepository.getById(personId);
}
@Transactional
public void deletePerson(UUID personId) {
personRepository.deleteById(personId);
}
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
}
@Transactional
public void deleteOrder(UUID orderId) {
orderRepository.deleteById(orderId);
}
public Order getOrder(UUID orderId) {
return orderRepository.getById(orderId);
}
}
Ну, там в целом уже из названия понятно, что выходит какая-то ерунда. А если еще в этот класс попробовать добавить управление затратами пользователя, например, и еще что-нибудь, то в скором времени у нас выйдет длинная простыня, в которой едва ли нам самим будет возможно разобраться, не говоря уже о новых ребятах на проекте.
Правильная реализация - добавить под управление заказами отдельный класс.
Кстати, у меня есть телеграмм-канал, где я всякие штуки про разработку пишу - задачки алгоритмеческие решаю, паттерны обсуждаю. Если интересно - клик по ссылке https://t.me/crushiteasy
А мы продолжаем наш SOLID!
O — Open-Closed Principle (Принцип открытости/закрытости)
Определение: Классы должны быть открыты для расширения, но закрыты для модификации.
Пример из жизни:
Допустим, мы купили шкаф! Хех. Опять шкаф, да. Когда у нас стало больше одежды (да-да, одежды, а не продукты мы тоже захотели в нем хранить), не нужно разбирать шкаф и делать новый. Мы просто покупаем дополнительные полки, ящик или секцию, расширяя таким образом функциональность шкафа и не ломая его структуру.
Пример из разработки:
Допустим, у нас есть класс TaskService, который отвечает за определенные действия над задачами - начинает их выполнение и завершает:
@Service
@RequiredArgsConstructor
public class TaskService {
public void process(String action) {
if (action.equals("start")) {
//начни выполнение задачи
//проставь дату начала выполнения
} else if (action.equals("complete")) {
//заверши выполнение задачи
//проставь дату окончания выполнения
}
}
}
Вроде бы все ничего, компактно и понятно. Но вдруг бизнес решил, что задачи нужно не только начинать и выполнять, но и иметь возможность переназначить, что ведет за собой еще ряд дополнительных действий. А потом бизнес захочет что-то еще, например. Если мы полезем со своими правками прямо в этот класс, то мы нарушим принцип открытости/закрытости, т.к. модифицируем его.
Ну, и ладно, скажете вы, нарушим и нарушим. Беда наступит тогда, когда у нас будет огромное полотно из if-ов, в каждом из которых будет своя логика. Результат: код, сложный для прочтения.
Правильная реализация:
Создаем общий интерфейс, назовем его TaskProcessor:
public interface TaskProcessor {
void process(String action);
}
Создаем два класса, каждый из которых реализует этот интерфейс и метод process:
public class StartActionProcessor implements TaskProcessor {
@Override
public void process(String action) {
//начни выполнение задачи
//проставь дату начала выполнения
}
}
public class CompleteActionProcessor implements TaskProcessor {
@Override
public void process(String action) {
//заверши выполнение задачи
//проставь дату окончания выполнения
}
}
Все, теперь не нужно волноваться, если потребуется добавить еще какое-нибудь действие вроде «переназначить» задачу. В этом случае мы просто создадим новый класс и сделаем это.
p.s. если интересно, как внедрять все эти процессоры и заставить сработать определенный, то в планах есть статья как раз об этом.
3. L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
Определение: Объекты должны быть заменяемыми их подтипами без изменения правильности работы программы.
Пример из жизни:
Представь, что мы купили пылесос, он выглядит, как пылесос, включается, как пылесос, но вот только одно но - вместо того, чтобы всасывать в себя пыль, он разбрызгивает огромную струю масла при включении. Думаю, такая ситуация, не вызовет у нас радости, а заставит вернуть пылесос обратно в магазин, да еще и жалобу накатать его создателям.
Пример из разработки:
У нас есть общий класс по обработке заказов с двумя методами - обработка заказа и печать чека:
public class OrderService {
public void process() {
//обработка
}
public void printReceipt() {
//печать чека
}
}
У этого этого класса есть два наследника:
public class OfflineOrderService extends OrderService {
public void process() {
//обработка заказа
}
public void printReceipt() {
//печать чека
}
}
public class OnlineOrderService extends OrderService {
public void process() {
//обработка заказа
}
public void printReceipt() {
throw new UnsupportedOperationException("Операция не поддреживается");
}
}
Второй наследник не поддерживает операцию «печать чека», а поэтому в случае подстановки вместо базового класса OrderService, мы получим исключение, что нарушает принцип.
Правильная реализация была бы:
public class OrderService {
public void process() {
//обработка
}
}
public class OfflineOrderService extends OrderService {
public void process() {
//обработка заказа
}
public void printReceipt() {
//печать чека
}
}
public class OnlineOrderService extends OrderService {
public void process() {
//обработка заказа
}
//что-то еще
}
Теперь, если мы заменим OrderService на OfflineOrderService или OnlineOrderService в любом куске кода, то общая логика сохранится.
4. I — Interface Segregation Principle (Принцип разделения интерфейса)
Определение: Не нужно заставлять клиента зависеть от методов, которые они не используют.
Пример из жизни:
Представьте, что мы купили телевизор. К этому телевизору шел пульт, с помощью которого им можно управлять. Но оказалось, что этот пульт управляет не только телевизором, но и кондиционером, и обогревателем, т.е. на нем гораздо больше кнопок. А ты хотел простой и понятный, минималистичный пульт, который управляет только телевизором.
Пример из разработки:
Например, у нас есть также онлайн и оффлайн заказы. К онлайн заказам можно применить скидку, а к оффлайн заказам нельзя. Если мы напишем общий интерфейс для обработки заказов, поместив туда методы и для создания заказа и для применения скидки, то получим что-то такое:
public interface OrderService {
void createOrder();
void applyDiscount();
}
public class OnlineOrderService implements OrderService {
@Override
public void createOrder() {
System.out.println("Order created.");
}
@Override
public void applyDiscount() {
System.out.println("Discount applied.");
}
}
public class OfflineOrderService implements OrderService {
@Override
public void createOrder() {
System.out.println("Order created.");
}
@Override
public void applyDiscount() {
throw new UnsupportedOperationException("Discount cannot be applied");
}
}
Получается, тут мы заставили OfflineOrder реализовывать метод «применения скидки», который ему не нужен. Правильная реализация:
public interface OrderService {
void createOrder();
}
public interface DiscountService {
void applyDiscount();
}
public class OnlineOrderService implements OrderService, DiscountService {
@Override
public void createOrder() {
System.out.println("Order created.");
}
@Override
public void applyDiscount() {
System.out.println("Discount applied.");
}
}
public class OfflineOrderService implements OrderService {
@Override
public void createOrder() {
System.out.println("Order created.");
}
}
Теперь мы не нарушаем принцип. Кстати, если подумать чуть глубже, то таким образом мы не нарушаем и принцип единственной ответственности - так как все-таки управление заказом и применение скидки - разные вещи.
5. D — Dependency Inversion Principle (Принцип инверсии зависимостей)
Сейчас будет самая нудитяна!! Ладно, там нудное только определение, а дальше разберемся:)
Итак, определение: Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба типа модулей должны зависеть от абстракций. Абстракции не должны зависеть от деталей, но детали должны зависеть от абстракций.
Погнали разбираться с всей этой «абстракцией»
Пример из жизни:
Представь, что у тебя есть розетка в доме. Ты не задумываешься о том, какое именно устройство ты будешь подключать в неё — фен, телефон, зарядку для ноутбука — всё будет работать, потому что розетка стандартизирована (абстракция) - не надо шутить тут про американские вилки))))). Если бы тебе нужно было менять розетку каждый раз под новое устройство, это было бы крайне неудобно.
Пример из разработки:
// Низкоуровневый класс для уведомлений
public class EmailNotificationService {
public void send(String message) {
System.out.println("Sending email notification: " + message);
}
}
// Высокоуровневый класс
public class OrderService {
private EmailNotificationService emailNotificationService;
public OrderService() {
this.emailNotification = new EmailNotification(); // Прямое создание зависимости
}
public void placeOrder(String orderDetails) {
System.out.println("Order placed: " + orderDetails);
emailNotificationService.send("Order confirmation for: " + orderDetails);
}
}
Здесь высокоуровневый класс OrderService зависит от низкоуровнего EmailNotification - это затруднит тестирование и замену реализации уведомлений, если появится новый способ - SmsNotificationService.
Правильная реализация:
// Абстракция для уведомлений
public interface NotificationService {
void send(String message);
}
// Конкретная реализация для уведомлений по электронной почте
public class EmailNotificationService implements NotificationService {
@Override
public void send(String message) {
System.out.println("Sending email notification: " + message);
}
}
// Конкретная реализация для уведомлений по SMS
public class SmsNotificationService implements NotificationService {
@Override
public void send(String message) {
System.out.println("Sending SMS notification: " + message);
}
}
// Высокоуровневый класс, который зависит от абстракции
public class OrderService {
private NotificationService notificationService;
// Зависимость передается через конструктор
public OrderService(NotificationService notificationService) {
this.notificationService = notificationService;
}
public void placeOrder(String orderDetails) {
System.out.println("Order placed: " + orderDetails);
notificationService.send("Order confirmation for: " + orderDetails);
}
}
Теперь высоуровневый класс зависит от абстракции - интерфейса NotificationService, а значит в случае замены реализации проблем не будет. Если подключить спринг, то там уже можно будет интереснее показать, когда и как какая именно реализация будет использоваться (тут у нас и профили, и квалифаеры, да и можно через процессоры с хэш-мапой тоже реализовать круто)
Ну, на этом SOLID все. А зачем все эти принципы, спросите вы? В конечном счете, чтобы ваш код было удобнее читать и поддерживать вам самому, ну и вашим коллегам, конечно же:)