Эффективное применение NuGet. Часть 2: свои пакеты и декомпозиция монореп

от автора

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

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

Зачем делать пакет, если есть ProjectReference?

Проще всего переиспользовать код между проектами одного решения через ссылку на проект:

<ItemGroup>  <ProjectReference Include="..\Acme.Common\Acme.Common.csproj" /></ItemGroup>

Пока всё живёт в одном солюшене (.sln) и собирается одной командой, это идеальный вариант. Версии и публикация не нужны, изменения видны мгновенно.

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

Простое правило: внутри одного солюшена подходит ProjectReference, а между репозиториями нужен пакет.

Создаём первый пакет

Чтобы собрать пакет из сборки, используется команда dotnet pack. Возьмём обычную библиотеку:

<Project Sdk="Microsoft.NET.Sdk">  <PropertyGroup>    <TargetFramework>net9.0</TargetFramework>    <PackageId>Acme.Common</PackageId>    <Version>1.0.0</Version>    <Authors>Acme Team</Authors>    <Description>Общие утилиты для проектов Acme.</Description>  </PropertyGroup></Project>

Если выполнить команду,

dotnet pack -c Release

в bin/Release появится файл Acme.Common.1.0.0.nupkg. Это и есть пакет: обычный zip-архив с вашими сборками и метаданными. Его можно отправить в приватную галерею командой:

dotnet nuget push --source $NUGET_SERVER --api-key $NUGET_SERVER_KEY bin/Release/Acme.Common.1.0.0.nupkg

Если не хочется каждый раз вызывать pack вручную, включите автоматическую упаковку при сборке:

<GeneratePackageOnBuild>true</GeneratePackageOnBuild>

Метаданные, которые стоит заполнить

Минимальный пакет соберётся, но работать с ним коллегам неудобно: непонятно, кто автор, что внутри и куда обращаться с вопросами. Стоит потратить несколько минут и заполнить метаданные, это окупится:

<PropertyGroup>  <PackageId>Acme.Common</PackageId>  <Version>1.2.0</Version>  <Authors>roman;ivan</Authors>  <Company>ЭРЕМЕКС</Company>  <Description>Хелперы для логирования, валидации и работы с конфигурацией.</Description>  <PackageTags>acme;utils;internal</PackageTags>  <PackageProjectUrl>https://git.acme.local/common</PackageProjectUrl>  <RepositoryUrl>https://git.acme.local/common.git</RepositoryUrl>  <RepositoryType>git</RepositoryType>  <PackageReadmeFile>README.md</PackageReadmeFile>  <PackageIcon>icon.png</PackageIcon></PropertyGroup>

Особенно полезен RepositoryUrl: по нему из галереи всегда можно перейти к исходникам и истории коммитов. А Description и PackageTags помогают находить пакет поиском внутри галереи, когда пакетов становится много.

Файл README.md и иконка не попадут в пакет автоматически, их нужно явно добавить внутрь:

<ItemGroup>  <None Include="README.md" Pack="true" PackagePath="\" />  <None Include="icon.png" Pack="true" PackagePath="\" /></ItemGroup>

README отображается прямо на странице пакета в галерее. Для внутренних библиотек это нередко единственная документация, которую действительно прочитают, поэтому пренебрегать им не стоит.

Для открытых проектов стоит добавить ещё SPDX-лицензию:

<PackageLicenseExpression>MIT</PackageLicenseExpression>

Версионирование

NuGet использует семантическое версионирование: MAJOR.MINOR.PATCH. Мажорная часть отвечает за несовместимые изменения, минорная за новую обратно совместимую функциональность, патч за исправления.

Для предрелизных версий используется суффикс:

<VersionPrefix>1.3.0</VersionPrefix><VersionSuffix>preview.2</VersionSuffix>

Получится 1.3.0-preview.2. Суффикс можно не задавать жёстко, а передавать на этапе сборки, что удобно для CI:

dotnet pack -c Release --version-suffix preview.2

Предрелизные версии не подтягиваются как обновление к стабильным, пока потребитель явно их не запросит. Это даёт безопасную песочницу: выкатили 2.0.0-preview.1, желающие протестировали, а на остальных он не повлиял.

Отладка: symbol-пакеты и встроенные исходники

Главная претензия к пакетам по сравнению с ProjectReference состоит в том, что не получается зайти отладчиком внутрь. Решается это символами и Source Link.

Самый надёжный для закрытого контура вариант заключается в том, чтобы встроить и PDB, и исходники прямо в сборку. Тогда не нужен ни внешний symbol-сервер, ни доступ к git-хосту во время отладки:

<PropertyGroup>  <DebugType>embedded</DebugType>  <EmbedAllSources>true</EmbedAllSources></PropertyGroup>

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

Если же символы удобнее отдавать отдельным файлом, используйте формат .snupkg:

<PropertyGroup>  <IncludeSymbols>true</IncludeSymbols>  <SymbolPackageFormat>snupkg</SymbolPackageFormat></PropertyGroup>

Детерминированная сборка

По умолчанию в сборку попадают абсолютные пути с той машины, где её собирали. Из-за этого один и тот же коммит, собранный на двух машинах, даёт разные бинарники, а в PDB прописаны пути вида C:\Users\roman\..., которых на машине коллеги нет.

Детерминированную сборку включают на CI следующими свойствами:

<PropertyGroup>  <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>  <Deterministic>true</Deterministic></PropertyGroup>

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

nuget.org как путь по умолчанию

Большая часть пакетов никогда не выходит за пределы компаний и остаётся в приватных галереях. Но если вы решили опубликовать пакет для всех, лучше всего подойдёт nuget.org. Это официальная галерея, и она уже подключена в любой IDE из коробки. Ни Visual Studio, ни Rider, ни dotnet настраивать не нужно: источник nuget.org прописан в глобальном NuGet.config по умолчанию, поэтому установка пакетов оттуда работает сразу после dotnet new. Это самый удобный для разработчика путь именно потому, что настраивать ничего не нужно.

API-ключ. Чтобы публиковать пакеты из консоли или CI, в настройках профиля на nuget.org создаётся ключ. Его стоит сделать ограниченным (scoped): разрешить только нужное действие (Push) и конкретный шаблон имён (например, Acme.*). Тогда даже утёкший ключ не позволит опубликовать что угодно от вашего имени.

dotnet nuget push Acme.Common.1.0.0.nupkg --api-key $NUGET_ORG_KEY --source https://api.nuget.org/v3/index.json

Зарезервируйте префикс. Опубликовать пакет почти с любым именем может кто угодно, поэтому теоретически кто-то способен занять Acme.SomethingShady и выдать его за ваш. Чтобы этого не произошло, зарезервируйте префикс ID. Резервирование привязывает шаблон (Acme.*) к вашей учётной записи: новые пакеты под этим префиксом сможете публиковать только вы, а на странице пакета и в Visual Studio появляется синяя отметка «verified», по которой потребитель видит, что пакет действительно ваш.

Префикс резервируется по заявке: нужно написать на account@nuget.org, указав ваше отображаемое имя (display name) на nuget.org и требуемый префикс. Команда nuget.org проверяет заявку по ряду критериев: префикс должен однозначно идентифицировать владельца, быть не короче четырёх символов и не быть общим словом (Data или Helpers вам не согласуют). Под уже зарезервированный префикс можно делегировать подпрефиксы другим владельцам. Это удобно, когда в организации много команд со своими учётными записями.

Проверка подписей пакетов

Как убедиться, что пакет не подменили по дороге? Здесь помогает подпись. Nuget.org автоматически проставляет репозиторную подпись (repository signature) на каждый загруженный пакет, что гарантирует целостность и подтверждает происхождение из nuget.org. Дополнительно автор может поставить свою авторскую подпись (author signature), но это опционально и требует сертификата для подписи кода (на сегодня авторская подпись поддерживается только nuget.exe под Windows).

Начиная с .NET 8 SDK проверка подписей включена по умолчанию: при каждом restore клиент проверяет, что подпись валидна. При необходимости её отключают переменной окружения:

DOTNET_NUGET_SIGNATURE_VERIFICATION=false

Проверить пакет можно и вручную:

dotnet nuget verify Acme.Common.1.0.0.nupkg --all

Команда покажет тип подписи (Author или Repository), сертификат, его отпечаток и срок действия.

По умолчанию NuGet доверяет любым авторам и репозиториям. В закрытом или чувствительном к безопасности контуре это ограничивают: настраивают доверенных подписантов (trusted signers), чтобы принимались только пакеты от конкретных издателей. Например, можно доверять пакетам с nuget.org и дополнительно только определённым владельцам:

dotnet nuget trust author MyTeam ./Acme.Common.1.0.0.nupkgdotnet nuget trust repository nuget.org ./Some.Package.nupkg --owners microsoft;acmedotnet nuget trust list

Эти команды записывают блок <trustedSigners> в nuget.config вместе с отпечатками сертификатов. После этого восстановление пакета, подписанного кем-то вне списка, будет отклонено. Такой nuget.config логично закоммитить в репозиторий, ровно как мы поступали с настройками приватной галереи в первой части.

Упаковка нативных сборок

Иногда библиотека тянет за собой нативные зависимости: библиотеки, написанные на C или C++, к которым обращаются через P/Invoke. NuGet умеет упаковывать их так, чтобы при сборке потребителя рядом с приложением оказывалась именно та нативная библиотека, которая нужна его платформе.

Есть соглашение о структуре папок внутри пакета. Управляемые сборки лежат в lib/{tfm}/, а нативные бинарники раскладываются по папкам runtimes/{rid}/native/, где {rid} это идентификатор среды выполнения (Runtime Identifier), например win-x64, linux-x64 или osx-arm64. Разложить файлы по этим путям при упаковке можно через PackagePath:

<ItemGroup>  <None Include="native\win-x64\acme.dll"        Pack="true" PackagePath="runtimes/win-x64/native/" />  <None Include="native\linux-x64\libacme.so"        Pack="true" PackagePath="runtimes/linux-x64/native/" /></ItemGroup>

Когда потребитель собирает или публикует проект под конкретный идентификатор среды (dotnet publish -r linux-x64), NuGet и SDK выбирают подходящую нативную библиотеку и копируют её в выходной каталог. Путь к ней при этом попадает в *.deps.json, а среда выполнения находит библиотеку по своим правилам поиска.

Есть одно ограничение, о котором стоит помнить. Соглашение runtimes/ рассчитано на .NET (5+) и на сборку под конкретный RID. Для проектов под .NET Framework в режиме AnyCPU оно не работает как есть, и там нативные зависимости подключают через собственные файлы .props и .targets (о них ниже).

Экзотические архитектуры и nuget

Каталог RID покрывает распространённые сочетания операционной системы и архитектуры, но сам идентификатор среды это по сути просто строка. Это открывает дорогу к весьма нестандартным платформам.

Нам, например, приходилось работать с процессорами «Эльбрус». В терминах .NET их идентификатор среды называется e2k. Мы собирали NuGet-пакеты по тем же правилам, что и для обычных нативных зависимостей: раскладывали бинарники в runtimes/e2k/native/ и указывали соответствующий RuntimeIdentifier. Никакой особой магии не потребовалось: соглашение о папках runtimes/{rid}/native/ работает с произвольным RID, и на «Эльбрусе» всё поднялось без проблем.

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

Упаковка расширений MSBuild

Пакет может добавлять в проект-потребитель не только сборки, но и логику сборки: свойства и цели MSBuild. Это удобно, когда библиотека должна, например, сгенерировать код перед компиляцией, подложить дополнительные настройки или скопировать нужные файлы.

Работает это по соглашению об именовании. Файлы .props и .targets кладутся в папку build/ внутри пакета, и их имя должно совпадать с идентификатором пакета: build/Acme.Common.props и build/Acme.Common.targets. При восстановлении NuGet автоматически импортирует их в проект, причём .props в начало, а .targets в конец. Если имя не совпадёт с PackageId, файл не подхватится, и NuGet выдаст предупреждение NU5129.

<ItemGroup>  <None Include="build\Acme.Common.props"        Pack="true" PackagePath="build/$(PackageId).props" />  <None Include="build\Acme.Common.targets"        Pack="true" PackagePath="build/$(PackageId).targets" /></ItemGroup>

Папка build/ подходит для большинства случаев. Если логику сборки нужно распространить и на тех, кто зависит от вашего пакета транзитивно, через промежуточную библиотеку, продублируйте файлы в папке buildTransitive/.

Декомпозиция монорепы

Представьте монорепу: один .sln на 80 проектов, холодная сборка идёт минутами, всё связано со всем, а изменение одной строки в общем хелпере пересобирает почти всё. Ситуация знакомая многим.

Пакеты помогают разрубить этот клубок:

  1. Находим устойчивое ядро, которое меняется редко и от которого зависят многие: общие утилиты, контракты, DTO, клиенты к внутренним сервисам.

  2. Выносим его в отдельный репозиторий и публикуем как пакет (или несколько) в приватную галерею.

  3. Прикладные репозитории подключают это ядро как обычную зависимость через PackageReference и центральный Directory.Packages.props.

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

Но есть и оборотная сторона, о которой нужно знать заранее. Любая правка в ядре теперь означает поднятие версии, публикацию и проверку. Если ядро меняется по десять раз в день, пакет только мешает. Выносите в пакет то, что стабильно и переиспользуется многими; то, что активно меняется, держите рядом через ProjectReference.

Локальная разработка: без публикации промежуточных версий

С одним общим солюшеном всё просто: поправили библиотеку, и приложение сразу видит изменения. После декомпозиции у нас два репозитория и два солюшена, а между ними появляется граница в виде опубликованного пакета.

Публиковать промежуточные версии в галерею при этом не обязательно. Потребителю можно отдать локальную папку как источник пакетов и складывать туда свежие .nupkg библиотеки прямо при сборке.

Пусть библиотека пакуется в общую папку при каждой сборке. Для этого в её .csproj (или в общем Directory.Build.props) добавим:

<PropertyGroup>  <GeneratePackageOnBuild>true</GeneratePackageOnBuild>  <PackageOutputPath>$(MSBuildThisFileDirectory)..\local-feed</PackageOutputPath></PropertyGroup>

Теперь обычная сборка библиотеки кладёт Acme.Common.<версия>.nupkg в папку local-feed. В репозитории-потребителе пропишем эту папку источником в nuget.config. Чтобы локальный источник не перехватывал вообще все пакеты, ограничим его через сопоставление источников (package source mapping): из локальной папки берём только свои Acme.*, а всё остальное из основной галереи.

<?xml version="1.0" encoding="utf-8"?><configuration>  <packageSources>    <clear />    <add key="local" value="..\local-feed" />    <add key="gallery" value="$NUGET_SERVER" allowInsecureConnections="true" />  </packageSources>  <packageSourceMapping>    <packageSource key="local">      <package pattern="Acme.*" />    </packageSource>    <packageSource key="gallery">      <package pattern="*" />    </packageSource>  </packageSourceMapping></configuration>

Кеши NuGet добавляют нюанс. Если пересобрать библиотеку с правками, но не поменять номер версии, потребитель изменений не увидит: эта версия уже распакована в кеше global-packages, и NuGet берёт её оттуда, не заглядывая в папку.

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

<PropertyGroup>  <VersionPrefix>1.3.0</VersionPrefix>  <VersionSuffix>dev.$([System.DateTime]::UtcNow.ToString(yyyyMMddHHmmss))</VersionSuffix></PropertyGroup>

Каждая сборка даёт новую предрелизную версию вроде 1.3.0-dev.20260627093015, и кеш больше не мешает. В проекте-потребителе ссылаемся на «плавающую» предрелизную версию, чтобы всегда подтягивалась самая свежая (в Directory.Packages.props из первой части):

<PackageVersion Include="Acme.Common" Version="1.3.0-dev.*" />

Также существует грубый запасной вариант: очищать кеш перед каждым обновлением.

dotnet nuget locals global-packages --clear

Альтернатива, если солюшены можно объединить. Локальный источник нужен, когда репозитории действительно разные. Но если на время разработки вы готовы собрать оба проекта в одном солюшене, проще временно вернуть ProjectReference и вообще обойтись без пакета. Переключение удобно сделать через MSBuild-свойство:

<ItemGroup Condition="'$(UseLocalCommon)' == 'true'">  <ProjectReference Include="..\..\acme-common\src\Acme.Common\Acme.Common.csproj" /></ItemGroup><ItemGroup Condition="'$(UseLocalCommon)' != 'true'">  <PackageReference Include="Acme.Common" /></ItemGroup>

Локально вы собираете с -p:UseLocalCommon=true и правите библиотеку как обычный проект с мгновенной обратной связью, а CI и остальные разработчики продолжают тянуть пакет.

Заключение

Мы прошли весь путь NuGet-пакета: от dotnet pack и заполнения метаданных до отладки через встроенные исходники, упаковки нативных сборок и расширений MSBuild, а также публикации в галерею. Попутно разобрались, когда пакет действительно нужен, а когда достаточно обычной ссылки на проект.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1055122/