Работа в команде Configuration Management связана с обеспечением функциональности билд-процессов — сборки продуктов компании, предварительной проверки кода, статистического анализа, ведения документации и многого другого. Помимо этого, мы постоянно работаем над оптимизацией различных процессов, и, что замечательно, мы практически свободны в выборе инструментов для этой интересной работы. Далее я подробно расскажу о том, как, обладая лишь разного уровня знаниями в C# и C++, я сделал функциональный WCF-сервис для работы с очередями фиксов. И почему решил, что это очень важно.
Автоматизация один раз или инструкция на 117 страниц снова и снова
Небольшое лирическое отступление, чтобы вы поняли, почему я так переживаю из-за автоматизации и оптимизации процессов.
До Veeam я работал в крупной международной компании — был тимлидом команды Configuration Management, занимался сборкой приложения и развертыванием его на тестовых окружениях. Программа успешно разрабатывалась, добавлялись новые функции, писалась документация, поддержкой которой я тоже занимался. Но меня всегда удивляло, почему у такой серьезной программы нет нормальной системы конфигурации параметров, которых были многие десятки, если не сотни.
Я общался на эту тему с разработчиками и получил ответ – заказчик не оплатил эту фичу, не согласовал ее стоимость, поэтому фича не была реализована. А по факту страдали QA и непосредственно мы, команда СМ. Конфигурация программы и ее предварительная настройка осуществлялась через множество файлов конфигурации, в каждом из которых были десятки параметров.
Каждый новый билд, каждая новая версия вносили свои изменения в конфигурацию. Старые файлы конфигурации нельзя было использовать, так как они часто были несовместимы с новой версией. В итоге каждый раз перед разворачиванием билда для теста или на рабочих машинах тестеров, приходилось тратить уйму времени на конфигурирование программы, исправление ошибок конфигурации, постоянные консультации с разработчиками по теме «а почему это теперь не так работает»? В общем, процесс был крайне не оптимизирован.
В помощь при настройке у нас была инструкция на 117 страниц шрифтом Arial размером 9. Читать приходилось очень-очень внимательно. Иногда казалось, что проще собрать ядро линукс с закрытыми глазами на выключенном компьютере.
Стало понятно, что без оптимизации здесь не обойтись. Я начал писать свой конфигуратор для программы с поддержкой профилей и возможностью менять параметры за несколько секунд, но проект подошел к своей финальной стадии, и я перешел на работу в другой проект. В нем мы анализировали множество логов одной биллинговой системы на предмет возможных багов в работе серверной части. От чудовищного объема ручной работы меня спасла автоматизация многих действий с помощью языка Python. Мне очень понравился этот скриптовый язык, и с его помощью мы сделали набор скриптов анализа на все случаи жизни. Те задачи, которые требовали несколько дней вдумчивого анализа по схеме «cat logfile123 | grep something_special», занимали считанные минуты. Все стало здорово… и скучно.
Configuration Management — новые приключения
В компанию Veeam я пришел как тимлид небольшой СM-команды. Множество процессов требовало автоматизации, оптимизации, переосмысления. Зато предоставлялась полная свобода в выборе инструментов! Разработчик обязан использовать определенный язык программирования, код-стайл, определенный набор библиотек. СМ же может вообще ничего не использовать для решения поставленной задачи, если у него хватит на это времени, смелости и терпения.
У Veeam, как и у многих других компаний, существует задача сборки апдейтов для продуктов. В апдейт входили сотни файлов, и менять надо было только те, которые изменились, учитывая еще ряд важных условий. Для этого создали объемный powershell скрипт, который умел лезть в TFS, делать выборку файлов, раскладывать их по нужным папочкам. Функциональность скрипта дополнялась, он постепенно стал огромным, требовал кучу времени на отладку и постоянно каких-то костылей в придачу. Надо было срочно что-то делать.
Что хотели разработчики
Вот к чему сводились основные жалобы:
- Невозможно поставить фиксы в очередь. Приходится постоянно проверять веб-страницу, чтобы увидеть, когда закончится сборка приватного фикса и можно будет запустить сборку своего.
- Нет нотификаций об ошибках — чтобы посмотреть ошибки в GUI приложения сборки, приходится заходить на сервер и смотреть множество объемных логов.
- Нет истории сборки приватных фиксов.
Нужно было разобраться с этими задачами и добавить приятных мелочей, от которых разработчики тоже бы не отказались.
Что такое приватные фиксы
Приватный фикс в контексте нашей разработки — это определенный набор исправлений в коде, который сохраняется в шелвсете (shelveset) Team Foundation Server для релизной ветки. Небольшое разъяснение для тех, кто не слишком знаком с терминологией TFS:
- check-in — набор локальных изменений в исходном коде, который вносится в код, хранящийся в TFS. Данный чекин может проверяться с помощью Continuous Integration/Gated Check-in процессов, позволяющих пропускать только корректный код и отклонять все чекины, нарушающие собираемость конечного проекта.
- shelveset — набор локальных изменений в исходном коде, который не вносится непосредственно в исходный код, находящийся в TFS, но доступен по его имени. Шелвсет может быть развернут на локальной машине разработчика или билд-системы для работы с измененным кодом, который не внесен в TFS. Также шелвсет может быть добавлен в TFS как чекин после разворачивания, когда все работы с ним будут завершены. К примеру, так работает гейтед-чекин. Сначала проверяется шелвсет на билдере. Если проверка проходит успешно, шелвсет превращается в чекин!
Вот что делает билдер приватных фиксов:
- Получает название (номер) шелвсета и разворачивает его на билдере приватных фиксов. В итоге мы получаем исходный код релизного продукта плюс изменения/фиксы из шелвсета. Релизная ветка остается без изменений.
- На билдере приватных фиксов собирается проект или ряд проектов, для которых был выполнен приватный фикс.
- Набор скомпилированных бинарных файлов копируется в сетевой каталог приватного фикса. Каталог содержит в себя имя шелвсета, которое представляет собой последовательность чисел.
- Исходный код на билдере приватных фиксов приводится к первоначальному виду.
Для удобства разработчиков используется веб-интерфейс, где можно указать продукт, для которого надо собрать приватный фикс, указать номер шелвсета, выбрать проекты, для которых требуется собрать приватный фикс, и добавить сборку фикса в очередь. На скриншоте ниже приведен финальный рабочий вариант веб-приложения, где отображается текущий статус билда, очередь приватных фиксов и история их сборки. В нашем примере рассматривается только организация очереди сборки приватных фиксов.
Что было у меня
- Билдер приватных фиксов, который собирал приватные фиксы из шелвсетов TFS с помощью запуска консольного приложения с заданными параметрами командной строки.
- Veeam.Builder.Agent – написанный в компании Veeam WCF-сервис, который запускает приложение с параметрами в консольном режиме под текущим пользователем и возвращает текущий статус работы приложения.
- IIS веб-сервис – приложение на Windows Forms, которое позволяло ввести имя шелвсета, заданные параметры и запустить процесс сборки приватного фикса.
- Весьма неглубокие знания в программировании — C++, немного C# в университете и написание небольших приложений для автоматизации, добавления новых функций в текущие билд-процессы и в качестве хобби.
- Опытные коллеги, Google и индийские статьи на MSDN — источники ответов на все вопросы.
Что будем делать
В этой статье я расскажу, как реализовал постановку сборки фиксов в очередь и последовательный их запуск на билдере. Вот из каких частей будет состоять решение:
- QBuilder.AppQueue – мой WCF-сервис, обеспечивающий работу с очередью сборки и вызывающий сервис Veeam.Builder.Agent для запуска программы сборки.
- dummybuild.exe – программа-заглушка, используемая для отладки и в качестве наглядного пособия. Нужна для визуализации передаваемых параметров.
- QBuilder.AppLauncher – WCF-сервис, который запускает приложения в консоли текущего пользователя и работает в интерактивном режиме. Это значительно упрощенный, написанный специально для этой статьи аналог программы Veeam.Builder.Agent. Оригинальный сервис умеет работать как windows-сервис и запускать приложения в консоли, что требует дополнительной работы с Windows API. Чтобы описать все ухищрения, потребовалась бы отдельная статья. Мой же пример работает как простой интерактивный консольный сервис и использует две функции — запуск процесса с параметрами и проверку его состояния.
Дополнительно создали новое удобное веб-приложение, которое умеет работать с несколькими билдерами и вести логи событий. Чтобы не перегружать статью, подробно рассказывать о нем мы тоже пока не будем. Кроме этого, в этой статье не приведена работа с TFS, с историей хранений собранных приватных фиксов и различные вспомогательные классы и функции.
Создание WCF-сервисов
Есть много подробных статей, описывающих создание WCF-сервисов. Мне больше всех понравился материал с сайта Microsoft. Его я взял за основу при разработке. Чтобы облегчить знакомство с проектом, я дополнительно выложил бинарники. Начнем!
Создаем сервис QBuilder.AppLauncher
Здесь у нас будет только первичная болванка сервиса. На данном этапе нам нужно убедиться, что сервис запускается и работает. Кроме этого, код идентичен как для QBuilder.AppLauncher, так и для QBuilder.AppQueue, поэтому этот процесс необходимо будет повторить два раза.
- Создаем новое консольное приложение с именем QBuilder.AppLauncher
- Переименовываем Program.cs в Service.cs
- Переименовываем namespace в QBuilder.AppLauncher
- Добавляем следующие референсы в проект:
a. System.ServiceModel.dll
b. System.ServiceProcess.dll
c. System.Configuration.Install.dll - Добавляем следующие определения в Service.cs
using System.ComponentModel; using System.ServiceModel; using System.ServiceProcess; using System.Configuration; using System.Configuration.Install;
В процессе дальнейшей сборки также понадобятся следующие определения:
using System.Reflection; using System.Xml.Linq; using System.Xml.XPath;
- Определяем интерфейс IAppLauncher и добавляем функции для работы с очередью:
// Определяем сервис контракт [ServiceContract(Namespace = "http://QBuilder.AppLauncher")] public interface IAppLauncher { // Добавляем функцию для проверки работы сервиса [OperationContract] bool TestConnection(); }
- В классе AppLauncherService имплементируем интерфейс и тестовую функцию TestConnection:
public class AppLauncherService : IAppLauncher { public bool TestConnection() { return true; } }
- Создаем новый класс AppLauncherWindowsService, который наследует ServiceBase класс. Добавляем локальную переменную serviceHost – ссылку на ServiceHost. Определяем метод Main, который вызывает ServiceBase.Run(new AppLauncherWindowsService()):
public class AppLauncherWindowsService : ServiceBase { public ServiceHost serviceHost = null; public AppLauncherWindowsService() { // Name the Windows Service ServiceName = "QBuilder App Launcher"; } public static void Main() { ServiceBase.Run(new AppLauncherWindowsService()); }
- Переопределяем функцию OnStart(), создающую новый экземпляр ServiceHost:
protected override void OnStart(string[] args) { if (serviceHost != null) { serviceHost.Close(); } // Create a ServiceHost for the CalculatorService type and // provide the base address. serviceHost = new ServiceHost(typeof(AppLauncherService)); // Open the ServiceHostBase to create listeners and start // listening for messages. serviceHost.Open(); }
- Переопределяем функцию onStop, закрывающую экземпляр ServiceHost:
protected override void OnStop() { if (serviceHost != null) { serviceHost.Close(); serviceHost = null; } } }
- Создаем новый класс ProjectInstaller, наследуемый от Installer и отмеченный RunInstallerAttribute, который установлен в True. Это позволяет установить Windows-сервис с помощью программы installutil.exe:
[RunInstaller(true)] public class ProjectInstaller : Installer { private ServiceProcessInstaller process; private ServiceInstaller service; public ProjectInstaller() { process = new ServiceProcessInstaller(); process.Account = ServiceAccount.LocalSystem; service = new ServiceInstaller(); service.ServiceName = "QBuilder App Launcher"; Installers.Add(process); Installers.Add(service); } }
- Меняем содержимое файла app.config:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <system.serviceModel> <services> <service name="QBuilder.AppLauncher.AppLauncherService" behaviorConfiguration="AppLauncherServiceBehavior"> <host> <baseAddresses> <add baseAddress="http://localhost:8000/QBuilderAppLauncher/service"/> </baseAddresses> </host> <endpoint address="" binding="wsHttpBinding" contract="QBuilder.AppLauncher.IAppLauncher" /> <endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" /> </service> </services> <behaviors> <serviceBehaviors> <behavior name="AppLauncherServiceBehavior"> <serviceMetadata httpGetEnabled="true"/> <serviceDebug includeExceptionDetailInFaults="False"/> </behavior> </serviceBehaviors> </behaviors> </system.serviceModel> </configuration>
Проверяем работоспособность сервиса
- Компилируем сервис.
- Устанавливаем его командой installutil.exe
1) Переходим в папку, где лежит скомпилированный файл сервиса
2) Запускаем команду установки:
C:WindowsMicrosoft.NETFramework64v4.0.30319InstallUtil.exe - Заходим в оснастку services.msc, проверяем наличие сервиса «QBuilder App Launcher» и запускаем его.
- Работоспособность сервиса проверяем с помощью программы WcfTestClient.exe, которая входит в поставку VisualStudio:
1) Запускаем WcfTestClient
2) Добавляем адрес сервиса: http://localhost:8000/QBuilderAppLauncher/service
3) Открывается интерфейс сервиса:4) Вызываем тестовую функцию TestConnection, проверяем, что все работает и функция возвращает значение:
Теперь, когда получен рабочая болванка сервиса, добавляем необходимые нам функции.
Зачем мне нужна тестовая функция, которая ничего не делает
Когда я начал изучать, как написать WCF-сервис с нуля, я прочитал кучу статей по этой теме. На столе у меня лежал десяток-другой распечатанных листов, по которым я разбирался, что и как. Признаюсь, сразу запустить сервис у меня не получилось. Я потратил кучу времени и пришел к выводу, что сделать болванку сервиса действительно важно. С ней вы будете уверены, что все работает и можно приступать к реализации необходимых функций. Подход может показаться расточительным, но он облегчит жизнь, если куча написанного кода не заработает как надо.
Добавляем возможность запуска из консоли
Вернемся к приложению. На этапе отладки и в ряде других случаев требуется запуск сервиса в виде консольного приложения без регистрации в виде сервиса. Это очень полезная функция, позволяющая обойтись без утомительного использования дебаггеров. Именно в таком режиме работает сервис QBuilder.AppLauncher. Вот как ее реализовать:
- Добавляем в класс AppLauncherWindowsService процедуру RunInteractive, обеспечивающую работу сервиса в консольном режиме:
static void RunInteractive(ServiceBase[] services) { Console.WriteLine("Service is running in interactive mode."); Console.WriteLine(); var start = typeof(ServiceBase).GetMethod("OnStart", BindingFlags.Instance | BindingFlags.NonPublic); foreach (var service in services) { Console.Write("Starting {0}...", service.ServiceName); start.Invoke(service, new object[] { new string[] { } }); Console.Write("Started {0}", service.ServiceName); } Console.WriteLine(); Console.WriteLine("Press any key to stop the services and end the process..."); Console.ReadKey(); Console.WriteLine(); var stop = typeof(ServiceBase).GetMethod("OnStop", BindingFlags.Instance | BindingFlags.NonPublic); foreach (var service in services) { Console.Write("Stopping {0}...", service.ServiceName); stop.Invoke(service, null); Console.WriteLine("Stopped {0}", service.ServiceName); } Console.WriteLine("All services stopped."); }
- Вносим изменения в процедуру Main – добавляем обработку параметров командной строки. При наличии параметра /console и открытой активной сессии пользователя – запускаем программу в интерактивном режиме. В ином случае – запускаем как сервис.
public static void Main(string[] args) { var services = new ServiceBase[] { new AppLauncherWindowsService() }; // Добавляем возможность запуска сервиса в интерактивном режиме в виде консольного приложения, если есть параметр командной строки /console if (args.Length == 1 && args[0] == "/console" && Environment.UserInteractive) { // Запускаем в виде интерактивного приложения RunInteractive(services); } else { // Запускаем как сервис ServiceBase.Run(services); } }
Добавляем функции запуска приложения и проверки его статуса
Сервис сделан предельно простым, здесь нет никаких дополнительных проверок. Он умеет запускать приложения только в консольном варианте и от имени администратора. Он может запустить их и как сервис – но вы их не увидите, они будут крутиться в фоновом режиме и вы сможете увидеть их только через Task Manager. Все это можно реализовать, но это тема для отдельной статьи. Здесь для нас главное — наглядный рабочий пример.
- Для начала добавляем глобальную переменную appProcess, хранящую в себе текущий запущенный процесс.
Добавляем ее в класс
public class AppLauncherService : IAppLauncher
:public class AppLauncherService : IAppLauncher { Process appProcess;
- Добавляем в этот же класс функцию, проверяющую статус запущенного процесса:
public bool IsStarted() { if (appProcess!=null) { if (appProcess.HasExited) { return false; } else { return true; } } else { return false; } }
Функция возвращает false, если процесс не существует или уже не запущен, и true – если процесс активен.
- Добавляем функцию запуска приложения:
public bool Start(string fileName, string arguments, string workingDirectory, string domain, string userName, int timeoutInMinutes) { ProcessStartInfo processStartInfo = new ProcessStartInfo(); processStartInfo.FileName = fileName; processStartInfo.Arguments = arguments; processStartInfo.Domain = domain; processStartInfo.UserName = userName; processStartInfo.CreateNoWindow = false; processStartInfo.UseShellExecute = false; try { if (appProcess!=null) { if (!appProcess.HasExited) { Console.WriteLine("Process is still running. Waiting..."); return false; } } } catch (Exception ex) { Console.WriteLine("Error while checking process: {0}", ex); } try { appProcess = new Process(); appProcess.StartInfo = processStartInfo; appProcess.Start(); } catch (Exception ex) { Console.WriteLine("Error while starting process: {0}",ex); } return true; }
Функция запускает любое приложение с параметрами. Параметры Domain и Username в данном контексте не используются и могут быть пустыми, так как сервис запускает приложение из консольной сессии с правами администратора.
Запуск сервиса QBuilder.AppLauncher
Как ранее описывалось, данный сервис работает в интерактивном режиме и позволяет запускать приложения в текущей сессии пользователя, проверяет, запущен ли процесс или уже завершен.
- Для работы необходимы файлы QBuilder.AppLauncher.exe и QBuilder.AppLauncher.exe.config, которые находятся в архиве по ссылке выше. Там же расположен исходный код данного приложения для самостоятельной сборки.
- Запускаем сервис с правами администратора.
- Откроется консольное окно сервиса:
Любое нажатие клавиши в консоли сервиса закрывает его, будьте внимательны.
- Для тестов запускаем wcftestclient.exe, входящий в поставку Visual Studio. Проверяем доступность сервиса по адресу http://localhost:8000/QBuilderAppLauncher/service или открываем ссылку в Internet Explorer.
Если все работает, переходим к следующему этапу.
Создаем сервис QBuilder.AppQueue
А теперь перейдем к самому главному сервису, ради чего и писалась вся эта статья! Повторяем последовательность действий в главе «Создаем сервис QBuilder.AppLauncher» и в главе «Добавляем возможность запуска из консоли», заменяя в коде AppLauncher на AppQueue.
Добавляем ссылку на сервис QBuilder.AppLauncher для использования в сервисе очереди
- В Solution Explorer для нашего проекта выбираем Add Service Reference и указываем адрес: localhost:8000/QBuilderAppLauncher/service
- Выбираем имя namespace: AppLauncherService.
Теперь мы можем обращаться к интерфейсу сервиса из своей программы.
Создаем структуру хранения элементов очереди
В namespace QBuilder.AppQueue добавляем класс QBuildRecord:
// Структура, где хранится элемент очереди
public class QBuildRecord
{
// ID билда
public string BuildId { get; set; }
// ID задачи
public string IssueId { get; set; }
// Название проблемы
public string IssueName { get; set; }
// Время начало билда
public DateTime StartDate { get; set; }
// Время завершения билда
public DateTime FinishDate { get; set; }
// Флаг сборки компонентов C#
public bool Build_CSharp { get; set; }
// Флаг сборки компонентов C++
public bool Build_Cpp { get; set; }
}
Имплементируем класс работы с очередью CXmlQueue
Добавим в наш проект класс CXmlQueue.cs, где будет находиться ряд процедур работы с XML-файлом:
- Конструктор CXmlQueue — задает при инициализации имя файла, где хранится очередь.
- SetCurrentBuild — записывает информацию о текущем билде в XML-файл очереди. Это элемент, не входящий в очередь, в нем хранится информация о текущем запущенном процессе. Может быть пустым.
- GetCurrentBuild — получает параметры запущенного процесса из XML-файла очереди. Может быть пустым.
- ClearCurrentBuild — это очистка элемента currentbuild в XML-файле очереди, если процесс завершился.
- OpenXmlQueue – функция открытия XML-файла, где хранится очередь. Если файл отсутствует, то создается новый.
- GetLastQueueBuildNumber – каждый билд в очереди имеет свой уникальный последовательный номер. Данная функция возвращает его значение, которое хранится в root-атрибуте.
- IncrementLastQueueBuildNumber – увеличивает значение номера билда при постановке нового билда в очередь.
- GetCurrentQueue – возвращает список элементов QBuildRecord из XML-файла очереди.
В оригинальном коде все эти процедуры были размещены в основном классе, но для наглядности я сделал отдельный класс CXmlQueue. Класс создается в пространстве имен namespace QBuilder.AppQueue, проверьте, что указаны все необходимые определения:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;
using System.Xml.XPath;
using System.IO;
namespace QBuilder.AppQueue
{
. . .
}
Итак, имплементируем. Непосредственно класс CXmlQueue:
// Класс работы с очередью в XML файле
public class CXmlQueue
{
// Имя файла, где хранится очередь
string xmlBuildQueueFile;
public CXmlQueue(string _xmlQueueFile)
{
xmlBuildQueueFile = _xmlQueueFile;
}
public string GetQueueFileName()
{
return xmlBuildQueueFile;
}
// Функция, получающая параметры запущенного процесса из файла xml (отдельная запись в xml)
public QBuildRecord GetCurrentBuild()
{
QBuildRecord qBr;
XElement xRoot = OpenXmlQueue();
XElement xCurrentBuild = xRoot.XPathSelectElement("currentbuild");
if (xCurrentBuild != null)
{
qBr = new QBuildRecord();
qBr.BuildId = xCurrentBuild.Attribute("BuildId").Value;
qBr.IssueId = xCurrentBuild.Attribute("IssueId").Value;
qBr.StartDate = Convert.ToDateTime(xCurrentBuild.Attribute("StartDate").Value);
return qBr;
}
return null;
}
// Функция, устанавливающая параметры запущенного процесса из файла xml (отдельная запись в xml)
public void SetCurrentBuild(QBuildRecord qbr)
{
XElement xRoot = OpenXmlQueue();
XElement newXe = (new XElement(
"currentbuild",
new XAttribute("BuildId", qbr.BuildId),
new XAttribute("IssueId", qbr.IssueId),
new XAttribute("StartDate", DateTime.Now.ToString())
));
XElement xCurrentBuild = xRoot.XPathSelectElement("currentbuild");
if (xCurrentBuild != null)
{
xCurrentBuild.Remove(); // remove old value
}
xRoot.Add(newXe);
xRoot.Save(xmlBuildQueueFile);
}
// Функция, обнуляющая параметры запущенного процесса из файла xml, в случае, когда процесс закончился
public void ClearCurrentBuild()
{
XElement xRoot = OpenXmlQueue();
try
{
XElement xCurrentBuild = xRoot.XPathSelectElement("currentbuild");
if (xCurrentBuild != null)
{
Console.WriteLine("Clearing current build information.");
xCurrentBuild.Remove();
}
}
catch (Exception ex)
{
Console.WriteLine("XML queue doesn't have running build yet. Nothing to clear!");
}
xRoot.Save(xmlBuildQueueFile);
}
// Функция открытия XML очереди из файла и его создания в случае его отсутствия
public XElement OpenXmlQueue()
{
XElement xRoot;
if (File.Exists(xmlBuildQueueFile))
{
xRoot = XElement.Load(xmlBuildQueueFile, LoadOptions.None);
}
else
{
Console.WriteLine("Queue file {0} not found. Creating...", xmlBuildQueueFile);
XElement xE = new XElement("BuildsQueue", new XAttribute("BuildNumber", 0));
xE.Save(xmlBuildQueueFile);
xRoot = XElement.Load(xmlBuildQueueFile, LoadOptions.None);
}
return xRoot;
}
// Получение номера последнего элемента в очереди
public int GetLastQueueBuildNumber()
{
XElement xRoot = OpenXmlQueue();
if (xRoot.HasAttributes)
return int.Parse(xRoot.Attribute("BuildNumber").Value);
return 0;
}
// Увеличение номера последнего элемента в очереди в случае добавления новых элементов в очередь
public int IncrementLastQueueBuildNumber()
{
int buildIndex = GetLastQueueBuildNumber();
buildIndex++;
XElement xRoot = OpenXmlQueue();
xRoot.Attribute("BuildNumber").Value = buildIndex.ToString();
xRoot.Save(xmlBuildQueueFile);
return buildIndex;
}
// Выгрузка очереди из xml файла в виде списка QBuildRecord
public List<QBuildRecord> GetCurrentQueue()
{
List<QBuildRecord> qList = new List<QBuildRecord>();
XElement xRoot = OpenXmlQueue();
if (xRoot.XPathSelectElements("build").Any())
{
List<XElement> xBuilds = xRoot.XPathSelectElements("build").ToList();
foreach (XElement xe in xBuilds)
{
qList.Add(new QBuildRecord
{
BuildId = xe.Attribute("BuildId").Value,
IssueId = xe.Attribute("IssueId").Value,
IssueName = xe.Attribute("IssueName").Value,
StartDate = Convert.ToDateTime(xe.Attribute("StartDate").Value),
Build_CSharp = bool.Parse(xe.Attribute("Build_CSharp").Value),
Build_Cpp = bool.Parse(xe.Attribute("Build_Cpp").Value)
});
}
}
return qList;
}
}
Очередь в XML-файле выглядит следующим образом:
<?xml version="1.0" encoding="utf-8"?>
<BuildsQueue BuildNumber="23">
<build BuildId="14" IssueId="26086" IssueName="TestIssueName" StartDate="2018-06-13T16:49:50.515238+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="15" IssueId="59559" IssueName="TestIssueName" StartDate="2018-06-13T16:49:50.6880927+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="16" IssueId="45275" IssueName="TestIssueName" StartDate="2018-06-13T16:49:50.859937+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="17" IssueId="30990" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.0321322+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="18" IssueId="16706" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.2009904+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="19" IssueId="66540" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.3581274+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="20" IssueId="68618" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.5087854+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="21" IssueId="18453" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.6713477+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="22" IssueId="68288" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.8277942+02:00" Build_CSharp="true" Build_Cpp="true" />
<build BuildId="23" IssueId="89884" IssueName="TestIssueName" StartDate="2018-06-13T16:49:52.0151294+02:00" Build_CSharp="true" Build_Cpp="true" />
<currentbuild BuildId="13" IssueId="4491" StartDate="13.06.2018 16:53:16" />
</BuildsQueue>
Создайте файл BuildQueue.xml с данным содержимым и положите в каталог с исполняемым файлом. Данный файл будет использоваться в тестовой отладке для соответствия тестовых результатов.
Добавляем класс AuxFunctions
В данном классе я размещаю вспомогательные функции. Сейчас здесь находится только одна функция, FormatParameters, которая выполняет форматирование параметров для передачи их в консольное приложение с целью запуска. Листинг файла AuxFunctions.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace QBuilder.AppQueue
{
class AuxFunctions
{
// Функция формирования параметров для запуска приложения
public static string FormatParameters(string fileName, IDictionary<string, string> parameters)
{
if (String.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentNullException("fileName");
}
if (parameters == null)
{
throw new ArgumentNullException("parameters");
}
var macros = String.Join(" ", parameters.Select(parameter => String.Format(""{0}={1}"", parameter.Key, parameter.Value.Replace(@"""", @""""))));
return String.Format("{0} /b "{1}"", macros, fileName);
}
}
}
Добавляем новые функции в интерфейс сервиса
Тестовую функцию TestConnection на данном этапе можно удалить. Для реализации работы очереди мне потребовался следующий набор функций:
- PushBuild(QBuildRecord): void. Это функция, добавляющая в XML-файл очереди новое значение с параметрами QBuildRecord
- TestPushBuild(): void. Это тестовая функция, добавляющая тестовые данные в очередь в XML-файле.
- PullBuild: QBuildRecord. Это функция, получающая значение QBuildRecord из XML-файла очереди. Он может быть пустым.
Интерфейс будет вот таким:
public interface IAppQueue
{
// Функция добавления в очередь
[OperationContract]
void PushBuild(QBuildRecord qBRecord);
// Тестовое добавление в очередь
[OperationContract]
void TestPushBuild();
// Функция получения элемента из очереди
[OperationContract]
QBuildRecord PullBuild();
}
Имплементируем функции в классе AppQueueService: IAppQueue:
public class AppQueueService : IAppQueue
{
// Сервис агента, запускающего консольные приложения
public AppLauncherClient buildAgent;
// Переменная, где хранится имя файла очереди
private string _xmlQueueFile;
public AppQueueService()
{
// Получаем значение файла очереди из конфиг файла. Это не самое лучшее решение, я знаю.
_xmlQueueFile = ConfigurationManager.AppSettings["QueueFileName"];
}
public QBuildRecord PullBuild()
{
QBuildRecord qBr;
CXmlQueue xmlQueue = new CXmlQueue(_xmlQueueFile);
XElement xRoot = xmlQueue.OpenXmlQueue();
if (xRoot.XPathSelectElements("build").Any())
{
qBr = new QBuildRecord();
XElement xe = xRoot.XPathSelectElements("build").FirstOrDefault();
qBr.BuildId = xe.Attribute("BuildId").Value;
qBr.IssueId = xe.Attribute("IssueId").Value;
qBr.IssueName = xe.Attribute("IssueName").Value;
qBr.StartDate = Convert.ToDateTime(xe.Attribute("StartDate").Value);
qBr.Build_CSharp = bool.Parse(xe.Attribute("Build_CSharp").Value);
qBr.Build_Cpp = bool.Parse(xe.Attribute("Build_Cpp").Value);
xe.Remove(); // Remove first element
xRoot.Save(xmlQueue.GetQueueFileName());
return qBr;
}
return null;
}
public void PushBuild(QBuildRecord qBRecord)
{
CXmlQueue xmlQueue = new CXmlQueue(_xmlQueueFile);
XElement xRoot = xmlQueue.OpenXmlQueue();
xRoot.Add(new XElement(
"build",
new XAttribute("BuildId", qBRecord.BuildId),
new XAttribute("IssueId", qBRecord.IssueId),
new XAttribute("IssueName", qBRecord.IssueName),
new XAttribute("StartDate", qBRecord.StartDate),
new XAttribute("Build_CSharp", qBRecord.Build_CSharp),
new XAttribute("Build_Cpp", qBRecord.Build_Cpp)
));
xRoot.Save(xmlQueue.GetQueueFileName());
}
public void TestPushBuild()
{
CXmlQueue xmlQueue = new CXmlQueue(_xmlQueueFile);
Console.WriteLine("Using queue file: {0}",xmlQueue.GetQueueFileName());
int buildIndex = xmlQueue.IncrementLastQueueBuildNumber();
Random rnd = new Random();
PushBuild
(new QBuildRecord
{
Build_CSharp = true,
Build_Cpp = true,
BuildId = buildIndex.ToString(),
StartDate = DateTime.Now,
IssueId = rnd.Next(100000).ToString(),
IssueName = "TestIssueName"
}
);
}
}
Вносим изменения в класс AppQueueWindowsService: ServiceBase
Добавляем новые переменные в тело класса:
// Таймер, необходимый для обращения к очереди через определенный интервал
private System.Timers.Timer timer;
// Переменная, в которой информация о запущенном процессе
public QBuildRecord currentBuild;
//public QBuildRecord processingBuild;
// Переменная, где будет хранится статус клиентского сервиса
public bool clientStarted;
// Имя файла очереди
public string xmlBuildQueueFileName;
// Класс очереди
public CXmlQueue xmlQueue;
// Строковые переменные для запуска процесса в клиентском сервисе
public string btWorkingDir;
public string btLocalDomain;
public string btUserName;
public string buildToolPath;
public string btScriptPath;
public int agentTimeoutInMinutes;
// Очередь
public AppQueueService buildQueueService;
В конструктор AppQueueWindowsService() добавляем функции для чтения файла конфигурации, инициализации сервисов и классов очереди:
// Считываем параметры из файла конфигурации и задаем начальные параметры
try
{
xmlBuildQueueFileName = ConfigurationManager.AppSettings["QueueFileName"];
buildToolPath = ConfigurationManager.AppSettings["BuildToolPath"];
btWorkingDir = ConfigurationManager.AppSettings["BuildToolWorkDir"];
btLocalDomain = ConfigurationManager.AppSettings["LocalDomain"];
btUserName = ConfigurationManager.AppSettings["UserName"];
btScriptPath = ConfigurationManager.AppSettings["ScriptPath"];
agentTimeout= 30000;
// Инициализируем сервис очереди
buildQueueService = new AppQueueService();
// Инициализируем класс очереди
xmlQueue = new CXmlQueue(xmlBuildQueueFileName);
}
catch (Exception ex)
{
Console.WriteLine("Error while loading configuration: {0}", ex);
}
AgentTimeout — частота срабатывания таймера. Указывается в миллисекундах. Здесь мы задаем, что таймер должен срабатывать каждые 30 секунд. В оригинале данный параметр находится в файле конфигурации. Для статьи я его решил задавать в коде.
Добавляем в класс функцию проверки запущенного билд-процесса:
// Функция проверки запущенного приложения в агентском сервисе
public bool BuildIsStarted()
{
IAppLauncher builderAgent;
try
{
builderAgent = new AppLauncherClient();
return builderAgent.IsStarted();
}
catch (Exception ex)
{
return false;
}
}
Добавляем процедуру работы с таймером:
private void TimerTick(object sender, System.Timers.ElapsedEventArgs e)
{
try
{
// Если билд не запущен
if (!BuildIsStarted())
{
// Проверяем значение булевой переменой clientStarted, показывающей статус нашего приложения
if (clientStarted)
{
// Если приложение уже завершило работу, устанавливаем clientStarted в false и присваиваем дату завершения процесса
currentBuild.FinishDate = DateTime.Now;
clientStarted = false;
}
else
{
// Если приложение уже не работает и clientStarted=false (статус приложения) - очищаем информацию о текущем билде
xmlQueue.ClearCurrentBuild();
}
// Достаем из очереди информацию об очередном билде
currentBuild = buildQueueService.PullBuild();
// Если значение не нулевое, начинаем работу с билдом
if (currentBuild != null)
{
// Статус клиента меняем на true - клиент в работе
clientStarted = true;
// Присваиваем значение currentbuild - данная информация отображается в xml и используется в веб приложения для отображения информации о текущем билде
xmlQueue.SetCurrentBuild(currentBuild);
// Формируем список параметров командной строки
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{"BUILD_ID", currentBuild.BuildId},
{"ISSUE_ID", currentBuild.IssueId},
{"ISSUE_NAME", currentBuild.IssueName},
{"BUILD_CSHARP", currentBuild.Build_CSharp ? "1" : "0"},
{"BUILD_CPP", currentBuild.Build_Cpp ? "1" : "0"}
};
// Форматируем список параметров для нашей программы
var arguments = AuxFunctions.FormatParameters(btScriptPath, parameters);
try
{
// Запускаем нашу программу с параметрами командной строки через сервис AppLauncher
IAppLauncher builderAgent = new AppLauncherClient();
builderAgent.Start(buildToolPath, arguments, btWorkingDir, btLocalDomain, btUserName, agentTimeout);
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
Вносим изменения в функцию OnStart, добавляем функцию работы с таймером:
// Переопределяем процедуру запуска сервиса OnStart
protected override void OnStart(string[] args)
{
if (serviceHost != null)
{
serviceHost.Close();
}
// Добавляем функционал работы с таймером
this.timer = new System.Timers.Timer(agentTimeout); // указывается в миллисекундах
this.timer.AutoReset = true;
this.timer.Elapsed += new System.Timers.ElapsedEventHandler(this.TimerTick);
this.timer.Start();
// Создаем ServiceHost для сервиса AppQueueService
serviceHost = new ServiceHost(typeof(AppQueueService));
// Открываем ServiceHostBase и ждем обращений к сервису
serviceHost.Open();
}
Проверяем список используемых определений
Вот как он должен теперь выглядеть:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.ComponentModel;
using System.ServiceModel;
using System.ServiceProcess;
using System.Configuration;
using System.Configuration.Install;
using System.Reflection;
using System.Xml.Linq;
using System.Xml.XPath;
using QBuilder.AppQueue.AppLauncherService;
Добавляем секцию конфигурации в App.config
В секцию добавляем следующий набор параметров:
<appSettings>
<add key="QueueFileName" value="BuildQueue.xml"/>
<add key="BuildToolPath" value="c:tempdummybuild.exe"/>
<add key="BuildToolWorkDir" value="c:temp"/>
<add key="LocalDomain" value="."/>
<add key="UserName" value="username"/>
<add key="ScriptPath" value="C:TempBuildSample.bld"/>
</appSettings>
Проверяем работу сервиса
- Распаковываем архив QBuilder.AppLauncher.zip. Он и другие нужные файлы доступны по ссылке.
- Копируем файл dummybuild.exe из каталога внутри архива binaries в каталог, например, в c:temp. Данная программа является тестовой заглушкой и просто отображает параметры командной строки, которые передает сервис запускаемому приложению. Если вы используете другой каталог, не забудьте изменить параметры BuildToolPath и BuildToolWorkDir в файле конфигурации.
- Переходим в каталог QBuilder.AppLauncherbinariesQBuilder.AppLauncher и запускаем файл QBuilder.AppLauncher.exe в режиме администратора. Также вы можете собрать данный сервис из исходников.
- Запускаем консольный вариант скомпилированного сервиса командой QBuilder.AppQueue.exe /console с правами администратора.
- Проверяем, что сервис запустился и работает:
- Запускаем и ждем. Если все работает успешно, то через 30 секунд появится следующее окно:
- Открываем файл BuildQueue.xml и наблюдаем, как уменьшается очередь и меняется значение currentbuild:
<?xml version="1.0" encoding="utf-8"?> <BuildsQueue BuildNumber="23"> <build BuildId="19" IssueId="66540" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.3581274+02:00" Build_CSharp="true" Build_Cpp="true" /> <build BuildId="20" IssueId="68618" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.5087854+02:00" Build_CSharp="true" Build_Cpp="true" /> <build BuildId="21" IssueId="18453" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.6713477+02:00" Build_CSharp="true" Build_Cpp="true" /> <build BuildId="22" IssueId="68288" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.8277942+02:00" Build_CSharp="true" Build_Cpp="true" /> <build BuildId="23" IssueId="89884" IssueName="TestIssueName" StartDate="2018-06-13T16:49:52.0151294+02:00" Build_CSharp="true" Build_Cpp="true" /> <currentbuild BuildId="18" IssueId="16706" StartDate="13.06.2018 23:20:06" /> </BuildsQueue>
- После каждого закрытия программы dummy имитируется завершение процесса, после которого запускается следующий элемент в очереди:
<?xml version="1.0" encoding="utf-8"?> <BuildsQueue BuildNumber="23"> <build BuildId="21" IssueId="18453" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.6713477+02:00" Build_CSharp="true" Build_Cpp="true" /> <build BuildId="22" IssueId="68288" IssueName="TestIssueName" StartDate="2018-06-13T16:49:51.8277942+02:00" Build_CSharp="true" Build_Cpp="true" /> <build BuildId="23" IssueId="89884" IssueName="TestIssueName" StartDate="2018-06-13T16:49:52.0151294+02:00" Build_CSharp="true" Build_Cpp="true" /> <currentbuild BuildId="20" IssueId="68618" StartDate="13.06.2018 23:24:25" /> </BuildsQueue>
Очередь работает!
Результаты
Видавший виды powershell-скрипт был отправлен на свалку. Новое приложение полностью написано на C#. У нас появилась возможность использовать rulesets — правила, которые делали выборку файлов по специальным критериям и вставляли их только в определенные места в setup-скрипте. За счет новой системы хеширования решили проблему выборки файлов только по имени и по размеру — она возникала, когда при одинаковом имени и размере файлы отличались по контенту. Новая программа сборки апдейтов не рассматривает файлы как файлы — она их рассматривает как MD5-хеши и создает хеш-таблицу, в которой каждому набору файлов в определенном каталоге соответствовал свой уникальный хеш.
Скриншот финального решения, которое мы используем в нашей работе
Небольшие доработки в решение вносятся постоянно, но мы уже решили самую главную проблему — новый подход позволил полностью убрать человеческий фактор и избавить себя от кучи костылей. Система получилась настолько универсальной, что в ближайшее время будет использоваться для сборки хотфиксов, где меняется несколько файлов. Все это будет работать через веб-интерфейс с помощью другого приложения.
В ходе проекта я разобрался, как работать с XML, с файлами конфигурации, с файловой системой. Теперь же у меня есть свои наработки, которые я успешно использую в других проектах. Для наглядности статьи я убрал большое количество кода, которое может отвлечь от сути, и произвел серьезный рефакторинг.
Надеюсь, эта статья поможет вам в работе с WCF-сервисами, с таймерами в теле сервисов и реализации очередей через XML-файлы. Работу приложений и очереди вы можете посмотреть на видео:
P.S. Хочу выразить благодарность Виктору Бородичу, чьи советы очень помогли довести данную систему до рабочего вида. Виктор доказывает, что если посадить рядом опытных разработчиков и джуниоров, то качество кода у последних обязательно вырастет.
Автор: vhuman