Exepack.NET, часть 3
С загрузчиком все понятно, осталось сделать сам упаковщик. Задача упаковщика в двух словах такова: определить список файлов, упаковать их, скомпилировать загрузчик и приклеить к нему упакованные файлы в виде ресурсов. Пока я писал загрузчик, мне нужно было делать это вручную, теперь же моя задача это автоматизировать.
Итак, упаковщик работает по такой схеме:
- Загружает главную исполняемую сборку (*.exe)
- Составляет список используемых библиотек классов (*.dll)
- Упаковывает все сборки, сохраняет во временные файлы
- Готовит исходник для загрузчика (loader.cs)
- Компилирует загрузчик, добавляя временные файлы как ресурсы
- Удаляет временные файлы
Список библиотек для упаковки, конечно, можно было бы задавать явно (например, в командной строке) . Но согласитесь, гораздо приятнее, если упаковщик сам его составит, тем более, что это сделать совсем несложно. Стандартный класс-обертка 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 (в двух словах: используйте как угодно, в коммерческих или некоммерческих целях, только оставьте упоминание оригинального авторства). Если захотите принять участие в дальнейшей разработке проекта — присоединяйтесь!