Сообщений 3    Оценка 256 [+1/-0]         Оценить  
Система Orphus

Заметки о WCF

Особенности создания производительного высокомасштабируемого распределенного решения с использованием WCF

Автор: Иван Бодягин
The RSDN Group

Источник: RSDN Magazine #1-2009
Опубликовано: 12.09.2009
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Разработка серверной части
ServiceBehaviour
Регулировка нагрузки (throttling).
IInstanceProvider
Разработка клиентской части
Auto Open
Закрытие соединения
ClientBase<T>
ChannelFactory<T>

Введение

Данный текст не является полноценным описанием работы с WFC или с какой-нибудь из подсистем WCF или даже введением в описание одного из аспектов работы с WCF. Скорее это просто набор заметок, посвященный особенностям работы с WCF в некоторых условиях, каковые заключаются в том, что решение желательно получить масштабируемым и производительным. Эти заметки так же не претендуют на полноту и полноценный охват темы и вполне подлежат расширению и уточнению.

Разработка серверной части

Если мы решили написать правильное высокомасштабируемое клиентское решение, для начала необходимо написать правильный высокомасштабируемый сервер. :) Этим мы и займемся, прежде чем переходить к клиенту.

При разработке серверной части производительности, масштабируемости и надежности, как правило, уделяют довольно много внимания, и в общем случае разработка сервера считается более ответственным мероприятием, чем разработка клиента. Одну из самых важных ролей в достижении упомянутых целей играет стратегия создания, управления временем жизни и поведением экземпляров сервисов, занимающихся обработкой приходящих запросов. Для управления поведением сервисного объекта, сервисный контракт может быть оборудован специальным атрибутом – ServiceBehavior.

ServiceBehaviour

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

InstanceContextMode может принимать следующие значения:

У ConcurrencyMode возможны варианты:

В подробности формального описания вдаваться не будем, они достаточно хорошо освещены в различной литературе, сосредоточимся лучше на ряде особенностей.

InstanceContextMode

Например, с точки зрения производительности есть один забавный нюанс... Как известно, WCF поддерживает массу всевозможных протоколов с кучей разных возможностей, от самых простых до самых сложных, вплоть до голубиной почты с гарантией доставки – надо только свой провайдер написать... В том числе есть протоколы как с поддержкой сессий, так и без оной. Так вот, если используется протокол с поддержкой сессий (например, TCP или NamedPipe), и в наличии имеется только один клиент, то, как ни странно, самой производительной будет стратегия Session. Хотя, казалось бы, поддержка сессий дело накладное.

Все дело в том, что для правильной поддержки сессий необходимо, чтобы запросы от одного клиента обрабатывал один и тот же сервисный объект, и если клиент один, то только один экземпляр сервисного объекта и будет создан. То есть, при таком раскладе получается аналог стратегии Single, но за одним исключением – Single все равно вынужден на каждый запрос использовать контекст синхронизации, он-то не знает, что клиент только один. Это, конечно, не бог весть какая растрата, но тем не менее... И, наконец, при стратегии PerCall, вне зависимости от количества клиентов, на каждый запрос обязательно создается новый экземпляр сервисного объекта, что является самым «дорогим» удовольствием, по сравнению со всем выше перечисленным.

Однако если привязка производится к протоколу, который сессий не поддерживает, то стратегия Single вырывается на первое место, так как PerSession начинает себя вести точно так же, как и PerCall, создавая новый экземпляр сервисного объекта на каждый запрос. Понятно почему – так как поддержки сессий нет, то с формальной точки зрения, каждый новый запрос – это новая сессия.

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

Concurrency Mode

Если для InstanceContextMode выбрана стратегия Single (только один экземпляр серверного объекта на все случаи жизни), то в случае ConcurrencyMode у нас есть два варианта:

Если же для InstanceContextMode выбрана стратегия PerCall (отдельный экземпляр на каждый запрос), то опция ConcurrencyMode = Multiple, очевидно, никакого смысла не имеет, так как у каждого запроса есть свой персональный экземпляр сервиса. При этом нет никаких проблем ни с конкурентным доступом, ни с масштабируемостью. Собственно, по этой причине такое сочетание и используется по умолчанию.

Последний вариант InstanceContextMode – Session, тут важную роль начинает играть такой нюанс, как многопоточность клиента. На первый взгляд Session работает так же, как и PerCall, лишь с той разницей, что новый экземпляр сервисного объекта создается не при каждом новом запросе, а для каждого клиента. Но если каждый отдельный клиент сам по себе многопоточный, и возможна ситуация, когда от одного клиента на сервер приходит множество конкурирующих запросов, то стратегия Session вырождается в Single для каждого конкретного клиента, и обе опции ConcurrencyMode (и Single, и Multiple) начинают играть одну и ту же роль.

Регулировка нагрузки (throttling).

Эти параметры служат для регулирования нагрузки на сервер и защиты от DOS-атак. Всего можно задать три параметра, отвечающих за разные аспекты:

Если превышены указанные выше значения параметров, то очередной запрос ставится в очередь, что при высокой нагрузке может привести к тайм-аутам на стороне клиента. Самый простой способ убедиться в этом – забыть закрыть на клиенте канал при попытке отправить несколько сообщений из разных потоков. Спустя минуту (тайм-аут по умолчанию) у вас будет шанс наблюдать исключение: TimeoutException: The open operation did not complete within the allotted timeout of 00:01:00. The time allotted to this operation may have been a portion of a longer timeout.

IInstanceProvider

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

А в правильной архитектуре сервис должен отвечать только за свою работу и не должен, например, лезть в конкретную базу данных или заниматься какой-нибудь другой, не свойственной ему деятельностью и как-то зависеть от окружения. И для того, чтобы он этим не занимался, ему нужно каким-то образом подсунуть другие сервисы, которые будут эту работу выполнять. Иными словами, наш сервисный объект надо уметь как-то проинициализировать.

Со стратегией Single, по большему счету, проблем нет – поскольку такой объект всегда один, то его можно создать самому, как и где нравится, и положить в специальный конструктор объекта ServiceHost.

Но в случае стратегий Session и PerCall созданием экземпляров сервисов занимается инфраструктура WCF, поэтому здесь все не так просто. Но и не так чтобы уж слишком сложно – достаточно реализовать свою фабрику создания экземпляров сервисов и объяснить инфраструктуре WCF, что при создании экземпляров нужно пользоваться именно этой фабрикой. Собственно наука не хитрая, нужно совершить всего три ритуальных действия:

  1. Создаем собственную фабрику. Чтобы WCF смог с этой фабрикой потом работать, она должна реализовывать интерфейс IInstanceProvider. Этот интерфейс содержит три метода, но в простейшем случае какой-либо значимый код (собственно, код создания экземпляра сервиса) придется писать только в одном.
  2. Объясняем WCF, как именно этой фабрикой пользоваться. Делается это посредством создания собственного атрибута, реализующего IServiceBehaviour – тоже не бином Ньютона. И тоже надо только над одним методом подумать, да и реализация в простейшем случае – три строчки кода.
  3. Говорим WCF, что таки при создании экземпляра сервиса, нужно воспользоваться нашей фабрикой, вышеупомянутым способом. Тут вообще думать не надо, просто размечаем сервисный контракт нашим атрибутом.

В результате получаем стратегию создания экземпляров сервисов в лучших традициях DI и прочих, столь модных в наше время, IoC.

Но это все – взгляд с высоты полета стратегического бомбардировщика, а дьявол, как известно, кроется в деталях. Давайте теперь рассмотрим подробнее, как совершить вышеописанные ритуальные действия.

Итак, нам нужно заставить инфраструктуру WCF пользоваться нашей фабрикой при создании экземпляров сервисов, обрабатывающих входящие запросы. Это может быть довольно полезно, если реализация сервиса не очень тривиальна и требует  особой инициализации. Собственно, реализация IInstanceProvider и есть фабрика. Точнее, чтобы WCF мог пользоваться фабрикой, она должна реализовывать интерфейс IInstanceProvider.

Этот интерфейс содержит три метода, но в простейшем случае значимый код придется писать только в одном из них. Это, собственно, код создания экземпляра сервиса. Если бы сервис назывался TestService, то код фабрики мог бы быть примерно таким:

        public
        class TestServiceFactory : IInstanceProvider
{
  publicobject GetInstance(InstanceContext instanceContext)
  {
    return GetInstance(instanceContext, null);
  }

  publicobject GetInstance(InstanceContext instanceContext, 
                Message message)
  {
    Console.WriteLine("Object Created");

    // Создаем сервис и возвращаем его экземпляр//return new TestService();
  }

  publicvoid ReleaseInstance(
InstanceContext instanceContext,   object instance)
  {
    Console.WriteLine("Object Released");
  }
}

Следующим шагом необходимо объяснить WCF, что при создании экземпляров TestService нужно пользоваться TestServiceFactory, и как именно ей пользоваться. Суть объяснений довольно проста: точкой расширения в данном случае является Endpoint, поэтому надо просто добраться до его свойства dispatchRuntime до открытия соединения и указать там фабрику. Существует масса вариантов решения этой задачи, все зависит от вашей фантазии и текущего сценария. Вот некоторые из них.

С помощью атрибута IContractBehaviour

Самый, наверное, простой и прямой вариант. Суть в том, чтобы реализовать интерфейс IContractBehaviour в атрибуте и разметить этим атрибутом контракт (ITestService) или реализацию (TestService) сервиса, например, так:

          public
          class TestServiceContractBehaviourAttribute : 
        Attribute, IContractBehavior
    {
        publicvoid ApplyDispatchBehavior(
            ContractDescription contractDescription,
            ServiceEndpoint endpoint,
            DispatchRuntime dispatchRuntime)
        {
            dispatchRuntime.InstanceProvider = new TestServiceFactory();
        }

        publicvoid Validate(
            ContractDescription contractDescription, 
            ServiceEndpoint endpoint) {}

        publicvoid ApplyClientBehavior(
            ContractDescription contractDescription, 
            ServiceEndpoint endpoint, 
            ClientRuntime clientRuntime) {}

        publicvoid AddBindingParameters(
            ContractDescription contractDescription, 
            ServiceEndpoint endpoint, 
            BindingParameterCollection bindingParameters) {}
    }

Красным выделен код, который пришлось написать руками, все остальное сделала Visual Studio. С разметкой контракта этим атрибутом, думаю, вы справитесь сами. :-)

Там же можно еще реализовать интерфейс IContractBehaviourAttribute, для пущего контроля, чтобы этот атрибут не использовали где попало...

С помощью реализации IServiceBehaviour

Если вы выбрали этот путь, то вместо реализации IContractBehaviour необходимо будет реализовать интерфейс IServiceBehaviour, например, так:

          public
          class TestServiceBehaviour : IServiceBehavior
    {
        publicvoid ApplyDispatchBehavior(
            ServiceDescription serviceDescription, 
            ServiceHostBase serviceHostBase)
        {
            foreach (ChannelDispatcherBase cdb in serviceHostBase
                                                    .ChannelDispatchers)
            {
                var cd = cdb as ChannelDispatcher;
                if (cd != null)
                {
                    foreach (EndpointDispatcher ed in cd.Endpoints)
                    {
                        ed.DispatchRuntime.InstanceProvider = 
                            new ServiceFactory();
                    }
                }
            }
        }

        publicvoid Validate(
            ServiceDescription serviceDescription, 
            ServiceHostBase serviceHostBase) {}

        publicvoid AddBindingParameters(
            ServiceDescription serviceDescription, 
            ServiceHostBase serviceHostBase, 
            Collection<ServiceEndpoint> endpoints, 
            BindingParameterCollection bindingParameters){}
    }

Здесь кода, конечно, побольше, но, прямо скажем, ненамного... Только надо еще не забыть рассказать WCF о том, что для нашего объекта надо использовать именно этот Service Behaviour. Сделать это можно также несколькими способами.

  1. В лоб, с помощью императивного кода – просто дописав примерно такую строчку host.Description.Behaviors.Add(new TestServiceBehaviour()); перед открытием хоста (вызовом host.Open()).
  2. Тоже императивный вариант, для параноиков – унаследоваться от ServiceHost и там, в методе OnOpening(), перед вызовом base.OnOpening(); вписать точно такую же строчку. Только, естественно, вместо «host» должно стоять «this», или ничего не стоять. =)
  3. Декларативно, посредством атрибута. Для этого достаточно сделать реализацию TestServiceBehaviour атрибутом, и разметить этим атрибутом реализацию сервиса (TestService в нашем случае).
  4. Через файл конфигурации. Собственно, в этом вся прелесть данного подхода, но тут придется попотеть. Нужно еще реализовать наследника абстрактного BehaviorExtensionElement, который должен возвращать реализованый ранее IServiceBehaviour. Это не очень хитрая наука. Выглядеть он может примерно так:
          public
          class FactoryExtensionElement : BehaviorExtensionElement
    {
        protectedoverrideobject CreateBehavior()
        {
            return new TestServiceBehaviour();
        }

        publicoverride Type BehaviorType
        {
            get { return typeof(TestServiceBehaviour); }
        }
    }

После этого останется только правильным образом написать конфигурационный файл.

ПРЕДУПРЕЖДЕНИЕ

Одно важное замечание по поводу конфигурационных файлов. К сожалению, в этом месте присутствует небольшой косяк и при указании конкретного расширения в разделе behaviourExtensions конфигурационного файла необходимо указывать полное имя сборки. Иными словами указать просто type="WcfServerTest.Factory.FactoryExtensionElement, WcfServerTest" не достаточно, нужно указывать type="WcfServerTest.Factory.FactoryExtensionElement, WcfServerTest, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", причем если забыть это сделать, то сообщение об ошибке будет совершенно неинформативным. Самые пытливые умы, могут почитать историю вопроса тут: https://connect.microsoft.com/wcf/feedback/ViewFeedback.aspx?FeedbackID=216431&wa=wsignin1.0

С помощью реализации IEndpointBehaviour

Как можно заметить, реализация метода ApplyDispatchBehaviour в предыдущем примере просто перебирает все Endpoint-ы и выставляет для каждого нашу реализацию IInstanceProvider. Но так как интерфейс IEndpointBehaviour также содержит метод ApplyDispatchBehaviour, в который передается уже конкретный Endpoint, то этот перебор или даже задание особенного InstanceProvider-а для конкретного сервиса можно вынести наружу. Реализация IEndpointBehaviour может быть примерно такой:

          public
          class TestEndpointBehaviour : IEndpointBehavior
    {
        publicvoid ApplyDispatchBehavior(
            ServiceEndpoint endpoint,
            EndpointDispatcher endpointDispatcher)
        {
            endpointDispatcher.DispatchRuntime.InstanceProvider = 
                new ServiceFactory();
        }

        publicvoid AddBindingParameters(
            ServiceEndpoint endpoint, 
            BindingParameterCollection bindingParameters){}

        publicvoid ApplyClientBehavior(
            ServiceEndpoint endpoint, 
            ClientRuntime clientRuntime) {}

        publicvoid Validate(ServiceEndpoint endpoint) { }
    }

И после этого, как и в предыдущем примере, нужно установить соответствующий Behaviour, только уже не для сервиса, а для Endpoint-а. Сделать это можно почти теми же способами, что и для сервиса. Например, императивно, перед вызовом ServiceHost.Open()

          foreach (var endpoint in host.Description.Endpoints)
                endpoint.Behaviors.Add(new TestEndpointBehaviour());

Только через атрибут, в отличие от IServiceBehaviour, задать Behaviour не получится, зато этот вариант удобнее, если для каждого Endpoint-а нужна своя фабрика.

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

Разработка клиентской части

При разработке взаимодействия клиента с сервером в WCF, в том числе и из соображений удобства, реализовывали так называемую симметричную модель – когда однажды описанный на сервере публичный контракт может использоваться без изменений на клиенте. Достигается это с помощью хорошо уже известного механизма Transparent Proxy. Смысл идеи в том, что получив на клиенте публичный контракт сервиса – либо в специальной сборке, либо сгенерировав его по метаданным с помощью специальной утилиты svcutil.exe, можно смело обращаться к методам этого интерфейса, как будто бы реализация находится тут же – инфраструктура WCF прозрачно для вызывающего кода преобразует эти обращения в вызовы соответствующих методов на сервере.

Общая схема взаимодействия выглядит примерно так – все прокси-классы создаются специальной фабрикой ChannelFactory<T> и строятся вокруг класса System.ServiceModel.ChnnelsService.Channel, который, собственно, и отвечает за взаимодействие с сервером.

ПРИМЕЧАНИЕ

В WCF термин Proxy во многих случаях является синонимом Channel (ClientChannel), поэтому во всех именах классов эти термины обычно имеют одинаковый смысл. Ну понятно почему, смысл всех прокси – скрывать канал взаимодействия с сервером от прикладного кода.

Однако полностью избавить прикладной код от знания о том, что все не так просто – не получится. Как минимум, все автоматически сгенерированные proxy и фабрика прокси являются IDisposable-объектами, что говорит разработчику прикладного кода о том, что объект использует ресурсы, которые неплохо бы освободить детерминированно. В принципе понятно, что раз наш proxy на самом деле channel, то есть обеспечивает соединение с сервером, а соединение – ресурс во все времена дефицитный, то важно отпустить его как можно быстрее.

Но, на самом деле, важно не только закрыть соединение вовремя, но еще и правильно открыть.

Auto Open

В принципе, proxy-класс, создаваемый WCF, реализует в методах серверного интерфейса механику под названием «auto open». Смысл довольно прост – при обращении к любому методу proxy класса производится проверка, открыто ли уже соединение с сервером, и если оно не открыто, то открывается. В целом, намерения самые благие и это довольно неплохо работает, однако в многопоточных сценариях могут быть следующие проблемы.

Можно провести изуверский эксперимент – на каждое обращение с клиента открывать на сервере MessаgeBox (при этом разрешив на сервере параллельную обработку запросов любым из описанных ранее способов) и отправить с одного клиента пяток сообщений параллельно, естественно, использовав механику «auto open». Эффект получается презабавный – несмотря на то, что на сервере разрешены параллельные подключения, все запросы все равно будут обрабатываться по очереди, что хорошо будет видно по MessageBox-ам, открывающимся строго после того, как закроется предыдущий.

На этот раз причина последовательной обработки лежат на клиенте. Если посмотреть рефлектором в код класса, отвечающего за соединение, непосредственно в метод Call, то можно увидеть следующие нехитрые строки:

        if (!this.explicitlyOpened)
        {
            this.EnsureDisplayUI();
            this.EnsureOpened(rpc.TimeoutHelper.RemainingTime());
        }

Проблема, очевидно, кроется в методе EnsureOpened(…), который, в свою очередь, обращается к CallOnceManager.CallOnce, и все последующие запросы попадают на SyncWait.Wait, до тех пор пока первый запрос не обработается – так и возникает очередь на ровном месте. В принципе понятно, для чего это сделано - чтобы нельзя было открывать один и тот же канал одновременно из нескольких потоков. К счастью, избежать образования очереди желающих открыть канал довольно просто, достаточно явно открывать его перед первым использованием. Если в описанном ранее смелом эксперименте после создания прокси явно открыть соединение, тут же все MessageBox-ы вывалятся на сервер почти одновременно...

Закрытие соединения

Итак, нюансы открытия соединения с сервером мы обсудили выше, теперь надо разобраться, как правильно его закрывать, что тоже не очень просто. Как я уже писал ранее, автоматически построенные Proxy и ряд других объектов требуют детерминированного освобождения ресурсов по причине непосредственной работы с живым соединением. Плохая новость заключается в том, что пользоваться паттерном using для этих объектов не рекомендуется.

Дело в том, что все сущности, занимающиеся непосредственно обслуживанием соединения, реализуют интерфейс ICommunicationObject, а для закрытия соединения этот интерфейс реализует два метода .Close() и .Abort(), что, собственно, и является источником проблемы.

Метод .Abort() – прост и лаконичен. Он без лишних вопросов и размышлений разрывает соединение, не пытаясь перевести в корректное состояние все, что это соединение могло потенциально использовать. Если в методе Dispose() вызывать .Abort(), как это и было до версии Beta1, то поставленная цель, безусловно, будет достигнута – все ресурсы освободятся. Но пользователи, наивно поместившие экземпляр ICommunicationObject в using, получат проблемы с закешированными, но не отправленными сообщениями (которые будут просто выкидываться), транзакции не будут фиксироваться, сессии корректно закрываться и т. д. В целом получается не очень ожидаемое поведение, причину которого обнаружить довольно сложно.

Метод .Close() – напротив, все закрывает максимально корректно и аккуратно, но, как следствие, выполняет довольно много работы, может блокировать, имеет асинхронную версию, и при некоторых сценариях может сгенерировать исключение, а именно CommunicationException или TimeoutException. По этой причине помещать его в метод .Dispose() тоже неправильно, так как исключение, выброшенное из Dispose, маскирует оригинальное исключение (если оно было), и делает его недоступным вызывающему коду, что также может привести к проблемам при отладке.

В конечном итоге пришли к следующему... Интерфейс IDisposable все же оставили для некоторых объектов, так как довольно большему количеству разработчиков удобно пользоваться им в качестве маркера, информирующего о необходимости детерменированного освобождения объекта. При этом реализация метода .Dispose() прибегает к некоей эвристике, пытаясь минимизировать время работы и вероятность выброса исключения. Например, если соединение не открыто, то вызывается метод .Abort(), что в этом случае относительно корректно (но только относительно), а если открыто, то .Close().

Тем не менее, вероятность возникновения исключения и не самого ожидаемого поведения по-прежнему остается, поэтому правильный подход при работе с подобного рода объектами заключается в отказе от использования паттерна using и явного закрытия соединения тем или иным образом, например так:

        try
{
    ...
    client.Close();
}
catch (CommunicationException e)
{
    ...
    client.Abort();
}
catch (TimeoutException e)
{
    ...
    client.Abort();
}
catch (Exception e)
{
    ...
    client.Abort();
    throw;
}

Что и проделывается во всех примерах MSDN и SDK при иллюстрации работы с WCF.

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

Как я уже упоминал ранее, созданием прокси/каналов, скрывающих взаимодействие с сервером от разработчика прикладного кода, занимается специальная фабрика – ChannelFactory. Воспользоваться этой фабрикой можно двумя способами, либо полностью вручную, либо прибегнуть к помощи автогенерированного класса, который является наследником ClientBase<T>, где, собственно и реализована практически вся логика.

ClientBase<T>

Очевидно, ClientBase<T> придуман для того, чтобы брать на себя большую часть грязной работы.

Одним из самых тонких моментов здесь является создание фабрики – дело в том, что это довольно накладное мероприятие. При создании фабрики приходится конструировать дерево описаний контрактов, генерировать все типы данных контракта, создавать стек каналов и выполнять кучу другой работы по инициализации... В первой версии WCF использование ClientBase «в лоб» было довольно дорогим удовольствием, так как фабрика просто содержалась в private-поле и время жизни конкретной фабрики совпадало со временем жизни экземпляра ClientBase, то есть фактически на каждый запрос создавалась новая фабрика.

С выходом .NET Framework 3.5 описанное поведение существенно улучшилось, теперь фабрика кэшируется, причем по-хитрому. С виду все осталось как раньше – внутренняя логика по-прежнему работает с private-переменной, но эта переменная теперь инициализируется при создании объекта, путем вызова метода InitializeChannelFactoryRef(), где и вершится основная магия – доставание экземпляра фабрики из объекта ChannelFactoryRefCache. Последний же заслуживает особого рассмотрения. Во-первых, он наследуется от объекта MruCache, что говорит о том, что в кэше сидит ограниченное количество экземпляров фабрик (а именно 32), и что эти фабрики живут там по принципу Most Recent Used, то есть если фабрик появляется больше чем 32, то из кэша выкидывается та, которую не использовали дольше всех. А во-вторых, он содержит фабрики в объекте EndpointTrait и реализует EndpointTrait Comparer, что дает ответ на вопрос – откуда так много фабрик. Для каждого конкретного Endpoint-а должна быть создана собственная фабрика, учитывающая его особенности, а Endpoint-ов теоретически может быть довольно много.

Таким образом, теперь использовать автогенерированных наследников ClientBase можно без оглядки на производительность, время жизни фабрики больше не зависит от времени жизни объекта и довольно грамотно кэшируется.

Но не все так просто – при тщательном рассмотрении некоторых методов и свойств объектов ClientBase и EndpointTrait начинают вылезать забавные подробности... Если взглянуть на любой конструктор ClientBase, где среди параметров участвует Binding, то в конце можно заметить вызов метода TryDisableSahring(), где с помощью следующих нехитрых манипуляций фабрика для данного объекта выкидывается из глобального кеша.

        if (this.useCachedFactory)
 {
    // сохраняем ссылку на закэшированную версию фабрики//
    ChannelFactoryRef<TChannel> channelFactoryRef = this.channelFactoryRef;
    
  // создаем фабрику локально, не из кэша//  this.channelFactoryRef = ClientBase<TChannel>
        .CreateChannelFactoryRef(this.endpointTrait);
    this.useCachedFactory = false;
    
  // удаляем из кэша ссылку на данную версию фабрики, если была//  lock (ClientBase<TChannel>.staticLock)
    {
        if (!channelFactoryRef.Release())
        {
             channelFactoryRef = null;
        }
    }
    if (channelFactoryRef != null)
    {
        channelFactoryRef.Abort();
    }
}

Сделано это из следующих соображений – Binding является частью Endpoint-а, соответственно, если объект манипулирует разными Binding-ами и, как следствие, разными Endpoint-ами, то и фабрики должны быть разными. Однако, разобрав объект EndpointTrait, который как раз и отвечает за сравнение двух Endpoint-ов между собой с целью выявления отличий, становится видно, что Binding в сравнении не участвует, в отличие от remoteAddress, endpointConfigurationName и callbackInstance... Причина такой дискриминации заключается в том, что объекты remoteAddress и endpointConfigurationName являются неизменяемыми (immutable) – это позволяет легко сравнить два экземпляра между собой, для сравнения callbackInstance достаточно проверки ссылок, а вот binding – полноценный изменяемый объект, что ведет к проблемам при сравнении. Иными словами, возможен сценарий, когда создается один экземпляр Binding-а, на его основе создается Endpoint, потом значение Binding-а меняется и создается второй Endpoint – оба Endpoint-а являются разными по смыслу объектами, но пользуются ссылкой на один экземпляр Binding-а. Выявить такой расклад при кэшировании фабрик – не самая тривиальная задача, именно поэтому, как только ClientBase пытается явно использовать Binding, фабрика каналов такого объекта выкидывается из кэша.

Метод TryDsableSharing() вызывается не только из конструкторов с участием Binding-а, но и при доступе к некоторым свойствам, а именно Endpoint, ChannelFactory и ClientCredentials. Сделано это ровно из тех же соображений – добравшись до этих свойств можно поменять параметры критичные для фабрики каналов, что повлияет на другие объекты, которые также пользуются этой фабрикой.

Все вышесказанное означает, что для кэширования фабрики каналов при использовании автоматически сгенерированных наследников ClientBase (а это может довольно существенно повлиять на производительность в некоторых сценариях), необходимо учитывать следующие особенности:

В принципе, несмотря на ряд ограничений, это вполне достойный выбор для большинства типичных сценариев, но что же делать, если по каким-либо причинам автоматически сгенерированный утилитами proxy-класс нас не устраивает? Ответ очевиден – работать напрямую с ChannelFactory<T>, создавая proxy-классы самостоятельно.

ChannelFactory<T>

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

        var factory = new ChannelFactory<ITestService>(...);
ITestService proxy = faclory.CreteChannel();
proxy.SomeMethodCall(...);

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

Дело в том, что объект, возвращаемый методом CreateChannel() экземпляра ChannelFactory<T>, реализует не только интерфейс серверного контракта (ITestService), но и IClientChannel, который в свою очередь наследует ICommunicationObject. Иными словами, нужные нам методы Close(), Open() и Abort() уже содержатся в объекте, который возвращает метод CreateChannel() (proxy), надо только предварительно привести этот объект к интерфейсу IClientChannel.

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

        public
        class FactoryWrapper<TChannel> where TChannel : class
    {
        privatestatic ChannelFactory<TChannel> _factory;

	   // или любой другой набор параметров, который вам нужен// для корректного создания фабрики...//public FactoryWrapper(Binding binding, EndpointAddress endpointAddress)
        {
            _factory = new ChannelFactory<TChannel>(binding, endpointAddress);
        }

        public TResult Execute<TResult>(Func<TChannel, TResult> action)
        {
            var proxy = default(TChannel);
            TResult result;
            try
            {
                proxy = _factory.CreateChannel();
                ((IClientChannel) proxy).Open();
                result = action(proxy);
                ((IClientChannel) proxy).Close();
            }
            catch (Exception)
            {
                if (proxy != null)
                    ((IClientChannel) proxy).Abort();
                throw;
            }
            return result;
        }
    }

Как можно заметить, здесь не реализовано кэширование отдельных экземпляров ChannelFactory, что могло бы быть одной из основных целей создания подобного рода самодельных оберток. Основная сложность, как я уже писал ранее, тут заключается в выборе ключа, позволяющего отличить одну фабрику от другой – дело это сугубо интимное, так как во многом зависит от особенностей конкретного проекта, остальное же вполне тривиально, поэтому оставляю это несложное упражнение вам в качестве домашнего задания. =)

Использовать же подобную обертку довольно просто, например, так:

        var factoryWrapper = new FactoryWrapper<ITestService>(binding, endpointAddress);
// ...var result = factoryWrapper.Execute(proxy => proxy.SomeMethodCall(...));

В случае необходимости обращение к нескольким методам вполне можно объединить в сессию:

        var result = factoryWrapper.Execute(proxy => 
{ 
    proxy.SessionMethod1();
    return proxy.SessionMethod2();
});
СОВЕТ

В реализации обертки вполне можно использовать и делегат Action<T>, вместо Func<T, R>, но в этом случае возврат значений нужно будет делать через замыкания.

Несмотря на то, что объект ChannelFactory<T> формально является Disposable, то есть требует детерменированной очистки ресурсов посредством вызова метода Close()/Dispose(), осуществлять эту очистку в обертке (и делать ее, в свою очередь, IDisposable-объектом) совсем не обязательно. ChannelFactory является Disposable только потому, что содержит в себе каналы/прокси, но их время жизни и так явно контролируется в реализации метода Execute().


Эта статья опубликована в журнале RSDN Magazine #1-2009. Информацию о журнале можно найти здесь
    Сообщений 3    Оценка 256 [+1/-0]         Оценить