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

В этой проблеме поможет разобраться Роман Хаимов. Он занимается разработкой ПО в практике Frontend “Рексофт”, компании, которая более 30 лет занимается разработкой ПО на заказ для российских и международных компаний. Сегодня Роман расскажет про главную задачу (или роль) любого структурного элемента в приложении. Вместе с ним мы посмотрим на простых примерах, на что влияет архитектура проекта, а также поймем, что может изменить ситуацию к лучшему.

Статья подготовлена на основе доклада Романа на конференции FrontendConf 2022.

Определение

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

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

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

Ценности ПО

Любое ПО можно разделить на два элемента:

  1. Поведение — всё то, что формирует стоимость программы: причину её существования. Данная ценность выражается в наблюдаемом поведении. При этом не имеет значения то, каким образом оно реализовано: с использованием каких языков программирования, парадигм и прочего. Важно то, что любое нарушение в наблюдаемом поведении, отклонение или противоречие требованиям выражается в виде всем известных артефактов, а именно, дефектах, например, в Jira или YouTrack;

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

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

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

Признаки нарушений

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

Жёсткость

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

Далее к нам приходит работодатель или заказчик и говорит, что нужно добавить на странице редактирования пользователя кнопку УДАЛЕНИЕ, которая будет доступна пользователям только с ролью администратора. Первое, что может прийти в голову, это добавить новый обязательный атрибут guard, который будет внутри себя инкапсулировать данную логику.

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

Причём изменятся не только клиенты, где нужна была эта кнопка УДАЛИТЬ, но и те страницы, которые никакого отношения к первоначальному требованию не имели. Эта ситуация и называется жёсткостью. Похоже на неподатливый каркас, который невозможно изменить — не разрушив основание.

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

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

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

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

Но здесь зарыта другая, более серьёзная, проблема: хрупкость.

Хрупкость

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

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

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

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

  1. Принцип открытости/закрытости;

  2. Принцип инверсии зависимостей.

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

Хрупкость можно решить композицией.

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

Другой способ решения — тесты.

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

Неподвижность

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

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

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

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

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

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

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

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

Избыточная сложность

Предположим, что мы пошли по пути преждевременных оптимизаций и добавили createPlayer заранее.

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

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

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

Непрозрачность

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

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

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

Вязкость

Наверное, это самый частый признак. Его проще всего посмотреть на примере.

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

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

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

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

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

Избыточное дублирование

Дублирование — одинаковый код. Рассмотрим пример:

Два разработчика сделали две страницы: создание пользователя и создание администратора. На той и на другой странице используется выпадающий оффлайновый список, в котором можно что-то искать. Инженер видит, что в обеих страницах есть списки, офлайн и поиск. Он вспоминает догму DRY (don’t repeat yourself) и пытается избавиться от дублирования, создавая один общий компонент.

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

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

Можно пойти «трушным» путём, попытаться изъять разность и выделить пересечение в отдельный компонент. От select потребуется, чтобы он был достаточно подвижным, чтобы мы могли его переиспользовать в требуемых контекстах.

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

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

Возникает вопрос: что же такое истинное дублирование?

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

  1. Обладают одинаковым наблюдаемым поведением;

  2. Изменяются по одним и тем же причинам;

  3. Изменяются с одной и той же скоростью.

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

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

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

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

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

Вывод

Роль архитектуры определяется перечисленными признаками.

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

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

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

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

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

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

Комментарии (1)


  1. visirok
    04.07.2023 19:04
    +2

    Приветствую появление статьи на эту тему.

    Хотя не со всеми тезисами согласен. Например, аспект описания поведения системы явно недооценён.

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