Я разрабатываю .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. Создаём консольное приложение, устанавливаем нашу библиотеку. В проекте действительно появляются два файла:
Выглядит обнадёживающе, пишем в файле Program.cs простой код:
static void Main(string[] args)
{
var result = DualLibClassLibrary.Class.Bar();
Console.WriteLine($"Result = {result}. Press any key to exit...");
Console.ReadKey();
}
Запускаем и грустим:
Что ж, заглянем в папку bin/Debug:
И правда нет файлов. Как же так, <CopyToOutputDirectory>
мы им указали, в структуре проекта файлы видны. Проверив содержимое .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
на месте. Устанавливаем библиотеку в наше консольное приложение и запускаем:
И снова неудача. Проверим в аналогичном консольном приложении, но на .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, обнаружим такую опцию:
Оказывается, для проектов .NET Framework она включена по умолчанию, а процесс приложения будет 32-битным даже на 64-битной операционной системе. Забавно, что в .NET Core/.NET 5+ опция по умолчанию выключена:
Можно, конечно, выключить эту опцию, и приложение наконец напечатает верный результат:
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 -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-битной сборки. Я думаю, варианты тут есть разные, я остановился на таком:
собираем нативные библиотеки 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 нужно думать отдельно. Может, кто-то уже сталкивался и подскажет в комментариях?
Спасибо за прочтение!
ad1Dima
Актуальные маки 64-битные.
Melanchall Автор
Ага, я как раз не был уверен, существуют ли нынче macOS 32-битные. Мне, увы, проверять приходится только в виртуалке :)
Спасибо за информацию!
pingwinator
а вот чисто теоретически 32 битный бинарь может понадобиться для поддержки 5 айфона. Но по факту это уже давно хлам и никто не поддерживает ios10
pingwinator
отвечу сам себе. Сегодня на глаза попала дока https://developer.apple.com/documentation/apple-silicon/addressing-architectural-differences-in-your-macos-code
Melanchall Автор
Полезно, спасибо! Буду иметь в виду. Но в целом, выходит, если код не сильно сложный, используются стандартные int, char и иже с ними, то проблем быть не должно, и 64-битная сборка и на ARM64 будет работать?
pingwinator
ага, а вот 64 бита бывают разные - x86_64 и arm64