Синхронные вызовы веб-службы в Silverlight: Развенчание мифа об исключительной асинхронности

ОГЛАВЛЕНИЕ

В данной статье показывается, как выполнять синхронные вызовы веб-служб, и вводятся некоторые типы, облегчающие выполнение синхронных вызовов с помощью генерируемых посредников канала. Также дается беглый обзор среды блочного тестирования Microsoft Silverlight.

•    Скачать SilverlightCore_01_00 - 169.74 KB

Введение

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

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

Вначале некоторые читатели могут возразить, что синхронные вызовы веб-службы неуместны в приложении с графическим пользовательским интерфейсом, потому что они рискуют заблокировать поток пользовательского интерфейса, что приводит к неотвечающему пользовательскому интерфейсу, и т.д. Несмотря на то, что крайне важно не блокировать поток пользовательского интерфейса, чтобы сохранить отзывчивость приложения, есть оправданные доводы в пользу синхронных вызовов веб-службы в потоках, не относящихся к пользовательскому интерфейсу. После переноса проектов из 1.1 в Silverlight 2 оказалось, что не только асинхронная модель была трудной и лишней во многих ситуациях, но и пришлось пересмотреть архитектуру, чтобы она стала совместима с асинхронностью. Для сложных сценариев, где применяется фоновая обработка, обязательная асинхронная модель неизящна.

Почему бы не воспользоваться синхронными вызовами служб при работе не в потоке пользовательского интерфейса? Безусловно, настольная CLR позволяет их применять (WPF XBAP), почему не должна позволять Silverlight CLR?

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


Резюме Silverlight WCF

Еще недавно в Silverlight 1.1 синхронные вызовы веб-служб действительно поддерживались. Visual Studio генерировал синхронные методы в посредниках канала и интерфейсах. Но был один подвох: любой вызов веб-службы мог происходить только в потоке пользовательского интерфейса; иначе следовало InvalidOperationException. В Silverlight 2 RTW генерируемые посредники больше не содержат методы для синхронного использования служб. Однако сейчас можно вызвать веб-службу из любого потока.

И что происходит здесь? Веб-службы Silverlight 2 все еще имеют привязку к потоку пользовательского интерфейса, просто сейчас происходит внутренняя обработка. Когда делается вызов веб-службы, он помещается в очередь на выполнение потоком пользовательского интерфейса.

Рассмотрим, как делаются вызовы веб-службы в Silverlight 2. Сначала генерируется посредник путем создания ссылки на службу. Затем производится подписка на событие [MethodName]Completed, как показано в следующем фрагменте.

var client = new SimpleServiceClient();
client.GetGreetingCompleted += ((sender, e) => { DisplayGreeting(e.Result); });
client.GetGreetingAsync("SimpleServiceClient");

Когда делается вызов client.GetGreetingAsync, он ставится в очередь для потока пользовательского интерфейса. Даже если вызов делается из потока пользовательского интерфейса, он все же ставится в очередь. При завершении вызова вызывается обработчик GetGreetingCompleted в том же потоке, в котором был сделан вызов GetGreetingAsync.

Превращение асинхронных вызовов в синхронные

Все это прекрасно, но что если разрабатывается набор несовместимых и модульных библиотек классов с контрактами, написанными без учета асинхронного выполнения, и где ожидаются возвращаемые значения? Это серьезное ограничение может заставить нарушить видимость реализации путем требования, чтобы потребители знали, что они не должны ожидать получения возвращаемого значения, потому что может произойти вызов веб-службы. Возможность явно выполнять синхронные вызовы службы полезна. Ее убрали не для предотвращения блокировок браузера, а для обеспечения кроссбраузерной поддержки, поскольку Silverlight должен реализовывать модель плагина NPAPI(интерфейс прикладного программирования плагинов Netscape).

Первые предположения

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

(Не используйте этот код!)

var resetEvent = new ManualResetEvent(false);
var client = new SimpleServiceClient();
client.GetGreetingCompleted += ((sender, e) =>                                               
{
         DisplayGreeting(e.Result);
        resetEvent.Set();
         });
client.GetGreetingAsync("SimpleServiceClient");
resetEvent.WaitOne(); /* Это будет блокировать бессрочно. */

К несчастью, это ничего не дает. Причина состоит в том, что вызов службы должен инициироваться в потоке пользовательского интерфейса. При вызове resetEvent.WaitOne() блокируется поток пользовательского интерфейса, а значит, вызова службы вообще не происходит. Почему? В фоновом режиме вызов client.GetGreetingAsync ставится в очередь сообщений и будет выполнен, только когда поток не выполняет код пользователя. При блокировке потока вызова службы вообще не происходит.

Для дальнейшей демонстрации этого момента был слегка изменен предыдущий пример:

var client = new SimpleServiceClient();
client.GetGreetingAsync("SimpleServiceClient");
/* Ждать секунду, чтобы показать, что вызов не происходит потом. */
Thread.Sleep(1000);
/* Подписка на событие в потоке пользовательского интерфейса после вызова службы все еще работает! */
client.GetGreetingCompleted += ((sender, e) => { DisplayGreeting(e.Result); });

Заметьте, что подписка на событие GetGreetingCompleted происходит после фактического вызова GetGreetingAsync. Однако результат совпадает с предыдущим; запоздалая подписка не препятствует вызову обработчика GetGreetingCompleted.

Здесь вызов веб-службы ставится в очередь. Ничего не происходит, пока поток пользовательского интерфейса не получит возможность вызвать его. Вызов веб-службы Begin[Method] сразу же возвращает управление, но нижележащий вызов WCF не происходит потом, как показано на следующей схеме.

Рисунок: Очередь сообщений пользовательского интерфейса

Теперь ясно, почему блокировка потока пользовательского интерфейса не работает, и это хорошо.

Итак, каково решение? Нельзя вызвать службу синхронно из потока пользовательского интерфейса. Но можно вызвать службу синхронно из потока, не относящегося к пользовательскому интерфейсу, с помощью ChannelFactory, как показывает следующий фрагмент.

ThreadPool.QueueUserWorkItem(delegate
{
    var channelFactory = new ChannelFactory<ISimpleService>("*");
    var simpleService = channelFactory.CreateChannel();
    var asyncResult = simpleService.BeginGetGreeting("Daniel", null, null);
    string greeting = null;
    try
    {
        greeting = simpleService.EndGetGreeting(asyncResult);
    }
    catch (Exception ex)
    {
        DisplayMessage(string.Format(
    "Не могу соединиться с сервером. {0} {1}",
            ex.Message, ex.StackTrace));
    }
    DisplayGreeting(greeting);
});

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

Не используйте данный прием в потоке пользовательского интерфейса, иначе приложение Silverlight зависнет. К несчастью, System.ServiceModel.ChannelFactory не проверяет, выполняется ли он в потоке пользовательского интерфейса, поэтому приходится придумывать средство самостоятельно.

Вводится SynchronousChannelBroker.

SynchronousChannelBroker – написанный класс, соединяющий все шаги и делающий выполнение синхронных вызовов веб-службы простым и безопасным.
Для облегчения настройки динамических каналов службы было создано несколько делегатов BeginAction следующего вида:

public delegate IAsyncResult BeginAction<TActionArgument1>(
TActionArgument1 argument1, AsyncCallback asyncResult, object state);

Сродни обобщенному Funcs, они позволяет легко использовать посредник службы, без необходимости дорабатывать или заменять функциональность добавления ссылки на службу Visual Studio.

Почему нельзя создать 'SynchronousChannelFactory', использующий сигнатуры методов, имеющиеся в контракте службы? Можно, но это была бы трудная задача. Генератор кода Visual Studio вырабатывает интерфейсы, использующие асинхронный шаблон "Begin[MethodName]", "End[MethodName]", поэтому, чтобы сделать это, пришлось бы выкинуть много инфраструктуры. Можно написать интерфейсы службы вручную, но недостаток в том, что это введет еще больше кода для обслуживания. Данный сценарий не входит в рамки статьи.

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

var simpleService = ChannelManager.Instance.GetChannel<ISimpleService>();
string result = string.Empty;
try
{
    /* Выполняется синхронный вызов WCF. */
    result = SynchronousChannelBroker.PerformAction<string, string>(
        simpleService.BeginGetGreeting, simpleService.EndGetGreeting, "there");
}
catch (Exception ex)
{
    DisplayMessage(string.Format("Не могу соединиться с сервером. {0} {1}",
        ex.Message, ex.StackTrace));
}
DisplayGreeting(result);

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

В примере выше канал службы ISimpleService возвращается из ChannelManager. Если этот тип службы возвращен впервые, то новый канал будет создан и закеширован, иначе будет возвращен кешированный канал. После возврата канала SynchronousChannelBroker используется для вызова методов Begin и End и для возврата результата. Следующая схема дает общее представление о том, что происходит внутри.

Рисунок: Блок-схема синхронного вызова веб-службы

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

public static TReturn PerformAction<TReturn, TActionArgument1>(
    BeginAction<TActionArgument1> beginAction,
    EndAction<TReturn> endAction, TActionArgument1 argument1)
{
    EnsureNonUIThread();
    var beginResult = beginAction(argument1, null, null);
    var result = endAction(beginResult);
    return result;
}

Чтобы не дать вызову службу произойти в потоке пользовательского интерфейса, вызывается EnsureUIThread(). Этот метод генерирует исключение, если текущий поток является потоком пользовательского интерфейса. Для этого он использует UISynchronizationContext.

Три главных нужных класса показаны на следующей схеме.

Рисунок: Диаграмма классов для главных классов проекта.

UISynchronizationContext

Включенный UISynchronizationContext использует RootVisual приложений (обычно Page(страница)) для получения Dispatcher(диспетчер) потока пользовательского интерфейса. Из Dispatcher можно создать DispatcherSynchronizationContext, позволяющий выполнять синхронные действия в потоке пользовательского интерфейса, а также асинхронные действия.

Иногда надо использовать UISynchronizationContext раньше назначения визуального корня. Для этого можно использовать различные перегрузки Initialize, чтобы назначить контекст диспетчеру пользовательского интерфейса. Пример этого показан в конструкторе класса Page в проекте SilverlightExamples.

UISynchronizationContext.Instance.Initialize(Dispatcher);

UISynchronizationContext использует DispatcherSynchronizationContext, инициализированный с помощью Dispatcher, и позволяет синхронно вызывать методы в потоке пользовательского интерфейса. Заметьте, что Dispatcher позволяет только вызывать асинхронно с помощью BeginInvoke.


Эффективное управление каналами

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

•    Согласование безопасности производится лишь однажды.
•    Не надо явно закрывать канал при каждом использовании.
•    Можно добавить дополнительную функцию инициализации.
•    Можно рано прервать работу, если посредник не может подключиться к серверу.

Кеширование канала производится в ChannelManager следующим образом:

readonly Dictionary<Type, object> channels = new Dictionary<Type, object>();
readonly object channelsLock = new object();

public TChannel GetChannel<TChannel>()
{
    Type serviceType = typeof(TChannel);
    object service;

    lock (channelsLock)
    {
        if (!channels.TryGetValue(serviceType, out service))
        {
            /* Фабрика не кешируется, так как она содержит список каналов,
             * которые не удаляются при наступлении ошибки. */
            var channelFactory = new ChannelFactory<TChannel>("*");

            service = channelFactory.CreateChannel();
            var communicationObject = (ICommunicationObject)service;
            communicationObject.Faulted += OnChannelFaulted;
            channels.Add(serviceType, service);
            communicationObject.Open(); /* Явное открытие канала
                         * предотвращает снижение производительности.  */
            ConnectIfClientService(service, serviceType);
        }
    }

    return (TChannel)service;
}

Если в канале происходит ошибка, он удаляется из кеша, как показано в следующем фрагменте:

/// <summary>
/// Вызывается при ошибке в канале.
/// Удаляет канал из кеша, чтобы он
/// заменился, когда потребуется в следующий раз.
/// </summary>
/// <param name="sender">Отправитель.</param>
/// <param name="e"> <see cref="System.EventArgs"/>
/// экземпляр, содержащий данные о событии</param>
void OnChannelFaulted(object sender, EventArgs e)
{
    var communicationObject = (ICommunicationObject)sender;
    communicationObject.Faulted -= OnChannelFaulted;

    lock (channelsLock)
    {
        var keys = from pair in channels
                   where pair.Value == communicationObject
                   select pair.Key;

        /* Удаляются все элементы, соответствующие каналу.
         * Это для защиты, так как должен быть только один экземпляр
         * канала в словаре каналов. */
        foreach (var key in keys.ToList())
        {
            channels.Remove(key);
        }
    }
}

Функциональное программирование с сигнатурами методов известного контракта службы

Если канал службы содержит сигнатуры методов BeginInitiateConnection и EndInitiateConnection, они будут вызваны автоматически при первом создании канала. Инициация соединения при создании канала позволяет рано прервать работу.
При условии, что в основном используются генерируемые посредники, был предоставлен способ вызова метода для запуска канала службы при создании канала. Чтобы сделать это, было решено использовать отражение для поиска метода по имени InitiateConnection. Конечно, генерируемый посредник будет иметь BeginInitiateConnection и EndInitiateConnection, и при создании канала вызываются эти методы, как показано в следующем фрагменте.

/// Пытается выполнить метод <code>IServiceContract.InitiateConnection</code>
/// на заданной службе, если эта служба - <code>IServiceContract</code>.
/// То есть, если служба имеет метод с сигнатурой InitiateConnection(string),
/// он будет вызван функционально.
/// </summary>
/// <param name="service">Служба для попытки осуществления соединения.</param>
/// <param name="serviceType">Тип службы для целей журналирования.</param>
/// <exception cref="TargetInvocationException">Возникает, если служба реализует <code>IServiceContract</code>,
/// и вызов ConnectFromClient приводит к <code>TargetInvocationException</code></exception>
void ConnectIfClientService(object service, Type serviceType)
{
    var beginMethodInfo = serviceType.GetMethod("BeginInitiateConnection");
    if (beginMethodInfo == null)
    {
        return;
    }

    beginMethodInfo.Invoke(service, new object[] { ChannelIdentifier, new AsyncCallback(ar =>
           {
            var endMethodInfo = serviceType.GetMethod("EndInitiateConnection");
            if (endMethodInfo == null)
            {
                return;
            }
            try
            {
                var result = (string)endMethodInfo.Invoke(service, new object[] {ar});
                Debug.WriteLine("Connected from client successfully. Result: " + result);
                /* Делать что-то с результатом, например, записать его где-то. */
            }
            catch (InvalidCastException)
            {
                /* Записать, что веб-сервер имеет неверную сигнатуру ConnectFromClient. */
            }
           }), null });
}

Среда тестирования Microsoft Silverlight

Здесь дано краткое описание опыта использования среды блочного тестирования Microsoft Silverlight. Эта среда качественная, и разработчики Silverlight сами используют ее.
Использование среды блочного тестирования подробно не разбирается, но кратко рассмотрено, как ее настроить и выполнять с ее помощью асинхронные блочные тесты, так как это имеет важное значение.

Начало работы со средой блочного тестирования Microsoft Silverlight

Среда имеет вид нескольких шаблонов Visual Studio. Вместо установки шаблонов можно создать новое приложение Silverlight, сослаться на показанные ниже сборки и внести изменения в метод Application_Startup в App.xaml.

Рисунок: Импорты среды тестирования Silverlight.

void Application_Startup(object sender, StartupEventArgs e)
{
    this.RootVisual = UnitTestSystem.CreateTestPage();
}

Проделанные действия привели к успеху.

Асинхронные тесты

Как оказалось, легко писать блочные тесты для вызываемых асинхронно методов с помощью инструментов тестирования Silverlight. Для выполнения асинхронного теста применяется Microsoft.Silverlight.Testing.AsynchronousAttribute, и при завершении теста вызывается метод TestComplete. TestComplete является методом Microsoft.Silverlight.Testing.SilverlightTest, поэтому надо расширить данный класс, чтобы использовать его.

[TestMethod]
[Asynchronous]
public void InvokeSynchronouslyShouldPerformActionSynchronouslyFromNonUIThread()
{
    ThreadPool.QueueUserWorkItem(delegate
         {
            CallInvokeSynchronouslyWithAction();
        TestComplete();
         });
}

При запуске проекта теста результаты видны в окне браузера.

Рисунок: Вид в браузере прогона теста Silverlight

Заключение

По какой-то причине команда Silverlight проявила правильный подход с синхронными вызовами веб-службы. Несмотря на то, что синхронные вызовы веб-службы не запрещены, при выполнении из потока пользовательского интерфейса они подвешивают приложение. Таким образом, недостает инфраструктуры, не дающей совершать ошибки. Однако удалось решить проблему в приложенных файлах для загрузки.

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

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