Архитектура кода программного обеспечения: декорируем стратегией. Рассказ в 10 эпизодах, основанный на реальных событиях
Встречаются два эксперта-консультанта по конструированию программного обеспечения:
- Как написать сложное корпоративное приложение, поддерживать которое будет всегда легко и дешево.
- Могу рассказать...
- Рассказать и я могу! Написать-то как?..
Время чтения: 25 мин.
Разработка корпоративных приложений со сложной бизнес-логикой всегда несет за собой немалые затраты. Причём львиная доля затрат приходится не на саму разработку, а на поддержку кода приложения: добавление нового функционала, поиск и исправление допущенных ошибок, рефакторинг и т.п. Мне как разработчику ПО всегда хотелось найти “серебряную пулю” для вопросов, возникающих при конструировании кода приложений, как написать потенциально сложное приложение, чтобы его было поддерживать как можно легче и дешевле.
Есть много замечательной доступной литературы с теорией. Найти теорию – не проблема; проблема – применить найденную теорию на практике. Я являюсь сторонником конструирования исключительно поддерживаемого кода, всегда стараюсь найти новые способствующие этому подходы. К сожалению, часто подобные поиски тщетны. Приходится набираться опыта разработки поддерживаемых приложений самостоятельно, придумывать различные подходы. В этой статье хочу поделиться практическими знаниями о проектировании архитектуры кода программного обеспечения, полученными из опыта.
В самом начале статьи хотел бы заранее попросить прощения у читателя за "много букв". Честно говоря, пробовал выразить свою мысль в более короткой версии статьи — всё время казалось, что не хватает важных деталей... Надеюсь, статья будет вам интересна и полезна.
Введение в предметную область
"Красота" поддержки программного обеспечения во многом зависит от того, насколько много времени и сил было уделено самым первым этапам разработки (определение цели, выработка требований, разработка архитектуры и т.д.). Неверно сформулированные требования — это тоже ошибка, такая же, как упустить переполнение переменных целочисленных типов данных в коде. Но цена ошибок первых этапов, выявленных на стадии поддержки приложения, непозволительна велика по сравнению с "багами", допущенными в коде при конструировании. Подробнее об этой математике цен ошибок на различных стадиях разработки можно почитать в "Совершенном коде" Стива Макконнелла.
При написании своих приложений с непростой бизнес-логикой у нас в Ozon мы так же сталкиваемся с обозначенной проблемой. Чтобы написать программное обеспечение так, что его будет комфортно и недорого поддерживать, нужно нарабатывать соответствующие техники конструирования кода.
В этой статье я хочу предложить технику написания программ, в основе которой лежит два паттерна проектирования ООП: декоратор и стратегия. Я уверен, что основная часть читающих статью наверняка не раз сталкивалась с этими паттернами (возможно, даже на практике). Но чтобы все чувствовали себя "в своей тарелке", обращусь к определениям из "Паттернов проектирования" Эриха Гаммы, Ричарда Хелма, Ральфа Джонсона и Джона Влиссидеса (Банда четырех, Gang of Four, GoF):
Декоратор (Decorator, Wrapper) — паттерн проектирования, позволяющий динамически добавлять объекту новые обязанности. Является гибкой альтернативой порождению подклассов с целью расширения функциональности.
Стратегия (Strategy, Policy) — паттерн проектирования, который определяет семейство алгоритмов, инкапсулирует каждый из них и делает их взаимозаменяемыми. Стратегия позволяет изменять алгоритмы независимо от клиентов, которые ими пользуются.
Подход, который я называю "Декорирование стратегией" и который мы с вами будем рассматривать дальше, предполагает использование этих паттернов совместно друг с другом. Соответственно он не имеет смысла при их использовании порознь.
Декорирование стратегией, на мой взгляд, даёт великую пользу при поддержке приложений на очень большом жизненном цикле программного продукта. Компоненты в коде, написанные с применением данного подхода, соответствуют всем принципам дизайна SOLID из "Чистой архитектуры" Роберта Мартина. Каждый компонент, который мы напишем далее, будет отвечать только за одно действие; после написания нового компонента мы ни разу не модифицируем логику его методов, а лишь будем расширять ее в декорирующих компонентах; в силу паттерна "Декоратор" все расширяемые и расширяющие компоненты соответствуют одному контракту, следовательно их можно заменять друг другом; интерфейсы компонентов не содержат зависимостей, которые не используются; компоненты бизнес-логики ни в коей мере не зависят от деталей.
Я не раз сталкивался в обсуждениях с опытными разработчиками, которые говорят: "А вот всё, что связано с применением принципов SOLID, паттернов ООП на практике — это миф!". Любезно обращаясь к скептически настроенным к применению теории разработки в реальных больших корпоративных проектах, хочу сказать: "А вот посмотрим!"
Предлагаю обозначить несколько условностей. Код приводить я буду на языке Golang. Конечно Go — не самый лучший язык для демонстрации "фишек" ООП, но, во-первых, так мы покажем, что применение паттернов проектирования не должно страдать от выбора языка программирования, ибо язык — это априори инструмент, а во-вторых, для меня данный язык на сей день ближе всего находится к нашим реальным корпоративным проектам, которые успешно работают в продакшне.
Также я хочу выделить очень важные моменты, которые в реальном коде обязательно бы имели место, но так как код в статье имеет демонстрационное назначение, здесь эти моменты будут опускаться, дабы не "перетягивать на себя" ценное внимание читателя:
Должная обработка ошибок. В коде мы ограничимся оборачиванием ошибок дополнительным сообщением с помощью пакета "github.com/pkg/errors".
Обработка утверждений (assertion). В нашем коде мы полагаемся на тот факт, что все использующиеся указатели инициализированы, интерфейсные аргументы методов — заданы и т.д.
Комментарии и документирование кода.
Всё, что связано, с конкурентным выполнением задач и синхронизацией.
Структура файлов и директорий проекта.
Стили, линтеры и статический анализ.
Покрытие кода тестами.
Сквозь методы компонентов рекомендуется с первых этапов разработки "тянуть" context.Context, даже если он в тот момент не будет использоваться. Для упрощения повествования в примерах далее контекст также использоваться не будет.
Перейдём же наконец от скучной теории к занимательной практике!
Пролог. Закладываем фундамент
Последующее повествование я буду вести в ключе начального жизненного цикла разработки приложения с потенциально "сильно загруженной" бизнес-логикой. Чтобы не тратить время читателя, методы некоторых компонентов, не имеющих большого отношения к теме статьи, я буду просто обозначать и оставлять их реализацию под TODO
.
Итак, начнём. Здесь мы с вами — высококвалифицированные разработчики программных продуктов. К нам приходит наш первый заказчик от бизнеса и говорит что-то вроде: "Нам нужна функциональность обновления такой-то информации о пользователях нашей платформы". Мы обрабатываем требования, продумываем архитектуру и переходим к конструированию кода.
Первое, что нужно сделать — определить интерфейс нашего первого компонента — службы, которая будет представлять желаемый use-case SavePersonService
. Но для этого нам нужно определить объекты нашей предметной области, а именно структуру данных, содержащую информацию о человеке PersonDetails
. Создадим в корне проекта пакет app
, далее создадим файл app/person.go, и оставим в нём нашу структуру:
// app/person.go
type PersonDetails struct {
Name string
Age int
}
Данный файл завершён, больше мы к нему в этой статье возвращаться не будем. Далее создаем файл app/save-person.go, и определяем в нём интерфейс нашего use-case:
// app/save-person.go
type SavePersonService interface {
SavePerson(id int, details PersonDetails) error
}
Оставим сразу рядом с определением интерфейса его первую реализацию — компонент noSavePersonService
, который ничего не делает в теле интерфейсного метода:
// app/save-person.go
// ... предыдущий код ...
type noSavePersonService struct{}
func (noSavePersonService) SavePerson(_ int, _ PersonDetails) error { return nil }
Поскольку объекты noSavePersonService
не содержат состояния, можно гарантировать, что данный "класс" может иметь только один экземпляр. Напоминает паттерн проектирования Синглтон (Singleton — ещё его называют Одиночка, но мне это название по ряду причин не нравится). Предоставим глобальную точку доступа к нему. В Golang легче всего это сделать, определив глобальную переменную:
/ app/save-person.go
// ... предыдущий код ...
var NoSavePersonService = noSavePersonService{}
Зачем мы написали ничего не делающий компонент? С первого взгляда он очень походит на заглушку. Это не совсем так. Далее поймём.
Эпизод 1. Будем знакомы, Декоратор Стратегией
Перейдём непосредственно к реализации бизнес-логики нашей задачи. Нам нужно в конечном счёте иметь хранилище, в котором содержатся данные о пользователях. С точки зрения выбора технологии мы сразу себе представляем, что будем использовать PostgreSQL, но правильно ли завязываться в коде нашей бизнес-логики на конкретную технологию. Вы правы — конечно нет. Определить компонент нашего хранилища нам позволит паттерн Репозиторий (Repository). Создадим пакет с реализациями интерфейса нашего use-case save-person
внутри app
, и в нём создадим файл app/save-person/saving_into_repository.go реализации нашего use-case, которая обновляет данные в репозитории:
// app/save-person/saving_into_repository.go
type PersonRepository interface {
UpdatePerson(id int, details app.PersonDetails) error
}
type SavePersonIntoRepositoryService struct {
base app.SavePersonService
repo PersonRepository
}
func WithSavingPersonIntoRepository(base app.SavePersonService, repo PersonRepository) SavePersonIntoRepositoryService {
return SavePersonIntoRepositoryService{base: base, repo: repo}
}
func (s SavePersonIntoRepositoryService) SavePerson(id int, details app.PersonDetails) error {
err := s.base.SavePerson(id, details)
if err != nil {
return errors.Wrap(err, "save person in base in save person into repository service")
}
err = s.repo.UpdatePerson(id, details)
if err != nil {
return errors.Wrap(err, "update person in repo")
}
return nil
}
В коде выше впервые появляется компонент, который выражает наш подход "Декорирование стратегией". Сам компонент представляет собой декоратор, реализующий интерфейс нашего use-case, который оборачивает любой компонент с таким же интерфейсом. В реализации метода изначально вызывается метод декорируемого объекта s.base
; после этого происходит вызов стратегии обновления данных о человеке в хранилище s.repo
. По сути, весь подход — это конструирование компонентов-декораторов, которые содержат два объекта:
Непосредственно декорируемый объект с таким же интерфейсом.
Стратегия, логику которой мы добавляем в довесок к логике декорируемого объекта.
Структурная схема программы, собранной из декораторов стратегий может выглядеть примерно так:
Компонент сам по себе настолько прост, что самое сложное, пожалуй, это определить, когда следует вызывать метод стратегии – до или после вызова метода декорируемого объекта или конкурентно с ним.
Напомню, что бизнес-логика не должна содержать ненужные зависимости, зависимости от деталей и т.п. Другими словами, бизнес-логика должна быть "чистая, как слеза". Где тогда должны находиться зависимости от конкретных реализаций, зависимости от используемых технологий? Ответ — в файле main.go. Следуя замечаниям Роберта Мартина, можно сделать умозаключение, что код компонентов файла, содержащего точку входа в программу, является самым "грязным" с точки зрения зависимостей от всего. Обозначим в main.go метод, который нам возвращает клиент к базе данных PostgreSQL. И собственно сборку объекта службы нашего use-case и вызов его метода на условных входных данных:
// main.go
func NewPostgreSQLDatabaseClient(dsn string) savePerson.PersonRepository {
_ = dsn // TODO implement
panic("not implemented")
}
func run() error {
userService := savePerson.WithSavingPersonIntoRepository(
app.NoSavePersonService,
NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/users?sslmode=disable"))
err := userService.SavePerson(5, app.PersonDetails{
Name: "Mary",
Age: 17,
})
if err != nil {
return errors.Wrap(err, "save user Mary")
}
return nil
}
В коде выше мы можем заметить, что в качестве стратегии репозитория выступает обозначенный конкретный компонент клиента к PostgreSQL. В качестве же декорируемого объекта выступает наша "фиктивная" реализация use-case app.NoSavePersonService
, которая по сути ничего не делает. Зачем она нужна? Она ничего полезного ведь не делает? Не легче ли просто вызвать метод клиента к базе данных? Спокойно, звёздный час этой реализации сейчас настанет.
Эпизод 2. Магия начинается!
Допустим, к нам приходит технический руководитель и ставит перед нами следующую задачу. В коде где-то в другом месте есть функциональность, где данные о пользователе запрашиваются из хранилища. Поскольку запрос данных из базы длится достаточно долго, предлагается данные также кэшировать в памяти. Этот кэш должен инвалидироваться после каждого сохранения пользователя в базу данных. В main.go добавляется функция, которая возвращает компонент управления кэша в памяти:
// main.go
// ... предыдущий код ...
func NewMemoryCache() savePerson.PersonRepository {
// TODO implement
panic("not implemented")
}
// ... последующий код ...
Так как этот компонент реализует интерфейс нашего репозитория, мы можем очень изящно выполнить поставленную задачу, не меняя кода бизнес-логики, а всего лишь дополнительно обернуть наш компонент службы в main.go, создав новый, который использует также стратегию сохранения пользователя в кэш:
// main.go
// внутри run()
userService := savePerson.WithSavingPersonIntoRepository(
savePerson.WithSavingPersonIntoRepository(
app.NoSavePersonService,
NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/users?sslmode=disable")),
NewMemoryCache(),
)
err := userService.SavePerson(5, app.PersonDetails{
Name: "Mary",
Age: 17,
})
if err != nil {
return errors.Wrap(err, "save user Mary")
}
Всё, что мы тут делаем в итоге — два раза декорируем наш "холостой" сервис обновлениями данных в двух репозиториях разного происхождения. Теперь мы можем добавлять обновление данных в новых репозиториях достаточно быстро и комфортно.
Ссылка на diff эпизода
Ссылка на полный код эпизода
Эпизод 3. Рефакторинг для здоровья
В предыдущем листинге кода создание сервиса выглядит достаточно громоздко. Нетрудно догадаться, применяя наш подход, мы продолжим и далее всё больше и больше оборачивать компонент, добавляя к логике новые стратегии. Поэтому мы, как опытные разработчики, замечаем эту потенциальную трудность и производим небольшой рефакторинг когда. Нам поможет паттерн Билдер (Builder — опять же мне не очень нравится ещё одно его название — Строитель). Это будет отдельный компонент, зона ответственности которого — предоставить возможность сборки объекта службы нашего use-case. Файл app/save-person/builder.go:
// app/save-person/builder.go
type Builder struct {
service app.SavePersonService
}
func BuildIdleService() *Builder {
return &Builder{
service: app.NoSavePersonService,
}
}
func (b Builder) SavePerson(id int, details app.PersonDetails) error {
return b.service.SavePerson(id, details)
}
Компонент Builder
должен обязательно реализовывать интерфейс службы нашего use-case, так как именно он будет использоваться в конечном счёте. Поэтому мы добавляем метод SavePerson
, который вызывает одноименный метод объекта в приватном поле service
. Конструктор данного компонента называется BuildIdleService
, потому что создаёт объект, который ничего не будет делать при вызове SavePerson
(нетрудно заметить инициализацию поля service
объектом app.NoSavePersonService
). Зачем нам нужен этот бесполезный компонент? Чтобы получить всю истинную пользу, необходимо обогатить его другими методами. Эти методы будут принимать в параметрах стратегию и декорировать ею объект службы в поле service
. Но вначале сделаем конструктор WithSavingPersonIntoRepository
в app/save-person/saving_into_repository.go приватным, так как для создания службы мы теперь будем использовать только Builder
:
// app/save-person/saving_into_repository.go
// ... предыдущий код ...
func withSavingPersonIntoRepository(base app.SavePersonService, repo PersonRepository) SavePersonIntoRepositoryService {
return SavePersonIntoRepositoryService{base: base, repo: repo}
}
// ... последующий код ...
Добавляем соответствующий метод для Builder
:
// app/save-person/builder.go
// ... предыдущий код ...
func (b *Builder) WithSavingPersonIntoRepository(repo PersonRepository) *Builder {
b.service = withSavingPersonIntoRepository(b.service, repo)
return b
}
И наконец производим рефакторинг в main.go:
// main.go
// ... предыдущий код ...
userService := savePerson.BuildIdleService().
WithSavingPersonIntoRepository(
NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/platform?sslmode=disable")).
WithSavingPersonIntoRepository(NewMemoryCache())
// ... последующий код ...
Ссылка на diff эпизода
Ссылка на полный код эпизода
Эпизод 4. Больше заказчиков!
Через несколько дней успешной работы нашего кода в продакшне, к нам приходит другой заказчик от бизнеса и просит реализовать функциональность обновления информации о налогоплательщиках в отдельном хранилище. По неким причинам, обсуждение которых находится за пределами данной статьи, мы понимаем, что эту информацию лучше хранить в MongoDB. Клиент к базе добавляется в main.go:
// main.go
// ... предыдущий код ...
func NewMongoDBClient(dsn string) savePerson.PersonRepository {
_ = dsn // TODO implement
panic("not implemented")
}
// ... последующий код ...
Воспользуемся нашим билдером и просто добавим новый код в main.go под имеющийся фрагмент с userService
:
// main.go
// ... предыдущий код ...
taxpayerService := savePerson.BuildIdleService().
WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).
WithSavingPersonIntoRepository(NewMemoryCache())
err = taxpayerService.SavePerson(1326423, app.PersonDetails{
Name: "Jack",
Age: 37,
})
if err != nil {
return errors.Wrap(err, "save taxpayer Jack")
}
Мы выполнили уже столько поставленных задач, имея небольшой фрагмент кода бизнес-логики. Заметьте, изменения преимущественно вносятся в файл main.go
Ссылка на diff эпизода
Ссылка на полный код эпизода
Эпизод 5. Путь в никуда
Проходит ещё время. Заказчик №2 ставит нам такую задачу. Так как все налогоплательщики должны быть совершеннолетними, необходимо в бизнес-логику добавить функциональность проверки возраста человека перед сохранением в хранилище. С этого момента начинаются интересные вещи. Мы можем добавить эту валидацию в метод SavePersonIntoRepositoryService.SavePerson
в файле app/save-person/saving_into_repository.go. Но тогда при нескольких декорированиях стратегией сохранения информации в репозиторий эта валидация будет вызываться столько раз, сколько производилось таких декораций. Хотя и все проверки помимо первой никак не влияют на результат напрямую, всё-таки не хочется лишний раз вызывать один и тот же метод.
Мы можем добавить валидацию в Builder.SavePerson
. Но есть проблема: заказчику №1 не нужна проверка возраста при сохранении. Придётся добавить if
и дополнительный флаг в параметры конструктора, который будет определять необходимость валидации:
// app/save-person/builder.go
type Builder struct {
service app.SavePersonService
withAgeValidation bool
}
func BuildIdleService(withAgeValidation bool) *Builder {
return &Builder{
service: app.NoSavePersonService,
withAgeValidation: withAgeValidation,
}
}
func (b Builder) SavePerson(id int, details app.PersonDetails) error {
if b.withAgeValidation && details.Age < 18 {
return errors.New("invalid age")
}
return b.service.SavePerson(id, details)
}
// ... последующий код ...
И тогда в main.go нужно вызывать конструкторы билдера с разными значениями флага withAgeValidation
:
// main.go
// ... предыдущий код ...
userService := savePerson.BuildIdleService(false).
// ... код ...
taxpayerService := savePerson.BuildIdleService(true).
// ... последующий код ...
Теперь код будет работать так, как это от него требуется. Но есть поверье, что если в бизнес-логике появляется if
, то положено твердое начало прохождению всех кругов ада при дальнейшей поддержке, будьте уверены.
Ссылка на diff эпизода
Ссылка на полный код эпизода
Эпизод 6. Путь истины
В этом эпизоде мы постараемся решить поставленную задачу предыдущего эпизода более изящно. Изменения начнём вносить в код, полученный в результате эпизода 4.
Добавим новый компонент, который будет отвечать за валидацию при сохранении информации о людях:
// app/save-person/validating.go
type PersonValidator interface {
ValidatePerson(details app.PersonDetails) error
}
type PreValidatePersonService struct {
base app.SavePersonService
validator PersonValidator
}
func withPreValidatingPerson(base app.SavePersonService, validator PersonValidator) PreValidatePersonService {
return PreValidatePersonService{base: base, validator: validator}
}
func (s PreValidatePersonService) SavePerson(id int, details app.PersonDetails) error {
err := s.validator.ValidatePerson(details)
if err != nil {
return errors.Wrap(err, "validate person")
}
err = s.base.SavePerson(id, details)
if err != nil {
return errors.Wrap(err, "save person in base in pre validate person service")
}
return nil
}
Опять ничего нового. PreValidatePersonService
— это очередной декоратор стратегией валидации перед последующим вызовом декорируемого метода.
Добавим соответствующий метод в Builder
:
// app/save-person/builder.go
// ... предыдущий код ...
func (b *Builder) WithPreValidatingPerson(validator PersonValidator) *Builder {
b.service = withPreValidatingPerson(b.service, validator)
return b
}
Добавление каждого нового декоратора стратегией требует добавление нового метода в наш билдер.
Добавим реализацию валидатора, проверяющую возраст человека:
// main.go
// ... предыдущий код ...
type personAgeValidator struct{}
func (personAgeValidator) ValidatePerson(details app.PersonDetails) error {
if details.Age < 18 {
return errors.New("invalid age")
}
return nil
}
var PersonAgeValidator = personAgeValidator{}
// ... последующий код ...
Так как personAgeValidator
не имеет состояния, можем сделать для компонента единую точку доступа PersonAgeValidator
. Далее просто вызываем новый метод в main.go только для taxpayerService
:
// main.go
// ... предыдущий код ...
taxpayerService := savePerson.BuildIdleService().
WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).
WithSavingPersonIntoRepository(NewMemoryCache()).
WithPreValidatingPerson(PersonAgeValidator)
// ... последующий код ...
Ссылка на diff эпизода
Ссылка на полный код эпизода
Эпизод 7. А ну-ка закрепим
Уверен, к данному эпизоду вы поняли смысл подхода "Декорирование стратегией". Чтобы закрепить, давайте добавим ещё один такой компонент. Представим, технический руководитель требует от нас покрыть метриками время выполнения сохранения данных в хранилище. Мы могли бы замерить это время, просто добавив пару строчек кода в SavePersonIntoRepositoryService
. Но как бы не так! Мы же не изменяем уже работающий в продакшне код, а можем его только расширить. Давайте же так и сделаем. Добавим новый декоратор стратегией отправки метрики времени:
// app/save-person/sending_metric.go
type MetricSender interface {
SendDurationMetric(metricName string, d time.Duration)
}
type SendMetricService struct {
base app.SavePersonService
metricSender MetricSender
metricName string
}
func withMetricSending(base app.SavePersonService, metricSender MetricSender, metricName string) SendMetricService {
return SendMetricService{base: base, metricSender: metricSender, metricName: metricName}
}
func (s SendMetricService) SavePerson(id int, details app.PersonDetails) error {
startTime := time.Now()
err := s.base.SavePerson(id, details)
s.metricSender.SendDurationMetric(s.metricName, time.Since(startTime))
if err != nil {
return errors.Wrap(err, "save person in base in sending metric service")
}
return nil
}
Помимо компонента стратегии, отправляющего метрики, мы в конструкторе также передаем название метрики, которую мы хотим замерять. Добавляем новый метод в Builder
:
// app/save-person/builder.go
// ... предыдущий код ...
func (b *Builder) WithMetricSending(metricSender MetricSender, metricName string) *Builder {
b.service = withMetricSending(b.service, metricSender, metricName)
return b
}
И наконец обозначаем в main.go функцию, возвращающую savePerson.MetricSender
и добавляем вызов нового метода Builder в сборку наших сервисов:
// main.go
// ... предыдущий код ...
func MetricSender() savePerson.MetricSender {
// TODO implement
panic("not implemented")
}
// ... код ...
userService := savePerson.BuildIdleService().
WithSavingPersonIntoRepository(NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/platform?sslmode=disable")).
WithMetricSending(MetricSender(), "save-into-postgresql-duration").
WithSavingPersonIntoRepository(NewMemoryCache())
// ... код ...
taxpayerService := savePerson.BuildIdleService().
WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).
WithMetricSending(MetricSender(), "save-into-mongodb-duration").
WithSavingPersonIntoRepository(NewMemoryCache()).
WithPreValidatingPerson(PersonAgeValidator)
// ... последующий код ...
Обратите внимание, что новые методы мы ставим в цепочку вызовов там, где мы хотим производить замер.
Ссылка на diff эпизода
Ссылка на полный код эпизода
Эпизод 8. Результаты ясновидения
Проходит время. Заказчик №2 ставит новую задачу. Он желает знать, как долго выполняется сохранение данных о налогоплательщике, но с небольшой оговоркой: учитывать нужно всё, кроме валидации. Похоже на замер времени, который мы недавно реализовали для своих целей, не правда ли? Чтобы решить задачу, всё что нам требуется — это добавить вызов метода для новой метрики в main.go:
// main.go
// ... предыдущий код ...
taxpayerService := savePerson.BuildIdleService().
WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).
WithMetricSending(MetricSender(), "save-into-mongodb-duration").
WithSavingPersonIntoRepository(NewMemoryCache()).
WithMetricSending(MetricSender(), "save-taxpayer-duration").
WithPreValidatingPerson(PersonAgeValidator)
Ссылка на diff эпизода
Ссылка на полный код эпизода
Эпизод 9. Укрощение капризов
Мы вот только недавно произвели релиз последней задачи от заказчика №2, но он захотел изменить начальные требования. Такие изменения часто возникают на стороне заказчика, которые заставляют нас "перелопатить" весь код. Знакомо? На этот раз заказчик желает отказаться от оговорки из предыдущего эпизода и производить замер полного цикла сохранения данных о налогоплательщике вместе с валидацией. Если бы мы конструировали нашу бизнес-логику в виде сценария транзакции (transaction script), то это повлекло бы за собой непосредственное вмешательство в тело метода, copy-paste кода, что требует приложить силы, в том числе в процессе ревью, тестирования и т.п. В нашем же случае нам достаточно просто подвинуть вызов метода WithMetricSending
в цепочке методов создания объекта службы в main.go:
// main.go
// ... предыдущий код ...
taxpayerService := savePerson.BuildIdleService().
WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).
WithMetricSending(MetricSender(), "save-into-mongodb-duration").
WithSavingPersonIntoRepository(NewMemoryCache()).
WithPreValidatingPerson(PersonAgeValidator).
WithMetricSending(MetricSender(), "save-taxpayer-duration")
В коде выше мы поменяли местами второй WithMetricSending
и WithPreValidatingPerson
.
Задача от заказчика выглядит надуманной. Но напомню, что цель статьи — не придумать качественные задачи заказчиков, а продемонстрировать пользу архитектуры кода при использовании подхода "Декорирование стратегией".
Ссылка на diff эпизода
Ссылка на полный код эпизода
Эпизод 10. Взгляд в будущее
Этот заключительный эпизод всего лишь подчеркивает потенциал дальнейших доработок логики данного кода. Что ещё может пожелать заказчик от бизнеса или с технической стороны? Вариантов более чем достаточно. Может потребоваться функциональность отправки асинхронных событий об изменении информации о человеке (полезно при ведении журнала аудита, коммуникации с другими сервисами и т.д.). Может понадобиться введение механизма гомогенных и даже гетерогенных транзакций. Возможно, потребуется добавить запрос данных к соседнему микросервису. По техническим соображениям возможно будет нужен предохранитель (circuit-breaker) для таких запросов к другим сервисам. Наверняка нужно будет добавлять механизм трассировки (tracing). И многое-многое другое.
Каждой новой функциональности в нашей архитектуре будет соответствовать свой компонент декоратора со стратегией. Каждый компонент мал и самодостаточен, легко расширяется и, в целом, поддерживается.
Эпилог. Подводим итоги
Вышеописанный подход конструирования программного обеспечения представляет набор моих субъективных взглядов. Я пришёл к нему однажды, был приятно воодушевлён его пользой. Велика вероятность, что вы тоже используете такой подход, называя его как-то иначе. Возможно, вы к нему тоже приходили, но он вам не понравился. Ни в коем случае не хочу сказать, что данный подход является единственным истинным при разработке.
Есть ли у подхода минусы? Однозначно есть. Подход нежелательно использовать, если, например, мы пишем код, который планируем использовать единожды, или пишем некий скрипт, время на введение предметной модели в который будет потрачено неоправданно.
Но для больших корпоративных приложений наличие подобного подхода просто желательно-обязательно. Если продукт подразумевает длительную поддержку (обычно это условие присутствует всегда), то объектная модель приложения будет иметь значительное преимущество над незамысловатым "полотном" кода сценария транзакции. Я приведу далее график, в основе которого лежит график из "Шаблонов корпоративных приложений" Мартина Фаулера.
Что есть что на этом графике? Почему на осях нет чисел? Всё потому что график абстрактный. Он отражает качественный смысл содержимого, не количественный. По горизонтальной оси у нас время, прошедшее с момента начала разработки продукта. Или если желаете, количество добавлений новой функциональности в изначально разработанный продукт. Меру по вертикальной оси тоже можно выразить различными способами. Это может быть цена добавления новой строчки кода функционала в денежном эквиваленте; может быть время добавления новой функциональности; может быть количество потраченных нервных клеток разработчиком, ревьювером или тестировщиком. Красный график демонстрирует зависимость этих величин для подхода разработки, который называется сценарием транзакции (Transaction Script) — последовательно следующие друг за другом инструкции. Синий график показывает эту зависимость для подхода модели предметной области (Domain Model).
Сравнивая эти зависимости, мы можем увидеть, что сценарий транзакции выигрывает у модели предметной области на первых стадиях разработки продукта. Да, это на самом деле так: когда продукт мал и зелен, вносить новую функциональность можно с ходу, не задумываясь о деталях. Но однозначно настанет время, когда стоимость добавления новых возможностей в "полотно" кода "возносится" резко вверх.
Сложность внесения нового функционала при использовании модели предметной области, конечно, тоже растёт, но линейно. Это говорит о том, что на поздних стадиях разработки продукт, сделанный при использовании подходов модели предметной области, будет обходиться гораздо дешевле, чем проект, сделанный при использовании более простых подходов "в лоб".
Содержание статьи изложено на основе моего субъективного понимания. Любые замечания с удовольствием готов обсуждать в комментариях. Использовать "Декорирование стратегией" или нет — личное решение каждого. Главное, я считаю, нужно помнить о том, что мы как разработчики должны в первую очередь уделять внимание не бизнесу, не пользователю, не выделенным машинным ресурсам, а нашему коллеге — такому же разработчику, который через несколько лет будет добавлять в наш код новую функциональность.
Литература
Макконнелл С. Совершенный код. Мастер-класс., 2020.
Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы объектно-ориентированного проектирования. Паттерны проектирования., 2020.
Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения., 2020
Фаулер, Мартин. Шаблоны корпоративных приложений., 2020.