BackendBit

К другим SOLID принципам

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

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

Внедрить изменение можно двумя способами:

  1. Модифицировать существующий код;
  2. Написать новый код и тем самым расширить систему.

Принцип открытости-закрытости (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
}
go
package 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
}
go
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 {
		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)
}
go
package 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)
}
go
package 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
Диаграмма классов паттерна Стратегия в примере с калькулятором зарплатCalculator зависит от интерфейса Bonus с методами IsPayable и Calculate. Три конкретных типа MiddleBonus, SeniorBonus и JuniorBonus реализуют интерфейс Bonus. Срез []Bonus является точкой расширения.<<interface>>Bonus+IsPayable(Employee) bool+Calculate(Employee) MoneyCalculator-bonuses: []BonusSeniorBonusMiddleBonusJuniorBonusТочка расширения –новые бонусы добавляютсябез модификации Calculator

Мы проходимся по каждому бонусу (стратегии), проверяем, подходит ли он текущему сотруднику; если да, то добавляем размер бонуса к общей сумме. Если ни один из бонусов не подошёл, возвращаем просто зарплату.

Точка расширения здесь – срез []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
Сравнение системы до и после добавления LoyaltyBonusДве панели. Слева – система с тремя бонусами (MiddleBonus, SeniorBonus, JuniorBonus), реализующими интерфейс Bonus, и Calculator. Справа – та же система с добавленным LoyaltyBonus. Calculator и существующие бонусы не изменились.ДоCalculator-bonuses: []Bonus<<interface>>Bonus+IsPayable(Employee) bool+Calculate(Employee) MoneySeniorBonusMiddleBonusJuniorBonusПослеCalculator-bonuses: []Bonus<<interface>>Bonus+IsPayable(Employee) bool+Calculate(Employee) MoneySeniorBonusMiddleBonusJuniorBonusLoyaltyBonusНовый тип –существующий кодне изменился

Мы расширили систему без модификации старого кода. Единственное место, которое пришлось изменить, – точка сборки, где создаётся калькулятор. Код самого 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
}
go

BatchDecorator реализует тот же интерфейс 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!")
}
go

Middleware оборачивает обработчик, добавляя поведение без модификации оригинального кода – это по сути тот же декоратор:

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))
	})
}
go

Middleware можно комбинировать, наслаивая друг на друга:

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) – зависимость от абстракций может сделать код открытым для расширения.