Правильный подход к модульной архитектуре ПО

Эта статья строится на двух простых идеях:

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

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

Дерево проектных решений

Мы можем представить пространство проектирования в виде дерева.

Решения, находящиеся в "листьях" дерева, касаются частностей, которые не влияют на остальные части проекта:

  • Какую кодировку ввода мы используем?
  • Какого цвета будет кнопка?
  • Какова продолжительность тайм-аута?

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

  • Сколько памяти мы можем использовать?
  • Какие задачи мы не будем решать?
  • Сколько контроля мы дадим пользователям?

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

В данном примере мы создали продукт, определённый нашими ответами на вопросы a-c-h-m-t. Примечательно, что решение, принятое в вопросе a, направило нас по пути к c, а это значит, что оно также исключило потенциальные решения, требующие ответа на вопрос b.

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

Разные конфигурации = разные продукты

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

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

Фокус на листьях

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

Какой продукт может из этого получиться? Никакого. Эти решения находятся на совершенно разных ветвях, то есть исключают друг друга и нет способа создать продукт, который удовлетворял бы им всем. Мы можем не замечать этого до тех пор, пока кто-то не заставит нас принять решение, например, по вопросу b, и тогда мы поймём, что что бы мы ни выбрали, мы исключаем для себя либо k, либо g.

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

Возможен и более позитивный сценарий, в котором мы случайно (или не совсем осознанно) принимаем согласованные решения на уровне листьев, например, вот так:

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

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

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

Начинать с листьев — проще

Мне кажется, что мы склонны начинать с листьев потому что:

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

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

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

Самые ранние этапы проекта — не время принимать низкоуровневые решения. Оставьте детали на потом.

Требования меняются, ветви остаются

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

Требования меняются!

Внезапно выясняется, что пользователи на самом деле хотели продукт, который получается в результате решений a-c-h-n-q. Мы возвращаемся назад, выкидываем часть кода, пишем новый код, рефакторим, и в итоге получаем новый набор проектных решений.

И только теперь мы узнаём, что пользователи, возможно, на самом деле хотят a-c-h-m-p!

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

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

Некоторые решения изменить не получится

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

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

Парадокс высокоуровневых решений

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

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

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

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

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

Семейство программ

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

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

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

Осознав это, мы начинаем мыслить модулями.

Модули позволяют отложить решение

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

Если мы спрячем решение, принятое в вопросе h, в нашем оригинальном продукте a-c-(h)-m-t, то сначала мы получим все тот же продукт.

Но потом, когда мы поймём, что вместо этого нам может понадобиться продукт a-c-(h)-n-q, нам не придётся возвращаться назад, выбрасывать старые решения и переписывать код. Эта ветка всё равно будет работать с общим интерфейсом, который мы сделали, чтобы скрыть решение h. Мы можем достраивать недостающие части, не изменяя то, что уже существует.

Это еще важнее для высокоуровневых решений. Если бы мы спрятали решение c в модуль, мы могли бы создать программу a-(c)-g-o, не меняя ничего на другой ветке, потому что ни одно из решений g, h или любых последующих не потеряет актальности, ведь модуль c может работать с любой из ветвей.

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

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

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

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

Теперь, когда мы знаем, для чего предназначены модули, мы также знаем, для чего они не предназначены:

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

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

Вольно переведено проектом Russian Hacker News. Оригинал статьи

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

0
Комментарии
-3 комментариев
Раскрывать всегда