Родин Максим
Старший разработчик ГК Юзтех
Всем привет! Я Родин Максим, старший разработчик ГК Юзтех.
С ростом количества прожитых лет проекты могут обрасти зоопарком разнообразных зависимостей. Все они со временем нуждаются в обновлении по разным причинам — плановый апгрейд зависимостей, переход на другую платформу, устранение уязвимостей, решение вопросов лицензии и прочее.
В данной статье хочу поделиться опытом обновления этих самых зависимостей в .Net проекте и рассказать про применённые подходы, принятые решения и нюансы.
И, прежде чем начать, хочется определиться с условностью, которая заключается в следующем: Обновление — это не всегда переход к более новой/свежей версии какого-либо продукта, это ещё и понижение его версии, смена одного инструмента на другой, избавление от того или иного решения.
Наша предыстория…
Итак, история обновления зависимостей началась с масштабного обновления всего ?. Поднимали версию .Net c Framework на .Net 6-8, сменили платформу с Windows на Linux, переехали с MSSql на PostgreSQL – типичный жизненный цикл .Net проекта, кто не согласен — готов к обсуждению в комментариях. И, естественно, апгрейд затронул многие части проекта, что стало одной из отправных точек, с которых начался путь обновления зависимостей.
Зачем же это стало необходимо?
Первое, в нашем случае новый .Net потребовал новых версий связанных библиотек — это очевидно, разброс от изначальной до целевой версии фреймворка получился довольно большой;
Дальше — смена платформы? Нужны зависимости, которые заточены под целевую ОС или платформо-независимые – ещё один апдейт!
Смена базы? – даёшь новые адаптеры/провайдеры для базы.
Но это была лишь вершина айсберга! Дальше:
Одно из основных — соображения безопасности. Многие сторонние инструменты в проекте очень давно, и часть из них, как оказалось, содержит уязвимости — исправления, как можно догадаться, в более свежих версиях, но! не всегда;
К вопросу безопасности ещё можно отнести нивелирование нюансов политической ситуации современности, которая также влияет и на контроль использования сторонних инструментов;
Аспект стандартов ПО и договорённостей с заказчиком — где обновление – как непрерывный процесс следования этим стандартам и договорённостям;
Избавление от платного ПО тоже может быть причиной обновления или перехода на другое, бесплатное, с меньшей стоимостью либо партнёрское с особыми условиями;
Наконец, отправной точкой может быть банальное причёсывание зависимостей. Как говорил ранее, у проекта богатая и длинная история, за которую накопилось множество всего, что необходимо привести к одному виду — одинаковые версии продуктов для разных частей проекта, один инструмент для близких сценариев (где это возможно), проверка на действительное использование тех или иных зависимостей.
Итак, вернёмся к нашему опыту. Задачей было, как уже очевидно – обновить зависимости так, чтобы учесть все требования обновлённого проекта, договорённости с заказчиком и неявные аспекты, и при этом ничего не сломать ?
С чего начинали?
Здравым решением является начать процесс с составления перечня зависимостей проекта, если он по каким-то причинам не контролировался. Это необходимо для понимания, какова отправная точка: какие зависимости есть вообще и какова их задача в проекте, каков разброс версий, к какой части проекта они относятся.
И только после этого переходить к процессу обновления, в зависимости от цели обновления.
Тут возникает вопрос: а как же собрать интересующую информацию?
Пути, на самом деле, есть различные и в бОльшей степени зависят от технологий, используемых в проекте, а также от типов зависимостей. В нашем случае мы использовали комбинированный подход: самописный инструмент на основе пакетных менеджеров (если кратко, в одних случаях сканировали папки с зависимостями, в других вычитывали файлы, хранящие информацию об используемых библиотеках, в третьих, просто брали выходной результат пакетных менеджеров, формируя всё это в единый отчёт) и обычную проверку глазками. Мы так сделали из-за использования в нашем проекте различного рода технологий помимо .Net: чуточку Java, angular фронтЫ на разных версиях, а также другие коммерческие продукты: коробочные решения, внешние нами не контролируемые сервисы и тому подобное.
Какие использовали подходы обновления… и трудности, с которыми столкнулись
Итак, собрали списочек, а как же обновлять? Первое, что приходит в голову, когда сталкиваешься с такой задачей — взять IDE, открыть проект, и через регулярку поднять версии до самых свежих и стабильных — вуаля, мы в будущем ?.
Кстати, некоторые IDE имеют в своём составе специализированные средства, которые могут помочь увидеть список зависимостей проекта и провести с ними какие-либо операции в том числе обновление. Например, для части зависимостей в .Net проекте можно использовать Nuget package manager. И в том же Rider’е или Visual Studio управлять версиями (к примеру, в Rider через Tools->Nuget->Upgrade Packages in Solution).
Совместимость и неявные зависимости
Оказалось, не всё так просто: первое препятствие, которое встаёт на пути автоматизированного обновления — это совместимость.Инструменты очень часто любят зависеть друг от друга и тянуть другие зависимости за собой, иногда неявно (implicitly). И это первое, с чем мы столкнулись, когда простой метод с автоматизированным подходом оказался не нашим вариантом. Поэтому каждую зависимость нужно было проверять на совместимость, и неявные, транзитивные зависимости тоже (на примере .Net Nuget в Rider их можно посмотреть следующим образом: открыть окно Nuget менеджера, выбрать интересующий проект и в списке в Implicitly Instаlled будут видны неявные зависимости).
Требования платформы
Отлично, с этим разобрались, дальше смотрим на требования целевой платформы, ОС. В некоторых случаях библиотеки в свежих версиях приобретают кроссплатформенность, что упрощает задачу. В некоторых — нет. И тут уже нужно принимать волевое решение использовать другой инструмент. К слову, иногда инструмент кроссплатформенен частично — что стало в нашем случае сюрпризом. Если кратко, есть такая библиотечка в .Net — System.Drawing (System.Drawing.Common, в частности). Она в большинстве своём заточена под Windows (хотя командой Microsoft предпринимались попытки переделать её на кроссплатформу, но оказалось, что много чего нужно преобразовывать (https://learn.microsoft.com/ru-ru/dotnet/core/compatibility/core-libraries/6.0/system-drawing-common-windows-only)).
Так вот, она используется в одной из наших кроссплатформенных (зависит от версии и продукта) зависимостей, а если точнее, Aspose. При этом, в одних случаях она отрабатывает везде (к примеру, мы хотим использовать класс Color), а в других падает с PlatformUnsupportedException. В таких случаях нужно быть максимально осторожным и решать, стоит ли держать при себе такую бомбу замедленного действия?
Замена используемого инструмента
Вернёмся к варианту, когда всё-таки возникла необходимость сменить используемый инструмент. Отмечу, что в большинстве случаев придётся переписывать код, где-то больше, где-то меньше. В этом вопросе нужно учитывать, что после этого результат должен работать так же (или максимально приближенно так как у инструментов могут быть свои ограничения). Хорошей практикой при этом является иметь тесты на переписываемый функционал.
Принимая решение о смене используемого инструмента необходимо ответить на вопросы:
Действительно ли он нужен в текущих реалиях?
А если и нужен, то прям весь?
Отвечая на первый вопрос, нужно провести мини-расследование, в котором свериться с требованиями, коллегами и уточнить, действительно ли данный функционал используется (напомню — проект с длинной историей, и у таких проектов со временем может оказаться неиспользуемый, устаревший функционал). Когда получен утвердительный ответ, уже ответить на второй вопрос. Бывает, можно взять от зависимости только часть функционала, которую можно аккуратно перенести к себе (как правило, присуще OpenSource решениям), и избавиться от всего остального – так было у нас в одном из случаев – пакет Microsoft.AspNetCore.Mvc.WebApiCompatShim. Проанализировав эту библиотеку, отвечающую за совместимость версий .Net и места её использования, удалось выяснить, что бОльшую часть можно заменить другими зависимостями, которые уже у нас есть, другая часть вообще не нужна, а третью можно взять из исходников и добавить себе ?.
Конечно же, такую хитрость можно применить не везде, и не везде она уместна хотя бы потому, что, добавляя часть зависимости себе, мы теряем дальнейшую поддержку и обновления со стороны её разработчика.
Но бывает так, что переход на новый инструмент неизбежен, и тогда необходимо дополнительное исследование. Оно ответит на следующие вопросы:
Подходит ли инструмент по функционалу?
Проходит ли по ценовой политике?
Соответствует ли ограничениям безопасности?
А так же проверить другие параметры, в зависимости от требований проекта. Только после этого можно пробовать (да, именно пробовать, вспоминаем про ограничения инструментов, которые иногда могут обнаружиться лишь при тестировании) добавлять выбранный инструмент. Так было у нас с решением для работы с графикой: картинки, документы, операции с ними. Выяснилось, что часть библиотек (iTextSharp, старые версии Aspose продуктов) заточены только под ОС, с которой мы уходим. И было принято решение перейти на кроссплатформенный инструмент. Благо, он уже использовался в некоторой части проекта (да, тот самый Aspose, но в более новой версии), что упростило нам жизнь.
Но почему лишь в части проекта? Как так получилось? И что с этим делать?
Так вышло, потому что проект богат своей историей, и происходило всякое (обсуждать глубже не имеет смысла так как эволюция проектов — это не всегда тривиальный процесс). Это привело к разрастанию зоопарка зависимостей, в том числе в части работы с графикой и появлению разных инструментов для одних и тех же либо родственных задач (работа с документами, сканирование QR-кодов, отрисовка графиков), а также применению различных их версий.
Что с этим делать? Опять анализировать: можем ли мы использовать одно решение вместо нескольких? В нашем случае получилось, что да — можем. Но, к сожалению, это возможно не всегда. Получилось так, что один из изначально выбранных для разработки инструментов оказался намного более функционален, чем использовался у нас до обновления. И многие другие зависимости можно было полностью либо частично им заменить, что мы и сделали?.
К слову о том, почему не всегда можно заменить одно решение другим или подогнать версии под одну (хотя очень хочется):
Самое банальное — это конечный результат. Даст ли новый инструмент на выходе то, что выдавал предыдущий? Если нет, то можем ли мы пойти на эти ограничения и условности?
Следующее, что может быть — это вопросы лицензии. Можем ли мы использовать рассматриваемый продукт у нас? В данном вопросе необходимо рассмотреть условия использований анализируемого продукта. Одно из них — стоимость, если продукт платный, то подходит ли он нам по ценовой политике? На эту тему можно много рассуждать, но при этом исходить из требований проекта. Из практики можно привести интересный пример, тот самый продукт для графики, который упоминался ранее, содержащий в составе многое (тот самый «комбайн»), по ценовой политике и дальнейшей поддерживаемости получился дешевле, чем использование нескольких маленьких.
Версионность
Вернёмся к вопросам обновления, с которыми мы столкнулись.
Это, конечно, здорово, когда везде одна версия зависимости. Как минимум, это в перспективе проще контролировать. Но бывает так, что разные части проекта (модули, сервисы) требуют различные вариации зависимости, что не позволяет привести всё к одному виду.
Одним из примеров может быть удовлетворение вопросов прямой и обратной совместимости. В нашей копилке тоже есть пример на этот счёт. Этот пример касается горячо любимого в .Net сообществе WCF. Так вот, у нас в одной части проекта используется данный инструмент для общения со сторонним сервисом, который мы не контролируем, но имеем доступ к контрактам (как потом оказалось не ко всем), по которым осуществляется автогенерация кода. Автосгенерированный когда-то давно код ссылается на библиотеки с уязвимостями, одна из которых System.ServiceModel.Http версии 4.9.0 уязвимость https://github.com/advisories/GHSA-jc8g-xhw5-6x46, об этом пакете и пойдёт дальше речь. Мы попробовали поднять его версию до самой свежей – 8.0.0. Это привело к тому, что возникла необходимость в перегенерации кода. Код генерировался по контрактами из неподконтрольных нам ресурсов, которые для части контрактов показали 404, что не позволило продолжить работу в данном направлении. И в нашем, случае решением стало использовать промежуточную версию зависимости (6.0.0) — не самую свежую, но уже без уязвимостей (по крайней мере выявленных), что позволило не производить перегенерацию.
А ещё, нас успокоило то, что когда-то в будущем будет произведён отказ от данного внешнего сервиса (но не сейчас). Упомянутый случай — это, кстати, пример зависимости в виде внешнего ресурса, который нельзя просто так взять и обновить поднятием/опусканием версии. Здесь могут потребоваться уже человеко-месяцы.
Вопросы безопасности
Переходя на новое решение, стоит особое внимание уделить вопросам безопасности: безвреден ли новый инструмент?
При этом, данный вопрос должен относиться не только к новым решениям, но и к уже существующим. Как упоминалось ранее, существует много причин следить за безопасностью:
Устранение уязвимостей;
Следование стандартам и договорённостям внутри проекта;
Политические вопросы.
Но как понять, что используемые инструменты могут содержать уязвимости?
Одно из первых, что приходит в голову – обратиться в компанию по информационной безопасности, чтобы они провели аудит?. Но мы то тут просто обновляем зависимости?Одним из быстрых решений для анализа возможных уязвимостей в проекте (особенно, если он относительно большой) является использование автоматизированных средств анализа.
В нашем случае, для анализа мы использовали OpenSource инструмент Trivy (https://trivy.dev/), который ранее себя зарекомендовал. С помощью него мы просканировали проект, и, исходя из полученного отчёта, принимали решение для той или иной зависимости. Если кратко, Trivy способен анализировать проекты разного типа для бэкенда, фронтенда, даже для докера. В отчёте он показывает зависимость, текущую версию, уязвимость и информацию о ней, а также версию, где присутствует исправление, если оно существует.
Но опять же, автоматизированные средства — это не панацея, они не способны показать исчерпывающую информацию по всем уязвимостям на свете. В качестве примера можно привести уязвимость в довольно известном инструменте, используемом для тестирования — Moq. Уязвимость заключалась в том, что в одной из свежих версий (4.20.0) инструмент собирал email адреса с устройства и отправлял их на сервер разработчика.
Как удалось это выявить? Подсказал коллега?
Кстати, пример с Moq в очередной раз дал нам понять, что самые свежие версии — это, конечно, здорово (модно, молодёжно), но нужно осторожнее на них переходить.
Точечный контроль версий
Продолжая вопрос версионности, хочется упомянуть ещё и аспект точечного контроля версий. Он заключается в:
Использовании строго заданных версий: с таким мы столкнулись при работе с зависимостями в фронтовой части проекта (как известно, там можно указать диапазоны версий);
Контроле неявных(транзитивных) зависимостей путём явного их добавления в требуемой версии. Так было у нас сделано с некоторыми nuget пакетами, которые тянулись через другие библиотеки, и их версия контролировалась только «родительскими пакетами».
Подобный тонкий контроль приведёт к более ожидаемому и стабильному поведению системы, особенно это важно при запуске на разных стендах, например стендах для разработки, тестовом и продуктивном.
Пару слов хочется сказать о моменте, который встретился на нашем пути при применении тонкого контроля. После апгрейда версий до целевых и очередной проверке работоспособности на локальном стенде, мы столкнулись с проблемой неработоспособности одного из компонентов. Первое, что приходит в голову – несовместимость, хотя по документации версия проходила. Но при последующем анализе оказалось, что в конкретном случае не были удалены артефакты предыдущей, что привело к конфликту. Этот случай добавил к нашим правилам ещё одно «перед проверкой – чистить артефакты» и по возможности все – вспоминаем случай с транзитивными зависимостями.
Упомянутые вопросы встретились у нас, при этом они возникали постепенно, давая понять, насколько процесс обновления зависимостей по-настоящему комплексный. Но в нашем случае так произошло, потому что глобальные изменения модифицировали всё и сразу, при этом были введены новые нефункциональные требования к системе, которые предполагали ещё бОльшую модернизацию.
Какой же вывод из этого всего?
На основе нашего опыта хочется описать примерный план, которому стоит следовать при обновлении зависимостей в проекте. Примерный он потому, что действия, которые необходимо предпринять, зависят от цели, ради которой зависимости обновляются. Также шаги плана могут зависеть от изначального состояния системы, используемых инструментов, сложности проекта, скрупулезности, с которой осуществляется процесс обновления. Говоря о скрупулёзности, стоит заметить, что новые цели могут появиться уже по ходу работы, что, несомненно, приведёт к корректировке плана.
Итак, примерный план:
-
Определиться, для чего обновление необходимо:
Обновление фреймворка
Переход на иную целевую платформу
Смена инфраструктурных компонентов
Устранение уязвимостей
Удовлетворение требований заказчика
Соответствие стандартам
Приведение «зоопарка» зависимостей к единому виду – «причёсывание» зависимостей
Изменение ценовой политики проекта/зависимостей
Комбинация целей
-
Сформировать список того, что необходимо обновить. В большинстве случаев зависит от цели, но могут появиться ещё и дополнительные цели обновления:
Определиться с инструментом для сбора перечня зависимостей для обновления
Что обновляем?
Где и как используется сейчас?
Какие есть неявные зависимости?
Действительно ли рассматриваемый компонент понадобится в будущем?
Присутствуют ли дублирующие зависимости?
Выбрать методы обновления: ручные/автоматизированные
-
Выбрать целевые версии/продукт, на которые(й) будет осуществлён переход:
Проверить соответствие требованиям
Проверить на безопасность
Проверить, что можно ещё заменить новой зависимостью?
Проверить, можно ли свести подобные/родственные зависимости к одному виду – одна версия/один инструмент для схожих задач
Выбрать версию
-
Обновить зависимость:
Поднять/опустить версию, заменить/удалить продукт
Обновить связанные инструменты/неявные зависимости
Переписать код, если необходимо
Избавиться от артефактов предыдущей зависимости
-
Проверить работу обновлённой зависимости:
Построение проекта
Проверка работоспособности и реального соответствия требованиям: автотесты, ручные сценарии. При этом убедиться, действительно ли обновлённая зависимость выдаёт то, что было ранее/было описано в требованиях
Проверить работоспособность на различных стендах: разработка, тестирование, препрод
Стоит отметить, что опыт, описанный выше — это результат, который получился на вводных нашего проекта, и он может отличаться от опыта на других. И всё же, хочу верить, что описанные нюансы станут полезны сообществу. А также сподвигнут поделиться своим опытом, историями, нюансами, методиками, инструментами автоматизации данного процесса, а также возможной критикой наших подходов. Ведь как известно, на Хабре настоящая истина порой рождается в комментариях?
P.S. Хочется сказать спасибо всем, кто прочитал статью, и пожелать успехов в Вашем обновлении зависимостей.
AleksandrIpatov
Крутой опыт, спасибо за статью