Пишем установщик на 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.

Надеемся, что наш опыт будет полезен и ждем ваши вопросы и комментарии по нашей реализации.

ссылка на оригинал статьи https://habr.com/ru/company/crosstech/blog/543032/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *