Русский
Русский
English
Статистика
Реклама

Проектирование и рефакторинг

Перевод Хватит организовывать код по типу файлов

06.06.2021 12:22:20 | Автор: admin

Какой самый популярный стиль организации кода вы встречали в корпоративных кодовых базах? Лично я чаще всего видел решение, подразумевающее группировку всех файлов по их типу. Таким образом, к примеру, в системе MVC все контроллеры, все сервисы, все репозитории, все POJO и так далее находятся вместе. Давайте назовем такое решение "стековым" стилем организации кода.

Это ужасный способ организации кода, и далее я объясню почему. Но сначала позвольте мне представить альтернативу.

Гораздо более удачный способ организации - группировка файлов по логической составляющей. Назовем это "смысловым" стилем организации кода. Идея состоит в том, чтобы держать вместе все файлы и классы, связанные с одной концепцией. Ставя смысловую составляющую на первое место, мы оптимизируем организацию кода для человеческого понимания (компиляторам все равно, куда вы помещаете какой файл). Обращая внимание в первую очередь на то, за какую часть проекта отвечает конкретный код, разработчики приходят к более разумному пониманию фактических границ системы.

Теперь давайте разберемся, почему я считаю, что смысловая модель лучше, чем модель стека.

Неправильная абстракция

Люди не читают код по назначению файлов и классов. Никто никогда не говорит: "покажите мне весь API проекта" или "продемонстрируйте мне все запросы, отправляемые решением". Люди читают код, полагаясь на его смысл и роль. Как в системе управления гостиницами, например, люди отдельно думают о номерах, гостях, ценах и так далее.

Поскольку стековый стиль организован по типу кода, трудно понять логическую модель системы. Мы не можем понять основные составляющие проекта и отношения между ними. Для этого приходится копать на один уровень глубже. Для человека, впервые читающего код, это является огромным источником неприятностей.

В нашем примере с управлением гостиницей, организованном смысловым стилем, весь код, связанный с гостями (независимо от технического назначения), помещён в один пакет, весь код, связанный с номерами, - в другой и так далее. Каждый из этих пакетов может иметь свою собственную внутреннюю организацию в стиле "стека" или просто несколько классов на одном уровне. Это позволяет легко найти все, что связано, скажем, с гостями, в одном месте.

Нарушения связи

Многие разработчики считают, что "стековая" организация все же выигрывает, отделяя разные слои кода друг от друга. Чтобы найти необходимые классы или файлы на любом уровне проекта, нужно просто перейти к слою, содержащему эти уровни. Это, безусловно, способствует разделению слоев друг от друга.

Проблема этого аргумента в том, что он фокусируется на разделении, но полностью игнорирует потребность в связи между определёнными структурами. Допуская, что все файлы и классы определённого типа в такой системе расположены вместе, справедливо ли будет сказать, что их взаимная связь, как структур одинакового назначения, но разного смысла, нас устраивает? Положено ли все же, к примеру, контроллерам, находиться отдельно от своих классов и репозиториев? Можем ли мы позволить, скажем, всем репозиториям быть сильно зависимыми друг от друга, но отделенными от уровня сервисов? Очевидный ответ - НЕТ! Такой код стал бы хрестоматийным куском г*вна. Рефакторинг такой системы на более мелкие решения был бы абсолютным кошмаром, потому что вам пришлось бы отделить друг от друга все классы на каждом уровне стека. Это убивает основную цель использования стиля MVC.

Стиль "смысла" способствует установлению связей между различными узлами проекта, связанными исключительно областью применения. Это также облегчает будущий рефакторинг, поскольку логические границы более ясны, чем в стиле "стека". В добавок, как я уже сказал, внутри этих логических границ нам ничего не мешает при необходимости устроить "стековую" организацию и наслаждаться разделением структур разного назначения.

Трудно менять

Чтобы внести какие-либо значимые изменения в кодовую базу, организованную в "стековом" стиле, например добавить новое поле к структуре и модернизировать ее API, разработчик должен прошерстить несколько пакетов и изменить каждый из них. Это создает бессмысленную когнитивную нагрузку, поскольку разработчику приходится модифицировать множество "вещей", а не одну логическую вещь.

В "смысловом" стиле, если вы что-то меняете, вы вносите изменения только в пределах одной логической границы. Это облегчает внесение изменений, поскольку, меняя только один объект, мы работаем только в очень небольшой части кодовой базы.

Ограничивает выбор дизайна

Поскольку код организован из соображений функциональности, он ограничивает возможности людей подходить к дизайну системы. Например, поскольку логика предметной области должна относиться к "сервисам", разработчики противятся использованию правильно разделённых конструкций и запихивают все в сервисы, создавая тем самым кошмарные классы длиной в тысячи строк.

"Смысловой" же способ не ограничен определённым набором функциональных понятий, внутри которых будет организован код. Используя его, разработчик может создавать неограниченное количество смысловых групп файлов и классов, создавая наиболее приемлемую для конкретного проекта дизайн-модель.


Способы мышления, которые скрываются в моделях организации кода это то, о чем, как мне кажется, мы думаем недостаточно. Я хотел бы услышать от вас больше о том, как вы организуете свой код и как, по вашему мнению, это оказывает влияние на поведение разработчиков, формирование ментальных моделей или эффективность. Напишите об этом в комментариях!

Подробнее..

Пишем переиспользуемые компоненты, соблюдая SOLID

01.06.2021 12:20:22 | Автор: admin
Всем привет! Меня зовут Рома, я фронтендер в Я.Учебнике. Сегодня расскажу, как избежать дублирования кода и писать качественные переиспользуемые компоненты. Статья написана по мотивам (но только по мотивам!) доклада с Я.Субботника видео есть в конце поста. Если вам интересно разобраться в этой теме, добро пожаловать под кат.

Статья содержит более детальный разбор принципов и подробные примеры из практики, которые не поместились в доклад. Рекомендую прочитать, если вы хотите глубоко погрузиться в тему и узнать, как мы пишем переиспользуемые компоненты. Если же вы хотите познакомиться с миром переиспользуемых компонентов в общих чертах, то, по моему мнению, вам больше подойдёт запись доклада.



Все знают, что дублирование кода это плохо, потому что об этом часто говорят: в книге по вашему первому языку программирования, на курсах по программированию, в книгах, посвящённых написанию качественного кода, таких как Совершенный код и Чистый код.

Давайте разберёмся, почему так сложно избегать дублирования во фронтенде и как правильно писать переиспользуемые компоненты. И помогут нам принципы SOLID.

Почему сложно перестать дублировать код?


Казалось бы, принцип звучит просто. И в то же время легко проверить, соблюдается ли он: если в кодовой базе нет дублирования, значит, всё хорошо. Почему же на практике получается так сложно?

Разберём пример с библиотекой компонентов Я.Учебника. Когда-то давно проект был монолитом. Позже для удобства разработчики решили вынести переиспользуемые компоненты в отдельную библиотеку. Одним из первых туда попал компонент кнопки. Компонент развивался, со временем появились новые умения и настройки для кнопки, увеличилось количество визуальных кастомизаций. Спустя некоторое время компонент стал настолько навороченным, что использовать его для новых задач и расширять дальше стало неудобно.

И вот, в очередной итерации появилась копия компонента Button2. Это произошло очень давно, точных причин появления уже никто и не помнит. Тем не менее, компонент был создан.



Казалось бы, ничего страшного пусть будет две кнопки. В конце концов, это всего лишь кнопка. Однако на самом деле наличие двух кнопок в проекте имело очень неприятные долговременные последствия.

Каждый раз, когда нужно было обновить стили, было непонятно, в каком компоненте это делать. Приходилось проверять, где какой компонент используется, чтобы случайно не сломать стили в других местах. Когда появлялся новый вариант отображения кнопки, мы решали, какой из компонентов расширять. Каждый раз, когда пилили новую фичу, задумывались, какую из кнопок использовать. А иногда в одном месте нужно было несколько разных кнопок, и тогда в один компонент проекта мы импортировали сразу два компонента кнопки.

Несмотря на то, что в долгосрочной перспективе существование двух компонентов кнопки оказалось болезненным, мы не сразу поняли серьёзность проблемы и успели сделать нечто похожее с иконками. Создали компонент, а когда поняли, что он нам не очень удобен, сделали Icon2, а когда и он оказался неподходящим для новых задач, написали Icon3.

Почти весь набор негативных последствий дублирования кнопки повторился в компонентах иконки. Было немного легче потому, что иконки используются в проекте реже. Хотя, если быть честным, тут всё зависит от фичи. Причём и для кнопки, и для иконки старый компонент не удалялся при создании нового, потому что удаление требовало большого рефакторинга с возможным появлением багов по всему проекту. Что же объединяет случаи с кнопкой и иконкой? Одинаковая схема появления дубликатов в проекте. Нам было сложно переиспользовать текущий компонент, адаптировать его к новым условиям, поэтому мы создавали новый.

Создавая дубликат компонента, мы усложняем себе дальнейшую жизнь. Нам хотелось собирать интерфейс из готовых блоков, как конструктор. Чтобы делать это удобно, нужны качественные компоненты, которые можно просто взять и использовать. Корень проблемы в том, что компонент, который мы планировали переиспользовать, был написан неправильно. Его было сложно расширять и применять в других местах.

Компонент для переиспользования должен быть достаточно универсальным и в то же время простым. Работа с ним не должна вызывать боль и напоминать стрельбу из пушки по воробьям. С другой стороны, компонент должен быть достаточно кастомизируемым, чтобы при небольшом изменении сценария не выяснилось, что проще написать Компонент2.

SOLID на пути к переиспользуемым компонентам


Чтобы написать качественные компоненты, нам пригодится набор правил, скрывающихся за аббревиатурой SOLID. Эти правила объясняют, как объединять функции и структуры данных в классы и как классы должны сочетаться друг с другом.

Почему же именно SOLID, а не любой другой набор принципов? Правила SOLID говорят о том, как правильно выстроить архитектуру приложения. Так, чтобы можно было спокойно развивать проект, добавлять новые функции, изменять существующие и при этом не ломать всё вокруг. Когда я попытался описать, каким, по моему мнению, должен быть хороший компонент, то понял, что мои критерии близки к принципам SOLID.

  • S принцип единственной ответственности.
  • O принцип открытости/закрытости.
  • L принцип подстановки Лисков.
  • I принцип разделения интерфейсов.
  • D принцип инверсии зависимостей.

Какие-то из этих принципов хорошо подходят для описания компонентов. Другие же выглядят более притянутыми за уши в контексте фронтенда. Но все вместе они хорошо описывают моё видение качественного компонента.

Мы пойдём по принципам не по порядку, а от простого к сложному. Вначале рассмотрим базовые вещи, которые могут пригодиться в большом количестве ситуаций, а затем более мощные и специфичные.

В статье приведены примеры кода на React + TypeScript. Я выбрал React как фреймворк, с которым больше всего работаю. На его месте может быть любой другой фреймворк, который вам нравится или подходит. Вместо TS может быть и чистый JS, но TypeScript позволяет явно описывать контракты в коде, что упрощает разработку и использование сложных компонентов.

Базовое


Принцип open/close


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

Рассмотрим применение принципа на примере кнопки. Мы создали компонент-кнопку, и у него есть стили. Пока всё работает хорошо. Но тут приходит новая задача, и выясняется, что в одном конкретном месте для этой кнопки нужно применить другие стили.


Кнопка написана так, что её нельзя изменить без редактирования кода

Чтобы применить другие стили в текущей версии, придётся отредактировать компонент кнопки. Проблема заключается в том, что в компонент не заложена кастомизируемость. Вариант написать глобальные стили рассматривать не будем, так как он ненадёжен. Всё может сломаться при любой правке. Последствия легко представить, если на место кнопки поставить что-то более сложное, например, компонент выбора даты.

Согласно принципу открытости/закрытости мы должны написать код так, чтобы при добавлении нового стиля не пришлось переписывать код кнопки. Всё получится, если часть стилей компонента можно прокинуть снаружи. Для этого заведём проп, в который пробросим нужный класс для описания новых стилей компонента.

// утилита для формирования класса, можно использовать любой аналогimport cx from 'classnames';// добавили новый проп  mixconst Button = ({ children, mix }) => {  return (    <button      className={cx("my-button", mix)}    >      {children}    </button>}

Готово, теперь для кастомизации компонента не нужно править его код.



Этот довольно популярный способ позволяет кастомизировать внешний вид компонента. Его называют миксом, потому что дополнительный класс подмешивается к собственным классам компонента. Отмечу, что проброс класса не единственная возможность стилизовать компонент извне. Можно передавать в компонент объект с CSS-свойствами. Можно использовать CSS-in-JS решения, суть не изменится. Миксы используют многие библиотеки компонентов, например: MaterialUI, Vuetify, PrimeNG и другие.

Какой вывод можно сделать о миксах? Они просты в реализации, универсальны и позволяют гибко настраивать внешний вид компонентов с минимальными усилиями.

Но у такого подхода есть и минусы. Он даёт очень много свободы, из-за чего могут возникнуть проблемы со специфичностью селекторов. А ещё такой подход нарушает инкапсуляцию. Для того, чтобы сформировать правильный css-селектор, нужно знать внутреннее устройство компонента. А значит, такой код может сломаться при рефакторинге компонента.

Изменчивость компонентов


У компонента есть части, являющиеся его ядром. Если их изменить, мы получим другой компонент. Для кнопки это набор состояний и поведение. Пользователи отличают кнопку от, например, чекбокса благодаря эффекту при наведении и нажатии. Есть общая логика работы: когда пользователь кликает, срабатывает обработчик события. Это ядро компонента, то, что делает кнопку кнопкой. Да, бывают исключения, но в большинстве сценариев использования всё работает именно так.

А ещё в компоненте есть части, которые могут меняться в зависимости от места применения. Стили как раз относятся к этой группе. Может, нам понадобится кнопка другого размера или цвета. С другой обводкой и скруглением или с другим ховер эффектом. Все стили изменяемая часть компонента. Мы не хотим переписывать или создавать новый компонент каждый раз, когда кнопка выглядит по-другому.

То, что часто меняется, должно настраиваться без изменения кода. Иначе мы окажемся в ситуации, когда проще создать новый компонент, чем настраивать и дописывать старый, который оказался недостаточно гибким.

Темизация


Вернёмся к кастомизации визуала компонента на примере кнопки. Следующий способ применение тем. Под темизацией я подразумеваю способность компонента отображаться в нескольких режимах, по-разному в разных местах. Эта интерпретация шире, чем темизация в контексте светлой и тёмной темы.

Использование тем не исключает предыдущий способ с миксами, а дополняет его. Мы явно говорим, что компонент имеет несколько способов отображения и при использовании требует указать желаемый.

import cx from 'classnames';import b from 'b_';const Button = ({ children, mix, theme }) => (  <button    className={cx(     b("my-button", { theme }), mix)}  >    {children}  </button>)

Темизация позволяет избежать зоопарка стилей, когда, например, у вас в проекте 20 кнопок и все выглядят немного по-разному из-за того, что стили каждой кнопки задаются в месте применения. Применять подход можно для всех новых компонентов, не опасаясь оверинжиниринга. Если вы понимаете, что компонент может выглядеть по-разному, лучше с самого начала явно завести темы. Это упростит дальнейшую разработку компонента.

Но есть и минус способ подходит только для кастомизации визуала и не позволяет влиять на поведение компонента.

Вложенность компонентов


Я перечислил не все способы избежать изменения кода компонента при добавлении новых функций. Другие будут продемонстрированы при разборе остальных принципов. Здесь же мне хотелось бы упомянуть про дочерние компоненты и слоты.

Веб-страница представляет собой древовидную иерархию компонентов. Каждый компонент сам решает, что и как отрисовать. Но это не всегда так. Например, кнопка позволяет указать, какой контент будет отрисован внутри. В React основной инструмент проп children и render-пропы. Во Vue есть более мощная концепция слотов. При написании простых компонентов с использованием этих возможностей не возникает никаких проблем. Но важно не забывать, что даже в сложных компонентах можно использовать пробрасывание части элементов, которые должен отобразить компонент сверху.

Продвинутое


Описанные ниже принципы подойдут для более крупных проектов. Соответствующие им приёмы дают большую гибкость, но увеличивают сложность проектирования и разработки.

Single Responsibility Principle


Принцип единственной ответственности означает, что модуль должен иметь одну и только одну причину для изменения.

Почему это важно? Последствия нарушения принципа включают:
  • Риск при редактировании одной части системы сломать другую.
  • Плохие абстракции. Получаются компоненты, которые умеют выполнять несколько функций, из-за чего сложно понять, что именно должен делать компонент, а что нет.
  • Неудобная работа с компонентами. Очень сложно делать доработки или исправлять баги в компоненте, который делает всё сразу.

Вернёмся к примеру с темизацией и посмотрим, соблюдается ли там принцип единственной ответственности. Уже в текущем виде темизация справляется со своими задачами, но это не значит, что у решения нет проблем и его нельзя сделать лучше.


Один модуль редактируется разными людьми по разным причинам

Допустим, мы положили все стили в один css-файл. Он может редактироваться разными людьми по разным причинам. Получается, что принцип единственной ответственности нарушен. Кто-то может отрефакторить стили, а другой разработчик внесёт правки для новой фичи. Так можно легко что-то сломать.

Давайте подумаем, как может выглядеть темизация с соблюдением SRP. Идеальная картина: у нас есть кнопка и отдельно набор тем для неё. Мы можем применить тему к кнопке и получить темизированную кнопку. Бонусом хотелось бы иметь возможность собрать кнопку с несколькими доступными темами, например, для помещения в библиотеку компонентов.


Желаемая картина. Тема отдельная сущность и может примениться к кнопке

Тема оборачивает кнопку. Такой подход используется в Лего, нашей внутренней библиотеке компонентов. Мы используем HOC (High Order Components), чтобы обернуть базовый компонент и добавить ему новые возможности. Например, возможность отображаться с темой.

HOC функция, которая принимает компонент и возвращает другой компонент. HOC с темой может прокидывать объект со стилями внутрь кнопки. Ниже представлен скорее учебный вариант, в реальной жизни можно использовать более элегантные решения, например, прокидывать в компонент класс, стили которого импортируются в HOC, или использовать CSS-in-JS решения.

Пример простого HOC для темизации кнопки:

const withTheme1 = (Button) =>(props) => {    return (        <Button            {...props}            styles={theme1Styles}        />    )}const Theme1Button = withTheme1(Button);

HOC может применять стили, только если указана определённая тема, а в остальных случаях не делает ничего. Так мы можем собрать кнопку с комплектом тем и активировать нужную, указав проп theme.

Использование нескольких HOCов для сбора кнопки с нужными темами:

import "./styles.css"; // Базовый компонент кнопки. Принимает содержимое кнопки и стилиconst ButtonBase = ({ style, children }) => { console.log("styl123e", style); return <button style={style}>{children}</button>;}; const withTheme1 = (Button) => (props) => { // HOC применяет стили, только если выбрана тема "theme1" if (props.theme === "theme1") {   return <Button {...props} style={{ color: "red" }} />; }  return <Button {...props} />;}; const withTheme2 = (Button) => (props) => { // HOC применяет стили, только если выбрана тема "theme2" if (props.theme === "theme2") {   return <Button {...props} style={{ color: "green" }} />; }  return <Button {...props} />;}; // ф-я для оборачивания компонента в несколько HOCconst compose = (...hocs) => (BaseComponent) => hocs.reduce((Component, nextHOC) => nextHOC(Component), BaseComponent); // собираем кнопку, передав нужный набор темconst Button = compose(withTheme1, withTheme2)(ButtonBase); export default function App() { return (   <div className="App">     <Button theme="theme1">"Red"</Button>     <Button theme="theme2">"Green"</Button>   </div> );}

И тут мы приходим к выводу, что нужно разделить области ответственности. Даже если кажется, что у вас один компонент, подумайте так ли это на самом деле? Возможно, его стоит разделить на несколько слоёв, каждый из которых будет отвечать за конкретную функцию. Почти во всех случаях визуальный слой можно отделить от логики компонента.

Выделение темы в отдельную сущность даёт плюсы к удобству использования компонента: можно поместить кнопку в библиотеку с базовым набором тем и разрешить пользователям писать свои при необходимости; темы можно удобно шарить между проектами. Это позволяет сохранить консистентность интерфейса и не перегружать исходную библиотеку.

Существуют разные варианты реализации разделения на слои. Выше был пример с HOC, но композиция также возможна. Однако я считаю, что в случае с темизацией HOC более уместны, так как тема не является самостоятельным компонентом.

Выносить в отдельный слой можно не только визуал. Но я не планирую подробно рассматривать вынесение бизнес-логики в HOC, потому что вопрос весьма холиварный. Моё мнение вы можете так поступить, если понимаете, что делаете и зачем вам это нужно.

Композитные компоненты


Перейдём к более сложным компонентам. Возьмём в качестве примера Select и разберёмся, в чём польза принципа единственной ответственности. Select можно представить как композицию более мелких компонентов.



  • Container связь между остальными компонентами.
  • Field текст для обычного селекта и инпут для компонента CobmoBox, где пользователь что-то вводит.
  • Icon традиционный для селекта значок в поле.
  • Menu компонент, который отображает список элементов для выбора.
  • Item отдельный элемент в меню.

Для соблюдения принципа единственной ответственности нужно вынести все сущности в отдельные компоненты, оставив каждому только одну причину для редактирования. Когда мы распилим файл, возникнет вопрос: как теперь кастомизировать получившийся набор компонентов? Например, если нужно задать тёмную тему для поля, увеличить иконку и изменить цвет меню. Есть два способа решить эту задачу.

Overrides


Первый способ прямолинейный. Все настройки вложенных компонентов выносим в пропы исходного. Правда, если применить решение в лоб, окажется, что у селекта огромное количество пропов, в которых сложно разобраться. Нужно как-то удобно их организовать. И тут нам поможет override. Это конфиг, который пробрасывается в компонент и позволяет настроить каждый его элемент.

<Select  ...  overrides={{    Field: {      props: {theme: 'dark'}    },    Icon: {      props: {size: 'big'},    },    Menu: {      style: {backgroundColor: '#CCCCCC'}    },  }}/>

Я привёл простой пример, где мы переопределяем пропы. Но override можно рассматривать как глобальный конфиг он настраивает всё, что поддерживает компоненты. Увидеть, как это работает на практике, можно в библиотеке BaseWeb.

Итого, с помощью override можно гибко настраивать композитные компоненты, а ещё такой подход отлично масштабируется. Из минусов: конфиги для сложных компонентов получаются очень большими, а мощь override имеет и обратную сторону. Мы получаем полный контроль над внутренними компонентами, что позволяет делать странные вещи и выставлять невалидные настройки. Также, если вы не используете библиотеки, а хотите реализовать подход самостоятельно, придётся научить компоненты понимать конфиг или написать обёртки, которые будут читать его и настраивать компоненты правильно.

Dependency Inversion Principle


Чтобы разобраться в альтернативе override-конфигам, обратимся к букве D в SOLID. Это принцип инверсии зависимостей. Он утверждает, что код, реализующий верхнеуровневую политику, не должен зависеть от кода, реализующего низкоуровневые детали.

Вернёмся к нашему селекту. Container отвечает за взаимодействие между другими частями компонента. Фактически он представляет собой корень, управляющий рендером остальных блоков. Для этого он должен их импортировать.

Так будет выглядеть корень любого сложного компонента, если не использовать инверсию зависимостей:

import InputField from './InputField';import Icon from './Icon';import Menu from './Menu';import Option from './Option';

Разберём зависимости между компонентами, чтобы понять, что может пойти не так. Сейчас более высокоуровневый Select зависит от низкоуровневого Menu, потому что импортит его в себя. Принцип инверсии зависимостей нарушен. Это создаёт проблемы.
  • Во-первых, при изменении Menu придётся править Select.
  • Во-вторых, если мы захотим использовать другой компонент меню, нам тоже придётся вносить правки в компонент селекта.


Непонятно, что делать, когда понадобится Select с другим меню

Нужно развернуть зависимость. Сделать так, чтобы компонент меню зависел от селекта. Инверсия зависимостей делается через инъекцию зависимостей Select должен принимать компонент меню как один из параметров, пропов. Здесь нам поможет типизация. Мы укажем, какой компонент ожидает Select.

// теперь вместо прямого импорта Select принимает одним из параметров компонент менюconst Select = ({  Menu: React.ComponentType<IMenu>}) => {  return (    ...    <Menu>      ...    </Menu>    ...  )...}

Так мы декларируем, что селекту нужен компонент меню, пропы которого удовлетворяют определённому интерфейсу. Тогда стрелки будут направлены в обратную сторону, как и предписывает принцип DI.


Стрелка развёрнута, так работает инверсия зависимостей

Мы решили проблему с зависимостями, но немного синтаксического сахара и вспомогательные инструменты здесь не помешают.

Каждый раз прокидывать все зависимости в компонент в месте рендера утомительно, но в библиотеке bem-react есть реестр зависимостей и процесс композиции. С их помощью можно упаковать зависимости и настройки один раз, а дальше просто использовать готовый компонент.

import { compose } from '@bem-react/core'import { withRegistry, Registry } from '@bem-react/di'const selectRegistry = new Registry({ id: cnSelect() })...selectRegistry.fill({    'Trigger': Button,    'Popup': Popup,    'Menu': Menu,    'Icon': Icon,})const Select = compose(    ...    withRegistry(selectRegistry),)(SelectDesktop)

В примере выше показана часть сборки компонента на примере bem-react. Полный код примера и песочницу можно посмотреть в сторибуке yandex UI.

Что мы получаем от использования Dependency Inversion?

  • Полный контроль свободу в настройке всех составляющих компонента.
  • Гибкую инкапсуляцию возможность сделать компоненты очень гибкими и полностью кастомизируемыми. При необходимости разработчик переопределит все блоки, из которых состоит компонент, и получит то, что хочет. При этом всегда есть вариант создать уже настроенные, готовые компоненты.
  • Масштабируемость способ хорошо подходит для библиотек любых размеров.

Мы в Яндекс.Учебнике пишем собственные компоненты, используя DI. Внутренняя библиотека компонентов Лего тоже использует этот подход. Но один существенный минус у него есть гораздо более сложная разработка.

Сложности разработки переиспользуемых компонентов


В чём же сложность разработки переиспользуемых компонентов?

Во-первых, долгое и тщательное проектирование. Нужно понять, из каких частей состоят компоненты и какие части могут меняться. Если сделать все части изменяемыми, мы получим огромное количество абстракций, в которых сложно разобраться. Если же изменяемых частей будет слишком мало, компонент получится недостаточно гибким. Его нужно будет дорабатывать во избежание будущих проблем с переиспользованием.

Во-вторых, высокие требования к компонентам. Вы поняли, из каких частей будут состоять компоненты. Теперь нужно написать их так, чтобы они ничего не знали друг о друге, но могли использоваться вместе. Это сложнее, чем разработка без оглядки на переиспользуемость.

В-третьих, сложная структура как следствие предыдущих пунктов. Если требуется серьёзная кастомизация, придётся пересобрать все зависимости компонента. Для этого нужно глубоко понимать, из каких частей он состоит. Важную роль в процессе играет наличие хорошей документации.

В Учебнике есть внутренняя библиотека компонентов, где находятся образовательные механики часть интерфейса, с которой взаимодействуют дети во время решения заданий. И ещё есть общая библиотека образовательных сервисов. Туда мы выносим компоненты, которые хотим переиспользовать между разными сервисами.

Перенос одной механики занимает несколько недель при условии, что у нас уже есть работающий компонент и мы не добавляем новый функционал. Большая часть этой работы распилить компонент на независимые куски и сделать возможным их совместное использование.

Liskov Substitution Principle


Предыдущие принципы были о том, что нужно делать, а последние два будут о том, что нужно не сломать.

Начнём с принципа подстановки Барбары Лисков. Он говорит, что объекты в программе должны быть заменяемыми на экземпляры их подтипов без нарушения правильности выполнения программы.

Мы обычно не пишем компоненты как классы и не используем наследование. Все компоненты взаимозаменяемы из коробки. Это основа современного фронтенда. Не совершать ошибок и поддерживать совместимость помогает строгая типизация.

Как же заменяемость из коробки может сломаться? У компонента есть API. Под API я понимаю совокупность пропов компонента и встроенных во фреймворк механизмов, таких, как механизм обработчика событий. Строгая типизация и линтинг в IDE способны подсветить несовместимость в API, но компонент может взаимодействовать с внешним миром и в обход API:

  • читать и писать что-то в глобальный стор,
  • взаимодействовать с window,
  • взаимодействовать с cookie,
  • читать/писать local storage,
  • делать запросы в сеть.



Всё это небезопасно, потому что компонент зависит от окружения и может сломаться, если перенести его в другое место или в другой проект.

Чтобы соблюдать принцип подстановки Лисков нужно:

  • использовать возможности типизации,
  • избегать взаимодействия в обход API компонента,
  • избегать побочных эффектов.

Как избежать взаимодействия не через API? Вынести всё, от чего зависит компонент, в API и написать обёртку, которая будет пробрасывать данные из внешнего мира в пропы. Например, так:
const Component = () => {   /*      К сожалению, использование хуков приводит к тому, что компонент много знает о своем окружении.      Например, тут он знает о наличии стора и его внутренней структуре.      При переносе в другой проект, где стор отсутствует, код может сломаться.   */   const {userName} = useStore();    // Тут компонент знает о куках, что не очень хорошо для переиспользования (может сломаться, если в другом проекте это не так).   const userToken = getFromCookie();    // Аналогично  доступ к window может стать проблемой при переиспользовании компонента.   const {taskList} = window.ssrData;    const handleTaskUpdate = () => {       // Компонент знает об API сервера. Это допустимо только для верхнеуровневых компонентов.       fetch(...)   }    return <div>{'...'}</div>;  }; /*   Здесь компонент принимает только необходимый ему набор данных.   Его можно легко переиспользовать, потому что только верхнеуровневые компоненты будут знать все детали.*/const Component2 = ({   userName, userToken, onTaskUpdate}) => {   return <div>{'...'}</div>;};

Interface Segregation Principle


Много интерфейсов специального назначения лучше, чем один интерфейс общего назначения. Мне не удалось так же однозначно перенести принцип на компоненты фронтенда. Поэтому я понимаю его как необходимость следить за API.

Нужно передавать в компонент как можно меньшее количество сущностей и не передавать данные, которые им не используются. Большое количество пропов в компоненте повод насторожиться. Скорее всего, он нарушает принципы SOLID.

Где и как переиспользуем?


Мы обсудили принципы, которые помогут в написании качественных компонентов. Теперь разберём, где и как мы их переиспользуем. Это поможет понять, с какими ещё проблемами можно столкнуться.

Контекст может быть разным: вам нужно использовать компонент в другом месте той же страницы или, например, вы хотите переиспользовать его в других проектах компании это совсем разные вещи. Я выделяю несколько вариантов:

Переиспользование пока не требуется. Вы написали компонент, считаете, что он специфичный и не планируете нигде больше его использовать. Можно не предпринимать дополнительных усилий. А можно сделать несколько простых действий, которые окажутся полезны, если вы всё-таки захотите к нему вернуться. Так, например, можно проверить, что компонент не слишком сильно завязан на окружение, а зависимости вынесены в обёртки. Также можно сделать запас для кастомизации на будущее: добавить темы или возможность изменять внешний вид компонента извне (как в примере с кнопкой) это не займёт много времени.

Переиспользование в том же проекте. Вы написали компонент и почти уверены, что захотите его переиспользовать в другом месте текущего проекта. Здесь актуально всё написанное выше. Только теперь обязательно нужно убрать все зависимости во внешние обёртки и крайне желательно иметь возможность кастомизации извне (темы или миксы). Если компонент содержит много логики, стоит задуматься, везде ли она нужна, или в каких-то местах её стоит модифицировать. Для второго варианта предусмотрите возможность кастомизации. Также здесь важно подумать над структурой компонента и разбить его на части при необходимости.

Переиспользование в похожем стеке. Вы понимаете, что компонент будет полезен в соседнем проекте, у которого тот же стек, что и у вас. Здесь всё сказанное выше становится обязательным. Кроме этого, советую внимательно следить за зависимостями и технологиями. Точно ли соседний проект использует те же версии библиотек, что и вы? Использует ли SASS и TypeScript той же версии?

Отдельно хочу выделить переиспользование в другой среде исполнения, например, в SSR. Решите, действительно ли ваш компонент может и должен уметь рендериться на SSR. Если да, заранее удостоверьтесь, что он рендерится, как ожидается. Помните, что существуют другие рантаймы, например, deno или GraalVM. Учитывайте их особенности, если используете.

Библиотеки компонентов


Если компоненты нужно переиспользовать между несколькими репозиториями и/или проектами, их следует вынести в библиотеку.

Стек


Чем больше технологий используется в проектах, тем сложнее будет решать проблемы совместимости. Лучше всего сократить зоопарк и свести к минимуму количество используемых технологий: фреймворков, языков, версий крупных библиотек. Если же вы понимаете, что вам действительно нужно много технологий, придётся научиться с этим жить. Например, можно использовать обёртки над веб-компонентами, собирать всё в чистый JS или использовать адаптеры для компонентов.

Размер


Если использование простого компонента из вашей библиотеки добавляет пару мегабайт к бандлу это не ок. Такие компоненты не хочется переиспользовать, потому что перспектива написать свою лёгкую версию с нуля кажется оправданной. Решить проблему можно с помощью инструментов контроля размера, например, size-limit.

Не забываем про модульность разработчик, который захочет использовать ваш компонент, должен иметь возможность взять только его, а не тащить весь код библиотеки в проект.

Важно, чтобы модульная библиотека не собиралась в один файл. Также нужно следить за версией JS, в которую собирается библиотека. Если вы собираете библиотеку в ES.NEXT, а проекты в ES5, возникнут проблемы. Ещё нужно правильно настроить сборку для старых версий браузеров и сделать так, чтобы все пользователи библиотеки знали, во что она собирается. Если это слишком сложно, есть альтернатива настроить собственные правила сборки библиотеки в каждом проекте.

Обновление


Заранее подумайте о том, как будете обновлять библиотеку. Хорошо, если вы знаете всех клиентов и их пользовательские сценарии. Это поможет лучше думать о миграциях и ломающих изменениях. Например, команде, использующей вашу библиотеку, будет крайне неприятно узнать о мажорном обновлении с ломающими изменениями накануне релиза.

Вынося компоненты в библиотеку, которую использует кто-то ещё, вы теряете лёгкость рефакторинга. Чтобы груз рефакторинга не стал неподъёмным, советую не тащить в библиотеки новые компоненты. Они с высокой вероятностью будут меняться, а значит, придётся тратить много времени на обновление и поддержку совместимости.

Кастомизация и дизайн


Дизайн не влияет на переиспользуемость, но является важной частью кастомизаци. У нас в Учебнике компоненты не живут сами по себе, их внешний вид проектируют дизайнеры. У дизайнеров есть дизайн-система. Если компонент в системе и репозитории выглядит по-разному проблем не избежать. У дизайнеров и разработчиков не совпадут представления о внешнем виде интерфейса, из-за чего могут быть приняты неверные решения.

Витрина компонентов


Упростить взаимодействие с дизайнерами поможет витрина компонентов. Одно из популярных решений для витрины Storybook. С помощью этого или другого подходящего инструмента можно показать компоненты проекта любому человеку не из разработки.

Добавьте в витрину интерактивность у дизайнеров должна быть возможность взаимодействовать с компонентами и видеть, как они отображаются и работают с разными параметрами.

Не забудьте настроить автоматическое обновление витрины при обновлении компонентов. Для этого нужно вынести процесс в CI. Теперь дизайнеры всегда смогут посмотреть, какие готовые компоненты есть в проекте, и воспользоваться ими.



Дизайн-система


Для разработчика дизайн-система набор правил, регулирующих внешний вид компонентов в проекте. Чтобы зоопарк компонентов не разрастался, можно ограничить кастомизируемость её рамками.

Другой важный момент дизайн-система и вид компонентов в проекте иногда расходятся между собой. Например, когда проходит большой редизайн и не всё получается обновить в коде, или приходится корректировать компонент, а внести изменения в дизайн-систему времени нет. В этих случаях как в ваших интересах, так и в интересах дизайнеров синхронизировать дизайн-систему и проект, как только появится такая возможность.

Последний универсальный и очевидный совет: общайтесь и договаривайтесь. Не нужно воспринимать дизайнеров как тех, кто стоит в стороне от разработки и только создаёт и правит макеты. Тесное взаимодействие с ними поможет спроектировать и реализовать качественный интерфейс. В конечном счёте это пойдёт на пользу общему делу и порадует пользователей продукта.

Выводы


Дублирование кода приводит к сложностям в разработке и снижению качества фронтенда. Чтобы избежать последствий, необходимо следить за качеством компонентов, а писать качественные компоненты помогают принципы SOLID.

Написать хороший компонент с заделом на будущее гораздо сложнее, чем тот, что быстро решит задачу здесь и сейчас. При этом хорошие кирпичи лишь часть решения. Если вы выносите компоненты в библиотеку, работу с ними нужно сделать удобной, а ещё их нужно синхронизировать с дизайн-системой.

Как видите, задача непростая. Разрабатывать качественные переиспользуемые компоненты сложно и долго. Стоит ли оно того? Я считаю, что каждый ответит на этот вопрос сам. Для небольших проектов накладные расходы могут оказаться слишком высокими. Для проектов, где не планируется длительное развитие, вкладывать усилия в повторное использование кода тоже спорное решение. Однако, сказав сейчас нам это не нужно, легко не заметить, как окажешься в ситуации, где отсутствие переиспользуемых компонентов принесёт массу проблем, которых могло бы и не быть. Поэтому не повторяйте наших ошибок и dont repeat yourself!

Смотреть доклад
Подробнее..

Чем меня не устраивает гексагональная архитектура. Моя имплементация DDD многоуровневая блочная архитектура

15.05.2021 18:08:01 | Автор: admin


* В данной статье примеры будут на TypeScript


Краткое предисловие


Что такое DDD (Domain Driven Design) вопрос обширный, но если в кратце (как Я это понимаю) это про перенос бизнес логики, как она есть, в код, без углубления в технические детали. То есть в идеале, человек, который знает за бизнес процессы, может открыть код и понять, что там происходит (так кстати часто бывает в 1С).


Всё это сопровождается кучей разных рекомендаций по технической реализации вопроса.


Для лучшего понимания статьи советую прочитать материалы, касающиеся DDD.


Гексагональная архитектура это один из подходов реализации DDD.


Многие продвинутые разработчики знакомы с понятием гексагональной архитектуры.


Конкретнее описывать не буду, на хабре полно статей на эту тему, всё давно разжевано и практически переварено.


Вместо этого Я покажу картинку (рис.1):


рис.1


Скажите пожалуйста, что Вам понятно из этой картинки?


Например мне, когда Я первый раз увидел её, было непонятно абсолютно всё.


И, как бы ни было смешно, это первая проблема для меня.


Визуализация должна давать понимание, а не добавлять вопросов.


В ходе изучения всё частично становится на свои места, но вопросы и проблемы остаются.


И тут Я задумался о приложении в общем, развивая идеи вынесенные из DDD в целом и гексагональной архитектуры в частности.


Что мы имеем:


  • Реальная жизнь. Здесь есть бизнес процессы, которые мы должны автоматизировать.
  • Приложение, которое решает проблемы из реальной жизни, которое в свою очередь, не находится в вакууме. У приложения есть:
    1. Пользователи, будь то АПИ, кроны, пользовательские интерфейсы и т.д.
    2. Сам код приложения.
    3. Объекты данных БД, другие АПИ.

Движение идёт сначала сверху вниз, потом обратно, то есть:


  • Субъекты из реальной жизни взаимодействуют с приложением, код приложения взаимодействует с объектами данных, затем получив от них ответ, возвращает его пользователям.

Всё логично.


Теперь углубимся в код приложения.


Как сделать так, чтобы код был понятным, тестируемым, но при этом максимально независимым от внешних объектов данных, таких как БД, АПИ и т.д.?


В ответ на этот вопрос родилась следующая схема (рис.2):


рис.2


То что мы здесь видим, очень похоже на гексагональную архитектуру, но в отличии от неё, логика не замкнута в гексагон или круг, как в луковой архитектуре, а просто разнесена по уровням, сохраняя логичную цепочку взаимодействий описанных выше запрос приходит сверху, спускается вниз, затем поднимается обратно, возвращая результат.


Ещё одна разница состоит в том, что добавлен уровень бизнес процессов, о котором поговорим ниже.


Многоуровневая блочная архитектура


Пробежимся по схеме.


На рисунке (рис.2), слева, мы видим названия сущностей, справа назначение уровней и их зависимости друг от друга.


Сверху вниз:


  1. Порты уровень взаимодействия, который зависит от уровня бизнес процессов. Уровень отвечает за взаимодействие с приложением, то есть хранит контроллеры. Пользоваться приложением можно только через порты.
  2. Ядро приложения уровень бизнес процессов, является центром всех зависимостей. Всё приложение строится исходя из бизнес процессов.
  3. Домены уровень бизнес логики, который зависит от уровня бизнес процессов. Домены образуются и выстраиваются на основании тех бизнес процессов, которые мы хотим автоматизировать. Домены отвечают за конкретную бизнес логику.
  4. Адаптеры уровень агрегации данных, который зависит от уровня бизнес логики. Сверху получает интерфейсы данных, которые должен реализовать. Отвечает за получение и нормализацию данных из объектов данных.
  5. Объекты данных уровень хранения данных, который не входит в приложение, но т.к. приложение не существует в вакууме, мы должны учитывать их.

Несколько правил


По ходу практики родилось и несколько правил, которые позволяют сохранять чистоту, простоту и универсальность кода:


  1. Бизнес процессы должны возвращать однозначный ответ.
    Например создание клиента, при наличии партнерской программы. Можно сделать бизнес процесс, который создает клиента, а если у него есть партнерский код добавляет его ещё и в партнеры, но это не правильно. Из за подобного подхода ваши бизнес процессы становятся непрозрачными и излишне сложными. Вы должны создать 2 бизнес процесса создание клиента и создание партнера.
  2. Домены не должны общаться на прямую между собой. Всё общение между доменами происходит в бизнес процессах. Иначе домены становятся взаимозависимыми.
  3. Все доменные контроллеры не должны содержать бизнес логики, они лишь вызывают доменные методы.
  4. Доменные методы должны быть реализованы как чистые функции, у них не должно быть внешних зависимостей.
  5. У методов все входящие данные уже должны быть провалидированы, все необходимые параметры должны быть обязательными (тут помогут data-transfer-object-ы или просто DTO-шки).
  6. Для unit тестирования уровня нужен нижестоящий уровень. Инъекция (DI) производится только в нижестоящий уровень, например тестируете домены подменяете адаптеры.

Как происходит разработка, согласно этой схеме


  1. Выделяются бизнес процессы, которые мы хотим автоматизировать, описываем уровень бизнес процессов.
  2. Бизнес процессы разбиваются на цепочки действий, которые связаны с конкретными областями (домены).
  3. Решаем как мы храним данные и с какими внешними сервисами взаимодействуем подбираем адаптеры и источники данных, которые наши адаптеры поддерживают. Например в случае с БД мы решаем хранить наши данные в реляционной базе данных, ищем ORM, которая умеет с ними работать и при этом отвечает нашим требованиям, затем под неё выбираем БД, с которой наша ORM умеет работать. В случае с внешними API, часто придется писать свои адаптеры, но опять таки с оглядкой на домены, потому что у адаптера есть 2 главные задачи: получить данные и отдать их наверх в необходимом домену, адаптированном виде.
  4. Решаем как мы взаимодействуем с приложением, то есть продумываем порты.

Небольшой пример


Мы хотим сделать небольшую CRM, хранить данные хотим в реляционной БД, в качестве ORM используем TypeORM, в качестве БД PostgresSQL.


Будет показан не весь код сервера, а лишь основные моменты, которые Вы сможете применить в своём приложении уже сейчас

Для начала реализуем бизнес процесс создания клиента.


Подготовим структуру папок:


рис.3


Для удобства добавим алиасы:


@clients = src/domains/clients@clientsEnities = src/adapters/typeorm/entities/clients@adapters = src/adapters

Из чего состоит бизнес процесс в самом простом виде:


  • на вход мы получаем данные о клиенте
  • нам нужно сохранить его в БД

После общения с доменным экспертом узнаем, что помимо общих данных клиента, у него могут быть различные контактные данные.


Формируем доменные модели, которые должны реализовать наши адаптеры. В нашем случае это 2 модели: клиент и контактные данные


domains/clients/models/Client.ts

import { Contact } from './Contact';export interface Client {  id: number;  title: string;  contacts?: Contact[];}

domains/clients/models/Contact.ts

import { Client } from './Client';export enum ContactType {  PHONE = 'phone',  EMAIL = 'email',}export interface Contact {  client?: Client;  type: ContactType;  value: string;}

Под них формируем TypeORM enitity


adapters/typeorm/entities/clients/Client.ts

import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';import { Client as ClientModel } from '@clients/models/Client';import { Contact } from './Contact';@Entity({ name: 'clients' })export class Client implements ClientModel {  @PrimaryGeneratedColumn()  id: number;  @Column()  title: string;  @OneToMany((_type) => Contact, (contact) => contact.client)  contacts?: Contact[];}

adapters/typeorm/entities/clients/Contact.ts

import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';import { Contact as ContactModel, ContactType } from '@clients/models/Contact';import { Client } from './Client';@Entity({ name: 'contacts' })export class Contact implements ContactModel {  @PrimaryGeneratedColumn()  id: number;  @Column({ type: 'string' })  type: ContactType;  @Column()  value: string;  @ManyToOne((_type) => Client, (client) => client.contacts, { nullable: false })  client?: Client;}

Сразу объясню почему поля со связями у меня помечены как опциональные: т.к. данные лежат в разных таблицах, их всегда надо дозапрашивать. Можно конечно их сделать обязательными, но если вы где-то забудете дозапросить данные получите ошибку.


Реализуем доменный метод создания клиента и доменный контроллер.


domains/clients/methods/createClient.ts

import { Repository } from 'typeorm';import { Client as ClientModel } from '@clients/models/Client';import { Client } from '@clientsEnities/Client';export async function  createClient(repo: Repository<Client>, clientData: ClientModel) {  const client = await repo.save(clientData);  return client;}

domains/clients/index.ts

import { Connection } from 'typeorm';import { Client } from '@clientsEnities/Client';import { Client as ClientModel } from '@clients/models/Client';import { createClient } from './methods/createClient';export class Clients {  protected _connection: Connection;  constructor(connection: Connection) {    if (!connection) {      throw new Error('No connection!');    }    this._connection = connection;  }  protected getRepository<T>(Entity: any) {    return this._connection.getRepository<T>(Entity);  }  protected getTreeRepository<T>(Entity: any) {    return this._connection.getTreeRepository<T>(Entity);  }  public async createClient(clientData: ClientModel) {    const repo = this.getRepository<Client>(Client);    const client = await createClient(repo, clientData);    return client;  }}

Т.к. TypeORM немного специфичная библиотека, внутрь мы прокидываем (для DI) не конкретные репозитории, а connection, который будем подменять при тестах.


Осталось создать бизнес процесс.


businessProcesses/createClient.ts

import { Client as ClientModel } from '@clients/models/Client';import { Clients } from '@clients';import { db } from '@adapters/typeorm'; // Я складываю TypeORM соединения в объект dbexport function createClient(clientData: ClientModel) {  const clients = new ClientService(db.connection)  const client = await clients.createClient(clientData)  return  client}

В примере не буду показывать как реализовать порты, которые по сути являются простыми контроллерами, которые вызывают те или иные бизнес процессы. Тут уж Вы сами как нибудь.


Что нам даёт данная архитектура?


  1. Понятную и удобную структуру папок и файлов.
  2. Удобное тестирование. Т.к. всё приложение разбито на слои выберете нужный слой, подменяете нижестоящий слой и тестируете.
  3. Удобное логирование. В примере видно, что логирование можно встроить на каждый этап работы приложения от банального замера скорости выполнения конкретного доменного метода (просто обернуть функцию метода функцией оберткой, которая всё замерит), до полного логирования всего бизнес процесса, включая промежуточные результаты.
  4. Удобную валидацию данных. Каждый уровень может проверять критичные для себя данные. Например тот же бизнес процесс создания клиента по хорошему в начале должен создать DTO для модели клиента, который провалидирует входящие данные, затем он должен вызвать доменный метод, который проверит, существует ли уже такой клиент и только потом создаст клиента. Сразу скажу про права доступа Я считаю что права доступа это адаптер, который Вы должны также прокидывать при создании доменного контроллера и внутри в контроллерах проверять права.
  5. Легкое изменение кода. Допустим Я хочу после создания клиента создавать оповещение, то есть хочу обновить бизнес процесс. Захожу в бизнес процесс, в начале добавляю инциализацию домена notifications и после получения результата создания клиента делаю notifications.notifyClient({ client: client.id, type:SUCCESS_REGISTRATION })

На этом всё, надеюсь было интересно, спасибо за внимание!

Подробнее..

Prototype Design Pattern в Golang

24.05.2021 18:21:26 | Автор: admin

Привет друзья! С вами Алекс и я продолжаю серию статей, посвящённых применению шаблонов проектирования в языке Golang.

Интересно получать обратную связь от вас, понимать на сколько применима данная область знаний в мире языка Golang. Ранее уже рассмотрели шаблоны: Simple Factory, Singleton и Strategy. Сегодня хочу рассмотреть еще один шаблон проектирования - Prototype.

Для чего нужен?

Это порождающий шаблон проектирования, который позволяет копировать объекты, не вдаваясь в подробности их реализации.

Какую проблему решает?

Представьте, у вас есть объект, который необходимо скопировать. Как это сделать? Создать пустой объект такого же класса, затем поочерёдно скопировать значения всех полей из старого объекта в новый. Прекрасно, но есть нюанс! Не каждый объект удается скопировать таким образом, ведь часть его состояния может быть приватной, а значит - недоступной для остального кода программы.

Есть и другая проблема. Копирующий код станет зависим от классов копируемых объектов. Ведь, чтобы перебрать все поля объекта, нужно привязаться к его классу. Из-за этого вы не сможете копировать объекты, зная только их интерфейсы, а не конкретные классы.

Какое решение?

Шаблон Prototype поручает создание копий самим копируемым объектам. Он вводит общий интерфейс для всех объектов, поддерживающих клонирование. Это позволяет копировать объекты, не привязываясь к их конкретным классам. Обычно такой интерфейс имеет всего один метод clone.

Реализация этого метода в разных классах очень схожа. Метод создаёт новый объект текущего класса и копирует в него значения всех полей собственного объекта. Так получится скопировать даже приватные поля, так как большинство языков программирования разрешает доступ к приватным полям любого объекта текущего класса. Объект, который копируют, называется прототипом, отсюда и название шаблона. Когда объекты программы содержат сотни полей и тысячи возможных конфигураций, прототипы могут служить своеобразной альтернативой созданию подклассов. Шаблон прототип должен копировать объекты любой сложности без привязки к их конкретным классам.

Все классы-прототипы имеют общий интерфейс. Поэтому вы можете копировать объекты, не обращая внимания на их конкретные типы и всегда быть уверены, что получите точную копию. Клонирование совершается самим объектом-прототипом, что позволяет ему скопировать значения всех полей, даже приватных. В этом случае все возможные прототипы заготавливаются и настраиваются на этапе инициализации программы. Потом, когда программе нужен новый объект, она создаёт копию из приготовленного прототипа.

Диаграмма классов

Prototype Class DiagramPrototype Class Diagram

На диаграмме видим Интерфейс прототипов, который описывает операции клонирования. В большинстве случаев - это единственный метод clone. Конкретный прототип реализует логику клонирования самого себя. Тут важно не допускать ошибку клонирования. Например, клонирование связанных объектов, распутывание рекурсивных зависимостей и прочее. Клиент создаёт копию объекта, обращаясь к нему через общий интерфейс прототипов. Все просто.

Как реализовать?

Подробно пошаговую реализацию данного шаблона, а также других шаблонов проектирования на языке PHP можно посмотреть тут. Наша задача реализовать шаблон Prototype на языке Golang.

Рассмотрим пример реализации рубрикатора каталога продуктов в интернет-магазине с большим количеством категорий товаров. В рубрикаторе есть разделы верхнего уровня, в которых содержатся конечные рубрики и разделы второго уровня. Разделы второго уровня также могут содержать рубрик и разделы третьего уровня и т.д. То есть это древовидная структура объектов, у них есть стволы и листья - конечные элементы дерева. Каждая рубрика и раздел имеет свой набор свойств, относительно товаров раздела. Например, рубашки - это цвет, размер, бренд и т.д.

В какой-то момент потребовалась панель администрирования рубрикатора, чтобы копировать разделы в раздел и рубрики со всеми свойствами товаров. Например, в разделе одежды нужно быстро уметь клонировать рубрики мужские, женские, детские шорты и т.д.

Каждую рубрика, как конечный элемент рубрикатора, может быть представлен интерфейсом prototype, который объявляет функцию clone. За основу конкретных прототипов рубрики и раздела мы берем тип struct, которые реализуют функции show и clone интерфейса prototype.

Итак, реализуем интерфейс прототипа. Далее мы реализуем конкретный прототип directory, который реализует интерфейс prototype представляет раздел рубрикатора. И конкретный прототип для рубрики. Обе структуру реализуют две функции show, которая отвечает за отображение конкретного контента ноды и clone для копирования текущего объекта. Функция clone в качестве единственного параметра принимает аргумент, ссылающийся на тип указателя на структуру конкретного прототипа - это либо рубрика, либо директория. И возвращает указатель на поле структуры, добавляя к наименованию поля _clone.

В клиенте создаем древовидную структуру рубрикатора. Копируем одну из директорий со всеми дочерними элементами. Видим, что все дерево успешно скопировано благодаря реализации функция клонирования для каждого конкретного прототипа. Вывод:

Open directory 2  Directory 2    Directory 1        category 1    category 2    category 3Clone and open directory 2  Directory 2_clone    Directory 1_clone        category 1_clone    category 2_clone    category 3_clone

Когда применять?

  1. У вас много объектов с множеством свойств и вам нужно создавать их клоны быстро и эффективно. Шаблон предлагает использовать набор прототипов, вместо создания подклассов для описания популярных конфигураций объектов. Вместо порождения объектов из подклассов, вы копируете существующие объекты-прототипы, в которых уже настроено состояние. Тем самым избегая роста количества классов в программе и уменьшая её сложность.

  2. Код не должен зависеть от классов копируемых объектов. Если ваш код работает с объектами, переданными через общий интерфейс - вы не можете привязаться к их классам, даже если бы хотели, поскольку их конкретные классы неизвестны. Прототип предоставляет клиенту общий интерфейс для работы со всеми прототипами. Клиенту не нужно зависеть от классов копируемых объектов, а только от интерфейса клонирования.

Итог

Друзья, шаблон Prototype предлагает:

  • Удобную концепцию для создания копий объектов.

  • Помогает избежать дополнительных усилий по созданию объекта стандартным путём, когда это непозволительно дорого для приложения.

  • В объектных языках позволяет избежать наследования создателя объекта в клиентском приложении, как это делает паттерн abstract factory, например.

Кстати, друзья, вот тут можно посмотреть результаты опроса читателей хабра. 63% опрошенных считают, что применение шаблонов проектирования в Golang - это зло. Связано, скорее всего, с тем, что язык Golang процедурный и ему чужды подходы объектно-ориентированных языков. Но рассматривать реализации и применение шаблонов стоит, так как это позволяет больше их понимать и периодически применять для решения тех или иных задач. Каждый подход требует, конечно, дискуссии и разумного применения.

Друзья, рад был поделиться темой, Алекс. На английском статью можно найти тут.
Удачи!

Подробнее..

Принцип подстановки Барбары Лисков (предусловия и постусловия)

28.05.2021 00:20:41 | Автор: admin

Почему у многих возникают проблемы с этим принципом? Если взять не заумное, а более простое определение, то оно звучит так:

Наследующий класс должен дополнять, а не замещать поведение базового класса.

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

В данной статье мы НЕ будем рассматривать общие примеры данного принципа, о которых уже есть много материалов (пример с квадратом и прямоугольником или управления термостатами). Здесь мы немного подробнее остановимся на таких понятиях как Предусловия, Постусловия, рассмотрим что такое ковариантность, контравариантность и инвариантность, а также что такое исторические ограничения или правило истории.

Предусловия не могут быть усилены в подклассе

Другими словами дочерние классы не должны создавать больше предусловий, чем это определено в базовом классе, для выполнения некоторого бизнес-поведения. Вот пример:

<?phpclass Customer{    protected float $account = 0;    public function putMoneyIntoAccount(int|float $sum): void    {        if ($sum < 1) {            throw new Exception('Вы не можете положить на счёт меньше 1$');        }        $this->account += $sum;    }}class  MicroCustomer extends Customer{    public function putMoneyIntoAccount(int|float $sum): void    {        if ($sum < 1) {            throw new Exception('Вы не можете положить на счёт меньше 1$');        }        // Усиление предусловий        if ($sum > 100) {             throw new Exception('Вы не можете положить на больше 100$');        }        $this->account += $sum;    }}

Добавление второго условия как раз является усилением. Так делать не надо!

К предусловиям также следует отнести Контравариантность, она касается параметров функции, которые может ожидать подкласс.

Подкласс может увеличить свой диапазон параметров, но он должен принять все параметры, которые принимает родительский.

Этот пример показывает, как расширение допускается, потому что метод Bar->process() принимает все типы параметров, которые принимает метод в родительском классе.

<?phpclass Foo{    public function process(int|float $value)    {       // some code    }}class Bar extends Foo{    public function process(int|float|string $value)    {        // some code    }}

Пример ниже показывает, как дочерний класс VIPCustomer может принимать в аргумент переопределяемого метода putMoneyIntoAccount более широкий (более абстрактный) объект Money, чем в его родительском методе (принимает Dollars).

<?phpclass Money {}class Dollars extends Money {}class Customer{    protected Money $account;    public function putMoneyIntoAccount(Dollars $sum): void    {        $this->account = $sum;    }}class VIPCustomer extends Customer{    public function putMoneyIntoAccount(Money $sum): void    {        $this->account = $sum;    }}

Таким образом, мы не добавляем дополнительных проверок, не делаем условия жестче и наш дочерний класс уже ведёт себя более предсказуемо.

Постусловия не могут быть ослаблены в подклассе

То есть подклассы должны выполнять все постусловия, которые определены в базовом классе. Постусловия проверяют состояние возвращаемого объекта на выходе из функции.

<?phpclass Customer{    protected Dollars $account;    public function chargeMoney(Dollars $sum): float    {        $result = $this->account - $sum->getAmount();        if ($result < 0) { // Постусловие            throw new Exception();        }        return $result;    }}class  VIPCustomer extends Customer{    public function chargeMoney(Dollars $sum): float    {        $result = $this->account - $sum->getAmount();        if ($sum < 1000) { // Добавлено новое поведение            $result -= 5;          }               // Пропущено постусловие базового класса              return $result;    }}

Условное выражение проверяющее результат является постусловием в базовом классе, а в наследнике его уже нет. Не делай так!

Сюда-же можно отнести и Ковариантность, которая позволяет объявлять в методе дочернего класса типом возвращаемого значения подтип того типа (ШО?!), который возвращает родительский метод.

На примере будет проще. Здесь в методе render() дочернего класса, JpgImage объявлен типом возвращаемого значения, который в свою очередь является подтипом Image, который возвращает метод родительского класса Renderer.

<?phpclass Image {}class JpgImage extends Image {}class Renderer{    public function render(): Image    {    }}class PhotoRenderer extends Renderer{    public function render(): JpgImage    {    }}

Таким образом в дочернем классе мы сузили возвращаемое значение. Не ослабили. Усилили :)

Инвариантность

Здесь должно быть чуть проще.

Все условия базового класса - также должны быть сохранены и в подклассе.

Инварианты это некоторые условия, которые остаются истинными на протяжении всей жизни объекта. Как правило, инварианты передают внутреннее состояние объекта.

Например типы свойств базового класса не должны изменяться в дочернем.

<?php class Wallet{    protected float $amount;    // тип данного свойства не должен изменяться в подклассе}

Здесь также стоит упомянуть исторические ограничения (правило истории):

Подкласс не должен создавать новых мутаторов свойств базового класса.

Если базовый класс не предусматривал методов для изменения определенных в нем свойств, подтип этого класса так же не должен создавать таких методов. Иными словами, неизменяемые данные базового класса не должны быть изменяемыми в подклассе.

<?phpclass Deposit{    protected float $account = 0;    public function __construct(float $sum)    {        if ($sum < 0) {            throw new Exception('Сумма вклада не может быть меньше нуля');        }        $this->account += $sum;    }}class VipDeposit extends Deposit{    public function getMoney(float $sum)    {        $this->account -= $sum;    }}

С точки зрения класса Deposit поле не может быть меньше нуля. А вот производный класс VipDeposit, добавляет метод для изменения свойства account, поэтому инвариант класса Deposit нарушается. Такого поведения следует избегать.

В таком случае стоит рассмотреть добавление мутатора в базовый класс.

Выводы

Даже не вникая в общие сложные абстрктные примеры самого принципа подстановки Барбары Лисков, а пользуясь этими, на мой взгляд, более простыми и более конкретными правилами, вы уже добьётесь более предсказуемого поведения дочерних классов.

Стоит упомянуть, что нужно страться избавляться от пред/пост условий. В идеале они должны быть определенны как входные/выходные параметры метода (например передачей в сигнатуру готовых value objects и возвращением конкретного валидного объекта на выход).

Надеюсь, было полезно.

Источники

  1. Вики - Принцип подстановки Барбары Лисков

  2. Metanit

  3. PHP.watch

  4. Telegram канал, с короткими заметками

Подробнее..

Перевод Актуальность принципов SOLID

05.06.2021 22:17:23 | Автор: admin

Впервые принципы SOLID были представлены в 2000 году в статье Design Principles and Design Patterns Роберта Мартина, также известного как Дядюшка Боб.

С тех пор прошло два десятилетия. Возникает вопрос - релевантны ли эти принципы до сих пор?

Перед вами перевод статьи Дядюшки Боба, опубликованной в октябре 2020 года, в которой он рассуждает об актуальности принципов SOLID для современной разработки.

Недавно я получил письмо с примерно следующими соображениями:

Годами знание принципов SOLID было стандартом при найме. От кандидатов ожидалось уверенное владение этими принципами. Однако позже один из наших менеджеров, который уже почти не пишет код, усомнился, разумно ли это. Он утверждал, что принцип открытости-закрытости стал менее важен, так как по большей части мы уже не пишем код для крупных монолитов. А вносить изменения в компактные микросервисы - безопасно и просто.

Принцип подстановки Лисков давно устарел, потому что мы уже не уделяем столько внимания наследованию, сколько уделяли 20 лет назад. Думаю, нам стоит рассмотреть позицию Дена Норса о SOLID - Пишите простой код

В ответ я написал следующее письмо.

Принципы SOLID сегодня остаются такими же актуальными, как они были 20 лет назад (и до этого). Потому что программное обеспечение не особо изменилось за все эти годы, а это в свою очередь следствие того, что программное обеспечение не особо изменилось с 1945 года, когда Тьюринг написал первые строки кода для электронного компьютера. Программное обеспечение - это все еще операторы if, циклы while и операции присваивания - Последовательность, Выбор, Итерация.

Каждому новому поколению нравится думать, что их мир сильно отличается от мира поколения предыдущего. Каждое новое поколение ошибается в этом, о чем они узнают, как только появляется следующее поколение, чтобы сказать им, насколько все изменилось. <смешок>

Итак, пройдемся по принципам по порядку.

SRP - Single Responsibility Principle Принцип единственной ответственности.

Объединяйте вещи, изменяющиеся по одним причинам. Разделяйте вещи, изменяющиеся по разным причинам.

Трудно представить, что этот принцип не релевантен при разработке ПО. Мы не смешиваем бизнес-правила с кодом пользовательского интерфейса. Мы не смешиваем SQL-запросы с протоколами взаимодействия. Код, меняющийся по разным причинам, мы держим отдельно, чтобы изменения не сломали другие части. Мы следим, чтобы модули, которые изменяются по разным причинам, не включали запутывающих зависимостей.

Микросервисы не решают эту проблему. Вы можете создать запутанный микросервис или запутанное множество микросервисов, если смешаете код, который изменяется по разным причинам.

Слайды Дена Норса полностью упускают этот момент и убеждают меня в том, что он вообще не понимает сам принцип (либо иронизирует, что более вероятно предположить, зная Дена). Его ответ на SRP - Пишите простой код. Я согласен. SRP - один из способов сохранять код простым.

OSP - Open-Closed Principle Принцип открытости-закрытости

Модуль должен быть открытым для расширения, но закрытым для изменения.

Мысль о том, что из всех принципов кто-то может подвергнуть сомнению этот, наполняет меня ужасом за будущее нашей отрасли. Конечно, мы хотим создавать модули, которые можно расширять, не изменяя их. Можете ли вы представить себе работу в зависимой от устройства системе, где запись в файл на диске принципиально отличается от вывода на принтер, печати на экран или записи в канал? Хотим ли мы видеть операторы if, разбросанные по всему коду, и вникать сразу во все мельчайшие детали?

Или... хотим ли мы отделить абстрактные понятия от деталей реализации? Хотим ли мы изолировать бизнес-правила от надоедливых мелких деталей графического интерфейса, протоколов связи микросервисов и тонкостей поведения различных баз данных? Конечно хотим!

И снова слайд Дэна преподносит это совершенно неправильно.

(Примечание. На слайде Ден утверждает, что при изменении требований существующий код становится неверным, и его нужно заменить на работающий)

При изменении требований неверна только часть существующего кода. Бльшая часть существующего кода по-прежнему верна. И мы хотим убедиться, что нам не нужно менять правильный код только для того, чтобы снова заставить работать неправильный. Ответ Дэна - Пишите простой код. И снова я согласен. По иронии, он прав. Простой код одновременно открыт и закрыт.

LSP - Liskov Substitution Principle Принцип подстановки Лисков

Программа, использующая интерфейс, не должна путаться в реализациях этого интерфейса.

Люди (включая меня) допустили ошибку, полагая что речь идет о наследовании. Это не так. Речь о подтипах. Все реализации интерфейсов являются подтипами интерфейса, в том числе при утиной типизации. Каждый пользователь базового интерфейса, объявлен этот интерфейс или подразумевается, должен согласиться с его смыслом. Если реализация сбивает с толку пользователя базового типа, то будут множиться операторы if/switch.

Этот принцип - о сохранении абстракций четкими и хорошо определенными. Невозможно вообразить такую концепцию устаревшей.

Слайды Дэна по этой теме полностью верны, он просто упустил суть принципа. Простой код - это код, который поддерживает четкие отношения подтипов.

ISP - Interface Segregation Principle Принцип разделения интерфейса

Интерфейсы должны быть краткими, чтобы пользователи не зависели от ненужных им вещей.

Мы по-прежнему работаем с компилируемыми языками. Мы все еще зависим от дат изменений, чтобы определить, какие модули должны быть перекомпилированы и перевыпущены. Покуда это справедливо, нам придется столкнуться с проблемой - когда модуль A зависит от модуля B во время компиляции, но не во время выполнения, изменения в модуле B ведут к перекомпиляции и перевыпуску модуля A.

Проблема особенно остро стоит в статически типизированных языках, таких как Java, C#, C++, GO, Swift и т.д. Динамически типизированные языки страдают гораздо меньше, но тоже не застрахованы от этого - существование Maven и Leiningen тому доказательство.

Слайд Дэна на эту тему ошибочен.

(Примечание. На слайде Ден обесценивает утверждение Клиенты не должны зависеть от методов, которые они не используют фразой Это же и так правда!!)

Клиенты зависят от методов, которые они не вызывают, если нужно перекомпилировать и перевыпускать клиент при изменении одного из этих методов. Финальное замечание Дэна по этому принципу в целом справедливо.

(Примечание. Речь о фразе Если классу нужно много интерфейсов - упрощайте класс!)

Да, если вы можете разбить класс с двумя интерфейсами на два отдельных класса, то это хорошая идея (SRP). Но такое разделение часто недостижимо и даже нежелательно.

DIP - Dependency Inversion Principle Принцип инверсии зависимостей

Направляйте зависимости согласно абстракциям. Высокоуровневые модули не должны зависеть от низкоуровневых деталей.

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

В каждом случае слайды Дена заканчиваются фразой Пишите простой код. Это хороший совет. Однако если годы и научили нас чему-то, так это тому, что простота требует дисциплины, руководствующейся принципами. Именно эти принципы определяют простоту. Именно эта дисциплина заставляют программистов создавать простой код.

Лучший способ создать путаницу - сказать всем будьте проще и не дать никаких дальнейших инструкций.

Подробнее..

Чему можно научиться у фикуса-душителя? Паттерн Strangler

12.06.2021 20:19:26 | Автор: admin

Ссылка на статью в моем блоге

Тропические леса и фикусы-душители

В тропических лесах, где всегда тепло, влажно и много зелени живет одно интересное растение. С необычным названием фикус-душитель. Почему он получил такое имя? Как из фильма ужасов.

Дело в том, что в таких комфортных тропических условиях у растений возникает жесткая конкуренция. Солнечный свет закрыт кронами мощных, вековых деревьев. Их крепкие корни выкачивают все полезные ресурсы из почвы воду, минералы. В таких условиях пробиться новому ростку крайне сложно. Но фикусы-душители нашли выход. Их семена изначально попадают на кроны деревьев, где много света. Там пускают свои побеги. Поначалу они растут медленно. Но по мере роста их корни спускаются вниз до самой земли, обвивают ствол дерева-носителя. И как только они добираются до земли скорость роста удваивается. Все! Дни дерева-носителя сочтены. Теперь ствол не может расти в ширь, так как он обвит фикусом и тот его сдавливает в своих горячих обьятиях.

Крона дерева не может получать достаточно света, потому что фикус забирает его себе. Его листья выше. Корни фикуса высасывают из почвы воду и питательные вещества, так что дереву носителю достается все меньше. В какой-то момент дерево-носитель погибает, но фикусу оно уже не нужно. Его стебли образуют прочную основу, которая повторяет силуэт своей жертвы. Обычно старое дерево полностью сгнивает в таком заключении и от него не остается и следа.Однако внешний образ по прежнему остается его в точности повторяет сам фикус:

Рефакторинг сервиса приложения доставки продуктов

Часто бывает необходимо разбить таблицу на две, либо вынести часть методов сервиса в отдельный сервис. Хорошо, если вы можете остановить ваше приложение. Пользователи в это время ничего не делают ждут обновления. Вы разделяете скриптами данные по таблицам и опять запускаете приложение теперь пользователи могут снова работать. Но иногда такой сценарий невозможен.

Допустим у нас есть приложение, которое позволяет заказывать продукты из магазинов. Оплатить их. В том числе и бонусами. Очевидно, сами бонусы начисляются какой-то не тривиальной логикой: число покупок, возраст, лояльность и прочее.

Допустим имеются следующие действия, которые у нас хранятся в одной таблице:

  • Открыть заказ. В таком случае оформляется сам факт посещения заказа и общая сумма. Пока он открыт в него можно добавлять товары. Затем заказ собирают, отправляют в доставку и в итоге заказ переходит взакрытый статус.

  • Можно оформитьвозврат товара. Если вам не понравился кефир - вы оформляете возврат и вам возвращают его цену.

  • Можносписать бонусысо счета. В таком случае часть стоимости оплачивается этими бонусами.

  • Начисляются бонусы. Каким-либо алгоритмом нам не важно каким конкретно.

  • Также заказ может бытьзарегистрирован в некотором приложении-партнере(ExternalOrder)

Все перечисленная информация по заказам и пользователям хранится в таблице (пусть она будет называтьсяOrderHistory):

id

operation_type

status

datetime

user_id

order_id

loyality_id

money

234

Order

Open

2021-06-02 12:34

33231

24568

null

1024.00

233

Order

Open

2021-06-02 11:22

124008

236231

null

560.00

232

Refund

null

2021-05-30 07:55

3456245

null

null

-2231.20

231

Order

Closed

2021-05-30 14:24

636327

33231

null

4230.10

230

BonusAccrual

null

2021-05-30 09:37

568458

null

33231

500.00

229

Order

Closed

2021-06-01 11:45

568458

242334

null

544.00

228

BonusWriteOff

null

2021-05-30 22:15

6678678

8798237

null

35.00

227

Order

Closed

2021-05-30 16:22

6678678

8798237

null

640.40

226

Order

Closed

2021-06-01 17:41

456781

2323423

null

5640.00

225

ExternalOrder

Closed

2021-06-01 23:13

368358

98788

null

226.00

Логика такой организации данных вполне справедлива на раннем этапе разработки системы. Ведь наверняка пользователь может посмотреть историю своих действий. Где он одним списком видит что он заказывал, как начислялись и списывались бонусы. В таком случае мы просто выводим записи, относящиеся к нему за указанный диапазон. Организовать в виде одной таблицы банальная экономия на создании дополнительных таблиц, их поддержании. Однако, по мере роста бизнес-логики и добавления новых типов операций число столбцов с null значениями начало расти. Записей в таблице сотни миллионов. Причем распределены они очень неравномерно. В основном это операции открытия и закрытия заказов. Но вот операции начисления бонусов составляют 0.1% от общего числа, однако эти записи используются при расчете новых бонусов, что происходит регулярно.В итоге логика расчета бонусов работает медленнее, чем если бы эти записи хранились в отдельной таблице. Ну и расширять таблицу новыми столбцами не хотелось бы в дальнейшем. Кроме того заказы в закрытом статусе с датой создания более 2 месяцев для бизнес-логики интереса не представляют. Они нужны только для отчетов не более.

И вот возникает идея.Разделить таблицу на две, три или даже больше.

Проблема в том, что эта таблица одна из наиболее активно используемых в системе (как раз по причине совмещения в себе данных для разных частей логики). И простой при ее рефакторинге крайне нежелателен.

Изменение структуры хранения в три этапа

Предположим, что наше legacy монолитное приложение хоть и плохое, но не совсем. Как минимум зарезервировано. То есть работает как минимум два экземпляра. Так, что при падении одного из них - второй продолжит обслуживать пользователей:

Между пользователем и монолитом есть прокси, которая в целях упрощения схемы можно не отображать. Просто учтем, что запрос может случайным образом выполнятся на любом экземпляре.

Оба экземпляра работают с одной базой данных. Реализуя паттернShared Database.

Первый шаг к рефакторингу выпуск новой версии монолита. Которая по-прежнему работает со старой таблицей, как и предыдущая версия. Но и пишет данные в новую таблицу или таблицы. На схеме для наглядности показана отдельная база данных.

Отдельная новая база данных вполне может появиться. Однако не всегда. Ввиду сложностей обеспечения транзакционности между двумя БД. Все в конечном счете зависит от реализации и от ограничений бизнес-логики.

Применительно к нашему примеру мы могли бы получить следующую структуру для новых таблиц.

BonusOperations:

id

operation_type

datetime

user_id

order_id

loyality_id

money

230

BonusAccrual

2021-05-30 09:37

568458

null

33231

500.00

228

BonusWriteOff

2021-05-30 22:15

6678678

8798237

null

35.00

Отдельную таблицу для данных из внешних систем -ExternalOrders:

id

status

datetime

user_id

order_id

money

225

Closed

2021-06-01 23:13

368358

98788

226.00

Для операций с заказами моложе, чем 2 недели (предположим, что ограничение бизнес-логики было как раз определено на это уровне. Ведь если заказ был произведен более двух недель назад его нельзя отменить, изменить и прочее) новая таблицаOrderHistoryс уменьшеным числом столбцов.

Для оставшихся типов записей -OrderHistoryArchive(старше 2х недель). Где теперь также можно удалить несколько лишних столбцов.

Выделение таких архивных данных часто бывает удобным. Если оперативная часть очень требовательна к производительности она вполне может себе размещается на быстрых SSD дисках. В то время как архивные данные могут использоваться один раз в месяц для отчета. И их больше в разы. Поэтому размещая их на дешевых дисках мы экономим иногда вполне приличную сумму.

По схеме выше мы видим, что версия начала дублировать всю информацию в новую структуру данных. Но пока использует в своей бизнес-логике данные из старой структуры. Запрос обработанный версией 2 записывается именно в том формате, в котором его ожидает версия 1. Запрос обработанный версией 1 сохранит данные, которые также используются в работе версии 2.

Монолит версии 1 и монолит версии 2 вполне могут работать совместно. Лишь для тех запросов, которые обрабатывались монолитом версии 1 в новой базе данных будут пробелы. Эти пробелы, а также недостающие данные можно будет в дальнейшем скопировать отдельным скриптом или утилитой.

Спустя какое-то время работы версии 2 мы получим заполненную новую базу данных. Если все хорошо, то мы готовы к следующей стадии переводу основной бизнес-логики на новую базу данных.

В случае успеха просто удаляем старые таблицы, так как все данные уже сохранены в новой структуре.

Итого. Внешне система никогда не менялась. Однако внутренняя организация радикально преобразилась. Возможно под капотом теперь работает новая система. Которая лишена недостатков предыдущей. Не напоминает фикусов-душителей? Что-то похожее есть. Поэтому именно такое название паттерн и получил Strangler.

Очевидно, что аналогичным образом можно подходить к рефакторнгу не только структуру данных, но и кода. Например, разделять монолит на микросервисы.

Выводы

  • ПаттернStranglerпозволяет совершенствовать системы с высокими требованиями к SLA.

  • Для обновления системы без простоя нужно сделать как минимум 3 последовательных развертования на продакшен. Это одна из причин, почему системы требовательные к показателям общего простоя заметно дороже.

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

Все вышеперечисленное имеет смысл только в том случае, если действительно имеется необходимость. И если мы имеем возможность обновить систему ночь или в часы наименьшей нагрузки, конечно таким подарком судьбы нужно воспользоваться.

Подробнее..

Перевод Про комментарии к коду

16.06.2021 00:20:01 | Автор: admin
image

Раньше я думал, что мне не нужны комментарии, если я пишу самодокументированный код. Однако я понял, что пишу комментарии и считаю их действительно полезными. Чтобы увидеть, сколько комментариев я пишу и какие они есть, я написал скрипт для анализа моих коммитов git за последние шесть лет. В общей сложности семь процентов моих утвержденных строк содержали комментарии. В этом посте есть подробности о том, что считать хорошими и плохими комментариями, а также дополнительная статистика из моего скрипта.

Javadoc самый бесполезный


Одна из причин, по которой я скептически относился к комментариям, заключалась в преобладании комментариев в стиле Javadoc. Этот стиль комментирования существует и на других языках. Вот пример на Python, который я только что придумал, но который является представителем этого стиля:

image

Проблема большинства этих комментариев в том, что они несут очень мало информации. Часто это просто повторение имени метода и имен параметров в нескольких словах. Эти комментарии могут быть полезны для API, открытых извне, но в приложении, где у вас есть доступ ко всему исходному коду, они в основном бесполезны. Если вам интересно, что делает метод или каков допустимый диапазон ввода для параметра, вам лучше просто прочитать код, чтобы увидеть, что он делает. Эти типы комментариев занимают много места, но не представляют особой ценности.

Самодокументируемый код


Вместо того, чтобы писать Javadoc-комментарии, лучше по-максимуму использовать имена методов и имена переменных. Каждое имя, которое вы используете, может помочь объяснить, о чем идет речь и как это делается. Одна из веских причин для написания множества небольших методов вместо одного большого заключается в том, что у вас есть больше мест для использования описательных имен, о чем я описал здесь.

Когда комментировать


Написание самодокументирующегося кода поможет вам в долгосрочной перспективе. Однако бывают случаи, когда полезно иметь больше информации. Например, комментарий об использовании зон набора в коде ниже:

image

Еще один пример:

image

Часто можно услышать совет пишие комменте ПОЧЕМУ, а не ЧТО. Хотя это, вероятно, охватывает большинство моих комментариев, это не то, как я думаю о том, когда комментировать. Вместо этого я обычно пишу комментарий, когда есть что-то особенно сложное, либо в домене, либо в том, как выполняется реализация.

Стандартный совет от толпы исповедующих принцип: никаких комментариев не требуется (к которой я когда-то принадлежал) переписать код, чтобы вам не нужно было его комментировать. Однако это не всегда возможно. Иногда домен просто слишком сложен. Иногда усилия по переписыванию кода были бы слишком большими по сравнению с добавлением комментария.

Еще одна жалоба на комментарии заключается в том, что они будут не синхронизированы с кодом, тем самым препятствуя вашему пониманию кода, а не помогая ему. Хотя это иногда случается, для меня это не было большой проблемой. Почти во всех случаях, которые я анализировал, комментарии все еще оставались в силе. Они также были очень полезны. Каждый раз, когда я натыкался на один из таких комментариев, я был счастлив, что написал его. Это не займет много времени, чтобы забыть некоторые детали и нюансы проблемы, которую вы решаете, и наличие комментария с некоторым дополнительным контекстом и объяснением было здорово.

Логи как комментарии


Иногда вы получаете комментарий на халяву, если регистрируете пояснительное сообщение. В приведенном ниже примере инструкция log объясняет, что произошло, поэтому нет необходимости в комментариях.

image

Анализ комментариев


Когда я впервые подумал о том, чтобы проверить, сколько комментариев содержится во всех моих коммитах, я подумал, что будет достаточно одной строки, чтобы найти комментарии во всех моих коммитах Python (я комментирую только с помощью #):

git log --author=Henrik -p|grep '^+[^+]'|grep '#' | wc -l

Однако вскоре я понял, что мне нужны более подробные сведения. Я хотел провести различие между комментариями в конце строки и комментариями всей строки. Я также хотел узнать, сколько блоков комментариев (последовательных строк комментариев) у меня было. Я также решил исключить тестовые файлы из анализа. Кроме того, я хочу обязательно исключить любой закомментированный код, который там оказался (к сожалению, таких случаев было несколько). В конце концов я написал скрипт на python для анализа. Входными данными для скрипта были выходные данные git log --author=Henrik -p.

Из выходных данных я увидел, что 1299 из 17817 добавленных строк моих содержали комментарии. Был 161 комментарий в конце строки и 464 однострочных комментария. Самый длинный блок комментариев составлял 11 строк, и было 96 случаев блоков комментариев, которые имели 3 или более последовательных строк.

Выводы


Раньше я думал, что написание хорошо названных функций будет означать, что комментарии не нужны. Однако, глядя на то, что я на самом деле сделал, я заметил, что я склонен добавлять комментарии в сложные или неинтуитивные части кода. Каждый раз, когда я возвращаюсь к этим частям программы, я рад, что приложил усилия, чтобы добавить комментарий они были очень полезны!
Подробнее..

Подпишись, чтобы не пропустить События

16.06.2021 20:20:54 | Автор: admin

Событийно-ориентированный подход к разработке позволяет естественным изолировать реализацию бизнес-логики приложения от пользовательского интерфейса. Как в большей степени Unity-разработчик, я буду демонстрировать все именно в контексте геймдева. В прошлой статье я показал, как конечные автоматы позволяют организовать бизнес-логику в виде системы состояний со строго обозначенным поведением. Сегодня мне бы хотелось уделить внимание тому, как именно автомат должен взаимодействовать с игровыми объектами и управлять состоянием сцены.

В примерах в статье про автоматы я использовал следующую конструкцию:

Game.Event.Invoke("joystick_updated", input);

По структуре вызова функции можно понять, что есть некоторый статический класс Game, предоставляющий глобальный доступ к своим полям, что очень удобно при небольших размерах проекта. Вот часть его содержимого:

public static class Game{    public static FSM Fsm = new FSM();    public static EventManager Event = new EventManager();       public static ObservableData Data = new ObservableData();...

В этих примерах можно увидеть некоторые вольности в деталях реализации. При масштабировании проекта, например, придется отказаться от статического контекста и на основе класса Game реализовать компоненты, назовем их претенциозно MonoBehaviourPro с подобной структурой для сложных подсистем, и передавать ее в качестве контекста автомату и компонентам этих подсистем. Я намеренно сглаживаю эти углы для большей наглядности примера. Сегодня мы рассмотрим класс с многострадальным названием EventManager, так как он является зависимостью ObservableData и без него мы не сможем двинуться дальше. По ссылке можно увидеть полную реализацию класса EventManager, принцип его работы предельно прост. Мы храним список делегатов c произвольной сигнатурой, подписанных на события со строковым ключом.

Важно, что мы работаем с Generic-структурой, поэтому следует помнить о Type safety. Тип аргумента при отправке события должен соответствовать сигнатурам функций, подписанных на него. Также, можно заметить, что EventManager отдельно хранит binds и binds_global и имеет отдельный интерфейс для работы с ними. Это реализация, специфичная для Unity. Дело в том, что там существует система сцен, позволяющая подгружать или выгружать сцены и объекты. И разница между этими двумя словарями в том, что первый очищается при выгрузке сцены. В идеальном мире мы всегда подписываем объект в Awake и отписываем его в OnDestroy. В таком случае можно было бы обойтись одним binds, не очищая его никогда. Каждый объект подписывается и отписывается в рамках своего жизненного цикла и разве что при переходе между сценами происходило бы немного лишней работы над поштучным отписыванием выгружаемых объектов. Но такой подход не прощает ошибок, выгруженный подписчик в лучшем случае сразу сломает вызов делегата и будет найдена, а в худшем - станет причиной утечки памяти. Так что, в качестве "защиты от дурака" лучше при переходе явно отписывать все, что не было обозначено как Global.

Итак, интерфейс EventManager cводится к 5 методам:

        public void Bind<T>(string name, Action<T> ev)        public void BindGlobal<T>(string name, Action<T> ev)        public void Unbind<T>(string name, Action<T> ev)        public void UnbindGlobal<T>(string name, Action<T> ev)                  public void Bind(string name, Action ev)        public void BindGlobal(string name, Action ev)        public void Unbind(string name, Action ev)        public void UnbindGlobal(string name, Action ev)                  public void Invoke<T>(string name, T arg)                  public void Invoke(string name)

Мы можем подписываться на события и отправлять их. И все это с аргументом произвольного типа. В примере из статьи про FSM мы передавали ввод с джойстика в автомат и, если состояние предусматривает такую возможность, передавали в EventManager событие изменения положения джойстика , на которое может подписаться компонент, управляющий положением игрока(Или потомок MonoBehaviourPro, какой нибудь PlayerController, который передаст информацию о вводе в свой автомат, и если игрок в состоянии SPlayerDriving , будет передавать ввод с джойстика уже автомобилю, за рулем которого он сидит, а если в SPlayerClimbing, джойстик будет двигать игрока перпендикулярно нормали плоскости, по которой он движется, с соответствующей анимацией. Но это уже более сложные примеры, не будем на этом задерживаться). Или же, на входе в состояние игры SWin мы можем отправить событие level_done, а на него подписать анимацию экрана победы, конфетти, и чего там еще ваш ГД придумает.

Некоторые события, не влияющие на игровой процесс, такие как включение-выключение звука, вполне можно обрабатывать в обход автомата, отправляя событие напрямую с кнопки, и принимая его классом-оберткой PlayerPrefs, как бы он ни был устроен. Тут я призываю вас руководствоваться здравым смыслом и собственным чувством прекрасного.

Таким образом, автомат в процессе своей работы может отправлять события подписавшимся на них объектам через EventManager. В следующей статье я расскажу о возможностях, предоставляемых классом ObservableData в связке с описанными инструментами.

Эта статья - вторая в серии:
- Разделяй и властвуй Использование FSM в Unity
- Подпишись, чтобы не пропустить События

Подробнее..

Маленькими шагами к красивым решениям

16.05.2021 12:20:18 | Автор: admin

Архитектура ПО это Вселенная. Все очень сложно, но если все правильно, то все невероятно просто. Шаг за шагом познаю что и как. Ищу лучшие практики и шаблоны. В конечном счете, в очередной раз делаю одно и то же заключение:

Изученные правильные практики и шаблоны проектирования лишь вектор, который вдохновляет на красивые и уникальные решения.

Здесь нет примеров хорошей архитектуры, советов как должно быть и как правильно. Я просто хочу зафиксировать мысли, которые надо не забывать воспроизводить при решении очередной задачи.

Снаружи для пользователя, внутри для команды разработки

Архитектура системы определяет скорость ее развития. Красивая и правильная архитектура вдохновляет работать и помогает команде разработки.

Вдохновляет простое. К простым и красивым решениям не всегда возможно прийти с первого раза. Ошибка тоже результат.

Если система проста и удобна внутри, то она так же проста и удобна снаружи. У пользователей меньше проблем, а значит и у разработчиков тоже.

Если у команды разработки нет уверенности в последствиях их доработки, и они не могут точно сказать на что она повлияет, то это начало плохих решений: дублирование кода, неявная логика и другие причуда.

Монолиты плохо, микросервисы не всегда хорошо, наносервисы плохо. При проектировании нужно держать баланс и рисовать логические границы. Помогут сценарии использования.

Модель

Объектная модель должна быть понятна не только разработчикам, но и пользователям.

Модель должна быть гибкой: позволять масштабировать решение, не вызывать проблем при развитии. Нужно прогнать на ней все известные сценарии, которые будут реализованы сейчас и могут быть реализованы в будущем.

При реализации объектной модели клиентам рекомендуется ориентироваться на модель сервер-приложения.

Логика

Упрощайте алгоритмы. Делите сложные части на простые. Не переусердствуйте.

Сначала продумайте тесты для алгоритма, получите альтернативные сценарии и предусмотрите обработку, а потом пишите сам алгоритм.

Если какая-то часть алгоритма многократно повторяется, то скорее всего она должна быть выделена в отдельный модуль и переиспользоваться. Отдельный модуль отдельный алгоритм.

Алгоритм должен легко читаться: в тексте, в любой нотации моделирования, в коде.

Не прячьте за методами в коде неявную логику. Неявные связи в реализации логики ведут к усложнению решения.

Когда отдельные части программы нельзя перекомбинировать в новое целое, значит создан неэффективный монолит.

Нейминг решает

Если назвать бокал стаканом, то назначение "пить" вроде бы не меняется, но что-то все-таки не то.

Имя объекта должно характеризовать его достаточно емко, не вызывать двусмысленности.

Метод должен реализовывать ровно то, о чем говорит его название. Название должно быть таким, чтобы другому разработчику не нужно было залезать внутрь и раскапывать, что еще здесь можно неожиданно встретить в реализации. Лучше, когда название метода описывает результат его работы, но не его реализацию.

Имена объектов и методов в конечном счете становится языком команды разработки, заказчика ПО и даже пользователей.

MVP и прототипы

MVP это быстро и просто, это способ сдвинуться с мертвой точки и начать показывать результат.

MVP лучше, чем волшебное ничего и долгое обсуждение, как же все-таки надо сделать. Это отличный способ собрать фидбэк и неявные требования от пользователей, от других команд разработки, которые будут использовать ваше решение.

Рефакторинг

Это придется делать. Это нормально и это важно. Даже для задач, выпущенных в продакшн совсем недавно.

Своевременный рефакторинг ускоряет разработку. Больше времени сегодня на задачу, в рамках которой надо сделать рефакторинг, меньше времени на будущие задачи.

Заботьтесь о том, чтобы в будущем код не вызывал страха и отвращения к рефакторингу. С ним должно быть приятно работать. Если нет четкой и понятной архитектуры, то программисты будут бояться даже заглянуть в код, чтобы понять как это работает.

Рефакторингу может быть подвергнуто все, что связано с системой: код, модель данных, алгоритмы, тест-кейсы.

Документация

Документация к системе должна дополнять программный код и хранить знания о причинах принятых архитектурных решений.

Документация к системе позволяет без доступа к коду понимать, как работает программа: реализацию алгоритма, покрытие альтернативных сценариев, обработку ошибок. Но разработчикам должен быть понятен код и без документации на него.

Выводы

Не усложнять.

Ничего не прятать.

Называть все своими именами.

Не стоять на месте.

Не поддаваться отчаянию, если не получилось.

Не забывать переосмысливать: смотреть через несколько дней свежим взглядом на готовое решение, если время позволяет.

Подробнее..

Почему большинство юнит тестов пустая трата времени? (перевод статьи)

17.05.2021 16:18:04 | Автор: admin

Автор: James O Coplien

Перевод: Епишев Александр

1.1 Наши дни

Во времена FORTRAN, когда функция была функцией, иногда заслуживающей функциональных проверок, юнит-тестирование было одним из главных составляющих. Компьютеры производили вычисления, в то время как функции и процедуры представляли собой вычислительные блоки. В те времена доминирующий подход в дизайне предполагал создание комплексной внешней функциональности из более мелких кусков, которые, в свою очередь управляли еще более мелкими, и так далее, вплоть до уровня хорошо понятных примитивов. Каждый слой поддерживал находящийся над ним слой. В целом, у вас были большие шансы отследить, как функциональность на самом дне, так называемые функции и процедуры, были связаны с требованиями, выраженными в доступном человеку интерфейсе. Можно было рассчитывать, что хороший дизайнер поймет бизнес цель той или иной функции. Такими же возможными для понимания были и взаимосвязи в дереве вызовов, как минимум в хорошо структурированном коде. Вы могли мысленно смоделировать выполнение кода во время код-ревью.

Постепенно объектное ориентирование штурмом захватывает мир, а мир проектирования, и вовсе, переворачивает вверх дном. Во-первых, проектируемые блоки превратились из вычислительных единиц в маленькие гетерогенные композиты, называемые объектами и совмещающие в себе несколько программных артефактов, включая и функции, и данные внутри одной обертки. Объектная парадигма использовала классы для оборачивания нескольких функций вместе с общими для этих функций спецификациями данных. Подобный класс становился шаблоном, из которого в ран-тайме создавались объекты. В таком вычислительном контексте, необходимая функция определяется во время выполнения и не может быть вызвана из исходного кода, как это было возможно с FORTRAN. Это привело к невозможности разобраться во взаимосвязях поведения кода в ран-тайме лишь за счет анализа. Теперь вам необходимо запустить программу для того, чтобы получить малейшее представление о том, что же в ней происходит.

Итак, тестирование снова вступает в игру. На этот раз - юнит-тестирование на стероидах. Объектно-ориентированное сообщество обнаружило ценность ранней обратной связи, обусловленной возрастающей скоростью техники, а также появлением всё большего числа персональных компьютеров. Дизайн становится более сфокусированным на данных, так как объекты всё больше конструируются исходя из структур этих данных, чем каких-либо свойств своих же методов. Нехватка явно выраженной структуры вызовов усложнила возможность поместить выполнение какой-либо атомарной функции в контекст своего же выполнения. И насколько бы малым ни был шанс для подобной возможности, и тот был отнят полиморфизмом. В результате, интеграционное тестирование оказывается вне, а первоочередную роль принимает на себя юнит-тестирование. Где-то на фоне всё ещё виднелось системное, но, скорее уже как проблема кого-то другого, или, что еще более опасно, как один из вариантов более зрелой версии юнит-тестирования, которое проводилось самими же создателями кода.

Классы превратились в объекты анализа, и, в определенной степени, проектирования. Популярной техникой дизайна стали CRC-карты (обычно представляющие Классы, Ответственности и Кооперацию), каждый класс в которых представлен отдельным человеком. Объектная ориентация начала ассоциироваться с антропоморфным дизайном. Классы, к тому же, превратились в единицы администрирования, дизайна и программирования, а их антропоморфная суть усилила стремление каждого создателя класса его протестировать. Поскольку у некоторых методов класса сохранилась такая же контекстуализация, как и у функции FORTRAN, у программистов возникла необходимость предоставлять контекст перед выполнением метода (помните, что мы не тестируем классы и, даже, не тестируем тестовые объекты, единицей функционального теста является метод). Юнит тесты обеспечивали выполнение сценариев драйверами. Моки - контекст состояния окружения (энва) и других методов, от которых зависел тестируемый метод. При подготовке к тесту, тестовые окружения поставляли необходимые средства для создания каждого объекта в его правильном состоянии.

1.2 Лекарство хуже болезни

Конечно же, юнит-тестирование не является проблемой исключительно объектно-ориентированного программирования, de rigueur (лат. "крайней необходимостью"), скорее всего, его сделала комбинация объектной-ориентированности, эджайла, разработки программного обеспечения, а также рост инструментов и вычислительных мощностей. Как консультант, я часто слышу вопросы о юнит-тестировании, включая следующий от одного из своих клиентов, Ричарда Якобса (Richard Jacobs) из Sogeti (Sogeti Nederland B.V.):

Второй вопрос касается юнит-тестов. Если я правильно припоминаю, вы говорили, что юнит-тесты - это пустая трата времени. Во-первых, я был удивлен. Тем не менее, сегодня моя команда сообщила, что их тесты сложнее, чем сам код. (Это не та команда, которая изначально написала код и юнит тесты. Поэтому некоторые тесты застают их врасплох. Текущая команда более высоко квалифицирована и дисциплинирована.) В таком случае, по моему, это пустая трата времени... Когда я ежедневно программировал, то создавал действительно тестируемый код, однако, почти никогда не писал никакие юнит тесты. При этом я заслуживал признание за свой качественный код и почти безошибочное программное обеспечение. Мне хотелось бы разобраться, ПОЧЕМУ такой вариант работал в моем случае?

Вспомните из своего университетского образования, что любую программу можно смоделировать как ленту Тьюринга, и то, на что именно способна эта программа, каким-то образом связано с количеством битов на данной ленте перед её выполнением. Если вы хотите тщательно протестировать такую программу, вам понадобится тест, по крайней мере, с таким же объемом информации: то есть другая лента Тьюринга, как минимум, с таким же количеством бит.

На практике же, особенности языка программирования затрудняют достижение подобной компактности выражения в тесте, поэтому для проведения полного тестирования, количество строк кода в юнит тестах должно было бы быть на порядок выше, чем в самом тестируемом юните. Немногие разработчики признают, что проводят только выборочное или частичное тестирование, многие же остальные скажут вам, что делают полное тестирование, исходя из некоего своего предполагаемого представления о том, что значит полное. К таким представлениям относится заявление типа Каждая строка кода покрыта, что, с точки зрения теории вычислений, является откровенной чепухой, особенно в понимании того, действительно ли код делает, что должен. Более детально мы обсудим эту проблему ниже. Всё же, большинство программистов, понимают юнит тестирование именно так, изначально обрекая его на провал.

Будьте скромны относительно способностей ваших юнит тестов, кроме тех случаев, когда для тестируемого юнита у вас есть оракул в виде внешних требований. Сомнительно, что юнит тесты способны протестировать более одной триллионной от функциональности любого предоставленного метода в рамках разумного цикла тестирования. Придите в себя.

(Здесь, триллион - не риторический прием, а цифра, основанная на различных возможных состояниях, с учетом того, что средний размер объекта представляет собой четыре слова, и, по консервативной оценке, вы используете 16-битные слова).

1.3 Тесты ради тестов и спроектированные тесты

У меня был клиент из Северной Европы, разработчики которого должны были предоставить 40% покрытия кода, для, так называемого, 1-го уровня зрелости программного обеспечения, 60% для 2-го уровня и 80% для 3-го, хотя были и стремящиеся к 100%. Без проблем! Как вы могли бы предположить, достаточно сложная процедура с ветвлениями и циклами стала бы вызовом, однако, это всего лишь вопрос принципа divide et impera (разделяй и властвуй). Большие функции, для которых 80% покрытие было невозможным, разбивались на множество более мелких, для которых 80% уже было тривиальным. Такой подход повысил общий корпоративный показатель зрелости команд всего лишь за один год, потому как вы обязательно получаете то, что поощряете. Конечно же, это также означало, что функции больше не инкапсулировали алгоритмы. Невозможным оказалось понимание контекста выполняемой строки, точнее тех, которые предшествуют и следуют за ней во время выполнения, поскольку эти строки кода больше не имеют прямого отношения к той, которая нас интересует. Такой переход в последовательности теперь происходил благодаря вызову полиморфной функции - гипер-галактической GOTO. Даже если всё, что вас беспокоит, - это покрытие решений (branch coverage), это больше не имеет значения.

Если вы обнаруживаете, что тестировщики дробят функции для поддержки процесса тестирования, вы разрушаете свою системную архитектуру, а, вместе с ней, и понимание кода. Тестируйте на более грубом уровне детализации.

Речь идет о простых объемах кода. Вы можете их уменьшить, однако такой код будет содержать циклы, обходящие стороной теорию информации, и заворачивать множество строк кода в маленькое пространство. Это означает, что тесты, по крайней мере, должны быть такими же вычислительно сложными, как и код. Помимо множества тестов, последние еще и очень время затратные. Для проверки любой разумной комбинации индексов в цикле простой функции могут потребоваться столетия.

Задумайтесь на секунду о вычислительной сложности этой задачи. Под 100% покрытием, я подразумеваю проверку всех возможных комбинаций всех возможных ветвлений, проходящих через все методы класса, которые воспроизводят все возможные конфигурации битов данных, доступные этим методам, в каждой инструкции машинного языка во время выполнения программы. Все остальное - это эвристика, о корректности которой нельзя сделать никаких формальных заявлений. Число возможных путей выполнения с помощью функции невелико: скажем, 10. Перекрестное произведение этих путей с возможными конфигурациями состояний всех глобальных данных (включая данные экземпляра, которые для области видимости метода являются глобальными) и формальных параметров в действительности же очень велико. Перекрестное произведение этого числа с возможной последовательностью методов внутри класса представляется счетно-бесконечным. Если вы возьмете несколько типичных чисел, то быстро осознаете, насколько вам повезло, если получите покрытие лучше, чем 1 из 1012.

Одна из брут-форс атак такой проблемы - непрерывный прогон тестов. Люди смешивают понятие автоматизированных тестов с юнит тестами: иногда настолько, что, когда я критикую юнит-тестирование, они упрекают меня за критику автоматизации.

Если вы пишете тест с целью покрытия как можно большего количества возможных сценариев, тогда можете уже выделять ферму машин для прогона тестов и отслеживания последних результатов 24/7.

Помните, однако, что автоматизированный хлам - это всё ещё хлам. И те из вас, у кого есть корпоративная Lean-программа, могли заметить, что основы производственной системы Toyota, которые лежали в основе Scrum, очень сильно противились автоматизации интеллектуальных задач (http://personeltest.ru/away/www.computer.org/portal/web/buildyourcareer/Agile Careers/-/blogs/autonomation). Более эффективно - это постоянно удерживать человека процессе, что становится еще более очевидным при исследовательском тестировании. Если вы собираетесь что-то автоматизировать, автоматизируйте что-нибудь ценное. Автоматизировать необходимо рутинные вещи. Возможно даже, вы получите еще больше прибыли от инвестиций, если автоматизируете интеграционные тесты, тесты для проверки регрессионных багов, а также системные, вместо того, чтобы заниматься автоматизацией юнит тестов.

Более разумный подход уменьшает объем тестового кода за счет формального проектирования тестов: то есть, формальной проверки граничных условий, большего количества тестов белого-ящика и т.д. Для этого необходимо, чтобы программный юнит проектировался как тестируемый. Вот как это делают инженеры по аппаратному обеспечению: разработчики предоставляют контрольные точки, способные считывать значения c J-Tag микросхем, для доступа к внутренним значениям сигналов микросхем - это равносильно доступу к значениям между промежуточными вычислениями, содержащимися в вычислительном юните. Я настоятельно рекомендую делать подобное на системном уровне, на котором должно быть сосредоточено основное внимание тестирования; я никогда не видел, чтобы кто-то достигал подобного на уровне юнита. Без таких приемов вы ограничиваете себя юнит-тестированием черного ящика.

Я мог бы поверить в формализованный дизайн юнит теста, если бы такое поведение можно было формализовать, то есть, если бы существовал некий абсолютный, формальный оракул корректности, на основе которого можно было бы создать тест. Подробнее об этом ниже. В противном случае это лишь догадка программиста.

Тесты следует создавать с огромной осторожностью. Большинство функциональных тестов должны скорее разрабатываться представителями бизнеса, чем программистами. Юнит тесты должны же ограничиваться теми, которые можно сопоставить с определенными сторонними приемочными критериями.

1.4 Убеждение, что тесты умнее кода, говорит о скрытом страхе или плохом процессе

У программистов существует негласное убеждение о том, что они способны мыслить более ясно (или, скорее, догадываться), во время создания тестов, чем при написании исходного кода, или что в тесте, каким-то образом, содержится больше информации, чем в самом коде. Это просто формальная ерунда. Психология здесь поучительна и важна, потому что именно она, а не какое-то рассчитываемое свойство, больше всего руководит поведением разработчика.

Если ваши кодеры предоставляют в юнит-тестах больше строк, чем в исходном коде, это, вероятно, означает одно из следующего. У них может быть параноидальное отношение к корректности; паранойя вытесняет ясное мышление и инновации, которые служат предзнаменованием высокого качества. Им может не хватать аналитических ментальных инструментов или дисциплины мышления, и хотелось бы, чтобы машина думала за них. Машины хорошо справляются с повторением механических задач, однако дизайн тестов по-прежнему требует тщательного обдумывания. Или может случиться так, что в вашем процессе частая интеграция невозможна из-за плохо налаженных процессов разработки или неподходящих инструментов. Программисты делают всё возможное, чтобы компенсировать подобную ситуацию за счет тестов там, где они хотя бы в какой-то степени обретают контроль над собственной судьбой.

Если у вас большой объем юнит-тестов, оцените обратную связь в процессе разработки. Интегрируйте код чаще; сократите время сборки и интеграции; сократите количество юнит тестирования и перейдите больше к интеграционному.

Проблема также может быть в другом: разработчики не обладают достаточно развитыми навыками проектирования, или же процесс не стимулирует архитектурное мышление и добросовестный подход к дизайну. Возможно, требования настолько плохи, что разработчики даже и не поняли бы что нужно протестировать, если бы им пришлось это сделать, поэтому всё, чем они руководствуются - это их лучшие догадки. Исследования программной инженерии показали, что наиболее экономически эффективным для исправления ошибок является момент перехода от анализа к проектированию, сам дизайн, а также дисциплина кодирования. Намного проще избежать ошибок, чем их устранять.

Если у вас есть комплексные юнит тесты, но по-прежнему наблюдается высокий процент ошибок в системных тестах или общее низкое качество, нет необходимости автоматически винить в этом тесты (ни юнит-тесты, ни системные). Тщательно проанализируйте свои требования и процесс проектирования, а также его связь с интеграционными тестами и системными тестами.

Тем не менее, будем честны, ошибки будут всегда. Тестирование никуда не денется.

1.5 У тестов с низким уровнем риска низкая (даже потенциально отрицательная) отдача

Как то я озвучил своему клиенту предположение о том, что множество их тестов могут быть тавтологическими. Предположим, задача какой-то функции - это присвоение X значения 5, и я готов поспорить, что существует соответствующий тест для данной функции, который, после запуска, проверяет, равняется ли X 5. Снова же, хорошее тестирование, основывается на тщательном размышлении, а также базовых принципах управления рисками. Управление рисками строится на статистике и теории информации; если тестировщики (или, по крайней мере, менеджер по тестированию) не обладают хотя бы элементарными навыками в этой области, вы, с большой вероятностью, создаете множество бесполезных тестов.

Разберем тривиальный пример. Цель тестирования - предоставить информацию о вашей программе. (Тестирование само по себе не повышает качество; это делают программирование и проектирование. Тестирование лишь сообщает об упущениях команды в создании правильного проектирования и соответствующей реализации.) Большинство программистов хотят услышать информацию о том, что их программный компонент работает. Поэтому, как только в проекте трехлетней давности была создана первая функция, тут же для нее был написан и юнит тест. Тест ни разу не падал. Вопрос: Много ли информации содержится в этом тесте? Другими словами, если 1 - это успешно выполненный тест, а 0 - упавший, тогда сколько будет информации в следующей строке результатов:

11111111111111111111111111111111

Существует несколько возможных ответов, обусловленных видом применяемого формализма, хотя большинство из них не верны. Наивный ответ - 32, однако, это биты данных, а не информации. Возможно, вы информационный теоретик и скажете, что количество битов информации в однородной двоичной строке равносильно двоичному логарифму длины этой строки, которая в данном случае равна 5. Однако это не то, что я хочу знать: в конце концов хотелось бы понять, сколько информации можно получить после одноразового прогона такого теста. Информация основывается на вероятности. Если вероятность успешного прохождения теста равняется 100%, тогда, по определению теории информации, этой информации нет вообще. Ни в одной из единиц указанной выше строки не содержится почти никакой информации. (Если бы строка была бесконечно длинной, то в каждом тестовом прогоне было бы ровно ноль битов информации.)

Далее, сколько бит информации в следующей строке тестовых прогонов?

1011011000110101101000110101101

Ответ... намного больше. Вероятно, 32. Это означает, что в каждом тесте содержится намного больше информации. Если мы изначально не способны предсказать, пройдет ли тест успешно или нет, тогда каждый запуск теста содержит полный бит информации, и добиться чего-то лучшего вы не сможете. Видите ли, разработчики любят поддерживать тесты, которые проходят успешно, потому что это подогревает их эго и уровень комфорта. Однако, информация поступает от упавших тестов. (Конечно же, мы могли бы взять другую крайность:

00000000000000000000000000000000

в которой, фактически, нет никакой информации, в том числе, даже о процессе улучшения качества.)

Если вы хотите сократить объем тестов, первое, что следует сделать, - это обратить внимание на те, которые ни разу за год не упали, и рассмотреть вопрос об их удалении. Такие тесты не предоставляют вам никакой информации, или, по крайней мере, очень мало информации. Ценность производимой ими информации может не оправдывать затрат на поддержку и выполнение таких тестов. Эти тесты - первые претенденты для удаления, и не имеет значения, это юнит тесты, интеграционные или системные.

У другого моего клиента так же было слишком много юнит тестов. Я отметил, что подобная ситуация замедлит скорость разработки, потому как каждое изменение функции потребует изменения соответствующего теста. В ответ мне сообщили, что тесты написаны таким образом, чтобы их не нужно было обновлять при изменении функциональности. Разумеется, это означает, что тесты не проверяли функциональность, поэтому всё, что не тестировалось бы такими тестами, не имело особой ценности.

Не стоит недооценивать интеллект своих людей, как и не стоит недооценивать коллективную глупость множества совместно работающих людей в сложном домене. Возможно, вы подумали, что никогда не допустили бы ошибок описанной выше команды, но я всегда нахожу все больше и больше таких случаев, в которые сложно поверить. Скорее всего и у вас в шкафу есть какие-то из этих скелетов. Отыщите их, посмейтесь над собой, исправьте их и двигайтесь дальше.

Если у вас есть подобные тесты - это второй претендент на удаление.

Третий набор для удаления - тавтологические тесты. Я сталкиваюсь с ними чаще, чем вы можете себе представить, особенно среди последователей, так называемой, разработки через тестирование (TDD). (Кстати, проверка this на ненулевое/не пустое (non-null) значение при входе в метод, не является тавтологической, и может быть очень информативной. Однако, как и в случае с большинством юнит тестов, лучше сделать ассершн, чем пичкать свой тестовый фреймворк подобными проверками. Подробнее об этом ниже.)

Во многих компаниях, единственные тесты с бизнес-ценностью - это те, в основании которых лежат бизнес-требования. Большинство же юнит тестов основываются на фантазиях программистов о том, как должна работать функция: на их надеждах, стереотипах, а иногда и желаниях, как все должно было бы быть. У всего этого нет подтвержденной ценности. В 1970-х и 1980-х годах существовали методологии, опирающиеся на прослеживаемость (tracebility), и стремящиеся сократить системные требования вплоть до уровня юнитов. В общем, это NP-трудная (нелинейная полиномиальная) задача (если только вы не выполняете чисто процедурную декомпозицию), поэтому я очень скептичен в отношении всех, кто говорит, что способен её решить. В итоге, единственный вопрос, который следовало бы задавать каждому тесту: Если тест упадет, какое из бизнес-требований будет нарушено? В большинстве случаев, ответ: Я не знаю. Если вы не понимаете ценность теста, тогда, теоретически, он может иметь нулевую ценность для бизнеса. У теста есть стоимость: поддержка, время вычислений, администрирование и так далее. Значит, у теста может быть чистая отрицательная ценность. И это четвертая категория тестов, которые необходимо удалять. Такие тесты, не смотря на их способность что-то проверять, в действительности ничего не проверяют.

Если вы не можете объяснить, как сбой какого-либо юнит теста отражается на рисках, следует подумать о возможном удалении такого теста. При отсутствии формальных критериев корректности, существуют более эффективные техники устранения изъянов в качестве, такие как исследовательское тестирование и методы Монте-Карло. (Они великолепны, и я рассматриваю их как отдельную категорию в отличие от того, о чем рассказываю здесь.) Не используйте юнит тесты для подобных проверок.

Обратите внимание, существуют определенные модули, а также тесты, которые дают четкий ответ на вопрос о ценности для бизнеса. Одни из таковых - это регрессионные тесты; они, однако, редко пишутся на уровне модульных тестов, больше - на системном. При падении регрессионного теста, в силу его внутреннего устройства, мы точно знаем об особенности ошибки. Кроме того, в определенных системах существуют ключевые алгоритмы, типа алгоритмов сетевой маршрутизации, которые можно протестировать за счет API. Как я уже отмечал выше, для таких API существует формальный оракул, на основании которого можно создавать тесты. Такие юнит тесты имеют ценность.

Подумайте, должна ли основная часть ваших юнит тестов проверять ключевые алгоритмы, для которых существует оракул успешности, предоставленный третьей стороной, а не той же командой, которая пишет код. Понятие успешности здесь должно отражать бизнес-видение, а не, скажем, мнение члена команды, называемого тестировщиком, которое ценится только лишь потому, что оно независимо. Хотя, конечно же, независимая оценка тоже важна.

1.6 Сложное - сложно

Существует следующая дилемма: большая часть интересных показателей о качестве определенных программ находиться в распределении результатов тестирования, несмотря на то, что традиционные подходы к статистике, всё же, предоставляют ложную информацию. Так, в 99,99% всех случаев тест может быть успешным, но однажды упав за десять тысяч раз, он убьет вас. Опять же, заимствуя аналогию из мира железа, для уменьшения вероятности ошибки до сколь угодно низкого уровня, вы можете всё проектировать с учетом заданной вероятности отказа или же провести анализ наихудшего случая (WCA). Специалисты по аппаратному обеспечению обычно используют WCA при проектировании асинхронных систем для защиты от сбоев в сигналах, выходящих за пределы проектных параметров: один сбой на 100 миллионов раз. В области аппаратного обеспечения, сказали бы, что коэффициент качества (FIT rate) такого модуля равняется 10 - десять отказов на триллион (Failures In a Trillion).

Клиент, упомянутый мной в начале этой статьи, был озадачен причиной отсутствия положительных результатов тестирования в своей текущей команде, по сравнению с предыдущей работой. Я отправил ему более раннюю версию данной статьи, на что он ответил:

Приятно читать статью, проливающую свет на причину моего успеха (и остальной части моей команды). Возможно, Вы уже знаете, что я инженер по авионике, чья карьера началась с разработки встраиваемого программного обеспечения, и, отчасти, разработки оборудования. И вот с таким образом мышления, ориентированным на особенности работы оборудования, я начал тестировать свое программное обеспечение. (Команда состояла из четырех человек: 3-х инженеров-электриков из Делфтского университета (включая меня, в качестве специалиста по авионике) и одного инженера-программиста (из Гаагского университета). Мы были очень дисциплинированы в разработке систем безопасности для банков, пенитенциарных учреждений, пожарных, полицейских участков, служб экстренной помощи, химических заводов и т.д. В каждом из случаев всё должно было правильно заработать с первого раза.)

Обычно, при наличии разумных предположений, вы можете провести WCA аппаратного обеспечения, из-за легко прослеживаемых причинно-следственных связей: можно взять схему и разобраться в причинах изменения состояний в элементах памяти. Состояния в машине фон Неймана изменяются в результате побочных эффектов (side effects) выполнения функции, и, как правило, отследить причину такого изменения нереально, даже если некое состояние является достижимым. Объектная ориентация еще больше усугубляет ситуацию. Невозможно понять, какая из инструкций последней изменила состояние, используемое программой.

Большинство программистов убеждены, что построчное покрытие исходного кода, или, по крайней мере, покрытие ветвлений является вполне достаточным. Нет. С точки зрения теории вычислений, покрытие наихудшего случая означает анализ всевозможных комбинаций в последовательностях работы машинного языка, при котором гарантируется достижение каждой инструкции, а также - воспроизведение каждой возможной конфигурации битов данных в каждом из значений счетчика команд выполняемой программы. (Недостаточна и симуляция состояния среды выполнения только лишь для модуля или класса, содержащего тестируемую функцию или метод: как правило, любое изменение в каком-либо месте может проявиться в любом другом месте программы, а поэтому, потребует повторного тестирования всей программы. Формальное доказательство предложено в статье: Перри и Кайзера (Perry and Kaiser), Адекватное тестирование и объектно-ориентированное программирование (Adequate Testing and Objectoriented Programming), Журнал объектно-ориентированного программирования 2 (5), январь 1990 г., стр. 13). Даже взяв небольшую программу, мы уже попадаем в такое тестовое окружение, количество комбинаций в котором намного превышает количество молекул во Вселенной. (Мое определение понятия покрытие кода - это процент всех возможных пар, {Счетчик команд, Состояние системы}, воспроизводимых вашим набором тестов; все остальное - эвристика, которую, очевидно, вам сложно будет как-либо обосновать). Большинство выпускников бакалавриата смогут распознать проблему остановки (Halting Problem) в большинстве вариантов подобных задачах и поймут, что это невозможно.

1.7 Меньше - это больше или вы не шизофреник

Вот еще одна проблема, которая имеет особое отношение к первоначальному вопросу моего клиента. Наивный тестировщик пытается извлечь множество данных из результатов тестирования, при этом постоянно поддерживая все существующие тесты или даже добавляя новые; это приводит к точно такой же ситуации, в которой оказался мой клиент, когда сложность тестов (объемы кода или какие-только-хотите-метрики) начинает превосходить сложность исходного кода. Тестируемые классы - это код. Тесты - это код. Разработчики пишут код. Когда разработчики пишут код, они допускают около трех ошибок, непосредственно влияющих на систему, на каждые тысячу строк кода. Если бы мы случайным образом выбрали участки кода с подобными ошибками у моего клиента, включая тесты, то обнаружили бы, что в тестах содержиться код, который приводит к неправильным результатам чаще, чем реальный баг, останавливающий выполнение кода!

Некоторые мне говорят, что подобное не имеет к ним отношения, поскольку они уделяют значительно больше внимания тестам, чем исходному коду. Во-первых, это просто вздор. (Меня действительно смешат утверждающие, что, с одной стороны, они способны забывать о своих ранее сделанных предположениях во время создания изначального кода, и, с другой, те, кто может привнести свежий и независимый взгляд во время тестирования. Как первые, так и вторые должны быть шизофрениками.) Посмотрите, что делают разработчики при запуске тест-сьютов: они их запускают, но не думают (кстати, это же относится и к большей части Agile манифеста). На моей первой работе в Дании был проект, в значительной степени построенный на XP методологии и юнит тестировании. Я всячески пытался собрать билд на своей локальной машине, и после долгой борьбы с Maven и другими инструментами, наконец-то, мне это удалось. Каким же было разочарование, когда я обнаружил, что юнит-тесты не проходят. Пришлось обратиться к своим коллегам, которые сказали: О, так тебе нужно запустить Maven с вот этим флагом, он отключает вот эти тесты - из-за изменений эти тесты уже не работают, поэтому их необходимо отключить.

Если у вас 200, 2000, или 10 000 тестов, вы не будете тратить время на тщательное исследование и (кхе-кхе) рефакторинг каждого из них каждый раз, когда тест падает. Самая распространенная практика, которую я наблюдал, работая в стартапе еще в 2005 году, - это просто переписать результат старых тестов (ожидаемый результат или результаты вычислений такого теста) новыми результатами. С психологической перспективы, зеленый статус - это вознаграждение. Современные быстрые машины создают иллюзию возможности замены мышления программиста; их скорость намекает на исключение моей необходимости мыслить. Ведь, в любом же случае, если клиент сообщит об ошибке, я, в свою очередь, сформулирую гипотезу о ее действительной причине, внесу изменения, исправляющие поведение системы, и, в результате, с легкостью смогу себя убедить, что функция, в которую я добавил исправление, теперь работает правильно. То есть я просто переписываю результат выполнения этой функции. Однако, подобное - просто лженаука, основанная на колдовстве, связь с которым - причинность. В таком случае, необходимо повторно запустить все регрессионные и системные тесты.

Во-вторых, даже если бы правдой оказалось утверждение о том, что тесты гарантируют качество лучше, чем исходный код, из-за более совершенного процесса или повышенной к ним доли внимания, я бы посоветовал такой команде улучшить свой процесс за счет принятия пилюль, повышающих умственные способности именно во время создания кода, а не тестов.

1.8 Вы платите за поддержку тестов и качество!

Суть в том, что код - это часть вашей системной архитектуры. Тесты - это модули. Тот факт, что кто-то может не писать тесты, не освобождает его от ответственности заниматься проектированием и техническим обслуживанием возрастающего количества модулей. Одна из методик, которую часто путают с юнит-тестированием, но использующая последнее в качестве техники - это разработка через тестирование (TDD). Считается, что она улучшает метрики сцепления и связности (coupling and coherence), хотя, эмпирические данные свидетельствуют об обратном (одна из статей, опровергающих подобное представление на эмпирических основаниях принадлежит Янзену и Саледиану (Janzen and Saledian), Действительно ли разработка через тестирование улучшает качество проектирования программного обеспечения? IEEE Software 25(2), март/апрель 2008 г., стр. 77 - 84.) Еще хуже то, что таким образом, в качестве запланированного изменения, вы уже вводите связанность (coupling) между каждым модулем и сопровождающими их тестами. У вас появляется необходимость относиться к тестам так же как и к системным модулям. Даже если вы удаляете их перед релизом, это никак не сказывается на необходимости их обслуживать. (Подобное удаление может быть даже достаточно плохой идеей, но об этом дальше.)

Я обратил внимание, что подавляющая часть юнит-тестов, особенно созданных с помощью JUnit, представляет собой замаскированные утверждения (assertions). Когда же я создаю какое-нибудь восхитительное программное обеспечение, то засыпаю его подобными утверждениями, описывая ожидаемые мной результаты, и по отношению к тем, кто вызывает мои функции, и в отношении самих функций, которые обязаны что-то предоставить своим клиентам. Такие проверки дорабатываются в том же артефакте, где находится и остальная часть моего кода. Множество энвайерментов предоставляют средства административной стерилизации этих тестов во время релиза.

Более профессиональный подход - оставить эти асершены в коде даже после релиза, автоматически отправлять отчеты об ошибках от имени конечного пользователя и, возможно, пытаться перезапускать приложение каждый раз, когда подобная проверка провалена. В одном из ранее упомянутых стартапов, мой босс настаивал, чтобы мы такого не делали. Я указал, что отрицательный результат проверок означает: в программе что-то пошло совсем не так, и, скорее всего, данная программа выдаст неправильный результат. Даже мельчайшая ошибка в создаваемом нами продукте, может обойтись клиенту в 5 миллионов долларов дополнительных доработок. На что он ответил: Для компании намного важнее избегать видимости чего-то неправильно сделанного, чем останавливаться еще до получения ошибочных результатов. Я ушел из этой компании. Возможно, сегодня вы один из её клиентов.

Превратите юнит-тесты в утверждения (assertions). Используйте их для обеспечения отказоустойчивости архитектуры высокодоступных систем. Это решает проблему поддержки множества дополнительных программных модулей, которые оценивают ход выполнения (программы) и проверяют правильность (её) поведения; это одна часть юнит-тестирования. Другая - это драйвер, выполняющий сам код: в этом отношении, рассчитывайте, на свои стресс-, интеграционные и системные тесты.

Почти последнее, существуют такие юнит-тесты, которые повторяют системные, интеграционные или другие виды тестов. На заре вычислений, когда компьютеры были медленными, вместо того, чтобы дожидаться запуска системных тестов, юнит-тесты предоставляли разработчику более быструю обратную связь о том, сломало ли их изменение код. Сегодня, когда появились более дешевые и мощные компьютеры, этот аргумент кажется менее убедительным. Каждый раз, внося изменения в свое приложение Scrum Knowsy, я тестирую его на системном уровне. Разработчики должны непрерывно интегрироваться и, так же непрерывно проводить тестирование системы, а не сосредотачиваться на своих юнит-тестах и откладывать интеграцию, даже на час. Так что избавляйтесь от юнит-тестов, которые дублируют то, что уже делают системные тесты. Если системный уровень обходится слишком дорого, создайте наборы интеграционных тестов. Рекс (Rex) считает, что следующим большим скачком в тестировании будет разработка таких юнит, интеграционных и системных тестов, которые устраняют случайные упущения и дублирование.

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

Последнее: однажды я от кого-то услышал оправдание в необходимости создания (особого) юнит-теста, из-за невозможности проверить некий блок кода с помощью внешнего интерфейса инструмента для тестирования. Если учесть, что подобные интерфейсы хорошо спроектированы и способны воспроизводить различное, наблюдаемое в реальном мире, поведение программы, и вы нашли такой код, к которому не достучаться из вашей тестировочной системы, тогда... удалите этот код! Серьезно, размышление о коде с позиции системных тестов может быть отличным способом для обнаружения мертвого кода. Это даже более ценно, чем поиск ненужных тестов.

1.9 Это процесс, глупец или лихорадка зеленого статуса

Возможно, самая серьезная проблема юнит-тестов заключается в том, что они нацелены на исправление ошибок, а не улучшение системы. Я довольно-таки часто встречал программистов, которые, склонив головы, пытались добиться успешного прохождения и, тем самым, повысить зеленые показатели. Тестировщик формулирует гипотезу, но, находясь в своей изолированной среде, он не может с легкостью получить достаточно информации, чтобы ее можно было подтвердить или опровергнуть. Поэтому он или она просто пытаются сделать хотя бы что-то, дабы понять, приближает ли это вас к зеленому статусу - и насколько это вообще возможно.

Тестирование преследует две потенциальные цели. Одна из них - обучение: для большего ознакомления с программой и тем, как она работает. Вторая - использовать его как оракул. Обычно, люди сталкиваются с ошибками во время обращения именно к этой цели: тест - это оракул, а цель - правильный результат. Они упускают из виду тот факт, что цель определяет обширное понимание, а понимание дает ключ к исправлению ошибки.

Именно поэтому необходимо ненадолго отрываться от терминала. Так вы уходите от рефлекса Павлова завязанного на появлении зеленого статуса и, основываясь на тестах, можете начать выстраивать свое общее понимание шаг за шагом. Когда его будет достаточно, у вас появится костяк общей картины. Если и он будет достаточно полным, ошибка станет очевидной.

Системные тесты почти сразу же погружают вас в подобную позицию размышления. Разумеется, вам все еще нужна будет более подробная информация, для этого на помощь приходит отладка (debugging). Отладка - это использование инструментов и устройств, помогающих изолировать ошибку. Отладка - не тестирование. Во-первых, она представляет собой ad-hoc (интуитивную активность) и, во-вторых, выполняется на основании последовательного перехода от ошибки к ошибке. Юнит тесты могут быть полезным инструментом отладки. На собственном опыте я обнаружил, что лучше всего работает комбинация различных инструментов, среди которых наиболее эффективными являются наборы с невалидными данными и доступ к глобальному контексту, включающий все значения данных и случайную трассировку стека.

1.10 Подводим итоги

Вернемся к моему клиенту из компании Sogeti. Вначале, я упоминал его высказывание:

Когда я ежедневно программировал, то создавал действительно тестируемый код, однако почти никогда не писал никакие юнит тесты. При этом я заслуживал признание за свой качественный код и почти безошибочное программное обеспечение. Мне хотелось бы разобраться, ПОЧЕМУ такой вариант работал в моем случае?

Возможно, Ричард - это один из тех редких людей, которые знают, как думать самому, вместо того, чтобы позволять компьютеру думать за него - будь то проектирование системы или дизайн более низкого уровня. Я чаще наблюдаю подобную ситуацию в Восточноевропейских странах, где отсутствие общедоступного компьютерного оборудования заставляло людей мыслить. Просто не хватало компьютеров. Когда я впервые посетил Сербию в 2004 году, студенты из ФОН (факультета информатики) могли получить доступ к компьютеру для выхода в интернет один раз в неделю. Расплата за ошибку высока: если запуск вашего кода не отработает, придется ждать еще неделю, чтобы повторить свою попытку.

К счастью, я вырос именно в такой культуре программирования, мой код записывался на перфокартах, которые отдавались оператору для установки в очередь машины, а затем, через сутки, собирались результаты. Такой формат действительно заставлял вас или же задуматься - или же, потерпеть неудачу. У Ричарда из Sogeti было аналогичное воспитание: у них была неделя на подготовку кода и всего один час на его запуск. Всё должно было делаться правильно с первого раза. В любом случае, обдуманный проект должен оценивать возможные риски, связанные с затратами, и устранять их по одному в каждой итерации, уделяя особое внимание постоянно растущей ценности. Одна из моих любимых циничных цитат: Я считаю, что недели программирования и тестирования могут сэкономить мне часы планирования. Что меня больше всего беспокоит в культуре раннего провала (fail-fast), так это не столько понятие провала, сколько слово раннее. Много лет назад мой босс Нил Халлер мне сказал, что отладка - это не то, что вы делаете, сидя перед своей программой с отладчиком; это то, что вы делаете, откинувшись на спинку стула и глядя в потолок, или обсуждение ошибки с командой. Однако многие, якобы ярые приверженцы эджайл методологий, ставят процессы и JUnit выше людей и взаимодействий.

Лучший пример, услышанный мной в прошлом году, был от моей коллеги, Нэнси Гитинджи (Nancy Githinji), управлявшей вместе со своим мужем IT-компанией в Кении; сейчас они оба работают в Microsoft. Последний раз, посещая свой дом (в прошлом году), она познакомилась с детьми, которые проживают в джунглях и пишут программы. Они могут приезжать раз в месяц в город, чтобы получить доступ к компьютеру и апробировать свой код. Я хочу нанять этих детей!

Мне, как стороннику эджайла (да и просто из принципа), немного больно признавать, что Рекс оказался прав, как, впрочем-то это было и ранее , достаточно красноречиво сказав: В этой культуре раннего провала (fail fast) есть нечто небрежное, она побуждает швырнуть кучу спагетти на стену, особо даже не задумываясь отчасти, из-за чрезмерной уверенности в заниженных рисках, предоставляемых юнит-тестами. Культура раннего провала может хорошо работать при очень высокой дисциплине, подкрепленной здоровым скептицизмом, однако редко можно встретить такое отношение в динамичном IT-бизнесе. Иногда ошибки требуют обдумывания, а последнее требует больше времени, чем результаты, достигаемые ранним провалом. Как только что напомнила моя жена Гертруда: Никто не хочет, чтобы ошибки затягивались на долго

Если вы наймете профессионального менеджера или консультанта по тестированию, он сможет помочь вам разобраться в вопросах более масштабной картины тестирования: интеграционным и системным тестированием, а также подходящим для них инструментарием и процессами. Это важно. Но не забывайте о позиции продакт овнера, бизнес-аналитика или менеджера в Scrum: управление рисками находится в центре их деятельности, и, возможно, именно поэтому Джефф Сазерленд говорит, что заказчик должен разрабатывать (а в лучшем случае и проектировать) системные тесты для использования в качестве входных данных до или во время планирования спринта.

Что касается Интернета: грустно и откровенно страшно, что там так много всего. Много советов, но очень мало из них подкреплено теорией, данными или даже моделью того, почему необходимо довериться тому или иному совету. Хорошее тестирование немыслимо без скептицизма. Относитесь скептически к себе: измеряйте, доказывайте, делайте повторные попытки. Ради всего святого, отнесись ко мне скептически.

Пишите мне свои комментарии на jcoplien@gmail.com с копией Рексу вначале этого письма.

В заключение:

  • Сохраняйте регрессионные тесты до года, большинство из них должны быть тестами системного уровня, а не юнит-тестами.

  • Оставляйте юнит-тесты, проверяющие ключевые алгоритмы, для которых существует широкий, формальный, независимый оракул правильности и, которому можно приписать бизнес-ценность.

  • Исключая предыдущее заявление, если у X есть определенная бизнес-ценность и вы можете протестировать X системным или же юнит-тестом, используйте системный: контекст - это всё.

  • Разрабатывайте тест более тщательно, чем код.

  • Превратите большинство юнит-тестов в утверждения (assertions).

  • Удалите тесты, которые за год ни разу не падали.

  • Тестирование не способно заменить хорошую разработку: высокий показатель падающих тестов говорит о том, что вам следует сократить интервалы разработки, возможно, радикально, а также убедиться, что у вашей архитектуры и дизайна есть зубы.

  • Если вы обнаруживаете, что определенные тестируемые функции тривиальны, еще раз перепроверьте способы стимуляции производительности разработчиков. Одобрение за покрытие или другие бессмысленные метрики могут привести к быстрому разрушению архитектуры.

  • Будьте скромны в отношении способностей тестов. Тесты не улучшают качество: это делают разработчики.

Подробнее..

Кто вы, мистер архитектор?

28.05.2021 12:18:53 | Автор: admin

Привет, меня зовут Алексей, я системный архитектор e-commerce платформы Lamoda, и в этом посте мое представление о том, чем на самом деле занимается ИТ-архитектор, какие вопросы решает в ежедневной работе и за что несет ответственность.

Сцена из фильма "Начало"Сцена из фильма "Начало"

С начала 90-х ИТ-сфера сильно эволюционировала, и роль архитектора (я не буду говорить о профессии, потому что считаю, что как таковой ее у нас нет) развивалась вместе с ней. В 2021-ом перед ним стоит задача куда шире, чем проектирование. Он как архитектор зданий, которому нужно не просто построить условный дом, но и вписать его в окружающий контекст, включить в существующую экосистему. Архитектор принимает решения о важных вещах, выступает катализатором изменений, которые нужны проекту. Он использует нарративы, описывая, как должны выглядеть системы и какие паттерны использовать, чтобы команда могла их одобрить и реализовать.

Чем мы вообще тут занимаемся?

Этот риторический вопрос я иногда задаю своей команде, выдергивая их из привычного потока мыслей и рутинных задач. А занимаемся мы перекладыванием JSON-ов, вот чем! сам же и отвечаю. Выражения лиц меняются с задумчивых на изумленные. Этой провокацией я делаю акцент на том, что происходит в программах, если убрать удобные абстракции привычных фреймворков и библиотек. Именно передача и координация являются нетривиальными задачами, хотя часто разработчики видят сложность совсем в других местах.

Мы разрабатываем приложения, которые выступают отдельными независимыми агентами в мультиагентной среде и обмениваются между собой сообщениями. Агентами могут выступать люди, которые взаимодействуют с приложениями, и другие системы, которые разрабатываются другими отделами, но также обмениваются с нашими приложениями сообщениями. JSON в данном случае выступает как условность, формат и протокол в действительности могут быть какими угодно.

JSON-ы принято обрабатывать: сериализовывать для передачи по сети и десериализовывать в модели для удобства обработки. Модели принято хранить в структурах данных в памяти и на диске для оптимизации операций записи и чтения. Все становится интереснее, когда мы хотим обрабатывать одновременно не один, а два JSON-а. Тут придется озаботиться, чтобы они друг другу не мешали и не нарушали задуманный порядок вещей. Задача перекладывания JSON-ов не является тривиальной, но должна оставаться простой.

Что важнее: доступность или согласованность

Опытные инженеры, среди которых есть и архитекторы, ежедневно работают для того, чтобы круглосуточно на отправленный запрос в нашу систему выдавался верный JSON за приемлемое время. Например, создание заказа. Для e-commerce это критичное место: нужно провалидировать соответствие всех условий, корректно зарезервировать товары и оповестить все заинтересованные стороны о новом успешном заказе. Все это должно происходить за доли секунды и отвечать ожиданиям кастомера.

Между кликом и созданием заказа проходит меньше секунды. Клиент получает уведомление, что всё прошло успешно. И менее, чем через минуту, заказ уже собирается в фулфилмент центре Lamoda.Между кликом и созданием заказа проходит меньше секунды. Клиент получает уведомление, что всё прошло успешно. И менее, чем через минуту, заказ уже собирается в фулфилмент центре Lamoda.

В распределенной системе, когда у нас множество сервисов общаются между собой по сети, нужно выбирать между доступностью и согласованностью. Невозможно обеспечить одновременно и то, и другое, необходим компромисс. Более того, в каждом конкретном случае нужно адекватно выбирать модель согласованности. Да и доступность тоже не ограничивается числом успешных ответов.

Расширим круг действующих лиц. Добавим во взаимодействия команды разработки, которые постоянно вносят изменения, а также бизнес, который требует улучшений и развития.

Еще один пример. В Lamoda, как и в любой крупной e-commerce компании, существует большая система обработки заказов. За более чем 10 лет домен нашей системы вырос и многократно усложнился, как и ее ответственность за все ту же согласованность и доступность. Сама система и ее сложность появилась не просто так и не была результатом проектирования архитектора-маньяка. Ее создали люди, которые приняли сотни решений, а эти решения привели к тем результатам, которые мы видим сейчас. Нужно отдать должное этим людям, так как система выполняет возложенные на нее требования. Проблема только в одном вносить изменения стало крайне проблематично. И решение этой проблемы нельзя назвать тривиальным, но оно должно быть простым. Как и в задаче с обработкой JSON-ов.

Какую задачу решает архитектор

У меня есть задача: построить систему взаимодействующих между собой сервисов. Они должны работать как единое целое, обеспечивать бесперебойную работоспособность большого и сложного e-commerce, который растет и развивается. При этом я должен учитывать десятилетний опыт legacy системы.

Отсутствие понимания, что делают существующие системы и как должны быть спроектированы будущие, является основной причиной того, что инициативы по глобальному рефакторингу слишком затягиваются, слишком дороги и иногда просто терпят неудачу.

Другими словами, моя задача управлять сложностью поддержки и развития. Не уменьшить, не препятствовать росту, а именно управлять. Настроить разработку в домене обработки заказов так, чтобы она была понятной, предсказуемой и стабильной.

Чтобы построить такую работающую систему, нужны технологии и практики, инструменты и компетенции. Опыт внутренней разработки в виде инструментов и инженеров, которые используют и развивают эти инструменты.

Как развивалось понимание архитектуры и обязанностей архитектора

В 1990-х годах архитектура рассматривалась как набор структур, которые представляют собой абстракцию поставляемой системы, необходимых для работы с растущей сложностью типичных программных систем. С этой точки зрения, основные обязанности архитектора заключались в создании (визуальных) моделей системы и их использовании для проверки архитектуры.

В начале 2000-х фокус ответственности сместился: архитекторам нужно было принимать важные решения, чтобы создавать правильные модели (правильные означают, что они удовлетворяют потребности заинтересованных сторон). Если абстракция и структуры описывают то, что создает архитектор, то принятие решения относится к тому, как они создаются.

Примерно в 2010 году, отчасти из-за стремления фокусироваться на ценностях бизнеса, к что и как добавляется для чего. Цель архитектуры улучшить контроль над рисками и стоимостью, поэтому архитектор отвечает за это не только на стадии проектирования, но в процессе реализации своего решения.

Но есть условие, без которого архитектор не может выполнять свои обязанности правильно это понимание контекста, т.е. знание заинтересованных сторон, их потребностей и окружения, в котором нужно реализовать решение.

За что отвечает современный архитектор

Таким образом, обязанности архитектора заключаются в том, чтобы:

  • понимать контекст;

  • принимать решения;

  • создавать модели;

  • валидировать дизайн;

  • реализовывать и поставлять решения.

При этом одни пункты из этого списка влияют на выполнение других. Например:

  • моделирование и принятие решений без понимания контекста приводит к построению неадекватных моделей;

  • моделирование фактически подразумевает принятие решений (о декомпозиции, взаимосвязях);

  • без моделирования и решений нечего валидировать;

  • реализация и поставка не валидированных решений приводит к проблемам.

Т.е. самого по себе выполнения описанных обязанностей недостаточно. Они должны выполняться согласованно между собой.

В зависимости от ситуации, каждому пункту нужно уделять внимание. Пропуск одного из них приводит к антипаттернам роли архитектора, которые все мы периодически наблюдаем по статьям и обсуждениям в сообществе.

Один из ярких примеров The Waterfall Wasteland, когда архитектурная команда занимается дизайном в отрыве от проектной и не вовлекается в ежедневные активности, в результате чего растет время между проектированием и доставкой в продакшен. Совместная работа с проектной командой дает важную обратную связь, без которой легко оказаться в Башне из слоновой кости (The Ivory Tower Architect).

С другой стороны, есть пример The Agile Outback, когда в страхе перед Ivory Tower проектирование считается излишней практикой или даже контрпродуктивной. Вместо этого команда получает фидбэк от реализованных ошибок. Такой подход может быть выгодным в начале, но вскоре приводит к серьезным затруднениям.

Как я решаю свою задачу через призму этих обязанностей

Напомню, что мне и моей команде нужно построить систему взаимодействующих между собой сервисов, которые должны бесперебойно работать как единое целое.

Раз архитектура это принятие решений о важных вещах, то в первую очередь нужно выстроить прочный скелет, на который можно уверенно наращивать все остальное. В нашем случае это согласованность и доступность JSON-ов, о которых я говорил в начале. Ко всему прочему, в распределенных системах невозможно избежать ошибок, поэтому мы проектируем с учетом того, что ошибки будут всегда. И мы должны свести их влияние к минимуму, обеспечить доставку сообщений, целостность локального состояния и достоверность глобального.

Например, однажды мы решили не использовать для обработки отложенных задач по событию внешний брокер. На тот момент существовало ограничение на использование внешних зависимостей, и нужно было по возможности переиспользовать уже имеющиеся зависимости. Мы использовали возможности баз данных. В скором времени разные типы событий и их количество стали расти пропорционально новым фичам. Текущее решение не позволяло масштабироваться должным образом, обработка отложенных задач деградировала по времени исполнения. И в конечном счете это привело к дополнительным затратам на реализацию конвейеров обработки уже далеко после запуска.

Следующий момент разграничение контекстов и маппинг опыта в новые сервисы, ведь мы говорим про работу в домене, который 10 лет развивался и рос. Нельзя с нуля написать новый сервис, поставить его рядом с работающей системой и заменить одно на другое. Только качественное моделирование контекста и его коммуникаций дает ответ, как корректно интегрировать новый сервис в отлаженную систему.

Я большой фанат подходов Domain Driven Design и того, как они развиваются последние несколько лет (DDD Europe, etc). В частности bounded contexts, поскольку именно они помогают определить транзакционные границы сервиса и то, как лучше настроить оркестрацию взаимодействий. Чтобы избежать единой точки отказа, оркестраторы у нас являются частью сервиса и контекста соответственно. Т.е. исполнением саги занимается исключительно ответственный за это сервис внутри контекста, а не отдельный инфраструктурный сервис, который исполняет саги по запросу.

a) исполнение саги локальным оркестратором; b) использование отдельного сервиса оркестратора для исполнения саги; с) вариант хореографии событий;a) исполнение саги локальным оркестратором; b) использование отдельного сервиса оркестратора для исполнения саги; с) вариант хореографии событий;

И если оркестрация находится внутри очерченного контекста, то для распространения изменений между более широкими контекстами используется уже хореография. Т.е. тут речь про обмен доменными событиями через шину данных (events-bus).

Еще один важный пункт валидация и поставка решений. Для его выполнения архитектору нужна команда. Никто не в силах в одиночку адекватно и за разумное время перемаппить контексты, которые формировались целое десятилетие. На самом деле никто такого поведения и не ждет. Но именно архитектор должен задуматься и сформировать рабочие группы с нужными компетенциями и настроить их совместную работу.

Как формируем команды

У нас есть большая система, которая занимается хранением и процессингом комплексных состояний. При этом происходят нетривиальные взаимодействия со множеством других внутренних систем: изменение состояния по запросу, реакция на ответ, координация нескольких запросов и т.п. У нас также есть технологическая база и практики, как все растащить на контексты и наладить взаимную коммуникацию.

Исходя из этого мы формируем команды, которые должны:

  • погрузиться в контекст;

  • иметь экспертизу в легаси-стеке;

  • иметь возможность адаптироваться к новому стеку;

  • принять необходимые технологии и практики;

  • получить экспертизу в домене;

  • принимать решения самостоятельно.

Следующая важная задача найти баланс сложности для команды. В нашем случае сложность лежит в плоскости домена и в плоскости решения. Чтобы инженеры не испытывали когнитивных перегрузок, этот момент прорабатываем изначально и возвращаемся в процессе для тонкой настройки.

Поэтому используем эмпирический подход и формируем команды в среднем из трех инженеров, один из которых техлид. Он выступает дизайнером выделенного контекста и совместно с еще двумя инженерами прорабатывает решение. В команде может присутствовать аналитик, доменный эксперт от бизнеса, продакт. Если команда смогла спроектировать решение и имплементировать его самостоятельно, значит, баланс сложности решения и домена соблюден.

Выстраиваем взаимодействия через единый нарратив

Каждый раз я рассказываю своей команде историю жизненного цикла заказа. Заказ для нас это не монолитная сущность с вариативным набором параметров, а прежде всего последовательность событий, распределенных во времени.

Событийная архитектура определяется наличием описанных событий фактов о состоянии системы, дальнейший их процессинг и построение релевантных проекций-отражений. Она выступает публичной схемой взаимодействия между сервисами.

Каждое событие изменяет состояние одного или более независимых сервисов. Нужно ли транслировать изменение вовне или оно затрагивает исключительно локальное состояние? Можно ли принять решение об изменении, используя локальное состояние, или требуется координация нескольких сервисов? Отвечая на вопросы, мы приходим к пониманию того, как корректно организовать коммуникацию и в какой момент времени фиксировать изменения.

При моделировании наших систем мы перестаем беспокоиться о тяжелом монолитном стейте. Теперь нам нужно распространять изменения, коммуницируя между сервисами по сети. Сместить фокус со структур и их состояний на дизайн взаимодействий и коммуникацию.

Всю работу над проектом, каким бы сложным и громоздким он не казался, должен пронизывать единый нарратив. Каждый техлид контекста-сервиса, каждый инженер в группе должен считывать и понимать смысл, который стоит за всей работой. Как в плоскости решения, так и в плоскости домена. Задача архитектора сформировать целостный и воспроизводимый нарратив для своей команды.


Выполняя свою задачу, архитектор в проекте играет режиссерскую роль. Как командообразующий игрок, он настраивает части, создавая целое. Иначе композиция не получится, а значит, впереди будет непредсказуемость, риски и потери.

Что еще почитать

Подробнее..

Как я пробовал внедрять DDD. Тактические паттерны

10.06.2021 14:04:32 | Автор: admin

Когда я проходил собеседование на текущее место работы, я упомянул о себе такую вещь: мне нравится участвовать в проектах, которые имеют социальные последствия. И талантливые менеджеры, нашли для меня аргументы, почему их проект именно такой и раcсказ меня очень подкупил. И даже больше довольно быстро речь зашла о том, что текущие инструменты устаревают, требуется новое более гибкое решение.


Поначалу мне попали в работу легаси проекты, архитектура которых была Transactional Script или Table Module. Модули требовали рефакторинга, решения тех.долгов, встал вопрос о целесообразности рефакторинга и альтернативных реализаций. Как инженер, я решил, что единственный верный шаг прокачать себя, а затем и команду, теоритически, а потом предпринимать стратегические шаги. Если с TS и TM архитектурами я был хорошо знаком, то шаблон Domain Model был знаком только в самых общих чертах по книге Мартина Фаулера. На фоне общения на конференциях, чтения матёрых книг про рефакторингу, SOLID, Agile, пришло понимание почему именно изучение подобных архитектур оправдано: в Enterprise есть смысл стремиться к максимально адаптируемому к изменениям ПО, а для доменной модели изменения требований стоят несравнимо дешевле в реализации. И меня напрягало, что как раз доменные модели я если и применяю, то понаитию, бессистемно, невежественно. Так началось моё знакомство с предметно-ориентированным проектированием.


В этой первой части, о том какие наработки удалось получить команде.


Тактические паттерны


Для DDD подходит несколько архитектур, которые приблизительно об одном и том же, самые известные: луковая и гексогональная. Полученный реультат, мне больше нравится называть гексогональной архитектурой. У нас выделен ряд обязательных слоёв с разной ответственностью, взаимодействие между слоями как положено по порядку, наружу доступны только абстракции. Далее хотел бы последовательно разобрать эти слои, последовательно описав, что там происходит.


Доменная модель


В этом слое описывается агрегат ограниченного контекста, связанные сущности, перечисления, спецификации, утверждения и т.п. Сам слой неоднородный, состоит из двух частей:


  1. Абстрактная модель. Публичные интерфейсы модели, которые могут быть доступны в других слоях. Сами интерфейсы пишутся так, чтобы они наследовали интерфейсы из нашего Seedworks, что позволяет избежать зоопарка в различных проектах. Абстрактная модель первое с чего начинается любой сервис, т.к. содержит в себе ОБЩЕУПОТРЕБИМЙ ЯЗК.
  2. Реализация модели. Internal реализация агрегата, содержатся необходимые проверки, скрываются фабрики, бизнес-методы, утверждения и т.д.

Реализация агрегата


Команда рассматривала следующие способы реализации агрегата:


  1. Свойства с модифицированными set'ерами, в которых сокрыта логика обнаружения изменений. Код получается неоправданно усложнённым, и не совсем понятно зачем. Мы имели такую реализацию, когда ещё оперировали анемичной моделью (вспоминаю как страшный сон).
  2. Aggregate Snapshots. Механизм делает регулярно или по триггеру снимки агрегата и, если что-то поменялось, регистрируется событие.
  3. Иммутабельные агрегаты, порождающие через бизнес-методы новую версию агрегата. В нашей команде прижился 3й вариант он сулит самые большие перспективы для распределённой системы.

Итак, строение агрегата.


  • Анемичная модель. Анемичных модели у нас две: обычная, и "дефолтная", с пустыми объект-значениями и корнем. При этом анемичная модель условная часть агрегата, существующая только для организации жизненного цикла данных, т.е. в репозитории, фабриках.
    • Идентификаторы. Мы используем составной ключ <guid, long>. Первая часть идентифицирует агрегат, вторая его версию.
    • Корень агрегата. Обязательная сущность, вокруг которой и строится ограниченный контекст. С этим элементом у нас были проблемы, мы ожидали что корень будет иммутабельным на всём протяжении жизненного цикла агрегата, однако, практика показала другое, нежели в книгах. Позже слышал на DDDevotion от Константина Густова то же самое.
    • Объект-значения. Простой иммутабельный класс: конструкторы закрыты, фабричные методы открыты.
  • Бизнес-методы. В нашей реализации составной объект, состоящий из предусловий и постусловий. Результат выполнения операции усложнённая монада Result или сложная структура, возвращающая две анемичных модели и результат операции. Результаты операций на данный момент делим на:
    • Успешные.
    • Ошибочные по бизнес-проверкам, которые могут порождать новую версию агрегата, однако, могут иметь место проблемы с постусловиями.
    • Фатальная проблема, когда предусловие говорит о том, что данная операция не может быть выполнена.

Доменные сервисы


Этот слой ответственен за работу с агрегатом. Состоит двух механизмов:


  • Нотификатор доменных сообщений. Декорирует методы агрегата, реализуя его абстракцию. Данный механизм служит для обработки анемичной модели до вызова новой версии анемичной модели и результата операций. Из этих сообщений формируются доменное событие, которое жёсткой схемой отражено в SeedWorks и реактивно помещается в Шину Доменных Событий.
  • Провайдер агрегатов. Данный механизм инкапсулирует репозитории и фабрики, т.е. служит для получения по идентификатору агрегата и поддержки жизненного цикла агрегата. Задача провайдера построить агрегат из анемичной модели, декорировав его методы нотификатором.

Сначала мы пытались реализовать поиск через провайдер с применением спецификации, однако, отловили проблемы с EF. Эти проблемы застали думать о CQS. Теперь CQRS+ES у нас из коробки, т.е. доменные события отражаются на материализованных представлениях, и, в свою очередь, с их помощью происходит поиск нужного агрегата. В случае если агрегат не найден в мат.представлениях, провайдер соберёт агрегат с пустой моделью это удобно тем, что бизнес-методы всегда остаются внутри агрегатов.


Слой приложения


Слой приложений довольно обыденный. Где-то свои обработчики, где-то на основе MediatR, но, в любом случае, всем командам ограниченного контекста надлежит получить из DI-контейнера провайдер агрегатов, а затем в обработчике из него (что?) определённую версию агрегата, у которой уже вызывается бизнес-метод.


Слой сервисов


С сервисами всё интересно. По умолчанию, мы продолжаем использовать .NET Core Web API, т.е. REST, протокол. Однако, REST это про архитектуры TableModule и нельзя использовать глаголы PUT, DELETE для модифицирования агрегата. Контроллеры наших микросервисов повторяют методы агрегата, используя глагол POST, ведь для стратегических паттернов нужны идемпотентные операции. В итоге получается дисфункция использования контроллеров. Возможно, следует использовать gRPC.


Инфраструктурный слой


В этом слое обычно ряд модулей: маппинги, хранилища команд, материализованные представления.


Хранилище и материализованное представление напрямую не привязано к агрегату. Вместо этого, они прослушивают ШДС(шину доменных событий), асинхронно обрабатывая эти события. Из этого не трудно понять, что ШДС становится для нас механизмом масштабирования.


Как это выглядит в итоге


С одной стороны:


Описание Зарисовка
Гексогональная архитектура микросервисов, декомпозированных по субдомену. image
Команда над доменной сущностью порождает новый объект (версию). image
Сравнение версий агрегата и метаданные команды (источник доменного события). image
Для распространений изменений используется ШДС, что открывает возможности для CQRS и ES. image
Версионирование команд и агрегатов должны помочь избежать блокировок и перепроверок при помощи оптимистичных блокирок. Появлется возможность ветвлений-сессий. image

С другой стороны:


  1. Тактические паттерны освоены костяком команды. Каждый может вести свою команду, распространять подход дальше.
  2. Наработки позволяют начинать работу с контестом даже если единый язык беден, оставив от модели лишь корень. По мере уточнения общеупотребимого языка, модель будет расширяться.
  3. Из всех взятых в работу ограниченных контекстов генерируются доменные события пригодные к использованию в смежных ограниченных контекстах.
  4. Предметная сложность полностью в модели. Даже инфраструктурных сложностей нет как таковых понятная работа по материализованным представлениям, обработчикам слоя приложения. Вместе с решением технической сложности, появляется soft-slills сложности.

После принятия этих практик, формирования некоторой культуры кода, у нас начинает появляться время задумываться о несравненно более интересном стратегических паттернах: о ядре домена, отношениях контекстов, плане их развития.




Данная часть, возможно, не настолько содержательная, как того хотелось бы читателю. Вместе с тем, я считаю что это и не важно, так как подсмотреть следует только цель, а путь нужно пройти с командой. В следующей части хотел бы рассказать о том, как я пытался организовать наш путь.

Подробнее..

Что нам стоит дом построить? (часть 2)

21.06.2021 12:17:59 | Автор: admin

Привет, Хабр. В прошлой статье я рассказал о начальном анализе предметной области и базовом проектировании нашей новой ECM-системы. Теперь я расскажу о первой практической задаче, которую мы решили. А именно - о выборе способа организации структуры хранилища бизнес-атрибутов объектов.

Напомню, что в результате анализа мы пришли к следующей структуре объекта:

Системные и интеграционные поля содержат техническую информацию и редко подвержены модификациям. А вот бизнес-поля - искомые атрибуты объектов - могут и будут различаться для разных типов документов, которых у нас на входе предполагается около 1000. Проанализировав возможность повторного использования полей, мы пришли к выводу, что у разных типов документов может быть не более 15% общих свойств. И при этом всё равно придется скрепя сердце согласиться на неполное соответствие названия и содержания.

Таким образом, есть большое количество сущностей с разнообразным атрибутивным составом, который нужно хранить, тратя как можно меньше ресурсов, и расширять который хотелось бы также с минимальными усилиями.

Какие есть варианты?

Любые концепции хранения, которые будем рассматривать подразумевают использование промышленных баз данных, организацию хранилища в виде таблиц и связей между ними. Сразу можно выделить два наиболее часто используемых принципа организации атрибутивного хранилища.

Первый, условно классический, заключается в том, что структура представляется в виде набора таблиц, в каждой из которых данные сохраняются в колонках, имеющих четко заданный тип данных, размерность, ограничения и проверки. Общие колонки и некий глобальный идентификатор выделяются в одной корневой таблицу - базовую для всех остальных, что позволяет гибко связывать между собой объекты разных типов. На эту таблицу ссылаются расширяющие, которые содержат в себе поля, уникальные для каждой сущности.

Такая структура хранения дает возможность прогнозировать размер хранилища при расширении функционала, позволяет хорошо оптимизировать производительность, она проще для понимания на первых этапах разработки.

Но при этом со временем усложняется ее модификация - чем больше данных и полей для них, тем менее интуитивно становится добавление нового и тем больше ошибок можно допустит - каждый раз придется модифицировать всю цепочку получения данных.

А впоследствии могут начать возникать проблемы с восприятием. Если поля данных добавляются в одну общую таблицу, то растет размер впустую выделяемых ячеек. Если для каждой сущности добавляется отдельная таблица со специфичными для нее данными, то со временем структура хранилища начинает напоминать примерно такой "одуванчик".

Именно с этим я столкнулся на двух прошлых (не самых старых) проектах со схожей декомпозицией объектов, разработчики которых выбрали именно такой путь организации данных.

Также стоит упомянуть про еще один способ организации данных, с которым я сталкивался. Он используется не в пример реже и больше напоминает "велосипед" - назовем его вертикальный. В хранилище создается таблица, которая содержит, условно, название поля, данные в нем, тип данных и связь с объектом. Возможно создание нескольких таких таблиц для разных типов данных или несколько колонок в одной таблице. Далее, при извлечении такие данные трансформируются из списка в плоский набор полей. Понимаете, почему я упомянул про велосипед?

А что у нас?

Второй вариант - это использование специализированных документоориентированных (или документных, как больше нравится) баз данных, реализующих NoSQL-подход к хранению и обработкенеструктурированной или слабоструктурированной информации. Наиболее часто данные хранятся в виде JSON объектов, но с предоставлением производителями СУБД инструментария для доступа к данным внутри этих структур.

У такого подхода, применительно к проектируемой системе, можно выделить несколько плюсов:

  • можем управлять структурой объектов без программирования, в том числе в реальном времени, путем администрирования. Администратор сможет зайти в некий интерфейс справочника полей и задать новые.

  • ограничиваем ситуации случайного воздействия на данные - модифицировать json объект гораздо сложнее, чем данные в колонке.

  • проще описывать объекты в коде - иногда можно вообще не описывать структуру документа в коде, а работать прямо с полями в JSON.

Но есть и минусы:

  • невозможно нативно реализовать проверки данных при размещении в хранилище.

  • валидацию данных придется проводить в коде.

  • невозможно организовать внутреннюю связность подчиненных данных, например, справочников.

Минусы, касающиеся необходимости дополнительной работы с данными в коде не так уж серьезны. Проблема со связностью решается организацией таблицы, в которой к любому объекту можно привязать любой другой. Правила связывания при этом должны также быть описаны в коде обработки данных. Плюсы же дают несравнимо больший потенциал для развития системы.

Чтобы продемонстрировать применимость такого подхода, мы решили сравнить, насколько оба варианта будут отличаться друг от друга по производительности, расширяемости и объемам хранилища.

Делаем прототип

Возьмем гипотезу, что NoSQL-подход для нашей системы применим как минимум не хуже, чем классический. Для ее проверки создадим прототип, в рамках которого реализуем оба подхода. В качестве СУБД возьмем Postgre, который уже давно умеет хорошо работать с JSON полями.

Создадим следующие таблицы:

Для описания объектов в табличном виде:

  • r_objects, базовые данные по объектам: тип, дата создания и ссылка на хранилище атрибутов.

  • r_attributes. атрибуты, во всех возможные колонки для всех объектов. В идеале конечно же хранилища атрибутов для разных объектов лучше разбить по разным таблицам, но для прототипа это не критично.

Для описания объектов в виде JSON:

  • objects. Данные по объектам, где в поле data формата jsonb хранятся искомые атрибуты.

Остальные таблицы - это различные вспомогательные хранилища.

Реализуем в коде 5 разных типов объектов, для каждого из них описав программную структуру, механизм извлечения данных из хранилища и наполнения этими данными самих объектов.

Методика тестирования

Для тестирования обоих подходов хранения данных используем следующие методы:

  • добавление данных по объекту. Критерий успешности: объект с данными появился в хранилище, метод вернул в ответе его идентификатор.

  • обновление данных по объекту. Критерий успешности: данные в хранилище были изменены, метод вернул отметку об успехе. Удаление данных в системе не предусматривается, поэтому удаление объекта является операцией, аналогичной обновлению.

  • извлечение данных по объекту. Критерий успешности: объект с данными возвращен в ответе на запрос. Извлечение объекта происходит по конкретному идентификатору, по критериям поиска и постранично (пагинация).

Генерация запросов будет происходить в 20 параллельных потоков по 50 запросов в каждом потоке. Для того, чтобы тестирование было действительно показательным с точки зрения производительности, предварительно наполним базу 200 млн. объектов.

Тестирование показало следующие результаты:

График по тестированию табличного хранилищаГрафик по тестированию табличного хранилищаГрафик по тестированию NoSQL-хранилищаГрафик по тестированию NoSQL-хранилища

Первая (высокая) часть графика - это получение объектов по случайной странице - пагинация. Здесь в обоих случаях пришлось применить небольшой трюк - так как Postgres не агрегирует точное число строк в таблице, то узнать, сколько всего записей на объеме данных теста простым count - это долго, и для получения количества записей пришлось брать статистику данных по таблице. Также время получения данных на страницах свыше 10000-й неприлично велико, поэтому верхняя планка для получения случайного номера страницы была установлена в 10000. Учитывая специфику нашей системы, получение пагинированных данных не будет частой операцией, и поэтому такое извлечение данных применяется исключительно в целях тестирования.

Вторая (средняя) часть графика - вставка или обновление данных.

Третья (низкая) часть графика - получение данных по случайному идентификатору.

В случае табличной структуры данных - линий на графике больше, потому что с точки зрения программной структуры, получение данных для каждого типа объекта идет по разным методам, тогда как для NoSQL - по одинаковым.

В данном тесте связность данных не учитывается, потому что она организовывается одинаково для обоих случаев.

Результаты тестов на 40000 запросов приведу в виде таблицы:

Табличная

NoSQL

Объем хранилища

74

66

Среднее количество операций в секунду

970

1080

Время тестирования, секунды

42

37

Количество запросов

40000

40000

Отмечу, что в течение месяца после реализации прототипа проводилось еще несколько тестирований по схожей методике, но с разными показателями нагрузки, показавших схожие результаты.

Что получилось?

В итоге, можно с уверенностью сказать, что для нашей системы NoSQL-подход к хранению данных оказался вполне подходящим. Даже с учетом потенциальных минусов, которые можно решить на этапе разработки серверной части, можно сказать, что он дает лучшие показатели по сравнению с табличным хранением. А плюсы в виде возможности настройки структуры объекта на лету, позволяют высвободить ресурсы от рутинного расширения объектов в сторону разработки новой функциональности.

Подробнее..

Реализация чистой архитектуры в микросервисах

25.05.2021 14:04:29 | Автор: admin
Привет хабр!

Сейчас многие проекты используют микросервисную архитектуру. Мы также не стали исключением и вот уже больше 2х лет мы стараемся строить ДБО для юридических лиц в банке с применением микросервисов.



Авторы статьи: ctimas и Alexey_Salaev



Важность архитектуры микросервиса

Наш проект это ДБО для юридических лиц. Много разнообразных процессов под капотом и приятный минималистичный интерфейс. Но так было не всегда. Долгое время мы пользовались решением от подрядчика, но в один прекрасный день было принято решение развивать свой продукт.

Начиная проект, было много обсуждений: какой же подход выбрать? как строить нашу новую систему ДБО? Началось все с обсуждений монолит vs микросервисы: обсуждали возможные используемые языки программирования, спорили про фреймворки (использовать ли spring cloud?, какой протокол выбрать для общения между микросервисами?). Данные вопросы, как правило, имеют какое-то ограниченное количество ответов, и мы просто выбираем конкретные подходы и технологии в зависимости от потребностей и возможностей. А ответ на вопрос Как же писать сами микросервисы? был не совсем простым.

Многие могут сказать А зачем разрабатывать общую концепцию архитектуры самого микросервиса? Есть архитектура предприятия и архитектура проекта, и общий вектор развития. Если поставить задачу команде, она ее выполнит, и микросервис будет написан и он будет выполнять свои задачи. Ведь в этом и есть суть микросервисов независимость. И будут совершенно правы! Но с течением времени команд становятся больше, следовательно растет количество микросервисов и сотрудников, a старожил меньше. Приходят новые разработчики, которым надо погружаться в проект, некоторые разработчики меняют команды. Также команды с течением времени перестают существовать, но их микросервисы продолжают жить, и в некоторых случаях их надо дорабатывать.

Разрабатывая общую концепцию архитектуры микросервиса, мы оставляем себе большой задел на будущее:
быстрое погружение новых разработчиков в проект;
легкая смена команд разработчиками;
универсальность: любой разработчик в рамках своих компетенций сможет в короткие сроки реализовать задачи в незнакомом миркосервисе.

Граница микросервиса

Все, кто работают с микросервисами, прекрасно знают их плюсы и минусы, одним из которых считается возможность быстро заменить старую реализацию на новую. Но насколько мелким должен быть микросервис, чтобы его можно было легко заменить? Где та граница, которая определяет размер микросервиса? Как не сделать мини монолит или наносервис? А еще всегда можно сразу идти в сторону функций, которые выполняют маленькую часть логики и строить бизнес процессы выстраивая очередность вызова таких функций

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

Рассмотрим пример стандартного бизнес процесса для любого банка создание платежного поручения



Можно увидеть, что вроде бы простой запрос клиента является достаточно большим набором операций. Данный сценарий является примерным, некоторые этапы опущены для упрощения, часть этапов происходят на уровне инфраструктурных компонентов и не доходят до основной бизнес-логики в продуктовом сервисе, другая часть операций работает асинхронно. В сухом остатке мы имеем процесс, который в один момент времени может использовать множество соседних сервисов, пользоваться функционалом разных библиотек, реализовывать какую-то логику внутри себя и сохранять данные в разнообразные хранилища.

Взглянув более пристально, можно увидеть, что бизнес-процесс достаточно линеен и в по мере своей работы ему потребуется или получить где-то какие-то данные или как-то обработать те данные, что у него есть, и для этого может потребоваться работа с внешними источниками данных (микросервисы, БД) или логики(библиотеки).



Некоторые микросервисы не подходят под данную концепцию, но количество таких микросервисов в общем процентном соотношении небольшое и составляет около 5%.

Чистая архитектура

Взглянув на разные подходы к организации кода, мы решили попробовать подход чистой архитектуры, организовав код в наших микросервисах в виде слоев.

Касательно самой чистой архитектуры написана не одна книга, есть много статей и в интернетах и на хабре (статья 1, статья 2), не раз обсуждали ее плюсы и минусы.

Популярная диаграмма которую можно найти по этой теме, была нарисована Бобом Мартиным в его книге Чистая архитектура:



Здесь на круговой диаграмме слева в центре видно направление зависимостей между слоями, а скромно в правом углу видно направление потока исполнения.

У данного подхода, как, впрочем, и в любой технологии программирования, имеются плюсы и минусы. Но для нас положительных моментов намного больше, чем отрицательных при использовании данного подхода.

Реализация чистой архитектуры в проекте

Мы перерисовали данную диаграмму, опираясь на наш сценарий.



Естественно, на этой схеме отражается один сценарий. Часто бывает так, что микросервис по одной доменной сущности производит больше операций, но, справедливости ради, многие адаптеры могут использоваться повторно.

Для разделения микросервиса на слои можно использовать разные подходы, но мы выбрали деление на модули на уровне сборщика проекта. Реализация на уровне модулей обеспечивает более легкое визуальное восприятие проекта, а также обеспечивает еще один уровень защиты проектов от неправильного использования архитектурного стиля.

По опыту, мы заметили, что при погружении в проект новому разработчику, достаточно ознакомиться с теоретической частью и он уже может легко и быстро ориентироваться практически в любой микросервисе.

Для сборки наших микросервисов на Java мы используем Gradle, поэтому основные слои сформированы в виде набора его модулей:



Сейчас наш проект состоит из модулей, которые или реализуют контракты или используют их. Чтобы эти модули начали работать и решать задачи, нам нужно реализовать внедрение зависимостей и создать точку входа, которая будет запускать все наше приложение. И тут возникает интересный вопрос: в книге дядюшки Боба Чистая архитектура есть целые главы, которые рассказывают нам про детали, модели и фреймворки, но мы не строим свою архитектуру вокруг фреймворка или вокруг БД, мы используем их как один из компонентов

Когда нам нужно сохранить сущность, мы обращаемся к БД, например, для того, чтобы наш сценарий получил в момент исполнения нужные ему реализации контрактов, мы используем фреймворк, который дает нашей архитектуре DI.

Встречаются задачи, когда нужно реализовать микросервис без БД или мы можем отказаться от DI, потому что задача слишком проста и ее быстрее решить в лоб. И если всю работу с БД мы будем осуществлять в модуле repository, то где же нам использовать фреймворк, чтобы он приготовил нам весь DI? Вариантов не так и много: либо мы добавляем зависимость в каждый модуль нашего приложения, либо постараемся выделить весь DI в виде отдельного модуля.
Мы выбрали подход с отдельным новым модулем и называем его или infrastructure или application.

Правда, при введении такого модуля немного нарушается тот принцип, согласно которому все зависимости мы направляем в центр к доменному слою, т.к. у него должен быть доступ до всех классов в приложении.

Добавить слой инфраструктуры в нашу луковицу в виде какого-то слоя не получится, просто нет для него там места, но тут можно взглянуть на все с другого ракурса, и получается, что у нас есть круг Infrastructure и на нем находится наша слоеная луковица. Для наглядности попробуем немного раздвинуть слои, чтобы было лучше видно:



Добавим новый модуль и посмотрим на дерево зависимостей от слоя инфраструктуры, чтобы увидеть итоговые зависимости между модулями:



Теперь осталось только добавить сам фреймворк DI. Мы у себя в проекте используем Spring, но это не является обязательным, можно взять любой фреймворк, который реализует DI (например micronaut).

Как скомпоновать микросервис и где какая часть кода будет мы уже определились, и стоит взглянуть на бизнес-сценарий еще раз, т.к. там есть еще один интересный момент.



На схеме видно, что проверка права действия может выполняться не в основном сценарии. Это отдельная задача, которая не зависит от того, что будет дальше. Проверку подписи можно было бы вынести в отдельный микросервис, но тут возникает много противоречий при определении границы микросервиса, и мы решили просто добавить еще один слой в нашу архитектуру.

В отдельные слои необходимо выделять этапы, которые могут повторятся в нашем приложении, например проверка подписи. Данная процедура может происходить при создании, изменении или при подписании документа. Многие основные сценарии сначала запускают более мелкие операции, а затем только основной сценарий. Поэтому нам проще выделить более мелкие операции в небольшие сценарии, разбитые по слоям, чтобы их было удобнее повторно использовать.

Такой подход позволяет упростить для понимания бизнес логику, а также со временем сформируется набор мелких бизнес-кирпичиков, которые можно использовать повторно.

Про код адаптеров, контроллеров и репозиториев особо нечего сказать, т.к. они достаточно простые. В адаптерах для другого микросервиса используется сгенерированный клиент из сваггера, спринговый RestTemplate или Grpc клиент. В репозитариях одна из вариаций использования Hibernate или других ORM. Контроллеры будут подчиняться библиотеке, которую вы будете использовать.

Заключение

В данной статье, мы хотели показать, зачем мы строим архитектуру микросервиса, какие подходы используем и как развиваемся. Наш проект молодой и находится только в самом начале своего пути, но уже сейчас мы можем выделить основные моменты его развития с точки зрения архитектуры самого микросервиса.

Мы строим многомодульные микросервисы, где к плюсам можно отнести:
однозадачность, каждый модуль решает только какую-то одну задачу, а значит не всегда нужно переписывать весь микросервис, чаще всего нужно просто дописать или иногда переписать какой-то один модуль, не затрагивая другие;
простота, модули ограничены контекстом и использованием контрактов, что позволяет проще писать небольшие юнит-тесты для проверки логики;
совместимость, в нашем проекте для внешнего Api, используется версионирование внутри микросервиса, каждая версия может быть представлена в виде отдельного модуля, что позволяет легко и просто переходить на новые реализации, сохраняя совместимость со старыми версиями;
стабильность, после того, как все зависимости между модулями уже выстроены, сломать их достаточно сложно, потому что велика вероятность вообще сломать само дерево зависимостей.

Не обошлось, конечно, и без ложки дегтя. Например, самое очевидное связано с тем, что часто каждый модуль работает со своими небольшими моделями. К примеру, контроллере у вас будет описание рестовых моделей, а в репозитории будут сущности БД. В связи с чем приходится много мапить объекты между собой, но такие инструменты как mapstruct позволяют это делать быстро и надежно.

Также к минусам можно отнести то, что нужно постоянно контролировать других разработчиков, потому что есть соблазн сделать меньше работы, чем стоит. Например, переместить фреймворк немного дальше, чем один модуль, но это ведет к размыванию ответственности этого фреймворка во всей архитектуре, что в будущем может негативно сказаться на скорости доработок.

Данный подход к реализации микросервисов подходит для проектов с долгим сроком жизни и проектов со сложным поведением. Так как реализации всей инфраструктуры требует время, но в будущем это окупается стабильностью и быстрыми доработками.
Подробнее..

SQLAlchemy а ведь раньше я презирал ORM

05.06.2021 22:17:23 | Автор: admin

Так вышло, что на заре моей карьеры в IT меня покусал Oracle -- тогда я ещё не знал ни одной ORM, но уже шпарил SQL и знал, насколько огромны возможности БД.

Знакомство с DjangoORM ввело меня в глубокую фрустрацию. Вместо возможностей -- хрена с два, а не составной первичный ключ или оконные функции. Специфические фичи БД проще забыть. Добивало то, что по цене нулевой гибкости мне продавали падение же производительности -- сборка ORM-запроса не бесплатная. Ну и вишенка на торте -- в дополнение к синтаксису SQL надо знать ещё и синтаксис ORM, который этот SQL сгенерирует. Недостатки, которые я купил за дополнительную когнитивную нагрузку -- вот уж где достижение индустрии. Поэтому я всерьёз считал, что без ORM проще, гибче и в разы производительнее -- ведь у вас в руках все возможности БД.

Так вот, эта история с SQLAlchemy -- счастливая история о том, как я заново открыл для себя ORM. В этой статье я расскажу, как я вообще докатился до такой жизни, о некоторых подводных камнях SQLAlchemy, и под конец перейду к тому, что вызвало у меня бурный восторг, которым попытаюсь с вами поделиться.

Опыт и как результат субъективная система взглядов

Я занимался оптимизацией SQL-запросов. Мне удавалось добиться стократного и более уменьшения cost запросов, в основном для Oracle и Firebird. Я проводил исследования, экспериментировал с индексами. Я видел в жизни много схем БД: среди них были как некоторое дерьмо, так и продуманные гибкие и расширяемые инженерные решения.

Этот опыт сформировал у меня систему взглядов касательно БД:

  • ORM не позволяет забыть о проектировании БД, если вы не хотите завтра похоронить проект

  • Переносимость -- миф, а не аргумент:

    • Если ваш проект работает с postgres через ORM, то вы на локальной машине разворачиваете в докере postgres, а не работаете с sqlite

    • Вы часто сталкивались с переходом на другую БД? Не пишите только "Однажды мы решили переехать..." -- это было однажды. Если же это происходит часто или заявленная фича, оправданная разными условиями эксплуатации у разных ваших клиентов -- милости прошу в обсуждения

    • У разных БД свои преимущества и болячки, всё это обусловлено разными структурами данных и разными инженерными решениями. И если при написании приложения мы используем верхний мозг, мы пытаемся избежать этих болячек. Тема глубокая, и рассмотрена в циклах лекций Базы данных для программиста и Транзакции от Владимира Кузнецова

  • Структура таблиц определяется вашими данными, а не ограничениями вашей ORM

Естественно, я ещё и код вне БД писал, и касательно этого кода у меня тоже сформировалась система взглядов:

  • Контроллер должен быть тонким, а лучший код -- это тот код, которого нет. Код ORM -- это часть контроллера. И если код контроллера спрятан в библиотеку, это не значит, что он стал тонким -- он всё равно исполняется

  • Контроллер, выполняющий за один сеанс много обращений к БД -- это очень тонкий лёд

  • Я избегаю повсеместного использования ActiveRecord -- это верный способ как работать с неконсистентными данными, так и незаметно для себя сгенерировать бесконтрольное множество обращений к БД

  • Оптимизация работы с БД сводится к тому, что мы не читаем лишние данные. Есть смысл запросить только интересующий нас список колонок

  • Часть данных фронт всё равно запрашивает при инициализации. Чаще всего это категории. В таких случаях нам достаточно отдать только id

  • Отладка всех новых запросов ORM обязательна. Всегда надо проверять, что там ORM высрала (тут пара сочных примеров), дабы не было круглых глаз. Даже при написании этой статьи у меня был косяк как раз по этому пункту

Идея сокращения по возможности количества выполняемого кода в контроллере приводит меня к тому, что проще всего возиться не с сущностями, а сразу запросить из БД в нужном виде данные, а выхлоп можно сразу отдать сериализатору JSON.

Все вопросы данной статьи происходят из моего опыта и системы взглядов

Они могут и не найти в вас отголоска, и это нормально

Мы разные, и у нас всех разный фокус внимания. Я общался с разными разработчиками. Я видел разные позиции, от "да не всё ли равно, что там происходит? Работает же" до "я художник, у меня справка есть". При этом у некоторых из них были другие сильные стороны. Различие позиций -- это нормально. Невозможно фокусироваться на всех аспектах одновременно.

Мне, например, с большего без разницы, как по итогу фронт визуализирует данные, хотя я как бы фулстэк. Чем я отличаюсь от "да не всё ли равно, что там происходит"? Протокол? Да! Стратегия и оптимизация рендеринга? Да! Упороться в WebGL? Да! А что по итогу на экране -- пофиг.

Знакомство в SQLAlchemy

Первое, что бросилось в глаза -- возможность писать DML-запросы в стиле SQL, но в синтаксисе python:

order_id = bindparam('order_id', required=True)return \    select(        func.count(Product.id).label("product_count"),        func.sum(Product.price).label("order_price"),        Customer.name,    )\    .select_from(Order)\    .join(        Product,        onclause=(Product.id == Order.product_id),    )\    .join(        Customer,        onclause=(Customer.id == Order.customer_id),    )\    .where(        Order.id == order_id,    )\    .group_by(        Order.id,    )\    .order_by(        Product.id.desc(),    )

Этим примером кода я хочу сказать, что ORM не пытается изобрести свои критерии, вместо этого она пытается дать нечто, максимально похожее на SQL. К сожалению, я заменил реальный фрагмент ORM-запроса текущего проекта, ибо NDA. Пример крайне примитивен -- он даже без подзапросов. Кажется, в моём текущем проекте таких запросов единицы.

Естественно, я сразу стал искать, как тут дела с составными первичными ключами -- и они есть! И оконные функции, и CTE, и явный JOIN, и много чего ещё! Для особо тяжёлых случаев можно даже впердолить SQL хинты! Дальнейшее погружение продолжает радовать: я не сталкивался ни с одним вопросом, который решить было невозможно из-за архитектурных ограничений. Правда, некоторые свои вопросы я решал через monkey-patching.

Производительность

Насколько крутым и гибким бы ни было API, краеугольным камнем является вопрос производительности. Сегодня вам может и хватит 10 rps, а завтра вы пытаетесь масштабироваться, и если затык в БД -- поздравляю, вы мертвы.

Производительность query builder в SQLAlchemy оставляет желать лучшего. Благо, это уровень приложения, и тут масштабирование вас спасёт. Но можно ли это как-то обойти? Можно ли как-то нивелировать низкую производительность query builder? Нет, серьёзно, какой смысл тратить мощности ради увеличения энтропии Вселенной?

В принципе, нам на python не привыкать искать обходные пути: например, python непригоден для реализации числодробилок, поэтому вычисления принято выкидывать в сишные либы.

Для SQLAlchemy тоже есть обходные пути, и их сразу два, и оба сводятся к кэшированию по разным стратегиям. Первый -- применение bindparam и lru_cache. Второй предлагает документация -- future_select. Рассмотрим их преимущества и недостатки.

bindparam + lru_cache

Это самое простое и при этом самое производительное решение. Мы покупаем производительность по цене памяти -- просто кэшируем собранный объект запроса, который в себе кэширует отрендеренный запрос. Это выгодно до тех пор, пока нам не грозит комбинаторный взрыв, то есть пока число вариаций запроса находится в разумных пределах. В своём проекте в большинстве представлений я использую именно этот подход. Для удобства я применяю декоратор cached_classmethod, реализующий композицию декораторов classmethod и lru_cache:

from functools import lru_cachedef cached_classmethod(target):    cache = lru_cache(maxsize=None)    cached = cache(target)    cached = classmethod(cached)    return cached

Для статических представлений тут всё понятно -- функция, создающая ORM-запрос не должна принимать параметров. Для динамических представлений можно добавить аргументы функции. Так как lru_cache под капотом использует dict, аргументы должны быть хешируемыми. Я остановился на варианте, когда функция-обработчик запроса генерирует "сводку" запроса и параметры, передаваемые в сгенерированный запрос во время непосредственно исполнения. "Сводка" запроса реализует что-то типа плана ORM-запроса, на основании которой генерируется сам объект запроса -- это хешируемый инстанс frozenset, который в моём примере называется query_params:

class BaseViewMixin:    def build_query_plan(self):        self.query_kwargs = {}        self.query_params = frozenset()    async def main(self):        self.build_query_plan()        query = self.query(self.query_params)        async with BaseModel.session() as session:            respone = await session.execute(                query,                self.query_kwargs,            )            mappings = respone.mappings()        return self.serialize(mappings)
Некоторое пояснение по query_params и query_kwargs

В простейшем случае query_params можно получить, просто преобразовав ключи query_kwargs во frozenset. Обращаю ваше внимание, что это не всегда справедливо: флаги в query_params запросто могут поменять сам SQL-запрос при неизменных query_kwargs.

На всякий случай предупреждаю: не стоит слепо копировать код. Разберитесь с ним, адаптируйте под свой проект. Даже у меня данный код на самом деле выглядит немного иначе, он намеренно упрощён, из него выкинуты некоторые несущественные детали.

Сколько же памяти я заплатил за это? А немного. На все вариации запросов я расходую не более мегабайта.

future_select

В отличие от дубового первого варианта, future_select кэширует куски SQL-запросов, из которых итоговый запрос собирается очень быстро. Всем хорош вариант: и высокая производительность, и низкое потребление памяти. Читать такой код сложно, сопровождать дико:

stmt = lambdas.lambda_stmt(lambda: future_select(Customer))stmt += lambda s: s.where(Customer.id == id_)

Этот вариант я обязательно задействую, когда дело будет пахнуть комбинаторным взрывом.

Наброски фасада, решающего проблему дикого синтаксиса

По идее, future_select через FutureSelectWrapper можно пользоваться почти как старым select, что нивелирует дикий синтаксис:

class FutureSelectWrapper:    def __init__(self, clause):        self.stmt = lambdas.lambda_stmt(            lambda: future_select(clause)        )        def __getattribute__(self, name):        def outer(clause):            def inner(s):                callback = getattr(s, name)                return callback(clause)                        self.stmt += inner            return self        return outer

Я обращаю ваше внимание, что это лишь наброски. Я их ни разу не запускал. Необходимы дополнительные исследования.

Промежуточный вывод: низкую производительность query builder в SQLAlchemy можно нивелировать кэшем запросов. Дикий синтаксис future_select можно спрятать за фасадом.

А ещё я не уделил должного внимания prepared statements. Эти исследования я проведу чуть позже.

Как я открывал для себя ORM заново

Мы добрались главного -- ради этого раздела я писал статью. В этом разделе я поделюсь своими откровениями, посетившими меня в процессе работы.

Модульность

Когда я реализовывал на SQL дикую аналитику, старой болью отозвалось отсутствие модульности и интроспекции. При последующем переносе на ORM у меня уже была возможность выкинуть весь подзапрос поля FROM в отдельную функцию (по факту метод класса), а в последующем эти функции было легко комбинировать и на основании флагов реализовывать паттерн Стратегия, а также исключать дублирование одинакового функционала через наследование.

Собственные типы

Если данные обладают хитрым поведением, или же хитро преобразуются, совершенно очевидно, что их надо выкинуть на уровень модели. Я столкнулся с двумя вопросами: хранение цвета и работа с ENUM. Погнали по порядку.

Создание собственных простых типов рассмотрено в документации:

class ColorType(TypeDecorator):    impl = Integer    cache_ok = True    def process_result_value(self, value, dialect):        if value is None:            return        return color(value)    def process_bind_param(self, value, dialect):        if value is None:            return        value = color(value)        return value.value

Сыр-бор тут только в том, что мне стрельнуло хранить цвета не строками, а интами. Это исключает некорректность данных, но усложняет их сериализацию и десериализацию.

Теперь про ENUM. Меня категорически не устроило, что документация предлагает хранить ENUM в базе в виде VARCHAR. Особенно уникальные целочисленные Enum хотелось хранить интами. Очевидно, объявлять этот тип мы должны, передавая аргументом целевой Enum. Ну раз String при объявлении требует указать длину -- задача, очевидно, уже решена. Штудирование исходников вывело меня на TypeEngine -- и тут вместо примеров использования вас встречает "our source code is open 24/7". Но тут всё просто:

class IntEnumField(TypeEngine):    def __init__(self, target_enum):        self.target_enum = target_enum        self.value2member_map = target_enum._value2member_map_        self.member_map = target_enum._member_map_    def get_dbapi_type(self, dbapi):        return dbapi.NUMBER    def result_processor(self, dialect, coltype):        def process(value):            if value is None:                return            member = self.value2member_map[value]            return member.name        return process    def bind_processor(self, dialect):        def process(value):            if value is None:                return            member = self.member_map[value]            return member.value        return process

Обратите внимание: обе функции -- result_processor и bind_processor -- должны вернуть функцию.

Собственные функции, тайп-хинты и вывод типов

Дальше больше. Я столкнулся со странностями реализации json_arrayagg в mariadb: в случае пустого множества вместо NULL возвращается строка "[NULL]" -- что ни под каким соусом не айс. Как временное решение я накостылил связку из group_concat, coalesce и concat. В принципе неплохо, но:

  1. При вычитывании результата хочется нативного преобразования строки в JSON.

  2. Если делать что-то универсальное, то оказывается, что строки надо экранировать. Благо, есть встроенная функция json_quote. Про которую SQLAlchemy не знает.

  3. А ещё хочется найти workaround-функции в объекте sqlalchemy.func

Оказывается, в SQLAlchemy эти проблемы решаются совсем влёгкую. И если тайп-хинты мне показались просто удобными, то вывод типов поверг меня в восторг: типозависимое поведение можно инкапсулировать в саму функцию, что сгенерирует правильный код на SQL.

Мне заказчик разрешил опубликовать код целого модуля!
from sqlalchemy.sql.functions import GenericFunction, register_functionfrom sqlalchemy.sql import sqltypesfrom sqlalchemy import func, literal_columndef register(target):    name = target.__name__    register_function(name, target)    return target# === Database functions ===class json_quote(GenericFunction):    type = sqltypes.String    inherit_cache = Trueclass json_object(GenericFunction):    type = sqltypes.JSON    inherit_cache = True# === Macro ===empty_string = literal_column("''", type_=sqltypes.String)json_array_open = literal_column("'['", type_=sqltypes.String)json_array_close = literal_column("']'", type_=sqltypes.String)@registerdef json_arrayagg_workaround(clause):    clause_type = clause.type    if isinstance(clause_type, sqltypes.String):        clause = func.json_quote(clause)    clause = func.group_concat(clause)    clause = func.coalesce(clause, empty_string)    return func.concat(        json_array_open,        clause,        json_array_close,        type_=sqltypes.JSON,    )def __json_pairs_iter(clauses):    for clause in clauses:        clause_name = clause.name        clause_name = "'%s'" % clause_name        yield literal_column(clause_name, type_=sqltypes.String)        yield clause@registerdef json_object_wrapper(*clauses):    json_pairs = __json_pairs_iter(clauses)    return func.json_object(*json_pairs)

В рамках эксперимента я также написал функцию json_object_wrapper, которая из переданных полей собирает json, где ключи -- это имена полей. Буду использовать или нет -- ХЗ. Причём тот факт, что эти макроподстановки не просто работают, а даже правильно, меня немного пугает.

Примеры того, что генерирует ORM
SELECT concat(  '[',  coalesce(group_concat(product.tag_id), ''),  ']') AS product_tags
SELECT json_object(  'name', product.name,  'price', product.price) AS product,

PS: Да, в случае json_object_wrapper я изначально допустил ошибку. Я человек простой: вижу константу -- вношу её в код. Что привело к ненужным bindparam на месте ключей этого json_object. Мораль -- держите ORM в ежовых рукавицах. Упустите что-то -- и она вам такого нагенерит! Только literal_column позволяет надёжно захардкодить константу в тело SQL-запроса.

Такие макроподстановки позволяют сгенерировать огромную кучу SQL кода, который будет выполнять логику формирования представлений. И что меня восхищает -- эта куча кода работает эффективно. Ещё интересный момент -- эти макроподстановки позволят прозрачно реализовать паттерн Стратегия -- я надеюсь, поведение json_arrayagg пофиксят в следующих релизах MariaDB, и тогда я смогу своё костылище заменить на связку json_arrayagg+coalesce незаметно для клиентского кода.

Выводы

SQLAlchemy позволяет использовать преимущества наследования и полиморфизма (и даже немного иннкапсуляции. Флеш-рояль, однако) в SQL. При этом она не загоняет вас в рамки задач уровня Hello, World! архитектурными ограничениями, а наоборот даёт вам максимум возможностей.

Субъективно это прорыв. Я обожаю реляционные базочки, и наконец-то я получаю удовольствие от реализации хитрозакрученной аналитики. У меня в руках все преимущества ООП и все возможности SQL.

Подробнее..

Может поменять способ хранения?

24.05.2021 20:06:11 | Автор: admin

Собрались однажды 2 разработчика. И нужно было им новую HTTP API реализовать для игрового магазина. Дошло дело до выбора БД, которую стоит применить в проекте:

- Слушай, а как мы выберем? Реляционную БД использовать или NoSQL. В частности, может нужна документоориентированная?

- Сперва нужно понять какие данные будут в нашей предметной области!

- Да, вот я уже набросал схемку:

Для построения каталога нам потребуются все данные У каждой игры свой каталог, отличать будем по game_id.

- Выглядит так, что у нас уже есть четко описанная структурированная модель, да и опыт использования MySQL есть в компании. Предлагаю использовать его!


И реализовали разработчики успешно свою задумку. Аккуратно нормализовали данные, использовали ORM для работы с БД из приложения. Написали красивый и аккуратный код.

Начали интегрироваться партнеры, и тут начались проблемы Оказывается, API работает очень медленно и совсем не держит нагрузки! Масштабирование дает смехотворный прирост, подключение кэшей всевозможных уровней дает профит, но зависимость запросов от параметров пользователей (сегментирование и атрибутирование) снижают его практически до ноля.

Корнем проблемы являлось большое (очень) количество запросов к БД - на каждое отношение между сущностями ORM генерировала дополнительный запрос.

Разработчики опустили руки и приуныли. Что же делать? Повезло им, ведь нашли они мудрость, увековеченную на бумажном носителе (милая книга с кабанчиком от Клеппмана).

А книга и порекомендовала еще раз внимательно взглянуть на свою предметную область... Ведь отношения между сущностями образуют дерево! А дерево можно уместить в одном документе (или представить с помощью одного JSON), что позволит избежать такого количества запросов.

Вооружились идеей разработчики и просто сериализовали сущность в JSON и сложили в 1 столбец MySQL (+ несколько генерируемых столбцов с индексами, для поиска):

95 перцентиль уменьшилась более чем в 3 раза, пропорционально увеличился и выдаваемый rps одного инстанса приложения.

А ведь просто поменяли формат хранимых данных, никак не затрагивая инфраструктуру и конфигурацию приложения

Какой вывод можно сделать?

  • Подход мы всегда так делали безопаснее в большинстве случаев за счет предыдущего опыта, но может быть неэффективен для новых задач.

  • Важнее понимать какие концепты помогут достичь требуемого качества, а затем выбирать технологии, реализующие их, нежели просто выбирать между технологиями

  • Стереотипное мышление вроде MongoDB для неструктурированных, для структурированных что-нибудь реляционное или Ну Redis небезопасный, поэтому не будем там хранить ничего и т.п. скорее вредно. Зачастую все зависит от реализации приложения и конфигурации сервисов.

Подробнее..

Чистим пхпшный код с помощью DTO

31.05.2021 00:23:47 | Автор: admin

Это моя первая статья, так что ловить камни приготовился.

При написании нового метода или сервиса мы стараемся его максимально абстрагировать от внешних зависимостей, чтобы новый функционал реализовывал только заложенную ему логику. Об этом, собственно, нам и говорит один из принципов SOLID - Принцип единственной ответственности (single responsibility principle).

Я постоянно встречаю код, где если у метода больше двух входных аргументов, добавляется условный (array $args), что влечет за собой реализацию проверки наличия ключа, либо она отсутствует и тогда увеличивается вероятность того, что метод может закрашиться в рантайме.

Возможно, такой подход в PHP сложился исторически, из-за отсутствия строгой типизации и такого себе ООП. Ведь как по мне, то только с 7 версии можно было более-менее реализовать типизацию+ООП, используя strict_types иtype hinting.

Также вызов подобных методов может сопровождаться описанием массива, который мы будем передавать. Или вовсе передается какой-то массив с мусором, а метод просто берет нужные ему ключи. Например, сервис по созданию пользователя:

$userService->create([      'name' => $object->name,      'phone' => $object->phone,      'email' => $object->email,  ]);

Меня такой подход не устраивает, поэтому я уже долгое время придерживаюсь DTOшек. Мы создаем нужный нам DTO объект, который требует метод, и его уже передаем. И тогда внутри метода мы с полной уверенностью можем работать с валидными данными. Мы точно знаем, что у него есть нужные типизированные параметры, нет ничего лишнего, а конечный метод у нас становится гораздо чище.

Но тогда нам же придется инициализировать нужную DTO, а это дополнительные прелюдии перед вызовом метода, и жирнеющий контроллер. Нехорошо. Ведь контроллер, как правило, должен только получить данные, которые прилетели к нему в запросе, привести их к внутренней структуре и отдать нужному методу или сервису.

Собственно, так и появился мой пакет.

Использование ClassTransformer

С его помощью я автоматически привожу данные к нужному мне классу. Рассмотрим более подробно все также на примере создание пользователя. К нам приходит запрос, и данные из него мы должны отправить в метод. В моем случае на Laravel проекте это выглядит вот так:

class UserController extends Controller {public function __construct(      private UserService $userService,) {}public function createUser(CreateUserRequest $request){      $dto = ClassTransformer::transform(CreateUserDTO::class, $request);      $user = $this->userService->create($dto);      return response(UserResources::make($user));}}
class CreateUserDTO{    public string $name;    public string $email;    public string $phone;}

В запросе к нам приходит массив параметров: name, phone и email. Пакет просто смотрит есть ли такие параметры у класса, и, если есть, сохраняет значение. В противном случае просто отсеивает их. На входе transform можно передавать не только массив, это может быть другой object, из которого также будут разобраны нужные параметры.

Но наименования аргументов могут отличаться. Тогда, в созданной нами DTO, мы можем спокойно описать свою реализацию приведения:

class CreateUserDTO{    public string $name;    public string $email;    public string $phone;        public static function transform(mixed $args):CreateUserDTO    {        $dto = new self();        $dto->name = $args['fullName'];        $dto->email = $args['mail'];        $dto->phone = $args['phone'];        return $dto;    }}

Существуют объекты гораздо сложнее, с параметрами определенного класса, либо массивом объектов. Что же с ними? Все просто, указываем параметру в PHPDoc путь к классу и все. В случае массива нужно указать, каких именно объектов этот массив:

class PurchaseDTO{    /** @var array<\DTO\ProductDTO> $products Product list */    public array $products;        /** @var \DTO\UserDTO $user */    public UserDTO $user;}

Указывая параметру класс, вы можете строить более сложные цепочки связей. Пакет рекурсивно будет проходить по всем типизированным параметрам и приведет их к нужному формату.

Путь к классу обязательно должен быть полным, т.к. иначе гораздо сложнее реализовать нахождение класса для инициализации. Или это вообще будет alias и нужно будет парсить файл, а это уже будет оверхед для такого сервиса.

Что мы получаем?

  • Метод сервиса работает с конкретным набором данным

  • Знаем все параметры, которые есть у объекта

  • Можно задать типизацию каждому параметру

  • Вызов метода становится проще, за счет удаления приведения вручную

  • В IDE работают все подсказки.

Аналоги

Увы, я не нашел подобных решений. Отмечу лишь пакет от Spatie - https://github.com/spatie/data-transfer-object

Он пытает решить ту же проблему используя DTO, но их подход заставляет ваш класс наследоваться от их DTO, и самим вызывать инициализацию, передавая в конструктор набор данных. При такой реализации я не вижу особо профита использования, ведь я так же могу сам прописать new DTO() и заполнить параметры.

Я же, в свою очередь, был вдохновлен методом преобразования из NestJS - plainToClass. Такой подход не заставляет реализовывать свои интерфейсы, что позволяет делать преобразования более гибким, и любой набор данных можно привести к любому классу. Хоть массив данных сразу в ORM модель (если прописаны параметры), но лучше так не надо:)

Roadmap

  • Реализовать метод afterTransform, который будет вызываться после инициализации DTO. Это позволит более гибко кастомизировать приведение к классу. В данный момент, если входные ключи отличаются от внутренних DTO, нужно самому описывать метод transform. И если у нас из 20 параметров только у одного отличается ключ, нам придется описать приведение всех 20. А с методом afterTransform мы сможем кастомизировать приведение только нужного нам параметра, а все остальные обработает пакет.

  • Поддержка атрибутов PHP 8

Вот и все.

Подробнее..

Архитектура кода программного обеспечения декорируем стратегией. Рассказ в 10 эпизодах, основанный на реальных событиях

21.05.2021 08:10:50 | Автор: admin

Встречаются два эксперта-консультанта по конструированию программного обеспечения:
- Как написать сложное корпоративное приложение, поддерживать которое будет всегда легко и дешево.
- Могу рассказать...
- Рассказать и я могу! Написать-то как?..

Время чтения: 25 мин.

Разработка корпоративных приложений со сложной бизнес-логикой всегда несет за собой немалые затраты. Причём львиная доля затрат приходится не на саму разработку, а на поддержку кода приложения: добавление нового функционала, поиск и исправление допущенных ошибок, рефакторинг и т.п. Мне как разработчику ПО всегда хотелось найти серебряную пулю для вопросов, возникающих при конструировании кода приложений, как написать потенциально сложное приложение, чтобы его было поддерживать как можно легче и дешевле.

Есть много замечательной доступной литературы с теорией. Найти теорию не проблема; проблема применить найденную теорию на практике. Я являюсь сторонником конструирования исключительно поддерживаемого кода, всегда стараюсь найти новые способствующие этому подходы. К сожалению, часто подобные поиски тщетны. Приходится набираться опыта разработки поддерживаемых приложений самостоятельно, придумывать различные подходы. В этой статье хочу поделиться практическими знаниями о проектировании архитектуры кода программного обеспечения, полученными из опыта.

В самом начале статьи хотел бы заранее попросить прощения у читателя за "много букв". Честно говоря, пробовал выразить свою мысль в более короткой версии статьи всё время казалось, что не хватает важных деталей... Надеюсь, статья будет вам интересна и полезна.

Введение в предметную область

"Красота" поддержки программного обеспечения во многом зависит от того, насколько много времени и сил было уделено самым первым этапам разработки (определение цели, выработка требований, разработка архитектуры и т.д.). Неверно сформулированные требования это тоже ошибка, такая же, как упустить переполнение переменных целочисленных типов данных в коде. Но цена ошибок первых этапов, выявленных на стадии поддержки приложения, непозволительна велика по сравнению с "багами", допущенными в коде при конструировании. Подробнее об этой математике цен ошибок на различных стадиях разработки можно почитать в "Совершенном коде" Стива Макконнелла.

При написании своих приложений с непростой бизнес-логикой у нас в Ozon мы так же сталкиваемся с обозначенной проблемой. Чтобы написать программное обеспечение так, что его будет комфортно и недорого поддерживать, нужно нарабатывать соответствующие техники конструирования кода.

В этой статье я хочу предложить технику написания программ, в основе которой лежит два паттерна проектирования ООП: декоратор и стратегия. Я уверен, что основная часть читающих статью наверняка не раз сталкивалась с этими паттернами (возможно, даже на практике). Но чтобы все чувствовали себя "в своей тарелке", обращусь к определениям из "Паттернов проектирования" Эриха Гаммы, Ричарда Хелма, Ральфа Джонсона и Джона Влиссидеса (Банда четырех, Gang of Four, GoF):

  • Декоратор (Decorator, Wrapper) паттерн проектирования, позволяющий динамически добавлять объекту новые обязанности. Является гибкой альтернативой порождению подклассов с целью расширения функциональности.

  • Стратегия (Strategy, Policy) паттерн проектирования, который определяет семейство алгоритмов, инкапсулирует каждый из них и делает их взаимозаменяемыми. Стратегия позволяет изменять алгоритмы независимо от клиентов, которые ими пользуются.

Подход, который я называю "Декорирование стратегией" и который мы с вами будем рассматривать дальше, предполагает использование этих паттернов совместно друг с другом. Соответственно он не имеет смысла при их использовании порознь.

Декорирование стратегией, на мой взгляд, даёт великую пользу при поддержке приложений на очень большом жизненном цикле программного продукта. Компоненты в коде, написанные с применением данного подхода, соответствуют всем принципам дизайна SOLID из "Чистой архитектуры" Роберта Мартина. Каждый компонент, который мы напишем далее, будет отвечать только за одно действие; после написания нового компонента мы ни разу не модифицируем логику его методов, а лишь будем расширять ее в декорирующих компонентах; в силу паттерна "Декоратор" все расширяемые и расширяющие компоненты соответствуют одному контракту, следовательно их можно заменять друг другом; интерфейсы компонентов не содержат зависимостей, которые не используются; компоненты бизнес-логики ни в коей мере не зависят от деталей.

Я не раз сталкивался в обсуждениях с опытными разработчиками, которые говорят: "А вот всё, что связано с применением принципов SOLID, паттернов ООП на практике это миф!". Любезно обращаясь к скептически настроенным к применению теории разработки в реальных больших корпоративных проектах, хочу сказать: "А вот посмотрим!"

Предлагаю обозначить несколько условностей. Код приводить я буду на языке Golang. Конечно Go не самый лучший язык для демонстрации "фишек" ООП, но, во-первых, так мы покажем, что применение паттернов проектирования не должно страдать от выбора языка программирования, ибо язык это априори инструмент, а во-вторых, для меня данный язык на сей день ближе всего находится к нашим реальным корпоративным проектам, которые успешно работают в продакшне.

Также я хочу выделить очень важные моменты, которые в реальном коде обязательно бы имели место, но так как код в статье имеет демонстрационное назначение, здесь эти моменты будут опускаться, дабы не "перетягивать на себя" ценное внимание читателя:

  • Должная обработка ошибок. В коде мы ограничимся оборачиванием ошибок дополнительным сообщением с помощью пакета "github.com/pkg/errors".

  • Обработка утверждений (assertion). В нашем коде мы полагаемся на тот факт, что все использующиеся указатели инициализированы, интерфейсные аргументы методов заданы и т.д.

  • Комментарии и документирование кода.

  • Всё, что связано, с конкурентным выполнением задач и синхронизацией.

  • Структура файлов и директорий проекта.

  • Стили, линтеры и статический анализ.

  • Покрытие кода тестами.

  • Сквозь методы компонентов рекомендуется с первых этапов разработки "тянуть" context.Context, даже если он в тот момент не будет использоваться. Для упрощения повествования в примерах далее контекст также использоваться не будет.

Перейдём же наконец от скучной теории к занимательной практике!

Пролог. Закладываем фундамент

Последующее повествование я буду вести в ключе начального жизненного цикла разработки приложения с потенциально "сильно загруженной" бизнес-логикой. Чтобы не тратить время читателя, методы некоторых компонентов, не имеющих большого отношения к теме статьи, я буду просто обозначать и оставлять их реализацию под TODO.

Итак, начнём. Здесь мы с вами высококвалифицированные разработчики программных продуктов. К нам приходит наш первый заказчик от бизнеса и говорит что-то вроде: "Нам нужна функциональность обновления такой-то информации о пользователях нашей платформы". Мы обрабатываем требования, продумываем архитектуру и переходим к конструированию кода.

Первое, что нужно сделать определить интерфейс нашего первого компонента службы, которая будет представлять желаемый use-case SavePersonService. Но для этого нам нужно определить объекты нашей предметной области, а именно структуру данных, содержащую информацию о человеке PersonDetails. Создадим в корне проекта пакет app, далее создадим файл app/person.go, и оставим в нём нашу структуру:

// app/person.gotype PersonDetails struct {    Name string    Age  int}

Данный файл завершён, больше мы к нему в этой статье возвращаться не будем. Далее создаем файл app/save-person.go, и определяем в нём интерфейс нашего use-case:

// app/save-person.gotype SavePersonService interface {    SavePerson(id int, details PersonDetails) error}

Оставим сразу рядом с определением интерфейса его первую реализацию компонент noSavePersonService, который ничего не делает в теле интерфейсного метода:

// app/save-person.go// ... предыдущий код ...type noSavePersonService struct{}func (noSavePersonService) SavePerson(_ int, _ PersonDetails) error { return nil }

Поскольку объекты noSavePersonService не содержат состояния, можно гарантировать, что данный "класс" может иметь только один экземпляр. Напоминает паттерн проектирования Синглтон (Singleton ещё его называют Одиночка, но мне это название по ряду причин не нравится). Предоставим глобальную точку доступа к нему. В Golang легче всего это сделать, определив глобальную переменную:

/ app/save-person.go// ... предыдущий код ...var NoSavePersonService = noSavePersonService{}

Зачем мы написали ничего не делающий компонент? С первого взгляда он очень походит на заглушку. Это не совсем так. Далее поймём.

Эпизод 1. Будем знакомы, Декоратор Стратегией

Перейдём непосредственно к реализации бизнес-логики нашей задачи. Нам нужно в конечном счёте иметь хранилище, в котором содержатся данные о пользователях. С точки зрения выбора технологии мы сразу себе представляем, что будем использовать PostgreSQL, но правильно ли завязываться в коде нашей бизнес-логики на конкретную технологию. Вы правы конечно нет. Определить компонент нашего хранилища нам позволит паттерн Репозиторий (Repository). Создадим пакет с реализациями интерфейса нашего use-case save-person внутри app, и в нём создадим файл app/save-person/saving_into_repository.go реализации нашего use-case, которая обновляет данные в репозитории:

// app/save-person/saving_into_repository.gotype PersonRepository interface {    UpdatePerson(id int, details app.PersonDetails) error}type SavePersonIntoRepositoryService struct {    base app.SavePersonService    repo PersonRepository}func WithSavingPersonIntoRepository(base app.SavePersonService, repo PersonRepository) SavePersonIntoRepositoryService {    return SavePersonIntoRepositoryService{base: base, repo: repo}}func (s SavePersonIntoRepositoryService) SavePerson(id int, details app.PersonDetails) error {    err := s.base.SavePerson(id, details)    if err != nil {        return errors.Wrap(err, "save person in base in save person into repository service")    }    err = s.repo.UpdatePerson(id, details)    if err != nil {        return errors.Wrap(err, "update person in repo")    }    return nil}

В коде выше впервые появляется компонент, который выражает наш подход "Декорирование стратегией". Сам компонент представляет собой декоратор, реализующий интерфейс нашего use-case, который оборачивает любой компонент с таким же интерфейсом. В реализации метода изначально вызывается метод декорируемого объекта s.base; после этого происходит вызов стратегии обновления данных о человеке в хранилище s.repo. По сути, весь подход это конструирование компонентов-декораторов, которые содержат два объекта:

  1. Непосредственно декорируемый объект с таким же интерфейсом.

  2. Стратегия, логику которой мы добавляем в довесок к логике декорируемого объекта.

Структурная схема программы, собранной из декораторов стратегий может выглядеть примерно так:

Компонент сам по себе настолько прост, что самое сложное, пожалуй, это определить, когда следует вызывать метод стратегии до или после вызова метода декорируемого объекта или конкурентно с ним.

Напомню, что бизнес-логика не должна содержать ненужные зависимости, зависимости от деталей и т.п. Другими словами, бизнес-логика должна быть "чистая, как слеза". Где тогда должны находиться зависимости от конкретных реализаций, зависимости от используемых технологий? Ответ в файле main.go. Следуя замечаниям Роберта Мартина, можно сделать умозаключение, что код компонентов файла, содержащего точку входа в программу, является самым "грязным" с точки зрения зависимостей от всего. Обозначим в main.go метод, который нам возвращает клиент к базе данных PostgreSQL. И собственно сборку объекта службы нашего use-case и вызов его метода на условных входных данных:

// main.gofunc NewPostgreSQLDatabaseClient(dsn string) savePerson.PersonRepository {    _ = dsn // TODO implement    panic("not implemented")}func run() error {    userService := savePerson.WithSavingPersonIntoRepository(        app.NoSavePersonService,        NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/users?sslmode=disable"))    err := userService.SavePerson(5, app.PersonDetails{        Name: "Mary",        Age:  17,    })    if err != nil {        return errors.Wrap(err, "save user Mary")    }    return nil}

В коде выше мы можем заметить, что в качестве стратегии репозитория выступает обозначенный конкретный компонент клиента к PostgreSQL. В качестве же декорируемого объекта выступает наша "фиктивная" реализация use-case app.NoSavePersonService, которая по сути ничего не делает. Зачем она нужна? Она ничего полезного ведь не делает? Не легче ли просто вызвать метод клиента к базе данных? Спокойно, звёздный час этой реализации сейчас настанет.

Ссылка на полный код эпизода

Эпизод 2. Магия начинается!

Допустим, к нам приходит технический руководитель и ставит перед нами следующую задачу. В коде где-то в другом месте есть функциональность, где данные о пользователе запрашиваются из хранилища. Поскольку запрос данных из базы длится достаточно долго, предлагается данные также кэшировать в памяти. Этот кэш должен инвалидироваться после каждого сохранения пользователя в базу данных. В main.go добавляется функция, которая возвращает компонент управления кэша в памяти:

// main.go// ... предыдущий код ...func NewMemoryCache() savePerson.PersonRepository {    // TODO implement    panic("not implemented")}// ... последующий код ...

Так как этот компонент реализует интерфейс нашего репозитория, мы можем очень изящно выполнить поставленную задачу, не меняя кода бизнес-логики, а всего лишь дополнительно обернуть наш компонент службы в main.go, создав новый, который использует также стратегию сохранения пользователя в кэш:

// main.go// внутри run()userService := savePerson.WithSavingPersonIntoRepository(    savePerson.WithSavingPersonIntoRepository(        app.NoSavePersonService,        NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/users?sslmode=disable")),    NewMemoryCache(),)err := userService.SavePerson(5, app.PersonDetails{    Name: "Mary",    Age:  17,})if err != nil {    return errors.Wrap(err, "save user Mary")}

Всё, что мы тут делаем в итоге два раза декорируем наш "холостой" сервис обновлениями данных в двух репозиториях разного происхождения. Теперь мы можем добавлять обновление данных в новых репозиториях достаточно быстро и комфортно.

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 3. Рефакторинг для здоровья

В предыдущем листинге кода создание сервиса выглядит достаточно громоздко. Нетрудно догадаться, применяя наш подход, мы продолжим и далее всё больше и больше оборачивать компонент, добавляя к логике новые стратегии. Поэтому мы, как опытные разработчики, замечаем эту потенциальную трудность и производим небольшой рефакторинг когда. Нам поможет паттерн Билдер (Builder опять же мне не очень нравится ещё одно его название Строитель). Это будет отдельный компонент, зона ответственности которого предоставить возможность сборки объекта службы нашего use-case. Файл app/save-person/builder.go:

// app/save-person/builder.gotype Builder struct {    service app.SavePersonService}func BuildIdleService() *Builder {    return &Builder{        service: app.NoSavePersonService,    }}func (b Builder) SavePerson(id int, details app.PersonDetails) error {    return b.service.SavePerson(id, details)}

Компонент Builder должен обязательно реализовывать интерфейс службы нашего use-case, так как именно он будет использоваться в конечном счёте. Поэтому мы добавляем метод SavePerson, который вызывает одноименный метод объекта в приватном поле service. Конструктор данного компонента называется BuildIdleService, потому что создаёт объект, который ничего не будет делать при вызове SavePerson (нетрудно заметить инициализацию поля service объектом app.NoSavePersonService). Зачем нам нужен этот бесполезный компонент? Чтобы получить всю истинную пользу, необходимо обогатить его другими методами. Эти методы будут принимать в параметрах стратегию и декорировать ею объект службы в поле service. Но вначале сделаем конструктор WithSavingPersonIntoRepository в app/save-person/saving_into_repository.go приватным, так как для создания службы мы теперь будем использовать только Builder:

// app/save-person/saving_into_repository.go// ... предыдущий код ...func withSavingPersonIntoRepository(base app.SavePersonService, repo PersonRepository) SavePersonIntoRepositoryService {    return SavePersonIntoRepositoryService{base: base, repo: repo}}// ... последующий код ...

Добавляем соответствующий метод для Builder:

// app/save-person/builder.go// ... предыдущий код ...func (b *Builder) WithSavingPersonIntoRepository(repo PersonRepository) *Builder {    b.service = withSavingPersonIntoRepository(b.service, repo)    return b}

И наконец производим рефакторинг в main.go:

// main.go// ... предыдущий код ...userService := savePerson.BuildIdleService().        WithSavingPersonIntoRepository(NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/platform?sslmode=disable")).        WithSavingPersonIntoRepository(NewMemoryCache())// ... последующий код ...

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 4. Больше заказчиков!

Через несколько дней успешной работы нашего кода в продакшне, к нам приходит другой заказчик от бизнеса и просит реализовать функциональность обновления информации о налогоплательщиках в отдельном хранилище. По неким причинам, обсуждение которых находится за пределами данной статьи, мы понимаем, что эту информацию лучше хранить в MongoDB. Клиент к базе добавляется в main.go:

// main.go// ... предыдущий код ...func NewMongoDBClient(dsn string) savePerson.PersonRepository {    _ = dsn // TODO implement    panic("not implemented")}// ... последующий код ...

Воспользуемся нашим билдером и просто добавим новый код в main.go под имеющийся фрагмент с userService:

// main.go// ... предыдущий код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithSavingPersonIntoRepository(NewMemoryCache())err = taxpayerService.SavePerson(1326423, app.PersonDetails{    Name: "Jack",    Age:  37,})if err != nil {    return errors.Wrap(err, "save taxpayer Jack")}

Мы выполнили уже столько поставленных задач, имея небольшой фрагмент кода бизнес-логики. Заметьте, изменения преимущественно вносятся в файл main.go

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 5. Путь в никуда

Проходит ещё время. Заказчик 2 ставит нам такую задачу. Так как все налогоплательщики должны быть совершеннолетними, необходимо в бизнес-логику добавить функциональность проверки возраста человека перед сохранением в хранилище. С этого момента начинаются интересные вещи. Мы можем добавить эту валидацию в метод SavePersonIntoRepositoryService.SavePerson в файле app/save-person/saving_into_repository.go. Но тогда при нескольких декорированиях стратегией сохранения информации в репозиторий эта валидация будет вызываться столько раз, сколько производилось таких декораций. Хотя и все проверки помимо первой никак не влияют на результат напрямую, всё-таки не хочется лишний раз вызывать один и тот же метод.

Мы можем добавить валидацию в Builder.SavePerson. Но есть проблема: заказчику 1 не нужна проверка возраста при сохранении. Придётся добавить if и дополнительный флаг в параметры конструктора, который будет определять необходимость валидации:

// app/save-person/builder.gotype Builder struct {    service           app.SavePersonService    withAgeValidation bool}func BuildIdleService(withAgeValidation bool) *Builder {    return &Builder{        service:           app.NoSavePersonService,        withAgeValidation: withAgeValidation,    }}func (b Builder) SavePerson(id int, details app.PersonDetails) error {    if b.withAgeValidation && details.Age < 18 {        return errors.New("invalid age")    }    return b.service.SavePerson(id, details)}// ... последующий код ...

И тогда в main.go нужно вызывать конструкторы билдера с разными значениями флага withAgeValidation:

// main.go// ... предыдущий код ... userService := savePerson.BuildIdleService(false).// ... код ...taxpayerService := savePerson.BuildIdleService(true).// ... последующий код ...

Теперь код будет работать так, как это от него требуется. Но есть поверье, что если в бизнес-логике появляется if, то положено твердое начало прохождению всех кругов ада при дальнейшей поддержке, будьте уверены.

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 6. Путь истины

В этом эпизоде мы постараемся решить поставленную задачу предыдущего эпизода более изящно. Изменения начнём вносить в код, полученный в результате эпизода 4.

Добавим новый компонент, который будет отвечать за валидацию при сохранении информации о людях:

// app/save-person/validating.gotype PersonValidator interface {    ValidatePerson(details app.PersonDetails) error}type PreValidatePersonService struct {    base      app.SavePersonService    validator PersonValidator}func withPreValidatingPerson(base app.SavePersonService, validator PersonValidator) PreValidatePersonService {    return PreValidatePersonService{base: base, validator: validator}}func (s PreValidatePersonService) SavePerson(id int, details app.PersonDetails) error {    err := s.validator.ValidatePerson(details)    if err != nil {        return errors.Wrap(err, "validate person")    }    err = s.base.SavePerson(id, details)    if err != nil {        return errors.Wrap(err, "save person in base in pre validate person service")    }    return nil}

Опять ничего нового. PreValidatePersonService это очередной декоратор стратегией валидации перед последующим вызовом декорируемого метода.

Добавим соответствующий метод в Builder:

// app/save-person/builder.go// ... предыдущий код ...func (b *Builder) WithPreValidatingPerson(validator PersonValidator) *Builder {    b.service = withPreValidatingPerson(b.service, validator)    return b}

Добавление каждого нового декоратора стратегией требует добавление нового метода в наш билдер.

Добавим реализацию валидатора, проверяющую возраст человека:

// main.go// ... предыдущий код ...type personAgeValidator struct{}func (personAgeValidator) ValidatePerson(details app.PersonDetails) error {    if details.Age < 18 {        return errors.New("invalid age")    }    return nil}var PersonAgeValidator = personAgeValidator{}// ... последующий код ...

Так как personAgeValidator не имеет состояния, можем сделать для компонента единую точку доступа PersonAgeValidator. Далее просто вызываем новый метод в main.go только для taxpayerService:

// main.go// ... предыдущий код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithSavingPersonIntoRepository(NewMemoryCache()).    WithPreValidatingPerson(PersonAgeValidator)// ... последующий код ...

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 7. А ну-ка закрепим

Уверен, к данному эпизоду вы поняли смысл подхода "Декорирование стратегией". Чтобы закрепить, давайте добавим ещё один такой компонент. Представим, технический руководитель требует от нас покрыть метриками время выполнения сохранения данных в хранилище. Мы могли бы замерить это время, просто добавив пару строчек кода в SavePersonIntoRepositoryService. Но как бы не так! Мы же не изменяем уже работающий в продакшне код, а можем его только расширить. Давайте же так и сделаем. Добавим новый декоратор стратегией отправки метрики времени:

// app/save-person/sending_metric.gotype MetricSender interface {    SendDurationMetric(metricName string, d time.Duration)}type SendMetricService struct {    base         app.SavePersonService    metricSender MetricSender    metricName   string}func withMetricSending(base app.SavePersonService, metricSender MetricSender, metricName string) SendMetricService {    return SendMetricService{base: base, metricSender: metricSender, metricName: metricName}}func (s SendMetricService) SavePerson(id int, details app.PersonDetails) error {    startTime := time.Now()    err := s.base.SavePerson(id, details)    s.metricSender.SendDurationMetric(s.metricName, time.Since(startTime))    if err != nil {        return errors.Wrap(err, "save person in base in sending metric service")    }    return nil}

Помимо компонента стратегии, отправляющего метрики, мы в конструкторе также передаем название метрики, которую мы хотим замерять. Добавляем новый метод в Builder:

// app/save-person/builder.go// ... предыдущий код ...func (b *Builder) WithMetricSending(metricSender MetricSender, metricName string) *Builder {    b.service = withMetricSending(b.service, metricSender, metricName)    return b}

И наконец обозначаем в main.go функцию, возвращающую savePerson.MetricSender и добавляем вызов нового метода Builder в сборку наших сервисов:

// main.go// ... предыдущий код ...func MetricSender() savePerson.MetricSender {    // TODO implement    panic("not implemented")}// ... код ...userService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/platform?sslmode=disable")).    WithMetricSending(MetricSender(), "save-into-postgresql-duration").    WithSavingPersonIntoRepository(NewMemoryCache())// ... код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithMetricSending(MetricSender(), "save-into-mongodb-duration").    WithSavingPersonIntoRepository(NewMemoryCache()).    WithPreValidatingPerson(PersonAgeValidator)// ... последующий код ...

Обратите внимание, что новые методы мы ставим в цепочку вызовов там, где мы хотим производить замер.

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 8. Результаты ясновидения

Проходит время. Заказчик 2 ставит новую задачу. Он желает знать, как долго выполняется сохранение данных о налогоплательщике, но с небольшой оговоркой: учитывать нужно всё, кроме валидации. Похоже на замер времени, который мы недавно реализовали для своих целей, не правда ли? Чтобы решить задачу, всё что нам требуется это добавить вызов метода для новой метрики в main.go:

// main.go// ... предыдущий код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithMetricSending(MetricSender(), "save-into-mongodb-duration").    WithSavingPersonIntoRepository(NewMemoryCache()).    WithMetricSending(MetricSender(), "save-taxpayer-duration").    WithPreValidatingPerson(PersonAgeValidator)

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 9. Укрощение капризов

Мы вот только недавно произвели релиз последней задачи от заказчика 2, но он захотел изменить начальные требования. Такие изменения часто возникают на стороне заказчика, которые заставляют нас "перелопатить" весь код. Знакомо? На этот раз заказчик желает отказаться от оговорки из предыдущего эпизода и производить замер полного цикла сохранения данных о налогоплательщике вместе с валидацией. Если бы мы конструировали нашу бизнес-логику в виде сценария транзакции (transaction script), то это повлекло бы за собой непосредственное вмешательство в тело метода, copy-paste кода, что требует приложить силы, в том числе в процессе ревью, тестирования и т.п. В нашем же случае нам достаточно просто подвинуть вызов метода WithMetricSending в цепочке методов создания объекта службы в main.go:

// main.go// ... предыдущий код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithMetricSending(MetricSender(), "save-into-mongodb-duration").    WithSavingPersonIntoRepository(NewMemoryCache()).    WithPreValidatingPerson(PersonAgeValidator).    WithMetricSending(MetricSender(), "save-taxpayer-duration")

В коде выше мы поменяли местами второй WithMetricSending и WithPreValidatingPerson.

Задача от заказчика выглядит надуманной. Но напомню, что цель статьи не придумать качественные задачи заказчиков, а продемонстрировать пользу архитектуры кода при использовании подхода "Декорирование стратегией".

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 10. Взгляд в будущее

Этот заключительный эпизод всего лишь подчеркивает потенциал дальнейших доработок логики данного кода. Что ещё может пожелать заказчик от бизнеса или с технической стороны? Вариантов более чем достаточно. Может потребоваться функциональность отправки асинхронных событий об изменении информации о человеке (полезно при ведении журнала аудита, коммуникации с другими сервисами и т.д.). Может понадобиться введение механизма гомогенных и даже гетерогенных транзакций. Возможно, потребуется добавить запрос данных к соседнему микросервису. По техническим соображениям возможно будет нужен предохранитель (circuit-breaker) для таких запросов к другим сервисам. Наверняка нужно будет добавлять механизм трассировки (tracing). И многое-многое другое.

Каждой новой функциональности в нашей архитектуре будет соответствовать свой компонент декоратора со стратегией. Каждый компонент мал и самодостаточен, легко расширяется и, в целом, поддерживается.

Эпилог. Подводим итоги

Вышеописанный подход конструирования программного обеспечения представляет набор моих субъективных взглядов. Я пришёл к нему однажды, был приятно воодушевлён его пользой. Велика вероятность, что вы тоже используете такой подход, называя его как-то иначе. Возможно, вы к нему тоже приходили, но он вам не понравился. Ни в коем случае не хочу сказать, что данный подход является единственным истинным при разработке.

Есть ли у подхода минусы? Однозначно есть. Подход нежелательно использовать, если, например, мы пишем код, который планируем использовать единожды, или пишем некий скрипт, время на введение предметной модели в который будет потрачено неоправданно.

Но для больших корпоративных приложений наличие подобного подхода просто желательно-обязательно. Если продукт подразумевает длительную поддержку (обычно это условие присутствует всегда), то объектная модель приложения будет иметь значительное преимущество над незамысловатым "полотном" кода сценария транзакции. Я приведу далее график, в основе которого лежит график из "Шаблонов корпоративных приложений" Мартина Фаулера.

Что есть что на этом графике? Почему на осях нет чисел? Всё потому что график абстрактный. Он отражает качественный смысл содержимого, не количественный. По горизонтальной оси у нас время, прошедшее с момента начала разработки продукта. Или если желаете, количество добавлений новой функциональности в изначально разработанный продукт. Меру по вертикальной оси тоже можно выразить различными способами. Это может быть цена добавления новой строчки кода функционала в денежном эквиваленте; может быть время добавления новой функциональности; может быть количество потраченных нервных клеток разработчиком, ревьювером или тестировщиком. Красный график демонстрирует зависимость этих величин для подхода разработки, который называется сценарием транзакции (Transaction Script) последовательно следующие друг за другом инструкции. Синий график показывает эту зависимость для подхода модели предметной области (Domain Model).

Сравнивая эти зависимости, мы можем увидеть, что сценарий транзакции выигрывает у модели предметной области на первых стадиях разработки продукта. Да, это на самом деле так: когда продукт мал и зелен, вносить новую функциональность можно с ходу, не задумываясь о деталях. Но однозначно настанет время, когда стоимость добавления новых возможностей в "полотно" кода "возносится" резко вверх.

Сложность внесения нового функционала при использовании модели предметной области, конечно, тоже растёт, но линейно. Это говорит о том, что на поздних стадиях разработки продукт, сделанный при использовании подходов модели предметной области, будет обходиться гораздо дешевле, чем проект, сделанный при использовании более простых подходов "в лоб".

Содержание статьи изложено на основе моего субъективного понимания. Любые замечания с удовольствием готов обсуждать в комментариях. Использовать "Декорирование стратегией" или нет личное решение каждого. Главное, я считаю, нужно помнить о том, что мы как разработчики должны в первую очередь уделять внимание не бизнесу, не пользователю, не выделенным машинным ресурсам, а нашему коллеге такому же разработчику, который через несколько лет будет добавлять в наш код новую функциональность.

Литература

  1. Макконнелл С. Совершенный код. Мастер-класс., 2020.

  2. Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы объектно-ориентированного проектирования. Паттерны проектирования., 2020.

  3. Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения., 2020

  4. Фаулер, Мартин. Шаблоны корпоративных приложений., 2020.

Подробнее..

Как мы создали собственную систему распределения жидкостей

19.06.2021 22:22:36 | Автор: admin

Вы узнаете:

  • зачем вообще нам это понадобилось

  • можно ли работать с отечественными производителями без страха и упрека

  • почему не стоит экономить на деталях для изделий (спойлер: если у вас железные нервы, то можно)

  • как не скатиться в отчаянье, а научиться управлять рисками.

Разработчик это звучит гордо

Как думают многие: придумываешь идею, разрабатываешь продукт, зарабатываешь миллионы. Как на самом деле: чтобы собрать робота, нужно думать как человек. Мыслить как человек с хорошо отлаженными процессами в голове, осознающий риски, считающий время, деньги и нервы как свои, так и команды, ну и конечно, заказчика.

Для чего нужен гистологический процессор

В доковидные времена, а именно в начале 2018-го, наша команда запустила инжиниринговый стартап Nextelligence и стартовала в проекте по созданию вакуумного процессора для гистологических лабораторий.

Если коротко, то это прибор, в который помещают специальные перфорированные пластиковые кассеты с материалами человеческого происхождения (живого или уже не очень), чтобы впоследствии морфолог (гистолог/патологоанатом) мог посмотреть под микроскопом микропрепарат и сделать гистологическое заключение.

Первый и самый затратный по времени из 5 этапов гистологической пробоподготовки осуществить замещение присутствующей в материале воды на парафиновую смесь. Процесс замещения воды парафином называется гистологической проводкой или просто проводкой. Этот процесс и нужно было автоматизировать, то есть создать прибор, который в автоматическом режиме последовательно заливает исследуемый материал разными реагентами. При этом важно было свести риск утраты исследуемого материала к минимуму по статистике в лабораториях теряется практически каждая пятая проба.

Проработка концепта

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

Естественно, заказчик хотел сделать изделие надёжным, так что мы сразу отмели вариант использования острова с соленоидными клапанами, выполняющего роль распределителя реактивов, просвет которых быстро забивается. Использование шаровых клапанов было также нежелательно из-за их громоздкости и неповоротливости, поэтому было решено пойти по своему особому пути и спроектировать компактный многопозиционный ротационный клапан.

Примерно концепт ротационного клапана можно описать следующим образом: в центре клапана лежат 2 специальных диска, один из которых, стационарный, имеет отверстия по периметру и в центре (рис. 1), а второй, ротационный канавку от центра к краю (рис. 2).

Рисунок 1. Стационарный дискРисунок 1. Стационарный дискРисунок 2. Ротационный дискРисунок 2. Ротационный диск

Сложности, отчаянье и надежда из Дюссельдорфа

При проработке концепта мы рассматривали несколько вариантов материала для этих дисков. Самые простые в исполнении, пластик и металл, имели свои недостатки. Пластик, даже фторопласт, царапался при попадании между дисками кристаллов соли из забуференного формалина и со временем сборка теряла герметичность. Металл имел ту же проблему, что и пластик, а также значительнее последнего расширялся при прогреве, да еще 2 прецизионно отполированных металлических диска, прижимаемых друг к другу пружиной, быстро прикипали друг к другу, даже при наличии между ними смазки.

Когда руки уже почти опустились, а проблема начала казаться нерешаемой, мне довелось съездить на выставку Medica/Compamed в Дюссельдорфе и подсмотреть у одного всемирно известного производителя инженерных решений для медицины и промышленности идею керамического ротационного клапана. К сожалению, у самого производителя не было в ассортименте таких клапанов с нужными нам характеристиками, поэтому от идеи разработать свой клапан я не отказался.

Горе-керамисты, убитое время и почти хэппи энд

На удивление легко оказалось найти в России производителя технической керамики, который взялся изготовить диски клапана по нашему заданию из оксида алюминия (искусственный корунд) методом шликерного литья. На выходе должно было получиться изделие твердостью 9 из 10 возможных по шкале Мооса, что значительно тверже практически любого солевого кристалла, имеющее низкий коэффициент температурного расширения, высокую химическую стойкость и минимальный риск прикипания двух компонентов друг к другу.

Заявленная стоимость и сроки производства необходимых нам изделий внушали осторожный оптимизм. Опыт взаимодействия с российскими производителями призывал не радоваться раньше времени и не зря.

Спустя заявленные в договоре на изготовление 3 месяца, началась борьба бобра с ослом. Производитель начал кормить нас завтраками, ссылаться на форс-мажоры, запой главного технолога, поломку фрезы для выточки пресс-формы и т.д. В таком режиме мы прожили еще около месяца и, наконец, мне на почту пришло долгожданное письмо: Изделия готовы, можете забирать. Моя радость была яркой, но длилась лишь до тех пор, пока транспортная компания не привезла мне груз. Диски оказались круглыми с выдержанными внешними размерами, но на этом их достоинства заканчивались. Практически все обнизки под посадку О-колец были в разной степени кривыми, в некоторых расхождение с чертежом было далеко за гранью приличия, про выдерживание допусков речи не шло вообще. Создалось впечатление, что на предоставленные нами чертежи подрядчик смотрел только в начале работы, да и то, издалека.

После недолгого, но очень содержательного диалога с керамистами они удалились читать чертежи и переделывать. Еще на месяц

Не вдаваясь в подробности, надо признать, что второй подход к снаряду нашему подрядчику удался гораздо лучше. За некоторым исключением изделия стали больше походить на то, что мы задумывали. Наступила долгожданная пора утомительных испытаний.

Рисунок 3. Диски ротационного клапана вживуюРисунок 3. Диски ротационного клапана вживую

Заключительный этап челленджа испытания

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

Рисунок 4. Сборка ротационного клапана в стальном ложе со внешним окружениемРисунок 4. Сборка ротационного клапана в стальном ложе со внешним окружением

Беда пришла откуда не ждали. В изобилии доступные на российском рынке О-кольца из NBR и FKM/FPM/Viton, уплотняющие стационарный диск ротационного клапана, приказали долго жить. Первые после недели работы, вторые после трёх. Оказалось, что ксилол, перепады температур и механическая нагрузка делают даже из хваленого Viton труху за каких-то несколько недель.

Выход нашли достаточно быстро. Оказалось, что во всем цивилизованном мире уже давно для таких сложных задач используют уплотнительные кольца из перфторкаучука (другие названия FFKM, Kalrez), которые незначительно дороже упомянутых выше, но отменно работают в самых жестких условиях как-раз то, что нам нужно.

Оставлю для хоррор-публицистики проблемы с китайскими мотор-редукторами и уральскими патронными ТЭНами, которые у нас всплыли по дороге. Скажу лишь, что работая над данным проектом пообещал сам себе более не экономить на критически важных компонентах даже, если очень хочется.

Резюме

В качестве резюме этой хардкор-разработки выделяю несколько тезисов-рекомендаций:

  1. Не экономьте на компонентах. Чем в более сложных условиях должно работать ваше изделие, тем меньше должно быть ваше желание порезать косты. Дёшево = плохо, чудес не бывает.

  2. Работать с отечественными производителями сложно, но можно. Надо только придирчиво подходить к выбору подрядчика и стараться контролировать его на разных стадиях выполнения вашего заказа, а также заставлять почаще читать ваши чертежи.

  3. Если вы решились на п. 2, пропишите в контракте побольше штрафов, так у подрядчика будет больше стимулов сдать вам то, что вы хотите когда вы хотите.

  4. В общем, управляйте рисками. ISO 14971 вам в помощь.

Подробнее..

Категории

Последние комментарии

  • Имя: Билал
    04.12.2024 | 19:28
  • Имя: Murshin
    13.06.2024 | 14:01
    Нейросеть-это мозг вселенной.Если к ней подключиться,то можно получить все знания,накопленные Вселенной,но этому препятствуют аннуннаки.Аннуннаки нас от неё отгородили,установив в головах барьер. Подр Подробнее..
  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2025, personeltest.ru