Создание маршрутизатора WCF
ОГЛАВЛЕНИЕ
Порой полезно ввести промежуточную службу (она же служба маршрутизатора) между клиентом и целевой службой для получения сообщений, которыми они обмениваются, и выполнения дополнительных действий, таких как ведение журнала событий, приоритетной маршрутизации, маршрутизации в оперативном или автономном режиме, балансировки нагрузок или введения границы безопасности. При введении такой промежуточной службы становится необходимым скорректировать некоторые из поведений адресации и фильтрации сообщений, приспосабливая их к ее присутствию.
Давайте взглянем поближе на то, как работать с промежуточными службами, которые я для простоты буду коллективно называть маршрутизаторами. В этом выпуске рубрики я расскажу о концепциях адресации WCF и фильтрации сообщений, уделяя основное внимание случаю маршрутизатора, а также разъясню некоторые из возможностей настройки маршрутизации вместе с соответствующими параметрами. В части 2 этой статьи я продемонстрирую, как использовать эти основы для создания более совершенных практических реализаций маршрутизации.
Семантика адресации по умолчанию
В выпуске рубрики «Станция техобслуживания» за июнь 2007 года (msdn.microsoft.com/msdnmag/issues/07/06/ServiceStation) Аарон Сконнард (Aaron Skonnard) объяснил, как WCF обрабатывает логическую и физическую адресацию конечных точек, адресацию заголовков и фильтрацию сообщений. В этом разделе я рассмотрю некоторые из этих основных функций адресации и их влияние в случаях маршрутизации – впрочем, статья Аарона останется полезной благодаря дополнительным сведениям об этих функциях WCF.
Как правило, клиенты отправляют сообщения напрямую целевой службе, используя прокси, созданный из описания службы. Чтобы клиент и служба были совместимыми, они разделяют эквивалентные контракты и настройки конечных точек. Рассмотрев контракт службы и настройку, показанные на рис. 1, можно вывести из них несколько важных требований к адресации для службы.
Рис. 1 Настройка конечной точки и контракт службы
Service Contract
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01")]
public interface IMessageManagerService
{
[OperationContract]
string SendMessage(string msg);
[OperationContract]
void SendOneWayMessage(string msg);
}
Endpoint Configuration
<system.serviceModel>
<services>
<service name="MessageManager.MessageManagerService"
behaviorConfiguration="serviceBehavior">
<endpoint
address="http://localhost:8000/MessageManagerService"
contract="MessageManager.IMessageManagerService"
binding="basicHttpBinding" />
</service>
</services>
</system.serviceModel>
Во-первых, ожидаемый заголовок адресации Action («Действие») для запроса к операции SendMessage таков:
http://www.thatindigogirl.com/samples/2008/01/IMessageManagerService/SendMessage
Поскольку OperationContractAttribute не указывает Action, это значение выводится из пространства имен контракта службы, имени контракта (по умолчанию им становится имя интерфейса) и имени операции (по умолчанию им становится имя метода).
Во-вторых, ожидаемый заголовок Action для ответа от SendMessage таков:
http://www.thatindigogirl.com/samples/2008/01/IMessageManagerService/SendMessageResponse
Поскольку OperationContractAttribute не указывает ReplyAction, это значение производится тем же способом, что и свойство Action, с добавлением суффикса «Response» («Ответ»).
Наконец, ожидаемый заголовок To («Кому») для сообщений, целью которых является конечная точка службы, таков:
http://localhost:8000/MessageManagerService
Значение выводится из атрибута адреса для элемента конечной точки, который считается логическим адресом конечной точки. Физический адрес конечной точки обычно совпадает с логическим, хотя может быть указано иначе. Это означает, что клиенты обычно отправляют сообщения на физический адрес, соответствующий заголовку To.
Метаданные службы описывают эти требования, позволяя клиентам создать совместимые прокси и настройку. Контракт службы, созданный для клиента, отражает все те же параметры Action и ReplyAction службы, а настройка привязки клиента будет отражать конечную точку с соответствующим логическим и физическим адресом. Например, следующая конечная точка клиента совместима со службой на рис. 1:
<client>
<endpoint
address="http://localhost:8000/MessageManagerService"
binding="basicHttpBinding"
contract="localhost.IMessageManagerService"
name="basicHttp" />
</client>
Прокси клиента использует свойство адреса элемента конечной точки клиента и для его логического, и для физического адреса. Как результат, сообщения обычно отправляются на физический адрес, соответствующий заголовку To («Кому»), как я указала раньше. Когда прокси вызывает операцию SendMessage, отправляется сообщение с заголовками To и Action, показанными на рис. 2.
Рис. 2 Сообщение, отправленное от прокси
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Header>
<To s:mustUnderstand="1"
xmlns="http://schemas.microsoft.com/ws/2005/05/addressing/none">
http://localhost:8000/MessageManagerService
</To>
<Action s:mustUnderstand="1"
xmlns="http://schemas.microsoft.com/ws/2005/05/addressing/none">
http://www.thatindigogirl.com/samples/2008/01
/IMessageManagerService/SendMessage
</Action>
</s:Header>
<s:Body>
<SendMessage xmlns="http://www.thatindigogirl.com/samples/2008/01">
<msg>test</msg>
</SendMessage>
</s:Body>
</s:Envelope>
Сочетание заголовков To и Action, соответственно, указывает модели службы, какой диспетчер канала должен обработать сообщение и какую операцию диспетчер должен вызвать. По умолчанию, должна существовать конечная точка, логический адрес которой совпадает с заголовком To, а операция с заголовком Action. На рис. 3 показан этот поток.
Рис. 3 Типовая адресация без маршрутизатора
В следующих разделах я объясню значение логического и физического адреса, смысл заголовков To и Action, а также правила фильтрации сообщений при введении маршрутизатора в эту ситуацию.
Архитектура маршрутизации
Хотя маршрутизатор можно построить несколькими способами, большинство маршрутизаторов должны иметь возможность получать сообщения, отправленные любой службе, и должны быть способны передать первоначальное сообщение соответствующей целевой службе. Существуют два фундаментальных подхода к созданию маршрутизаторов: сквозной маршрутизатор и обрабатывающий маршрутизатор.
Сквозной маршрутизатор прозрачен для клиента. Клиент связан с подчиненными службами, и сообщения просто протекают через маршрутизатор. Клиент должен отправить сообщение маршрутизатору, используя совместимый транспортный протокол и кодировщик сообщений, но требования любых протоколов безопасности, надежности сеансов, сеансов приложений и прочих, требуемые каналом службы, удовлетворяются сообщением, созданным на клиенте. Маршрутизатор может рассматривать заголовки сообщений или даже вставлять заголовки, но изначальные элементы сообщения перенаправляются службе в неизменном виде. Такой тип отношений показан на рис. 4.
Рис. 4 Сквозной маршрутизатор
Обрабатывающий маршрутизатор играет более активную роль в обработке сообщений для приложения. В силу этого клиент связан уже с самим маршрутизатором в том, что касается транспорта, шифрования и совместимости протоколов, хотя клиент все же должен иметь возможность отправлять сообщения, совместимые с подчиненными службами. Сообщения протекают через маршрутизатор к службам, и тело сообщения – вместе с требуемыми службой заголовками – остается неизменным.
Безопасность, надежность сеансов, а также заголовки или сообщения, относящиеся к другим протоколам связи, обычно обрабатываются маршрутизатором, и маршрутизатор создает новое сообщение с соответствующим потоком связи для подчиненных служб. На рис. 5 показана совместимость для обрабатывающего маршрутизатора.
Рис. 5 Обрабатывающий маршрутизатор
Каждая настройка маршрутизатора имеет свои практические применения. Также возможно реализовать гибридное решение, смешивающее эти два.
Контракт маршрутизатора
Маршрутизаторы получают сообщения, предназначенные для подчиненных служб, и ответственны за пересылку этих сообщений соответствующей службе. Они также ответственны за получение сообщений от службы и передачу их клиенту. Обычный контракт маршрутизатора предоставляет единственную операцию, которая может обработать любой запрос сообщения или ответ. В приведенном ниже примере эта операция именуется ProcessMessage:
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01")]
public interface IRouterService {
[OperationContract(Action = "*", ReplyAction = "*")]
Message ProcessMessage(Message requestMessage);
}
В обычной ситуации такие свойства OperationContractAttribute, как Action и ReplyAction, выводятся, как указывалось ранее в этой статье, – из пространства имен контракта службы, имени контракта и имени операции. По умолчанию, при прибытии сообщения диспетчер канала должен найти операцию, соответствующую определенному заголовку Action. Однако, в случае, когда Action и ReplyAction установлены на "*", диспетчер канала будет отправлять любые сообщения, не сопоставленные с конкретной операцией, вышеупомянутой многоцелевой операции вне зависимости от значения заголовка Action. Чтобы избежать двусмысленности, только одна операция может указать "*" для свойства Action или ReplyAction.
Типичный маршрутизатор предоставляет единственную операцию, вроде ProcessMessage, которая может обработать любое достигшее ее сообщение. Тогда как Action и ReplyAction обеспечивают сопоставление сообщения с ProcessMessage при помощи диспетчера канала, сигнатура операции также должна иметь возможность обработать любое сообщение.
Чтобы решить эту задачу, ProcessMessage получает и возвращает нетипизированные сообщения в форме типа Message («Сообщение»). Маршрутизатор может получить доступ к коллекции заголовка и телу сообщения через этот тип, но автоматической сериализации не происходит, помимо общих заголовков адресации, которые десериализуются и делаются доступными через строго типизированные свойства.
Любая последующая обработка сообщений зависит от реализации маршрутизатора. Базовый маршрутизатор просто получит нетипизированное сообщение и перенаправит его подчиненным службам, ожидая ответа. Аналогично, ответ перенаправляется вызывающему клиенту в том же необработанном формате.
Перенаправление сообщений
После того, как маршрутизатор получает сообщение и обрабатывает его в соответствии с собственными требованиями, он перенаправляет сообщение соответствующей подчиненной службе для дальнейшей обработки. Простая реализация маршрутизатора для описанного выше контракта показана на рис. 6. ProcessMessage конструирует канал клиента (или прокси) при помощи ChannelFactory<T> и использует этот прокси для пересылки сообщений определенной конечной точке службы, возвращая любые ответы.
Рис. 6 Реализация простого маршрутизатора
[ServiceBehavior(
InstanceContextMode = InstanceContextMode.Single,
ConcurrencyMode = ConcurrencyMode.Multiple)]
public class RouterService : IRouterService {
public Message ProcessMessage(Message requestMessage) {
using (ChannelFactory<IRouterService> factory =
new ChannelFactory<IRouterService>("serviceEndpoint")) {
IRouterService proxy = factory.CreateChannel();
using (proxy as IDisposable) {
return proxy.ProcessMessage(requestMessage);
}
}
}
}
Прокси обычно строго типизированы в контракте целевой службы, но в данном случае прокси должен быть способен перенаправить любое сообщение и получить любой ответ – контракт маршрутизатора способствует этому. В этом простом примере маршрутизатор просто перенаправляет первоначальное сообщение целевой службе и возвращает любой ответ. Если операция на целевой службе одностороння, ответ не отправляется.
Поскольку контракт работает с нетипизированными сообщениями, то же сообщение перенаправляется службе, как показано на рис. 7. Однако следует понять, что в сообщение внесено изменение, которое может оказаться неожиданным: заголовок To изменен перед отправкой сообщения службе.
Рис. 7 Адресация семантики через простой маршрутизатор
Вспомним, что по умолчанию прокси будет использовать логический адрес из своей настройки конечной точки, чтобы установить заголовок To для исходящих сообщений – даже если передан необработанный экземпляр Message, уже имеющий заголовок To. Это может показаться хорошей идеей (поскольку все службы требуют, чтобы заголовок To совпадал с логическим адресом одной из их конечных точек), но способно вызвать побочные эффекты. Например, если обновленный заголовок To не подписан, а у службы включена безопасность, то сообщение будет отвергнуто.
В идеале, клиент должен отправить сообщение с заголовком To, соответствующим целевой службе, маршрутизатор должен принять это сообщение, несмотря на несовпадение, и перенаправить его службе, не изменяя заголовок To. Этого можно добиться через настройки привязки, о которых я сейчас и расскажу.
Физическая и логическая адресация
При введении маршрутизатора в архитектуру приложения всегда лучше дать клиенту возможность отправлять сообщения, используя верный заголовок To для службы, но отправляя сообщение все же маршрутизатору. Один из способов добиться этого – настроить клиент на использование ClientViaBehavior, как в настройке, показанной на рис. 8. Такая настройка указывает прокси клиента создать сообщение с заголовком To в соответствии с логическим адресом конечной точки, но переслать его через физический адрес маршрутизатора. Проблема в том, что это прикрепляет клиента к существованию маршрутизатора.
Рис. 8 Использование ClientViaBehavior
<client>
<endpoint address="http://localhost:8000/MessageManagerService"
binding="wsHttpBinding"
bindingConfiguration="wsHttpNoSecurity"
contract="localhost.IMessageManagerService"
name="basicHttp"
behaviorConfiguration="viaBehavior"/>
</client>
<behaviors>
<endpointBehaviors>
<behavior name="viaBehavior">
<clientVia viaUri="http://localhost:8010/RouterService"/>
</behavior>
</endpointBehaviors>
</behaviors>
Другой способ решить эту проблему – указать службе настроить атрибут listenUri для ее конечных точек, чтобы логический адрес службы совпадал с таковым маршрутизатора, тогда как физический адрес оставался бы собственным. Рассмотрите следующую настройку службы:
<endpoint address="http://localhost:8010/RouterService"
contract="MessageManager.IMessageManagerService"
binding="wsHttpBinding"
bindingConfiguration="wsHttpNoSecurity"
listenUri="http://localhost:8000/MessageManagerService"/>
Получающиеся метаданные службы публикуют адрес маршрутизатора для клиентов, так что конечные точки клиентов отражают адрес маршрутизатора. Мне не особенно нравится это решение, поскольку оно прикрепляет службу к маршрутизатору, а в идеале службе тоже не нужно знать об этом.
Альтернатива – использование службой логического адреса, являющегося типом URI-адреса, не привязанного к маршрутизатору или службе, с последующим указанием вручную клиентам физического адреса для отправки сообщений, поскольку он не будет частью метаданных. Вот пример такой настройки конечной точки:
<endpoint address="urn:MessageManagerService"
contract="MessageManager.IMessageManagerService"
binding="wsHttpBinding"
bindingConfiguration="wsHttpNoSecurity"
listenUri="http://localhost:8000/MessageManagerService"/>
В обеих случаях служба получит заголовок To, соответствующий ее настройке конечной точки, и маршрутизатор получит сообщение первым.
Маршрутизатор должен нести бремя настройки, чтобы клиенты и службы не зависели от его присутствия. Таким образом, заголовок To никогда не будет совпадать с логическим адресом маршрутизатора. По умолчанию, службы используют EndpointAddressMessageFilter для определения, совпадает ли заголовок To сообщения с любой из настроенных конечных точек. Поскольку маршрутизатор не может ожидать, чтобы это сработало, ему необходимо установить MatchAllMessageFilter.
ServiceBehaviorAttribute поддерживает это через свойство AddressFilterMode, которое может быть установлено на одно из перечислений AddressFilterMode: Exact («Точный») (установка по умолчанию), Prefix («Префикс») или Any («Любой»). Поскольку совпадение префикса маршрутизатора со всеми службами, для которых он получает сообщения, нельзя гарантировать, имеет смысл разрешить передачу всех заголовков To, вот так:
[ServiceBehavior(InstanceContextMode =
InstanceContextMode.Single,
ConcurrencyMode = ConcurrencyMode.Multiple,
AddressFilterMode=AddressFilterMode.Any)]
public class RouterService : IRouterService
По умолчанию, заголовок To всегда будет обновляться, чтобы соответствовать логическому адресу прокси, основываясь на его настройке конечной точки, вне зависимости от того, не установлен ли уже адрес To на верное значение. Чтобы подавить это поведение и дать маршрутизатору возможность отправлять сообщения службам, используя изначальный заголовок To, маршрутизатор должен использовать настройку привязки с адресацией вручную. Это свойство не из тех, которые могут быть установлены на любой из стандартных привязок, так что для достижения результата необходимо использовать пользовательскую привязку.
В приведенном ниже фрагменте кода показан раздел customBinding, устанавливающий эту функцию для транспортного канала HTTP:
<customBinding>
<binding name="manualAddressing">
<textMessageEncoding />
<httpTransport manualAddressing="true"/>
</binding>
</customBinding>
Это помогает транспортному потоку адресации, показанному на рис. 9, где заголовки не изменены.
Рис. 9 Адресация семантики через маршрутизатор с ручной адресацией
Заголовки MustUnderstand
Пока что речь шла о простой реализации маршрутизации, позволяющей проиллюстрировать вещи, которые следует принимать во внимание при разработке ядра маршрутизатора и которые также влияют на настройку адресации, фильтрации и привязки. Это простое решение маршрутизации работает, только если служба не включает безопасность, надежные сеансы или любой другой полнофункциональный протокол для ее привязок. На рис. 10 показано упрощенное представление протоколов привязки, о которых пока идет речь.
Рис. 10 Service Contract и Endpoint Configuration
На рис. 11 показано то же представление для сквозного маршрутизатора, где служба требует безопасности и надежных сеансов. Включение этих протоколов значит, что клиент и каналы службы будут обмениваться дополнительными сообщениями для установки сеансов, запроса маркеров безопасности и прочего. Поскольку маршрутизатор пропускает все сообщения, данные сообщения, относящиеся к определенному протоколу, также будут переданы службе – и это хорошо.
Рис. 11 Сквозная конфигурация с безопасными сессиями
Однако когда сообщения службе и от службы включают заголовки, которые должны быть поняты получателем, возникает проблема. Поскольку у сквозного маршрутизатора не включена безопасность или надежные сеансы, эти каналы отсутствуют и не могут обрабатывать заголовки связанного протокола.
Службе маршрутизатора можно приказать игнорировать заголовки MustUnderstand, установив свойство ValidateMustUnderstand атрибута ServiceBehaviorAttribute на false, как показано тут:
[ServiceBehavior(InstanceContextMode =
InstanceContextMode.Single,
ConcurrencyMode = ConcurrencyMode.Multiple,
AddressFilterMode=AddressFilterMode.Any,
ValidateMustUnderstand=false)]
public class RouterService : IRouterService
Это решит проблему со входящими сообщениям от клиента, но не с сообщениями, возвращенными подчиненными службами.
Для решения этой проблемы необходимо также изменить реализацию маршрутизатора, чтобы указать подобное поведение при инициализации фабрики каналов для вызова подчиненной службы, вот так:
using (ChannelFactory<IRouterService> factory =
new ChannelFactory<IRouterService>("serviceEndpoint"))
{
factory.Endpoint.Behaviors.Add(new MustUnderstandBehavior(false));
IRouterService proxy = factory.CreateChannel();
// remaining code
}
Теперь протокол и сообщения службы могут свободно протекать между клиентом и службой через маршрутизатор, предполагая, что используется протокол HTTP.
Другая проблема возникает при применении дуплексных протоколов, таких как TCP, либо именованных каналов. Это значит, что сообщения могут быть инициализированы службой на клиенте, скажем при включении надежных сеансов. Существует усовершенствованная настройка маршрутизатора, которую можно использовать для решения этого специального случая.
Случаи транзитных маршрутизаторов
Как упоминалось в части 1, если между клиентом и службой устанавливается транизитный маршрутизатор, клиент взаимодействует с целевой службой, а не с маршрутизатором. Хотя, безусловно, сообщения должны отправляться посредством транспортного протокола и кодировщика сообщений, понятных маршрутизатору, в целом содержимое сообщения (включая заголовки безопасности и надежные сеансы) маршрутизатором не обрабатывается. К примерам применения транзитного маршрутизатора относятся балансировка нагрузки, маршрутизация на основе содержимого или преобразование сообщений.
Балансировка нагрузки и распределение работы по ресурсам сервера наилучшим образом подходит для NLB (балансировка сетевой нагрузки) или даже больше – для устройств балансировки нагрузки на основе оборудования. Кроме этого, маршрутизатор WCF может быть полезен для балансировки нагрузки, если службы размещаются в средах без «излишеств», когда службы устанавливаются на физические инфраструктуры, не доступные непосредственному управлению, и когда требуется выполнять маршрутизацию на основе характерной для домена логики, или если приложение просто вызывает облегченное, легко настраиваемое решение маршрутизации. Такой маршрутизатор WCF можно использовать для доставки сообщений службам, размещенным в нескольких процессах на одной и той же машине, или распределенным на несколько машин.
Независимо от модели распределения маршрутизатор балансировки нагрузки безусловно должен обладать некоторыми основными функциями. Службы, для их включенния в распределение нагрузки, должны быть некоторым образом зарегистрированы маршрутизатором. Чтобы правильно перенаправлять сообщения, маршрутизатор должен быть в состоянии определить тип службы и соответствующую конечную точку. Кроме этого, у маршрутизатора должен быть алгоритм распределения нагрузки, например классический циклический подход или некоторый тип маршрутизации на основе приоритетов.
Иногда, в противоположность балансировке нагрузки, распределение сообщений между службами выполняется на основе содержимого сообщения. Маршрутизатор на основе содержимого проверяет, как правило, заголовки или тело сообщения, чтобы извлечь данные маршрутизации. Например, сообщения от клиентов с действительным ключом лицензии могут быть направлены в качестве высокоприоритетных в обширный пул машин с большей вычислительной мощностью, в то время как сообщения с пробной лицензией направляются в небольшой пул менее мощных серверов. В этой ситуации маршрутизатор должен не только знать, куда направлять сообщения, но также быть в состоянии проверять каждое сообщение, его заголовки или тело, перед принятием решения о том, куда его направить. В следующих разделах обсуждаются функции маршрутизации, необходимые для поддержки таких ситуаций.
Перенаправление в соответствии с заголовком Action
Сообщения, принимаемые на маршрутизаторе, имеют два адресных заголовка, которые удобно использовать при пересылке сообщений в надлежащую службу.
To Этот заголовок указывает имя конечной точки. Если заголовок соответствует целевой службе, а не маршрутизатору, он указывает адрес URL конечной точки службы, которой предназначено это сообщение.
Action Этот заголовок указывает операцию службы, которой предназначено данное сообщение, но он может не представлять допустимый адрес URL per se.
Однако, во многих случаях заголовок To соответствует адресу маршрутизатора, а не службе, оставляя заголовку Action роль более надежного источника информации о правильном пункте назначения этого сообщения. Вспомним, что заголовок Action строится на основе пространства имен контракта службы, имени контракта службы и имени операции. В предположении, что контракты не используются совместно разными типами служб, маршрутизатору достаточно этой информации для однозначного определения целевой службы. Рассмотрим следующие контракты служб, каждый из которых реализован на разных типах служб.
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01")]
public interface IServiceA {
[OperationContract]
string SendMessage(string msg);
}
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01")]
public interface IServiceB {
[OperationContract]
string SendMessage(string msg);
}
public class ServiceA : IServiceA {...}
public class ServiceB : IServiceB{...}
Из рис. 1 видно, что маршрутизатор может опираться на соответствие между пространствами имен контрактов для всех контрактов служб и конечными точками служб, которым должны быть направлены сообщения.
Рис. 1 Сопоставление пространства имен контракта конечной точке службы
Далее показано, как словарь, инициализированный таким образом, что каждая запись пространства имен контракта сопоставляется элементу конфигурации, указывающему, какие параметры конфигурации канала надлежит использовать.
static public IDictionary<string, string> RegistrationList =
new Dictionary<string, string>();
RegistrationList.Add(
"http://www.thatindigogirl.com/samples/2008/01/IServiceA",
"ServiceA");
RegistrationList.Add(
"http://www.thatindigogirl.com/samples/2008/01/IServiceB",
"ServiceB");
В таком случае код для инициализации канала будет выглядеть следующим образом.
static public IDictionary<string, string> RegistrationList =
new Dictionary<string, string>();
RegistrationList.Add(
"http://www.thatindigogirl.com/samples/2008/01/IServiceA",
"ServiceA");
RegistrationList.Add(
"http://www.thatindigogirl.com/samples/2008/01/IServiceB",
"ServiceB");
В этой ситуации имеется несколько важных зависимостей проекта, на которые следует обратить внимание.
- Для упрощения конфигурации и поддержки нескольких экземпляров маршрутизатора сопоставление контрактов службам будет находиться, скорее всего, в базе данных.
- Контракт службы может быть реализован на службах нескольких типов, только если сообщения можно обрабатывать любой службой, реализующей контракт.
- Если на ферме серверов имеется несколько экземпляров службы, конфигурация каждой конечной точки должна сопоставляться виртуальному адресу, который физический балансировщик нагрузки сможет затем распределить соответствующим образом.
- Не существует поддержки для сообщений, содержащих заголовок action, кроме поддержки сообщений для служб приложений.
Последний факт важен, поскольку если для служб приложений активированы безопасные или надежные сеансы, до отправки сообщений службы приложений осуществляется отправка дополнительных сообщений для установления этих сеансов. Эти сообщения используют заголовок Action для соответствующих протоколов и совершенно не зависят ни от какой службы приложений. Это означает, что для перенаправления сообщений необходимо использовать некоторую альтернативу заголовку Action.
Перенаправление с использованием настраиваемых заголовков
Чтобы гарантировать, что каждое сообщение содержит заголовок маршрута, надлежащим образом указывающий службу приложений, с которой клиент пытается связаться, настраиваемый заголовок можно указать в разделе конфигурации конечной точки службы приложений, как показано ниже.
<service behaviorConfiguration="serviceBehavior"
name="MessageManager.ServiceA">
<endpoint address="http://localhost:8010/RouterService"
binding="wsHttpBinding" bindingConfiguration="wsHttp"
contract="IServiceA" listenUri="ServiceA">
<headers>
<Route
xmlns="http://www.thatindigogirl.com/samples/2008/01">
http://www.thatindigogirl.com/samples/2008/01/IServiceA
</Route>
</headers>
</endpoint>
</service>
Настраиваемые заголовки имеют имя, пространство имен и значение. В некоторых случаях заголовки являются более динамическими, но в данном случае заголовок является фиксированным для представления пространства имен контракта службы. Элемент Route указывает имя заголовка, а пространство имен указывается атрибутом xmlns. Поскольку заголовок указывается как часть конфигурации конечной точки, он входит в метаданные службы. Поэтому, когда клиенты генерируют прокси, они генерируют также конфигурацию клиента, в которую входит заголовок, как показано ниже.
<client>
<endpoint address="http://localhost:8010/RouterService"
binding="wsHttpBinding" bindingConfiguration="wsHttp"
contract="localhost.IServiceA" >
<headers>
<Route xmlns="http://www.thatindigogirl.com/samples/2008/01">
http://www.thatindigogirl.com/samples/2008/01/IServiceA
</Route>
</headers>
</endpoint>
</client>
Это делает прозрачным присутствие заголовка при кодировке клиента и обеспечивает включение заголовка во все сообщения, включая те, которые используются для установления безопасных и надежных сеансов. По имени заголовка и его пространству имен маршрутизатор может извлечь значение заголовка из любого сообщения следующим образом.
string contractNamespace =
requestMessage.Headers.GetHeader<string>(
"Route",
"http://www.thatindigogirl.com/samples/2008/01");
Единственное отличие этой реализации от предыдущего примера заключается в том, каким образом маршрутизатор обнаруживает пространство имен контракта — из настраиваемого заголовка Route, а не из заголовка Action. Это позволяет маршрутизатору перенаправлять сообщения, относящиеся к безопасным и надежным сеансам, к соответствующей конечной точке службы.
Регистрация служб
Вместо того, чтобы прописывать в коде конечные точки для служб приложений, маршрутизатор может предоставлять конечную точку службы для регистрации и отмены регистрации служб при их подключении к сети и переходе в автономный режим. В отсутствие программного или аппаратного балансировщика нагрузки это снижает дополнительную нагрузку на маршрутизатор в случае необходимости масштабирования служб приложения или при изменении имен машин или портов в соответствующих адресах конечных точек. Для поддержки этой модели следует выполнить следующие действия.
- Реализовать контракт регистрации службы для маршрутизатора и предоставить эту конечную точку служам приложения за брандмауэром.
- Поддерживать для маршрутизатора регистрационный список.
- После инициализации каждого ServiceHost обеспечить регистрацию им конечных точек служб посредством маршрутизатора.
- После сбоя или закрытия всех ServiceHost обеспечить посредством маршрутизатора отмену регистрации конечных точек служб.
Схема на рис. 2 демонстрирует процесс регистрации, в котором осуществляется добавление записей, обеспечивающих сопоставление пространства имен контракта физическому адресу конечной точки.
Рис. 2 Регистрация служб с использованием маршрутизатора
При этом подходе для регистрации требуется только пространство имен контракта и физический адрес всех конечных точек службы. На рис. 3 показан контракт службы IRegistrationService и соответствующие данные RegistrationInfo, передаваемые маршрутизатору для проведения регистрации и ее отмены.
Рис. 3 Контракт службы IRegistrationService с данными контракта
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01")]
public interface IRegistrationService {
[OperationContract]
void Register(RegistrationInfo regInfo);
[OperationContract]
void Unregister(RegistrationInfo regInfo);
}
[DataContract(Namespace =
"http://schemas.thatindigogirl.com/samples/2008/01")]
public class RegistrationInfo {
[DataMember(IsRequired = true, Order = 1)]
public string Address { get; set; }
[DataMember(IsRequired = true, Order = 2)]
public string ContractName { get; set; }
[DataMember(IsRequired = true, Order = 3)]
public string ContractNamespace { get; set; }
public override int GetHashCode() {
return this.Address.GetHashCode() +
this.ContractName.GetHashCode() +
this.ContractNamespace.GetHashCode();
}
}
Маршрутизатор может хранить по одной записи на контракт, но при этом не допускается более одной службы для каждого контракта. Для поддержки распределения по нескольким записям маршрутизатор должен использовать уникальный ключ для каждой регистрации. В следующем коде используется словарь, который каждой записи однозначно сопоставляет хэш-код экземпляра RegistrationInfo.
// registration list
static public IDictionary<int, RegistrationInfo>
RegistrationList =
new Dictionary<int, RegistrationInfo>();
// to register
if (!RouterService.RegistrationList.ContainsKey(
regInfo.GetHashCode())) {
RouterService.RegistrationList.Add(regInfo.GetHashCode(),
regInfo);
}
// to unregister
if (RouterService.RegistrationList.ContainsKey(
regInfo.GetHashCode())) {
RouterService.RegistrationList.Remove(
regInfo.GetHashCode());
}
Когда маршрутизатор получает сообщения, он должен собрать пространство имен контрактов и отыскать в словаре соответствующий подходящий элемент. Если таких элементов несколько, для перенаправления сообщения к соответствующей конечной точке службы должен использоваться критерий выбора (см. рис. 4).
Рис. 4 Сопоставление сообщений соответствующим конечным точкам
string contractNamespace =
requestMessage.Headers.Action.Substring(0,
requestMessage.Headers.Action.LastIndexOf("/"));
// get a list of all registered service entries for
// the specified contract
var results = from item in RouterService.RegistrationList
where item.Value.ContractNamespace.Contains(contractNamespace)
select item;
int index = 0;
// find the next address used ...
// create the channel
RegistrationInfo regInfo = results.ElementAt<KeyValuePair<int,
RegistrationInfo>>(index).Value;
Uri addressUri = new Uri(regInfo.Address);
Binding binding = ConfigurationUtility.GetRouterBinding (addressUri.Scheme);
EndpointAddress endpointAddress = new EndpointAddress(regInfo.Address);
ChannelFactory<IRouterService> factory = new
ChannelFactory<IRouterService>(binding, endpointAddress)
// forward message to the service ...
Помимо распределения по машинам потребностей служб в балансировке нагрузки, динамическая регистрация может пригодиться в тех ситуациях, когда несколько экземпляров службы могут размещаться на одной машине — для этого требуется несколько назначений портов, если они размещаются в службе Windows.
Чтобы обеспечить это, службы должны выбирать динамическое назначение порта для машины. Для служб TCP эту задачу можно выполнить, задавая в конфигурации конечной точки значение Unique для режима прослушивания URI.
<endpoint address="net.tcp://localhost:9000/ServiceA"
contract=" IServiceA" binding="netTcpBinding"
listenUriMode="Unique"/>
Однако, для именованных каналов и HTTP, это значение не обеспечивает выбор уникального порта. Вместо этого к адресу добавляется GUID.
net.tcp://localhost:64544/ServiceA
http://localhost:8000/ServiceA/66e9c367-b681-4e4f-8d12-80a631b7bc9b
net.pipe://localhost/ServiceA/6660c07e-c9f5-450b-8d40-693ad1a71c6e
Чтобы для конечных точек служб TCP и HTTP обеспечить уникальный порт, в коде можно инициализировать базовые адреса или явные адреса конечных точек.
Uri httpBase = new Uri(string.Format(
"http://localhost:{0}",
FindFreePort()));
Uri tcpBase = new Uri(string.Format(
"net.tcp://localhost:{0}",
FindFreePort()));
Uri netPipeBase = new Uri(string.Format(
"net.pipe://localhost/{0}",
Guid.NewGuid().ToString()));
ServiceHost host = new ServiceHost(typeof(ServiceA),
httpBase, tcpBase, netPipeBase);
На рис. 5 показано несколько служб, размещенных на одной машине, регистрирующихся на маршрутизаторе. Из этой схемы также видно, что для удаления одной точки сбоя маршрутизатора может потребоваться программный или физический балансировщик нагрузки, распределяющий регистрационные вызовы по экземплярам. Безусловно, это подразумевает хранение регистрационного списка в общей базе данных.
Рис. 5 Регистрация служб с динамическими портами посредством маршрутизатора с сбалансированной нагрузкой
Проверка сообщений
Хотя обычно маршрутизаторы перенаправляют исходное сообщение службам приложений, они могут выполнять действия на основе содержимого сообщения, например проверять заголовки или элементы тела в случае маршрутизации на основе содержимого, или отклонять сообщения на основе допустимости заголовков или элементов тела.
Проверка заголовков выполняется очень просто, поскольку тип Message предоставляет свойство Headers для непосредственного извлечения адресных заголовков и настраиваемых заголовков по их имени и пространству имен. Рассмотрим следующую операцию службы, использующую контракт сообщения для добавления настраиваемого заголовка LicenseKey для входящей операции.
// operation
[OperationContract]
SendMessageResponse SendMessage(SendMessageRequest message);
// message contract
[MessageContract]
public class SendMessageRequest {
[MessageHeader]
public string LicenseKey { get; set; }
[MessageBodyMember]
public string Message { get; set; }
}
Клиенты отправляют сообщения, содержащие заголовок LicenseKey, возможно пустой, если у них еще нет ключа лицензии. Маршрутизатор может извлечь этот заголовок следующим образом.
string licenseKey =
requestMessage.Headers.GetHeader<string>(
"LicenseKey",
"http://www.thatindigogirl.com/samples/2008/01");
Если это же значение LicenseKey требовалось бы передать в теле сообщения, для получения доступа к этому значению маршрутизатору необходимо было бы прочесть тело сообщения (поскольку к этой информации нет прямого доступа посредством типа Message). Метод GetReaderAtBodyContents возвращает XmlDictionaryReader, который можно использовать для чтения тела сообщения, следующим образом.
XmlDictionaryReader bodyReader =
requestMessage.GetReaderAtBodyContents();
Свойство State типа Message может быть одним из следующих значений перечисления MessageType: Created, Copied, Read, Written или Closed. Сообщение начинается в состоянии Created, и маршрутизаторы, принимающие параметры Message операций, не обрабатывают сообщения, поэтому сохраняется состояние Created.
Чтение тела сообщения вызывает переход сообщения запроса из соотояния Created в состояние Read. После прочтения сообщение невозможно перенаправить службам приложений, поскольку сообщение может быть только один раз прочитано, записано или скопировано.
Перед чтением сообщения реализация маршрутизатора на основе содержимого должна скопировать сообщение в буфер. С помощью этой буферизованной копии сообщения можно создавать новые копии исходного сообщения и использовать их для обработки следующим образом.
MessageBuffer messageBuffer =
requestMessage.CreateBufferedCopy(int.MaxValue);
Message messageCopy = messageBuffer.CreateMessage();
XmlDictionaryReader bodyReader =
messageCopy.GetReaderAtBodyContents();
XmlDocument doc = new XmlDocument();
doc.Load(bodyReader);
XmlNodeList elements = doc.GetElementsByTagName("LicenseKey");
string licenseKey = elements[0].InnerText;
Этот же буфер можно повторно использовать для создания сообщения для перенаправления к службам приложений. Вызов CreateMessage возвращает новый экземпляр Message, основанный на исходном сообщении.
Сеансы маршрутизаторов и транспортов
В случае транзитного маршрутизатора клиенты должны отправлять сообщения с помощью транспортного протокола и формата кодирования, ожидаемого маршрутизатором. Маршрутизатор должен перенаправлять сообщение службам приложения, используя транспотрный протокол и формат кодирования, ожидаемый службами. Все обсуждавшиеся до сих пор функции маршрутизации прекрасно работают, если на обе оконечности имеют тип HTTP — с сеансами или без сеансов. Однако, если вводится траспортный сеанс, например TCP, возникают некоторые интересные задачи. Все прекрасно в простейшем случае, когда режим безопасности отключен и нет надежных сеансов, но добавление этих функций привносит проблемы.
Как только для службы приложения включается режим безопасности, маршрутизатор должен предоставить подписанный заголовок To. Обычно это означает, что заголовок To остается нетронутым — в том виде, как он был отправлен клиентом. Но по умолчанию маршрутизатор изменяет заголовок To, чтобы он соответствовал адресу службы на момент отправки сообщений, если только не активирован режим адресации вручную. Если, например, для перенаправления сообщений службе маршрутизатор использует протокол TCP, адресация вручную не допускается, если канал выхода основан на контракте запрос-ответ.
Еще одна проблема возникает, если активированы надежные сеансы, и для вызова службы маршрутизатор использует протокол TCP. В этом случае асинхронные подтверждения отправляются обратно через маршрутизатор. Для этого требуется, чтобы маршрутизатор поддерживал сеанс со службой для получения этих асинхронных подтверждений. В результате клиент должен поддерживать дуплексный сеанс с маршрутизатором для получения этих же асинхронных подтверждений.
Обе проблемы можно разрешить частично посредством реализации маршрутизатора, поддерживающего сеансы и опирающегося на дуплексные входной и выходной каналы. Нет необходимости напрямую ставить об этом в известность ни вызывающий клиент, ни службу приложения — это реализуется в рамках маршрутизатора. Однако, существует зависимость от привязок, осведомленных о сеансе, и от дуплексной связи, если вводятся асинхронные подтверждения надежных сеансов.
Дуплексные маршрутизаторы
В коде на рис. 6 показан пример контракта дуплексного маршрутизатора для поддержки ситуации, когда сообщения между клиентом, маршрутизатором и службами приложений отправляются по протоколу TCP. У этого контракта следующие отличия от традиционного контракта маршрутизатора типа запрос-ответ.
- ProcessMessage теперь является односторонней операцией.
- Контракту службы требуются сеансы, и у него имеется соответствующий контракт обратного вызова. Важно обратить внимание на то, что при этом не требуется клиент для реализации обратного вызова; это внутренняя задача маршрутизатора.
- У контракта обратного вызова имеется единственный метод для получения ответов от обращений маршрутизатора к службам приложений. Обратите внимание, что службы не знают о том, что их ответы отправляются в канал обратного вызова; они могут быть сообщениями запрос-ответ.
Рис. 6 Контракт дуплексного маршрутизатора
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01",
SessionMode = SessionMode.Required,
CallbackContract = typeof(IDuplexRouterCallback))]
public interface IDuplexRouterService {
[OperationContract(IsOneWay=true, Action = "*")]
void ProcessMessage(Message requestMessage);
}
[ServiceContract(Namespace =
"http://www.thatindigogirl.com/samples/2008/01",
SessionMode = SessionMode.Allowed)]
public interface IDuplexRouterCallback {
[OperationContract(IsOneWay=true, Action = "*")]
void ProcessMessage(Message requestMessage);
}
Архитектура дуплексного маршрутизатора показана на рис. 7. Что касается клиента, отправляются запросы и ожидается синхронный ответ. Маршрутизатор получает запросы на одностороннюю операцию и сохраняет клиентский канал обратного вызова, чтобы отправить ответ. Тем временем маршрутизатор перенаправляет сообщения, используя дуплексный канал, и обеспечивает канал обратной связи для получения ответа от службы.
Рис. 7 Архитектура дуплексного маршрутизатора
Служба получает запрос и отправляет синхронный ответ, полученный посредством канала обратного вызова маршрутизатора. Этот канал обратного вызова в свою очередь использует клиентский канал обратного вызова для отправки ответа клиенту. От начала до конца операция выполняется синхронно, но маршрутизатор разъединяет действия и для коррелирования сообщений опирается на дуплексную связь в базовых каналах получения и отправки.
Реализация маршрутизатора для этого случая показана на рис. 8. Существует несколько соответствующих изменений в реализациях маршрутизатора типа запрос-ответ. Во-первых, маршрутизатор поддерживает сеансы и реализует дуплексный контракт. Когда маршрутизатор перенаправляет сообщения службам, дуплексный канал создается с помощью DuplexChannelFactory<T>, что означает предоставление объекта обратного вызова, который будет получать ответы от службы.
Рис. 8 Реализация дуплексного маршрутизатора
[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerSession,
ConcurrencyMode = ConcurrencyMode.Multiple,
AddressFilterMode=AddressFilterMode.Any,
ValidateMustUnderstand=false)]
public class DuplexRouterService : IDuplexRouterService, IDisposable {
object m_duplexSessionLock = new object();
IDuplexRouterService m_duplexSession;
public void ProcessMessage(Message requestMessage) {
lock (this.m_duplexSessionLock) {
if (this.m_duplexSession == null) {
IDuplexRouterCallback callback =
OperationContext.Current.GetCallbackChannel
<IDuplexRouterCallback>();
DuplexChannelFactory<IDuplexRouterService> factory =
new DuplexChannelFactory<IDuplexRouterService>
(new InstanceContext(null,
new DuplexRouterCallback(callback)), "serviceEndpoint");
factory.Endpoint.Behaviors.Add(new MustUnderstandBehavior(false));
this.m_duplexSession = factory.CreateChannel();
}
}
this.m_duplexSession.ProcessMessage(requestMessage);
}
public void Dispose() {
if (this.m_duplexSession != null) {
try {
ICommunicationObject obj = this.m_duplexSession as
ICommunicationObject;
if (obj.State == CommunicationState.Faulted)
obj.Abort();
else
obj.Close();
}
catch {}
}
}
}
public class DuplexRouterCallback : IDuplexRouterCallback {
private IDuplexRouterCallback m_clientCallback;
public DuplexRouterCallback(IDuplexRouterCallback clientCallback) {
m_clientCallback = clientCallback;
}
public void ProcessMessage(Message requestMessage) {
this.m_clientCallback.ProcessMessage(requestMessage);
}
}
Объект обратного вызова реализует контракт обратного вызова, и он будет получать ответы от служб. Этот объект обратного вызова должен использовать клиентский канал обратного вызова для отправки ответа клиенту.
Экземпляр службы маршрутизатора, ссылка на клиентский канал обратного вызова и канал обратного вызова маршрутизатора существуют в течение сеанса связи с клиентом. По этой причине, чтобы все это работало, маршрутизатор должен предоставить конечные точки, поддерживающие сеансы, а подчиненная служба должна поддерживать сеансы.
Сеансы с транспортами разных типов
В некоторых ситуациях может оказаться желательным, чтобы клиент отправлял сообщения маршрутизатору по протоколу HTTP, в то время как маршрутизатор перенаправляет эти сообщения службам приложений по протоколу TCP. Если активированы функции безопасности или надежных сеансов, для поддержки такой ситуации недостаточно даже конфигурации дуплексного маршрутизатора.
Как было упомянуто, адресация вручную поддерживается только каналами запрос-ответ. В противном случае модель службы опирается на функции адресации для коррелирования сообщений. Поскольку в TCP нет встроенной поддержки запроса-ответа, адресация вручную является возможным вариантом только в случае одностороннего контракта. Таким образом, канал отправки из рис. 7 должен создаваться из одностороннего контракта типа IDuplexRouterService. Канал обратного вызова предоставляется для получения ответа.
Канал обратного вызова маршрутизатора должен также сохранятся до отправки ответа, также должен сохраняться и клиентский канал обратного вызова. Для поддержки этой ситуации у клиента должен быть сеанс связи с маршрутизатором, а у маршрутизатора должен быть сеанс связи со службой.
В предположении, что службы приложений, вызываемые маршрутизатором, являются безопасными, для перенаправления неизмененных маршрутизатором сообщений вероятно понадобится адресация вручную. Если маршрутизатор вызывает службы приложений по протоколу TCP, для этого, как обсуждалось ранее, требуется реализация дуплексного маршрутизатора, чтобы исходящий вызов мог быть односторонним каналом. Это вынуждает клиента отправлять сообщения посредством привязки, осведомленной о сеансе, что подразумевает активирование безопасных сеансов или надежных сеансов по HTTP.
Если маршрутизатор является транзитным, вопрос сводится к предоставлению службе приложений возможности обрабатывать заголовки безопасности и заголовки надежных сеансов. Если маршрутизатору, чтобы поддерживать сеансы по HTTP, требуются безопасные или надежные сеансы для его клиентских конечных точек, маршрутизатор будет обрабатывать эти заголовки, и сеанс с службами приложений устанавливаться не будет.
Таким образом, протоколы разных типов работают только в ограниченном наборе случаев, если только в слое канала не осуществляется спуск на более низкий уровень для переопределения поведений по умолчанию. Если режим безопасности и надежные сеансы отключены, клиенты могут отправлять сообщения маршрутизатору по протоколу HTTP, в то время как маршрутизатор перенаправляет эти сообщения службам приложений по протоколу TCP. Если режим безопасности или надежные сеансы активированы, клиент должен отправлять сообщения маршрутизатору по протоколу TCP, не активируя надежные сеансы или безопасные сеансы для канала маршрутизатора.
Мишель Леру Бустамант (Michele Leroux Bustamante) – главный архитектор компании IDesign Inc., региональный директор Майкрософт в Сан-Диего и обладатель звания Microsoft MVP по Connected Systems. Ее последняя книга – Learning WCF («Изучаем WCF»).