Сегодня поговорим о том, как можно обойтись без 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-файла.

Пожалуйста, используйте приведённый выше код только если вы хорошо понимаете, что оно вам действительно надо.