Сам себе ILMerge
Сегодня поговорим о том, как можно обойтись без ILMerge если вам требуется скрыть сборки, от которых вы зависите, внутри единого exe-файла. Дело в том, что .NET позволяет вмешиваться в процесс поиска требуемых сборок, путём подписки на событие AppDomain.AssemblyResolve
для конкретного домена. Подписчик на событие должен вернуть загруженный экземпляр класса System.Reflection.Assembly
, если сборка с именем args.Name
может быть им найдена или вернуть null
в противном случае. Если подписаться на данное событие до того момента, как приложению понадобится зависимая сборка, то можно запросто загрузить требуемую сборку из embedded-ресурсов приложения.
Давайте создадим в Visual Studio решение из двух проектов: Console Application и Class Library. Определим ссылку консольного приложения на библиотеку и отключим параметр Copy Local в свойствах ссылки на сборку (то есть после сборки решения в каталоге консольного приложения будет не хватать сборки ClassLib.dll
):
Отлично, теперь следует добавить сборку библиотеки как embedded-ресурс консольного приложения. Это можно сделать средствами Visual Studio, но не без существенных недостатков: если добавить сборку в проект, то она скопируется и не будет обновляться при обновлении кода проекта Class Library. Если добавить сборку в проект как ссылку (действие Add as link в диалоге Add Existing Item), то Visual Studio зачем-то отобразит в дереве проекта все каталоги, в которых находится эта сборка. В любом из случаев Visual Studio не позволяет контролировать имя embedded-ресурса, поэтому мы поступим хитрее.
Выгружаем проект консольного приложения (действие Unload Project контекстного меню) и открываем .csproj-файл для редактирования. В файле проекта нам следует добавить ссылку на embedded-ресурс (dll-файл библиотеки) и указать полное имя сборки в качестве имени ресурса:
<!-- ... -->
<ItemGroup>
<ProjectReference Include="ClassLib\ClassLib.csproj">
<Project>{1F1F562F-3CDD-4996-89B0-A5EC2049474A}</Project>
<Name>ClassLib</Name>
<Private>False</Private>
</ProjectReference>
</ItemGroup>
<!-- добавить этот раздел -->
<ItemGroup>
<!-- указать путь до сборки библиотеки -->
<EmbeddedResource Include="ClassLib\bin\$(Configuration)\ClassLib.dll">
<!-- в качестве имени ресурса - полное имя сборки -->
<LogicalName>ClassLib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</LogicalName>
<!-- скрываем из дерева проекта Visual Studio -->
<Visible>false</Visible>
</EmbeddedResource>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- ... -->
Это всё равно не очень приятно выглядит: приходится указывать полное имя сборки, завязываться на output-пути другого проекта и тот факт, что названия конфигурации обоих проектов (доступные через выражение $(Configuration)
) должны совпадать. Однако некоторую автоматизацию данный подход всё же осуществляет (изменения проекта Class Library будут автоматически переносится в embedded-ресурс консольного приложения), а если глубже погрузиться в MsBuild, то можно устранить эти недостатки, например, получая полное имя сборки автоматически с помощью задачи GetAssemblyIdentity
.
Остался один нюанс - необходимо подписаться на событие AssemblyResolve
до того, как приложению потребуется зависимая сборка. А что, если сборка понадобится уже в коде метода Main
(точки входа сборки)? Ведь CLR требует, чтобы все необходимые методу сборки, были подгружены до начала исполнения этого метода. Ответ достаточно простой: в сборках с точкой входа самым первым исполняется статический конструктор типа, содержащего метод точки входа, и только потом исполнение переходит к методу Main
. Таким образов, код приложения может иметь следующий вид:
using System;
using System.Reflection;
class Program {
static Program() {
AppDomain.CurrentDomain.AssemblyResolve += (_, args) => {
byte[] rawAssembly;
// ищем в embedded-ресурсах текущей сборки
// ресурс с именем сборки, чей поиск осуществляется
var assembly = Assembly.GetExecutingAssembly();
using (var stream = assembly.GetManifestResourceStream(args.Name)) {
// если сборка не нашлась
if (stream == null) return null;
// считываем в массив байт
rawAssembly = new byte[stream.Length];
stream.Read(rawAssembly, 0, (int) stream.Length);
}
return Assembly.Load(rawAssembly);
};
}
static void Main() {
ClassLib.Foo.SayHello();
Console.ReadKey(true);
}
}
Код “библиотеки”:
namespace ClassLib {
public class Foo {
public static void SayHello() {
System.Console.WriteLine("Hello from ClassLib.Foo!");
}
}
}
И всё будет работать, как ожидается. Недостатки данного подхода:
- Подписка на событие
AssemblyResolve
действует в рамках только одного .NET-домена, что может создавать проблемы в приложениях, использующих несколько доменов. - Код, загружаемый через
Assembly.Load(byte[])
скорее всего подвергается JIT-компиляции при каждом запуске (не нашёл возможности проверить, но скорее всего это так). - Описанная выше подписка на событие
AssemblyResolve
в статическом конструкторе класса имеет смысл только в сборках, имеющих точку входа (exe-приложения). То есть не выйдет так же просто убрать зависимости dll-библиотеки в embedded-ресурсы, так как сложно определить расположение кода подписки наAssemblyResolve
, которое будет гарантировать выполнение подписки до исполнения любого другого кода этой сборки.
Преимущества:
- Можно очень гибко управлять процессом поиска требуемой версии сборки и т.п.
- Сборки в embedded-ресурсах можно сжать, например, с помощью класса
System.IO.Compression.GZipStream
и распаковывать во время поиска, тем самым получая меньший размер exe-файла.
Пожалуйста, используйте приведённый выше код только если вы хорошо понимаете, что оно вам действительно надо.