[Программирование, .NET] Создание пакета NuGet для библиотеки с платформозависимым API

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
24-Июн-2021 16:34

Я разрабатываю .NET библиотеку для работы с MIDI файлами и MIDI устройствами – DryWetMIDI. Большинство API библиотеки кроссплатформенное (в рамках поддерживаемых .NET систем, конечно же), однако работа с MIDI устройствами различна на разных операционных системах. На данный момент соответствующий API библиотеки работает только на Windows, однако есть большое желание обеспечить его работу и на других системах. Не буду бросаться в поддержку всего и вся, посему сначала собираюсь поддержать macOS, тем более что данная операционная система не менее популярна для работы с музыкой, а может даже и самая популярная в профессиональных кругах.Разумеется, нет смысла заниматься сразу реализацией реального API, проще проверить всё на маленьком примере. Именно так я и поступил, и хочу пройти путь до работающего решения ещё раз вместе с вами. Краткий список шагов будет приведён в конце статьи.Первые попыткиМоя библиотека, как и положено оной в мире .NET, поставляется в виде NuGet пакета. Поэтому я сразу понял, что в нём нужно будет также поставлять нативные библиотеки, предоставляющие API для конкретной операционной системы.В C# мы можем написать такое определение внешней функции:
[DllImport("test")]
public static extern int Foo();
То бишь нет нужды указывать расширение нативной библиотеки, .NET подставит нужное на основе текущей операционной системы. Иными словами, если рядом с нашим приложением будут лежать файлы test.dll и test.dylib, то на Windows вызовется функция Foo из test.dll, а на macOS – из test.dylib. Можно масштабировать и на *nix, поставляя файл test.so.Чтобы двинуться дальше, создадим проект нашей тестовой библиотеки. В файле .csproj DryWetMIDI указаныTFM netstandard2.0 и net45, поэтому для тестового проекта я также указал эти целевые платформы для приближения к реальным условиям. Проект назовём DualLibClassLibrary, внутри будет всего один файл Class.cs:
using System.Runtime.InteropServices;
namespace DualLibClassLibrary
{
    public static class Class
    {
        [DllImport("test")]
        public static extern int Foo();
        public static int Bar()
        {
            return Foo() * 1000;
        }
    }
}
Кроме того, нам, разумеется, нужны сами нативные сборки (test.dll и test.dylib). Я собрал их из простого кода на C (к слову, такого подхода буду придерживаться затем и в реальной библиотеке):для Windows
int Foo() { return 123; }
для macOS
int Foo() { return 456; }
Если интересно, файлы test.dll и test.dylib создавал в рамках тестового пайплайна в Azure DevOps (в действительности двух, для Windows и macOS). В конце концов, мне нужно будет делать всё в рамках CI, так что решил сразу проверить, как всё будет происходить в реальности. Пайплайн простой, состоит из 3 шагов:1. сгенерировать файл с кодом на C (задача PowerShell):
New-Item "test.c" -ItemType File -Value "int Foo() { return 123; }"
(return 456; для macOS);2. собрать библиотеку (задача Command Line):
gcc -v -c test.c
gcc -v -shared -o test.dll test.o
(test.dylib для macOS);3. опубликовать артефакт с библиотекой (задача Publish Pipeline Artifacts).Итак, имеем файлы test.dll и test.dylib, предоставляющие одну и ту же функцию Foo, которая для Windows возвращает 123, а для macOS – 456, так что мы всегда сможем проверить корректность вызова и результата. Файлы положим рядом с DualLibClassLibrary.csproj.Теперь нужно понять, как добавить их в NuGet пакет так, чтобы после установки пакета они копировались в выходную директорию при сборке приложения, обеспечивая таким образом работу установленной библиотеки. Так как библиотека у нас кроссплатформенная и использует новый формат файла .csproj (SDK style), очень хочется там и объявить инструкции для упаковки файлов. Изучив немного вопрос, пришёл к такому содержимому .csproj:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>netstandard2.0;net45</TargetFrameworks>
    <LangVersion>6</LangVersion>
    <Configurations>Debug;Release</Configurations>
  </PropertyGroup>
  <PropertyGroup>
    <PackageId>DualLibClassLibrary</PackageId>
    <Version>1.0.0</Version>
    <Authors>melanchall</Authors>
    <Owners>melanchall</Owners>
    <Description>Dual-lib class library</Description>
    <Copyright>Copyright ​ Melanchall 2021</Copyright>
    <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
  </PropertyGroup>
  <ItemGroup>
    <Content Include="test.dll">
      <Pack>true</Pack>
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
    <Content Include="test.dylib">
      <Pack>true</Pack>
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
  </ItemGroup>
</Project>
Собираем пакет:dotnet pack .\DualLibClassLibrary.sln -c ReleaseДабы проверить установку пакета, создадим папку (я назвал её TestFeed) где-нибудь и укажем её в качестве источника пакетов в Visual Studio. Внутрь положим полученный файл DualLibClassLibrary.1.0.0.nupkg. Установку пакета проверим в старом добром классическом .NET Framework на Windows. Создаём консольное приложение, устанавливаем нашу библиотеку. В проекте действительно появляются два файла:
Файлы test.dll и test.dylib добавились из пакетаВыглядит обнадёживающе, пишем в файле Program.cs простой код:
static void Main(string[] args)
{
    var result = DualLibClassLibrary.Class.Bar();
    Console.WriteLine($"Result = {result}. Press any key to exit...");
    Console.ReadKey();
}
Запускаем и грустим:
Программа не нашла файл test.dllЧто ж, заглянем в папку bin/Debug:
Файлы test.dll и test.dylib отсутствуют в выходной директории приложенияИ правда нет файлов. Как же так, <CopyToOutputDirectory> мы им указали, в структуре проекта файлы видны. Проверив содержимое .csproj нашего приложения, всё становится понятно:
В csproj полный беспорядок с добавленными файламиВо-первых, элемент <CopyToOutputDirectory> отсутствует, а во-вторых, по неведомой причине test.dylib добавился как элемент <None>, а test.dll как элемент <Content>. Остаётся только посмотреть содержимое файла .nupkg. Воспользовавшись программой NuGet Package Explorer, видим следующий манифест:
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
  <metadata>
    <id>DualLibClassLibrary</id>
    <version>1.0.0</version>
    <authors>melanchall</authors>
    <owners></owners>
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <description>Dual-lib class library</description>
    <copyright>Copyright ​ Melanchall 2021</copyright>
    <dependencies>
      <group targetFramework=".NETFramework4.5" />
      <group targetFramework=".NETStandard2.0" />
    </dependencies>
    <contentFiles>
      <files include="any/net45/test.dll" buildAction="Content" />
      <files include="any/netstandard2.0/test.dll" buildAction="Content" />
      <files include="any/net45/test.dylib" buildAction="Content" />
      <files include="any/netstandard2.0/test.dylib" buildAction="Content" />
    </contentFiles>
  </metadata>
</package>
Как видим, файлы добавились без атрибута copyToOutput, что печально (про атрибут можно почитать в таблице тут: Using the contentFiles element for content files).Копирование файлов в выходную директорию при сборке приложенияПолистав некоторое время просторы интернета в виде issues на GitHub, ответов на StackOverflow и официальной документации Microsoft, видоизменил элементы включения файлов в .csproj библиотеки:
<Content Include="test.dll">
  <Pack>true</Pack>
  <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  <PackageCopyToOutput>true</PackageCopyToOutput>
  <PackagePath>contentFiles;content</PackagePath>
</Content>
<Content Include="test.dylib">
  <Pack>true</Pack>
  <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  <PackageCopyToOutput>true</PackageCopyToOutput>
  <PackagePath>contentFiles;content</PackagePath>
</Content>
Элемент <PackageCopyToOutput> как раз должен привнести атрибут copyToOutput в манифест пакета. Кроме того, явно указал папки, куда нужно положить файлы, дабы избежать директорий вроде any. Подробнее о том, как всё это работает, можно почитать тут: Including content in a package.Собираем снова наш пакет и проверяем манифест:
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
  <metadata>
    <id>DualLibClassLibrary</id>
    <version>1.0.1</version>
    <authors>melanchall</authors>
    <owners></owners>
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <description>Dual-lib class library</description>
    <copyright>Copyright ​ Melanchall 2021</copyright>
    <dependencies>
      <group targetFramework=".NETFramework4.5" />
      <group targetFramework=".NETStandard2.0" />
    </dependencies>
    <contentFiles>
      <files include="test.dll" buildAction="Content" copyToOutput="true" />
      <files include="test.dylib" buildAction="Content" copyToOutput="true" />
    </contentFiles>
  </metadata>
</package>
Теперь всё выглядит куда лучше, простая структура файлов и атрибут copyToOutput на месте. Устанавливаем библиотеку в наше консольное приложение и запускаем:
copyToOutput ситуацию не спасаетИ снова неудача. Проверим в аналогичном консольном приложении, но на .NET 5:
Всё так же файлов нет в выходной директории приложенияКроме слегка изменённого текста исключения разницы не видно. Отписался в issue по итогу, на что мне ответили:
Please see our docs on contentFiles. It supports adding different content depending on project's target framework and language, and therefore needs files in a specific structure which your package is not currently using.
Оказалось, что я проглядел документацию, и, действительно, если файлы добавлять не по пути contentFiles, а по, например, contentFiles/any/netstandard2.0, то-таки да, автоматически создаётся .props файл, содержащий правильные элементы для файлов. Однако я свои исследования вёл до получения этого ответа, посему пошёл другим путём. И, как оказалось, верным, ибо подход с contentFiles исключает возможность использования пакета в .NET Framework приложениях, а я считаю, что этот сценарий обязан быть поддержан.Есть статья в документации Microsoft с подозрительно нужным заголовком: Creating native packages. Статья не сильно содержательная, однако кое-что полезное из неё можно почерпнуть. А именно, что можно сделать файл .targets, где мы и укажем <CopyToOutputDirectory> нашим файлам. Сам файл .targets мы включим в пакет вместе с нативными библиотеками. Сказано – сделано. Создаём файл DualLibClassLibrary.targets:
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <ItemGroup>
    <None Include="$(MSBuildThisFileDirectory)test.dll">
      <Link>test.dll</Link>
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Include="$(MSBuildThisFileDirectory)test.dylib">
      <Link>test.dylib</Link>
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>
</Project>
А в файле DualLibClassLibrary.csproj пропишем:
<ItemGroup>
  <None Include="test.dll">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    <PackagePath>build\</PackagePath>
    <Pack>true</Pack>
  </None>
  <None Include="test.dylib">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    <PackagePath>build\</PackagePath>
    <Pack>true</Pack>
  </None>
  <None Include="DualLibClassLibrary.targets">
    <PackagePath>build\</PackagePath>
    <Pack>true</Pack>
  </None>
</ItemGroup>
Собираем версию 1.0.2, устанавливаем в наше консольное приложение .NET Framework и запускаем:
Ошибка уже другаяДанная ошибка может возникнуть из-за несоответствующей разрядности приложения и нативных сборок. Я собирал их на 64-битных системах, приложение запускаю также в 64-битной ОС. Что ж, продолжаем наше путешествие.Поддержка 32- и 64-битных процессовЕсли зайти в свойства проекта приложения в Visual Studio на вкладку Build, обнаружим такую опцию:
Процесс будет 32-битнымОказывается, для проектов .NET Framework она включена по умолчанию, а процесс приложения будет 32-битным даже на 64-битной операционной системе. Забавно, что в .NET Core/.NET 5+ опция по умолчанию выключена:
А в .NET Core опция выключенаМожно, конечно, выключить эту опцию, и приложение наконец напечатает верный результат:Result = 123000. Press any key to exit...Но, разумеется, это не решение по следующим причинам:
  • не будет возможности использовать библиотеку в 32-битных процессах;
  • придётся требовать от пользователей лишних действий в виде отключения галки;
  • классический дефолтный сценарий (создать новое приложение .NET Framework безо всяких дополнительных манипуляций) оказывается нерабочим.
Конечно же, так никуда не годится, и проблему нужно победить. На самом деле, вариант тут очевиден: сделать нативные сборки для каждой операционной системы в двух вариантах – 32- и 64-битном. То есть поставка пакета чуть распухнет, вместо 2 платформозависимых библиотек внутри будут 4. Я в этом ничего плохого не вижу, ибо файлы всё равно небольшие, а потому буду продолжать именно с этим подходом (тем более, что иного не придумал).Немного расскажу о том, как собирал 32-битные версии библиотек. Как я упоминал выше, я произвожу сборку в конвейерах Azure DevOps через gcc. У gcc есть флаг -m32, который, по идее, должен как раз собрать 32-битную библиотеку. На сборочных агентах с macOS всё здорово, а вот на Windows получил нелицеприятные логи:C:/ProgramData/Chocolatey/lib/mingw/tools/install/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/8.1.0/../../../../x86_64-w64-mingw32/bin/ld.exe: skipping incompatible C:/ProgramData/Chocolatey/lib/mingw/tools/install/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/8.1.0/../../../../x86_64-w64-mingw32/lib\libuser32.a when searching for -luser32...C:/ProgramData/Chocolatey/lib/mingw/tools/install/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/8.1.0/../../../../x86_64-w64-mingw32/lib/libmsvcrt.a when searching for -lmsvcrtC:/ProgramData/Chocolatey/lib/mingw/tools/install/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/8.1.0/../../../../x86_64-w64-mingw32/bin/ld.exe: cannot find -lmsvcrtcollect2.exe: error: ld returned 1 exit statusЗадав вопрос и на StackOverflow, и в Microsoft Developer Community, выяснилось, что на агентах Microsoft не предустановлен 32-битный MinGW, что и приводит к падению. Попробовав множество вариантов, я остановился на проекте brechtsanders/winlibs_mingw, придя к простому PowerShell скрипту:
Write-Host "Downloading winlibs..."
Invoke-WebRequest -Uri "https://github.com/brechtsanders/winlibs_mingw/releases/download/11.1.0-12.0.0-9.0.0-r1/winlibs-i686-posix-dwarf-gcc-11.1.0-mingw-w64-9.0.0-r1.zip" -OutFile "winlibs.zip"
Write-Host "Downloaded."
Write-Host "Extracting winlibs..."
Expand-Archive -LiteralPath 'winlibs.zip' -DestinationPath "winlibs"
Write-Host "Extracted."
Write-Host "Building DLL..."
$gccPath = Get-ChildItem -Path "winlibs" -File -Filter "i686-w64-mingw32-gcc.exe" -Recurse
& $gccPath.FullName -c test.c -m32
& $gccPath.FullName -shared -o test.dll test.o -m32
Write-Host "Built."
Используя поставляемый в составе архива компилятор i686-w64-mingw32-gcc.exe, удалось наконец-таки собрать 32-битный файл test.dll. Ура!Теперь осталось придумать, как заставить нашу библиотеку вызывать API либо из 32- либо из 64-битной сборки. Я думаю, варианты тут есть разные, я остановился на таком:
  • собираем нативные библиотеки test32.dll, test64.dll, test32.dylib и test64.dylib;
  • делаем абстрактный класс Api с абстрактными методами, соответствующими нашему managed API для внутреннего использования;
  • делаем два наследника Api32 и Api64, в которых реализуем абстрактный API из родительского класса, вызывая unmanaged API из test32 и test64 соответственно;
  • делаем класс ApiProvider, чьё свойство Api будет отдавать нам реализацию, соответствующую разрядности текущего процесса.
Приведу код файлов:Api.cs
namespace DualLibClassLibrary
{
    internal abstract class Api
    {
        public abstract int Method();
    }
}
Api32.cs
using System.Runtime.InteropServices;
namespace DualLibClassLibrary
{
    internal sealed class Api32 : Api
    {
        [DllImport("test32")]
        public static extern int Foo();
        public override int Method()
        {
            return Foo();
        }
    }
}
Api64.cs
using System.Runtime.InteropServices;
namespace DualLibClassLibrary
{
    internal sealed class Api64 : Api
    {
        [DllImport("test64")]
        public static extern int Foo();
        public override int Method()
        {
            return Foo();
        }
    }
}
ApiProvider.cs
using System;
namespace DualLibClassLibrary
{
    internal static class ApiProvider
    {
        private static readonly bool Is64Bit = IntPtr.Size == 8;
        private static Api _api;
        public static Api Api
        {
            get
            {
                if (_api == null)
                    _api = Is64Bit ? (Api)new Api64() : new Api32();
                return _api;
            }
        }
    }
}
И тогда код нашего класса Class будет таким:
namespace DualLibClassLibrary
{
    public static class Class
    {
        public static int Bar()
        {
            return ApiProvider.Api.Method() * 1000;
        }
    }
}
Собрав пакет (разумеется, обновив предварительно содержимое файлов DualLibClassLibrary.targets и DualLibClassLibrary.csproj, добавив новые файлы), убедимся, что метод нашей библиотеки работает корректно при любой разрядности процесса приложения.ЗаключениеЯ привёл полную хронологию моих мытарств касаемо создания NuGet пакета с платформозависимым API, но будет полезно кратко перечислить основные моменты (я же обещал инструкцию):
  • создать нативные сборки, причём в двух вариантах: 32- и 64-битном;
  • положить их рядом с проектом библиотеки (можно и в папку какую-то, главное путь указать к ним потом верный);
  • добавить файл .targets, в котором для всех нативных сборок добавить элемент <CopyToOutputDirectory> с желаемым значением;
  • в файле .csproj библиотеки прописать упаковку как нативных сборок, так и файла .targets (должен пойти в папку build пакета);
  • реализовать механизм выбора нужной версии нативной сборки в зависимости от разрядности процесса.
Это всё. Солюшн нашей тестовой библиотеки можно взять отсюда: DualLibClassLibrary.zip. Решение было проверено в следующих сценариях на Windows и macOS:
  • .NET Framework приложение;
  • .NET Core / .NET 5 приложение;
  • Self-contained приложение.
Касаемо проверки в 32- и 64-битном процессах – проверял только на Windows, не уверен, как проверить это на macOS.Стоит заметить, что .NET на данный момент поддерживает только десктопные операционные системы. Однако в .NET 6 заявляется поддержка также и мобильных платформ. Если честно, не уверен, сработает ли описанный в статье подход там. Думаю, что для iOS файл dylib спокойно подойдёт (или нет?), а касаемо Android нужно думать отдельно. Может, кто-то уже сталкивался и подскажет в комментариях?Спасибо за прочтение!
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_programmirovanie (Программирование), #_.net, #_nuget, #_.net, #_krossplatformennost (кроссплатформенность), #_programmirovanie (
Программирование
)
, #_.net
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 14:28
Часовой пояс: UTC + 5