среда, 2 февраля 2011 г.

Пишем Wix Extension (CompilerExtension)

Насколько мне известно, WiX - это одна из наиболее развитых и продуманных XML-систем. Причем, сделано так, что этим может пользоваться даже человек. Сегодня я еще раз убедился в том, что WiX предоставляет действительно классные возможности.

Проблема

Дело в том, что у нашего продукта множество вариаций. Версии Базовый и Стандарт, различные локализации, вариации для Saas и т.д... Все это выливается в необходимость создания большого числа однотипных установщиков.

Раньше мы использовали для этого Microsoft Setup Project, однако количество багов и слабые возможности этого решения вынудили все это переделать на WiX, чем я и занялся.

В процессе переделки возникла следующая идея: раньше для каждого типа установщиков мы создавали по специальному xml-файлу конфигурации, причем эти файлы часто друг от друга не сильно отличались. Теперь же хотелось сделать так, чтобы исключить дублирование фрагментов описанных xml-файлов.

Поиск решения

Решение вроде бы напрашивалось само собой: в библиотеке WixUIExtension имеются элементы XmlFile и XmlConfig. Однако, для генерации больших файлов они не подходят. Попробовав их использовать, я выяснил, что 10 строк обычного xml с их помощью преобразуется аж в 100(!!).

Ну и конечно, XmlFile и XmlConfig не позволяют подключить интеллисенс, ведь для нашего файла конфигурации специально была создана тщательно документированная xsd-схема, благодаря чему создание и изменение файлов конфигурации было довольно удобным.

Поэтому, для активной генерации xml элементы XmlFile и XmlConfig явно не подходят. В процессе поиска альтернативного решения, я обратил внимание на Wix Extensions, и задумал написать собственный такой модуль расширения, который бы позволил максимально быстро и эффективно развертывать наш конкретный xml, и желательно, позволял бы использовать нашу же xsd-схему при его создании/изменении.

Например, это могло бы выглядеть примерно так:

<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">

    <!-- ... -->

    <DeployConfigFragment File="[TARGETDIR]\DeployConfig.xml" xmlns="http://www.deskwork.ru/2010/DeployConfig">
        <SolutionsList>
                <Solution Name="Softline.Portal.Helper" Id="21e394a2-7dab-4565-aaad-2540d360daf2" Require="true" Order="0" />
                <Solution Name="Softline.Portal.Libraries.MediaLibrary" Id="3b3ae4b1-26e7-4680-bd5c-db7e10de86e9" Require="true" Order="1">
                    <Features>
                        <Feature Name="Softline.Portal.Libraries.MediaLibrary.Softline_MediaLibrary" Id="fcc74650-a765-48af-91f6-247d50e7a907">
                            <ActivateRequire>true</ActivateRequire>
                        </Feature>
                    </Features>
                </Solution>
        </SolutionList>
    </DeployConfigFragment>

    <!-- ... -->

</Wix>

И все, что внутри <DeployConfigFragment>, хотелось бы чтобы в неизменном виде развертывалось в xml-файл DeployConfig.xml.

ДА! Я согласен, что это неправильно идеологически. Лучше разделять xml разного смысла, заключая inner xml в CDATA... Однако, в этом случае мы лишаемся интеллисенса, а этого бы очень не хотелось.

В общем, в итоге именно на таком варианте я и остановился, и приступил к исследованию возможности создания WiX Extensions.

Однако, в интернете оказалось не так уж и много документации по вопросу создания WiX Extension'ов, и поэтому мне пришлось лезть в исходники WiX и WiX Contrib Project.

В общем, этом посте я детально рассмотрел процесс создания CompilerExtension расширения для WiX.

Расширения Wix

Оказывается, WiX позволяет создавать расширения множества типов, и использовать их на многих стадиях своей работы. На сайте WiX описано (очень кратко) создание только расширений препроцессора, остальные типы расширений, к сожалению, в документации отсутствуют.

Я нашел также пару неплохих статей о расширении функционала heat.exe (это утилита для сбора файлов из указанных каталогов и автоматического добавления их в проект):

  1. Статья Brian Rogers о HeatExtension
  2. Статья с блога MetaWir, логическое продолжение статьи Brian Rogers.

Прочитав все эти материалы и мельком просмотрев исходники, я нарисовал небольшую схемку отображающую состав WiX Extension Project:



Из схемы видно следующее:
  1. WiX Extension Project представляет собой обычный Class Library
  2. Подключение к основному проекту происходит с помощью Reference
  3. В AssemblyInfo.cs добавляется специальный атрибут AssemblyDefaultWixExtension, который должен ссылаться на класс, отнаследованный от WixExtension класса, назовем его MyWixExtension.
  4. В классе MyWixExtension производится перегрузка (override) свойств, в геттере которых нужно возвращать экземпляры классов, которые собственно и являются классами расширений. Помимо Extension-классов, также в MyWixExtension можно определить набор дополнительных MSI-таблиц, которые нужны для промежуточного хранения данных.
  5. В одном проекте расширений может быть несколько расширений разных типов. Помимо обозначенных на диаграмме CompilerExtension и PreprocessorExtension, это могут быть: MutatorExtension, DecompilerExtension, BinderExtension, HeatExtension, и другие.
  6. Для расширений некоторых типов может потребоваться создание и подключение дополнительных данных (как например, xsd-схема для CompilerExtension).
Важные моменты, в схему не попавшие:
  1. Чаще всего, вместе с WiX Extension Project создается WiX Custom Action Project, поскольку конкретные действия выполняются почти всегда через Custom Action'ы.
  2. Классы конкретных типов расширений должны наследоваться от одноименных классов библиотеки WiX (классы PreprocessorExtension, CompilerExtension...).
  3. В классах конкретных типов расширений также нужно перегружать свойства и методы, и писать их реализацию.
Для меня самым полезным типом расширений оказался CompilerExtension. Именно он позволяет добавлять собственные xml-элементы в wxs-файлы.

CompilerExtension

После исследования исходников Wix Contrib Project и WixUtilExtension, я смог сформировать шаблон кода для создания собственного CompilerExtension-потомка:

public class MyCompilerExtension : CompilerExtension
{
    private XmlSchema schema;

    public MyCompilerExtension()
    {
        this.schema = LoadXmlSchemaHelper(Assembly.GetExecutingAssembly(), "MyProject.Schema.xsd");
    }

    public override XmlSchema Schema
    {
        get { return this.schema; }
    }
    public override void ParseElement(SourceLineNumberCollection sourceLineNumbers, System.Xml.XmlElement parentElement, System.Xml.XmlElement element, params string[] contextValues)
    {
        string keyPath = null;
        this.ParseElement(sourceLineNumbers, parentElement, element, ref keyPath, contextValues);
    }

    public override ComponentKeypathType ParseElement(SourceLineNumberCollection sourceLineNumbers, System.Xml.XmlElement parentElement, System.Xml.XmlElement element, ref string keyPath, params string[] contextValues)
    {
        ComponentKeypathType keyType = ComponentKeypathType.None;

        // verify element and get values from it
        foreach (XmlAttribute attrib in node.Attributes)
        {
            switch (attrib.LocalName)
            {
                case "Id":
                    id = this.Core.GetAttributeIdentifierValue(sourceLineNumbers, attrib);
                    break;
                default:
                    this.Core.UnexpectedAttribute(sourceLineNumbers, attrib);
                    break;
            }
        }

        // create data row in MSI table "MyTable"
        if (!this.Core.EncounteredError)
        {
            Row row = this.Core.CreateRow(sourceLineNumbers, "MyTable");
            row[0] = id;
        }

        // reference your custom action
        this.Core.CreateWixSimpleReferenceRow(sourceLineNumbers, "CustomAction", "MyCustomAction");

        return keyType;
    }
}

Здесь важно отметить следующие вещи:
  • element - это экземпляр системного класса XmlElement
  • this.Core - это ядро компилятора WiX, CompilerCore. Через него осуществляются все необходимые действия - запрос атрибутов, сообщения об ошибках, добавление ссылок на CustomAction'ы.
  • без CustomAction'ов ничего не заработает. В нашем случае CustomAction должен создавать или открывать для добавления файл DeployConfig.xml, и писать туда соответствующий xml. Фрагмент с CustiomAction нужно создавать отдельно. Cсылка же в данном коде добавляется, чтобы ранее созданный фрагмент был подключен в установщик (в случае, если на элементы фрагмента нет ссылок, как известно он не подключается в итоговый msi).
  • метод CreateRow добавляет строку в таблицу MSI, которая должна заранее быть определена через tables.xml 
Итак, основная работа здесь должна идти по валидации xml-элементов и извлечения из них значений, которые затем будем помещать в соответствующую MSI-таблицу.

Для работы нашей CustomAction необходимо извлечь атрибут, указывающий имя файла, в который будет помещаться xml, а также собственно большую строку с xml. Код, это реализующий, выглядит очень просто:

string fileName = this.Core.GetAttributeValue(sourceLineNumbers, element.Attributes[0]);
string xml = element.InnerXml;

Теперь полученные нами строки необходимо сохранить в таблицу. Как это делается мы уже знаем. В итоге у меня получился следующий код:

public override ComponentKeypathType ParseElement(SourceLineNumberCollection sourceLineNumbers, XmlElement parentElement, XmlElement element, ref string keyPath, params string[] contextValues)
{
    if (element.Attributes.Count == 0 || element.Attributes[0].Name != "File")
        this.Core.OnMessage(WixErrors.ExpectedAttribute(sourceLineNumbers, element.Name, "File"));

    string fileName = this.Core.GetAttributeValue(sourceLineNumbers, element.Attributes[0]);

    string xml = element.InnerXml;

    if (!this.Core.EncounteredError)
    {
        Row row = this.Core.CreateRow(sourceLineNumbers, "Deskwork_DeployConfig_Table");
        row[0] = CreateRandomId();
        row[1] = fileName;
        row[2] = xml;
    }

    this.Core.CreateWixSimpleReferenceRow(sourceLineNumbers, "CustomAction", "Softline_DeployConfig_CustomAction");

    return ComponentKeypathType.None;
}

Здесь метод CreateRandomId() выполняет генерацию уникального идентификатора (это требование WiX), который будет использован в как Primary Key для таблицы. Такой идентификатор должен содержать только латинские буквы, цифры и символы "_" и ".", а также должен начинаться с буквы или знака подчеркивания (это я к тому, что Guid в чистом виде туда не подпихнешь). В итоге, я генерю этот Id следующим образом:
"_" + Guid.NewGuid().Replace("-",string.Empty)
Что же, тут вроде бы все, теперь можно назвать этот класс "DeployConfigCompilerExtension", и двигаться дальше. Вспомним, что осталось:
  • Описание таблицы Deskwork_DeployConfig_Table, где будут лежать сохраненные нами значения строк fileName и xml
  • CustomAction, который собственно будет производить нужные действия (запись в файл)

Создание описаний таблиц

Описание таблиц представляет собой обычный xml-файлик tables.xml. Этот файл имеет довольно простую структуру, и создается по схеме http://schemas.microsoft.com/wix/2006/tables. Файл tables.xsd можно вытащить из исходников WiX. Приведу итоговое содержимое tables.xml для моего случая:

<?xml version="1.0" encoding="utf-8" ?>
<tableDefinitions xmlns="http://schemas.microsoft.com/wix/2006/tables">
  <tableDefinition name="Softline_DeployConfig_Table" createSymbols="yes">
    <columnDefinition name="Id" type="string" length="72" modularize="column" category="identifier" primaryKey="yes" />
    <columnDefinition name="File" type="string" length="0" modularize="property" category="path" description="File to write xml into"/>
    <columnDefinition name="Xml" type="string" length="0" modularize="property" category="formatted" description="Xml to write into the file"/>
  </tableDefinition>
</tableDefinitions>

Обратите внимание!

И для tables.xml, и для DeployConfig.xsd применяются сходные алгоритмы линковки через override свойства, и последующего вызова специального метода. В случае xsd-файла код выглядит так:

LoadXmlSchemaHelper(Assembly.GetExecutingAssembly(), "Your.Project.Namespace.SchemaFileName.xsd");
А в случае tables.xml, код будет такой:
LoadTableDefinitionHelper(Assembly.GetExecutingAssembly(), "Your.Project.Namespace.tables.xml");

И на этих строках может выпасть Exception (при билде основного WiX Setup-проекта) следующего вида:

Exception: Object reference not set to an instance of an object.
at System.Xml.XmlReader.CalcBufferSize(Stream input)
at System.Xml.XmlTextReaderImpl.InitStreamInput(Uri baseUri, String baseUriStr, Stream stream, Byte[] bytes, Int32 byteCount, Encoding encoding)
at System.Xml.XmlTextReaderImpl..ctor(String url, Stream input, XmlNameTable nt)
at System.Xml.XmlTextReader..ctor(Stream input)
at Microsoft.Tools.WindowsInstallerXml.CompilerExtension.LoadXmlSchemaHelper(Assembly assembly, String resourceName)
at Softline.Portal.Installer.WixDeployConfigExtension.DeployConfigCompilerExtension..ctor() in D:\Projects\Softline.Portal.Installer.SetupCore\Softline.Portal.Installer.WixDeployConfigExtension\DeployConfigCompilerExtension.cs:line 21
at Softline.Portal.Installer.WixDeployConfigExtension.DeployConfigExtension.get_CompilerExtension() in D:\Projects\Softline.Portal.Installer.SetupCore\Softline.Portal.Installer.WixDeployConfigExtension\DeployConfigExtension.cs:line 20
at Microsoft.Tools.WindowsInstallerXml.Compiler.AddExtension(WixExtension extension)
at Microsoft.Tools.WindowsInstallerXml.Tools.Candle.Run(String[] args)
at Microsoft.Tools.WindowsInstallerXml.Tools.Candle.Main(String[] args)

Это происходит из-за того, что все файлы данных должны собираться в виде EmbeddedResource, тогда как по умолчанию они собираются как Content или вообще None. Изменить это очень просто, на всякий случай напомню: жмем на файл в Solution Explorer правой кнопкой, Properties, в Build Action выбираем EmbeddedResource, а в Copy to output directory - Copy always. Должно получиться вот так:


Custom Action

Здесь тоже все достаточно просто: создаем проект Custom Action Project, в нем - единственный Custom Action, достаем из сессии нашу таблицу Deskwork_DeployConfig_Table, вытаскиваем из неё значения, и пишем в указанный файл указанную строку xml. Все очень просто:

public class CustomActions
{
    [CustomAction]
    public static ActionResult DeployConfigCustomAction(Session session)
    {
        session.Log("DeployConfig custom action -- START");
        var view = session.Database.OpenView(session.Database.Tables["Deskwork_DeployConfig_Table"].SqlSelectString);
        view.Execute();
        foreach (var row in view)
        {
            string fileName = session.Format(row["File"].ToString());
            string xml = session.Format(row["Xml"].ToString());

            session.Log("DeployConfig custom action -- APPEND XML to file '" + fileName + "' (xml text length =" + xml.Length + ").");

            var fileStream = File.AppendText(fileName);
            fileStream.WriteLine(xml);
            fileStream.Flush();
            fileStream.Close();
        }
        session.Log("DeployConfig custom action -- FINISH");
        return ActionResult.Success;
    }
}

Подключение в проект

К сожалению, просто добавить Reference на dll с расширением, и на проект с Custom Action недостаточно, чтобы все заработало. Потребуется также небольшая модификация wxs-файлов (при желании это можно оформить в виде отдельной Wix Library). Суть модификаций сводится к добавлению следующих фрагментов xml-кода:

<Binary Id="DeployConfigCABinary" SourceFile="..\Softline.Portal.Installer.WixDeployConfigCustomAction\bin\Debug\Softline.Portal.Installer.WixDeployConfigCustomAction.CA.dll" />

<CustomAction Id="Deskwork_DeployConfig_CustomAction" DllEntry="DeployConfigCustomAction" BinaryKey="DeployConfigCABinary" />

<InstallExecuteSequence>
  <Custom Action="Deskwork_DeployConfig_CustomAction" Sequence="10000" />
</InstallExecuteSequence>

Не забудьте заменить на свои пути к файлам CustomAction и на свои названия таблиц.

Ну и после всего этого, в принципе уже можно пытаться запускать. Да, запустится и заработает, может быть, не с первого раза - в этом случае, можете задавать вопросы, исследовать мои исходники, и т.д.

Тестовый проект

Мой тестовый проект получился довольно прикладным, поскольку данная статья писалась в рамках выполнения конкретной рабочей задачи. К тому же, более абстрактный пример я сделать просто не успеваю по времени. Впрочем, думаю, даже такой проект будет достаточно полезен, поэтому выкладываю исходники данного тестового проекта в общий доступ.

Сам проект не устанавливает никаких файлов, только генерирует файл DeployConfig.xml. После установки msi-файла, вы получите следующий результат:


Надеюсь, данное руководство сэкономит вам пару часов бесценного времени. Удачи!

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

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

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

Примечание. Отправлять комментарии могут только участники этого блога.