Я разрабатываю .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 добавились из пакета
Файлы 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
Программа не нашла файл test.dll

Что ж, заглянем в папку bin/Debug:

Файлы test.dll и test.dylib отсутствуют в выходной директории приложения
Файлы test.dll и test.dylib отсутствуют в выходной директории приложения

И правда нет файлов. Как же так, <CopyToOutputDirectory> мы им указали, в структуре проекта файлы видны. Проверив содержимое .csproj нашего приложения, всё становится понятно:

В 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 ситуацию не спасает
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-битным
Процесс будет 32-битным

Оказывается, для проектов .NET Framework она включена по умолчанию, а процесс приложения будет 32-битным даже на 64-битной операционной системе. Забавно, что в .NET Core/.NET 5+ опция по умолчанию выключена:

А в .NET Core опция выключена
А в .NET Core опция выключена

Можно, конечно, выключить эту опцию, и приложение наконец напечатает верный результат:

Result = 123000. Press any key to exit...

Но, разумеется, это не решение по следующим причинам:

  1. не будет возможности использовать библиотеку в 32-битных процессах;

  2. придётся требовать от пользователей лишних действий в виде отключения галки;

  3. классический дефолтный сценарий (создать новое приложение .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 -lmsvcrt

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: cannot find -lmsvcrt

collect2.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-битной сборки. Я думаю, варианты тут есть разные, я остановился на таком:

  1. собираем нативные библиотеки test32.dll, test64.dll, test32.dylib и test64.dylib;

  2. делаем абстрактный класс Api с абстрактными методами, соответствующими нашему managed API для внутреннего использования;

  3. делаем два наследника Api32 и Api64, в которых реализуем абстрактный API из родительского класса, вызывая unmanaged API из test32 и test64 соответственно;

  4. делаем класс 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, но будет полезно кратко перечислить основные моменты (я же обещал инструкцию):

  1. создать нативные сборки, причём в двух вариантах: 32- и 64-битном;

  2. положить их рядом с проектом библиотеки (можно и в папку какую-то, главное путь указать к ним потом верный);

  3. добавить файл .targets, в котором для всех нативных сборок добавить элемент <CopyToOutputDirectory> с желаемым значением;

  4. в файле .csproj библиотеки прописать упаковку как нативных сборок, так и файла .targets (должен пойти в папку build пакета);

  5. реализовать механизм выбора нужной версии нативной сборки в зависимости от разрядности процесса.

Это всё. Солюшн нашей тестовой библиотеки можно взять отсюда: DualLibClassLibrary.zip. Решение было проверено в следующих сценариях на Windows и macOS:

  1. .NET Framework приложение;

  2. .NET Core / .NET 5 приложение;

  3. Self-contained приложение.

Касаемо проверки в 32- и 64-битном процессах – проверял только на Windows, не уверен, как проверить это на macOS.

Стоит заметить, что .NET на данный момент поддерживает только десктопные операционные системы. Однако в .NET 6 заявляется поддержка также и мобильных платформ. Если честно, не уверен, сработает ли описанный в статье подход там. Думаю, что для iOS файл dylib спокойно подойдёт (или нет?), а касаемо Android нужно думать отдельно. Может, кто-то уже сталкивался и подскажет в комментариях?

Спасибо за прочтение!