Принцип открытости-закрытости (OCP)
Принцип открытости-закрытости (OCP) – компоненты приложения (пакеты, структуры, функции и т.д.) должны быть открыты для расширения, но закрыты для модификации. Достигается это за счёт абстракций – интерфейсов и полиморфизма, – которые создают точки расширения в системе.
Система постоянно меняется. Появляются новые бизнес-требования, проводится рефакторинг, устраняются баги. Мы должны уметь работать с изменением и на это нацелен принцип.
Внедрить изменение можно двумя способами:
- Модифицировать существующий код;
- Написать новый код и тем самым расширить систему.
Принцип открытости-закрытости (OCP) призывает нас проектировать компоненты таким образом, чтобы изменения вносились вторым способом.
Вот почему мы не хотим трогать старый код:
- Любая модификация кода – риск что-то сломать;
- Нужно больше думать. Нам придётся прочитать и понять код прежде, чем его изменить. Это усложняет поддержку и задерживает внедрение фич.
Конечно, полностью написать систему таким образом, чтобы никогда не редактировать старый код, скорее всего невозможно. Однако даже ограниченное следование OCP упрощает поддержку приложения.
Нарушение OCP
Представим, что у нас есть калькулятор зарплаты сотрудников. В расчёт включена премия в зависимости от грейда:
package money
// Money представляет денежную сумму в копейках.
type Money int
func (m Money) Multiply(factor float64) Money {
return Money(float64(m) * factor)
}
func (m Money) Add(other Money) Money {
return m + other
}gopackage employee
import "example.com/salary/money"
type Grade int
const (
Junior Grade = iota
Middle
Senior
)
type Employee struct {
Name string
Salary money.Money
Grade Grade
MonthsInCompany int
}gopackage salary
import (
"example.com/salary/employee"
"example.com/salary/money"
)
type Calculator struct{}
func (c Calculator) Calculate(e employee.Employee) money.Money {
var bonus float64
if e.Grade == employee.Middle {
bonus = 1.15
} else if e.Grade == employee.Senior {
bonus = 1.20
} else {
bonus = 1
}
return e.Salary.Multiply(bonus)
}goКомпания решила выплачивать 5-процентный бонус джунам, которые проработали больше 3 месяцев. Придётся модифицировать метод Calculate:
package salary
import (
"example.com/salary/employee"
"example.com/salary/money"
)
type Calculator struct{}
func (c Calculator) Calculate(e employee.Employee) money.Money {
var bonus float64
if e.Grade == employee.Middle {
bonus = 1.15
} else if e.Grade == employee.Senior {
bonus = 1.20
} else if e.Grade == employee.Junior && e.MonthsInCompany > 3 {
bonus = 1.05
} else {
bonus = 1
}
return e.Salary.Multiply(bonus)
}goКаждый раз когда будет появляться новое бизнес-требование, мы будем возвращаться к этому методу, копаться в его логике и увеличивать простыню из if’ов. OCP не соблюдается, потому что вместо расширения системы за счёт написания нового кода мы модифицируем существующий код. Попробуем это исправить.
Соблюдение OCP через стратегию
Каждый if в логике расчёта зарплаты представляет собой алгоритм расчёта бонуса для определённого грейда. Можно выделить бонус как абстракцию. У каждого бонуса будет логика расчёта и условие, при котором эта логика применяется:
package salary
import (
"example.com/salary/employee"
"example.com/salary/money"
)
type Bonus interface {
IsPayable(e employee.Employee) bool
Calculate(e employee.Employee) money.Money
}goТеперь реализуем каждый бонус как отдельный тип, удовлетворяющий интерфейсу Bonus:
package salary
import (
"example.com/salary/employee"
"example.com/salary/money"
)
type MiddleBonus struct{}
func (b MiddleBonus) IsPayable(e employee.Employee) bool {
return e.Grade == employee.Middle
}
func (b MiddleBonus) Calculate(e employee.Employee) money.Money {
return e.Salary.Multiply(0.15)
}gopackage salary
import (
"example.com/salary/employee"
"example.com/salary/money"
)
type SeniorBonus struct{}
func (b SeniorBonus) IsPayable(e employee.Employee) bool {
return e.Grade == employee.Senior
}
func (b SeniorBonus) Calculate(e employee.Employee) money.Money {
return e.Salary.Multiply(0.20)
}gopackage salary
import (
"example.com/salary/employee"
"example.com/salary/money"
)
type JuniorBonus struct{}
func (b JuniorBonus) IsPayable(e employee.Employee) bool {
return e.Grade == employee.Junior && e.MonthsInCompany > 3
}
func (b JuniorBonus) Calculate(e employee.Employee) money.Money {
return e.Salary.Multiply(0.05)
}goКаждый тип инкапсулирует алгоритм расчёта бонуса. Поскольку у нас теперь есть абстракция в виде интерфейса Bonus, алгоритм можно выбирать в рантайме. Таким образом, мы реализовали паттерн Стратегия.
Передадим все существующие бонусы в Calculator через конструктор:
package salary
import (
"example.com/salary/employee"
"example.com/salary/money"
)
type Calculator struct {
bonuses []Bonus
}
func NewCalculator(bonuses []Bonus) Calculator {
return Calculator{bonuses: bonuses}
}
func (c Calculator) Calculate(e employee.Employee) money.Money {
totalBonus := money.Money(0)
for _, b := range c.bonuses {
if b.IsPayable(e) {
totalBonus = totalBonus.Add(b.Calculate(e))
}
}
return e.Salary.Add(totalBonus)
}goМы проходимся по каждому бонусу (стратегии), проверяем, подходит ли он текущему сотруднику; если да, то добавляем размер бонуса к общей сумме. Если ни один из бонусов не подошёл, возвращаем просто зарплату.
Точка расширения здесь – срез []Bonus. Мы можем расширять поведение калькулятора, добавляя новые бонусы, без модификации существующего кода.
Расширение без модификации
Представим, что появилось новое требование: всем сотрудникам, работающим в компании больше года, выплачивается ежемесячный бонус 3000 рублей за лояльность.
Создаём новый тип бонуса:
package salary
import (
"example.com/salary/employee"
"example.com/salary/money"
)
type LoyaltyBonus struct{}
func (b LoyaltyBonus) IsPayable(e employee.Employee) bool {
return e.MonthsInCompany > 12
}
func (b LoyaltyBonus) Calculate(_ employee.Employee) money.Money {
return 3000_00
}goИ добавляем его при создании калькулятора:
calc := salary.NewCalculator([]salary.Bonus{
salary.MiddleBonus{},
salary.SeniorBonus{},
salary.JuniorBonus{},
salary.LoyaltyBonus{},
})
emp := employee.Employee{
Name: "Alice",
Salary: 100_000_00,
Grade: employee.Senior,
MonthsInCompany: 24,
}
fmt.Printf("%s: %d\n", emp.Name, calc.Calculate(emp))goМы расширили систему без модификации старого кода. Единственное место, которое пришлось изменить, – точка сборки, где создаётся калькулятор. Код самого Calculator, MiddleBonus, SeniorBonus и JuniorBonus остался нетронутым.
Соблюдение OCP через декоратор
Рассмотрим ещё один вариант соблюдения OCP с использованием шаблона Декоратор. Декоратор – это набор типов-обёрток, которые реализуют тот же интерфейс, что и оборачиваемый компонент. Их основная цель – расширить поведение оборачиваемого компонента без модификации его кода.
Допустим, у нас есть приложение, которое отправляет пользователям сообщения через различные мессенджеры. Мессенджер представлен интерфейсом:
package messenger
type Messenger interface {
SendMessage(to string, text string) error
}goСоздадим реализацию мессенджера Telegram:
package messenger
import "fmt"
type TelegramMessenger struct{}
func (t TelegramMessenger) SendMessage(to string, text string) error {
fmt.Printf("Telegram to %s: %s\n", to, text)
return nil
}goУ Telegram есть ограничение на количество символов в тексте – 4096. У других мессенджеров тоже может быть такое ограничение, при этом конкретный лимит может варьироваться.
Создадим декоратор, который поделит текст сообщения на части, если оно превышает лимит символов:
package messenger
type BatchDecorator struct {
wrapped Messenger
batchSize int
}
func NewBatchDecorator(wrapped Messenger, batchSize int) BatchDecorator {
return BatchDecorator{wrapped: wrapped, batchSize: batchSize}
}
func (d BatchDecorator) SendMessage(to string, text string) error {
runes := []rune(text)
for i := 0; i < len(runes); i += d.batchSize {
end := i + d.batchSize
if end > len(runes) {
end = len(runes)
}
if err := d.wrapped.SendMessage(to, string(runes[i:end])); err != nil {
return err
}
}
return nil
}goBatchDecorator реализует тот же интерфейс Messenger, что и TelegramMessenger. Это позволяет обернуть любую реализацию мессенджера:
telegram := messenger.NewBatchDecorator(
messenger.TelegramMessenger{},
4096,
)
_ = telegram.SendMessage("user-123", "Hello!")goКак и в примере с подсчётом зарплат, мы расширяем систему путём написания только нового кода.
Реальный пример: http.Handler
Паттерн middleware в Go – каноничный пример OCP на практике. Интерфейс http.Handler определяет единственный метод:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}goФункция-обработчик реализует бизнес-логику:
package main
import (
"fmt"
"net/http"
)
func helloHandler(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "Hello!")
}goMiddleware оборачивает обработчик, добавляя поведение без модификации оригинального кода – это по сути тот же декоратор:
package main
import (
"log"
"net/http"
"time"
)
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}goMiddleware можно комбинировать, наслаивая друг на друга:
handler := loggingMiddleware(http.HandlerFunc(helloHandler))
http.Handle("/", handler)goКаждый middleware расширяет поведение обработчика без модификации его кода. При этом сами middleware тоже удовлетворяют интерфейсу http.Handler, что позволяет оборачивать их снова и снова.
OCP на уровне системы
Принципы SOLID можно применять не только для типов, но и на любом уровне абстракции. Несколько примеров применения OCP в софте, которым программисты пользуются ежедневно:
- Популярные браузеры (Google Chrome, Mozilla Firefox) поддерживают расширения. Браузеры предоставляют стабильный API, который позволяет создавать новую функциональность без модификации самого приложения;
- Современные IDE (IntelliJ IDEA, Visual Studio Code) поддерживают плагины;
- Пакет
database/sqlв стандартной библиотеке Go предоставляет точку расширения через регистрацию драйверов. Можно подключить PostgreSQL, MySQL или SQLite, не модифицируя код пакетаdatabase/sql.
Заключение
Принцип открытости-закрытости позволяет проектировать гибкие системы, которые возможно расширять без модификации старого кода. Роберт Мартин считает этот принцип самым главным, вокруг которого строятся все остальные принципы SOLID.
Мы увидели один из симптомов нарушения OCP: если вы видите простыню из if’ов, возможно, стоит подумать, как зарефакторить код, чтобы он был расширяем.
Мы рассмотрели два варианта соблюдения OCP: через паттерны Стратегия и Декоратор, а также показали, что принцип может соблюдаться не только на уровне типов, но и на уровне целых систем. В обоих примерах OCP достигается за счёт абстракций: Calculator зависит от интерфейса Bonus, а не от конкретных типов бонусов; BatchDecorator оборачивает интерфейс Messenger, а не конкретный мессенджер. Именно так OCP связан с принципом инверсии зависимостей (DIP) – зависимость от абстракций может сделать код открытым для расширения.