Разработка эргономичного кода
Техническая глава
Работа не завершена
Этот материал, который пока что скорее является серией статей и заметок находится в разработке. Я надеюсь, что со временем, он превратится в книгу о том, как разрабатывать "сатисфактори" проекты - проекты которые и разрабатывать приятно и использовать. Разделы, которые я планирую включить в книгу, я пишу в "совершённом" времени - как будто они часть уже написанной книги. Некоторые разделы написаны в стиле "поток сознания" и оставлены отлежаться, но закоммитаны, чтобы не мозолить глаза и не затеряться.
На момент написания этого материала, я большую часть времени занимаю разработкой бэка на спринге, поэтому есть некоторый перекос в эту сторону. Но за свою карьеру я участвовал в разработке проектов самых разных типов и изложенные идеи должны быть применимы в целом к разработке софта. Со временем я обобщу изложенные идеи и приведу примеры их реализации в различных контекстах.
To do
- Вставить диаграмму кубита, в качестве иллюстрации подхода к разбиению на пакеты
- Разобрать пример с отображением списка (списков дел), либо сообщение о пустом списке
- Где должен быть этот иф? В юз кейсе или во вью?
- Что вообще делать с этим ифом? Тащить его отдельно в логику кажется перебором.
- Выброс исключения - это эффект?
- Разбиение цикла между вью и юз кейсом
- Забить
- Обсёрвер
- Возврат экшена в onXXX. Но что делать, если требуется возаимодействие - onDelete → askConfirmation → delete. Отдельные эвенты? Корутины?
- Примерно туда же - где должна быть логика запроса подтверждения удаления? В принципе её можно целиком во вью оставить, дёргая юз кейс только при подтверждении. Но Очевидно ли это?
- threads#bd5d4a4e, MessageServiceImpl#setMessageChannel
- Эвент != юз кейс. Эвент - атомарное действие вызываемое обычным событием (сообщением в традиционном ООП), юз кейс - сценарий достижения определённой цели, продвигаемый одним или более событием DCI: Practical Tips and Lessons for Nerds
- Habits DCI: Practical Tips and Lessons for Nerds
- Инклуды юз кейсов вредные, потому что теряют цель DCI: Practical Tips and Lessons for Nerds
- Дизайн - разделение стабильных и изменяющихся частей DCI: Practical Tips and Lessons for Nerds, Анкл Боб
- Доменная модель должна быть стабильной DCI: Practical Tips and Lessons for Nerds, Анкл Боб
- Традиционные сервисы - жирные DCI Roles?
- Peter Coad, object modelling in code (https://www.infoq.com/articles/domain-color-modeling/)
- Юз кейсы - идеально место для осмысленных комментов. Сейчас стандартный бэк - это в основном набор структур данных и пакетов процедур для манипуляции ими и логику описывать не где. Юз кейсы же кодом описывают связанные куски логики и этот код можно сдобрить хорошим комментом.
- Комменты и коммит мессаджи на русском. Глухой телефон в КБ информ. На английском в опенсорсе, забугорных заказчиках, между народных коммандах.
- Временные таблицы (таблицы с данными не входящими в доменную модель и из которых данные постоянно удаляются) - потенциально скрытые юз кейсы
- Юз кейсы - настоящие объекты, с настоящим состоянием и настоящей логикой и настоящей инкапсуляцией.
- Коплейн Джеймс Коплейн (James Coplien): юнит тесты снижают качество кода
- Изучение домена: Джеймс Коплейн (James Coplien), DDD, Object Thinking
- Большинство ошибок находятся во взаимо действии Segue
- "Чем раньше обнаружена ошибка, тем дешевле её исправить" - миф? Segue
- "A proper book isn’t just a collection of facts, it reflects cause and mission" Lean Architecture for Agile Software Development
- "If we reflect the end user mental model in the code, we are more likely to have working software" Lean Architecture for Agile Software Development
- Высокое качество достигается в первую очередь Очевидностью эффектов кода и во вторую покрытием тестами
- Динамическая вс статическая типизация
- Типы Очевидны
- Код проще исследовать
- Типы исключат целый пласт ошибок
- Юнит тесты не могут исключить те ошибки, которые исключают типы
- Архитекутра ОО-сиситема - протоптанные пути сообщений между объектами, A Glimpse of Trygve: From Class-oriented Programming to Real OO, 12:00
- Архитектура - результат дизайна. Дизайн - акт решения проблемы Проблема - разници между имеющимся положением дел и желаемым Lean Architecture for Agile Software Development
- Сервисы в ДДД - это роли в ДэЦэИ. "Some of these are intrinsically activities or actions, not things, but since our modeling paradigm is objects, we try to fit them into objects anyway…" DDD
- Инфраструктурные, доменные и прикладные сервисы из ддд - это адаптеры, бизнес-логика и юзкейсы из эрго.
- https://www.ozon.ru/context/detail/id/5430638/
- http://se.ethz.ch/~meyer/publications/functional/meyer_functional_oo.pdf
- https://github.com/jcoplien/trygve
- http://fulloo.info/Documents/trygve/trygve1.html
- Определение хорошейго описания проблемы Lean Architecture for Agile Software Development, p. 70
- Добавить вставки с техниками как в Lean Architecture for Agile Software Development?
- "Localizing change lowers cost and makes programming more fun", Lean Architecture for Agile Software Development, p. 102
- "while modules have a necessary relationship to business semantics", Lean Architecture for Agile Software Development, p. xxx
- "Architecture is more art than sience", Lean Architecture for Agile Software Development, p. 117
- https://www.amazon.com/Pattern-Oriented-Software-Architecture-System-Patterns/dp/0471958697
- https://www.youtube.com/watch?v=Nsjsiz2A9mg
- Arch is about intent, 10:30
- Софт общего назначения не должен зависить от софта спец назначения Lean Architecture for Agile Software Development, p. 176
- Habits из Lean Architecture for Agile Software Development - юз кейсы подсистем?
- "Habits tend to be partial orderings of steps, and can represent business rules, algorithms, or steps in a use case" Lean Architecture for Agile Software Development, p. 184
- "Habits should not have variations" Lean Architecture for Agile Software Development, p. 184
- "It’s common to separate out business rules and other supporting details from use case descriptions", Lean Architecture for Agile Software Development, p. 183
- Эффекты можно описывать пост-условиями
- if considered harmful
- В случае гуя юз кесйы должны быть в гуе? Что делать с многопользовательскими юзкейсами (Запрос/апрув блокировки)?
- Переходы между разделами/главами
- Баги видны только через эффекты
- алиасы + персональные менеджеры
- Patterns of Software - habitability
- A complex software system becomes manageable when responsibilities are partitioned and organized and when collaboration follow predictable patterns. Object Design Roles Responsibilities and Collaborations, Chapter 5. Collaborations
- Conceptual integrity is an attribute of a quality design. It implies that a limited number of design "forms" are used and that they are used uniformly. Object Design Roles Responsibilities and Collaborations, Control Style Options минус одна страница
Введение
Мотивация
Начинается новый рабочий день. Вы приходите на работу или натягиваете рабочие штаны, если повезло работать из дома. В багтрекере на вас назначена новая задача. Или эта задача висит уже несколько дней или даже недель. Её надо делать, вы понимаете, что ещё важнее налить кофе. Идёте наливать кофе, если вам "повезло" курить, то заодно можно и покурить. Если вам "повезло" работать в офисе, то в курилке цепляетесь языками с коллегой. Так прошёл час и вы возвращаетесь к компьютеру. Но вспоминаете, что не проверили почту! Идём проверять почту. Так почта, проверена, но чего-то ещё не хватает. А, точно, новости! Обязательно индустриальные, не шоубиз или политика какая. Ну и кофе кончился, да и час прошёл, покурить ещё раз можно. Прошёл ещё час. В принципе уже и пообедать можно. А после обеда покурить - святое дело. Да и кофе остыл, надо новый налить. Ещё час. Скоро стендап, там надо будет что-то говорить, так что надо уже таки наконец пытаться начинать пытаться что-то как-то делать…
Знакомая ситуация? Мне - да. У меня так бывает когда я боюсь делать задачу, потому что практика показывает, что любая правка вносит два бага в самых разных и неожиданных местах. Или второй вариант - не понятно не то что как работает тот код куда надо вносить правки, непонятно даже где этот самый код и как его искать. А единственный человек который это знал уволился пару месяцев назад.
Я профессионально занимаюсь разработкой софта с 2004 года. За это время я поработал в пятнадцати командах и более двадцати проектах. Это были очень разные проекты - от встроенных систем до биг даты, с командой от одного до двадцати пяти человек, гринфилд проекты и проекты корнями уходящие в 80-ые годы. Одно объединяло все эти проекты - в каждом из них хотя бы раз был день из первого абзаца.
Для меня разработка софта это не способ поменять N единиц времени на K единиц денег. Для меня разработка софта явлется основной областью интересов. Поэтому я много часов (возможно те самые десять тысяч) провёл в поисках ответов на вопросы "Почему весь нетривиальный софт так сложно понимать и так страшно менять?" и "Как делать софт, котрый легко понимать и безопасно менять?".
И в результате этих размышлений я пришёл к выводу, что все эти проекты объединяли скрытые связи в коде. Именно скрытые связи делают код и хрупким и сложным для понимания. Скрытые связи делают тестирование кода сложным и/или бессмысленным (проходящие тесты ничего не говорят о работоспособности кода). Скрытые связи невозможно исключить полностью, поэтому "эти дни" - я это часть нашей профессии, а умение работать в такие дни - часть профессионализма. Но скрытые связи можно максимально проявить и свести количество "этих дней" к минимуму.
В этой книге я привожу концептуальную модель софта и набор практик разработки, которые: . Делают Очевидным то, какие функции выполняет софт . Делает Очевидным то, что является входом и выходом каждой функции, выполняемой софтом . Делает Тестируемым то, что невозможно сделать Очевидным в силу его естественной сложности
Благодаря этому, становится намного проще понять, куда именно необходимо вносить те или иные правки и каковы будут их последствия. А для сложных частей кода можно быть уверенным в тестах.
Главной мотивацией к написанию этой книги было структурирование собственных мыслей о том, как писать эргономичный код. Кроме того, мне требовалось руководство разработчика в командах, которыми управляю я сам, и как референсный (todo: корректное слово) материал в предложениях по улучшению кода и архитектуры в командах, в которых политику разработки определяют другие люди.
Кроме того я уже много лет преподаю различные курсы по программированию и просто довольно много взаимодействую с молодыми программистами. И в последнее время я начал уставать от пересказа одних и тех же идей по нескольку раз в год и с этой книгой у меня есть единое и "консистентное" (todo: перевести на русский) место, куда можно отсылать учеников.
Я пишу эту книгу с очень амбициозной целью - создать новый стандарт де факто разработки коммерческих приложений. Стандарт, который сделает софт эргономичным не только для конченого пользователя, но я для разработчика.
Что такое эргономичный код?
(todo: попровить шрифт цитат)
Что же такое эргономичный код? Для начала рассмотрим несколько определений термина "эргономичность" в общем смысле, а потом адаптируем их к коду:
Эргономичность - наличие условий, возможностей для лёгкого, приятного, необременительного пользования чем-либо или удовлетворения каких-либо нужд, потребностей
Эргономичность - способность продукта быть понимаемым, изучаемым, используемым и привлекательным для пользователя в заданных условиях
Эргономичность - дизайн оборудования, учитывающий взаимодействие человек/машина, позволяющий снизить вероятность ошибки оператора, повысить комфортность условий его работы.
Эргономичность - в изначальном смысле это эффективность инструмента производства или системы в эргономике. Под эффективностью при этом понимается наибольшая производительность при наименьшей вероятности ошибки (пользователя но не устройства). Ныне термин употребляется в более широком смысле, обозначая общую степень удобства предмета (не обязательно средства производства), экономию времени и энергии при использовании предмета. Например: «эргономичный токарный станок», «эргономичный электромобиль» или даже «эргономичный стул».
В нашем случае, понятно, пользователем/оператором/человеком будет программист, чем-либо/продуктом/оборудованием/инструментом производства будет код, а пользованием/использованием будет внесение модификаций (включая добавление нового кода) в существующий код. В первой цитате, мне (как "пользователю" кода) нравятся характеристики "лёгкий и приятный в использовании"; В второй цитате, мне нравятся характеристики "понимаемый и изучаемый"; В третьей цитате, мне нравится характеристика "снижающий вероятность ошибки"; Наконец, в четвёртой цитате (помимо уже упомянутой вероятности ошибки) мне нравится характеристика "наибольшая производительность".
Объединив все эти характеристики, получаем следующее определение:
Эргономичный код - это код, обеспечивающий наибольшую производительность программиста, за счёт простоты понимания и изучения, снижения вероятности внесения ошибки при модификации. Понятный и защищённый от внесения ошибок код, в свою очередь становится лёгким и приятным для внесения изменений.
Важно понимать, что создание эргономичной вещи требует намного больше усилий, чем создание просто вещи. Поэтому эта книга не о том, как сделать вашу жизнь лёгкой сегодня, эта книга о том, какие усилия надо приложить сегодня, чтобы сделать вашу жизнь лёгкой завтра.
Что же делает код эргономичным? Явность (todo: перевести на русский) связей и надёжный набор автоматизированных тестов. Тому что это значит и как этого достичь посвящена вся оставшаяся часть книги.
Это всё из-за эффектов (todo: или таки состояния)
Для начала определимся с терминологией и для этого обратимся к основам ИТ - устройству компьютера. Напомню, что упрощённо, компьютер состоит из трёх частей:
- Процессор
- Память
- Устройства ввода вывода
- Материнская плата
А работа компьютера это следующий REPL:
- Дождаться прерывания от устройства ввода
- Скопировать данные из памяти выделенной для устройства ввода в память программы
- Обработать данные в памяти
- Результаты обработки скопировать из памяти программы в память выделенную для устройства вывода
- Отправить прерывание
И любая программа, от таймкиллера на смартфоне, до компилятора, до АСУТП в конечном итоге сводится к тому, что устройство ввода превращает нажатие кнопки в прерывание, а устройство вывода изменяет физический мир благоприятным для пользователя образом.
Так вот в данной книге используются следующие термины:
- Состояние
- значение памяти всех устройств из которых состоит система
- Эффект
- операция записи данных в память
- Событие
- вызов прерывания устройством ввода
Так на самом абстрактом уровне результат работы программы можно наблюдать только по средствам изменения характеристик каких-то физических объектов - пикселей экранов, транзисторов SSD-дисков и т.д. И как следует из приведённых устройств компьютера и его работы, наблюдаемые результаты являются отражением нового состояния системы, изменённого под воздействием эффектов выполненных в ходе реакции на событие.
То есть все программы пишутся ради эффектов, которые они выполняют. А баги в программах - это не те эффекты или те эффекты, но выполненные не так, как ожидает пользователь. Наконец, регрессии в программах - это когда в результате модификации программы изменился набор, порядок и/или значения эффектов, выполняемых программой по определённому событию. (todo: подводку в эргономичном коде про регресии и сложность рефакторинга)
Так вот эргономичная программа, это такая программа, в которой связка "событие → начальное состояние + набор эффектов" описаны настолько просто и явно, что по этому описанию можно было одним взглядом понять, что "в программе очевидно нет дефектов" (todo: сноска на Хоара)
Проблема в том, что сейчас ни где не учат и практически никто не акцентирует внимание на том, насколько важно понимание начального состояние и эффектов программы для корректной модификации программы. В результате обращение к глобальному состоянию и ввод-вывод в произвольных частах программы являются общепринятой практикой в современных программах. А это в свою очередь влечёт то, что понимание эффектов программы требует огромных концентрации и времени.
(todo: систему надо нарезать на пакеты соответствующие объектам из OOSE) (todo: а объекты дизайнить как аггрегаты ДДД) (todo: и минимизировать их кол-во как в ФП #) (#todo: и модули соответствующие чистой архитектуре) (todo: потому что один фиг надо чем-то жертвовать. чем в каждом из вариантов?)
Как появляются скрытые связи?
Скрытые связи появляются в коде всякий раз, когда вы обращаетесь к куче (глобальной памяти). (todo: исключения записать в эффекты?) (todo: менеджед языки уменьшают кол-во скрытых связей?)
(todo: втф в секунду)
Базовые идеи
(todo: сделать факт-чекинг)
Принципиально новых идей в эргономичном подходе нет и его главной контрибуией (todo: перевод) является сбор в одном месте и подгонка друг к другу идей из различных сообществ - в первую очередь объектно-ориентированного и функционального.
- Layered architecture
- Hexagonal/Onion/Clean architecture
- Data, Context, interaction architecture
- Domain Driven Design
- Simple Made Easy
- Functional core, imperative shell
- Railway oriented programming
Давайте бегло рассмотрим эти идеи подчеркнув что роднит эргономичный подход с ними, а что отличает (todo: поправить стиль). Начнём с идей из ОО-лагеря, потому что эргономичный подход это скорее ОО-подход с элементами ФП, нежели наоборот.
Layered architecture
Layered architecture, слоистая архитектура. (todo: найти хоршие ссылки)
(todo: привести 100500ое описание слоёной архитектуры?)
Эргономичный код нарезан в том числе и на слои. Но в отличие от традиционной слоёной архитектуры, слои являются предпоследней гранулярностью (todo: перевод) нарезки, зачастую вырождающейся в нарезку на классы/объекты. Плюс в отличие от многих версий слоёной архитектуры, слой доступа к данным (ввод-вывод) поднят на один уровень с бизнес-логикой. Это сделано во имя "Очевидности и тестируемости": - Благодаря обращению к инфраструктурному слою напрямую из слоя приложения, становится Очевидно какие эффекты имеет функция - Благодаря удалению зависимости слоя бизнес-логики (где обычно находится вся сложность приложения) от слоя ввода-вывода, бизнес-логика становится Тестируемой.
Hexagonal (Ports&Adapters) architecture, Clean architecture, Onion architecture
- Оригинальная статья 2005 года о Hexagonal Architecture
- описание на русском Hexagonal Architecture.
- Оригинальная серия статей об Onion Architecture
- Оригинальная статья о Clean Architecture
- Хорошее пояснение Clean Architecture на русском
- Оригинальная книга о Clean Architecture
- Книга на русском о Clean Architecture
Все эти три архитектуры (HOCA), на мой взгляд, являются вариациями разных авторов на одну и ту же тему. По сути все эти архитектуры призывают к одному - отделить логику от ввода-вывода, для того чтобы её было легко тестировать. И это основное что роднит эргономичный подход с HOCA. Но способы достижения целей у нас разные. HOCA предлагает вводить интерфейсы между логикой и вводом-выводом, что подразумевает активное использование моков в тестах. А тестирование с моками - это тестирование реализации, а не контракта и оно ничего не говорит о поведении кода в бою. Эргономичный же стиль предлагает реализовывать логику ввиде чистых функций, что, во-первых, делает невозможным сокрытие эффектов в дебрях логики и, во-вторых, позволяет тестировать контракт, а не реализацию и именно тот код, который будет работать в бою.
Так же HOCA утверждает, что способы взаимодействия с пользователем и хранения данных являются незначительными деталями. Для того чтобы обеспечить лёгкость замены этих деталек, они предлагают по дефолту вводить интерфейсы между всеми слоями. Я не разделяю мнение, что эти части являются незначительными деталями, поэтому в эргономичном подходе предлагаю не вводить лишних интерфейсов без реальной необходимости, потому что эти интерфейсы не бесплатны.
В целом, я разделяю идею HOCA о том, что фреймворки должны быть задвинуты на задворки приложения (на самый внешний слой). Но если использование той или иной фичи фреймворка делает жизнь проще и не наносит ущерб Очевидности и Тестируемости, то я не вижу большого криминала в зависиомсти от фреймворка. Например, я считаю необоснованной технику, по абстрагированию логики транзакций в шлюзе вместо использования спрингового @Transactional (todo: ссылка на статю Маритна с примером).
Наконец дядюшке Бобу над отдать должное за Screaming architecture. На мой взгляд архитектура это слишком громкое слово, но я включаю этот принцип в тактические приёмы.
(todo: ревью: наверно стоит уделить внимание поподробнее чем они друг от друга отличаются)
Data, Context, Interaction Architecture
Эргономичный подход включает в себя DCI целиком в качестве устройства юз кейса по дефолту. Но так же как и в случае HOCA, эргономичный подход делает акцент на вынесении эффектов в юз кейс (контекст в терминах DCI) и как следствие на чистоте бизнес-логики (ролей в терминах DCI).
В чём эргономичный подход слегка расходится с DCI, так это в вопросе логики в объектах доменной модели. По DCI объекты должны быть "dumb, dumb, dumb", т.е. просто структурами данных. В эргономичном же подходе, доменные объекты во-первых, должны быть иммутабельными, и, во-вторых, должны защищать свои инварианты.
Domain Driven Desing
У эргономичного подхода много общего с DDD. Например сервисы приложений, домена и инфраструктуры из DDD ответствуют юз кейсам, бизнес логике и адаптерам из эргономичного подхода.
Но в отличие от DDD, в эргономичном подходе большая часть поведения уносится в роли DCI. Это сделано потому что подход DDD (помещения максимальной части бизнес-логики в сущности) плохо масшатабируется - у одной сущности может быть много ролей, и если все их засунуть в один класс, то он станет слишком большим. Кроме того анемичная модель является стандартом де факто в индустрии.
И так же как и в случае со всеми предыдущими идеями из ОО-сообщества, эргономичный подход в отличие от DDD делает акцент на чистых функциях.
На этом идеи ОО-лагеря закончены и переходим к ФП лагерю.
Simple Made Easy
Simple Made Easy, (краткий пересказ на русском).
На мой взгляд, Рич Хики - один из самых крутых чуваков в индустрии в наши дни. А этот доклад - один из самых крутых докладов Рича Хики.
Именно этот доклад первым навёл меня на ключевую мысль эргономичного подхода - разделение эффектов и логики. Кроме того в нём есть синхрония todo: нормальное слово в с DCI касательно, разделения структур данных и поведения.
Но я не разделяю мнение Хики о том, что типы бесполезны. На мой взгляд, типы снимают целый класс проблем при модификации кода, и, что ещё важнее, делают существенный вклад в Очевидность кода. Дополнительным плюсом является возможность создания эргономичных ИДЕ, что прекрасно ложиться на идею эргономичного кода.
Так же я не сторонник ядрёной функциональщины с абстракциями ультра высокого уровня. Во-первых их сложно интернализировать todo: перевод до того уровня, чтобы код написанный с их помощью был Очевидным. Во-вторых, они плохо поддерживаются большинством языков на которых пишется большинство программ. В-третьих, они редко точно ложатся на предметную область. В-четвёртых, многие из них созданы для обхода ограничений чистых функциональных языков, и этих ограничений нет в целевых языках эргономичного подхода.
Functional core, imperative shell (FCIS)
Boundaries, версии на русском я не нашёл.
Идеи изложенные в этом докладе являются вторым краеугольным камнем эргономичного подхода. Пересмотр этого доклада привёл меня к концептуальной модели эргономичного юз кейса, которая в итоге вылилась в данную книгу. В эргономичный подход включены обе ключевые идеи этого доклада - разделение логики и эффектов и использование структур данных, передаваемых юз кейсами, в качестве интерфейса между логикой и адаптерами.
Эргономичный подход является надмножеством FCIS и дополняет его как более высокоуровневыми политиками, так и более низкоуровневыми механизмами.
Railway oriented programming
Серия статей о функциональном подходе к обработке ошибок. Суть идеи в том, что юз кейс начинается на основном пути, в случае успеха идёт по нему и там же и заканчивается, но с основного пути есть съезды на "ошибочный экспресс", который ведёт сразу к завершению юз кейса.
Это наиболее низкоуровневая из базовых идей, которая применяется на уровне конкретных методов. Но её вклад в Очевидность настолько важен, что я включил её и в список базовых идей и концептуальную модель юз кейса.
Так же эргономичный подход включает идею того, что ошибки которые предполагают обработку лучше передавать в качестве возможного результата выполнения функции. Исключения же лучше оставить для ошибок программирования и фатальных ошибок в адаптерах и платформе.
Но в отличие от чисто функционального подхода на монадах, предлагаемого в этой серии статей, я за использование банальных ифов раннего возврата там, где они работают хорошо. А они работают хорошо в большинстве случаев. Я выбираю ифы, потому что условие и действие явно прописанные в коде более Очевидные, тем map, который может отработать или нет в зависимости от типа ресивера (todo: переписать по русски).
На этом рассмотрение базовых идей завершено и можно переходить к сути книги. Как я уже говорил, в основе эргономичного подхода лежит концептуальная модель и набор практик. Концептуальная модель описана в главе "Проектирование". Набор практик разделён на практики кодирования и тестирования, и каждый вид практик выделен в отдельную главу. Так же, в приложении приведено множество примеров различных типов приложений в различных предметных областях, которые призваны помочь читателю связать изложенные идеи с каждодневными проблемами, возникающими при написании кода.
Проектирование
(todo: алгоритмы + структуры данных = программы. В том числе на уровне модулей, контейнеров и систем)
... The fundamental organiztion of a system embodien in its components, their relationships to each oterh, and to the environment and the principles guiding its design and evolution
Architecture represents the significant design decisioins that shape a system, where significiant is measured by cost of change
the form of a system, where the word form has a special meainign that we’ll explore a bit later. (p. 2)
(todo:)
(todo: Lean Architecture for Agile Software Development, p. 80)
(todo: In software, an architectural style describes a set of constraints that — if followed — lead to certain traits of a system, http://olivergierke.de/2016/10/evolving-distributed-systems/)
Принципы проектирования
Программы живут только пока они изменяются, поэтому при проектировании программы надо стремиться к тому, чтобы внесение этих изменений было простым. Простота изменений достигается, если при проектировании программ следовать принципами:
- Очевидности
- Локальности
- Расширяемости
Если дизайн и код вашей программы Очевидны, то легко понять, какой код надо модифицировать для реализации изменения и к каким последствиям приведут эти модификации. Очевидность достигается за счёт разделения Логики и Эффектов. Приятным побочным эффектом этого разделения является повышение переиспользуемости Логики. Если Логика просто выдаёт какое-то значение, не порождая никаких эффектов, то к ней могу обращаться разные клиенты, которым нужны разные эффекты. Этого же можно добиться, по средствам инжектирования интерфейса для Эффектов, но это намного более неуклюже (todo: стиль), чем чистая функция + "эффектор" + связующий их код.
Локальность достигается за счёт проектирования модулей с высокой связностью внутри модуля и низкой связностью между модулями. Что в свою очередь достигается за счёт следования принципу SRP из SOLID.
Наконец, расширяемость учитывается в последнюю очередь. Потому что люди плохо предсказывают будущее, а расширяемость стоит ресурсов и в момент разработки и при сопровождении. Но делать заготовки для точек расширения - можно и нужно. Во многом, разделение логики и эффектов уже будет заготовкой для расширения - реализации Логики и Эффектов можно свободно добавлять и комбинировать между собой, а использование данных инкапсулированных в объекте в качестве интерфейса между Логикой и Эффектами, позволит локализовать изменения этого интефейса. Где-то можно выделить алгоритм в отдельный метод или класс - что-то имеющее интерфейс, который в будущем можно будет сделать и легко заинжектить. Где-то вместо простой строки можно использовать класс-обёртку, который в будущем опять же можно будет выделить и заменить на (закрытую) иерархию классов.
Парадигмы программирования (todo: переименовать и унести куда-то)
Какие парадигмы существуют? На данный момент это сложный вопрос - нет единого авторитетного источника, а в разных источниках эти списки разнятся. Но во всех источниках присуствуют следующие парадигмы:
- Процедурная. Вообще считается устаревшей и повсеместно критикуемая. Но на моей практике большинство програм написано в процедурном стиле на объектно-ориентированном языке.
- Объектно-ориентированная. Я думаю большинство промышленных программистов считают её наилучшей парадигмой и считают, что используют именно её.
- Функциональная. Старше объектно-ориентированной, но долгое время использовалась практически исключительно в академических кругах. Однако в последние 10-15 лет стала набирать популярность и в промышленных кругах, во многом в связи с обострением потребности в много-поточном программировании.
- Логическая. Пока что так и осталась исключительно в академических кругах. По крайней мере мне в промышленном коде не встречалась ни разу ни в каком виде за все 15 лет карьеры.
От себя ещё свангую, что ИИ и МЛ со временем приведут к появлению какой-то новой парадигмы, очевидно уже применяемой в промышленном программировании. Но пока не очень понимаю, как она впишется в эргономичный подход. Видимо в качестве одной из функций логики, просто реализованной иначе.
Какая же из этих парадигм позволяет писать эргономичный код?
Процедурное программирование (todo: коммент)
(todo: качественно разботанить тему и обосновать почему ПП хорошо только для эффектов. Ну или убедиться в обратном и написать книгу о ПП:))
Объектно-ориентированное программирование
Если вы ни разу не слышали про ООП, то у меня для вась есть новости:) Если вы слышали про ООП, то, весьма вероятно, у меня для вас есть большие новости:)
Основываясь на определении парадигмы из введения, становится ясно что объектно-ориентированной парадигмы не существует. Сейчас объясню.
Если вы что-то слышали про ООП, то наверняка слышали, что ООП это это программирование с классами и объектами. А принципы ООП это:
- Инкапсуляция
- Полиморфизм
- Наследование
Некоторые особо продвинутые товарищи включают ещё и абстракцию.
Программа в целом в объектно-ориентированном подходе рассматривается как:
- Либо набор объектов, отражающий сущности реального мира (Буч Object-Oriented Design with Applications и Коад Object-Oriented Analysis).
- Либо набор объектов, предстающий команду людей, которая сообща решает общую задачу обмениваясь сообщениями (Вест Object Thinking).
Звучит хорошо, но если вы пробовали применить эти подходы, то столкнулись с тем, что в реальном мире они не выживают.
Сначала рассмотрим классы и объекты. Класс - это матрица для создания объектов. А объект - это сущность, обладающая состоянием, поведением и идентичностью.
Но загляните в реальные проекты. В типовом проекте 90% классов это либо структуры данных без поведения, либо пакеты процедур без состояния и идентичности.
В то же время, классы являются прекрасным инструментом для реализации функциональных концепций замыканий и каррирования, например.
Далее инкапсуляция и полиморфизм. Эти техники активно используются и в процедурной и в функциональной парадигмах - это естественная потребность при написании больших программ.
С наследованием ещё хуже - это инструмент, от которого больше вреда чем пользы (см. Effective Java, "Item 18: Favor composition over inheritance").
Вообще все эти концепции, хоть и чуть более многословно, но вполне моделируются и часто используются в языках и с функциональной и с процедурной парадигмой.
То есть применения рассмотренных техник программирования недостаточно, для того чтобы подход к разработке был объектно-ориентированным. Возможно дело в дизайне?
Дисклаймер - вообще да:). Но к озвученным выше подходам к дизайну возникает много вопросов и вот ключевые:
- Как замоделировать письмо текста ручкой на бумаге? (todo: ответить на вопрос в терминаъ Труъ-ООП)
- Почему в мире в один момент времени живёт только один человек/объект реального мира? Ответ - потому что объекты имеют состояние, а состояние и параллельная работа - это боль и баги. Есть конечно Экторная модель, но это уже из царства функционального программирования.
- Если у меня у объекта двадцать пять операций - мне все их в один класс засовывать? А он не треснет?
В результате предлагаемая модель программы не распространена в промышленном программировании - просто не понятно как реальные программы представить в этой модели. Таким образом получается, что популярная версия ООП, принятая сообществом промышленных программистов, является эволюционным развитием процедурной парадигмы и отдельной парадигмой не является.
Так что же ООП это фикция? В моей карьере был период когда я так считал и благодаря этому периоду я плотно изучил функциональный подход. Но сейчас, после 15 лет изучения, практики и преподавания ООП/ООД, я начал понимать и снова верить в ООП.
Причиной тому послужили книги и статьи трёх других не менее авторитетных авторов:
- Lean Architecture for Agile Software Development, в которой среди прочего описана DCI архитектура, сейчас продвигаемая Коплейном (соавтором шаблонов проектирования).
- Но оригинальная идея DCI архитектуры была описана в статье The Common Sense of Object Orientated Programming, Тригви Риинскауга (автор шаблона MVC). Эта работа, в свою очередь уходит корнями к Working with objects: The OOram Software Engineering Method его же авторства.
- Object-Oriented Software Engineering: Use Case Drive Approach, Ивара Якобсона (соавтор UML).
Оба этих подхода утверждают, что программирование с объектами != программированию на классах и один объект дизайна в коде может превратиться в набор классов и их экземпляров.
OOSE такие наборы классов называет блоками, а DCI - контекстами.
Так же оба этих подхода включают понятие роли (интерфейса) - набора функций, выполняемых объектом (блоком). И один и тот же блок может играть много ролей. И все эти роли не должны быть реализованы в одном классе.
OOSE выделяет три разных вида объектов - интерфейсы (уже в смысле интерфейса системы во внешний мир), сущности и контроллеры. DCI в свою очередь выделяет три других, но очень похожих видов объектов - Data, Context и Interactor. И если OOSE допускает реализацию объекта несколькими классами, то DCI прямо требует разделение объекта на 3 (и более, на самом деле, зависит от количества ролей) этих класса в коде.
Возможно в этот момент вы подумаете "Но группа связанных классов - это же модуль". В том-то и дело, что "классов". Статических структур времени компиляции. Во время выполнения же, модули инстанциируются в объекты (далее будем называть их блоками, чтобы не было путаницы). А то, что принято называть объектами, во время выполнения - может быть как объектом, так и структурой данных в хорошем смысле этого слова. Либо непосредственно с данными, либо со ссылками на методы. Объект превращается в блок, состоящий из структур данных в тот момент, когда становится слишком (todo: это сколько в граммах?) большим или приобретает поведение с разных уровней абстракции и/или консёрнов (todo: перевести на русский).
Именно блоки позволяют из недообъектов-структур собирать те самые каноничные объекты, с идентичностью, поведением и инкапсулированным состоянием. Инкапсуляция на уровне блоков достигается за счёт публикации только ограниченного интерфейса-фасада блока (либо реализации интерфейсов из других блоков) и сокрытия состояния и реализации блока. Один блок может предоставлять несколько интерфейсов нужных ему коллабораторов и реализовывать несколько интерфейсов, определённых другими коллабораторами. За счёт этого достигается полиморфизм на уровне блоков.
Так же как и множество объектов с собственным состоянием может быть порождено статическим конструктором класса, так и множество блоков может быть порождено статическим конструктором модуля. И так же как и класс, может переиспользовать объекты, подменяя им состояние (см Flyweight Design Patterns: Elements of Reusable Object-Oriented Software), так и модуль может переиспользовать часть объектов (поведения) создавая композиции, на основе синглтонов поведения и датахолдеров, загужаемых из БД по ИДу.
(todo: авторская вставка - не к месту. Или сноской сделать или утащить куда-нить) Наконец, блоки надо использовать только тогда, когда решаемая проблема не ложится на объекты. Если проблема хорошо ложится на объекты, то можно и нужно использовать их.
Вот этот подход бы стать тем самым сдвигом парадигмы, который бы породил новую парадигму, если бы какое-либо из значительных сообществ приняло эти правила и стандарты разработки. А не наследование, полиморфизм, инкапсуляция и попытка моделировать реальный мир или антроморфизировать программы..
(todo: "мягкая" подводочка) Но как мы видим, ООП хорошо работает для проектирования крупных частей программы, а в деталях оно скатывается к процедурному программированию. И тут на сцену выходит функциональный подход.
Функциональное программирование
Примерно в 2013-14 кодах (после пары лет работы в типовых проектах на спринге) я решил, что ООП это фикция, которая не работает и пошёл искать счастья в функциональный мир. Три-четыре года я активно изучал и старался применять в персональных проектах чистый функциональный подход. В котором я так же разочаровался.
Основной проблемой функционального подхода на мой взгляд является его отрицание очевидного - Эффектов. А т.к. мы программы пишем ради Эффектов, ему приходится в своём идеальном чистом мире заводить грязный уголок для Эффектов. Тех самых эффектов, ради которых пишется программа. И для того чтобы уберечь свой идеальный мир от грязи эффектов, функциональному программированию приходится выстраивать забор из зубодробительных абстракций. В итоге программы в функциональном стиле понятны только людям с очень мощным бэкграундом в дискретной математике, для которых эти зубодробительные абстракции уже на подкорке. А таких людей очень мало. А у нас в индустрии острая нехватка кадров.
Второе чего мне не хватало в функцональном подходе - это тех самых крупных блоков-объектов из ООП из которых состоит программа во время выполнения. А составить программу из чистых функциональных пайплайнов не всегда получается.
Наконец, иногда "в поле", локальная изменяемая переменная позволяет выразить намерение разработчика Очевиднее, чем попытка завернуть это состояние в какую-нибудь монаду.
Но для реализации Логики нет ничего более эргономичного, чем функциональный подход. Освобождение Логики от Эффектов делает её простой, понятной, локальной, тестируемой, более переиспользуемой и пригодной для параллельного исполнения. А что с Эффектами - главной ценностью, которую создают программы? Для реализации Эффектов нет ничего более эргономичного, чем процедурный подход.
(todo: расписать функциональное представление объектов - последовательность иммутабельных структур с общим идом и менеджер мутабельной ссылки на актуальное состояние)
Так мы приходим к мультипарадигменному подходу.
Мультипарадигменное программирование
Этот раздел начался с вопроса: "Какая же из этих парадигм позволяет писать эргономичный код?". Ответ - эргономичный код позволяет писать только комбинация всех мейнстримовых парадигм.
Объектно-ориентированная парадигма используется для описания структуры объектов, из которых состоят система и подсистемы, а так же потоков данных между ними. Так же в терминах ООП прекрасно реализуются абстрактные типы данных, но они обычно берутся из библиотек, а не разрабатываются.
Функциональная парадигма используется для описания функций системы. То есть Логики, которая интересует заказчика.
Наконец, процедурная парадигма используется для описания процедур воплащения в жизнь решений, принятых Логикой.
(todo: чёт разделение Логики и Эффектов очень напоминает CQRS - надо обдумать)
(todo: прочитать Multi-Paradigm Design for C++ - мош я тут велосипед изобретаю)
Модель системы? (Stub)
(todo: he hardest part of splitting a program into modules is just deciding on what the module boundaries should be. There’s no easy guidelines to follow for this, indeed a major theme of my life’s work is to try and understand what good module boundaries will look like, https://martinfowler.com/articles/refactoring-dependencies.html) Perhaps the most important part of drawing good module boundaries is paying attention to the changes you make and refactoring your code so that code that changes together is in the same or nearby modules.0 As a result I favor using this approach in smaller scopes, but larger applications need high level modules to be developed along different lines. (#todo: This illustrates the advantage of keeping a program factored into small pieces - it allows substitution of those pieces, even if the original writer didn’t have any substitutions in mind. It enables unforeseen customization. #)
Таблица эффектов
(todo: эффекты операции - это публичное АПИ)
В функциональном подходе иногда рассматривают программу как функцию (todo: prooflink):
f(e) = e'
, где e
- это окружение программы (память, диск, экран, сеть), а e'
- изменённое окружение после исполнения программы.
Давайте выполним два небольших преобразования этой функции.
Во-первых, обозначим то, что программа может реагировать на множество различных сигналов:
f(e) = f'(s(e), e)
s(e) = s
f'(s, e) = e'
, где s(e)
- функция извлекающая сигнал s
из окружения e
, а f'
- функция изменяющая окружение e
в ответ на сигнал s
.
Во-вторых, Логику и Эффекты и выделим их в отдельные функции:
f'(s, e) = f'' x g
f''(s, e) = (e, [de]) // Формула 1
g(e, [de]) = e' // Формула 2
, где f''
- функция преобразующая входные сигнал и окружение в вектор Эффектов (и неизменное входное окружение для передачи в g
), а g
- функция применяющая Эффекты к окружению.
Есть три способа определения функции (todo: пруфлинк):
- Аналитический
- Графический
- Табличный
Как описать программу графическим способом я вообще представить не могу, а аналитический способ (исходный код по сути) слишком конкретный для модели. Поэтому давайте в качестве модели программы возьмём таблицу эффектов:
Сигнал | Окружение | Предусловие | Решение | Эффект |
---|---|---|---|---|
Сигнал 1
| Окружение 1.1 | Предусловие 1.1 | Решение 1.1 | Эффект 1.1.1 |
Эффект 1.1.2 | ||||
Окружение 1.2 | Предусловие 1.2 | Решение 1.2 | Эффект 1.2.1 | |
Эффект 1.2.2 | ||||
Сигнал 2 | Окружение 2.1 | Предусловие 2.1 | Решение 2.1 | Эффект 2.1.1 |
Эффект 2.1.2 | ||||
Окружение 2.2 | Предусловие 2.2 | Решение 2.2 | Эффект 2.2.1 | |
Эффект 2.2.2 |
В этой таблице:
- Сигнал
- Какое-то событие в окружении. В самом общем случае это событие оборудования - получения пакета по сети, нажатие на кнопку, истечение таймаута. Но на уровне приложения это превращается уже в событие платформы - поступление хттп-запроса по такому-то урлу, генерация такого-то события у такого-то компонента пользовательского интерфейса. У сигнала могут быть связанные с ним параметры. Сигнал соотвествует переменной s в Формуле 1
- Окружение
- Собственно окружение программы. В самом общем случае - состояние памяти и дисков всех компьютеров, на которых запущена система. На уровне приложения это уже может быть значение глобальной переменной или содержание таблицы в БД. Окружение соотвествует переменной e в Формуле 1
- Предусловие
- Описание значений параметров сигнала и окружения, необходимых для того чтобы решение было принято.
Например - в таблице Х есть запись удовлетворяющая условиям Y, текущее время находится в интервале с 08:00 до 20:00.
Предусловие соотвествует функции
f''
в Формуле 1 - Решение
- Высокоуровневое описание решения.
Например - удалить объект X, перевести объект Y в состояние Z, отправить сообщение K.
Решение соответствует переменной
[de]
в Формуле 1 - Эффект
- Низкоуровневое описание изменений в окружении в следствии реализации решения.
Например - объекту X поле Y установить в значение Z, отправить http-запрос по такому-то урлу.
Эффект соотвествует функции
g
в Формуле 2
Этапы обработки сигнала образуют первую ось модели приложения в эргономичном подходе. (todo: оси в каком пространстве? надо или другую метафору или эту до ума довести)
Для краткого анализа или же для анализа через чур запутанного приложения, колонки "Окружение", "Предусловие" и "Решение" можно опустить.
Построим таблицу эффектов для группы сигналов Q5 связанной с автоматическим сохранением расходов.
Сигнал | Окружение | Предусловие | Решение | Эффект |
---|---|---|---|---|
Опубликована новая нотификация
|
| text совпал с одним из шаблонов | Предложить пользователю сохранить транзакцию с определёнными суммой и категорией | По средствам NotificationManager отобразить нотификацию пользователю. К нотификации привязано два действия - сохранить расход как есть и открыть форму редактирования этого расхода Также нотификация содержит два параметра - check - распознаный чек (текст, сумма, место совершения) и trx - Информация о расходе |
Пришло новое СМС сообщение | ||||
Пользователь подтвердил сохранение определённого (todo: неоднозначность) расхода
| transactions - Таблица расходов | Сохранить расход | Добавить в таблицу расходов запись для trx | |
Пользователь решил внести правки в определённый расход
| Отобразить форму редактирования расхода | Сгенерировать интент открытия EnterSumActivity предзаполненную данными из trx. | ||
Пользователь нажал кнопку "Сохранить расход"
|
| Сохранить расход | Добавить в таблицу расходов запись для trx | |
Место совершения расхода определено | Обновить/дополнить статистику по связи мест с категориями | place2category[check.place] = trx.category |
(todo: При том эффектом в этой таблицы может быть "Сгенерировать сигнал Х".) (todo: как сюда вписать "cross-cutting concerns?") (todo: циклы) (todo: отложенные эффекты - эффективные лямбды переданные в платформу, аля PendingIntent)
Важно заметить, что приведённые сигналы связаны друг с другом - за сигналом "Опубликована новая нотификация" и "Пришло новое СМС сообщение" часто следует сигнал "Подтверждение сохранения определённого расхода" или "Открыть форму редактирования расхода". Перед сигналом "Открыть форму редактирования расхода" всегда имеет место либо один из выше перечисленных сигналов, либо не приведённый здесь сигнал "Открыть форму вывода расходов за период". За сигналом "Открыть форму редактирования расхода" обычно следует сигнал "Сохранение расхода".
Если задуматься все эти Сигналы и Эффекты предназначены для решения одной задачи пользователя - внести информацию о расходе. Одна задача пользователя определяет один Юз Кейс приложения. При том у одного Юз Кейса может быть несколько вариантов, в данном случае - автоматизированный и ручной ввод информации о расходе.
Юз Кейсы образуют вторую ось в пространстве модели приложения эргономичного подхода. (todo: стиль) В эргономичном подходе, программа рассматривается как набор Юз Кейсов, каждый из которых явлется функцией отображающей набор Сигналов в набор Эффектов предназначенных для решения одной задачи пользователя.
Принципы проектирования системы
(todo: https://www.ics.uci.edu/~cs223/papers/cidr07p15.pdf - entity=component?)
Цели
Здесь я буду использовать следующие определения:
- архитектура - логическое устройство системы, оторванное от средства реализации.
- дизайн - реализация архитектуры с использованием конкретных средств (котлин, классы, грэдл и т.п.)
Для любой проблемы (набора бизнес-требований) можно спроектировать множество архитектур, обладающих разными характеристиками. И любую из этих архитектур можно также реализовать множеством дизайнов, также обладающих разными характеристиками.
Так вот, достижение следующих характеристик дизайна не является целью описываемых принципов:
- Возможность 100% покрытия юнит тестами
- Производительность. Но на практике следование описываемым принципам даёт на несколько порядков более быстрый дизайн, чем дизайн полученный по средствам безпринципного программирования, за счёт подсвечивания и последующей оптимизации ввода-вывода, который занимает львиную долю времени обработки события/запроса.
- Масштабируемость. Тут уже теоретически следование описываемым принципам (маленькие агрегаты и интерфейсы компонент, см. ниже) даёт существенно более масштабируемую систему, чем безпринципнре программирование за счёт минимизации конкурентных модификаций глобального состояния и возможности быстрого выделения и деплоя компонент в отдельные сервисы, при необходимости.
- Привычность для глаза среднестатистического ява-разработчика - чтобы можно было нанять кого угодно с рынка, и он сразу бы начал писать код, проходящий ревью с первой-второй попытки.
- Максимальная скорость реализации одной отдельно взятой фичи.
А вот какие цели преследуются: . Минимизация зависимостей в коде . Предельно простое описание контракта событие (рест-запрос) → эффекты (запись в бд, отправка сообщений) . Подсвечивание связей через глобальное состояние между разными частями кода . Простота покрытия надёжными (без моков) юнит-тестами бизнес-логики
Благодаря этому минимизируется количество регрессий при рефакторинге и реализации новых фич. Благодаря чему у команды исчезает страх перед рефакторингом из-за боязни что-то сломать. Благодаря чему общий дизайн постоянно улучшается и адаптируется к изменениям в требованиях.
Так же благодаря п. 4 (+ фокус на интеграционных тестах) минимизируется количество изменений, требуемых при рефакторинге (в идеальном случае они инкапсулируются в одном компоненте), что убирает другой перед рефакторингом из-за боязни застрять на изменении всей системы. Благодаря чему, опять же, общий дизайн постоянно улучшается и адаптируется к изменениям в требованиях.
Благодаря качественному дизайну, в долгосрочной перспективе, средняя скорость становится превышает среднюю скорость разработки с "экономией" на дизайне.
Путь в этот чудесный мир, хорошего дизайна, надёжных тестов и быстрой и приятной разработки лежит через настоящие объекты/компоненты.
ООП, ООД и компоненты
Я утверждаю, что ООП - не смогло выполнить общения миру о более поддерживаемом коде. В результате ООП захватило мир чисто номинально и повсеместно выродилось в процедурное программирование с элементами полиморфизма со всеми его проблемами. С этим тезисом согласен и один из корифеев ОО Дэвид Вест: OOP is Dead! Long Live OODD!. Сейчас 90% классов в индустрии это либо ваще примитивные структуры данных (энтити, дто), либо синтаксический сахар над старыми добрыми структурами с функциональными указателями из С (контроллеры, сервисы). При том, что именно реализацию лучше писать в функциональном стиле, т.к. в результате получается код, по которому проще понять его контракт и который проще протестировать и, следовательно, проще поддерживать.
А вот объектно-ориентированные дизайн и анализ же - это совсем другая история. Напомню, что изначально объект - это состояние, поведение и идентичность. И если объектно-ориентированный подход применять к более крупным чем классы блокам - компонентам - то он внезапно из теоретических лозунгов превращается в практический инструмент. В моей концепции компонент физически представлен набором классов, находящихся в одном пакете, но, при необходимости, разбитых на несколько грэдл-модулей (в зависимости от нужных им зависимостей, прошу прощения за каламбур:)). А логически, компонент - объект, т.е. обладает состоянием и поведением.
Но возникает вопрос - что такое состояние и поведение у компонента (пакета с множеством классов в разных модулях)? Для ответа нам понадобятся агрегаты из Domain Driven Design (DDD) и Application Service/Workflows/Pipelines/Use Cases из DDD/Domain Modeling Made Functional/ЭП.
Агрегаты
Агрегат из DDD - это граф объектов (JPA Entity) с корневым объектом (корень агрегата), который является единицей персистанса, т.е. этот граф загружается целиком (без ленивой загрузки) и сохраняется целиком. DDD накладывает ряд ограничений на агрегаты:
- Как я уже писал - агрегаты загружаются и сохраняются целиком;
- это влечёт рекомендацию держать агрегаты маленькими;
- репозитории пишутся только для агрегатов;
- на агрегат можно ссылаться только через корень;
- ссылки между агрегатами делаются только через идентификаторы корней;
- в одной транзакции можно менять только один агрегат (создавать можно сколько угодно);
- отсюда рекомендация проектировать агрегаты исходя из юз кейсов, а не модели данных;
И уже моё ограничение - в БД изменения вносятся только через репозитории.
Другие виды состояний компонента
Агрегат - это наиболее распространённый вид состояния в информационных системах, но вообще состояние - это любая внешняя система
- все виды баз данных;
- файловая система;
- пуш-сервисы;
- любые внешние информационные системы - Jira, Jenkins, Google Docs;
- Email;
Workflows
См. Модель Юз Кейса
Workflow верхнеуровнево описывает одну операцию системы и отвечает за две функции:
- Управление потоком данных;
- предельное простое описание в одном месте глобального состояния необходимого для выполнения операции и эффектов выполнения операции.
Реализуются они в функциональном стиле, при желании без монад - в workflow описывается сэндвич из максимально простого (без условной логики) ввода-вывода и сложной логики.
И снова компоненты
Так вот состоянием компонента является изолированный кусочек состояния внешней системы (таблиц агрегатов, например), который должен меняться атомарно и изменение которого компонент инкапсулирует за поведением - workflow-ами. В оригинале у объекта есть ещё идентичность, но она в этой концепции не особо нужна, т.к. большинство компонент в рантайме будет в единственном экземпляре. Но если надо несколько экземпляров, то идентичность компонента привязывается к идентичности объекта фасада. Публичным интерфейсом компонента выступает класс-фасад, который либо сразу содержит workflows, либо просто делегирует их выделенным для них классам. Аргументы и результат метода фасада должны быть экземплярами DTO-классов. Сущности, агрегаты и репозитории являются приватными членами - не уверен что это удастся реализовать на практике только средствами Java/Gralde/Maven. Но ArchUnit, надеюсь, сможет помочь.
Продолжая аналогию с классами/объектами: . Класс в ООП = пакет в ООД . Объект в ранатайме в ООП = граф объектов в рантайме с корнем в виде фасада в ООД . Конструктор объекта = специальный класс (Spring-конфигурация), который на вход получает конфиг и набор других компонент (в виде фасадов), строит граф объектов компонента и возвращает объект-фасад . Метод = метод фасада . Поле = некоторое глобальное изменяемое состояние - просто изменяемое поле класса/объекта, таблица в БД, таблица в БД за РЕСТ АПИ внешней системы и т.д.
Тут ещё детально не продумывал, но такое ощущение, что все принципы ООП - ацикличный граф зависимостей, high cohesion/low coupling, SOLID, CQS и контракты Мейера и т.д. - прекрасно и, главное понятно, работают на уровне компонент. За исключением всего, что касается наследования, понятное дело. Но оно в любом случае должно уйти на покой:) И тут Вест снова со мной согласен, см. OOP is Dead! Long Live OODD!
Из системы компоненты выставляются по средствам адаптеров - рест контроллеры, либо какие-то другие штуки, которые знают как делать ввод-вывод пригодный для использования конечным пользователем или внешней системой.
Если интерфейс компонента сразу сделать асинхронным, то его можно тривиальной манипуляцией вынести в отдельный сервис при деплое. Это уже будет экторная модель дефакто:)
Надо подумать, но вроде вариант инкапсуляции нескольких агрегатов в одном компоненте допустим.
Gradle/Maven-модули
Вообще я сторонник кричащей архитектуры. Поэтому надо стремиться к тому, чтобы компоненты соответствовали модулям 1 в 1. Но из-за особенностей систем сборки на Java (нельзя прописать Gradle-зависимость конкретному классу), компонент разбивается как минимум на два модуля - домен и инфраструктура.
В домене живут энтити, агрегаты, интерфейсы репозиториев и других гейтвеев, workflows и всё что надо для их работы. Сюда же можно поместить доменные сервисы, но они должны быть чистыми - без ввод-вывода. И эти модули не зависят ни от чего, кроме модулей других доменов и небольших, неинвазивных локальных библиотек.
В инфраструктуре живут реализации репозиториев и гейтвеев, контроллеры и конструктор компонента (Spring-конфигурация, например). И они зависят от всех фреймворков вроде Spring. Конфигурация модуля публикует (в Spring-контекст, например) только контроллеры и юз кейсы, репозитори репозы и гейтвеи создаются и инжектируются в юз кейсы приватно и снаружи недоступны.
В принципе можно вообще обойтись двумя модулями - app и domain, и их внутри уже на пакеты нарезать на компоненты. Но т.к. я сторонник кричащей архитектуры и модули это намного более прочные границы, чем пакеты, я всё-таки за то чтобы доменные части компонент выделять в отдельные модули.
Подсистемы
В целом систему можно бить на подсистемы, состоящие из логически и физически сильно связанных компонент.
Модель Юз Кейса
(todo: сделать подводку, что все беды от смешения логики и эффетов. Её видимо надо делать во введении и привести пример тиндера)
Самое важное, что необходимо сделать для Очевидизации (todo: перевести на русский) связей в приложении - это разделить нетривиальную логику и эффекты. Для достижения этой цели, эргономичный подход рассматривает программу как набор юз кейсов, каждый из которых состоит из следующих частей:
- Платформа - базовый код обеспечивающий общение с внешним миром и универсальные сервисы;
- Порты - обработчики событий во внешнем, вызываемые платформой;
- Адаптеры - точки "выхода" из приложения, в которых сконцентирированы эффекты;
- Логика - "мозг" приложения, в котором содержится вся сложная логика;
- Юз кейс - "обединятор" (todo: перевести на русский) приложения, который отвечает за организацию потока данных между адаптерами и логикой.
Платформа
В платформу я включаю всё, что не является непосредственной функцией приложения - начиная от железа, продолжая осью, библиотеками ввода-вывода, мидлварем, фреймворками и заканчивая вашим инфраструктурным кодом. Платформа отвечает за взаимодействие со внешним миром и у этого взаимодействия, по сути есть только два варианта - понять что наступило какое-то событие (пришёл пакет по сети, пользователь кликнул мышью, истёк таймаут) и обменяться массивами байт с каким-то железом.
Если в вашем инфраструктуром коде есть какая-то логика, то ещё раз подумайте, там ли ей место. Если место всё-таки там, то инфраструктур можно рассматривать как отдельную программу так же состоящую из юз кейсов и при менять к ней те же принципы, что и к верхне-уровневой программе, которая решает проблемы конечных пользователей.
Порты
Порт является точкой входа в функцию системы. Его задача - принять вызов, сконвертировать входные данные и создать объекта юз кейса, передать в него управление и вернуть результат, снова сконвертировав его. Конвертация входов/выходов и создание объектов юз кейсов опциональны - конвертацией может заниматься платформа, а юз кейс может быть инжектирован в порт, если у него нет состояния. В коде портов не должно быть никакой логики - ифов, форов, вызовов приватных методов. Порты инкапсулируют в себе логику регистрации методов в платформе и могут иметь аннотации специфичные для платформы и принимать на вход объекты классов, определённых в платформе. Но обращение к методам платформы настоятельно не рекомендуется, а обращение к методам платформы, которые ведут к изменению состояния внешней среды запрещено.
Далее для простоты я буду называть событиями все вызовы из платформы методов портов. Так, в случае веб приложения вызов метода, назначенного на обработку запроса определённого URL будет событием "Поступление HTTP-запроса XXX", а вызов метода назначенного на исполнение с определённой периодичностью или в определённый момент времени будет событием "Срабатывание расписания (таймера) ХХХ". События асинхронного ввода-вывода и события тулкита пользовательского интерфейса укладываются в этот термин естественным образом.
В вырожденных случаях (например CRUD операция), я не вижу особого криминала, в том, чтобы смёржить порт и юзкейс и из порта обратиться непосредственно в адаптер и вернуть результат. При условии, что соблюдается запрет на логику в порте (включая логику выраженную декларативно - читай транзакции). Так же не стоит в одном классе смешивать выделенные порты и порты-юзкейсы.
Порт может вызвать только один юз кейс. Если вам надо вызвать два юз кейса, значит у вас есть составной юз кейс.
Зачастую у одного нетривиального юз кейса может быть несколько портов, которые переводят управление на разные этапы юз кейса. Может быть и наоборот, несколько портов вызывают один и тот же юз кейс. В этом случае, желательно, объединять их в одном классе.
(todo: обобщить на случай юз кейсов подсистем, вызываемых из юз кейсов первичной системы)
Адаптеры
Адаптеры делают программу живой для внешнего наблюдателя. Сделать программу без адаптеров можно, но это будет чёрная дыра, которая просто всасывает ресурсы и ничего не выдаёт взамен.
Главной задачей адаптеров является исполнение Эффектов. Поэтому это единственные компоненты, которым разрешено обращаться к Платформе. Но как я писал ранее, разрешение на исполнение эффектов исключает сложную логику (todo: стиль)(todo: привести критерии определения сложности логики).
Именно в адаптерах берёт своё начало запрет на сложную логику, который транзитивно распространяется на юз кейсы и порты. Дело в том, что уверенность при внесении изменений в сложную логику требует набора надёжных тестов. А все эти компоненты транзитивно зависят от платформы и ввода-вывода, которые сложно привести к пред определённому состоянию и которые работают на порядки медленнее чистых функций. Создать набор исчерпывающих тестов в таких условиях наверное возможно, теоретически, но на практике я ни разу такого не видел.
Что я часто видел на практике, так это замокивание ввода-вывода, но я считаю моки плохой практикой. В этом случае ваши тесты завязываются на реализацию тестируемого кода - они начинают зависеть от того, что и в каком порядке он вызывает, и требуют обработки напильником после каждого рефакторинга. Плюс тесты с использованием моков совершенно ничего не говорят о работоспособности вашего кода в бою. Это приводит к тому, что либо эта логика не покрыта тестами которым можно доверять и её страшно менять, либо любое изменение этой логики требует существенно больших усилий на исправление тестов, которые сложно, скучно и не приятно делать.
Если же порты, юз кейсы и адаптеры простые, то их достаточно покрыть минимальным набором интеграционных и приёмочных тестов, для того чтобы быть уверенным в том, что система работает.
Но бывает так, что атомарная с точки зрения юз кейса операция требует логики. В этом случае эта операция является юз кейсом более низкоуровневой подсистемы, которая должна быть выявлена, названа, ограничена и оформлена в соответствии с правилами эргономичного подхода.
Логика
Логика. Она же предметная область, она же домен, она же Бизнес-Логика, она же бизнес-правила, она же домен. Вот здесь уже нет никаких ограничений на конструкции управления - можно оторваться за все лишения в остальных компонентах. Но тут есть другое ограничение - логика должна быть чистой в функциональном смысле, то есть не иметь наблюдаемых сайд эффектов.
Логика не должна быть реализована в идиоматичном функциональном стиле - весь код в функциях, без переменных, только с неизменяемыми структурами данных, с монадами и их интерпретаторами, трнасдьсерами, зипперами и т.д. Более того, я против того, чтобы все эти абстрактные термины фигурировали в коде. Это детали реализации и они снижают отношение сигнал/шум и путают неинициированных, коих пока что большинство. Поэтому если любите классы и объекты - пожалуйста, императивные форы и ифы - я не против, изменяемые локальные переменные и массивы ради эффективности - я только за. Даже исключения и try-catch можно, но я бы хорошенько подумал, как обойтись без них. Ну и да логгирование тоже можно, при условии, что оно не является функцией вашей системы, значимой для конечного пользователя. Вобщем, при реализации логики надо следовать двум правилам:
- каждая функция или метод для одних и тех же параметров должна всегда возвращать одно и то же значение.
- функции и методы не должны менять глобальное состояние в ходе своей работы. Тут не много сложнее, поэтому поясню. Результат работы Логики должен быть целиком заключён в значении возвращаемом вызванной функции. Никаких записей на диск (по крайней мере значимых для пользователя и/или влияющих на дальнейшее функционирование системы), ни каких отправок пакетов по сети, никаких отображений чего либо на экране, никаких воспроизведений звуков, ни каких присваиваний в глобальные переменные, никакого вывода в консоль. Ничего что можно заметить, помимо результата вызова функции.
Это ограничение основано на той же мотивации - сложная логика должна быть исчерпывающе покрыта тестами. Ввод-вывод исчерпывающе покрыть тестами сложно, замокать его и сложно и бессмысленно, поэтому единственный вариант - исключить его из кода требующего исчерпывающего покрытия тестами.
Так же хочу отметить, что фигура изображающая логику на иллюстрации эргономичного юз кейса, не просто так больше по размеру всех прочих компонент и имеет самые толстые границы. В идеальной реализации эргономичного подхода именно в логике содержится большая часть кода, и защите логике от внешней среды уделяется особое внимание.
Технически, логику следует помещать либо в сущности предметной области, либо в DCI роли, в зависимости от контекста.
Юз кейсы
Главной задачей кода реализации юз кейса явлется предельно ясное, декларативное описание юз кейса с точки зрения пользователя, а так же входных данных юз кейса и видимых эффектов, к которым приводит его выполнение. В идеале должно быть как в старых добрых книгах по XP и DDD - вы показываете код юзкейса заказчику и он его понимает в общих чертах. Для того чтобы код юз кейса был максимально приближен к языку пользователя, он не должен содержать низкоуровневых деталей и сложной логики.
С технической же точки зрения, юз кейс является центральным связующим звеном между Портами, Адаптерами и Логикой. Юз кейс определяет верхнеуровневую структуру потоков управления и данных.
Юз кейс может быть простым и много шаговым. Юз кейс является простым, если его цель может быть достигнута в результате обработки одного события. Для этого необходимо чтобы все требуемые данные были доступны в момент обработки этого события и чтобы все эффекты могли быть выполнены в процессе обработки. Юз кейс является много шаговым, если для достижения цели юз кейса требуется факт возникновения нескольких событий или части входных данных становятся доступны в разные моменты времени или эффекты могут быть выполнены в разные моменты времени
Технически, юз кейс может быть представлен объектом без состояния, объектом с состоянием только в памяти, и объектом с состоянием во внешнем хранилище.
Первый тип наиболее простой и распространенный и подходит в случаях, когда всё состояние юз кейса хранится в объектах предметной области. В этом случае, единственный объект юз кейса создаётся платформой или приложением и инжектируется в порт. Затем порт может либо самостоятельно получить объекты предметной области и передать их в юз кейс, либо передать в юз кейс идентификаторы этих объектов (которые содержатся в событиях). Какой вариант лучше выбрать, зависит от конкретного случая.
Если же юз кейсу требуется какое-то состояние, которое не укладывается естественным образом в модель предметной области (todo: например?), то необходимо создать репозиторий юз кейсов, к которому будет обращаться порт, для получения объекта юз кейса. Репозиторий может быть как ин-мемори, так и персистентный. Ин-мемори вариант проще и быстрее, но персистентый позволяет юз кейсам переживать шатдауны и работать в много-нодовой среде. В случае персистентного юз кейса, можно состояние юз кейса выделить в отдельный объект и сохранять только его. Наконец, объекты юз кейсов с состоянием должны быть синхронизированы должным образом.
Несколько тривиальных одно шаговых юз кейсов можно группировать в один класс (без приватных методов). Составной же юз кейс, должен целиком содержаться в одном отдельном классе и быть единственным содержимым этого класса. Допустимо, чтобы несколько разных портов вызывали один и тот же юз кейс.
Я настоятельно рекомендую не использовать в юз кейсах какие-либо управляющие конструкции (todo: уточнить термин) за исключеним ROP-конструкций (конструкции вида if (error) return ErrorData
) и условий отражающих описание юз кейса на естественном языке.
В юз кейсах недопустимо использование блоков с уровнем вложенности более двух и вызов приватных методов (todo: стиль).
Если в вашем описании юз кейса на естественном языке есть уровень вложенности больше двух - пересмотрите его.
(todo: изучить возможность использования корутин для описания много шаговых юз кейсов одним методом)
(todo: ROP вместо исключений отделяет ошибки предметной области от ошибок программирования)
Взаимодействующие с гуём (диалог подтверждения операции)
To do
Дополнительные эффекты применения модели юз кейса
Производительность
Одним из приятных эффектов отделения логики от Эффектов (прощу прощения за каламбур:) ) является натурально более производительный код. Это обусловено двумя причинами. Во-первых, выделяя Эффекты вам у вас будет естественное желание минимизировать эту работу и получать все необходимые данные одной пачкой. А то что пакетный ввод-вывод всегда быстрее (и часто на порядки) единичного ввода вывода - это одна из аксиом (todo: вообще это обоснованное правило) разработки софта. Во-вторых, все Эффекты вытянутые в юз кейс становятся Очевидными и вы быстро поймёте, что юз кейс становится тяжёлым и в его реализации необходимо держать производительность в уме.
На этом мы завершаем рассмотрение концептуальной модели софта и начинаем потихоньку двигаться в сторону практики.
Декомпозиция приложения
Разбиение по видам классов
У меня нет однозначного и универсального рецепта разбиения классов по пакетам заранее. Но я точно могу сказать, что не надо разбивать проект по видам классов - entities, services, controllers. В особо одиозных случаях заводят пакеты exceptions, enums и annotations. Пакетов classes и interfaces почему-то ни разу не видел:) В плюсы такого подхода можно попытаться записать только то, что при его использовании не надо думать. Но, во-первых, в нашей работе это минус, а во-вторых, думать всё-таки надо - либо как привести класс к одному из существующих видов, либо придумать новый вид. К дизайну ни та ни другая деятельность отношения не имеет и я считаю, что время лучше посвящать продумыванию дизайна системы.
Проблемы пакетирования по видам классов:
- Не все классы однозначно относятся к одному виду
- Плохо масштабируется
- Скрывает описание архитектуры за деталями реализации
- Изменения одной фичи, как правило затрагивают несколько модулей
- todo: сложнее рулить логами через стандартные тулы
- todo: проблемы с вайлдкард импортами apx_talk_clean_coders_hate, apx_books_clean_code:Chapter 17, J1
- Все выше перечисленное - это мелкие не приятности. Действительным же аргументом против такого стиля пакетирования, является то, что он исключает использование ограниченных модификаторов доступа (package private в Java, internal в Kotlin) и вынуждает весь код делать публичным. В итоге границы отсутсвуют в принципе - есть только соглашение о том что из более низких слоёв нельзя обращаться к более высоким. А внутри слоёв и от более высоких к более низким слоям даже никаких соглашений о границах нет. В итоге получается мегамесиво, слегка напоминающие очертаниями снеговик. Это ещё больше усугубляется при использовании спригового компонент скана и иньекции зависимостей на полях.
Другие идеи к разбиению классов
Что касается правильного разбиения с самого начала проекта, то за вдохновением советую обратиться к:
- статье "Four Strategies for Organizing Code"
- статье "Screaming architecture"
- и к главе "34 THE MISSING CHAPTER" из книги "Clean Architecture".
- пакетирование по объектам-блокам из Object-Oriented Software Engineering: Use Case Drive Approach
- https://phauer.com/2020/package-by-feature/
- глава 10 "Modules", Object-Oriented Software Engineering: Use Case Drive Approach
Мой подход к разбиению классов
- По началу я складываю все классы в один модуль пакет, потому как моя методика требует некоторой критической массы классов, для того чтобы сработать.
- Мою методику можно применять, когда:
- Набралось хотя бы 10, а лучше 20 классов. Но я обычно на интуитивном уровне, чувствую, что пора навести порядок в этом бардаке.
- Когда целиком реализовано 3-5 юз кейсов, среди которых есть и однотипные и ортогональные
- После того как набирается достаточное количество классов, я строю для них матрицу зависимостей. И разбиваю все циклы в зависимостях. Это бывает очень сложно, но многие из лучших своих решений я нашёл именно разбивая циклы.
- После того, как все циклы разбиты, классы должны разбиться на три вида кластеров:
- кластеры классов, от которых ничего не зависит, но которые зависят от почти всех остальных классов (это будут порты и код сборки и инициализации графа объектов вашего приложения, при запуске)
- кластеры классов, которые сами ни от чего не зависят, но от которых зависит почти всё (это будет домен/логика)
- кластеры классов, от которых и зависят и другие классы и которые сами зависят от других классов (это будут порты, юз кейсы и адаптеры).
- Кластеры должны быть высоко связные (highly cohesive, много связей между классами внутри кластера) и слабо связанные (loosely coupled, мало связей с классами из других кластеров). Вот эти кластеры я и делаю пакетами/модулями.
- Если после разбиения циклов кластеры не выявились, то тут уже надо смотреть каждый конкретный случай и универсального рецепта у меня нет.
Кодирование
Конструкторы должно создавать валидные объекты
У класса может быть не более 5 зависимостей
Под зависимостями я понимаю параметры конструктора, включая примитивные (конфигурацию). Обращение к синглтонам откуда-либо помимо платформы запрещено категорически. У этого правила несколько оснований:
Если вашему классу требуется более 5 зависимостей, то он либо делает слишком много, либо делает это использую слишком низкоуровневые примитивы (зависимости), на базе которых надо создать новую абстракцию.
Наследование (todo)
Открытые иерархии
Закрытые иерархии
Избегайте интерфейсов с единственной реализацией (todo)
Потому что они создают только видимость барьера и усложняют код. Невозможно сделать настоящий интерфейс по единственной реализации. Интерфейсы в АПИ лучше делать абстракными классами с закрытой реализацией, чтобы клиенты не могли их реализовывать. Интерфейсы в SPI - норм.
Иммутабельность по дефолту (todo:)
Защита от случайного внесения эффекта
Domain Specific Languages (todo)
Типизированные ИДы (todo:)
Типобезопасность и проще грепать логи
CQRS (todo:)
Обработка ошибок (todo:)
Find Usages колонок БД (#todo: #)
Для того чтобы код был очевиден, необходимо чтобы была возможность быстро найти все использования определённой колонки БД хотя бы внутри приложения.
Тестирование (todo)
(#todo: #)
поэтому я всё-таки за компромисс и по самому свежаку, начал выделять тесты 4ёх типов:
- Тесты эффектов (репозов, гейтвеев) - для всего, что возможно используются реальные зависимости (постгрес в докере на рам диске), где нельзя (облако для пушей) - пишется стаб, который слушает настоящий tcp-порт
- Тесты бизнес логики домена - пишутся без моков.
- Тесты юзкейсов - должны быть, пишутся без моков, но можно застабить эвент паблишер. стаб вместо мока позволит, если вдруг потребуется, не переписывать все тесты при изменении интерфейса паблишера. работают изнутри всё ещё - приложение не запускается через мейн, но тест сам себе собирает нужный граф объектов и тычет его как надо
- Сценарные тесты - живут в отдельном модуле независящим от основного приложения, ДТОшки тупо копи-пастятся, работают снаружи, прогоняют реальные хэппи пасы из прода и особо важные фейлы
(#todo: #)
Моки (todo)
Использование моков для подсовывание входных данных - зло. Моки можно использовать для верификации эффектов юз кейсов, но по возможности лучше всё-таки отдавать предпочтение аксептанс/интеграционным тестам.
TDD? (todo)
Ассерты (todo:)
Контракты (todo:)
Заключение
Эргономичный подход рассматривает систему как набор юз кейсов. Каждый юз кейс реализуются набором компонент различных типов: платформа, порты, юз кейсы, адаптеры и логика. Каждый из типов может содержать либо Эффекты, либо Логику.
Эргономичный подход делает два акцента:
- Описание всех Эффектов юз кейса должно содержаться в одном месте
- Необходимо разделять Логику и Эффекты
Первый акцент упрощает понимание системы и то, как та или иная доработка повлияет на видимые Эффекты, что способствует уменьшению количества ошибок, допускаемых в ходе модификации системы. Второй акцент позволяет покрыть систему надёжным набором тестов, что так же способствует и простоте понимания системы (за счёт документирования системы по средствам тестов) и уменьшению количества ошибок.
В итоге стоимость разработки системы уменьшается, а её качество увеличивается.
Appendix A: Примеры (todo)
- ГУЙ
- Низкоуровневое программирование
- микросервисы
- консольный уй
- рекативность
- Плагины билд систем
- Распределённые кластеры
qbit (todo)
- Факторизация кубита
- Б+Дерево с кэшем нод в памяти и ленивой загрузкой нод с диска
- WebDavStorage
- Типизация: разделить создание графа энтитий и его "отипование"
Q5 (todo)
Удобно (todo)
Проект ТруСтори
Это вымышленный проект с примерами по мотивам проблем, с которыми я столкнулся у различных заказчиков.
Юз кейс: КПИ сотрудников
(todo: добавить пролонгацию, при быстром логине, чтобы когда в рассчёте кпи начал бы учитываться финиш тайм, то оно бы не сломалось)
В этом примере ТруСтори является стандартным бэком на Java/Spring/JPA с веб-фронтом с полнодуплексным соединением (todo: проверить термин).
Одной из фич ТруСтори является подсчёт КПИ сотрудников, среди которых есть длительность текущей смены. Это значение сохраняется при перерыве в работе менее часа.
В реальной системе фича реализована так:
- Доменному классу юзера было добавлено поле со временем начала работы.
- Была переиспользована существующая таблица таймаутов, для того чтобы хранить момент сброса времени начала работы сотрудника.
- При логине, проверяется наличие таймаута сброса,
- если он есть (что подразумевает, что время логаута не превысило час, т.е. продолжается текущая смена), то подсчитывается обновлённый КПИ и отправляется в браузер
- в противном случае, обновляется значение времени начала работы
- При логауте, заводится таймер сброса времени начала работы.
- Отдельный тред в фоне удаляет протухшие таймауты из базы.
В этой функциональности зарылся неожиданный баг. Некоторые новые (ниразу не логинвшиеся) сотрудники не могли подключиться, потому что каким-то образом у них был заведён таймаут на сброс времени начала работы (что происходит только при логауте), но при этом не было времени начала работы (т.е. не было логина). В процессе расследования выяснилось, что одно из вспомогательных приложений, вело себя не совсем корректно и через АПИ звало логаут этим сотрудникам, что заводило им таймаут, но из-за того что они ни разу не логинились, им ни разу не проставлялось время начала работы и логика подсчёта КПИ крэшилась, из-за чего ломался логин (п. 3а).
Теперь давайте реализуем этот юз кейс в эргономичном стиле и увидим, как он помог бы избежать подобной проблемы и какие дополнительные преимущества принёс бы.
Начнём с того, что сформулируем сам юз кейс (todo: разботанить как составлять толковые юз кейсы).
Цель: Я как сотрудник хочу видеть длительность своей рабочей смены.
Рабочая смена: Один или более подряд идущих периодов времени нахождения сотрудника онлайн, с перерывами не более 60 минут.
События:
- Логин сотрудника
- Запрос КПИ
- Штатный логаут сотрудника
- Нештатный логаут сотрудника (закрытие вкладки)
Эффекты:
- Отображение текущих показателей сотрудника в браузере по запросу и при начале нового периода в рамках одной смены.
Технические эффекты: todo: оно надо?
- Пачка всякий загрузок из БД
- Отправление сообщения в браузер
- Сохранение чего-то в БД?
Алгоритм:
- При логине сотрудника
- Если нет существующей смены (первый логин сотрудника в системе), то начать рабочую смену, и зафиксировать время её начала
- Если существующая смена есть и время логаута менее часа назад (возврат сотрудника с обеда), то отправить сотрудника его текущие показатели КПИ.
- Если существующая смена есть, и время логаута более часа назад (начало новой смены), то зафиксировать начало новой смены
- При логауте и закрытии вкладки, зафиксировать время события, в качестве потенциального времени окончания смены
- При запросе КПИ сотрудника, вычислить текущие показатели КПИ и отправить в браузер.
Глядя на этот юз кейс, лично у меня появляется одно желание - завести класс рабочей смены. Давайте так и поступим:
Этот класс является не плохим объектом в классическом ООП - у него есть настоящее состояние и настоящее поведение. К тому же теперь есть место где можно заэнфорсить инвариант, что время начала смены не налл. Но у него есть и ряд проблем:
- Этот объект мутабельный и может быть использован в разных тредах, поэтому его надо синхронизировать.
- У него нет однозначной идентичности - это объект текущей рабочей смены и в разные моменты времени он соотвествует разным объектам реального мира.
- В него зашита логика определённого юз кейса. Если появятся новые требования, связанные с рабочей сменой, например ограничение длительности рабочей смены, то эту логику также придётся добавить в этот объект, что снизит его связность (cohesion).
- Он нарушает принцип трёх зависимостей.
Для решения этих проблем воспользуемся принципами DCI и неизменяемости:
- Оставим WorkShift простым доменным объектом и сделаем его неизменяемым
- Логику вынесем в роль KpiTracker
Удивительно, как DCI всё ставит на свои места. Я долгое время руководствовался эвристикой, что класс с именем заканчивающимся на *er (все возможные Controllers, Managers, Drivers, Updaters и т.д.) указывает на проблемы в дизайне, потому что как правило это были пакеты процедур управляющие структурами данных.
Роль же с именем *er является вполне логичной и является одним из аспектов поведения объекта, который манипулирует состоянием того же объекта.
(todo: чёт с KpiTracker-ом в итоге концептуальное месиво какое-то вышло - он и роль, и юз кейс и контекст, надо выяснить норм ли это)
Рассмотрим, как новая версия решает обозначенные выше проблемы:
- Синхронизация: теперь
WorkShift
иммутабельный, аKpiTracker
создаётся для каждого треда по отдельности - ни тот ни другой класс синхронизации больше не требуют. - Идентичность: рабочая смена стала вэлью объектом и больше не имеет идентичности.
Эта версия кода подсветила новый объект - рабочая смена сотрудника.
У него уже вполне понятная идентичность, которая определяется ключём
(user, startTime)
. Следующим шагом выделим классUserWorkShift
. - Теперь логика юз кейса находится в отдельном классе.
Если потребуется добавить логику ограничения смены, то она так же пойдёт в отдельный класс
TimeShiftLimiter
. Каждый из этих классов будет описывать отдельный юз кейс и будет иметь высокую связность (cohesion). - Принцип трёх зависимостей остался нарушен, но мы это исправим, создав класс
UserWorkShift
.
Кроме того, в новой версии стала Очевидна вероятность возникновения ошибочной ситуации повторного логина без предварительного логаута - в первой версии он была скрыта обработкой первого логина сотрудника в системе.
Теперь давайте выделим UserWorkShift
.
При попытке выделить UserWorkShift
обнаружится проблема: при создании KpiTracker
ещё не понятно, есть ли у сотрудинка активная текущая смена.
Можно попробовать сделать этот параметр нуллабельным, но мы тогда потеряем инфу о сотруднике, и не сможем начать рабочую смену при логине.
Поэтому в конструктор надо передавать сотрудника, для которого будем отслеживать рабочую смену и репозиторий рабочих смен, из-за чего мы снова нарушим правило трёх зависимостей.
Для того чтобы окончательно решить проблему с зависимостями, мы пойдём другим путём - вместо передачи репозитория рабочих смен, воспользуемся техникой шлюза из чистой архитектуры и все нужные зависимости скроем за одним интерфейсом.
Так же этот рефакторинг, по мимо решения проблем с идентичностью и зависимостями, сделал Очевидным то, что в нашей системе есть потенциальная возможность позвать логаут сотруднику, который ни разу не логинился.
Внимательный читатель, наверное заметил, что мы сейчас только загружаем смены из репозитория, но никогда их не сохраняем. Давайте добавим в репозиторий возможность сохранения смен и сделаем эффекты по загрузке и сохранению рабочих расписаний симметричными и Очевидными:
В этой реализации есть две новые проблемы:
- При логине сохранение рабочей смены дублируется 3 раза
- Метод логина начал нарушать правило логики или эффектов - логика определения начала смены не совсем тривиальная и её хочется покрыть тестами, но это невозможно не замокав
kpiGateway
.
Для решения этих проблем вынесем бизнес правило определения начала рабочей смены в чистую функцию предметной области в классе KpiRules
.
Отлично, теперь нам не хватает только лишь Порта, для того чтобы получить канонический эргономичный юз кейс, давайте добавим его:
Порт вышел тривиальным - таким каким и должен быть.
(todo: диаграмма)
Вот чего мы добились применив эргономичный подход:
- Обнаружили и сделали Очевидной ранее скрытую сущность предметной области - рабочая смена сотрудника
- Замкнули на один класс все входы и выходы юз кейса - теперь очевидно куда добавлять новую функциональность (этого юз кейса конечно же, другие юз кейсы пойдут в другие классы), когда она появится, и при каких событиях она должна и будет вызываться и какие эффекты будет иметь
- Описали юз кейс в одном месте и сделали его Очевидным (в оригинальной версии, юз кейс раскидан по четырём разным классам в трёх разных модулях)
- Описали правило начала новой рабочей смены (в оригинальном коде, начало смены определялось по наличию записи в таблице таймаутов, которая записывалась в двух разных классах, а удалялась в третьем)
Оригинальная ошибка в эргономичной версии практически исключена - из-за того что языком реализации является Java, приходится рассчитывать на аннотации и подскзки Идеи, в Kotlin’е эта ошибка была бы исключена на уровне типов.
Единственное что меня не много смущает в итоговой версии - объединение отслеживания рабочих смен и отправку КПИ в одном классе.
Но пока что рабочая смена является нужна только в юз кейсе КПИ, поэтому я думаю эту связность пока можно оставить.
Когда рабочая смена потребуется в другом юз кейсе, её надо будет выделить в отдельный модуль.
Наконец, это объясняет все наши мучения с принципом трёх зависимостей - KpiTracker
действительно делает слишком много.
И он и рабочие расписания отслеживает, и определяет правило продления смены (вообще надо было изначально длительность перерыва перенести в KpiRules
, но оставим так) и КПИ отправляет.
Модель состояний сотрудника (todo)
Отчёты
Тиндер для породистых собак
Требования:
- Необходимо разработать систему для "знакомства" породистых собак.
- Для кобелей может быть запланировано одновременная случка с несколькими сучками.
- Сучки так же могут учавствовать в случках несколько раз, но только в одной случке в один момент времени.
- Перед случкой ветеринары проверяют совместимость собак, и могут назначить сучке другого кабеля в обход алгоритма - на один запрос на случку, может быть по очереди назначено несколько кабелей.
- Если была смена кобеля, то при последующем запросе на случку, данный кобель не должен быть вновь на значен той же сучке.
- Сводить можно только собак одной породы.
- Породы имеют приоритет
- Администратор системы может создавать дополнительные требования к кобелям, которые могут быть сгруппированы в несколько приоретизированных групп.
- Владельцы сучек могут, выбирать группы требований, которым должен соответствовать кобель
Общий алгоритм выбора кобеля сучке следующий:
- Для каждой сучки выполняется поиск кобеля последовательно по группам доп. требований
- Выполняется поиск кобеля соответствующий требованиям группы
- При нахождении свободно кобеля назначается случка
- Если нет ни одного свободного кобеля соответствующего требованиям, рассматривается следующая группа. Если ни в одной из групп нет свободных кобелей, то поиск выполняется повторно начиная с первой группы при появлении свободного кобеля
- Если в группе на одному кобелю могут быть назначены несколько сучек, то учитываются правила
- Сучки распределяются согласну приоритету породы
- Среди сучек одной породы, кобель назначется той, которая запросила случку первой
- В каждой группе требований выполняется алгоритм поиска кобеля:
- Поиск хотя бы одного кобеля в группе требований
- Если нет ни одного свободного кобеля - переход в следующую группу
- Если свободен только один подходящий кобель - назначить случку
- Если свободных кобелей несколько, то выполняется поиск кобеля, с которым была последняя случка
- Если такого кобеля не найдено, то выполняется поиск кобеля с наибольшим количеством мест в очери на случку. При наличии кобелей с равной занятостью, выбирается кобель с наибольшим временем без случки
- Поиск хотя бы одного кобеля в группе требований
- Если была смена кобеля перед случкой, то данный кобель больше не должен назначаться на случку с данной сучкой
Todos (todo)
Appendix B: Дальнейшее чтение
Люди
Анкл Боб
Эрик Майер
Дэвид Вест
Эрик Эванс
Кевлин Хэнни
Рич Хикки
Трюгве Реенскауг
Джеймс Коплейн (James Coplien)
Книги
DDD
Object Thinking
Lean Architecture for Agile Software Development
Clean Code
Clean Architecture
Effective Java
Practical API Design
Object-Oriented Design with Applications
Object-Oriented Analysis
Object-Oriented Software Engineering: Use Case Drive Approach
Working with objects: The OOram Software Engineering Method
Functional Design and Architecture
Design Patterns: Elements of Reusable Object-Oriented Software
Object-Oriented Software Engineering: Use Case Drive Approach
Domain Modeling Made Functional
Patterns, principles and practises of DDD
Научные статьи
The Common Sense of Object Orientated Programming
Публицистические статьи
Segue
Блог посты
Screaming architecture
Ссылка: https://blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html
Доклады
DCI: Practical Tips and Lessons for Nerds
A Glimpse of Trygve: From Class-oriented Programming to Real OO
Clean Coders Hate What Happens to Your Code When You Use These Enterprise Programming Tricks
Giving code a good name
OOP is Dead! Long Live OODD!
Appendix C: Spring
Не использовать компонент скан (todo:)
Заметает бардак в зависимостях под ковёр Проблемы с циклическими зависимости проявляются ток в рантайме