Объектно-ориентированный дизайн часто приводит к непомерной сложности. Почему?

Я делаю видеоплееры для Интернета. Со стороны такие игроки кажутся простыми; вставьте HTML-тег ‹video› на свою страницу, укажите его на медиафайл и, немного поднапрягшись, вперед. Попробуйте. Попробуй.

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

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

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

Кто из вас знаком с процессом? Это выглядит примерно так:

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

Ой! Вас ударили рабочим концом клюшки.

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

Снова и снова я видел один и тот же общий шаблон, используемый для сложных клиентских приложений (под которым я не имею в виду веб-приложения, такие как большинство страниц, созданных в React или Angular — вместо этого подумайте о видео игроки). Я никогда не встречал описания этого шаблона проектирования ни в одной книге по архитектуре программного обеспечения, хотя я считаю, что это расширение шаблона Модель-Представление-Контроллер. Я назову это шаблоном Controller-Manager-Muddle (CMM).

CMM происходит, когда вы пытаетесь применить принципы «хорошего» объектно-ориентированного дизайна, расширяя часть контроллера MVC для обработки сложной бизнес-логики. В итоге у вас будет несколько Контроллеров божественного класса и куча поддерживающих Менеджеров, Фабрики, Двигатели и Предметы — целая гора Олимп меньших богов. Каждый из этих классов, если он построен «хорошо», усердно работает над инкапсуляцией собственного состояния.

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

Давайте назовем это тем, чем оно является: чертовой катастрофой.

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

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

Вот схема типичного применения КИМ.

Совершенно ясно, как работает это приложение… НЕТ! (Это безопасное место для возрождения мемов поколения X?)

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

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

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

Применение Смерть pt. 1: Передача сообщений становится негибкой

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

...
this.adPlugin?.setAds(ads!);
if (ads?.importSys?.csai_type) {
   if (popPlaybackData.adsConfig) {
      popPlaybackData.adInsertConfig.isTlManRequired = true;
   }
   this.timelineAdsPlugin = new TimelineAdsPlugin(
      new JsonABProvider(),
      this.session,
      initialPlaybackData.type,
      this.pauseAdvsDispatcher!,
      initialPlaybackData
   );
   this.timelineAdsPlugin.setAds(ads!);
}
if (popPlaybackData.adsFailoverReason) {
   warn(`Error Fetching Ads data. Destroying Adverts Manager:`);
   this.destroy();
   return popPlaybackData;
}
const modPlaybackData = await this.modifyPlaybackData(popPlaybackData);
this.adPolicyMan?.init(this.session.onPbTimelineUpdated.bind(this.session));
this.abProvider?.setPlaybackData(modPlaybackData);
return modPlaybackData;

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

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

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

Но что с того? Приложения сложные. Зависимости случаются. Сосать его и узнать код.

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

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

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

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

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

Применение Смерть pt. 2: Поток выполнения становится зависимым от структуры состояния

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

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

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

Но причина этого коллапса неуловима. Вот как это работает:

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

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

Может быть несколько способов справиться с этим. Может быть, вы создаете новый контроллер D, который обрабатывает эту функцию, вводя как менеджер A, так и менеджер C. Когда контроллеру D нужно сделать свою работу, он нажимает A, а затем C (или C, а затем A, что угодно), возможно, вызывая недавно созданные функции, которые пусть A и C изменяют свое внутреннее состояние (инкапсуляция, детка).

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

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

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

В сложной системе эти связи становится почти невозможно распутать. Но можно попробовать…

Скажем, вы столкнулись с одной из этих проблем и благодаря упрямой воле находите ее первопричину. Вы стоите перед командой и говорите: «Помните того контроллера D, которого мы поставили туда в прошлом году?» Встреча замолкает. Сколько из них действительно работали над проектом в прошлом году?

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

Кажется, никто не впечатлен вашим шерлоковским расследованием. Черт бы их побрал. Вы продолжаете: «Мы никак не можем провести рефакторинг Manager D, если не полностью переписать его». Все нервно смеются, кроме менеджера по продукту. «Но без проблем», — скажете вы. «Мы просто поставим флаг «isFullMoonPhillipines» и сможем двигаться дальше.

Истощенные аплодисменты. Что может пойти не так?

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

Итак, поскольку вы все еще читаете, я надеюсь, вы хотя бы немного убедились, что создание приложения с помощью шаблона «контроллер-менеджер-неразбериха» похоже на последнюю сцену в «Убить Билла», где Кит Кэррадайн понимает, что Ума Турман сделала пятиконечная ладонь взрывается сердцем на нем и ему осталось жить всего пять шагов — а ваш проект уже реализован, что? Два шага? Три?

В голове должен гореть один вопрос.

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

Извините, понятия не имею.

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

А пока удачного кодирования.