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

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

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