Проблема
Дело в том, что у нашего продукта множество вариаций. Версии Базовый и Стандарт, различные локализации, вариации для 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 (это утилита для сбора файлов из указанных каталогов и автоматического добавления их в проект):
- Статья Brian Rogers о HeatExtension
- Статья с блога MetaWir, логическое продолжение статьи Brian Rogers.
Прочитав все эти материалы и мельком просмотрев исходники, я нарисовал небольшую схемку отображающую состав WiX Extension Project:
Из схемы видно следующее:
- WiX Extension Project представляет собой обычный Class Library
- Подключение к основному проекту происходит с помощью Reference
- В AssemblyInfo.cs добавляется специальный атрибут AssemblyDefaultWixExtension, который должен ссылаться на класс, отнаследованный от WixExtension класса, назовем его MyWixExtension.
- В классе MyWixExtension производится перегрузка (override) свойств, в геттере которых нужно возвращать экземпляры классов, которые собственно и являются классами расширений. Помимо Extension-классов, также в MyWixExtension можно определить набор дополнительных MSI-таблиц, которые нужны для промежуточного хранения данных.
- В одном проекте расширений может быть несколько расширений разных типов. Помимо обозначенных на диаграмме CompilerExtension и PreprocessorExtension, это могут быть: MutatorExtension, DecompilerExtension, BinderExtension, HeatExtension, и другие.
- Для расширений некоторых типов может потребоваться создание и подключение дополнительных данных (как например, xsd-схема для CompilerExtension).
- Чаще всего, вместе с WiX Extension Project создается WiX Custom Action Project, поскольку конкретные действия выполняются почти всегда через Custom Action'ы.
- Классы конкретных типов расширений должны наследоваться от одноименных классов библиотеки WiX (классы PreprocessorExtension, CompilerExtension...).
- В классах конкретных типов расширений также нужно перегружать свойства и методы, и писать их реализацию.
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
Для работы нашей 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-файла, вы получите следующий результат:
Надеюсь, данное руководство сэкономит вам пару часов бесценного времени. Удачи!
Комментариев нет:
Отправить комментарий
Внимание! Реклама и прочий спам будут беспощадно удаляться.
Примечание. Отправлять комментарии могут только участники этого блога.