Встречаются два эксперта-консультанта по конструированию программного обеспечения:
- Как написать сложное корпоративное приложение, поддерживать которое будет всегда легко и дешево.
- Могу рассказать...
- Рассказать и я могу! Написать-то как?..
Время чтения: 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.
Emelian
Статья интересная и достаточно сложная для первого восприятия. Возможно, я к ней еще не раз вернусь. Но пока хочется чего-нибудь более простого и наглядного.
Я, как любитель, делал много попыток написания разного кода на С++ с фреймворками. По истечении некоторого времени, все благополучно забывалось, и надо было начинать все сначала. Поэтому решил озадачиться идеей модульного программирования, так чтобы проблемы можно было бы искать в пределах одного модуля, а не всего проекта.
Но модульность у меня двоякая. Проектирование и тестирование должно использовать бинарную модульность, практически, плагины. А конечный проект, формально монолитный, но структурно модульный, должен генерироваться автоматически из независимого кода модулей (плагинов) в общий код проекта, с помощью скрипта на Питоне.
Другими словами, сначала делаем проект, состоящий из одних плагинов и минимального главного модуля – приложения, которое автоматически загружает все наши плагины. При этом одному плагину соответствует один пункт меню (с горячими клавишами) и одно самодостаточное окно. Кроме того, плагины могут работать с клиентской частью главного окна приложения и, соответственно, не требовать горячих клавиш и меню. Также допустимы сложные плагины, которые подгружают другие зависимые от них плагины.
Но это все «теория». Начал я свои эксперименты (недавно) с самого простого варианта. Сгенерировал мастером VS C++ простейшее оконное приложение на WinAPI, содержащее всего два пункта меню: «Выход» и «О программе», которое оформил не в виде диалога, а виде дочернего окна. Последний удалил и преобразовал в виде плагина. Однако, несмотря на кажущуюся простоту, связь между главной программой и ее плагином «About», оказалась достаточно сильной. Это и общие переменные (нужно выработать стратегию по работе с ними) и конструирование объединенного меню и, главное, совместная, непротиворечивая, работа всех циклов сообщений. Здесь, как ни странно, трудности возникли с закрытием дочернего окна, в некоторых случаях происходят утечки памяти и зависание программы с ее полным крэшем.
Хотелось бы найти готовый прототип подобного примера в Интернете, но плагины там не любят работать с окнами, только с консолью. Поэтому приходится изобретать очередной велосипед самому.
nicholassoven Автор
Довольно интересная история из вашего опыта.
Если не ошибаюсь, в разработку оконных приложений WinAPI уже заложен сильный паттерн проектирования «из коробки», включающий взаимодействие модели предметной области, представления и управления. Но это всё на уровне целого приложения. Прелесть компонентной архитектуры кода в том, что сами компоненты можно декомпозировать до тривиального уровня, применяя к каждому уровню такой детализации свой подходящий паттерн.
Инкапсуляция логики части программы в свои отдельные компоненты-плагины безусловно должна иметь право на существование. Если я правильно понял мысль, то плагины в теории можно взаимно заменять, компоновать в новые плагины, которые будут иметь тот же интерфейс. А это, на мой взгляд, уже большой рывок к более эффективной последующей поддержке кода. На общие переменные двух компонентов, я уверен, тоже есть решение. Не рискну сказать, что точно поможет, без погружения в детали, но можно предложить совсем ограничить доступ к полям объектов, оперировать только методами, использовать различного рода абстракцию. В более сложных случаях, например, можно было бы применить Наблюдатель и т.п. Главное — не сдаваться.
Кто ищет, тот найдет. Верю, что вы всё-таки найдете искомый пример подобной архитектуры кода или самостоятельно придёте к некому своему решению. После того, как поиск закончится успехом, можно смело выкладывать статью про это. Я бы с удовольствием взглянул на что-либо подобное.
Emelian
Если я правильно понимаю, то паттерны это алгоритмы кода высокого уровня, другими словами, идеи реализации.
WinAPI либо WTL / ATL на С++, это, в принципе, все, что мне надо. Но чтобы реализовывать там паттерны (идеи, в моем понимании), нужно понимать протоколы работы функций и компонент WinAPI, т.е., знать документацию, типа MSDN.
Вот я и споткнулся там, на «правильном» протоколе удаления дочернего окна. Документация говорит, что, в дочернем окне надо использовать вызовы: «DestroyWindow(hWnd); break;», при сообщении WM_CLOSE, и вызовы: «PostQuitMessage(0); break;», при сообщении WM_DESTROY. Но это-то как раз и приводит к краху при выходе из приложения, при открытом дочернем окне. Но если его вручную закрыть, то программа, потом, завершается нормально.
Естественно, использовал уже различные мыслимые и немыслимые варианты, как со стороны главного окна, так и со стороны «плагина». Тем более что я могу отслеживать все сообщения, и дочернего окна, и его родителя. Но все упирается в отсутствие механизма «хорошего» удаления окна (созданного в dll), во всех случаях. Иногда срабатывает и обычный способ, но не всегда.
Сейчас пытаюсь анализировать опенсорс с поддержкой плагинов. Естественно, есть какой-то нюанс, который я пока не понимаю. Но сама идея бинарной модульности для целей разработки и тестирования приложений с последующей сборкой «монолитного» проекта скриптом на Питоне, достаточно интересна. Однако, Дьявол, как всегда кроется в деталях… :)
nicholassoven Автор
Emelian, мы с вами в этом треде комментария обсуждаем слегка другую тему, чем та, которая поднимается в статье. Как я понял, вы говорите о том, как чудесно было бы быстро собирать приложения, имея в вооружении набор неких плагинов или бинарных модулей. То есть речь идёт про профит при непосредственных начальных проектировании и конструировании приложений.
Я же пишу о том, что все ставки надо делать на более поздние этапы жизненного цикла разработки. А это чаще всего предполагает принесение в жертву удобных процессов той самой начальной разработки: время создания приложения увеличивается, приходится больше продумывать детали взаимодействия компонентов приложения, уделять внимание внешним зависимостям, где-то нужно добавлять дополнительное копирование одних и тех же данных из одного класса в другой и т.п. В общем моя мысль — отдать максимальное количество сил при проектировании и конструировании программного обеспечения, чтобы потом при дальнейшей поддержке приложения не отдать максимальное количество нервных клеток.
Emelian
Судя по вашей цели, у меня те же мысли. Только подход у нас разный. Я бы сказал, что вы ориентируетесь на оптимальные, управляемые и прогнозируемые отношения между, пусть это будет, бизнес объектами. Я к этому тоже стремлюсь, назовем проще, бизнес логике. Только для меня это следующий этап.
Сейчас меня больше интересует форма, а не содержание. Конкретно, программный интерфейс. При всей кажущейся простоте, работа с ним достаточно сложная, особенно если хочешь выйти за пределы стандарта.
Но программная логика и бизнес логика, естественно, тоже имеют значение и их тоже надо учитывать. Вы предлагаете это «продумывать» и я тоже. Концептуально, я решил использовать для этого, скажем так, временную бинарную модульность. Ее, кстати, можно делать и постоянной, если устроит производительность приложения.
Как я уже заметил, даже кажущиеся относительно независимые части приложения имеют, на самом деле, сильную связь. Разорвать которую, иначе говоря, распараллелить используемую программную модель, достаточно сложно. Но, пока, интересно. Тем более что в век потоков, субъядер и многопроцессорным систем этим сам Бог велел заниматься. Кстати, как вы управляете потоками в своем приложении?
В моей парадигме, «быстро собирать приложения», конечно, хорошо, но главное, пожалуй, не это. Важна именно логическая независимость бинарных модулей, а, следовательно, и большая легкость их отладки и тестирования. Да и разбираться, последовательно, с работой каждого модуля, все же проще, чем сразу со всем программным монолитом.
Так что разногласия наши естественны, я ни разу не встречал, человека с абсолютно похожими интересами, если только это не работа по принуждению.
P.S. Что касается моей проблемы с закрытием дочернего окна, созданного в плагине, то, скорее всего, это связано с тем, что при завершении работы приложения, не осуществляется выгрузка dll, хотя я даю команду на это. Операционная система, она ведь «умнее» программиста, она просто уменьшает счетчик ссылок на объекты этой длл и выгрузит ее не раньше, чем он обнулится. А у меня ведь плагин еще и дополнительный пункт меню из dll создает. В общем, нужен как бы мониторинг всех объектов, работающих с ресурсами длл. Да, и в теории плагинов написано, что выгрузка длл, это не тривиальная задача. Нужно ли с этими вещами разбираться? Очевидно, каждый ответит по-разному.