Привет! Меня зовут Александр, я старший разработчик в команде, которая занимается оцифровкой документов. Когда мы разрабатываем пакеты библиотек компонентов, иногда возникает необходимость дополнить исполняемый код определенным контентом. Нередко этот контент нужен именно в виде отдельных файлов, а не встроенных ресурсов. Примерами таких задач могут быть различные пакеты .NET-оболочек, которым обычно необходимы исходные библиотеки. Нам же понадобились специальные шрифты во внутрикорпоративной библиотеке конвертации документов.
Мы видели два варианта решения проблемы:
Встроить шрифты как embedded-ресурсы и копировать их при инициализации библиотеки в целевую папку.
Добавить файлы в nuget-пакет.
Первое решение — это фактически хардкод. Если пользователь библиотеки захочет использовать свои шрифты вместо наших, мы все равно будем добавлять их в папку приложения при каждом запуске. Поэтому мы решили добавить файлы шрифтов в nuget-пакет.
Опишу решение и подводные камни, на которые наткнулся в процессе работы. На Хабре уже есть одна статья на эту тему. Я хотел бы подробнее рассказать о своем решении и обсудить некоторые моменты, которые не были разобраны в том материале.
Первая попытка (неудачная)
Сначала я решил установить для всех файлов свойства Build Action — None и Copy to Output Directory — Copy if never. Это решение отлично работает при прямых ссылках на проекты в солюшене, но в nuget-пакетах шрифты оказались доступны только в пакетах, которые напрямую ссылались на пакет со шрифтами.
Например, у нас есть nuget-пакет LibA, содержащий шрифты. LibA используется в nuget-пакете LibB, и при добавлении LibB в проект шрифты остаются доступны. LibB используется в nuget-пакете LibC, и при добавлении LibC в проект шрифты не добавляются.
Вторая попытка (удачная)
После долгого изучения MSDN и StackOverflow я пришел к выводу, что лучше сделать все вручную. То есть написать .nuspec-файл с описанием пакета и .props-файл с логикой, которая должна будет выполниться при сборке проекта.
Добавляем в проект папку buildTransitive, складываем в нее шрифты, .nuspec- и .props-файлы. Папку я назвал buildTransitive, потому что в пакете будет папка с таким же именем. Она нужна, чтобы каждый последующий пакет в цепочке ссылок имел доступ к шрифтам. Больше о ней можно узнать из документации.
Добавляем ссылки на .props и .nuspec в файле проекта.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
...
<NuspecFile>buildTransitive\LibA.nuspec</NuspecFile>
</PropertyGroup>
...
<Import Project="buildTransitive\LibA.props" />
</Project>
Открываем .nuspec-файл и описываем, какие файлы куда положить в нашем пакете.
<files>
<file src="LibA.props" target="buildTransitive" />
<file src="fonts\**" target="buildTransitive\fonts" />
<file src="..\bin\Debug\net6.0\LibA.dll" target="lib\net6.0\LibA.dll" />
</files>
В этом случае мы копируем файлы LibA.props и папку fonts в папку пакета buildTransitive, а собранный файл проекта — в папку пакета lib\net6.0\LibA.dll.
Внимание! Всегда используйте обратный слеш в путях! Иначе можно получить разный результат при сборке под Windows и Linux. Более подробно ситуация описана здесь.
Описываем логику копирования файлов шрифтов при сборке в .props-файле.
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)fonts\**" >
<Link>fonts\%(RecursiveDir)%(Filename)%(Extension)</Link>
<PackageCopyToOutputDirectory>PreserveNewest</PackageCopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>False</Visible>
</None>
</ItemGroup>
</Project>
В этом скрипте содержимое папки fonts из пакета при сборке будет рекурсивно скопировано в папку fonts в выходном каталоге.
Дополняем функциональность
На этом этапе уже получается собрать пакет со шрифтами, при использовании которого шрифты будут автоматически копироваться в выходной каталог независимо от длины цепочки ссылок на наш пакет.
Многие IDE позволяют заполнить данные об авторе, компании, описании пакета и так далее. В идеале эти метаданные о nuget-пакете нужно получать из файла проекта. Дополнительные переменные можно передать при сборке билда. В моем случае это была версия пакета.
Чтобы передать данные из файла проекта в .nuspec, используем тег NuspecProperties. В результате файл проекта будет выглядеть так:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<Version>$(PkgVersion)</Version>
<Authors>Authors list</Authors>
<Description>Project description</Description>
<NuspecFile>buildTransitive\LibA.nuspec</NuspecFile>
<NuspecProperties>$(NuspecProperties);PackageId=$(MSBuildProjectName)</NuspecProperties>
<NuspecProperties>$(NuspecProperties);PackageAuthors=$(Authors)</NuspecProperties>
<NuspecProperties>$(NuspecProperties);PackageDescription=$(Description)</NuspecProperties>
</PropertyGroup>
<Target Name="NuspecProperties" AfterTargets="Build">
<PropertyGroup>
<NuspecProperties>$(NuspecProperties);PackageVersion=$(Version)</NuspecProperties>
<NuspecProperties>$(NuspecProperties);PackageTargetPath=$(TargetPath)</NuspecProperties>
</PropertyGroup>
</Target>
<Import Project="buildTransitive\LibA.props" />
</Project>
Номер версии будет передан при сборке в переменной PkgVersion и записан в тег Version. Переменные из .nuspec передаются в переменную NuspecProperties парами «ключ — значение» и разделяются точкой с запятой. При этом данные о версии и конечном пути к выходному файлу сборки записываются после билда.
Если не передать версию в переменной PkgVersion и запустить сборку проекта, ее номер будет 1.0.0.
Сборка проекта на билд-машине запускается с помощью команды:
- dotnet build $ SOLUTION_FILE_PATH -c Release --no-restore -p:PkgVersion=$PACKAGE_VERSION --output outDir
В этом случае, если в файле проекта тег GeneratePackageOnBuild установлен в true, будет выполнена сборка проекта и создан nuget-пакет. Если тег GeneratePackageOnBuild установить в false и разделить операции dotnet build и dotnet pack, данные из файла проекта не попадут в .nuspec-файл.
Пример .nuspec-файла с добавленными переменными, объявленными в файле проекта:
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>$PackageId$</id>
<version>$PackageVersion$</version>
<authors>$PackageAuthors$</authors>
<description>$PackageDescription$</description>
<dependencies>
<group targetFramework="net6.0" />
</dependencies>
</metadata>
<files>
<file src="LibA.props" target="buildTransitive" />
<file src="fonts\**" target="buildTransitive\fonts" />
<file src="$PackageTargetPath$" target="lib\net6.0\LibA.dll" />
</files>
</package>
На этом все. Надеюсь, моя статья поможет кому-то сэкономить время и нервы! Тестовый проект можно посмотреть на GitHub.
Дополнительная информация
В моем тестовом проекте, который содержит шрифты, нет ссылок на другие пакеты. Если добавить ссылку на другой nuget-пакет, она будет записана в файл проекта и ее придется вручную прописать в .nuspec. Чтобы избежать ручной поддержки целостности ссылок, в рабочем проекте я вынес шрифты в отдельную сборку, в которой нет ничего кроме шрифтов, создал отдельный пакет и ссылался на него из других проектов.
Если при сборке нужно посмотреть значения переменных из файла проекта, это можно сделать с помощью тега Message.
<Project Sdk="Microsoft.NET.Sdk">
...
<Target Name="Log" AfterTargets="Build">
<Message Importance="High" Text="----------Build Variables-------------" />
<Message Importance="High" Text="MSBuildProjectName = $(MSBuildProjectName)" />
<Message Importance="High" Text="TargetPath = $(TargetPath)" />
<Message Importance="High" Text="NuspecProperties = $(NuspecProperties)" />
<Message Importance="High" Text="----------Build Variables-------------" />
</Target>
...
</Project>
Если у вас остались вопросы или вы хотите поделиться опытом работы с nuget-пакетами, добро пожаловать в комментарии.
kuda78
добавлю для несведущих, что имя
<package>.props
должно строго совпадать с именем выпускаемого пакетая бы добавил
<ItemGroup Condition="'$(_IsExecutable)' == 'true'">
для копирования файловых ресурсов только в "конечные" исполняемые проекты (в том числе тесты) а не во все промежуточные ClassLibrary, где они используютсямагию создания пакетов можно применять и без
nuspec
.dotnet pack
в большинстве случаев все делает сам. рекомендую посмотреть в сторону свойстваIsPackable
при создании data пакетов магией
dotnet pack
обратите внимание на свойстваIncludeBuildOutput
иSuppressDependenciesWhenPacking
просмотр свойств - рекомендую собрать проект из командной строки с ключиком
-bl
и воспользоваться замечательным инструментомMSBuild Structured Log Viewer
вопрос скорее религии, но для создание артефактов проекта я агитирую за
dotnet publish
иdotnet pack
а не создавать артефакты при каждой сборке проекта, так как придерживаюсь позиции, что сборка должна происходить как можно быстрее для уменьшения требуемого времени для запуска юнит тестов.Alex063 Автор
Спасибо за ценные дополнения. Попробую применить на практике.
По п. 3 и 4 - пытался выставить IncludeBuildOutput, SuppressDependenciesWhenPacking и IsPackable в true и собрать пакет без props и nuspec. Файлы в пакет так и не попали. Возможно я пропустил какой-то важный нюанс. Надо будет на досуге еще раз попытаться с ними все сделать.
П. 6 - абсолютно с вами согласен. В примере пакеты собираются при сборке для простоты.
kuda78
и на нем
dotnet pack
не скажу уже точно по какой причине, но всегда включаю и
build
иbuildTransitive
причем из второго запускаю импорт первого, а само копирование делаю именно вbuild
. ВозможноbuildTransitive
не запускается если пакет подключен непосредственно к "исполняемому проекту".Есть еще некоторое "неудобство" предложенного решения. Если подключить такой проект, например, к юнит тесту в этом же solution как project reference, то в целевом каталоге не будет нужных артефактов. Они появятся только при подключении как packageReference, т.е. нужно будет использовать какой-то, например локальный, репозиторий пакетов.
Alex063 Автор
Спасибо за пример!