О темной стороне legacy-кода. Как решить проблемы с монолитными приложениями
Меня часто просят рассказать о работе с legacy-монолитами. Про микросервисную архитектуру и переход на нее говорят много, но редко упоминают о том, что проекты приходят к этому после многих лет развития как монолиты. Учебники по решению проблем не пишут. Чтобы поменять архитектуру живого решения, надо пройти несколько этапов. Я работал с разными проектами — и с полноценным multitenancy service-oriented REST architecture в Oracle, и с огромным монолитом, в репозитории которого были коммиты за десять лет. Эта статья — о темной стороне, о legacy-коде, и практических решениях проблем с монолитными приложениями на PHP.
Иллюстрация Алины Самолюк
Причины появления
Есть две основные причины появления legacy-кода. Первая — выходят новые версии операционных систем, языков, браузеров, библиотек. Особенно актуальна проблема для мобильных приложений и скриптовых языков — в каждой новой версии платформы нужно исправлять проблемы совместимости со старым кодом. Этого процесса не исбежать.
Вторая — технический долг, который создается специально. Руководство сокращает срок разработки ПО за счет отказа от проектирования, автоматического тестирования или code review, одобряет сторонние библиотеки, которые не поддерживаются, а разработчики не документируют сложную логику. Это происходит повсеместно и не зависит от количества денег в компании. Не стоит ругать плохих начальников. У них есть весомые причины поступать именно так.
У продуктов есть жизненный цикл, период большого спроса на популярные товары длится три-четыре месяца. Все лучшее конкуренты скопируют и сделают еще лучше, поэтому компании вынуждены регулярно выпускать новинки. Чтобы поддерживать объем выручки, новые продукты и новые версии появляются каждые несколько месяцев, так продажи нового цикла компенсируют снижение продаж в конце цикла. По три-четыре крупных релиза в год делают и Apple, и Marvel. И в Oracle на рынке Enterprise SaaS тоже квартальный релизный цикл. При этом рецепта успеха не существует. 97% стартапов выкидывают свои наработки и пробуют делать что-то новое, прежде чем найдут такой продукт, который у них будут покупать. Поэтому затраты на разработку MVP в стартапах максимально сокращают.
Проблемы с легаси? Значит, вам повезло!
Проблемы появляются, когда жизненный цикл продукта не согласован с жизненным циклом ПО. В стабильных организациях обычно есть сроки вывода старого ПО из эксплуатации или его обновления. Однако в стартапах поддерживать его, скорее всего, будет не нужно. Пишется недокументированный хрупкий код, который не масштабируется. Когда стартап находит продукт, который продается лучше, чем инвесторы ожидали, переводит его на следующий цикл, выпускает новую версию. Процент стартапов, которые стали успешными, настолько мал, что окупаемость длительного цикла жизни ПО с выводом из эксплуатации старого кода станет понятна только после долгого и большого роста. К этому времени у проекта уже накопится большой объем legacy-кода. Проблем с legacy нет у тех стартапов, чей жизненный цикл предсказуем и которые закрылись.
Проблемы?
Не всегда плохой код создает проблемы. Например, в пакете WordPress — очень плохой код, но на его основе работает 38% интернет-сайтов. Стандартные работы выполняют специалисты на аутсорсинге по прайс-листу, а обновления устанавливаются по нажатию кнопки. Проблемы с WordPress начинаются, когда в него добавляют нестандартный код, тогда автоматическое обновление становится невозможно.
Долгие годы без обновлений может работать ПО, которое обеспечено защитой от взаимодействия с внешним миром — например, в банкоматах и стабильных изолированных сервисах в сервисной архитектуре.
Что делать тем, кому повезло?
Начинать надо с тестирования
Серьезные изменения кода всегда порождают неожиданные проблемы. Без надежного тестирования сбои приложения приведут к потере выручки и снижению продаж.
Перед началом доработки приложения надо подготовить план тестирования и отработать механизм релиза новых версий с возможностью отката к предыдущей. API и работу основной логики лучше проверять автоматическими приемочными тестами. Если автоматического приемочного тестирования в проекте нет, надо начинать обновление с обучения тестировщиков и составления плана тестирования.
Обновление версии языка
Через несколько лет после написания код становится несовместимым с актуальной версией языка, и это приводит к целому вороху проблем.
Для разработки новых продуктов нужны сторонние библиотеки, которые требуют современную версию платформы. Еще в старых версиях не исправляются ошибки. В проект на устаревшей версии языка сложнее найти разработчиков. Как следствие, растет цена решения задач на основе существующего ПО и нужно больше усилий на поддержку работоспособности.
Составить список проблем совместимости с новой версией PHP помогут утилиты статического анализа.
- Rector — решит простые случаи несовместимости с новой версией, автоматически обновив часть кода.
- Exakat — проанализирует совместимости кода по версиям PHP, покажет список используемых расширений, проблемных участков кода и поможет составить список задач на доработку.
- Phan — покажет в коде лексические конструкции, которые убраны из новых версий PHP.
Если для новой версии языка нет расширения, которое используется в приложении, участки кода с вызовами отсутствующих расширений придется переписать.
Обновление версии платформы или языка в таком случае выполняется быстро. Автор был инициатором обновления PHP с
Переход от монолита к сервисной архитектуре
Иногда проекты вырастают. Продукты стали успешными на рынке и регулярно выпускаются. По законам Лемана сложность ПО растёт, функциональное содержание расширяется, вместе с ними штат разработчиков и объем кода постоянно увеличиваются. Замена устаревшего ПО в бюджет разработки не закладывают, чтобы улучшить финансовые результаты, поэтому качество программ ухудшается. Размер Git-репозитория может исчисляться гигабайтами. Постепенно скорость разработки уменьшается, и когда разработчики перестают успевать выпускать ПО для новых продуктов, монолит решают разделить.
Самый модный и дорогой путь — параллельная разработка сервисов. Одновременно с поддержкой старого работающего решения создают новые сервисы, зачастую на новом языке — например, на Golang. Главная проблема — это риск, что создать замену не получится. За время разработки сервиса основное приложение меняется, и новый сервис не догонит программу по требованиям. Оценить этот риск непросто.
К счастью, слона можно съесть по кусочкам: отделять от монолита модули, не переписывая код заново, зафиксировать API, а затем превращать их в сервисы. Сначала части кода приложения надо выделить в отдельные пакеты, затем из пакетов можно будет создавать сервисы.
Перенос кода в пакеты открывает ряд возможностей:
- можно сократить размер репозитория приложения;
- разработчикам из разных команд можно предоставить только публичный API пакетов и ограничить вызовы внутренних классов;
- можно описать зависимости между своими модулями и использовать composer для управления зависимостями и версиями своих пакетов;
- у каждого модуля может быть независимый цикл разработки, и работу над проектом можно масштабировать;
- можно выпускать разные версии пакетов и согласовать изменения API.
Главное — это относительно небольшая по объему работы задача. Вынести часть кода в пакет без переписывания можно за несколько дней. У автора был опыт переноса в пакеты по тысяче строк кода в день с инверсией внешних зависимостей. А после фиксации API-модулей будет проще заниматься масштабным рефакторингом.
Разделение приложения на пакеты
Допустим, есть приложение на PHP, которое предоставляет клиентский API. Начинать любые изменения надо с тестирования и релиза, который включает план отката. Это называют release, control, validation и DevOps. Однако в активно развивающихся проектах тестирование и выкладка отработаны. В этом случае надо начинать разделять приложение с определения таких ограниченных контекстов, которые логично выделить в отдельные модули и сервисы.
Как пример, из приложения можно выделить обработку фотографий, аутентификацию пользователей, обработку платежей.
Создание отдельного модуля — это цикл из пяти подзадач:
- Выбрать небольшой функционал для переноса в модуль. Например, изменение размера изображений.
- Определить API модуля — написать интерфейс, доступный приложению.
- Написать или проверить приемочные тесты, например на загрузку и валидацию изображения.
- Скопировать в модуль старый код и инвертировать в коде модуля зависимости через границу модуля, без рефакторинга или переписывания всего кода.
- Заменить в коде приложения прямые обращения к старому коду на вызовы сервиса из нового модуля. Для решения этой задачи используют две технологии: IoC-контейнер и менеджер зависимостей.
Когда в модуль перенесен код для реализации всех запланированных функциональных требований, можно удалить этот код из приложения.
Начать создавать пакеты можно в локальном каталоге, а для полноценной сборки и развертывания стоит создать собственный репозиторий пакетов, такой как Packeton, и перенести код модулей в собственные Git-репозитории. Также использовать платный репозиторий Private Packagist.
Как создать composer-пакет в приложении и зарегистрировать его как сервис в IoC-контейнере, смотрите здесь: до изменений, после изменений, diff.
В примерах используется composer для управления зависимостями пакетов и Symfony Dependency Injection как IoC-контейнер для сервисов. У вас может быть другой контейнер. Если в приложении нет IoC-контейнера, придется делать рефакторинг и реализовать внедрение зависимостей. Простейший пример добавления IoC-контейнера в приложение.
Решение проблем со связанностью кода
Есть два типа связанности:
- код будущего модуля содержит вызовы структур, которые описаны в других частях приложения;
- код других частей приложения содержит описания структур, которые используются в будущем модуле.
Рассмотрим случаи связанности кода и варианты выделения модулей без трудоемкого рефакторинга всего кода.
1. Расширение классов, реализация интерфейсов, использование трейтов, когда декларация структур используется «через границу» будущего модуля. Приведу пример устранения связанности при наследовании, когда родительский и дочерний классы вызывают методы друг друга: результат, diff.
Основные алгоритмы расцепления связанности:
- Сторонние библиотеки можно указать в зависимостях пакета.
- Для интерфейсов, которые используются и в пакете, и в приложении, надо создать пакет контрактов и указывать его в зависимостях.
- Наследование от внешних классов с зависимостями надо превратить в композицию с помощью адаптеров, которые внедряются как сервисы.
- Для защищенных свойств, которые используются в дочернем классе, надо сделать getter-методы, а для защищенных методов — создать прокси-методы.
- Наследование классов приложения от классов модуля стоит инвертировать в композицию с сервисом, который предоставляется новым пакетом.
Рефакторинг наследования — трудоемкая задача, а добавление адаптеров может негативно повлиять на производительность. Поэтому для небольших родительских классов и трейтов без зависимостей, имена которых не используются в типах параметров, можно нарушить принцип подстановки лисков и для сокращения объема работы просто скопировать в пакет, выставив им пространство имен пакета. Пример: до изменений, после изменений, diff.
2. Статические вызовы. Синтаксис PHP допускает вызов статических методов у объектов как методов класса (пример). Если выносите в пакет обычную функцию или класс, у которого есть статический метод, эти функции/методы нужно добавить в публичное API пакета (пример, diff).
Аналогично статические вызовы из пакета к методам классов приложения можно заменить статическими вызовами сервисов. Это будет реализация паттерна «мост».
Ссылки: пример прямого статического вызова, пример инверсии зависимости статического вызова через внедрение сервиса, diff коммита.
Если несколько методов из разных классов используются вместе, для них можно создать сервис-фасад.
Аналогично статические вызовы тех классов и функций, которые переносятся в модуль, нужно заменить обращениями к объекту сервиса-адаптера или фасада.
Если есть несколько независимых классов-«хелперов» (пример) или обычных пользовательских функций, которые используются одновременно и в приложении, и в новом модуле, из них стоит создать отдельный composer-пакет и указать его в зависимостях приложения и других пакетов.
5. Применение глобальных констант и констант классов. Возьмем пример: в приложении есть класс, который нарушает Single Responsibility Principle и содержит обращения к константе другого класса. Наша задача — вынести первый класс в пакет без рефакторинга второго класса, потому что рефакторинг потребует изменения всего кода, в котором используется константа. Надо избавиться от прямого обращения к константе.
Первый вариант решения — создать в приложении сервис-адаптер, из которого можно в модулях получать значение констант. Однако вызов метода работает медленнее, чем обращение к константе, и в цикле вызов метода может замедлить работу приложения, что нежелательно. Другое решение — передать константу как параметр через IoC-контейнер.
Ссылки: до изменений, после изменений, diff, декларация инъекции константы в контейнере.
6. Динамическое разрешение имен через строковые операции. Пример:
$model = new ($modelName . ’Class’);
Такой алгоритм встречается внутри некоторых фреймворков. Однако в приложении такой код создает большие сложности, и ясного алгоритма решения проблемы связанности здесь нет.
Эту конструкцию можно попробовать переписать в switch-структуру со статическим списком классов. К счастью, в приложениях подобный код бывает редко.
Оптимизация
В больших приложениях количество сервисов в IoC-контейнере бывает очень большим. Если в пакет выносится большой объем кода, у него могут быть десятки зависимостей. При обработке клиентских вызовов обычно создается только небольшая часть сервисов. Но при передаче зависимостей в конструктор класса контейнер будет создавать все перечисленные сервисы.
Есть несколько способов решения этой задачи:
- Сервисы, которые передаются в пакет, можно объявить как lazy.
- Объект API пакета можно объявить как Service Subscriber.
- Разделить API пакета на несколько сервисов.
Самый гибкий способ — это реализация Service Subscriber. Когда сервис объявляется подписчиком, можно реализовать в пакете вызов внешних сервисов по мере обращения к ним. Примеры: код до изменений, где используется один из нескольких классов, и код после переноса в пакет c инверсией зависимостей, где нужный сервис создается по требованию. Diff.
Service-Oriented Architecture
Хорошо, разделили код на пакеты, но при выкладке все собирается в одно приложение и работает в одном процессе, как монолит. А где же сервис-ориентированная архитектура? До нее еще долгий путь.
У каждого пакета зафиксирован публичный API. На основе этого API можно создать сервис с RESTful-протоколом. Код нового сервиса — это код пакета, вокруг которого написан стандартный роутинг, записываются логи и прочий инфраструктурный код. А в старом коде вместо кода пакета появляется адаптер для HTTP-вызовов через curl.
При создании отдельных внутренних приложений-сервисов надо решить две задачи:
- Детальное протоколирование вызовов всех сервисов. Каждому клиентскому запросу надо присваивать уникальный ID вызова, который передается во все сервисы при вызовах внутренних API. И каждый вызов сервиса следует протоколировать. Необходимо иметь возможность отследить вызовы сервисов по цепочке.
- Гарантировать единственный результат выполнения запроса при сбое одного из сервисов, когда запрос к сервису передан заново. Пример: клиентский запрос на платеж с его счета на другой счет. При сбое внутреннего выделенного сервиса, который выполняет запись результатов транзакции и пересчитывает баланс на счетах пользователей, повторный запрос к нему не должен привести к двум денежным переводам с одного счета на другой.
Заключение
Доработка крупного монолитного приложения может быть намного медленнее, чем создание нового кода. Для крупных приложений переход к SOA иногда растягивается на несколько лет. Разделение кода на пакеты может быть первым шагом, который позволит сделать менее сложным большое приложение.
Конечно, разделение на пакеты не поможет масштабировать систему и не повысит ее надежность. Главная цель здесь — управление техническим долгом. К тому же, когда в проекте несколько команд, у них обычно разные стандарты кодинга. Благодаря разделению кода на пакеты можно уменьшить количество общего кода, чтобы люди поменьше спорили при слиянии правок.