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

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

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

  • Инкапсуляция данных.
  • Интерфейсы.
  • Полиморфизм.
  • Перенаправление.
  • Проектирование на основе данных - чтение значений, путей к файлам классов и т.п.
  • Поиск служб.
  • Интерпретаторы.
  • Рефлексия.
  • Унифицированный доступ к полям и свойствам (автогеттеры\сеттеры) класса.
  • Принцип подстановки Лисков.
  • Принцип открытости/закрытости.
  • Don’t Talk to Strangers.
  • Инжекция зависимостей.
  • Избегание цепочек вызовов getA().getB().getC() и т.п.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Какие беглые выводы можно сделать:

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

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