在 .NET 设计模式中异步访问 Web 服务






4.92/5 (23投票s)
讨论 .NET 1 的 Begin/End 模式和 .NET 2 的事件驱动模型实现。
引言
在实际应用中,客户端软件通常异步地与 Web 服务通信。异步调用会立即返回,并在处理完成后单独接收结果。这可以避免网络延迟冻结应用程序 UI 或阻塞其他进程。通过异步机制,如果 Web 服务调用时间较长或卡住,应用程序可以为用户提供取消挂起请求的选项。
要访问 Web 服务,您可以使用 .NET Framework 中的 WSDL 工具生成代理类,或在 Visual Studio 中添加 Web 引用。代理以同步和异步函数的形式封装了 Web 服务公开的所有公共方法。有关详细信息,请参阅 MSDN 文档。一篇相关的有益文章是 Matt Powell 的 “使用 .NET Framework 进行 HTTP 上的异步 Web 服务调用”。
异步实现主要依赖于生成的代理类。.NET Framework 在代理中提供了两种异步构造。一种是 .NET 1.0 的 Begin/End 设计模式,另一种是 .NET 2.0 中可用的事件驱动模型。在本文中,我将演示这两种实现并讨论一些有趣且未公开的问题。示例代码包含一个简单的 Web 服务、一个用 VS 2003 为 .NET 1.1 构建的客户端,以及一个用 VS 2005 为 .NET 2.0 构建的另一个客户端。
测试 Web 服务
由于 Web 服务是平台通用的,因此无论其来源或版本如何,您都可以使用它。这里,我创建了一个在 .NET 2.0 中编写的测试服务,由两个客户端使用。下面的清单 1 中的 *Service.cs* 显示了该服务,它只有一个方法 `GetStock()`,该方法接受一个符号并返回其报价。
// Listing-1. A test web service in Service.cs
using System;
using System.Web.Services;
using System.Threading;
[WebService(Namespace = "http://tempuri.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
public class Service : System.Web.Services.WebService
{
Random _delayRandom = new Random();
Random _stockRandom = new Random();
[WebMethod]
public double GetStock(string symbol, int timeout)
{
int delay = _delayRandom.Next(50, 5000);
Thread.Sleep(delay > timeout ? timeout : delay);
if (delay > timeout) return -1;
double value;
switch (symbol)
{
case "MSFT": value = 27; break;
case "ELNK": value = 11; break;
case "GOOG": value = 350; break;
case "SUNW": value = 6; break;
case "IBM": value = 81; break;
default: value = 0; break;
}
return value + value * 0.1 * _stockRandom.NextDouble();
}
}
我使用两个 `Random` 对象来模拟服务操作。`_delayRandom` 用于模拟 50 到 5000 毫秒的在线流量,而 `_stockRandom` 用于模拟股价波动。该服务允许客户端设置 `GetStock()` 的第二个参数 `timeout`。因此,除了返回正常报价外,`GetStock()` 还会为未识别的符号返回零,为超时标志返回负一。
为了简单起见,我将服务托管在 VS 2005 测试服务器下,如下所示
要自己搭建,请调用 *WebDev.WebServer.exe* 并指定您的物理路径选项,其中包含 *Service.asmx*,例如(参考演示中的 *startWsTest.bat*):
C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\WebDev.WebServer.EXE
/port:1111
/path:"c:\Articles\CallWsAsync\code\WebService2"
/vpath:"/callwsasync"
现在,我希望创建客户端来消费这个 Web 服务,在以下对话框中单击“获取报价”按钮时,进行五种场景的测试:
Begin/End 设计模式
要在 .NET 1.1 中生成代理,您可以使用 VS 2003 中的“添加 Web 引用”命令,将 URL 指向虚拟路径,例如:*https://:1111/callwsasync/*,然后选择 *Service.asmx*。代理包含 `BeginGetStock()` 和 `EndGetStock()`。我们将其命名为 `WsClient1.WsTestRef1.Service`,并定义一个 `_wsProxy1` 类型的对象。下面的清单 2 显示了第一个客户端如何工作:
// Listing-2. Using Begin/End pattern with callback
private void buttonGetQuote_Click(object sender, System.EventArgs e)
{
textBoxResult.Text = "";
_tmStart = DateTime.Now.Ticks;
string symbol = comboBoxSymb.Text.ToUpper();
int timeout = int.Parse(textBoxTimeout.Text);
_wsProxy1 = new WsClient1.WsTestRef1.Service();
_wsProxy1.BeginGetStock(symbol, timeout,
new AsyncCallback(OnGetStock), symbol);
}
private void OnGetStock(IAsyncResult ar)
{
string symbol = (string)ar.AsyncState;
string result;
try
{
double value = _wsProxy1.EndGetStock(ar);
if (value ==0)
result = "Invalid, " + "'" +symbol +"'";
else
if (value <0)
result = "TimeOut, " + "[" +symbol +"]";
else
result = "OK, " + value.ToString("F");
}
catch (WebException e)
{
if (e.Status == WebExceptionStatus.RequestCanceled)
result = "Cancelled, " + "<" +symbol +">";
else
result = "Exception, " + e.Message;
}
catch (Exception e)
{
result = "Exception, " + e.Message;
}
textBoxResult.Invoke(new ShowResultDelegate(ShowResult),
new object[] {result});
_wsProxy1 = null;
}
private void buttonCancel_Click(object sender, System.EventArgs e)
{
if (_wsProxy1 != null)
_wsProxy1.Abort();
}
private delegate void ShowResultDelegate(string str);
private void ShowResult(string str)
{
textBoxResult.Text = str + ", (" +
(DateTime.Now.Ticks - _tmStart) / 10000 + " ms)";
}
在 `buttonGetQuote_Click()` 中,我从对话框中获取符号和超时,并将它们传递给 `_wsProxy1.BeginGetStock()` 以触发异步请求。`BeginGetStock()` 的第三个参数初始化了一个回调 `OnGetStock()`。我将最后一个参数 `AsyncState` 设为符号名称,以便稍后在回调中检索作为指示符。一旦调用 `BeginGetStock()`,您就可以通过在 `buttonCancel_Click()` 中调用 `_wsProxy1.Abort()` 来取消请求。
查看 `OnGetStock()`。在 `try
` 块中,我首先调用 `EndGetStock()` 来获取结果。请记住,零表示符号未识别,负数表示超时,其他表示正常报价。
请注意,取消会在 `WebException` 中捕获。如果您调用代理的 `Abort()`,请求将被终止并引发 Web 异常。回调仍然会被调用,并且 `WebException` 会从 `EndGetStock()` 中抛出。您可以通过检查 `RequestCanceled` 状态来检测这一点,以区分其他 Web 异常,如服务器/网络中断。
您必须认识到,此异步回调隐式运行在 .NET 线程池管理的另一个线程中。回调可能不在调用 `BeginGetStock()` 的线程上下文中。当您尝试向窗体的控件发送命令或访问类中定义的实例对象时,请务必注意。这就是为什么调用 `textBoxResult.Invoke()` 而不是直接设置 `textBoxResult.Text` 的原因。
事件驱动代理模型
您可能会发现,在 VS 2005 中执行相同操作来获取 .NET 2.0 代理会有点复杂。将此代理命名为 `WsClient2.WsTestRef2.Service`,并将其对象定义为 `_wsProxy2`。与 .NET 1.1 中的 `BeginGetStock()` 类似,此代理提供了 `GetStockAsync()` 作为启动器。但您应该添加一个事件处理程序,它就像以前的回调一样。下面的清单 3 显示了第二个客户端代码:
// Listing-3. Using event-driven model
private void buttonGetQuote_Click(object sender, EventArgs e)
{
textBoxResult.Text = "";
_tmStart = DateTime.Now.Ticks;
string symbol = comboBoxSymb.Text.ToUpper();
int timeout = int.Parse(textBoxTimeout.Text);
_wsProxy2 = new WsClient2.WsTestRef2.Service();
_wsProxy2.GetStockCompleted += new
GetStockCompletedEventHandler(OnGetStockCompleted);
_wsProxy2.GetStockAsync(symbol, timeout, symbol);
}
private void OnGetStockCompleted(Object sender,
GetStockCompletedEventArgs gca)
{
string symbol = (string)gca.UserState;
string result;
if (gca.Cancelled) // Call CancelAsync
result = "Cancelled2, " + gca.UserState;
else
if (gca.Error != null)
{
WebException webEx = gca.Error as WebException;
if (webEx !=null && webEx.Status ==
WebExceptionStatus.RequestCanceled)
result = "Cancelled, " + "<" + symbol + ">";
else
result = "Exception, " + gca.Error.Message;
}
else
if (gca.Result == 0)
result = "Invalid, " + "'" + symbol + "'";
else
if (gca.Result < 0)
result = "TimeOut, " + "[" + symbol + "]";
else
result = "OK, " + gca.Result.ToString("F");
textBoxResult.Text = result + ", (" +
(DateTime.Now.Ticks - _tmStart) / 10000 + " ms)";
_wsProxy2 = null;
}
private void buttonCancel_Click(object sender, EventArgs e)
{
if (_wsProxy2 != null)
{
//_wsProxy2.CancelAsync("<<" +
// comboBoxSymb.Text.ToUpper() + ">>");
_wsProxy2.Abort();
}
}
在 `buttonGetQuote_Click()` 中,我将 `OnGetStockCompleted()` 事件处理程序添加到 `_wsProxy2`,并调用 `GetStockAsync()` 来启动异步请求。同样,前两个参数是符号和超时,第三个参数 `UserState`(类似于 `BeginGetStock()` 中的 `AsyncState`)将在处理程序中稍后检索。您也可以通过后续调用 `_wsProxy2.Abort()` 来取消请求。
`OnGetStockCompleted()` 中有什么新内容?一旦被调用,它的第二个参数 `gca`(类型为 `GetStockCompletedEventArgs`,稍后在清单 4 中查看)会带来所有完成的信息。`gca` 包含四个属性:`UserState`(类型为 object
)、`Result`(类型为 double
)、`Error`(类型为 `Exception`)和 `Cancelled` 标志。与回调中的逻辑(清单 2)相比,大部分内容都应该可以理解,无需重复。
唯一棘手的地方与 `gca.Cancelled` 有关。如清单 3 所示,我特意在 `OnGetStockCompleted()` 的开头检查此标志,并在 `gca.Error` 中的 `RequestCanceled` 状态进行了另一个检查。哪个被捕获了?当然是 `gca.Error`,而不是 `gca.Cancelled`,因为调用 `_wsProxy2.Abort()` 会导致 `WebException` 被抛出。
如果我想将 `gca.Cancelled` 拦截为取消标志怎么办?让我们更深入地研究一下这个代理。为了区分从 `gca.Error` 中捕获的取消响应,我将显示“Cancelled2”表示 `gca.Cancelled`,如下所示:
我使用的是代理的 `CancelAsync()`,它最初只调用其基类的一个方法。因此,在 `buttonCancel_Click()`(清单 3)中,我尝试调用 `_wsProxy2.CancelAsync()` 而不是 `_wsProxy2.Abort()`。
清单 4 说明了修改后的代理,其中我添加了四个编号的注释来指示更改:
// Listing-4. A modified event-driven proxy
public partial class Service : SoapHttpClientProtocol
{
private System.Threading.SendOrPostCallback
GetStockOperationCompleted;
private bool useDefaultCredentialsSetExplicitly;
// 1. Added the control flag _done
private bool _done = true;
public Service() { ... }
public new string Url { ... }
public new bool UseDefaultCredentials { ... }
public event GetStockCompletedEventHandler GetStockCompleted;
[System.Web.Services.Protocols.SoapDocumentMethodAttribute(...)]
public double GetStock(string symbol, int timeout)
{
object[] results = Invoke("GetStock",
new object[] {symbol, timeout});
return ((double)(results[0]));
}
... ... ...
public void GetStockAsync(string symbol, int timeout, object userState)
{
// 2. Initialize _dene - Not done
_done = false;
if (GetStockOperationCompleted == null)
{
GetStockOperationCompleted = new
System.Threading.SendOrPostCallback(
OnGetStockOperationCompleted);
}
this.InvokeAsync("GetStock", new object[] {symbol, timeout},
GetStockOperationCompleted, userState);
}
private void OnGetStockOperationCompleted(object arg)
{
// 3. When completed without cancelling, fire the event
if (GetStockCompleted != null && !_done)
{
_done = true;
InvokeCompletedEventArgs invokeArgs = (InvokeCompletedEventArgs)(arg);
GetStockCompleted(this,
new GetStockCompletedEventArgs(invokeArgs.Results,
invokeArgs.Error,
invokeArgs.Cancelled,
invokeArgs.UserState));
}
}
public new void CancelAsync(object userState)
{
// 4. If done, we are not in processing
if (_done) return;
// Cancellation is called. Done and fire the event
_done = true;
GetStockCompleted(this,
new GetStockCompletedEventArgs(null,
null,
true,
userState));
// base.CancelAsync(userState);
}
private bool IsLocalFileSystemWebService(string url) { ... }
}
[System.CodeDom.Compiler.GeneratedCodeAttribute( ... )]
public delegate void GetStockCompletedEventHandler(object sender,
GetStockCompletedEventArgs e);
... ...
public partial class GetStockCompletedEventArgs
: System.ComponentModel.AsyncCompletedEventArgs
{
private object[] results;
internal GetStockCompletedEventArgs(object[] results,
System.Exception exception,
bool cancelled,
object userState) :
base(exception, cancelled, userState) { ... }
public double Result { get { ... } }
}
为了清晰起见,我省略了大部分不相关的区域。首先,我在类中定义了一个标志 `_done`,并将其设置为 true
,表示没有请求正在处理。其次,在 `GetStockAsync()` 中,我将 `_done` 初始化为 false
(未完成)。`GetStockAsync()` 注册其自己的内部回调 `OnGetStockOperationCompleted()`,并通过 `InvokeAsync()` 启动异步请求。一旦 `OnGetStockOperationCompleted()` 被调用以处理已完成的请求,它就会触发 `GetStockCompleted()` 事件,该事件只是调用我之前添加到 `_wsProxy2` 的处理程序 `OnGetStockCompleted()`。
然后,第三个更改发生在 `OnGetStockOperationCompleted()` 中。我添加了一个 `_done` 检查,以防止触发,以防 `_done` 已在 `CancelAsync()` 中设置为 true
,这是下一步 - 第四个更改。一旦用户调用 `CancelAsync()` 进行取消,如果请求处于挂起状态(`_done` 为 false
),我将 `_done` 设置为 true
并触发已取消事件 - 该事件发送一个 `GetStockCompletedEventArgs` 参数,其中 `Cancelled` 标志设置为 true
。
这个练习有助于理解事件驱动代理的工作原理。但我绝不会建议在实际生产场景中进行此类代理更改。如果代理稍后重新生成,任何代码更改都将丢失。因此,我建议使用 `Abort()` 而不是 `CancelAsync()`,至少在撰写本文时是这样。
集中式服务管理
通过上述实现,您可以开始多个不同股票符号的异步调用,并在一个回调或一个事件处理程序中接收结果。您可以接收由 `AsyncState` 或 `UserState` 成员指示的符号的报价。也许您希望将其制作为服务类 DLL 以供多个调用者使用。这也可以正常工作,只要每个调用都创建自己的服务类实例,其中包含代理的副本。
在远程分布式系统中,我们可能需要构建一个服务中心来管理对后端的多项调用。在此集中式处理中,我们必须对用户进行身份验证,检索数据,缓存状态等。服务中心可以作为单例工作,接收多个调用并独占地维护控制。在这种情况下,它必须创建另一个异步机制来管理多个调用。下图描述了这种情况:
现在,从客户端访问 Web 服务被分为两个阶段。我可以构建一个服务中心类,并导出一个 `GetStock()` 方法供客户端使用(如上下文中的股票示例)。`GetStock()` 触发一个异步调用(阶段 1)以隐式启动一个线程。该新线程过程会创建一个代理对象来访问 Web 服务(阶段 2)。
下面的清单 5 说明了使用 Begin/End 异步方法实现的 `ServiceAsync` 类。
// Listing-5. A service class with Begin/End method
// Define an Aync service class
public class ServiceAsync
{
// Define the private Async method Delegate in Phase 1
private delegate GetStockStatus
AsyncGetStockDelegate(ref string symbol);
// An alternative event if no callbackProc supplied
public event OnGetStockResult OnGetStock;
// Web Service proxy
private Service _wsProxy1;
// This is a public method for user to call
public void GetStock(string symbol, OnGetStockResult callbackProc)
{
// Create the private Async delegate.
AsyncGetStockDelegate dlgt = new AsyncGetStockDelegate(GetStock);
callbackData data = null;
if (callbackProc !=null)
{
data = new callbackData();
data._callbackProc = callbackProc;
}
// Initiate the asychronous request.
IAsyncResult ar = dlgt.BeginInvoke(ref symbol,
new AsyncCallback(AsyncGetStockResult), data);
}
// This is a private thread procedure
private GetStockStatus GetStock(ref string symbol)
{
// Phase 2: Use _wsProxy1 to access Web Service.
// Return status in GetStockStatus
_wsProxy1 = new Service();
... ... ...
symbol = value.ToString("F");
return GetStockStatus.OK;
}
// Callback data structure
private class callbackData
{
public OnGetStockResult _callbackProc;
// Other data followed to pass and retrieve
}
// Async Callback when a request completed in Phase 1
private void AsyncGetStockResult(IAsyncResult ar)
{
AsyncGetStockDelegate dlgt =
(AsyncGetStockDelegate)((AsyncResult)ar).AsyncDelegate;
string result = string.Empty;
GetStockStatus status = dlgt.EndInvoke(ref result, ar);
callbackData data = (callbackData)ar.AsyncState;
if (data != null)
{
OnGetStockResult callbackProc = data._callbackProc;
callbackProc(result, status); // Call user supplied delegate
}
else
if (OnGetStock != null)
OnGetStock(result, status); // If no delegate, fire event
}
public void Cancel()
{
_wsProxy1.Abort();
}
}
// Define result status
public enum GetStockStatus { OK, Exception, TimeOut, Invalid, Cancelled }
// Define a delegate for an event to fire result
public delegate void OnGetStockResult(string result,
GetStockStatus status);
第一个公共 `GetStock()` 方法接受一个符号和一个用户提供的回调。然后它创建一个 `AsyncGetStockDelegate` 对象 `dlgt`,并准备回调数据。一旦 `dlgt` 发起异步请求,就会调用第二个私有的 `GetStock()` 方法来执行 Web 服务任务。
我通过两种方式使客户端调用 `GetStock()` 具有灵活性。查看内部回调 `AsyncGetStockResult()`。如果用户提供了回调过程,我使用它来发送结果。如果未提供回调,我将触发 `OnGetStock()` 事件来通知调用者传入的结果。因此,您可以这样做:
ServiceAsync sa = new ServiceAsync();
sa.GetStock(symbol, new OnGetStockResult(OnGetStock));
或者通过订阅事件处理程序将回调置空:
as.OnGetStock += new OnGetStockResult(OnGetStock);
as.GetStock(symbol, null);
单例服务管理多个调用的替代方法是为每个用户的调用生成一个线程。清单 6 概述了这种设计。
// Listing-6. A service class spawning a thread
// Define a Thread service class
public class ServiceThread
{
// An event to send result
public event OnGetStockResult OnGetStock;
// Web Service proxy
private Service _wsProxy2;
private string _symbol;
private int _timeout;
// This is a public method for user to call
public void GetStock(string symbol)
{
_symbol = symbol;
_wsProxy2 = new Service();
_wsProxy2.GetStockWithTimeoutCompleted +=
new GetStockWithTimeoutCompletedEventHandler(GetStockCompleted);
Thread thread = new Thread(new ThreadStart(GetStock));
thread.Start();
}
// This is a private thread procedure
private void GetStock()
{
_wsProxy2.GetStockWithTimeoutAsync(_symbol, _timeout, _symbol);
}
// Event handler of GetStockWithTimeoutCompletedEventHandler in .NET 2
private void GetStockCompleted(Object sender,
GetStockWithTimeoutCompletedEventArgs gca)
{
// Phase 2: Use _wsProxy to access Web Service.
// Based on results in gca, fire the event
... ... ...
OnGetStock(_symbol +" " +gca.Result.ToString("F"), GetStockStatus.OK);
}
public void Cancel()
{
_wsProxy2.Abort();
}
}
// Define result status
public enum GetStockStatus { OK, Exception, TimeOut, Invalid, Cancelled }
// Define a delegate for an event to fire result
public delegate void OnGetStockResult(string result,
GetStockStatus status);
第一个公共 `GetStock()` 方法显式启动一个线程来运行第二个私有 `GetStock()` 方法,该方法使用 .NET 2.0 代理发起事件驱动的异步调用。选择线程还是回调取决于您的应用程序用法、资源权衡以及您的系统有多少并发调用。
客户端超时
在我的测试服务(清单 1)中,我让服务器端处理客户端传递的超时。关于 Web 服务代理,它确实有一个 `Timeout` 属性(继承自 `WebClientProtocol`),这仅用于同步请求的完成。在异步模式下,代理不直接提供超时,可能是因为您可以使用 `Abort()`(也来自 `WebClientProtocol`)来取消挂起请求。
有时在异步设计中,我们等待请求完成,但仍然希望超时,而服务器及其代理不提供超时方法。因此,客户端必须自己处理超时。回想一下 `BeginGetStock()`,当它触发请求时,它会返回一个如下所示的对象 `r`:
IAsyncResult r = _weProxy1.BeginGetStock(symbol, null, null);
您不应该调用 r.AsyncWaitHandle.WaitOne(timeout, false)
,因为 `WaitOne()` 在返回之前不会释放当前线程,因此它甚至会阻塞取消。
一种解决方案是设置一个循环来轮询 `IAsyncResult` 的 `IsCompleted` 属性,模拟超时周期。清单 7 显示了这种方法,结合了对超时和取消标志的检查。
// Listing-7. Polling to achieve client side timeout (wsasyExp4.txt)
// Example 4. Polling to achieve client side timeout (wsasyExp4.txt)
public GetStockStatus GetStock(ref string symbol)
{
_cancel = true;
_weProxy1 = new Service();
IAsyncResult r = _weProxy1.BeginGetStock(symbol, null, null);
// Poll here, if _cancel is true Abort
// Simulating timeout with 10 ms interval.
int i = 0;
int n =_timeout/10;
for (; i < n; i++)
{
if (_cancel)
{
symbol = "<" +symbol +">";
_weProxy1.Abort();
return GetStockStatus.Cancelled;
}
if (r.IsCompleted == true)
break;
Thread.Sleep(10);
}
if (r.IsCompleted == false && i==n)
{
symbol = "[" +symbol +"]";
_weProxy1.Abort();
return GetStockStatus.TimeOut;
}
// if (!r.AsyncWaitHandle.WaitOne(_timeout, true))
// return GetStockStatus.TimeOut;
double value;
try
{
value = _weProxy1.EndGetStock(r);
}
catch (Exception e)
{
... ... ...
}
... ... ...
symbol = value.ToString("F");
return GetStockStatus.OK;
}
public void Cancel()
{
_cancel = true;
}
我在 `Cancel()` 中使用 `_cancel` 标志,而不是直接调用 `_weProxy1.Abort()`。当循环结束时,我可以检测到超时并中止对服务器的请求。一旦 `r.IsCompleted` 设置为 true
,调用就已完成,并从 `_weProxy1.EndGetStock(r)` 返回有意义的值。
这种轮询的缺点是循环会消耗大量 CPU 周期资源。在您的异步实现中请密切关注此弱点。
摘要
如今,软件开发正从早期的面向对象/组件的模式演变为基于服务的设计。异步机制可以在服务型系统中非常普遍地使用,例如在 XML Web 服务、.NET Remoting 和 .NET 3 的 Windows Communication Foundation 中。在本文中,我提出了几种设计模型,特别是带有回调、委托、事件和线程的异步实现。涉及的两个有趣考虑因素是取消和超时。本文提出的每种方法都有其优点和缺点,您应该在实践中仔细权衡。尽管示例项目是用 C# 在 .NET 1.1 和 2.0 上构建的,但基本技术对于跨版本、语言和平台的系统都是可取的。