
Каждый маломальский проект сталкивается с дистрибьюцией продукта. В нашем случае это коробочный вариант и так исторически сложилось, что мы предоставляем нашим заказчикам установщик, который должен сделать уйму всего в системе, тем самым упростив заказчику этап внедрения.
В первой своей реинкарнации это было решение из множества приложений, которые дергали друг друга и все это подавалось под соусом InnoSetup. Масштабировать функционал уже не представлялось возможным. И мы пришли к решению пересесть на «новые рельсы» и тут понеслось…
Знакомство с Wix, а затем и WixSharp
Выбор пал на Wix. Но желающих писать xml скрипты Wix в команде не оказалось. Основной приоритет отдавали C#. И, ура, был замечен фреймворк называемый Wix# (WixSharp).
По Wix# написано немало статей в сети и есть интересный перевод статьи на Хабре. В каждой статье авторы пытаются донести свой уникальный опыт и помочь читателям с пользой воспользоваться материалом. Поэтому и мы решили поделиться своим опытом с вами.
Wix# позволяет реализовать большинство сценариев установки и обновления msi. Также, есть возможность дополнить функционал путем подключения wix расширений и описания новых сущностей Wix. Приятно было обнаружить возможность прикрутить WPF. Однако на начальном этапе мы приняли решение написать формы на WinForm. И в процессе мы выявили ряд важных моментов, про которые расскажем ниже.
Особенности работы с WinForm
При проработке форм установщика мы поняли, что сделать простые, не перегруженные формы для наших потребностей невозможно. Поэтому каждая форма требовала лаконичного размещения контролов с учетом ограничений в размерах форм в msi.

В итоге по формам мы смогли более менее раскидать необходимый функционал. И его можно расширить, добавив еще пару-тройку новых форм. Но…
Первое, что стало бросаться в глаза при добавлении новых форм, это то, что отрисовка была с «запозданием». Наблюдалось мерцание форм при переходе от одной к другой. Происходило это при подгонке размеров контролов во вновь инициализированной форме под разрешение текущего экрана. В этом оказалось особенность работы с WinForm msi.
На данный момент мы остановились на этой реализации. А в ближайшее время запланировали переписать UI на WPF.
Основной модуль
В основном модуле мы описываем все необходимые опции проекта. В примерах разработчика Wix# обычно это один модуль, в котором перечислена реализация всех опций. Выглядит это так:
var binaries = new Feature("Binaries", "Product binaries", true, false); var docs = new Feature("Documentation", "Product documentation (manuals and user guides)", true); var tuts = new Feature("Tutorials", "Product tutorials", false); docs.Children.Add(tuts); var project = new ManagedProject("ManagedSetup", new Dir(@"%ProgramFiles%\My Company\My Product", new File(binaries, @"Files\bin\MyApp.exe"), new Dir("Docs", new File(docs, "readme.txt"), new File(tuts, "setup.cs")))); project.Binaries = new[] { new Binary(new Id("EchoBin"), @"Files\Echo.exe") }; project.Actions = new WixSharp.Action[] { new InstalledFileAction("registrator_exe", "/u", Return.check, When.Before, Step.InstallFinalize, Condition.Installed), new InstalledFileAction("registrator_exe", "", Return.check, When.After, Step.InstallFinalize, Condition.NOT_Installed), new PathFileAction(@"%WindowsFolder%\notepad.exe", @"C:\boot.ini", "INSTALLDIR", Return.asyncNoWait, When.After, Step.PreviousAction, Condition.NOT_Installed), new ScriptAction(@"MsgBox ""Executing VBScript code...""", Return.ignore, When.After, Step.PreviousAction, Condition.NOT_Installed), new ScriptFileAction(@"Files\Sample.vbs", "Execute" , Return.ignore, When.After, Step.PreviousAction, "NOT Installed"), new BinaryFileAction("EchoBin", "Executing Binary file...", Return.check, When.After, Step.InstallFiles, Condition.NOT_Installed) { Execute = Execute.deferred } }; project.Properties = new[] { new Property("Gritting", "Hello World!"), new Property("Title", "Properties Test"), new PublicProperty("NOTEPAD_FILE", @"C:\boot.ini") } project.GUID = new Guid("6f330b47-2577-43ad-9095-1861ba25889b"); project.ManagedUI = ManagedUI.Default; project.UIInitialized += Project_UIInitialized; project.Load += msi_Load; project.AfterInstall += msi_AfterInstall;
В своем проекте у нас получилось гораздо больше различных опций и их реализаций. Поэтому мы разнесли все опции по модулям и получили лаконичный вид:
var project = new ManagedProject(ProjectConstants.PROJECT_NAME) { GUID = new Guid(ProjectConstants.PROJECT_GUID), Platform = Platform.x64, UpgradeCode = new Guid(ProjectConstants.PROJECT_GUID), InstallScope = InstallScope.perMachine, Description = ProjectConstants.COMPANY_NAME, Language = "ru-RU", LocalizationFile = @"WixUI_ru-ru.wxl", ControlPanelInfo = productInfo, MajorUpgradeStrategy = upgradeStrategy, MajorUpgrade = majorUpgrade, DefaultRefAssemblies = RefAssembliesGenerator.InitializeRefAssemblies(), GenericItems = GenericEntitiesGenerator.InitializeGenericEntities(), Properties = PropertiesGenerator.InitializeProperties(), Dirs = DirsGenerator.InitializeDirs(), Binaries = BinariesGenerator.InitializeBinaries(), Actions = ActionsGenerator.InitializeActions(), ManagedUI = new ManagedUI(), ReinstallMode = "amus" };
Глобальные переменные msi
В нашем проекте мы столкнулись с необходимостью объявить не один десяток свойств (Property), которые, подобно глобальным переменным, могут использоваться практически во всех местах установки, как при работе с формами, так и при обработке Custom Action.
Обращение к этим переменным происходит по их имени в текстовом виде. Например, объявив свойство new Property("Gritting", "Hello World!") в конструкторе проекта, далее, чтобы получить к этому свойству доступ, например, из диалога, нужно обратиться к Runtime.Session["Gritting "]
Такое обращение к переменным требовало от разработчика помнить, как называется нужное ему свойство и в случае некорректного значения, ошибка была бы обнаружена только в runtime и при отработке именно того куска кода, где была допущена опечатка.
В итоге мы решили переместить все свойства и их значения в enum и упростить работу с чтением и записью этих свойств. Объявление свойств стало выглядеть следующим образом:
public enum eProperties { [Value("Hello World!"))] GRITTING, [Value("Properties Test "))] TITLE, // перечисление других свойств }
А сама генерация свойств на основе enum так:
public static class PropertiesGenerator { private static Property InizializeProperty(string propertyName, string propertyValue) { return new Property(new Id(propertyName), propertyName, propertyValue) { IsDeferred = true }; } private static IList<T> ToTypedList<T>(Type entityType, Func<Enum, T> createFunc) { if (createFunc != null && entityType.IsEnum) { return Enum.GetValues(entityType) .Cast<Enum>() .Select(createFunc) .ToList(); } return null; } public static Property[] InitializeProperties() { return ToTypedList(typeof(eProperties), e => InizializeProperty(e.ToString(), e.GetPropertyValue())) .ToArray(); } }
Обращение к свойствам из диалога тоже изменилось. На чтение стало this.GetData(GRITTING), а на запись this.SetData(GRITTING, “New value”), где GetData() и SetData() методы расширения для класса ManagedForm.
Для обращения из Custom Action стало session.Data(GRITTING)
MSI нужны зависимые библиотеки
В ходе работы над установщиком у нас появилась необходимость подключать дополнительные библиотеки (например для работы с СУБД Postgre). Сначала мы подключали все ручками, как было описано в документации Wix#:
project.DefaultRefAssemblies.Add("FontAwesome.Sharp.dll"); project.DefaultRefAssemblies.Add("Newtonsoft.Json.dll"); project.DefaultRefAssemblies.Add("ManagedOpenSsl.dll");
Однако из-за того, что стало возрастать количество зависимостей в проекте, мы решили не делать точечное добавление библиотек, а написали метод, который считывает список всевозможных dll из указанного ресурса:
private static List<string> GetResourceList(string resourcesDirPath) => Directory.GetFiles($@"{resourcesDirPath}\") .Where(file => file.EndsWith("dll")) .ToList(); public static List<string> InitializeRefAssemblies() => GetResourceList(Application.StartupPath) .Concat(GetResourceList("Resources")) .ToList();
Инициализация каталогов
C добавлением каталогов все оказалось, более или менее, очевидно и понятно. Указываем иерархию каталогов с добавлением в них необходимых артефактов и, по необходимости, фильтруем файлы по названиям и расширениям:
private static bool ServicePredicate(string file) => !file.EndsWith(".pdb"); private static IEnumerable<WixEntity> InitializeDirWixEntities(object dirName) { var items = new List<WixEntity>(); items.AddRange(new List<WixEntity> { new Dir("logs"), new DirFiles($@"Sources\{dirName}\*.*", ServicePredicate) }); return new[] { new Dir(dirName.ToString(), items.ToArray()) }; } private static WixEntity[] InilizeDirItems() => new List<WixEntity>() .Concat(InitializeDirWixEntities(FirstService)) .Concat(InitializeDirWixEntities(SecondService)) .Concat(InitializeDirWixEntities(ThirdService)) .Concat(InitializeDirWixEntities(FourthService)) .ToArray(); public static Dir[] InitializeDirs() => new[] { new Dir(@"%ProgramFiles%\CompanyName\", new Dir("distr", new Dir(FluentMigrator, GetMigratorFileList("FluentMigrator")), ), new Dir("app", InilizeDirItems()) ) };

Развертывание сайтов на IIS
Одна из задач нашего установщика — это развернуть определенное количество сайтов/сервисов на IIS, при этом должна учитываться возможность включения https с указанием сертификата ssl. Из коробки Wix# такого не умел (до выпуска версии 1.14.3). Поэтому была описана кастомная сущность Wix, которая использовала расширение WixExtension.Iis.
Базовый класс, описывающий Wix сущность для создания сайта на IIS:
public abstract class IISWebSite: WixEntity, IGenericEntity { [Xml] public string Condition; [Xml] public string Description; [Xml] public string IpAddress; [Xml] public string Port; protected string Prefix { get; } protected XElement Component { get; private set; } protected string DirId { get; private set; } protected string DirName { get; private set; } protected string WebAppPoolId { get; private set; } protected IISWebSite(string prefix) { Prefix = prefix; } public virtual void Process(ProcessingContext context) { context.Project.Include(WixExtension.IIs); DirId = context.XParent.Attribute("Id").Value; DirName = context.XParent.Attribute("Name").Value; var componentId = $"{DirName}.{Prefix}.Component.Id"; Component = new XElement(XName.Get("Component"), new XAttribute("Id", componentId), new XAttribute("Guid", WixGuid.NewGuid(componentId)), new XAttribute("KeyPath", "yes")); context.XParent.Add(Component); Component.Add(new XElement("Condition", new XCData(Condition))); WebAppPoolId = $"{DirName}.{Prefix}.WebAppPool.Id"; Component.Add(new XElement(WixExtension.IIs.ToXName("WebAppPool"), new XAttribute("Id", WebAppPoolId), new XAttribute("Name", $"AppPool{Description}") )); } }
Далее класс-наследник, для cоздания сайта с подключением по http:
public sealed class IISWebSiteHttp : IISWebSite { public IISWebSiteHttp() : base("Http") { } public override void Process(ProcessingContext context) { base.Process(context); Component.Add(new XElement(WixExtension.IIs.ToXName("WebSite"), new XAttribute("Id", $"{DirName}.{Prefix}.WebSite.Id"), new XAttribute("Description", Description), new XAttribute("Directory", DirId), new XElement(WixExtension.IIs.ToXName("WebAddress"), new XAttribute("Id", $"{DirName}.{Prefix}.WebAddress.Id"), new XAttribute("IP", IpAddress), new XAttribute("Port", Port), new XAttribute("Secure", "no") ), new XElement(WixExtension.IIs.ToXName("WebApplication"), new XAttribute("Id", $"{DirName}.{Prefix}.WebSiteApplication.Id"), new XAttribute("WebAppPool", WebAppPoolId), new XAttribute("Name", $"AppPool{Description}")) )); } }
И класс-наследник для создания сайта с подключением по https и с возможностью привязки сертификата ssl:
public sealed class IISWebSiteHttps : IISWebSite { private readonly bool _haveCertRef; public IISWebSiteHttps(bool haveCertRef) : base(haveCertRef ? "HttpsCertRef" : "Https") { _haveCertRef = haveCertRef; } public override void Process(ProcessingContext context) { base.Process(context); var siteConfig = new XElement(WixExtension.IIs.ToXName("WebSite"), new XAttribute("Id", $"{DirName}.{Prefix}.WebSite.Id"), new XAttribute("Description", Description), new XAttribute("Directory", DirId), new XElement(WixExtension.IIs.ToXName("WebAddress"), new XAttribute("Id", $"{DirName}.{Prefix}.WebAddress.Id"), new XAttribute("IP", IpAddress), new XAttribute("Port", Port), new XAttribute("Secure", "yes") ), new XElement(WixExtension.IIs.ToXName("WebApplication"), new XAttribute("Id", $"{DirName}.{Prefix}.WebSiteApplication.Id"), new XAttribute("WebAppPool", WebAppPoolId), new XAttribute("Name", $"AppPool{Description}"))); if (_haveCertRef) { siteConfig.Add(new XElement(WixExtension.IIs.ToXName("CertificateRef"), new XAttribute("Id", IISConstants.CERTIFICATE_ID))); } Component.Add(siteConfig); } }
Все параметры сайта указываются на формах и передаются через глобальные переменные в новый экземпляр объекта.
Через некоторое время в релиз Wix# добавили схожее расширение. Но, в отличии от реализации во фреймворке, наше расширение позволяет менять протокол у сайтов и делать привязку сертификата ssl.
Инициализация наших объектов получилась следующая:
new List<WixEntity> { new IISWebSiteHttp { Condition = HttpSiteCondition, Description = siteName, IpAddress = ipAddress, Port = port }, new IISWebSiteHttps(false) { Condition = HttpsSiteWithoutCertCondition, Description = siteName, IpAddress = ipAddress, Port = port }, new IISWebSiteHttps(true) { Condition = HttpsSiteWithCertCondition, Description = siteName, IpAddress = ipAddress, Port = port } }

Создаем БД из msi
В предыдущей версии установщика, создание/обновление БД делало внешнее приложение. Так как Wix# позволяет запускать свои Custom Action, мы решили добавить возможность создания и обновления БД прямо в msi.
В формах заносятся первичные данные по БД (провайдер, адрес сервер, название) и в Custom Action передаются эти данные через глобальные переменные:
[CustomAction] public static ActionResult ExecMigratorRunner(Session session) { var workDir = session.Data(MIGRATOR_FILE_DIR); var appCmdFile = $@"{workDir}{session.Data(MIGRATOR_FILE_NAME)}"; var args = session.Data(MIGRATOR_ARGS); return ProccessHelper.RunApplication(appCmdFile, args); }
Опытный читатель, скорее всего, спросит, почему не использовали коробочные решения Wix#? Например такое:
var project = new Project("MyProduct", new Dir(@"%ProgramFiles%\My Company\My Product", new File(@"Files\Bin\MyApp.exe")), new User("James") { Password = "Password1" }, new Binary(new Id("script"), "script.sql"), new SqlDatabase("MyDatabase0", ".\\SqlExpress", SqlDbOption.CreateOnInstall, new SqlScript("script", ExecuteSql.OnInstall), new SqlString("alter login Bryce with password = 'Password1'", ExecuteSql.OnInstall) ) );
Все просто. В нашем проекте используется Fluent Migrator. И для разворачивания новой БД нужна только собранная библиотека, которую нужно вызвать через командную строку с параметрами, содержащими информацию по создаваемой БД. А поддержка различных провайдеров СУБД ложится уже на саму библиотеку.
Сценарий обновления БД реализуется по всем канонам накатывания миграций.
Какой еще функционал мы реализовали?
-
Назначение прав доступа на папки приложения. Через CustomAction, т.к. коробочное решение раздает права в определенные момент установки, и мы не нашли возможности переиспользовать наработки Wix.
-
Добавление пользователей БД и СУБД (через CustomAction, по тем же причинам).
-
Добавление сертификата ssl в локальное хранилище.
-
Привязка вновь добавленных сертификатов ssl к сайтам на IIS (через CustomAction).
-
Принудительный запуск сайтов на IIS (через CustomAction).
-
Обновление старой версии БД (до внедрения Fluent Migrator) путем запуска скрипта t-sql (через CustomAction).
-
Проверка соединения с сервером БД (на форме).
-
Проверка соединения с сервером RabbitMQ (на форме).
-
Проверка сайтов и их адресов на уникальность на текущей IIS (на форме).
-
Проверка необходимых компонентов на текущей машине (на форме).
А как же обновление?
Да. Без этого сценария, установщик для нас и заказчиков стал бы бесполезным.
Мы рассмотрели различные варианты обновлений доступных через msi и остановились на major upgrade. Нам нет необходимости хранить устаревшие исполняемые файлы и, например, выпускать патчи. Нас устроил вариант с полным удалением старой версии ПО и установкой новой версии.
Wix# из коробки позволяет сделать достаточно неплохую схему обновления. Но, в нашем случае, все-таки пришлось добавить несколько своих событий.
Во-первых, мы сделали сохранение глобальных переменных в реестр в зашифрованном виде, чтобы была связь с предыдущей установкой. Это дало возможность отображать ранее введенные данные в формах по установленной версии.
Далее добавили собственную проверку установленной версии продукта, для совместимости с предыдущими версиями установщика (приложения установленные через InnoSetup не определялись msi как тот же продукт).
И защитили БД от удаления в режиме обновления.
Какие у нас планы по расширению функционала?
-
Конфигурирование очередей на сервере RabbitMQ.
-
Разворачивание сервиса на IIS, написанного на Python.
-
Реализовать режим Modify средствами msi (возможность изменить введенные ранее в установщике настройки приложения).
-
Переписать UI c WinForms на WPF.
Надеемся, что наш опыт будет полезен и ждем ваши вопросы и комментарии по нашей реализации.
ссылка на оригинал статьи https://habr.com/ru/company/crosstech/blog/543032/
Добавить комментарий