Exepack.NET, часть 3

ilmerge_packedС загрузчиком все понятно, осталось сделать сам упаковщик. Задача упаковщика в двух словах такова: определить список файлов, упаковать их, скомпилировать загрузчик и приклеить к нему упакованные файлы в виде ресурсов. Пока я писал загрузчик, мне нужно было делать это вручную, теперь же моя задача это автоматизировать.

Итак, упаковщик работает по такой схеме:

  1. Загружает главную исполняемую сборку (*.exe)
  2. Составляет список используемых библиотек классов (*.dll)
  3. Упаковывает все сборки, сохраняет во временные файлы
  4. Готовит исходник для загрузчика (loader.cs)
  5. Компилирует загрузчик, добавляя временные файлы как ресурсы
  6. Удаляет временные файлы

Список библиотек для упаковки, конечно, можно было бы задавать явно (например, в командной строке) . Но согласитесь, гораздо приятнее, если упаковщик сам его составит, тем более, что это сделать совсем несложно. Стандартный класс-обертка Assembly, который позволяет более-менее успешно манипулировать .NET-сборками, дает возможность получить список модулей, на которые ссылается загруженная сборка: Assembly.GetReferencedAssemblies().

Повторяя этот процесс рекурсивно, мы получим полный список библиотек, которые используются нашим пакуемым приложением. Правда, в этом списке будут присутствовать всякие системные сборки типа mscorlib.dll и System.dll. При упаковке их, конечно, нужно будет пропускать.

Упаковки данных делается тем же самым классом DeflateStream, что и распаковка в загрузчике. При упаковке файлов я меняю их расширение на .deflated, в ресурсах файлы так и будут называться (было MyLib.dll, стало MyLib.deflated). Тут есть еще одна небольшая хитрость. Сборка-библиотека может быть спрятана где-нибудь далеко, например, в глобальном кэше сборок (GAC). Чтобы не искать ее самостоятельно, можно загрузить сборку стандартным механизмом загрузки — с помощью Assembly.LoadFrom(string name), — а затем посмотреть на свойство Assembly.Location. Получается весьма компактный метод:

static string CompressFile(string asmName)
{
  // load assembly
  Assembly asm = Assembly.LoadFrom(asmName);
  string compressedName = Path.ChangeExtension(asm.Location, "deflated");
 
  using (FileStream inFile = File.OpenRead(asm.Location))
  using (FileStream outFile = File.Create(compressedName))
  using (DeflateStream ds = new DeflateStream(outFile, CompressionMode.Compress))
  {
    new BinaryWriter(ds).Write(new BinaryReader(inFile).ReadBytes((int)inFile.Length));
  }
 
  return compressedName;
}

При загрузке каждой сборки надо еще проверить: вдруг она уже была загружена (например, по ссылке из другой сборки). Здесь я опустил такую проверку, но в исходных текстах Exepack.NET она, конечно, есть.

Дальше у нас по плану — исходник загрузчика. Можно его держать в отдельном файле loader.cs на диске, но я считаю, что это неудобно. Вдруг потеряется. Гораздо лучше, если он будет храниться в ресурсах самого упаковщика. Непосредственно перед компиляцией его нужно извлечь и сохранить на диск, чтобы компилятор C# смог до него добраться. Попутно нужно в нем исправить кое-какие мелочи (в частности, прописать имя ресурса, из которого будут загружены упакованные данные):

static string GetLoaderSource(string exeResourceName)
{
  using (Stream stream =
    Assembly.GetExecutingAssembly().GetManifestResourceStream("loader.cs"))
  {
    string loader = Encoding.UTF8.GetString(
      new BinaryReader(stream).ReadBytes((int)stream.Length));
    loader = loader.Replace("app.deflated", exeResourceName);
    return loader;
  }
}

Осталось рассказать о том, как запустить C#, чтобы скомпилировать загрузчик с упакованными ресурсами. Я не советую париться по поводу путей установки .NET Framework и собирания директив компиляции в RSP-файл: есть куда более легкий путь. Можно воспользоваться CodeDom-провайдером для C# (это такая подсистема .NET Framework, которая отвечает за генерацию кода — например, в дизайнере форм). Провайдер CodeDom самостоятельно определит, где находится компилятор C# (csc.exe), подготовит ключи компиляции, запустит и сохранит готовый EXE-файл. Очень удобно:

static string CreateExecutable(List files,
  string iconName, bool isConsole)
{
  // prepare loader source code
  string exeName = Path.GetFileName(files[0]);
  string loader = GetLoaderSource(exeName);
 
  // prepare compiler parameters
  CompilerParameters cp = new CompilerParameters();
  cp.GenerateExecutable = true;
  cp.OutputAssembly = Path.ChangeExtension(exeName, "packed.exe");
  cp.IncludeDebugInformation = false;
  cp.GenerateInMemory = false;
  cp.ReferencedAssemblies.Add("System.dll");
  cp.CompilerOptions = "/o /filealign:512";
  cp.CompilerOptions += isConsole ? " /target:exe" : " /target:winexe";
  cp.CompilerOptions += iconName != null ? " /win32icon:\"" + iconName + "\"" : "";
 
  // add compressed resources
  foreach (string fileName in files)
  {
    if (fileName == exeName)
      continue;
 
    cp.EmbeddedResources.Add(fileName);
  }
 
  // compile and check for errors
  CompilerResults cr = new CSharpCodeProvider().CompileAssemblyFromSource(cp, loader);
  if (cr.Errors.Count > 0)
  {
    // Display compilation errors.
    Console.WriteLine("Errors building loader into {0}", cr.PathToAssembly);
    foreach (CompilerError ce in cr.Errors)
    {
      Console.WriteLine("  {0}", ce.ToString());
      Console.WriteLine();
    }
  }
 
  // return new executable name
  return cp.OutputAssembly;
}

К сожалению, класс CompilerParameters поддерживает не все возможности компилятора C#. Поэтому кое-что приходится задавать ключами CompilerOptions. Это плохо, потому что не переносимо (то есть, не будет работать с другими компиляторами C# — Mono, Portable.NET и т. д.) Лично я с этим пока смирился: если придется портировать упаковщик для Linux — вероятно, это будет наименьшей проблемой из всех, что у меня появятся.

Ну вот, собственно, и все. Оказалось неожиданно просто. Весь процесс от задумки до работающей программы занял три часа (или чуть больше), зато написание статей растянулось на несколько дней. Кстати, на скриншоте в самом начале статьи показан файл ILMerge.exe после упаковки (было 828, стало 373 килобайта).

Я создал проект Exepack.NET на сайте CodePlex. Исходники для упаковщика из этой статьи — это релиз версии 0.00 alpha. Проект выпущен под лицензией MIT (в двух словах: используйте как угодно, в коммерческих или некоммерческих целях, только оставьте упоминание оригинального авторства). Если захотите принять участие в дальнейшей разработке проекта — присоединяйтесь!

Leave a Reply