Intro

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

Я познакомился с понятием “архитектурные паттерны” и MVC в частности еще в 2012, и с тех пор заболел идеей архитектур программного обеспечения. Я восхищался людьми, которые пишут архитектурные фреймворки. Я тратил дни и недели читая их исходники и просматривая видео на YouTube. Но чем больше я в это погружался, тем больше я чувствовал себя в состоянии, которое я называю “начинающий иллюзионист”: пока смотришь на фокусы - это магия, но когда начинаешь их делать, то они становятся банальными.

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

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

В своих скитаниях я нашел кое-что не менее интересное: причину почему MVx всегда получается плохо. И вот об этом и пойдет речь сегодня.

Три проблемы MVx

Итак, мы посмотрим на три проблемы MVx архитектур. x - это просто заглушка, так что можете представить, что это MVC, MVP, MVVM и т.п. Это не имеет значения, я обещаю. =)

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

Проблема остатка (remainder issue)

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

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

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

Как декомпозиция работает для “маленьких” фич
Как декомпозиция работает для “маленьких” фич

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

Что происходит с декомпозицией на реальных проектах
Что происходит с декомпозицией на реальных проектах

И дальше хуже: после пары таких фич у нас появляется мысль “а не пора ли расширить шаблон архитектуры новыми компонентами?”. И наша небольшая и стройная архитектура становится больше. И что происходит потом? Мы применяем новый шаблон к фичам, которые раньше идеально под него подходили. Но теперь эти фичи “малы” для него. И мы получаем больше “лишнего” кода, который надо писать, тестировать и компилировать. А что мы делаем, когда надо писать один и тот же код много раз подряд? О да, мы делаем для него генератор. Который теперь генерирует еще больше бесполезного кода.

Надо добавить генератор, чтобы сэкономить время разработчикам
Надо добавить генератор, чтобы сэкономить время разработчикам

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

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

Проблема масштабирования (scalability issue)

Далее - проблема масштабирования.

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

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

Как MVx справляется с доработками?
Как MVx справляется с доработками?

Вот тут все и начинается: каков будет наш интуитивный подход к внесению доработок? Подглядывайте в свой код, если в вашем проекте было что-то подобное.

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

Композиция слоев данных
Композиция слоев данных

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

Пересечение доменных слоев
Пересечение доменных слоев

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

Слияние презентационных слоев
Слияние презентационных слоев

Ну что, похоже на ваш код?

А что происходит дальше? Кода много, он часто ломается, тестировать сложно. Что же делать? Правильно, время создавать новые компоненты!

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

Кстати, а вытаскивание новых компонент ничего вам не напоминает? =)

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

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

Проблема разрывов в логике

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

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

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

Из-за разрывов мы теряем контроль над последовательностью выполнения логики
Из-за разрывов мы теряем контроль над последовательностью выполнения логики

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

Чтобы было понятнее, скорее всего нам надо посмотреть на какой-нибудь класс, с которым взаимодействует View, вроде ViewModel или Controller (даже если View не взаимодействует с ним напрямую, а просто отправляет события, которые они слушают). Такой класс, обычно, представляет из себя набор точек входа (методов) для запуска логики и подразумевает, что эти точки входа будут выполняться в определенном порядке. Но у нас нет механизмов, которые могли бы гарантировать эту последовательность выполнения. Разве что интеграционные или end-to-end тесты. Или на худой конец комментарии со страшными предупреждениями о том, что перед этим методом надо вызвать другой.

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

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

Эпилог

Три проблемы: остаток, масштабируемость и разрывы. От них страдали и страдают все реализации MVx-архитектур, которые я видел. А не MVx-архитектуры обычно решают только часть из этих трех. Например, архитектуры, похожие на ELM или flux, в основе которых лежит State-машина, очень стараются решить проблему масштабируемости и разрывов, но страдают от проблемы остатка, когда дело доходит до асинхронных операций (привет эффектам и подобным абстракциям).

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

Как так получилось? Почему алгоритм при переносе с бумаги в код становится таким рваным, таким ненадежным? Почему с этим ничего не делают? Можно ли что-то с этим сделать?

Да...

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

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