Мотивация


Для desktop-мира wcf остаётся самым распространенным способом организации клиент-серверного взаимодействия в .net как для локальных, так и для глобальных сетей. Он гибок в настройке, прост в использовании и прозрачен.

По крайней мере, так должно быть. На практике добавление нового сервиса — это рутина. Нужно не забыть прописать конфигурацию на сервере, сделать то же самое на клиенте, нужно написать или сгенерировать proxy-класс. Поддерживать конфиги неудобно. Если сервис изменился, то нужно вносить изменения в proxy-класс. А ещё не забыть про регистрации в IoC-контейнере. И добавление новых хостов для новых сервисов. И еще хочется простой асинхронности. По отдельности всё просто, но даже для статьи я дописывал этот список уже трижды, и не уверен, что не упустил чего-нибудь.

Время автоматизировать. Простейший сценарий от создания решения до вызова wcf-сервиса выглядит так:
  1. Install-Package Rikrop.Core.Wcf.Unity
  2. Пишем ServiceContract и их реализации
  3. На сервере и клиенте добавляем одну строку регистрации в IoC (конфиги править не надо)
  4. Поднимаем хосты с двух строк
    var assembly = Assembly.GetExecutingAssembly();
    _serviceHostManager.StartServices(assembly);
    
  5. На клиенте резолвим IServiceExecutor<TService>. Эта обёртка служит для вызова методов сервиса и скрывает работу с каналом.
  6. Можно пользоваться
    var articles = await _myServiceExecutor.Execute(service => service.GetArticles());
    


Quick start


Создадим клиент-серверное приложение. Клиенты передают серверу индекс числа из последовательности Фибоначчи, сервер возвращает число из последовательности с заданным индексом. Из кода в статье убрано логирование и обработка ошибок, но в коде на github я привожу более полный пример для иллюстрации целостного подхода.
Структура проектов, приближенная к реальности:



Server.Contracts содержит интерфейсы wcf-сервисов, Server — их реализацию, а так же реализацию хостера — класса, который будет поднимать wcf-сервисы. BL — логика сервера. ConsoleServiceHost хостит сервисы в домене консольного приложения. Client.Presentaion содержит соответствующий слой клиента. В нашем примере там только команда вызова сервиса и обработка результата. Client — консольное приложение, использующее предыдущую сборку для обработки ввода пользователя.

Собственно, nuget-пакеты нужно устанавливать следующим образом:
  • Rikrop.Core.Wcf.Unity содержит хелперы для регистрации в IoC-контейнере инфраструктуры, необходимой для работы wcf. Это набор готовых решений и расширений для быстрой настройки всех аспектов взаимодействия. Пакет следует добавить в проекты, где будут серверные и клиентские регистрации в IoC-контейнере. У нас это RikropWcfExample.Server и RikropWcfExample.Client.
  • Rikrop.Core.Wcf содержит основные классы по работе с wcf, управлению каналом, сессиями, авторизацией, хостинга wcf-сервисов. Его добавим в RikropWcfExample.Server, там будет лежать хостер, и RikropWcfExample.Client.Presentation*, откуда будет происходить вызов wcf-сервиса.

В RikropWcfExample.Server.Contracts добавим описание wcf-сервиса:

using System.ServiceModel;
using System.Threading.Tasks;

namespace RikropWcfExample.Server.Contracts
{
    [ServiceContract]
    public interface ICalculatorService
    {
        [OperationContract]
        Task<ulong> GetFibonacciNumber(int n);
    }
}

Реализация в CalculatorService.cs будет передавать запрос и возвращать результат из слоя бизнес-логики:

using RikropWcfExample.Server.BL;
using RikropWcfExample.Server.Contracts;
using System.Threading.Tasks;

namespace RikropWcfExample.Server
{
    public class CalculatorService : ICalculatorService
    {
        private readonly FibonacciCalculator _fibonacciCalculator;

        public CalculatorService(FibonacciCalculator.ICtor fibonacciCalculatorCtor)
        {
            _fibonacciCalculator = fibonacciCalculatorCtor.Create();
        }

        public async Task<ulong> GetFibonacciNumber(int n)
        {
            return await _fibonacciCalculator.Calculate(n);
        }
    }
}

Пока можно заметить одну особенность — wcf-сервис использует async/await для описания асинхронности. В остальном никаких специфических конструкций нет.

Теперь перейдем к регистрации. Простейший синтаксис для сервера указывает тип привязки (NetTcp) список поведений, которые должны быть добавлены к сервисам:

private static IUnityContainer RegisterWcfHosting(this IUnityContainer container, string serviceIp, int servicePort)
{
    container
        .RegisterServerWcf(
            o => o.RegisterServiceConnection(reg => reg.NetTcp(serviceIp, servicePort))
                  .RegisterServiceHostFactory(reg => reg.WithBehaviors().AddDependencyInjectionBehavior())
                  );
    return container;
}

Для клиента указывается тип обёртки-исполнителя для сервисов (ServiceExecutor), тип обёртки над привязкой (Standart предполагает NetTcp) и, собственно, адрес сервера:

private static IUnityContainer RegisterWcf(this IUnityContainer container, string serviceIp, int servicePort)
{
    container
        .RegisterClientWcf(o => o.RegisterServiceExecutor(reg => reg.Standard()
                                                 .WithExceptionConverters()
                                                 .AddFaultToBusinessConverter())
        .RegisterChannelWrapperFactory(reg => reg.Standard())
        .RegisterServiceConnection(reg => reg.NetTcp(serviceIp, servicePort)));

    return container;
}

Всё. Не нужно регистрировать каждый сервис по интерфейсу, не нужно создавать Proxy, не нужно прописывать wcf в конфигурации — эти регистрации позволят сразу начать работать с сервисами так, будто это локальные вызовы.
Но сначала нужно захостить их на сервере. Библиотека Rikrop.Core.Wcf уже включает класс ServiceHostManager, который сделает всю работу самостоятельно. Прописывать каждый сервис не нужно:

using Rikrop.Core.Wcf;
using System.Reflection;

namespace RikropWcfExample.Server
{
    public class WcfHoster
    {
        private readonly ServiceHostManager _serviceHostManager;

        public WcfHoster(ServiceHostManager serviceHostManager)
        {
            _serviceHostManager = serviceHostManager;
        }

        public void Start()
        {
            var assembly = Assembly.GetExecutingAssembly();
            _serviceHostManager.StartServices(assembly);
        }

        public void Stop()
        {
            _serviceHostManager.StopServices();
        }
    }
}

Запустим сервер:

public static void Main()
{
    using (var serverContainer = new UnityContainer())
    {
        serverContainer.RegisterServerDependencies();

        var service = serverContainer.Resolve<WcfHoster>();
        service.Start();

        Console.WriteLine("Сервер запущен. Для остановки нажмите Enter.");
        Console.ReadLine();

        service.Stop();
    }
}

Запустим клиент:

static void Main()
{
    using (var container = new UnityContainer())
    {
        container.RegisterClientDependencies();

        var calculateFibonacciCommandCtor = container.Resolve<CalculateFibonacciCommand.ICtor>();

        int number;
        while (int.TryParse(GetUserInput(), out number))
        {
            var command = calculateFibonacciCommandCtor.Create();
            var result = command.Execute(number);
            Console.WriteLine("Fibonacci[{0}] = {1}", number, result);
        } 
    }
}

Работает:



Сравнение с классическим подходом и расширяемость


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

Добавление нового метода в существующий wcf-сервис или изменение сигнатуры существующего метода

Rikrop.Core.Wcf(.Unity) Без использования библиотек
  • В ServiceContract добавить определение метода.
  • В классе wcf-сервиса добавить реализацию.

Теперь можно вызвать новый метод на клиенте.
  • В ServiceContract добавить определение метода.
  • В классе wcf-сервиса добавить реализацию.
  • Сгенерировать proxy-класс на клиенте или добавить ручную реализацию вызова нового сервиса на клиенте (иногда оба варианта, если не хочется напрямую использовать proxy-класс.

Теперь можно вызвать новый сервис на клиенте.

Добавление нового wcf-сервиса в существующий хост

Rikrop.Core.Wcf(.Unity) Без использования библиотек
  • Создать ServiceContract нового сервиса.
  • Реализовать контракт сервиса.

Теперь можно вызвать новый сервис на клиенте.
  • Создать ServiceContract нового сервиса.
  • Реализовать контракт сервиса.
  • Сгенерировать proxy-класс на клиенте или добавить ручную реализацию вызова нового сервиса на клиенте (иногда оба варианта, если не хочется напрямую использовать proxy-класс.
  • Добавить в хост wcf код, инициализирующий ServiceHost для нового сервиса.
  • Зарегистрировать вклиентском IoC-контейнере Proxy-класс нового сервиса.
  • Добавить конфигурацию сервиса на сервере и не клиенте.

Теперь можно вызвать новый сервис на клиенте.

Изменение настроек всех wcf-сервисов (на примере типа привязки)
Rikrop.Core.Wcf(.Unity) Без использования библиотек
  • В серверной регистрации изменить строку с типом привязки*.
  • В клиентской регистрации изменить строку с типом тип привязки*.

* см. public Result Custom<TServiceConnection>(LifetimeManager lifetimeManager = null, params InjectionMember[] injectionMembers) where TServiceConnection: IServiceConnection
  • В app.config сервера изменить все записи в блоке <bindings>*.
  • В app.config клиента изменить все записи в блоке <bindings>*.

* Количество работы пропорционально количеству wcf-сервисов. Если их 100, то остаётся только надеяться, что быстрая замена по файлу сработает.

Изменение настроек нескольких wcf-сервисов (на примере типа привязки)

Rikrop.Core.Wcf(.Unity) Без использования библиотек
  • На сервере добавить регистрацию для нового адреса и типа привязки.
  • В клиентской регистрации добавить новую регистрацию для другого адреса и типа привязки.

  • В app.config сервера изменить записи в блоке <bindings> для нужных wcf-сервисов.
  • В app.config клиента изменить записи в блоке <bindings> для нужных wcf-сервисов.


Стоит сказать несколько слов о последних двух пунктах. При правильной организации app.config вносить изменения в него довольно легко. Это можно делать без пересборки приложения. В реальной разработке структурированная конфигурация wcf попадается довольно редко, чему виной итеративность разработки. Изменять конфигурацию непрогаммисту тоже приходится нечасто, если начальные настройки удовлетворяют требованиям. ��ри этом, легко совершить опечатку, которую компилятор не найдёт.

Расширяемость. Behavior для авторизации и работы с сессиями


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

Регистрация на сервере будет выглядеть следующим образом:

container
    .RegisterType<ISessionResolver<Session>, SessionResolver<Session>>()
    .RegisterServerWcf(
        o => o.RegisterServiceConnection(reg => reg.NetTcp(serviceIp, servicePort))
                .RegisterServiceHostFactory(reg => reg.WithBehaviors()
                .AddErrorHandlersBehavior(eReg => eReg.AddBusinessErrorHandler().AddLoggingErrorHandler(NLogger.CreateEventLogTarget()))
                .AddDependencyInjectionBehavior()
                .AddServiceAuthorizationBehavior(sReg => sReg.WithStandardAuthorizationManager()
                                                .WithStandardSessionHeaderInfo("ExampleNamespace", "SessionId")
                                                .WithOperationContextSessionIdInitializer()
                                                .WithSessionAuthStrategy<Session>()
                                                .WithLoginMethod<ILoginService>(s => s.Login())
                                                .WithOperationContextSessionIdResolver()
                                                .WithInMemorySessionRepository()
                                                .WithStandardSessionCopier())
                                )
                );

Можно изменить способ авторизации, добавив свою имплементацию System.ServiceModel.ServiceAuthorizationManager, изменить способ инициализации идентификатора сессии, метод проверки авторизации, способ извлечения сессии из контекста выполнения запроса, способ хранения и копирования сессий на сервере. В обобщенном случае регистрация AuthorizationBehavior может выглядеть следующим образом:

.AddServiceAuthorizationBehavior(sReg => sReg.WithCustomAuthorizationManager<ServiceAuthorizationManagerImpl>()
                .WithCustomSessionHeaderInfo<ISessionHeaderInfoImpl>()
                .WithCustomSessionIdInitializer<ISessionIdInitializerImpl>()
                .WithCustomAuthStrategy<IAuthStrategyImpl>()
                .WithLoginMethod<ILoginService>(s => s.Login())
                .WithCustomSessionIdResolver<ISessionIdResolverImpl>()
                .WithCustomSessionRepository<ISessionRepositoryImpl<MySessionImpl>>()
                .WithCustomSessionCopier<ISessionCopierImpl<MySessionImpl>>())

Клиентская регистрация так же меняется:

private static IUnityContainer RegisterWcf(this IUnityContainer container, string serviceIp, int servicePort)
{
    container
        .RegisterType<ClientSession>(new ContainerControlledLifetimeManager())
        .RegisterClientWcf(o => o
                .RegisterServiceExecutor(reg => reg.Standard()
                .WithExceptionConverters()
                .AddFaultToBusinessConverter())
        .RegisterChannelWrapperFactory(reg => reg.Standard())
        .RegisterServiceConnection(reg => reg
        .NetTcp(serviceIp, servicePort)
        .WithBehaviors()
        .AddSessionBehavior(sReg => sReg
                        .WithStandardSessionHeaderInfo("ExampleNamespace", "SessionId")
                        .WithCustomSessionIdResolver<ClientSession>(new ContainerControlledLifetimeManager())
                        .WithStandardMessageInspectorFactory<ILoginService>(service => service.Login()))));

    return container;
}

Результат:



Алгоритм работы

  1. Клиент авторизуется через выбранный метод в wcf-контракте. При успешной аутентификации сервер создаёт сессию, сохраняет её в репозитории и отдаёт данные о ней клиенту:

    var newSession = Session.Create(userId);
    _sessionRepository.Add(newSession);
    return new SessionDto { SessionId = newSession.SessionId, Username = "ExampleUserName" };
    
  2. Клиент получает данные о сессии и сохраняет их:

    var clientSession = container.Resolve<ClientSession>();
    var sessionDto = Task.Run(async () => await loginServiceExecutor.Execute(s => s.Login())).Result;
    clientSession.Session = sessionDto;
    
  3. Сервер имеет возможность получить данные о вызывающем клиенте:

    public async Task<ulong> GetFibonacciNumber(int n)
    {
        var session = _sessionResolver.GetSession();
        _logger.LogInfo(
            string.Format("User with SessionId={0} and UserId={1} called CalculatorService.GetFibonacciNumber", session.SessionId, session.UserId));
    
        return await _fibonacciCalculator.Calculate(n);
    }
    
  4. Клиент имеет возможность получить данные, принятые с сервера при авторизации:

    _logger.LogInfo(string.Format("SessionId {0} with name {1} begin calculate Fibomacci", _clientSession.SessionId, _clientSession.Session.Username));
    

Что внутри


Большую часть инфраструктуры предоставляет библиотека System.ServiceModel.dll. Однако, есть несколько решений, которые нужно рассмотреть подробнее.

Основой взаимодействия между клиентом и сервером служат реализации интерфейса IServiceExecutor, находящиеся в библиотеке Rikrop.Core.Wcf.

public interface IServiceExecutor<out TService>
{
    Task Execute(Func<TService, Task> action);
    Task<TResult> Execute<TResult>(Func<TService, Task<TResult>> func);
}

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

public async Task<TResult> Execute<TResult>(Func<TService, Task<TResult>> func)
{
    using (var wrapper = _channelWrapperFactory.CreateWrapper())
    {
        return await func(wrapper.Channel);
    }
}

Более сложные реализации могут конвертировать ошибки или дополнительно извещать об окончании обработки изменением свойства. Наибольшее распространение эти идеи получили в WPF-реализациях IServiceExecutor, где с помощью ServiceExecutorFactory можно создать обёртки над wcf-сервисом, позволяющие использовать DataBinding для оповещения UI о продолжительной операции, или отображающие popup с произвольной информацией во время ожидания ответа от сервера.
Для легкой реализации главную роль играют Fluent interface при регистрации и стандартные реализации инфраструктуры библиотеки, из-за чего даже даже в самых сложных конструкциях легко разобраться с первого раза с помощью подскзок студии:



В статье так же косвенно упомянаются другие библиотеки:
  • Реализация автофабрик:

    private static IUnityContainer RegisterFactories(this IUnityContainer container)
    {
        new[] { Assembly.GetExecutingAssembly(), typeof (FibonacciCalculator).Assembly }
            .SelectMany(assembly => assembly.DefinedTypes.Where(type => type.Name == "ICtor"))
            .Where(type => !container.IsRegistered(type))
            .ForEach(container.RegisterFactory);
    
        return container;
    }
    
  • Обёртки над логгерами:

    private static IUnityContainer RegisterLogger(this IUnityContainer container)
    {
        container.RegisterType<ILogger>(new ContainerControlledLifetimeManager(),
                                        new InjectionFactory(f => NLogger.CreateConsoleTarget()));
    
        return container;
    }
    

Итоги


Единожды настроив инфраструктуру на проекте, можно надолго забыть о сетевой природе взаимодействия через IServiceExexutor. Лучше всего применять системный подход и использовать так же бибилиотки для построения настольных приложений с применением mvvm-паттерна, взаимодействия с БД, логирования и других типовых задач. Но даже при нежелании использовать незнакомый и не всегда привычный фреймворк, можно найти применение идеям, лежащим в его основе. Расширяемость компонент, строгая типизация при конфигурировании, прозрачность взаимодействия на всех слоях, минимизация инфраструктурного кода и затрат времени на поддержание инфрастурктуры — это то, о чём важно не забывать при написании калькулятора и многопользовательской Enterprise-системы. Можно скачать код библиотек и подключить их к решению проектом вместо использования библиотеки. Это позволит изучить работу под отладчиком и при необходимости внести свои изменения.

Бонус


Нет ничего лучше практики. Я узнал, что у нас был опыт перевода довольно крупного проекта (~300.000 строк кода) в стадии где-то между разработкой и поддержкой на использование Rikrop.Core.Wcf. Это довольно интересный опыт мучений с async/await в .net 4.0, кастомизации работы с сессиями, извлечения настроек из конфига и перевод их в c#-форму. Если это кому-нибудь будет интересно, можно описать конкретный пример перехода на эту библиотеку без пеетягивания всего фреймворка.

Еще есть решение для wpf с информированием пользователя через блокировку ui или всплывающие окна, реализованные через ServiceExecutorFactory. Это частный пример и он относится куда больше к wpf, чем к wcf. Но это может дать больше информации о преимуществах библиотеки и мотивации к использованию.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Нужна ли статья с примером перевода реального приложения на работу с библиотеками, описанными в статье?
91.24%Да125
8.76%Нет12
Проголосовали 137 пользователей. Воздержались 53 пользователя.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Нужна ли статья с примером работы в wpf-приложении и взаимодействием с UI?
90.21%Да129
9.79%Нет14
Проголосовали 143 пользователя. Воздержались 49 пользователей.