Silverlight 中的同步 Web 服务调用:破除仅异步的神话






4.95/5 (45投票s)
在本文中,我们将探讨 Silverlight 中的异步 Web 服务模型,以及如何对其进行增强以支持同步 Web 服务调用。我们还将探讨高效的通道缓存和异步 Silverlight 单元测试。

目录
- 引言
- Silverlight WCF 回顾
- 将异步调用变为同步
- 初步假设
- UISynchronizationContext
- 高效地管理通道
- 具有已知服务契约方法签名的函数式编程
- Microsoft Silverlight 测试框架
- 结论
- 历史
引言
我喜欢创造和探索,而 Silverlight 提供了一个探索新环境的有趣机会;在这个环境中,有些事情,比如异步 Web 服务模型,与我们开发桌面 CLR 的习惯略有不同。所以你可能听说过 Silverlight 中不允许进行同步 Web 服务调用。
不再有同步 Web 请求,同步调用 Web 服务,哦天哪 Silverlight 异步太糟糕了,使用 Silverlight 2 消费服务,Silverlight 2 挂起...,异步调用服务
嗯,事实上,它们是可以的。在本文中,我将向你展示如何执行同步 Web 服务调用,并介绍一些使生成通道代理进行同步调用变得轻而易举的类型。我还会快速概述 Microsoft Silverlight 单元测试框架。
一些读者可能最初会反对说,同步 Web 服务调用在以 GUI 为主的应用程序中没有位置,因为它有阻塞 UI 线程的风险,导致 UI 无响应等。虽然我同意不阻塞 UI 线程对于保持应用程序的响应能力至关重要,但我确实认为在非 UI 线程上进行同步 Web 服务调用是合法的。你看,在我将最近的一些项目从 1.1 移植到 Silverlight 2 后,我发现异步模型在许多情况下不仅难以使用而且不必要,它还意味着必须重新思考架构以使其与异步兼容。对于使用后台处理的复杂场景,强制的异步模型是不优雅的。
本质上,如果一个人不在 UI 线程上工作,那么为什么不提供同步服务调用呢?当然,桌面 CLR 允许使用它们(想想 WPF XBAP),为什么 Silverlight CLR 不可以呢?在此 处 可以找到正反两方面论点的优秀摘要。
直到经过一些实验后,我才发现了本文将要与你分享的技术。
Silverlight WCF 回顾
直到最近,对于 Silverlight 1.1,同步 Web 服务调用确实得到了支持。Visual Studio 在通道代理和接口中生成了同步方法。但有一个问题:任何 Web 服务调用只能在 UI 线程上进行;否则会发生 `InvalidOperationException`。在 Silverlight 2 RTW 中,生成的代理不再包含同步消费服务的这些方法。不过现在我们可以从任何线程调用 Web 服务了。
那么这里发生了什么?嗯,Silverlight 2 Web 服务仍然具有与 UI 线程的线程关联性,只是现在有一些后台处理在进行。当发出 Web 服务调用时,它会被放入一个队列,由 UI 线程执行。
让我们看看 Silverlight 2 中通常如何进行 Web 服务调用。首先,我们通过创建服务引用来生成代理。然后,我们订阅 `[MethodName]Completed` 事件,如下面的摘录所示。
var client = new SimpleServiceClient();
client.GetGreetingCompleted += ((sender, e) => { DisplayGreeting(e.Result); });
client.GetGreetingAsync("SimpleServiceClient");
当调用 `client.GetGreetingAsync` 时,它会被排队等待 UI 线程处理。即使是从 UI 线程进行的调用,它仍然会被排队。一旦调用完成,GetGreetingCompleted 处理程序将在进行 GetGreetingAsync 调用的同一线程上被调用。
将异步调用变为同步
这都很好,但如果我们正在以一种分离和模块化的方式开发一组类库,其契约没有考虑到异步执行,并且期望返回值呢?这是一个很大的限制,它可能会迫使我们通过要求消费者知道他们不应该期望收到返回值来违反实现可见性,因为可能会发生 Web 服务调用。显然,能够执行同步服务调用是一件有用的事情。它们的省略 **不是** 为了防止浏览器锁定,而是根据 Peter Bromberg 的说法,是为了支持跨浏览器支持,因为 Silverlight 需要实现 NPAPI 插件模型。
初步假设
你可能像作者一样,假设你可以通过简单地使用例如 ManualResetEvent 来阻止直到获得结果来模拟同步 Web 服务调用。这种不正确的做法在下面的摘录中得到了展示。
(不要使用此代码)
var resetEvent = new ManualResetEvent(false);
var client = new SimpleServiceClient();
client.GetGreetingCompleted += ((sender, e) =>
{
DisplayGreeting(e.Result);
resetEvent.Set();
});
client.GetGreetingAsync("SimpleServiceClient");
resetEvent.WaitOne(); /* This will block indefinately. */
不幸的是,这没有用。原因是服务调用的启动必须在 UI 线程上执行。当我们调用 `resetEvent.WaitOne()` 时,我们阻塞了 UI 线程,这意味着服务调用从未发生。为什么?在后台,对 `client.GetGreetingAsync` 的调用实际上被放入了一个消息队列,并且只有在线程不执行用户代码时才会执行。如果我们阻塞了线程,服务调用就永远不会发生。
为了进一步说明这一点,让我们看看之前的例子,但稍作修改
var client = new SimpleServiceClient();
client.GetGreetingAsync("SimpleServiceClient");
/* Sleep for a moment to demonstrate that the call doesn't occur until later. */
Thread.Sleep(1000);
/* Subscribing to the event on the UI thread, after the service call, still works! */
client.GetGreetingCompleted += ((sender, e) => { DisplayGreeting(e.Result); });
请注意,对 GetGreetingCompleted 事件的订阅发生在实际的 GetGreetingAsync 调用之后。然而,结果与之前相同;延迟的订阅并没有阻止 GetGreetingCompleted 处理程序被调用。
这里发生的情况是 Web 服务调用被排队了。在 UI 线程有机会调用它之前,什么都不会发生。`Begin[Method]` Web 服务调用会立即返回,但底层的 WCF 调用直到稍后才会发生,如下面的图所示。
图:UI 消息队列
现在我们知道为什么阻塞 UI 线程不起作用了,而且它不起作用也挺好。
那么,答案是什么?嗯,我们不能从 UI 线程同步调用服务。但是,我们可以使用 `ChannelFactory` 从非 UI 线程以同步方式调用它,如下面的摘录所示。
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(
"Unable to communicate with server. {0} {1}",
ex.Message, ex.StackTrace));
}
DisplayGreeting(greeting);
});
`ChannelFactory` 创建一个动态代理,我们可以用它来等待我们的服务方法的执行结果。当我发现它在 UI 线程上无限期地阻塞时,我最初驳回了这个熟悉的模式。直到我在非 UI 线程上尝试它,才发生了我的顿悟时刻。
因此,我们看到必须小心处理,以免犯从 UI 线程尝试此技术的错误。如果发生这种情况,我们的 Silverlight 应用程序将会挂起。不幸的是,`System.ServiceModel.ChannelFactory` 不会检查它是否在 UI 线程上执行,所以我们需要自己想出一个体面的方法。
这时就有了 SynchronousChannelBroker。
SynchronousChannelBroker 是我编写的一个类,它将所有步骤联系起来,使执行同步 Web 服务调用变得容易且安全。
为了帮助适应动态服务通道,我创建了几个 `BeginAction` 委托,它们看起来很像这样
public delegate IAsyncResult BeginAction<TActionArgument1>( TActionArgument1 argument1, AsyncCallback asyncResult, object state);
与通用的 `Func` 类似,它们允许我们以一种相当简单的方式使用服务代理,而无需修改或替换 Visual Studio 的“添加服务引用”功能。
那么为什么我们不能构建一个“SynchronousChannelFactory”来使用服务契约中存在的方法签名呢?嗯,我们可以,但这将是一项相当大的任务。Visual Studio 的代码生成器会生成使用异步模式“`Begin[MethodName]`”、“`End[MethodName]`”的接口,所以如果我们想这样做,我们可能不得不抛弃很多基础设施。人们可以选择手动编写服务接口,但缺点可能是我们会引入更多需要维护的代码。无论如何,这超出了本文的范围。
除了提供执行同步 Web 服务调用的手段外,我还包含了一个 `ChannelManager`,用于高效缓存服务通道。下面的摘录展示了如何使用缓存的服务通道执行同步通道调用。
var simpleService = ChannelManager.Instance.GetChannel<ISimpleService>(); string result = string.Empty; try { /* Perform synchronous WCF call. */ result = SynchronousChannelBroker.PerformAction<string, string>( simpleService.BeginGetGreeting, simpleService.EndGetGreeting, "there"); } catch (Exception ex) { DisplayMessage(string.Format("Unable to communicate with server. {0} {1}", ex.Message, ex.StackTrace)); } DisplayGreeting(result);
当然,这一切都是强类型化的,并且泛型类型必须与服务接口方法中的参数类型匹配。
在上面的示例中,我们从 `ChannelManager` 中检索 `ISimpleService` 服务通道。如果这是第一次检索此服务类型,则会创建一个新通道并进行缓存,否则将返回一个缓存的通道。检索到通道后,我们使用 `SynchronousChannelBroker` 调用 `Begin` 和 `End` 方法并返回结果。下图概述了内部发生的情况。
图:同步 Web 服务调用流程图
`SynchronousChannelBroker` 的 `PerformAction` 有各种重载,以匹配服务接口的方法签名。在前面的示例中,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; }
我们看到,为了防止服务调用发生在 UI 线程上,我们调用了 `EnsureUIThread()`。此方法会抛出异常,如果你猜对了,如果当前线程是 UI 线程。为了做到这一点,它使用了 `UISynchronizationContext`。
下图显示了三个主要类。
图:主要项目类的类图。
UISynchronizationContext
包含的 `UISynchronizationContext` 使用应用程序的 RootVisual(通常是 Page)来获取 UI 线程的 `Dispatcher`。从 `Dispatcher`,我们可以创建一个 `DispatcherSynchronizationContext`,它允许我们在 UI 线程上执行同步和异步操作。
有时我们可能希望在 RootVisual 尚未分配之前使用 `UISynchronizationContext`。为此,我们可以使用各种 `Initialize` 重载来使用 UI Dispatcher 分配上下文。可以在 SilverlightExamples 项目的 `Page` 类构造函数中看到一个示例。
UISynchronizationContext.Instance.Initialize(Dispatcher);
`UISynchronizationContext` 利用一个使用 `Dispatcher`初始化的 `DispatcherSynchronizationContext`,它允许我们在 UI 线程上同步调用方法。请注意,`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)) { /* We don't cache the factory as it contains a list of channels * that aren't removed if a fault occurs. */ var channelFactory = new ChannelFactory<TChannel>("*"); service = channelFactory.CreateChannel(); var communicationObject = (ICommunicationObject)service; communicationObject.Faulted += OnChannelFaulted; channels.Add(serviceType, service); communicationObject.Open(); /* Explicit opening of the channel * avoids a performance hit. */ ConnectIfClientService(service, serviceType); } } return (TChannel)service; }
当通道发生故障时,我们将其从缓存中移除,如下面的摘录所示
/// <summary>
/// Called when a channel faults.
/// Removes the channel from the cache so that it is
/// replaced when it is next required.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="System.EventArgs"/>
/// instance containing the event data.</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;
/* Remove all items matching the channel.
* This is somewhat defensive as there should only be one instance
* of the channel in the channel dictionary. */
foreach (var key in keys.ToList())
{
channels.Remove(key);
}
}
}
具有已知服务契约方法签名的函数式编程
如果服务通道包含 `BeginInitiateConnection` 和 `EndInitiateConnection` 方法签名,它们将在通道首次创建时自动调用。通过在创建通道时启动连接,我们可以提前失败。
假设我们主要会使用生成的代理,我想提供一种在创建通道时调用方法来启动服务通道的方法。为此,我选择使用反射来查找名为 `InitiateConnection` 的方法。生成的代理当然会有 `BeginInitiateConnection` 和 `EndInitiateConnection`,当通道创建时,我们调用这些方法,如下面的摘录所示。
/// Attempts to excercise the <code>IServiceContract.InitiateConnection</code> method
/// on the specified service if that service is an <code>IServiceContract</code>.
/// That is, if the service has a method with the signature InitiateConnection(string),
/// it will be invoked in a functional manner.
/// </summary>
/// <param name="service">The service to attempt a connection.</param>
/// <param name="serviceType">Type of the service for logging purposes.</param>
/// <exception cref="TargetInvocationException">Occurs if the service implements <code>IServiceContract</code>,
/// and the call to ConnectFromClient results in a <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);
/* TODO: Do something with the result such as log the it somewhere. */
}
catch (InvalidCastException)
{
/* TODO: log that web server has invalid ConnectFromClient signature. */
}
}), null });
}
Microsoft Silverlight 测试框架
随附的是我对 Microsoft Silverlight Unit Testing Framework 的经验的简要总结。我对该框架的质量感到满意,Silverlight 开发者们也如他们所说,在这一点上“吃了自己的狗粮”。
我们不会过多地深入探讨单元测试框架的使用方法,但考虑到它与异步单元测试直接相关,我认为简要讨论如何设置它以及如何进行异步单元测试是值得的。
Microsoft Silverlight Unit Testing Framework 入门
该框架以 Visual Studio 模板的形式提供。我必须承认,我在安装模板时有点懒惰,而是选择创建一个新的 Silverlight 应用程序,引用下面的程序集,并修改 App.xaml 中的 Application_Startup 方法。
图: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 团队在同步 Web 服务调用方面采取了正确的方法。虽然同步 Web 服务调用并非被禁止,但如果从 UI 线程执行,它们会导致应用程序无响应。因此,我们已经看到缺少一些应该存在的架构来阻止我们犯错。然而,我们通过随附的下载在解决问题方面取得了一些进展。
在某些方面,我们获得了两全其美的效果:禁止阻塞 UI 线程,同时我们仍然可以在使用后台处理时回退到我们熟悉的同步模型。
我希望看到包含一种标准的机制来从非 UI 线程执行同步 Web 服务调用,因为它们有合法的用例。在此之前,请随时使用本文提供的库。
我希望您觉得这个项目有用。如果有用,我将不胜感激您能对其进行评分和/或在下方留下反馈。这将帮助我写出更好的下一篇文章。
历史
2008 年 11 月
- 初始发布。