Привет! Меня зовут Александр, я старший разработчик в команде, которая занимается оцифровкой документов. Когда мы разрабатываем пакеты библиотек компонентов, иногда возникает необходимость дополнить исполняемый код определенным контентом. Нередко этот контент нужен именно в виде отдельных файлов, а не встроенных ресурсов. Примерами таких задач могут быть различные пакеты .NET-оболочек, которым обычно необходимы исходные библиотеки. Нам же понадобились специальные шрифты во внутрикорпоративной библиотеке конвертации документов.

Мы видели два варианта решения проблемы:

  1. Встроить шрифты как embedded-ресурсы и копировать их при инициализации библиотеки в целевую папку.

  2. Добавить файлы в 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-пакетами, добро пожаловать в комментарии.

Комментарии (4)


  1. kuda78
    11.04.2023 08:30
    +1

    1. добавлю для несведущих, что имя <package>.props должно строго совпадать с именем выпускаемого пакета

    2. я бы добавил <ItemGroup Condition="'$(_IsExecutable)' == 'true'"> для копирования файловых ресурсов только в "конечные" исполняемые проекты (в том числе тесты) а не во все промежуточные ClassLibrary, где они используются

    3. магию создания пакетов можно применять и без nuspec. dotnet pack в большинстве случаев все делает сам. рекомендую посмотреть в сторону свойства IsPackable

    4. при создании data пакетов магией dotnet pack обратите внимание на свойства IncludeBuildOutput и SuppressDependenciesWhenPacking

    5. просмотр свойств - рекомендую собрать проект из командной строки с ключиком -bl и воспользоваться замечательным инструментом MSBuild Structured Log Viewer

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


    1. Alex063 Автор
      11.04.2023 08:30

      Спасибо за ценные дополнения. Попробую применить на практике.

      По п. 3 и 4 - пытался выставить IncludeBuildOutput, SuppressDependenciesWhenPacking и IsPackable в true и собрать пакет без props и nuspec. Файлы в пакет так и не попали. Возможно я пропустил какой-то важный нюанс. Надо будет на досуге еще раз попытаться с ними все сделать.

      П. 6 - абсолютно с вами согласен. В примере пакеты собираются при сборке для простоты.


      1. kuda78
        11.04.2023 08:30

        test.csproj
        <Project Sdk="Microsoft.NET.Sdk">
        
        	<PropertyGroup>
        		<TargetFramework>net6.0</TargetFramework>
        		<IsPackable>true</IsPackable>
        		<IncludeBuildOutput>false</IncludeBuildOutput>
        		<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
        	</PropertyGroup>
        
        	<ItemGroup>
        		<Content Include="$(MSBuildThisFileFullPath)" Pack="true" PackagePath="none\$(PackageId).props" PackageCopyToOutput="true" CopyToOutputDirectory="Always"/>
        	</ItemGroup>
        </Project>
        

        и на нем dotnet pack

        1. не скажу уже точно по какой причине, но всегда включаю и build и buildTransitive причем из второго запускаю импорт первого, а само копирование делаю именно в build. Возможно buildTransitive не запускается если пакет подключен непосредственно к "исполняемому проекту".

        2. Есть еще некоторое "неудобство" предложенного решения. Если подключить такой проект, например, к юнит тесту в этом же solution как project reference, то в целевом каталоге не будет нужных артефактов. Они появятся только при подключении как packageReference, т.е. нужно будет использовать какой-то, например локальный, репозиторий пакетов.


        1. Alex063 Автор
          11.04.2023 08:30

          Спасибо за пример!