Примечание: Этот пост является адаптированной расшифровкой моего одноимённого доклада на Joker' 24. |
Введение
Я пишу коммерческий код с 2005 года и с 2014 года ищу способ систематически писать хороший код.
В рамках этих поисков я изучил всю популярную литературу о хорошем коде и его дизайне — от «Чистого кода» Анкл Боба до «DDD» Эрика Эванса. Однако все популярные подходы в значительной степени субъективны: они не дают объективного и последовательного судьи, который бы решал, какой код лучше.
Например, в чистом коде я до сих пор не знаю способа за конечное время дать ответ на вопрос «Сколько уровней абстракции в этой функции?». А если взять DDD — то я до сих пор не знаю способа, который бы позволял стабильно и за конечное время находить границы между ограниченными контекстами (прошу прощения за каламбур) или агрегатами.
Эта неопределённость ведёт к длительным дискуссиям на ревью и в голове разработчика о том, какой из способов является наилучшим для решения задачи. А после этих дискуссий, каждый из участников (включая того дилетанта в собственной голове) остаётся при своём мнении.
Отчаявшись научиться писать стабильно хороший объектно‑ориентированный код, в 2016 году я пошёл в сторону функционального программирования и архитектуры. Там с детерминированностью было получше: если в коде нет побочных эффектов (ввода‑вывода, оператора присваивания и чтения глобальных переменных) — то код хороший, если есть — плохой. Однако как затащить в коммерческий проект и, главное, собственную голову свободные монады и их интерпретаторы — я так и не понял.
Поэтому в 2020 году поиски своего Святого Грааля я продолжил в «эзотерических» и древних книгах. Одной из таких книг стал «Структурный дизайн» Ларри Константина. И в этой книге я, наконец, нашёл простой и понятный принцип, который лёг в основу моего текущего подхода к проектированию и кодированию, и для которого можно быстро и однозначно дать ответ, соответствует ли тот или иной кусочек кода этому принципу или нет.
В этом посте я даю краткий экскурс в идею сбалансированной формы системы из структурного дизайна и рассказываю на примере трёх кейсов о результатах применения этой идеи в моей коммерческой практике.
Экспресс-курс структурного дизайна
Структурный дизайн — это методология разработки ПО, которую Ларри Константин и Эдвард Йордан начали разрабатывать в 60-х годах прошлого века и в итоге опубликовали в одноимённой книге в 1977 году (доступна для чтения после бесплатной регистрации).
Самым известным наследием структурного дизайна стали понятия cohesion и coupling — они впервые были введены именно в этой книге.
Но на мой взгляд, незаслуженно была забыта другая идея из структурного дизайна — transform‑centered morphology (aka balanced system).
Примечание: Transform‑centered morphology — это термин из оригинальной книги. Однако он звучит неуклюже даже в английском варианте, поэтому далее по тексту я, вслед за Пейдж‑Джонсом, автором «The practical guide to structured systems design» (следующей книги по структурному дизайну), буду использовать термин balanced system — сбалансированная система. |
Идея предельно проста: при разработке код надо разделять на четыре типа — управляющий код, код чтения данных, код трансформации и код записи данных.
И если систему, разработанную с разделением кода на эти типы, представить в виде структурной схемы, то получится примерно такая картинка (здесь и далее все картинки кликабельные):
Примечание: на картинке узлы называются модулями, однако следует помнить, что книге почти 50 лет и в ней термину модуль даётся следующее определение: «A module is a lexically contiguous sequence of program statements, bounded by boundary elements, having an aggregate identifier». Если переводить на русский, то модуль — это лексически непрерывная последовательность программных выражений, ограниченная граничными элементами и имеющая совокупный идентификатор. То есть это метод, в современной терминологии. Соответственно, стрелками от блока к блоку обозначаются вызовы методов, а маленькими стрелками вдоль стрелок‑вызовов обозначается поток данных между методами. |
Здесь четыре типа кода обозначены цветами:
Красный — код чтения данных. По стрелкам потока данных видно, что этот код берёт данные «из ниоткуда» (на самом деле — с диска, из БД, из сети, из глобальных переменных и т. д.) и заносит их в систему.
Зелёный — код трансформации. По стрелкам потока данных видно, что этот код что‑то получает на вход и что‑то возвращает. Притом по стрелкам вызовов видно, что код трансформаций сам не обращается к коду ввода или вывода, соответственно, все нужные ему данные он получает «сверху» и все результаты своей работы возвращает «наверх» (спойлер: если это вам напоминает чистые функции, то вы правы).
Жёлтый — код записи данных. По стрелкам потока данных видно, что этот код «сливает данные в никуда» (на самом деле — на диск, в БД, в сеть, в глобальные переменные и т. д.).
Голубой — код координации. Это код, который никто не вызывает (на самом деле в оригинале — код, который вызывается ОС, а в современных реалиях — код, который вызывается фреймворком), но который вызывает все другие виды кода и определяет поток данных между ними.
В книге Константин пишет, что он проводил эмпирические исследования, которые показали корреляцию между соответствием системы этой форме и низкой стоимостью её разработки и поддержки.
Если перекладывать эту абстрактную модель на современные реалии (Spring‑) разработки, то она будет выглядеть так:
На первый взгляд, все термины в ней привычные и знакомые — уверен, практически все разработчики если не работали с, то хотя бы слышали про сервисы приложения (Application Service), доменные сервисы (Domain Service), гейтвеи и репозитории (Gateway и Repository) и Data Transfer Objects.
Но есть две детали, которые существенно отличают эту модель от устройства большинства коммерческих кодовых баз и ведут к существенному упрощению кодовых баз и, как следствие, ускорению работы команды и самого кода.
Во‑первых, метод доменного сервиса не выполняет никакого ввода‑вывода. Во‑вторых, все типы кода обмениваются между собой DTO‑шками — простыми структурами данных, не содержащими в себе автомагии (такой как ленивая загрузка и дёрти чекинг в JPA‑сущностях).
Давайте посмотрим, что будет, если применять эту идею в современной разработке и приводить форму систем к сбалансированной.
Кейс №1: Project Barcoder
И начнём мы с кусочка кода из Project Barcoder — внутренней системы предприятия для автоматизации бизнес‑процесса архивации отчётных документов.
Бизнес-процесс
Обобщённо бизнес‑процесс следующий:
Филиалы предприятия собирают отчётность за один или более периодов в подручную тару (коробки, в коде — Box);
Экспедиторы доставляют коробки в пункты сбора (ПС);
Специалисты ПС переупаковывают документы из коробок в стандартизированные коробы (в коде — OsgBox);
Специалисты ПС передают коробы в специализированную организацию для долговременного хранения;
Бизнес‑правила:
В одной коробке могут содержаться документы за несколько отчётных периодов;
В одном коробе могут содержаться документы за несколько отчётных периодов;
Для каждого отчётного периода коробки необходимо сохранять привязку, в какие коробы он попал;
При переупаковке допустимы только два варианта:
Переупаковка из одной коробки в несколько коробов;
Переупаковка нескольких коробок в один короб.
В этом кейсе контроль сбалансированности системы помог мне на ревью отловить проблему в работе с БД, которую я пропустил при первой итерации ревью методом пристального вглядывания, а также помог в целом упростить код. Давайте посмотрим, как это было.
До применения принципов структурного дизайна
В процессе разработки системы мне принесли на ревью код реализации переупаковки коробок в короба и сначала я его ревьювил методом пристального вглядывания, начиная с корневого метода сервиса приложения:
@Transactional
open fun repackage(boxIds: List<Long>, osgBoxes: List<OsgBoxDto>) {
if (boxIds.isEmpty() || osgBoxes.isEmpty() || (boxIds.size > 1 && osgBoxes.size > 1)) {
throw OsgRepackageIncorrectAmountException("Допустима перепаковка только одной коробки в несколько коробов, либо нескольких коробок в один короб")
}
val osgIds = osgBoxes.map { it.osgBoxId }
if (osgService.hasAnyLinks(boxIds, osgIds)) {
throw OsgRepackageBoxAlreadyLinkedException("Некоторые из коробок или коробов уже имеют привязку")
}
if (boxIds.size == 1) {
repackageSingleBox(boxIds.single(), osgBoxes)
} else {
repackageMultipleBoxes(boxIds, osgBoxes.single())
}
}
По большому счёту, про этот код сложно сказать что‑то плохое — он достаточно короткий, аккуратный и простой. Пожалуй, название ошибки OsgRepackageIncorrectAmountException
не самое удачное, да условие boxIds.size == 1
можно было бы вынести в метод с названием, отражающим бизнес‑семантику — но нельзя сказать, что это кардинально улучшит качество кода. Тем не менее, всё равно напишем это в замечания и перейдём к следующему методу — обработке кейса перепаковки одной коробки в несколько коробов:
private fun repackageSingleBox(
boxId: Long,
osgBoxes: List<OsgBoxDto>,
) {
val box = boxRepo.findByIdOrNull(boxId) ?: throw BoxNotFoundException("Короб не найден")
box.reportingIntervals = toReportingIntervals(osgBoxes)
boxRepo.save(box) // В проекте используется Spring Data JDBC, поэтому вызов save обязателен
osgService.insertBoxesToOsgLinks(osgBoxes.map { it.osgBoxId }, listOf(boxId))
}
Тут код уже совсем простой и линейный — загрузили коробку, перепаковали, сохранили (в проекте используется Spring Data JDBC, поэтому save
надо вызывать явно), сохранили привязку коробки к коробам.
Наверное, в этом коде можно докопаться, что у метода osgService.insertBoxesToOsgLinks
слишком низкий уровень абстракции и он должен принимать на вход сущности, а не идентификаторы. Или нельзя — ведь тогда этот метод будет сложнее использовать, например, в случае, когда привязка выполняется напрямую по команде от фронта и передаются только ИДы 🤔.
Ладно, замечание напишем, на ревью похоливарим, обсудим и пойдём дальше к методу конвертации ДТО периодов в объекты‑значения (value objects из DDD, можно считать сущностью, если не знакомы с DDD):
private fun toReportingIntervals(osgPeriods: List<OsgBoxDto>) =
osgPeriods.flatMap {
it.periods.map { rp ->
ReportingInterval(
rp.convertFromDateToDate(),
rp.convertToDateToDate(),
it.osgBoxId
)
}
}.toSet()
Тут уже есть пара вложенных циклов в flatMap
и map
, и с непривычки этот код может показаться сложным, но, по сути, он предельно простой: у нас есть список коробов со списком отчётных периодов, попавших в этот короб, и мы превращаем его в список отчётных периодов коробок, со ссылкой на короб, куда он попал.
Снова до этого кода достаточно сложно докопаться, так что пойдёмте читать дальше.
private fun repackageMultipleBoxes(
boxIds: List<Long>,
osgBox: OsgBoxDto,
) {
boxIds.forEach { boxId ->
repackageBoxToOsgBox(boxId, osgBox)
}
osgService.insertBoxesToOsgLinks(listOf(osgBox.osgBoxId), boxIds)
}
Метод‑двухстрочник — пробегаемся в цикле по коробкам и перепаковываем их, затем сохраняем привязку коробки к коробам — читать особо нечего, идём дальше.
private fun repackageBoxToOsgBox(boxId: Long, osgPeriod: OsgBoxDto) {
val box = boxRepo.findByIdOrNull(boxId) ?: throw BoxNotFoundException("Короб не найден")
box.reportingIntervals.forEach {
it.osgBoxId = osgPeriod.osgBoxId
}
boxRepo.save(box)
}
Снова предельно простой код — загрузить коробку, обновить, сохранить — снова на первый взгляд сложно до чего‑то докопаться.
На этом код на ревью у меня закончился, и методом пристального вглядывания я серьёзных проблем не увидел.
Но внимательный читатель мог разглядеть в нём проблему — метод repackageBoxToOsgBox
, вызываемый в цикле в методе repackageMultipleBoxes
, содержит два обращения к БД — findByIdOrNull
и save
. Что ведёт к тому, что мы тянем из БД, а потом сохраняем строчки по одной (напомню, в проекте используется Spring Data JDBC и автомагического батчинга save не происходит).
А как известно, пакетная работа с БД, когда строчки загружаются или сохраняются пакетом в одном запросе, намного более эффективна.
Почему я не увидел этой проблемы, когда ревьювил — сложный вопрос. Может, я куда‑то торопился. Или просто был с похмелья. А может, потому что исходный код был запутан, и ввод‑вывод был раскидан по всем уровням абстракции и «погребён» под несколькими слоями условий, циклов и вызовов методов:
Примечание: кроме того, в посте код сокращён и причёсан во имя упрощения восприятия материала, соответственно, в реальном коде обнаружить эту проблему было ещё сложнее. |
Но меня спасло то, что на момент этого ревью я уже следил за сбалансированностью формы системы и после пристального вглядывания, я пошёл проверять код на сбалансированность.
И первым признаком того, что с кодом «всё ОК», является наличие вызовов методов чтения и записи данных в корневом методе операции. А у нас тут только вспомогательное чтение в виде osgService.hasAnyLinks
. Соответственно, увидев, что код «не ОК», я пошёл внимательно смотреть что там с IO, и в этот момент увидел проблему с работой с БД в цикле.
А даже если бы и после этого я всё ещё не увидел проблему, то сам процесс балансировки системы естественным образом бы её исправил.
Давайте посмотрим, как это происходит и как балансировка системы приводит к упрощению кода.
Приведение кода к сбалансированной форме
Я строю структурные схемы кода примерно раз в год. Но, для наглядности, давайте построим структурную схему текущей версии кода переупаковки:
Очевидно, этот код не сбалансирован: методы repackageSingleBox
, repackageMultipleBoxes
и repackageBoxToOsgBox
содержат в себе и бизнес-логику, и ввод-вывод. Кроме того, этот граф нельзя назвать совсем простым. Во-первых, он граф, а не дерево. Во-вторых, в нём достаточно много пересекающихся рёбер, что говорит о пересекающейся функциональности методов.
Для решения всех этих проблем давайте отрефакторим код перепаковки и приведём его к сбалансированной форме.
Шаг 1: инлайн ввода-вывода
Первым делом надо заинлайнить весь код с io в корневой метод:
@Transactional
open fun repackage(boxIds: List<Long>, osgBoxes: List<OsgBoxDto>) {
if (boxIds.isEmpty() || osgBoxes.isEmpty() || (boxIds.size > 1 && osgBoxes.size > 1)) {
throw OsgRepackageIncorrectAmountException("Допустима перепаковка только одной коробки в несколько коробов, либо нескольких коробок в один короб")
}
val osgIds = osgBoxes.map { it.osgBoxId }
if (osgService.hasAnyLinks(boxIds, osgIds)) {
throw OsgRepackageBoxAlreadyLinkedException("Некоторые из коробок или коробов уже имеют привязку")
}
if (boxIds.size == 1) {
val boxId = boxIds.single()
val box = boxRepo.findByIdOrNull(boxId) ?: throw BoxNotFoundException("Короб не найден")
box.reportingIntervals = toReportingIntervals(osgBoxes)
boxRepo.save(box)
osgService.insertBoxesToOsgLinks(osgBoxes.map { it.osgBoxId }, listOf(boxId))
} else {
val osgBox = osgBoxes.single()
boxIds.forEach { boxId ->
val box = boxRepo.findByIdOrNull(boxId) ?: throw BoxNotFoundException("Короб не найден")
box.reportingIntervals.forEach {
it.osgBoxId = osgBox.osgBoxId
}
boxRepo.save(box)
}
osgService.insertBoxesToOsgLinks(listOf(osgBox.osgBoxId), boxIds)
}
}
Теперь нам надо из самого большого if-а в конце метода вытащить чтение и запись данных.
Шаг 2: вынесение чтения из бизнес-логики
С чтением всё достаточно просто: если приглядеться, то обеим веткам на вход подаётся список ИДов, затем каждая из них превращает этот список в список сущностей и что-то с ними делает. Соответственно, мы можем до if-а превратить ИДы в сущности, и на вход бизнес-логике подать уже список сущностей.
@Transactional
open fun repackage(boxIds: List<Long>, osgBoxes: List<OsgBoxDto>) {
if (boxIds.isEmpty() || osgBoxes.isEmpty() || (boxIds.size > 1 && osgBoxes.size > 1)) {
throw OsgRepackageIncorrectAmountException("Допустима перепаковка только одной коробки в несколько коробов, либо нескольких коробок в один короб")
}
val osgIds = osgBoxes.map { it.osgBoxId }
if (osgService.hasAnyLinks(boxIds, osgIds)) {
throw OsgRepackageBoxAlreadyLinkedException("Некоторые из коробок или коробов уже имеют привязку")
}
val boxes = boxRepo.findByIdIn(boxIds)
if (boxes.size == 1) {
val box = boxes.single()
box.reportingIntervals = toReportingIntervals(osgBoxes)
boxRepo.save(box)
osgService.insertBoxesToOsgLinks(osgBoxes.map { it.osgBoxId }, listOf(box.id!!))
} else {
val osgBox = osgBoxes.single()
boxes.forEach { box ->
box.reportingIntervals.forEach {
it.osgBoxId = osgBox.osgBoxId
}
boxRepo.save(box)
}
osgService.insertBoxesToOsgLinks(listOf(osgBox.osgBoxId), boxes.map { it.id!! })
}
}
И обратите внимание, что этим же движением руки, мы заодно перевели и чтение на пакетный (то есть более эффективный) режим работы. Сама структура сбалансированной системы, в которой бизнес‑логика может работать только с тем, что уже было загружено ранее, вынуждает разработчика делать io пакетно.
Шаг 3: выделение записи связки коробок с коробами из бизнес-логики
Теперь давайте перейдём к вытаскиванию вывода из бизнес‑логики. Сейчас в бизнес‑логике содержится два метода, которые делают вывод — boxRepo.save
и osgService.insertBoxesToOsgLinks
, давайте начнём со второго.
С непривычки задача вынесения вывода из бизнес‑логики может показаться сложной. Однако разделение ввода‑вывода и бизнес‑логики характерно ещё и для функционального программирования (в плане сбалансированности системы — фактического преемника структурного дизайна). И у ФП для этой задачи есть стандартное решение — замена вызова метода с io на возврат параметров этого вызова (см. например, главу «Performing I/O to Passing Data» из Java to Kotlin. A Refactoring Guidebook).
Для того, чтобы провернуть этот трюк, давайте выделим if с бизнес‑логикой в отдельный метод repackBoxes
:
open fun repackage(boxIds: List<Long>, osgBoxes: List<OsgBoxDto>) {
if (boxIds.isEmpty() || osgBoxes.isEmpty() || (boxIds.size > 1 && osgBoxes.size > 1)) {
throw OsgRepackageIncorrectAmountException("Допустима перепаковка только одной коробки в несколько коробов, либо нескольких коробок в один короб")
}
val osgIds = osgBoxes.map { it.osgBoxId }
if (osgService.hasAnyLinks(boxIds, osgIds)) {
throw OsgRepackageBoxAlreadyLinkedException("Некоторые из коробок или коробов уже имеют привязку")
}
val boxes = boxRepo.findByIdIn(boxIds)
repackBoxes(boxes, osgBoxes)
}
private fun repackBoxes(
boxes: List<Box>,
osgBoxes: List<OsgBoxDto>
) {
if (boxes.size == 1) {
val box = boxes.single()
box.reportingIntervals = toReportingIntervals(osgBoxes)
boxRepo.save(box)
osgService.insertBoxesToOsgLinks(osgBoxes.map { it.osgBoxId }, listOf(box.id!!))
} else {
val osgBox = osgBoxes.single()
boxes.forEach { box ->
box.reportingIntervals.forEach {
it.osgBoxId = osgBox.osgBoxId
}
boxRepo.save(box)
}
osgService.insertBoxesToOsgLinks(listOf(osgBox.osgBoxId), boxes.map { it.id!! })
}
}
Теперь мы можем:
Завести «локальный» класс
RepackageResult
с конструктором с теми же аргументами, что и методinsertBoxesToOsgLinks
;В методе
repackBoxes
вызовыinsertBoxesToOsgLinks
заменить на вызов конструктораRepackageResult
;Изменить тип возвращаемого результата метода
repackBoxes
сUnit
(akavoid
) наRepackageResult
и вернуть из метода соответствующие объекты;Добавить в
repackage
вызовinsertBoxesToOsgLinks
, используя в качестве параметров поля объектаRepackageResult
, полученного из вызоваrepackBoxes
.
После всех этих изменений код станет выглядеть так:
fun repackage(boxIds: List<Long>, osgBoxes: List<OsgBoxDto>) {
if (boxIds.isEmpty() || osgBoxes.isEmpty() || (boxIds.size > 1 && osgBoxes.size > 1)) {
throw OsgRepackageIncorrectAmountException("Допустима перепаковка только одной коробки в несколько коробов, либо нескольких коробок в один короб")
}
val osgIds = osgBoxes.map { it.osgBoxId }
if (osgService.hasAnyLinks(boxIds, osgIds)) {
throw OsgRepackageBoxAlreadyLinkedException("Некоторые из коробок или коробов уже имеют привязку")
}
val boxes = boxRepo.findByIdIn(boxIds)
val repackageResult = repackBoxes(boxes, osgBoxes)
osgService.insertBoxesToOsgLinks(repackageResult.osgBoxIds, repackageResult.boxIds)
}
private fun repackBoxes(
boxes: List<Box>,
osgBoxes: List<OsgBoxDto>
): RepackageResult {
if (boxes.size == 1) {
val box = boxes.single()
box.reportingIntervals = toReportingIntervals(osgBoxes)
boxRepo.save(box)
return RepackageResult(osgBoxes.map { it.osgBoxId }, listOf(box.id!!))
} else {
val osgBox = osgBoxes.single()
boxes.forEach { box ->
box.reportingIntervals.forEach {
it.osgBoxId = osgBox.osgBoxId
}
boxRepo.save(box)
}
return RepackageResult(listOf(osgBox.osgBoxId), boxes.map { it.id!! })
}
}
private data class RepackageResult(
val boxIds: List<Long>,
val osgBoxIds: List<Long>
)
Как видите, никакого рокет сайнса. Единственно, что может показаться непривычным — для того, чтобы вернуть два объекта из repackBoxes
, нам пришлось завести «локальный» класс RepackageResult
.
Шаг 4: выделение записи обновлённых коробок из бизнес-логики
Теперь нам остался последний шаг к сбалансированной системе — вытащить вызов boxRepo.save
из метода бизнес‑логики repackBoxes
.
Вообще, я сторонник неизменяемой модели данных и по‑хорошему, на мой взгляд, с коробками надо проделать тот же трюк, что и со связями — в repackBoxes
создать обновлённый экземпляр класса Box
и вернуть его в RepackageResult
. Но мне кажется, неизменяемые объекты ещё не стали мейнстримом, и чтобы оставить пример близким более широкой аудитории, оставим Box
изменяемым.
И в этом случае мы можем использовать boxes
как in‑out параметр и просто заменить boxRepo.save
в repackBoxes
на boxRepo.saveAll
в repackage:
fun repackage(boxIds: List<Long>, osgBoxes: List<OsgBoxDto>) {
if (boxIds.isEmpty() || osgBoxes.isEmpty() || (boxIds.size > 1 && osgBoxes.size > 1)) {
throw OsgRepackageIncorrectAmountException("Допустима перепаковка только одной коробки в несколько коробов, либо нескольких коробок в один короб")
}
val osgIds = osgBoxes.map { it.osgBoxId }
if (osgService.hasAnyLinks(boxIds, osgIds)) {
throw OsgRepackageBoxAlreadyLinkedException("Некоторые из коробок или коробов уже имеют привязку")
}
val boxes = boxRepo.findByIdIn(boxIds)
val repackageResult = repackBoxes(boxes, osgBoxes)
boxRepo.saveAll(boxes)
osgService.insertBoxesToOsgLinks(repackageResult.osgBoxIds, repackageResult.boxIds)
}
private fun repackBoxes(
boxes: List<Box>,
osgBoxes: List<OsgBoxDto>
): RepackageResult {
if (boxes.size == 1) {
val box = boxes.single()
box.reportingIntervals = toReportingIntervals(osgBoxes)
return RepackageResult(osgBoxes.map { it.osgBoxId }, listOf(box.id!!))
} else {
val osgBox = osgBoxes.single()
boxes.forEach { box ->
box.reportingIntervals.forEach {
it.osgBoxId = osgBox.osgBoxId
}
}
return RepackageResult(listOf(osgBox.osgBoxId), boxes.map { it.id!! })
}
}
Этим действием мы не только вынесли последнюю io‑операцию из бизнес‑логики в repackBoxes
, но и решили вторую проблему с эффективностью работы с БД — в случае saveAll
Spring Data JDBC уже сможет выполнить обновление в пакетном режиме.
На этом «балансировка» системы завершена, однако надо сделать ещё один шаг для того, чтобы обеспечить сохранение баланса в будущем.
Шаг 5: создание защитного барьера между бизнес-логикой и вводом-выводом
Я не стал это явно прописывать в листингах, но думаю, очевидно, что в течение рефакторинга методы repackage
и repackBoxes
находились в одном классе и сейчас в области видимости метода бизнес‑логики repackBoxes
находятся все зависимости, которые позволяют делать ввод‑вывод — boxRepo
и osgService
в частности. Соответственно, новый или старый, но спешащий член команды, может лёгким движением руки добавить ввод‑вывод в бизнес‑логику и снова сломать баланс системы.
Для того чтобы это предотвратить, нам надо построить «заборчик» вокруг бизнес‑логики, через который дотянутся до ввода‑вывода было бы уже не так просто.
Глобально, для того чтобы построить такой заборчик, бизнес‑логику надо куда‑то унести и есть три основных типа таких безопасных мест:
Доменные сущности/агрегаты — а‑ля ООП/DDD‑стиль;
Свободные функции или статические методы в Java — а‑ля ФП/DOP‑стиль;
Доменные классы, которые на уровне гайдлайна или архитектуры создаются руками, а не силами Spring и не могут зависеть от Spring‑бинов — а‑ля ПП‑стиль;
С точки зрения обеспечения защиты бизнес‑логики все способы одинаково хороши, поэтому вы можете выбрать тот, что больше всего нравится вам. Я предпочитаю ФП/DOP‑стиль, так как, на мой взгляд, он лучше всего масштабируется, поддерживается и отражает ментальную модель пользователя. Поэтому в примере я вынесу repack
в Kotlin top‑level функцию:
class ArchivistBoxService(
private val boxRepo: BoxRepository,
private val osgService: OsgService
) {
fun repackage(boxIds: List<Long>, osgBoxes: List<OsgBoxDto>) {
if (boxIds.isEmpty() || osgBoxes.isEmpty() || (boxIds.size > 1 && osgBoxes.size > 1)) {
throw OsgRepackageIncorrectAmountException("Допустима перепаковка только одной коробки в несколько коробов, либо нескольких коробок в один короб")
}
val osgIds = osgBoxes.map { it.osgBoxId }
if (osgService.hasAnyLinks(boxIds, osgIds)) {
throw OsgRepackageBoxAlreadyLinkedException("Некоторые из коробок или коробов уже имеют привязку")
}
val boxes = boxRepo.findByIdIn(boxIds)
val repackageResult = repackBoxes(boxes, osgBoxes)
boxRepo.saveAll(boxes)
osgService.insertBoxesToOsgLinks(repackageResult.osgBoxIds, repackageResult.boxIds)
}
}
private fun repackBoxes(
boxes: List<Box>,
osgBoxes: List<OsgBoxDto>
): RepackageResult {
if (boxes.size == 1) {
val box = boxes.single()
box.reportingIntervals = toReportingIntervals(osgBoxes)
return RepackageResult(osgBoxes.map { it.osgBoxId }, listOf(box.id!!))
} else {
val osgBox = osgBoxes.single()
boxes.forEach { box ->
box.reportingIntervals.forEach {
it.osgBoxId = osgBox.osgBoxId
}
}
return RepackageResult(listOf(osgBox.osgBoxId), boxes.map { it.id!! })
}
}
Это не железобетонная защита, при желании её несложно обойти. Однако попытки обхода такой защиты (добавление Spring‑бина в параметры функции или перенос функции внутрь класса) будут намного более явными, чем просто добавление строчки с вводом‑выводом, и моя практика показывает, что такая защита работает достаточно хорошо.
На этом рефакторинг по балансировке системы завершён и можно подвести его итоги.
Итоги рефакторинга
Структурная схема отрефакторенной версии стала выглядеть так:
Очевидно, новый код стал сбалансированным и стал обладать более простой структурой.
Для большей наглядности приведу структурные схемы до и после:
Касательно «погребения» ввода‑вывода в логике, ситуация также стала выглядеть намного лучше:
Теперь вся бизнес‑логика (помимо пары защитных условий в корневом методе) «ушла направо», а весь ввод‑вывод «ушёл налево».
Также мы и убрали из бизнес‑логики знание того, что коробки хранятся в BoxRepo под целочисленными ИДами, повысив тем самым уровень абстракции и переиспользуемость этой функции. И теперь мы можем, например, переиспользовать эту же функцию в другом контексте, где коробки для перепаковки идентифицируются не внутренним ИДом, а штрих-кодом.
Итого, в результате приведения кода к сбалансированной форме мы не только сделали общую структуру кода более простой, но ещё и оптимизировали работу с БД — часть, которая обычно занимает львиную долю времени выполнения операции и оптимизация которой даёт наибольшие плоды.
На таком масштабе может показаться, что это всё косметические изменения и на качество кодовой базы они особо не влияют. Однако моя практика показывает, что такие проблемы со сложностью и эффективностью, какие были у первой версии Project Barcoder, с течением времени только усугубляются и превращаются в огромные проблемы, которые начинают больно бить уже по бизнесу.
Об этом мой следующий кейс — Project Daniel.
Кейс №2: Project Daniel
Напрямую описать предметную область Project Daniel я не могу из‑за NDA, поэтому возьму схожий по сути, но вымышленный пример.
Project Daniel — сервис заказа VIP‑такси. Клиенты оставляют заказы на поездку, система по сложному алгоритму подбирает для него водителя и назначает на заказ. Далее водитель везёт клиента.
Очевидно, ключевой функцией системы является функция назначения водителя на заказ. И так как у нас VIP‑такси, для принятия решения она использует множество различных данных и правил: время ожидания клиента, время простоя водителя, статус клиента, навыки водителя, историю взаимодействия конкретной пары клиента и водителя и т. д. Также VIP‑персоны не привыкли долго ждать, поэтому рассчитывают, что машины им будут назначать за пару секунд и если это не так — уходят к конкурентам.
До применения принципов структурного дизайна
Так как функция назначения водителя является ключевой, она появилась на 10-ый день разработки проекта. И поначалу была предельно простой: водитель назначается на тот заказ, который дольше всего находится в очереди — и даже сбалансированной:
Однако буквально ещё через 10 дней, одновременно с обретением функцией «мозгов», она потеряла свой баланс — бизнес‑логика почему‑то утекла в метод сервиса приложения OrdersService.process
:
И вместе с утерей баланса, в этой функции появилась и первая проблема в эффективности работы с БД — почему‑то (на тот момент я ещё не участвовал в разработке проекта) вызов OrdersRepository.findOrders
оказался внутри цикла.
С течением времени, ситуация со сложностью кода и эффективность работы с БД продолжала усугубляться — через 3 месяца разработки граф вызовов этой функции стал выглядеть уже так:
А через 5 лет разработки граф стал выглядеть так:
Примечание: на этой диаграмме узлы промаркированы их когнитивной сложностью. |
Понять по этому графу все нюансы бизнес‑логики было очень сложно.
А ещё за один цикл назначения водителей на заказы этот граф делал более 100 SQL‑запросов (сколько точно — знает один бог, я плюнул на подсчёты на второй сотне). Это хорошо видно по количеству методов чтения (красные узлы), вызовы многих из которых находились внутри 2–3 вложенных циклов.
Как следствие, он работал очень медленно и был в состоянии обработать только очередь из 5 заказов за секунду.
Примечание: функция назначения водителя работала как фоновая задача — она запускалась раз в секунду, просматривала очередь заказов и выполняла назначения. |
Долгое время это всех устраивало. Однако в один прекрасный ужасный момент очередь выросла до 1–2 тысяч заказов. И время ожидания назначения машины выросло с пары секунд до нескольких минут. И наши VIP‑клиенты начали отваливаться. И мы вместо того, чтобы грести деньги лопатой, начали их терять.
Поэтому случилось неизбежное — к команде пришёл продакт со словами:
Поначалу я попробовал отскочить малой кровью — поиграть с настройками Hibernate и потюнить аннотации на сущностях. Но продраться сквозь мешанину бизнес‑логики и хиберовской автомагии было очень сложно, и в этом начинании я потерпел фиаско.
Поэтому я решил переписать всё с нуля с учётом баланса системы — этот путь был более предсказуемым и гарантировал результат. Под соусом предсказуемости я и продакту с СТО продал эту затею.
После применения принципов структурного дизайна
На самом деле, суть проблемы в Project Daniel была такая же, как и в Project Barcoder — у функции было два варианта реализации, которые внутри себя поштучно внутри 2–3 вложенных циклов тянули данные по мере необходимости.
Давайте рассмотрим как был устроен оригинальный код функции назначения водителей на заказы.
Примечание: в этом проекте детальный код я показать не могу, поэтому покажу только сокращённые кусочки и размытые скриншоты оригинального кода. |
Корневой метод доставал всю очередь заказов, потом разбивал её на две группы, каждую из которых передавал в свой метод:
Корневой метод назначения водителей
public void assignOrders() {
List<Order> orders = getAssignCandidates();
if (orders.isEmpty()) {
return;
}
var ordersGroups = orders.stream()
.groupBy(this::isEligibleForPersonalService));
handlePersonalAssignments(ordersGroups.get(PERSONAL));
handleStandardAssignments(ordersGroups.get(STANDARD));
}
Далее каждый из методов выполнял свой алгоритм:
Здесь приведён размытый реальный код реализации и через размытие (надеюсь), видно, что это довольно длинные методы, с кучей условий и уровней вложенности. По которым были равномерно раскиданы обращения к БД через сервисы и ленивую загрузку.
И так как суть проблемы была такой же, как и в Project Barcoder, то и решение её было таким же — привести реализацию операции к сбалансированной форме.
Для этого мы собрали всё чтение в одном методе AssignRepository.fetchAssignData
, который последовательно выполнял несколько развесистых и оптимизированных SQL‑запросов:
Опять же надеюсь, что тут через размытие видно, что этот метод уже достаточно короткий, линейный и содержит только один уровень вложенности. Два единственных условия в этом методе — защитные условия для раннего выхода, если очередь пустая и если нет ни одного активного водителя.
И на выход этот метод выдаёт структурку из трёх полей, которая содержит все необходимые бизнес‑логике данные:
public class AssignData {
// простой список заказов для распределения с вспомогательными данными
public final List<OrderAssignData> queue;
// "умный" список активных на момент вызова операции водителей
public final ActiveDrivers activeDrivers;
// динамические правила назначения водителей на заказы
public final Map<Long, Rule> assignRules;
}
Далее эта структура передавалась уже в более сложный метод, который реализует всю бизнес‑логику, включая группировку заказов и обе ветки логики назначения:
Этот метод уже и сам больше, и уровней вложенности в нём больше и в целом он сложнее — натуральная сложность задачи‑то никуда не делась. Но этот метод уже сам не делает никакого ввода‑вывода, он работает только с тем, что ему передали в параметре assignData
и всю свою работу собирает в выходную ДТО‑шку:
public class AssignResult {
// список кого куда назначить
public final List<Assignment> assignments;
// список заказов, на которые не удалось назначить
public final Set<Order> notAssigned;
}
Примечание: ещё одним полезным свойством сбалансированных систем, которое я решил не брать полноценно в пост во имя сохранения его фокуса — максимально возможная тестируемость бизнес‑логики. В этом кейсе метод Соответственно, его можно покрыть (и мы покрыли) чистыми JUnit‑тестами без Spring‑контекста и моков вдоль и поперёк, и эти тесты будут работать за единицы (а не сотни и тысячи) миллисекунд. Благодаря чему, при желании, для тестирования этого метода можно было применить продвинутые методы тестирования, вроде тестирования свойств и мутационного тестирования — но до этого мы уже не дошли. |
Далее, экземпляр AssignResult
передавался в метод performAssignments
:
Этот метод уже посложнее fetchAssignData
— в нём есть какой‑то свитч, но его сложность не сравнится со сложностью AssignPolicy.assignOrders
.
Примечание для зануд: когнитивная сложность методов Второе примечание для зануд: вызываемые методы в |
Наконец, всё это склеивалось в корневом методе:
AssignService.assignOrders
public void assignOrders() {
final AssignData assignData = assignRepository.fetchAssignData();
final AssignResult assignResult = new AssignPolicy(assignData)
.assignOrders(Instant.now());
performAssignments(assignResult);
}
Итоги рефакторинга
Сбалансированную версию мы выкатили в опытную эксплуатацию через 2 недели, после того как решились на эту авантюру, и работала она в 300 раз быстрее и была уже в состоянии обработать за секунду очередь из 1500 заказов, что полностью закрыло потребности бизнеса на тот момент.
С простотой кода тоже всё стало существенно лучше:
Граф вызовов превратился в дерево;
Суммарная когнитивная сложность упала с 94 до 69 единиц;
Максимальная когнитивная сложность упала с 25 до 16 единиц.
Касательно когнитивной сложности стоит проговорить, что на тот момент я про эту метрику не знал и специально её не оптимизировал — это получилось естественным следствием балансировки системы.
Балансировка системы вынуждает разработчика делать ввод‑вывод (самую медленную часть бакендов) в пакетном режиме и подталкивает его к тому, чтобы задуматься об эффективности работы ввода‑вывода. Как следствие, это ведёт в среднем к лучшей производительности, чем разработка без явного руководства о том, когда и где можно делать io.
Однако сбалансированная форма системы, это не про эффективность работы кода — если потребуется выжать из кода последнюю микросекунду — скорее всего от сбалансированности операции придётся отказаться. Структурный дизайн в целом и сбалансированность системы — это в первую очередь про эффективность работы команды. И об этом будет мой третий и последний кейс — Project E.
Кейс №3: Project E
Кейсы Project Barcoder и Project Daniel были хороши тем, что в них менялась только одна переменная — сбалансированность системы. Соответственно, можно с высокой долей уверенности говорить о том, что позитивные изменения в структуре кода и его производительности вызваны изменением формы системы.
Аналогичного кейса ещё большего масштаба, где я бы взял целое приложение и поменял бы в нём только форму системы у меня нет. Но есть кейс, где я взял целое приложение на 150 HTTP‑эндпоинтов и 34К строк С# кода и переписал его с нуля силами трёх юниоров, в процессе чего среди прочего следил за балансом системы.
Сам проект, причины и ход его реинжиниринга, а также методика получения цифр для оценки результатов приведены у меня в отдельных постах — если интересно, то можно покопаться. Здесь же приведу только выжимку.
Project E — это медицинский онлайн‑дневник, разработанный по заказу производителя медицинского оборудования.
Изначально проект разрабатывала одна команда на C#, микросервисной архитектуре системы и вертикальной архитектуре приложения силами одного мидла. Но заказчик заморозил разработку примерно на 90% готовности первой версии. А когда решил возобновить разработку, изначальная команда отказалась продолжать работы, и проект достался нашей команде.
Поначалу мы с командой примерно три‑четыре месяца пытались развивать оригинальный проект, но по ряду причин у нас это получалось плохо. Поэтому я решил переписать проект на Kotlin, монолитной архитектуре системы и функциональной архитектуре приложения (читай: сбалансированность системы + неизменяемая модель данных) силами трёх юниоров.
И, после того, как мы это благополучно проделали и три месяца поработали над развитием новой версии, я покопался в JIRA и выяснил, что средние трудозатраты и количество ошибок на задачу сократились в три раза.
В цифрах это всё выглядит так:
Первичная разработка | ||
---|---|---|
Версия на C# | Версия на Kotlin | |
Архитектура | Микросервисы + Vertical Slice Architecture | Монолит + функциональная архитектура |
Суммарные трудозатраты | 189 ч/дней мидла | 145 ч/дней юниора |
Покрытие тестами | 0 Тестов | 100% покрытие тестами веток кода эндпоинтов |
Развитие (доработки) | ||
Количество выполненных задач | 14 шт. (за 526 ч/час) | 52 шт. (за 497 ч/час) |
Медианные трудозатраты на задачу | 16 ч/час | 5 ч/час |
Среднее количество багов на задачу | 1.5 шт. | 0.5 шт. |
С учётом количества переменных (архитектура системы, архитектура приложения, покрытие тестами, команда, платформа), в этом случае значение сбалансированности системы уже не очевидно. Однако моё субъективное мнение, основанное в том числе на кейсе Project Daniel, например — оно есть. И как показывает тот же Project Daniel, чем дольше система растёт и развивается, тем большее значение будет иметь сбалансированность системы.
Как научиться делать сбалансированные системы
Надеюсь, мне удалось убедить вас в том, что сбалансированность системы имеет существенное влияние на стоимость её разработки и поддержки. Стоимость как в нервных клетках разработчика, так и в рублях бизнеса.
И в этом случае у вас должен возникнуть вопрос: «А как мне научиться разрабатывать сбалансированные системы?».
К сожалению, «Структурный дизайн» не будет ответом — объективно эта методология умерла. По ней нет современной информации, а оригинальные источники показывают свой 60-летний возраст.
Однако разделение ввода‑вывода и бизнес‑логики является краеугольным камнем ещё и функционального программирования, которое сейчас цветёт и пахнет и о котором сейчас публикуется множество книг.
Примечание: тут может возникнуть вопрос — «зачем тогда понадобилось откапывать Как я писал во введении — затем, чтобы увидеть суть функциональной архитектуры. В 2016–2020 году, когда я изучал функциональную архитектуру, её описывали только через монады. А монады можно затащить далеко не в каждый продакшн. Сейчас же, в 2024 году откапывать структурный дизайн уже особого смысла нет, потому что появилось множество книг, которые предлагают простой и понятный большинству стиль функционального программирования и архитектуры. А в доклад и пост я взял термин структурный дизайн для того, чтобы дистанцироваться от предубеждения: «функциональное программирование — это непонятная академическая фигня, и в промышленном программировании оно не нужно». Кроме того, ФП и ФА включают в себя неизменяемую модель данных, а структурный дизайн — только разделение io и бизнес‑логики — и он может послужить промежуточной остановкой на пути к полноценному функциональному стилю с неизменяемой моделью данных. |
Поэтому сейчас для освоения идеи сбалансированных систем, я рекомендую следующие книги:
Примечания * вопреки своему названию, в этой книге описаны не только принципы юнит‑тестирования, но и в целом принципы дизайна ПО, включая функциональную архитектуру. ** снова вопреки своему названию, суть рефакторингов в этой книге сводится к переходу от императивного объектно‑ориентированного стиля к декларативному. *** в этом списке не просто так только две книги на русском языке — дело в том, что в современных переводах пишут откровенную ересь (пример № 1, пример № 2), поэтому большинство книг лучше читать в оригинале. Но автор принципов юнит‑тестирования — русскоговорящий, а «Структуру и интерпретацию компьютерных программ» переводили в 90-х — поэтому перевод обеих книг вполне адекватен. |
Из этого списка, для того чтобы начать эффективно проектировать и реализовывать сбалансированные системы, достаточно прочитать первые три книги («Grokking Simplicity», «Принципы юнит‑тестирования» и «Java to Kotlin»).
«Domain Modeling Made Functional» и «Data‑Oriented Programming» — эти книги помогут освоить уже следующую ступень — неизменяемую модель данных.
Наконец, последние три книги — «Структура и интерпретация компьютерных программ», «Functional Design» и «Structured Design» — это полировка функционального мышления.
Также рекомендую почитать мой блог и Telegram‑канал:
В них я много пишу о своей коммерческой практике применения идей структурного дизайна и data‑oriented programming и с радостью помогу вам внедрить эти идеи в свою работу.
Заключение
Все три кейса этого поста из разных проектов, но мне кажется, что они укладываются в одну историю в духе «потому что в кузнице не было гвоздя».
Project Barcoder показывает, как без системного подхода к разделению ввода‑вывода и бизнес‑логики в коде проекта самозарождаются проблемы со сложностью и, как следствие, эффективностью работы кода.
Project Daniel показывает, как без своевременного купирования этих проблем в отдельной функции системы они со временем нарастают как снежный ком и превращаются в проблемы для бизнеса.
А Project E показывает, как без системного подхода к упрощению кодовой базы она может превратиться в монстра, который связывает команду по рукам и ногам и в три раза замедляет её работу. Потому что когда‑то, в первые дни жизни системы, при разработке смешали бизнес‑логику и ввод‑вывод, а на ревью техлид был с похмелья и не заметил этого.
В общем, разделяйте io и бизнес‑логику, а идеи из структурного дизайна и современных книг по функциональному программированию вам в помощь. И будет вам простое девелоперское щясте.
P.S>
Неофициальная запись последней репетиции доклада