Судьба столкнула с довольно интересной задачей. Вкратце, её можно сформулировать как оффлайн-синхронизация файлов в процессе установки.
Я бы предпочел такое решение сделать в виде онлайн веб-сайта, но с командиром не поспоришь. Сказал WinForms - будет тебе WinForms. И WiX - заодно!
Речь идет об утилите для переводчиков для перевода ресурсов действующего и развивающегося продукта. Переводчики должны переводить resx-файлы, и пересылать их нам, разработчикам. Поскольку продукт развивается, естественно, что мы им тоже должны посылать свежие версии файлов, с новыми ресурсами. Посылаемые нами файлы скорее всего будут в виде установщиков - так проще всего. А значит, нужно придумать мерж во время установки!
Исходя из условий задачи, довольно логично сделать ресурсные файлы и саму утилиту в виде отдельных установщиков. Сейчас мы сосредоточимся на установщике ресурсов, а утилиту и её установщик в этой статье я буду упоминать по минимуму.
Прежде всего, что должен делать установщик ресурсных файлов?
- Проверять, установлена ли "родительская" утилита. Если нет - выдавать ошибку и закрываться.
- Проверять, не установлена ли уже текущая или более старшая версия ресурсных файлов. В этом случае делаем также - выдаем ошибку и на выход.
- Сохранять версию файлов переводчика в папочку Backup (ведь переводчик в этой версии что-то правил, и её нельзя просто удалить или заменить).
- Установить файлы, присутствующие в нашем установочном пакете
- Попробовать смержить папку с файлами переводчика в папку с новыми ресурсными файлами.
Первый пункт - проверку установленного "родительского" продукта, можно реализовать довольно просто, путем поиска файла. В WiX'е для этого есть простенький XML:
<Property Id="PRODUCTISINSTALLED"> <DirectorySearch Id="TargetDirSearch" Path="[ProgramFilesFolder]Translation tool\"> <FileSearch Name="ResourceTranslator.exe"/> </DirectorySearch> </Property>Таким образом, мы определили, установлен ли уже родительский продукт, и записали результат в переменную. В дальнейшем эту переменную нужно применять для отображения ошибки и не отображения всех этих GUI-форм.
Вообще, можно использовать другие методы определения наличия продукта. Например, по реестру, или даже, наверное, существует возможность сделать установщик в виде Upgrade к базовой утилите - с этим пока не разбирался. Я сделал быстро, реализовал первый пришедший в голову способ - он меня полностью устраивает, и будет устраивать в будущем (название exe-шника или папку установки я менять не собираюсь).
Третий пункт - сохранение бэкапа текущих файлов. Проще всего сделать через Custom Action. По сути нужно просто переименовать старую папку в Backup или, надежнее, в random-имя, чтобы не затереть её. Если переименовываем в случайное имя - желательно сообщить об этом обратно в установщик, чтобы в дальнейшем можно было мержить файлы.
Мне показалось, что нагляднее всего будет сделать этот CustomAction вставкой на JScript. Код будет выглядеть следующим образом:
<CustomAction Id="JSCA_BACKUPRESOURCESFOLDER" Script="jscript"> <![CDATA[ var fso = new ActiveXObject("Scripting.FileSystemObject"); var installFolder = Session.Property("INSTALLDIR") + "\\Resources"; var backupFolder = Session.Property("INSTALLDIR") + "\\" + fso.GetTempName(); if (fso.FolderExists(installFolder)) { Session.Property("BACKUPDIR") = backupFolder; fso.MoveFolder(installFolder, backupFolder); } ]]> </CustomAction>Думаю, из кода всё понятно. Session.Property служит для получения и установки переменных сессии MSI. В InstallExecuteSequence нужно поместить этот CustomAction перед созданием каталогов и уж тем более, ясное дело, перед копированием файлов.
Пятый пункт... Последний... И вот тут встает проблема мержа, да? Огого, да у нас там сейчас будут страшные конфликты, придется пририсовывать GUI для их разрешения, а как рядовому переводчику объяснить, что с чем мержить и как разрешать конфликты?... Он всё напутает, потеряет все свои правки, исплюется на "тупых" программистов. Ой-ой-ой!!
На самом деле, фигня это всё. Почему? Ну дак давайте подумаем головой!
А если подумать, то оказывается, что мержить эти файлы несложно. И никаких конфликтов в принципе быть не может. Смотрите сами:
Если определенный ресурс есть и у переводчика, и в установочных файлах - надо брать версию от переводчика. Потому что переводчик отвечает за текст, и значит текст у него правильный всегда.
Дальше. Если у переводчика нет каких-то ресурсов - нужно добавить их в его файлы. Ведь переводчик не может добавлять новые ресурсы. Он просто переводит старые! Добавляют ресурсы только программисты.
Как видите, тут не полноценный текстовый мерж, который может быть с конфликтами, а очень простая версия мержа, которая алгоритмически реализуется в виде довольно очевидного C#-кода, с использованием классов ResXResourceWriter и ResXResourceReader из namespace'а System.Resources.
Тут мы сталкиваемся с небольшим ньюансом. Дело в том, что .Net сборку напрямую MSI не поддерживает в качестве Custom Action. Так что, придется использовать специальный тип проекта, один из поставляемых вместе с WiX, "C# Custom Action Project".
Проект содержит, в моем случае, всего один cs-файлик с несколькими статическими методами. Код следующий:
using System; using System.Linq; using System.Text; using System.IO; using System.Resources; using System.Collections; using System.Collections.Generic; using System.Reflection; using Microsoft.Deployment.WindowsInstaller; namespace Translator.CustomActions { class ResourceEntry { public string Name; public string Value; public string Comment; } public class MergeHelper { [CustomAction] public static ActionResult MergeFiles(Session session) { string installPath = session["INSTALLDIR"] + "\\Resources"; string backupPath = session["BACKUPDIR"]; try { // Мержим установленные файлы с соответствующими файлами переводчика foreach (string filePath in Directory.GetFiles(installPath, "*.resx")) { string backupedFilePath = Path.Combine(backupPath, Path.GetFileName(filePath)); if (File.Exists(backupedFilePath)) { PerformMerge(filePath, backupedFilePath); } } // Перемещаем в каталог установки файлы, созданные переводчиком foreach (string filePath in Directory.GetFiles(backupPath, "*.resx")) { string installedFilePath = Path.Combine(installPath, Path.GetFileName(filePath)); if (!File.Exists(installedFilePath)) { File.Move(filePath, installedFilePath); } } } catch { return ActionResult.Failure; } return ActionResult.Success; } private static void PerformMerge(string installedFilePath, string backupedFilePath) { IEnumerable<ResourceEntry> installedResources = ReadResXFile(installedFilePath); IEnumerable<ResourceEntry> backupedResources = ReadResXFile(backupedFilePath); List<ResourceEntry> finalResources = new List<ResourceEntry>(); // Добавляем все новые ресурсы foreach (ResourceEntry installedEntry in installedResources) { bool found = false; foreach (ResourceEntry backupedEntry in backupedResources) { if (backupedEntry.Name == installedEntry.Name) { found = true; break; } } if (!found) finalResources.Add(installedEntry); } // Добавляем все старые ресурсы foreach (ResourceEntry backupedEntry in backupedResources) { bool found = false; foreach (ResourceEntry installedEntry in installedResources) { if (backupedEntry.Name == installedEntry.Name) { found = true; break; } } if (found) finalResources.Add(backupedEntry); } // Сохраняем WriteResXFile(installedFilePath, finalResources); } private static IEnumerable<ResourceEntry> ReadResXFile(string filePath) { List<ResourceEntry> resources = new List<ResourceEntry>(); ResXResourceReader reader = new ResXResourceReader(filePath); reader.UseResXDataNodes = true; foreach (DictionaryEntry entry in reader) { ResXDataNode node = (ResXDataNode)entry.Value; AssemblyName[] assemblies = Assembly.GetExecutingAssembly().GetReferencedAssemblies(); ResourceEntry resource = new ResourceEntry(); resource.Name = node.Name; resource.Value = node.GetValue(assemblies).ToString(); resource.Comment = node.Comment; resources.Add(resource); } return resources; } private static void WriteResXFile(string filePath, IEnumerable<ResourceEntry> resources) { ResXResourceWriter writer = new ResXResourceWriter(filePath); foreach (ResourceEntry resource in resources) { ResXDataNode node = new ResXDataNode(resource.Name, resource.Value); node.Comment = resource.Comment; writer.AddResource(node); } writer.Generate(); writer.Close(); } } }
Надеюсь, код более-менее понятный.
На выходе этот проект будет генерить, в дополнение к обычной <название проекта>.dll, файлик <название проекта>.CA.dll. Он-то нам и нужен. Простенький XML позволяет нам подключить файл в msi и вызвать из него наш метод MergeFiles:
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> <Product Id="*" Name="Resources for translation tool" Language="1033" Version="2.0.0.0" Manufacturer="Company" UpgradeCode="PUT-YOUR-GUID-HERE"> <Binary Id="customactionsdll" SourceFile="..\Setup\Translator.CustomActions.CA.dll" /> <!-- ... --> <CustomAction Id="DLLCA_MERGEFILES" DllEntry="MergeFiles" BinaryKey="customactionsdll" /> <!-- ... --> <InstallExecuteSequence> <!-- ... --> <Custom Action="DLLCA_MERGEFILES" Sequence="2000" /> </InstallExecuteSequence> </Product> </Wix>Этот Custom Action должен выполняться после установки новых файлов, чтобы было что с чем мержить, так что можете смело вставлять его в самый конец InstallExecuteSequence.
Ну вот, в общем-то, и всё...
P.S. Да кстати, насчет всех этих мержей, мораль сей басни такова: не забивайте гвозди микроскопом, господа программисты! Все гораздо проще решается, зачастую.
Комментариев нет:
Отправить комментарий
Внимание! Реклама и прочий спам будут беспощадно удаляться.
Примечание. Отправлять комментарии могут только участники этого блога.