Контекст
Одна из проблем в разработке - это дублирование. Когда код дублируется, это означает, что любые изменения в нем потребуют внесения изменений в двух местах. В процессе эволюции разработки сформировалось понимание, что код нужно стараться переиспользовать, а не дублировать. Закрепился успех этой идеи вместе с распространением пакетных менеджеров, которые упростили возможность получать и обновлять пакеты с кодом. Но не везде от дублирования избавляются. Когда речь заходит о пакетах в dotnet, то сразу на ум приходят NuGet пакеты с C# кодом, различные библиотеки, которые подключаются к проектам, чтобы в них использовать предоставляемый API. Но помимо C# кода в проекте существуют ещё .csproj файлы. Далее будет рассмотрено, как можно применить пакетные менеджеры для уменьшения дублирования в таких файлах.
Две основные проблемы, которые решаются данным подходом:
- Упрощение поддержки существующих solution’ов. Время идёт, требования меняются, новые версии dotnet’а выходят. Ходить и обновлять каждый пакет, следить, чтобы изменения попадали во все репозитории сложно.
- Добавление новых пакетов. Чем больше надстроек над проектами, тем сложнее создавать новые проекты. Обычно появляются чек-листы создания новых проектов, которые состоят из большого количества шагов (скопировать что-то с существующего репозитория).
Пример пакета, который в итоге получился, лежит в открытом доступе: kysect/SolutionDefaults: Kysect.SolutionDefaults is NuGet packages for distributing solution configuration via package manager (github.com).
Какие данные нужно переиспользовать
.csproj
файл описывает единицу сборки - проект. Он содержит много информации о конкретном проекте, которую не нужно переиспользовать: список файлов, связи с другими проектами, список используемых пакетов. Но есть и те, которые хочется задать на уровне всего solution’а или даже на уровне группы схожих solution’а.
Первая категория таких настроек была упомянута в 2023-12-23-Introduction-to-Roslyn-analyzers-using - настройки анализатора. Там же была описана идея с использованием пакетного менеджера для их распространения - предшественник данного текста:
Вторая категория - это настройка сборки. К ней можно причислить различные MSBuild свойства, которые задают поведение сборки: LangVersion, ImplicitUsings, UseArtifactsOutput. И с каждым релизом новой версии dotnet появляется что-то ещё:
Третья категория - настройки связанные с публикацией NuGet’ов - sourcelink, snupkg, determeministic build:
Четвёртая категория - метаданные, которые прямого отношения к коду не имеют, то могут являться важной частью сборки - ссылка на репозиторий с кодом, список авторов, лицензия, лого:
Как NuGet’ы встраиваются в процесс сборки
Для простоты рассказа дальше будет использовать вполне конкретный пример - NuGet пакет Kysect.SolutionDefaults который добавляется в проект Kysect.CommonLib.
Почему использовать NuGet:
- Привычный для разработчиков инструмент
- Интегрирован с экосистемой dotnet, нет необходимости писать дополнительные скрипты, внедрять их в процесс сборки
- Версионирование из коробки
А дальше сложная часть всего процесса. Нужно разобраться с процессом попадания NuGet-пакета в проект и найти точки расширения, где можно добавить свою логику.
Рассмотрим шаги, которые проходит NuGet:
- Добавление пакета в проект. Само добавление не оказывает влияния на проект и исходный код до начала выполнения команд MSBuild.
- Restore. Во время выполнения Restore’а пакет скачивается из NuGet repository (обычно nuget.org) в директорию с NuGet caches. Сам nupkg файл - это архив, в котором находятся другие файлы. Этот архив распаковывается после скачивания, файлы раскладываются в
.nuget/packges/{package-name}/{package-version}
. - Во время компиляции MSBuild добавляет ссылку на распакованные
.dll
файлы - После компиляции выполняется MSBuild target для копирования всех зависимостей в bin или publish/ директорию.
Доставляем .props файл
Из такого описания может показаться, что пакеты могут повлиять только на компиляции и добиться доставки конфигураций в .csproj
не получится. Но у пакетов есть особенности, которые в большинстве обычных пакетов не используются. Внутри NuGet-пакета можно создать build/ директорию. Эта директория специальным образом обрабатывается MSBuild’ом. MSBuild пытается найти в ней файлы, которые называются именам пакета с расширениями .props и .target. Если такие файлы будут найдены, то во время Restore’а в obj директории будет сгенерирован для проекта файл ProjectName.csproj.nuget.g.props в котором среди прочего будет такой блок:
Это значит, что во время выполнения MSBuild команд будет подключён .props файл из нашего пакета. А значит вклинится в сборку таки можно. Важным ограничением является то, что название .props
файла должно соответствовать шаблону {PackageName}.props
. В противном случае будет сгенерирована ошибка:
error NU5129: Warning As Error: - At least one .props file was found in 'build/', but'build/OtherName.props' was not.
Окей, создаём проект Kysect.SolutionDefaults.csproj, создаём файл Kysect.SolutionDefaults.props, прописываем там все необходимые настройки, собираем пакет, добавляем в проект:
Получаем ошибку связанную с UseArtifactsOutput
:
error NETSDK1199: The ArtifactsPath and UseArtifactsOutput properties c
annot be set in a project file, due to MSBuild ordering constraints. They must be set in a Directory.Build.props file or from the command line.
Эта проблема вызвана тем, что .props
файл подключается в каждый проект независимо. Directory.Build.props
концептуально работает таким же образом, то для него команда dotnet прописала отдельную логику, чтобы UseArtifactsOutput
таки можно было выставить. А вот добавление UseArtifactsOutput
в обычный проект (напрямую через .csproj
или через сторонние .props
файлы) будет заканчиваться с ошибкой. Это ограничение делает невозможным выставлять свойство UseArtifatcsOutput
изнутри пакета.
Все остальные параметры должны работать корректно и будут учитываться при сборке проекта, к которому подключили пакет.
Доставляем иконку для пакета
NuGet пакеты могут содержать иконку. Это ещё одна настройка, которую хотелось бы не настраивать в каждом отдельном проекте, а вместо этого сделать одну общую иконку и доставлять её пакетным менеджером. Сложность такого трюка в том, что сам файл нужно доставить пакетом, .props файла для этого недостаточно. В такой ситуации нужно прибегать ко второй “специальной” директории - /content
. В эту директорию можно добавлять файлы, которые нужно запаковать, чтобы потом иметь к ним доступ:
Теперь до этого файла нужно добраться из MSBuild’а. Тут нужно вспомнить, что пакет - это архив, который разворачивается после скачивания и хранится в кэше. Точкой входа будет .props файл, который уже был добавлен в процесс сборки. Если открыть директорию, куда кэшируется пакет, то можно найти такую структуру:
/build/Kysect.SolutionDefaults.props
/content/Default-icon.png
/lib/...
Kysect.SolutionDefaults.x.y.z.nupkg
К счастью, MSBuild предоставляет удобный механизм, чтобы получить полный путь к текущему .props файлу - $(MSBuildThisFileDirectory)
. А значит путь к иконке можно получить написав $(MSBuildThisFileDirectory)../content/Default-icon.png
. Последний шаг - это прописать в .props файле добавление этой иконки:
<None Include="...">
добавит иконку в пакет во время создания. PackagePath
- это путь, где внутри пакета будет файл хранится, он должен быть указан в PackageIcon
, чтобы использовать в качестве иконки пакета.
Важно понимать, что данный код добавляет элемент в проект, а значит этот элемент будет также отображаться в IDE как если бы файл лежал в директории с проектом. Чтобы файл не отображался в IDE можно выставить атрибут Visible в false.
Доставляем .editorconfig и всё что угодно
Но возможность подложить .props файл открывает намного больше возможностей для интеграции со сборкой. Рассмотрим более сложную задачу. Нужно доставлять .editorconfig файл. Первый шаг аналогичен добавлению иконки:
Важным отличием является необходимость добавить NoDefaultExcludes
. MSBuild исключается из пакета все файлы, которые начинаются с точки. NoDefaultExcludes
позволяет отключить это поведение. Теперь этот файл нужно скопировать в директорию solution’а, чтобы он применился. Это легко сделать зная, что в .props файле можно прописывать собственные target’ы, который MSBuild будет выполнять:
Данный target будет вызывать MSBuild таску Copy, которая будет копировать файл в директорию solution’а. К сожалению, это будет работать только если выполнять сборку solution’а. При запуске сборки отдельного проекта переменная $(SolutionDir)
не инициализируется и получить путь solution’а невозможно. (варианты с эвристикой и поиском по маске не рассматриваются). Поэтому лучше добавить условие пропуска target’а, если сборка запущена для проекта. Таким образом можно собрать один раз solution’а, а дальше можно запускать отдельно проекты и проблем не будет.
Bonus: DevelopmentDependency
Полученный пакет является вспомогательным для сборки. В отличие от большинства обычных пакетов он не нужен в итоговой сборке, не нужен для работы приложения. И для подобных пакетов у MSBuild’а есть специальное свойство - DevelopmentDependency
. Если выставить его в true, то MSBuild при добавлении пакета в другой проект будет автоматически добавлять атрибуты к PackageReference
:
Выставив эти атрибуты пакет не будет транзитивно тянуться в другие, не будет копировать в bin/ и publish/. Хорошей практикой является добавлять такой атрибут к подобным пакетам, пакетам с compile-time информацией, анализаторам, генераторам.