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

в 8:00, , рубрики: .net, C#, configuration management, Блог компании «Veeam Software», Программирование, управление проектами

Работа в команде Configuration Management связана с обеспечением функциональности билд-процессов — сборки продуктов компании, предварительной проверки кода, статистического анализа, ведения документации и многого другого. Помимо этого, мы постоянно работаем над оптимизацией различных процессов, и, что замечательно, мы практически свободны в выборе инструментов для этой интересной работы. Далее я подробно расскажу о том, как, обладая лишь разного уровня знаниями в C# и C++, я сделал функциональный WCF-сервис для работы с очередями фиксов. И почему решил, что это очень важно.

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

Автоматизация один раз или инструкция на 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 своими руками: почему мне нравится работа в CM - 2

Создание 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:WindowsMicrosoft.NETFramework64v4.0.30319InstallUtil.exe
  3. Заходим в оснастку services.msc, проверяем наличие сервиса «QBuilder App Launcher» и запускаем его.
  4. Работоспособность сервиса проверяем с помощью программы WcfTestClient.exe, которая входит в поставку VisualStudio:

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

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

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

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

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

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

Когда я начал изучать, как написать 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. Откроется консольное окно сервиса:

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

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

  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: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>

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

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

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

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

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

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

От простых скриптов к клиент-серверному приложению на WCF своими руками: почему мне нравится работа в CM - 8
Скриншот финального решения, которое мы используем в нашей работе

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

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

Надеюсь, эта статья поможет вам в работе с WCF-сервисами, с таймерами в теле сервисов и реализации очередей через XML-файлы. Работу приложений и очереди вы можете посмотреть на видео:

P.S. Хочу выразить благодарность Виктору Бородичу, чьи советы очень помогли довести данную систему до рабочего вида. Виктор доказывает, что если посадить рядом опытных разработчиков и джуниоров, то качество кода у последних обязательно вырастет.

Автор: vhuman

Источник

* - обязательные к заполнению поля