четверг, 4 ноября 2010 г.

Автоматический merge файлов во время установки


Судьба столкнула с довольно интересной задачей. Вкратце, её можно сформулировать как оффлайн-синхронизация файлов в процессе установки.

Я бы предпочел такое решение сделать в виде онлайн веб-сайта, но с командиром не поспоришь. Сказал WinForms - будет тебе WinForms. И WiX - заодно!

Речь идет об утилите для переводчиков для перевода ресурсов действующего и развивающегося продукта. Переводчики должны переводить resx-файлы, и пересылать их нам, разработчикам. Поскольку продукт развивается, естественно, что мы им тоже должны посылать свежие версии файлов, с новыми ресурсами. Посылаемые нами файлы скорее всего будут в виде установщиков - так проще всего. А значит, нужно придумать мерж во время установки!

Исходя из условий задачи, довольно логично сделать ресурсные файлы и саму утилиту в виде отдельных установщиков. Сейчас мы сосредоточимся на установщике ресурсов, а утилиту и её установщик в этой статье я буду упоминать по минимуму.

Прежде всего, что должен делать установщик ресурсных файлов?
  1. Проверять, установлена ли "родительская" утилита. Если нет - выдавать ошибку и закрываться.
  2. Проверять, не установлена ли уже текущая или более старшая версия ресурсных файлов. В этом случае делаем также - выдаем ошибку и на выход. 
  3. Сохранять версию файлов переводчика в папочку Backup (ведь переводчик в этой версии что-то правил, и её нельзя просто удалить или заменить).
  4. Установить файлы, присутствующие в нашем установочном пакете
  5. Попробовать смержить папку с файлами переводчика в папку с новыми ресурсными файлами.
Сразу, на вскидку, отметаем 2й и 4й пункты. Это базовый функционал MSI, его реализовывать не потребуется. Пройдемся по тому, что осталось.

Первый пункт - проверку установленного "родительского" продукта, можно реализовать довольно просто, путем поиска файла. В 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. Да кстати, насчет всех этих мержей, мораль сей басни такова: не забивайте гвозди микроскопом, господа программисты! Все гораздо проще решается, зачастую.

Комментариев нет:

Отправить комментарий

Внимание! Реклама и прочий спам будут беспощадно удаляться.