Утилита резервного копирования файлов. Часть 1: Конфигурация WCF

Несмотря на большое количество уже созданных приложений для резервного копирования, проект по созданию backup tool может оказаться настолько интересным и нетривиальным, что появляется желание поделится наиболее удачными наработками. Именно об этом и будет моя статья. В ней я не буду описывать весь процесс разработки и выкладывать код полностью; цель статьи – рассказать о принципиальном решении и продемонстрировать наиболее интересные моменты проекта.

 ▎Компоненты системы. Взаимодействие модулей.

Задача была достаточно простая – создать утилиту резервного копирования с возможностью сохранять данные на сервере по запросу и по расписанию, с поддержкой шифрования и упаковки файлов, используя технологию .NET 4.0. В качестве интерфейса пользователя было решено использовать Windows Forms. Так как все работы по копированию должны были исполняться по расписанию, приложение должно было располагаться в Windows Service, чтобы иметь возможность постоянной работы.

Утилита резервного копирования состоит из следующих модулей:

  1. Сервис Windows – сервис, постоянно работающий в фоновом режиме. Занимается непосредственным исполнением задач согласно расписанию. Выполняет сжатие, шифрование и передачу данных на сервер.
  2. Клиентское приложение – приложение Windows Forms. Данное приложение занимается составлением/редактированием задач, их запуском и отслеживанием состояния.
  3. Серверное API – средство общения с хранилищем данных.

Windows Service (далее – сервис) и Windows Forms (далее – приложение) должны взаимодействовать друг с другом. Взаимодействие реализуется двумя способами:

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

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

Прямые команды сервису. Приложение управляет сервисом посредством следующих команд:

  • Запустить задачу
  • Приостановить задачу
  • Возобновить задачу
  • Отменить задачу
  • Отобразить статус в лог

Общение сервиса и приложения реализовывается через именованные каналы с помощью WCF в режиме дуплекса: на сервере поднимается двунаправленный хост, приложение подключается к сервису и выполняет взаимодействие.

 ▎Дуплексный WCF. Конфигурация на стороне сервиса.

Реализация дуплексного WCF не так проста, как хотелось бы. Причина в том, что WCF реализован как общий фреймворк для различных протоколов взаимодействия, основным из которых является HTTP. А HTTP изначально является протоколом в одну сторону и двунаправленность не поддерживается. Для того, чтобы ее все-таки реализовать для HTTP и чтобы при смене протокола не требовалось перекодирования, а также по ряду других причин, в Microsoft реализовали дополнительные надстройки. Реализация получилась нетривиальной, требующей от программиста специальных знаний и понимания тонкостей WCF. В данной статье я попробую изложить вариант реализации дуплексного протокола просто и доступно.

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

В общем же случае возможно расположение сервиса на отдельном Windows сервере, а управление сервисом в этом случае будет осуществляться дистанционно. Использование WCF для коммуникаций является идеальным решением – достаточно переконфигурировать файл App.config в приложении и сервисе, и мы получим дуплексный http-канал!

Дуплексный WCF имеет некоторые особенности реализации.

  1. Требуется включение поддержки сессионности.
  2. Клиенту необходимо держать соединение с сервисом все время жизни клиента.

Пункт 2 имеет важное значение. Существует ряд задач, по которым требуется вызвать клиента из сервера:

  • Отображение текущего статуса задачи (начало, окончание, прогресс).
  • Информативность о событиях в системе.

WCF сервис в дуплексном режиме не может просто так при необходимости соединиться с клиентом и вызвать нужный метод. Поэтому возможный сценарий следующий:

  1. Приложение устанавливает соединение с сервисом (например, путем вызова метода WCF сервиса Register). Вызов «в одну сторону», поэтому клиент не ждет ответа от сервиса.
  2. WCF сервис в методе Register не завершает сразу свою работу, а делает следующие действия:
  • Регистрирует свой экземпляр WCF-клиента в пуле клиентских соединений (ClientConnectionPool).
  • Переходит в режим ожидания команд от Windows сервиса.

Windows сервис при возникновении определенных событий с помощью ClientConnectionManager опрашивает все содержащиеся в ClientConnectionPool клиенты и выдает им команды на отправку определенных сообщений. При получении команды от Windows сервиса WCF сервис отсылает сообщение клиенту и снова переходит в режим ожидания. Режим ожидания реализовывается посредством объекта ядра Event. Завершение метода Register произойдет либо при завершении клиентского приложения, либо сервиса.

[ServiceContract(SessionMode = SessionMode.Required,
    CallbackContract = typeof(IClientCommunicationServiceCallback))]
public interface IClientCommunicationService
{
    /// 
    /// Register client to service to keep callback events
    /// 
    /// Client unique identifier. Differs for each connection
    [OperationContract(IsOneWay = true)]
    void Register(Guid clientId);

}

/// 
/// WCF service to organize calback operations to client
/// 
[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerSession)]
public class ClientCommunicationService: IClientCommunicationService, IDisposable
{
/// 
/// Register client to service to keep callback events
/// 
/// Client unique identifier. Differs for each connection
public void Register(Guid clientId)
{
        Contract.Requires(clientId != Guid.Empty);

        if (isDisposed)
        {
           throw new ObjectDisposedException(this.GetType().ToString());
        }

    this.clientId = clientId;
    // Add current service to ClientConnectionManager
    ClientConnectionManager.Current.AddService(this);

    bool exitNeeded = false;
    serviceCompletedEvent.Reset();

    try
    {
        while (!exitNeeded)
        {
            int eventId = WaitHandle.WaitAny(events);
            lock(syncObj)
            {
                switch (eventId)
                {
                    case 0:
                        // Remove current service from ClientConnectionManager
                        ClientConnectionManager.Current.RemoveService(this.clientId);
                        exitNeeded = true;
                        break;
                    case 1:
                        {
                            // Send message to client
                            // Process all messages from queue
                            while (jobInfoQueue.Count > 0)
                            {
                                object job = jobInfoQueue.Dequeue();
                                ...
                            }
                            updateJobStateEvent.Reset();
                        }
                        break;
                }
            }
        }

    }
    finally
    {
        serviceCompletedEvent.Set();
    }
}
}

Рассмотрим метод Register подробнее. Строка Contract.Requires(clientId != Guid.Empty) использует контракты кода для проверки входных данных. Проверка флага isDisposed является частью паттерна IDisposable и используется для исключения обращения к объекту, у которого уже был вызван метод Dispose() (см. ч. 4 «Освобождение ресурсов сервисов»). Каждый метод WCF сервиса должен выполнять такие проверки. В этой статье для упрощения данные проверки не будут указываться.

Базовым классом, обеспечиваемым взаимодействие клиента и сервера, является singleton ClientConnectionManager. Данный класс содержит два основных публичных метода, управляющих соединением сервера с клиентом: AddService и RemoveService. Метод AddService принимает в качестве параметра ссылку на экземпляр ClientCommunicationService и помещает его в список ClientConnectionPool:

/// 
/// Add service into service client pool
/// 
public void AddService(ClientCommunicationService clientCommunicationService)
{
   lock(syncObject)
   {
       clientConnectionPool.Add(clientCommunicationService);
   }
}

/// 
/// Remove service by specified client Id. It is necessary when client closes connection to server.
/// 
/// Client Id
public void RemoveService(Guid clientId)
{
    lock(syncObject)
    {
        ClientCommunicationService service =
            clientConnectionPool.Where(cp => cp.ClientId == clientId).SingleOrDefault();
        if (service != null)
        {
            service.StopService();
            clientConnectionPool.Remove(service);
        }
    }
}

Обратите внимание на оператор lock(syncObject) – объект clientConnectionPool является статическим и, следовательно, разделяемым ресурсом. Поэтому каждое обращение к данному объекту должно быть изолировано от других потоков.

На самом деле регистрация дуплексного WCF – непростая задача. Требование сессионности ([ServiceBehavior(InstanceContextMode = InstanceContextMode.PerSession)] у сервиса и [ServiceContract(SessionMode = SessionMode.Required) у интерфейса) является недостаточным условием для этого – как сервис будет инициировать отправку сообщений клиенту? Сервису необходимо сообщить интерфейс команд клиента:

/// 
/// Callback interface to communicate with client application
/// 
public interface IClientCommunicationServiceCallback
{
    /// 
    /// Update status for job
    /// 
    /// Job info
    [OperationContract(IsOneWay = true)]
    void UpdateJobState(JobInfo jobInfo);

    /// 
    /// Update status for a list of jobs
    /// 
    /// A list of job infos
    [OperationContract(IsOneWay = true)]
    void UpdateJobsState(List jobInfos);

    /// 
    /// Update date for next run of jobs
    /// 
    /// Contains next run date for jobs
    [OperationContract(IsOneWay = true)]
    void UpdateJobsNextRun(List jobInfos);

    /// 
    /// On client update actions which happen on server.
    /// 
    /// Server action
    [OperationContract(IsOneWay = true)]
    void UpdateServerAction(ServerAction serverAction);
}

IClientCommunicationServiceCallback – это и является интерфейсом клиента. Где же его взять, спросите вы? И не зря – здесь есть еще пара хитростей.

IClientCommunicationServiceCallback является простым интерфейсом, каждый метод которого содержит атрибут OperationContract и указывает признак однонаправленности (IsOneWay=true), то есть того, что при вызове данных методов сервис не ждет ответа от клиента:

Интерфейс IClientCommunicationServiceCallback располагается внутри WCF сервиса. Теперь остается научить клиента реагировать на методы, объявленные в данном интерфейсе. Интересный вопрос, не правда ли?

Для начала на стороне клиента создадим прокси-класс ClientCommunicationService:

Выбираем из списка ClientCommunicationService… упс, а где же он?

Постойте, а с чего Вы могли решить, что он там будет? Это не веб-приложение, и Visual Studio автоматически не сможет определить задекларированные в приложении сервисы. Поэтому придется сначала скомпилировать Windows Service, зарегистрировать его в системе и запустить через оснастку Services.

Для того, чтобы WCF сервис «поднялся» внутри Windows Service’а, нужно:

  • сконфигурировать параметры WCF;
  • активировать WCF при старте Windows сервиса.

Ниже представлен пример конфигурации дуплексного канала посредством протокола NamedPipes:


    
      
        
        
        
        
          
            
          
        
      
    
    
      
        
          
          
          
          
        
      
    
  

Строка адреса "net.pipe://BackupClient/Services/ClientCommunicationService/" является адресом именованного канала. Этот адрес нужно указать в окне «Add Service Reference» при генерировании прокси-класса для WCF сервиса:

Теперь активируем сам WCF сервис:

protected override void OnStart(string[] args)
{
   // Run WCF Hosts
   clientCommunicationServiceHost = new ServiceHost(typeof(ClientCommunicationService));
   clientCommunicationServiceHost.Open();
}

Код по деактивации сервиса:

protected override void OnStop()
{
    // Close WCF hosts
    if (clientCommunicationServiceHost.State == CommunicationState.Opened)
    {
       clientCommunicationServiceHost.Close();
    }
}

Так, сервис сконфигурировали, прописали активацию WCF при старте Windows сервиса, скомпилировали, запустили windows service - надо сгенерировать прокси класс для клиента… пробуем!

Получилось! Теперь можно расслабиться и выпить чашку кофе. Осталось совсем чуть-чуть.

 ▎Конфигурация WCF на стороне клиента.

Вспомним задачи взаимодействия клиента и сервера:

  1. Управление сервисом из клиента;
  2. Отображение статуса работы сервиса на клиенте.

ClientCommunicationService реализует вторую задачу. Он «прослушивает» команды из WCF сервиса и обрабатывает их по мере поступления.

Для начала необходимо реализовать интерфейс IClientCommunicationServiceCallback, объявленный на стороне WCF сервиса. Методы UpdateJobState(), UpdateJobsNextRun(), UpdateJobsNextRun(), UpdateServerAction() реализовывают обработчики событий сервера на клиенте:

public class ClientCommunicationServiceCallback : IClientCommunicationServiceCallback
{
    #region Events

    public event EventHandler OnUpdateJobInfoList;
    public event EventHandler OnUpdateJobInfo;
    public event EventHandler OnUpdateJobNextRun;
    public event EventHandler OnServerAction;

    #endregion

    #region Implementation of IClientCommunicationServiceCallback

    public void UpdateJobState(JobInfo jobInfo)
    {
        if (OnUpdateJobInfo != null)
        {
            OnUpdateJobInfo(this, new JobInfoEventArgs(jobInfo));
        }
    }

    public void UpdateJobsState(List jobInfos)
    {
        if (OnUpdateJobInfoList != null)
        {
            OnUpdateJobInfoList(this, new JobInfoListEventArgs(jobInfos));
        }
    }

    public void UpdateJobsNextRun(List jobInfos)
    {
        if (OnUpdateJobNextRun != null)
        {
            OnUpdateJobNextRun(this, new JobNextRunEventArgs(jobInfos));
        }
    }

    public void UpdateServerAction(ServerAction serverAction)
    {
        if (OnServerAction != null)
        {
            OnServerAction(this, new ServerActionEventArgs(serverAction));
        }
    }

    #endregion

На самом деле совсем не обязательно объявлять здесь события, можно напрямую писать обработчики в данном классе. События я добавил для того, чтобы разделить коммуникации от UI.

Теперь посмотрим, как устанавливается соединение клиента с сервисом:

Guid clientId = Guid.NewGuid();

// Create callback class and add event handlers on it
ClientCommunicationServiceCallback serviceCallback = new ClientCommunicationServiceCallback();
serviceCallback.OnUpdateJobInfoList += ServiceCallbackOnUpdateJobInfoList;
serviceCallback.OnUpdateJobInfo += serviceCallback_OnUpdateJobInfo;
serviceCallback.OnUpdateJobNextRun += serviceCallback_OnUpdateJobsNextRun;
serviceCallback.OnServerAction += serviceCallback_OnServerAction;
InstanceContext instanceContext = new InstanceContext(serviceCallback);
clientCommunicationService = new ClientCommunicationServiceClient(instanceContext);

// Register on server
clientCommunicationService.Register(clientId);

Переменная clientId является уникальным идентификатором для текущего соединения клиента с сервером и может быть использована при обмене сообщениями клиента и сервера. Класс InstanceContext используется для создания контекста для класса ClientCommunicationServiceCallback.

 ▎Завершение работы клиента.

При завершении работы клиента необходимо завершать сессию соединения на сервисе. Мы помним, что WCF сервис держит сессию в процессе всего времени работы клиента.

Возможны два сценария завершения работы – нормальное и аварийное. В последнем случае WCF сам обнаруживает исчезновение клиента спустя некоторое время, а при нормальном завершении нужно самостоятельно отсоединиться от сервиса. Если это не сделать, то, во-первых, ресурсы на сервисе не освободятся сразу, а во-вторых, возникнут ошибки при попытке обратной связи с клиентом. Зачем нам ошибки, если их можно избежать?

Для нормального завершения соединения с сервисом вызовем метод Unregister:

try
{
    JobServiceController.Current.JobService.Unregister(clientId);
}
catch(CommunicationException)
{
}

Как это работает, мы рассмотрим в статье "Освобождение ресурсов сервисов2, которую я собираюсь скоро опубликовать. А пока, на этом я хочу завершить первую часть статьи про утилиту для резервного копирования файлов.