Действительно прозрачное использование WCF

в 4:02, , рубрики: .net, client-server, inversion of control, wcf, Программирование, разработка

Мотивация

Для 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 я привожу более полный пример для иллюстрации целостного подхода.
Структура проектов, приближенная к реальности:
Действительно прозрачное использование WCF - 1
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 - 2

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

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

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

table width=«100%»>

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;
}

Результат:
Действительно прозрачное использование WCF - 3

Алгоритм работы
  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 при регистрации и стандартные реализации инфраструктуры библиотеки, из-за чего даже даже в самых сложных конструкциях легко разобраться с первого раза с помощью подскзок студии:
Действительно прозрачное использование WCF - 4
В статье так же косвенно упомянаются другие библиотеки:

  • Реализация автофабрик
    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. Но это может дать больше информации о преимуществах библиотеки и мотивации к использованию.

Автор: Vadimyan

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js