От простых скриптов к клиент-серверному приложению на WCF своими руками: почему мне нравится работа в CM

от автора

Работа в команде 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 как чекин после разворачивания, когда все работы с ним будут завершены. К примеру, так работает гейтед-чекин. Сначала проверяется шелвсет на билдере. Если проверка проходит успешно, шелвсет превращается в чекин!

Вот что делает билдер приватных фиксов:

  1. Получает название (номер) шелвсета и разворачивает его на билдере приватных фиксов. В итоге мы получаем исходный код релизного продукта плюс изменения/фиксы из шелвсета. Релизная ветка остается без изменений.
  2. На билдере приватных фиксов собирается проект или ряд проектов, для которых был выполнен приватный фикс.
  3. Набор скомпилированных бинарных файлов копируется в сетевой каталог приватного фикса. Каталог содержит в себя имя шелвсета, которое представляет собой последовательность чисел.
  4. Исходный код на билдере приватных фиксов приводится к первоначальному виду.

Для удобства разработчиков используется веб-интерфейс, где можно указать продукт, для которого надо собрать приватный фикс, указать номер шелвсета, выбрать проекты, для которых требуется собрать приватный фикс, и добавить сборку фикса в очередь. На скриншоте ниже приведен финальный рабочий вариант веб-приложения, где отображается текущий статус билда, очередь приватных фиксов и история их сборки. В нашем примере рассматривается только организация очереди сборки приватных фиксов.

Что было у меня

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

  1. Создаем новое консольное приложение с именем QBuilder.AppLauncher
  2. Переименовываем Program.cs в Service.cs
  3. Переименовываем namespace в QBuilder.AppLauncher
  4. Добавляем следующие референсы в проект:
    a. System.ServiceModel.dll
    b. System.ServiceProcess.dll
    c. System.Configuration.Install.dll
  5. Добавляем следующие определения в 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;
  6. Определяем интерфейс IAppLauncher и добавляем функции для работы с очередью:
    // Определяем сервис контракт [ServiceContract(Namespace = "http://QBuilder.AppLauncher")]     public interface IAppLauncher     { 	      // Добавляем функцию для проверки работы сервиса         [OperationContract]         bool TestConnection();     } 
  7. В классе AppLauncherService имплементируем интерфейс и тестовую функцию TestConnection:
    public class AppLauncherService : IAppLauncher     {         public bool TestConnection()         {             return true;         }         } 
  8. Создаем новый класс 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());         } 
  9. Переопределяем функцию 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();         } 
  10. Переопределяем функцию onStop, закрывающую экземпляр ServiceHost:
    protected override void OnStop()         {             if (serviceHost != null)             {                 serviceHost.Close();                 serviceHost = null;             }         }     } 
  11. Создаем новый класс 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);         }     } 
  12. Меняем содержимое файла 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>

Проверяем работоспособность сервиса

  1. Компилируем сервис.
  2. Устанавливаем его командой installutil.exe
    1) Переходим в папку, где лежит скомпилированный файл сервиса
    2) Запускаем команду установки:
    C:\Windows\Microsoft.NET\Framework64\v4.0.30319\InstallUtil.exe
  3. Заходим в оснастку services.msc, проверяем наличие сервиса «QBuilder App Launcher» и запускаем его.
  4. Работоспособность сервиса проверяем с помощью программы WcfTestClient.exe, которая входит в поставку VisualStudio:

    1) Запускаем WcfTestClient
    2) Добавляем адрес сервиса: http://localhost:8000/QBuilderAppLauncher/service
    3) Открывается интерфейс сервиса:

    4) Вызываем тестовую функцию TestConnection, проверяем, что все работает и функция возвращает значение:

Теперь, когда получен рабочая болванка сервиса, добавляем необходимые нам функции.

Зачем мне нужна тестовая функция, которая ничего не делает

Когда я начал изучать, как написать WCF-сервис с нуля, я прочитал кучу статей по этой теме. На столе у меня лежал десяток-другой распечатанных листов, по которым я разбирался, что и как. Признаюсь, сразу запустить сервис у меня не получилось. Я потратил кучу времени и пришел к выводу, что сделать болванку сервиса действительно важно. С ней вы будете уверены, что все работает и можно приступать к реализации необходимых функций. Подход может показаться расточительным, но он облегчит жизнь, если куча написанного кода не заработает как надо.

Добавляем возможность запуска из консоли

Вернемся к приложению. На этапе отладки и в ряде других случаев требуется запуск сервиса в виде консольного приложения без регистрации в виде сервиса. Это очень полезная функция, позволяющая обойтись без утомительного использования дебаггеров. Именно в таком режиме работает сервис QBuilder.AppLauncher. Вот как ее реализовать:

  1. Добавляем в класс 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."); } 
  2. Вносим изменения в процедуру 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. Все это можно реализовать, но это тема для отдельной статьи. Здесь для нас главное — наглядный рабочий пример.

  1. Для начала добавляем глобальную переменную appProcess, хранящую в себе текущий запущенный процесс.

    Добавляем ее в класс public class AppLauncherService : IAppLauncher:

    public class AppLauncherService : IAppLauncher     {         Process appProcess; 
  2. Добавляем в этот же класс функцию, проверяющую статус запущенного процесса:
       public bool IsStarted()         {             if (appProcess!=null)             {                 if (appProcess.HasExited)                 {                     return false;                 }                 else                 {                     return true;                 }             }             else             {                 return false;             }         } 

    Функция возвращает false, если процесс не существует или уже не запущен, и true – если процесс активен.

  3. Добавляем функцию запуска приложения:
    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

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

  1. Для работы необходимы файлы QBuilder.AppLauncher.exe и QBuilder.AppLauncher.exe.config, которые находятся в архиве по ссылке выше. Там же расположен исходный код данного приложения для самостоятельной сборки.
  2. Запускаем сервис с правами администратора.
  3. Откроется консольное окно сервиса:

Любое нажатие клавиши в консоли сервиса закрывает его, будьте внимательны.

  1. Для тестов запускаем wcftestclient.exe, входящий в поставку Visual Studio. Проверяем доступность сервиса по адресу http://localhost:8000/QBuilderAppLauncher/service или открываем ссылку в Internet Explorer.

Если все работает, переходим к следующему этапу.

Создаем сервис QBuilder.AppQueue

А теперь перейдем к самому главному сервису, ради чего и писалась вся эта статья! Повторяем последовательность действий в главе «Создаем сервис QBuilder.AppLauncher» и в главе «Добавляем возможность запуска из консоли», заменяя в коде AppLauncher на AppQueue.

Добавляем ссылку на сервис QBuilder.AppLauncher для использования в сервисе очереди

  1. В Solution Explorer для нашего проекта выбираем Add Service Reference и указываем адрес: localhost:8000/QBuilderAppLauncher/service
  2. Выбираем имя 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:\temp\dummybuild.exe"/>         <add key="BuildToolWorkDir" value="c:\temp\"/>         <add key="LocalDomain" value="."/>         <add key="UserName" value="username"/>         <add key="ScriptPath" value="C:\Temp\BuildSample.bld"/>     </appSettings>

Проверяем работу сервиса

  1. Распаковываем архив QBuilder.AppLauncher.zip. Он и другие нужные файлы доступны по ссылке.
  2. Копируем файл dummybuild.exe из каталога внутри архива binaries в каталог, например, в c:\temp. Данная программа является тестовой заглушкой и просто отображает параметры командной строки, которые передает сервис запускаемому приложению. Если вы используете другой каталог, не забудьте изменить параметры BuildToolPath и BuildToolWorkDir в файле конфигурации.
  3. Переходим в каталог \QBuilder.AppLauncher\binaries\QBuilder.AppLauncher\ и запускаем файл QBuilder.AppLauncher.exe в режиме администратора. Также вы можете собрать данный сервис из исходников.
  4. Запускаем консольный вариант скомпилированного сервиса командой QBuilder.AppQueue.exe /console с правами администратора.
  5. Проверяем, что сервис запустился и работает:

  6. Запускаем и ждем. Если все работает успешно, то через 30 секунд появится следующее окно:

  7. Открываем файл 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>

  8. После каждого закрытия программы 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. Хочу выразить благодарность Виктору Бородичу, чьи советы очень помогли довести данную систему до рабочего вида. Виктор доказывает, что если посадить рядом опытных разработчиков и джуниоров, то качество кода у последних обязательно вырастет.


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


Комментарии

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

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