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