Введение
Проектируя систему, мы занимаемся моделированием, а значит решаем инженерную задачу. Мы строим гипотезу о том, каковы отношения между сущностями в этой системе.
Однако бизнес-требования не вечны, они могут (и будут) меняться. Хорошо спроектированная система способна пережить эти изменения, отразить их в себе и продолжить функционировать.
Основная причина, по которой вносить изменения бывает трудно или дорого — когда небольшое изменение в одной части системы вызывает лавину изменений в других частях. Грубо и утрировано: если в программе для изменения цвета кнопки надо поправить 15 модулей, такая система спроектирована плохо.
Принцип открытости-закрытости
Принцип открытости-закрытости (Open-Closed Principle, OCP) помогает исключить такую проблему. Согласно ему модули должны быть открыты для расширения, но закрыты для изменения.
Простыми словами — модули надо проектировать так, чтобы их требовалось менять как можно реже, а расширять функциональность можно было с помощью создания новых сущностей и композиции их со старыми.
Основная цель принципа — помочь разработать проект, устойчивый к изменениям, срок жизни которых превышает срок существования первой версии проекта.
Модули, которые удовлетворяют OCP:
- открыты для расширения — их функциональность может быть дополнена с помощью других модулей, если изменятся требования;
- закрыты для изменения — расширение функциональности модуля не должно приводить к изменениям в модулях, которые его используют.
Конечно, всегда есть изменения, которые невозможно внести, не изменив код какого-то модуля — никакая система не может быть закрыта на 100%. Поэтому при проектировании важен стратегический подход. Необходимо определить, от каких именно изменений и какие именно модули вы хотите закрыть. Это решение следует принимать опираясь на опыт, а также знания предметной области и пользователей системы.
Нарушение принципа открытости-закрытости приводит к ситуациям, когда изменение в одном модуле вынуждает менять другие, связанные с ним. Это в свою очередь нарушает принцип единой ответственности, SRP, потому что весь код, который меняется по какой-то одной причине, должен быть собран в одном модуле. (Разные модули — разные причины для изменения.)
На примере
На схеме ниже объект Client
непосредственно связан с объектом Server
. Если нам вдруг понадобится, чтобы Client
мог работать с разными объектами Server
, нам придётся поменять его код.
Чтобы решить эту проблему, необходимо связывать объекты не напрямую, а через абстракции. Если все объекты Server
реализуют интерфейс Abstract Server
, то нам уже не придётся менять код объекта Client
для замены одного объекта Server
на другой.
В коде
Если попробовать выразить этот принцип в коде, то самым простым примером будет замена множественных проверок на принадлежность к типу на абстракцию.
Например, мы пишем сервис рассылки сообщений. Он принимает текст, который надо выслать, и сервис стороннего API для отправки СМС, пушей или электронных писем. Плохо спроектированный сервис мог бы выглядеть так:
class SmsSender {
sendSms(message: MessageText) { /* ... */ }
}
class PushSender {
sendPush(message: MessageText) { /* ... */ }
}
class EmailSender {
sendEmail(message: MessageText) { /* ... */ }
}
class Notifier {
constructor(private api: SmsSender | PushSender | EmailSender) {}
notify(): void {
const message = 'Some user notification';
if (this.api instanceof SmsSender) {
this.api.sendSms(message)
} else if (this.api instanceof PushSender) {
this.api.sendPush(message)
} else if (this.api instanceof EmailSender) {
this.api.sendEmail(message)
}
}
}
Проблема этого кода в том, что при добавлении нового типа стороннего API — голубиной почты — нам придётся менять уже существующий код.
// ...Предыдущие классы.
// Добавили новый тип стороннего API:
class DoveSender {
sendDove(message: MessageText) { /* ... */ }
}
class Notifier {
constructor(private api: SmsSender | PushSender | EmailSender | DoveSender) {}
notify(): void {
const message = 'Some user notification';
if (this.api instanceof SmsSender) {
this.api.sendSms(message)
} else if (this.api instanceof PushSender) {
this.api.sendPush(message)
} else if (this.api instanceof EmailSender) {
this.api.sendEmail(message)
} else if (this.api instanceof DoveSender) { // Последние 3 строчки —
this.api.sendDove(message) // ...это новый код,
} // ...который пришлось добавить.
}
}
Вместо этого
OCP же предлагает не проверять конкретные типы, а использовать абстракцию, которая позволит не менять код класса Notifier
. Для этого мы создадим интерфейс Sender
, который будут реализовывать классы SmsSender
, PushSender
и EmailSender
:
// Интерфейс будет абстракцией, которая описывает контракт,
// по которому должны работать классы, реализующий этот интерфейс:
interface Sender {
sendMessage(message: MessageText): void
}
class SmsSender implements Sender {
sendMessage(message: MessageText) {
/* То, что раньше было внутри метода `sendSms`. */
}
}
class PushSender implements Sender {
sendMessage(message: MessageText) {
/* То, что раньше было внутри метода `sendPush`. */
}
}
class EmailSender implements Sender {
sendMessage(message: MessageText) {
/* То, что раньше было внутри метода `sendEmail`. */
}
}
Тогда классу Notifier
перестанет быть нужно проверять конкретный тип, и он сможет положиться на контракт, описанный в интерфейсе:
class Notifier {
constructor(private api: Sender) {}
notify(): void {
const message = 'Some user notification';
this.api.sendMessage(message);
}
}
В результате
Теперь при добавлении голубиной почты, нам уже не потребуется менять код класса Notifier
, зависящего от интерфейса Sender
:
class DoveSender implements Sender {
sendMessage(message: MessageText) {
/* То, что раньше было внутри метода `sendDove`. */
}
}
// Код класса `Notifier` останется тем же.
Таким образом, вводя адекватную абстракцию мы «расцепляем» модули. Мы делим зоны ответственности между разными частями приложения и уменьшаем количество кода, который нужно изменять при добавлении новой функциональности.
Коротко
Принцип открытости-закрытости:
- заставляет проектировать модули так, чтобы они делали только одну вещь и делали её хорошо;
- побуждает связывать сущности через абстракции (а не реализацию) там, где могут поменяться бизнес-требования;
- обращает внимание проектировщиков на места стыка и взаимодействие сущностей;
- позволяет сократить количество кода, который необходимо менять при изменении бизнес-требований;
- делает внесение изменений безопасным и относительно дешёвым.