Мотивация
Для desktop-мира wcf остаётся самым распространенным способом организации клиент-серверного взаимодействия в .net как для локальных, так и для глобальных сетей. Он гибок в настройке, прост в использовании и прозрачен.
По крайней мере, так должно быть. На практике добавление нового сервиса — это рутина. Нужно не забыть прописать конфигурацию на сервере, сделать то же самое на клиенте, нужно написать или сгенерировать proxy-класс. Поддерживать конфиги неудобно. Если сервис изменился, то нужно вносить изменения в proxy-класс. А ещё не забыть про регистрации в IoC-контейнере. И добавление новых хостов для новых сервисов. И еще хочется простой асинхронности. По отдельности всё просто, но даже для статьи я дописывал этот список уже трижды, и не уверен, что не упустил чего-нибудь.
Время автоматизировать. Простейший сценарий от создания решения до вызова wcf-сервиса выглядит так:
- Install-Package Rikrop.Core.Wcf.Unity
- Пишем ServiceContract и их реализации
- На сервере и клиенте добавляем одну строку регистрации в IoC (конфиги править не надо)
- Поднимаем хосты с двух строк
var assembly = Assembly.GetExecutingAssembly(); _serviceHostManager.StartServices(assembly); - На клиенте резолвим IServiceExecutor<TService>. Эта обёртка служит для вызова методов сервиса и скрывает работу с каналом.
- Можно пользоваться
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) | Без использования библиотек |
|---|---|
Теперь можно вызвать новый метод на клиенте. |
Теперь можно вызвать новый сервис на клиенте. |
Добавление нового wcf-сервиса в существующий хост
| Rikrop.Core.Wcf(.Unity) | Без использования библиотек |
|---|---|
Теперь можно вызвать новый сервис на клиенте. |
Теперь можно вызвать новый сервис на клиенте. |
Изменение настроек всех wcf-сервисов (на примере типа привязки)
| Rikrop.Core.Wcf(.Unity) | Без использования библиотек |
|---|---|
* см. public Result Custom<TServiceConnection>(LifetimeManager lifetimeManager = null, params InjectionMember[] injectionMembers) where TServiceConnection: IServiceConnection |
* Количество работы пропорционально количеству wcf-сервисов. Если их 100, то остаётся только надеяться, что быстрая замена по файлу сработает. |
Изменение настроек нескольких wcf-сервисов (на примере типа привязки)
| Rikrop.Core.Wcf(.Unity) | Без использования библиотек |
|---|---|
|
|
Стоит сказать несколько слов о последних двух пунктах. При правильной организации 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-контракте. При успешной аутентификации сервер создаёт сессию, сохраняет её в репозитории и отдаёт данные о ней клиенту:
var newSession = Session.Create(userId); _sessionRepository.Add(newSession); return new SessionDto { SessionId = newSession.SessionId, Username = "ExampleUserName" }; - Клиент получает данные о сессии и сохраняет их:
var clientSession = container.Resolve<ClientSession>(); var sessionDto = Task.Run(async () => await loginServiceExecutor.Execute(s => s.Login())).Result; clientSession.Session = sessionDto; - Сервер имеет возможность получить данные о вызывающем клиенте:
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); } - Клиент имеет возможность получить данные, принятые с сервера при авторизации:
_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 пользователей.
