构建排队 WCF 响应服务(From MSDN)

Windows Communication Foundation (WCF) 使客户端与服务之间能够以非连接方式进行通信。客户端将消息发布给队列,服务稍后再对这些消息进行处理。这种交互方式造就了一种不同于默认的请求/响应模式的编程模型,从而有望更好地平衡负载、提高可用性、进行补偿工作,为用户带来诸多好处。本专栏首先简要介绍 Windows® Communication Foundation 的排队调用功能,然后提出“如何从排队的调用获取结果”这样一个有趣的问题,接着通过一些超酷的 Windows Communication Foundation 编程技术以及我为此所编写的助手类来找到解决办法。


排队调用

Windows Communication Foundation 使用 NetMsmqBinding 来支持排队调用。Windows Communication Foundation 在传输消息时不是通过 TCP 或 HTTP,而是通过 Microsoft® 消息队列 (MSMQ)。客户端也不是将 Windows Communication Foundation 消息发送到某个在线服务,而是发送到 MSMQ 队列。所有客户端所面向和交互的对象是队列,而非服务端点。因此,调用在本质上是异步的、是不连接的。直到服务在将来某一时刻处理消息时,这些调用才得以执行。

请注意,Windows Communication Foundation 消息并不直接映射到 MSMQ 消息。一个 MSMQ 消息可以包含一个或多个 Windows Communication Foundation 消息,具体个数视合约会话模式而定。对于必需会话模式,多个 Windows Communication Foundation 调用可共存于一个 MSMQ 消息中;而对于允许或不允许会话模式(由单调用和单例式服务使用),每个 Windows Communication Foundation 调用将位于单独的 MSMQ 消息中。

如同各 Windows Communication Foundation 服务一样,客户端会与代理进行交互,如图 1 所示。由于已将代理配置为使用 MSMQ 绑定,因而该代理不会向任何特定服务发送 Windows Communication Foundation 消息,而是将调用转换为 MSMQ 消息,然后将这些消息发布到端点地址所指定的队列中。

图 1 WCF 排队调用体系结构
图 1 WCF 排队调用体系结构

在服务端,当具有排队端点的服务主机启动后,主机会安装队列侦听程序。队列侦听程序会检测到队列中的消息并使其出队,然后创建主机端以调度程序为终点的侦听器链。调度程序会照例调用服务实例。如果客户端向队列发布了多个消息,侦听程序会随着消息的出队创建新的实例,最终以异步、非连接的并发调用结束。

如果主机处于离线状态,消息将在队列中保持待处理状态。待下次主机上线时,消息会被转发给服务。

面向队列进行的、可能处于非连接状态的调用不可能返回任何值,因为在将消息调度到队列时并未调用任何服务逻辑。此外,调用可能会在客户端应用程序停止运行后被调度给服务进行处理,而这时客户端根本无法处理返回的值。同样,调用也无法将任何服务端异常返回给客户端,而且也没有客户端用来捕获和处理异常。由于客户端不会因为调用操作而被封锁,更确切地说,客户端只有在将消息送去排队的片刻才才被封锁,因而从客户端的角度来看,排队调用在本质上属于异步调用。这些是单向调用的典型特征。因此,由使用 NetMsmqBinding 的端点所提供的任何合约都只能具有单向操作。Windows Communication Foundation 会在加载服务和代理时对此进行验证:

//只能对排队合约执行单向调用
[ServiceContract]
interface IMyContract
{
[OperationContract(IsOneWay = true)]
void MyMethod();
}

 

由于与 MSMQ 的交互封装在绑定中,因而在服务调用代码或客户端调用代码中没有任何与调用排队相关的内容。服务代码和客户端代码看起来与任何其他 Windows Communication Foundation 客户端代码和服务代码都是一样的,如图 2 所示。

针对排队服务定义端点时,端点地址中必须包含队列名称和队列类型(公有或私有):

<endpoint
address = "net.msmq://localhost/private/
MyServiceQueue"
binding = "netMsmqBinding"
...
/>

 

最后,MSMQ 是 Windows Communication Foundation 的事务性资源管理器。如果队列是事务性的,则当客户端的事务中止时,客户端所发布的消息将会回滚。在服务端,从队列中读取消息时会启动新的事务。如果服务参与并中止该事务(可能因异常而导致),消息会回滚到队列中等待下一次重试。Windows Communication Foundation 提供了完善的故障检测和病毒消息处理支持功能,本专栏对此不作介绍。

Back to top

响应服务

到目前为止,我们所介绍的排队调用的编程模型是单侧的:客户端向队列发布单向消息,再由服务处理该消息。如果排队的操作真的就是单向调用,那么这种模型足以满足要求。然而,排队服务有时候需要反过来向其客户端报告调用的结果、返回的结果,甚至是错误。但在默认情况下,这是无法实现的。Windows Communication Foundation 将排队调用与单向调用等同起来,而单向调用在本质上是禁止任何此类响应的。此外,排队服务(及其客户端)可能未处于连接状态。如果客户端发布对未连接服务的排队调用,则当服务最终获得并处理这些消息时,可能不会有客户端来接收值,因为客户端可能早已离线了。这一问题的解决方案是让服务将报告返回给客户端所提供的排队服务。我将此类服务称作响应服务。图 3 显示了此类解决方案的体系结构。

图 3 响应服务
图 3 响应服务 

响应服务就是系统中的另一个排队服务。它同样可能与客户端断开连接并由单独的进程或单独的计算机进行托管,或者它也可能共享客户端的进程。如果响应服务共享客户端的进程,则当客户端启动时,响应服务即开始处理排队的响应。将响应服务由独立于客户端的进程(甚至是计算机)托管有助于进一步将响应服务的生存期与使用该响应服务的客户端相分离。

Back to top

设计响应服务合约

就如使用任何 Windows Communication Foundation 服务一样,客户端和服务需要预先商定响应合约及其适用对象(例如返回的值和错误信息,或仅仅是返回的值)。请注意,您也可将响应服务拆分为两个服务,一个用于响应结果,另一个用于响应错误。例如,假定有如下由排队 MyCalculator 服务所实现的 ICalculator 合约:

   [ServiceContract]
interface ICalculator
{
[OperationContract(IsOneWay = true)]
void Add(int number1,int number2);
... //更多操作
}
[ServiceBehavior(InstanceContextMode =
InstanceContextMode.PerCall)]
class MyCalculator : ICalculator {...}

 

要求 MyCalculator 服务以计算结果来响应客户端并报告所有错误。计算结果为整数形式,错误以 Windows Communication Foundation ExceptionDetail 数据合约形式表示。对于响应服务,可按下列方式定义 ICalculatorResponse 合约:

[ServiceContract]
interface ICalculatorResponse
{
[OperationContract(IsOneWay = true)]
void OnAddCompleted(int result,ExceptionDetail error);
}

 

支持 ICalculatorResponse 的响应服务需要检查返回的错误信息,在方法结束时通知客户端应用程序、用户或应用程序管理员,并将结果提供给相关方。下面是一个支持 IcalculatorResponse 的简单响应服务:

[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerCall)]
class MyCalculatorResponse : ICalculatorResponse
{
[OperationBehavior(TransactionScopeRequired = true)]
public void OnAddCompleted(int result,ExceptionDetail error)
{
MessageBox.Show("结果 =  " + result,"MyCalculatorResponse");
if(error != null)
{
//处理错误
}
}
}

 

实现 MyCalculator 和 MyCalculatorResponse 会直接引出两个问题。第一个问题是同一响应服务可能会被用于处理多个排队服务上多个调用的响应(或完成),而 MyCalculatorResponse(更重要的是其所服务的客户端)无法区分这些响应。这一问题的解决方案是让发出原始排队调用的客户端向该调用分配某个唯一的 ID 作为标记。排队服务 MyCalculator 需要将该 ID 传递给 MyCalculatorResponse,使其能够应用与该 ID 相关的某种自定义逻辑。

第二个问题是排队服务如何发现响应服务的地址。与双向回调不同的是,Windows Communication Foundation 内部并不支持将响应服务引用传递给服务。而将该地址放入服务主机配置文件中(客户端一节中)并不是明智之举,因为同一排队服务可能会被多个客户端调用,而每个客户端都有其自身专用的响应服务和地址。

一种可能的解决方案是将客户端所管理的 ID 和所需的响应服务地址作为参数基于排队服务合约明确传递给每个操作:

[ServiceContract]
interface ICalculator
{
[OperationContract(IsOneWay = true)]
void Add(int number1,int number2,
string responseAddress,string methodID);
}

 

同样,排队服务也可以将响应服务的方法 ID 作为参数基于排队响应合约明确传递给每个操作:

[ServiceContract]
interface ICalculatorResponse
{
[OperationContract(IsOneWay = true)]
void OnAddCompleted(int result,ExceptionDetail error,
string methodID);
}

 

Back to top

使用消息头

尽管将地址和 ID 作为显式参数进行传递能够解决以上问题,但这种做法会改变原始的合约,并且在业务级参数的基础上又在同一操作中引入了管道级参数。因而更好的解决方案是让客户端将响应地址和操作 ID 存储在调用的传出消息头中。这种方式是在将带外信息(只有通过此方式才会出现在服务合约中的信息)传递给服务时通常所采用的技术。

操作上下文会提供传入和传出标头的集合,这些集合可以通过 IncomingMessageHeaders 和 OutgoingMessageHeaders 属性来获取:

public sealed class OperationContext : ...
{
public MessageHeaders IncomingMessageHeaders {get;}
public MessageHeaders OutgoingMessageHeaders {get;}
... //更多成员
}

 

各集合均为 MessageHeaders 类型,代表 MessageHeader 对象的集合:

public sealed class MessageHeaders : IEnumerable<...>, ...
{
public void Add(MessageHeader header);
public T GetHeader<T>(int index);
public T GetHeader<T>(string name,string ns);
... //更多成员
}

 

不能使用 MessageHeader 类来与应用程序开发人员直接进行交互,而应使用可提供类型安全性并轻松将公共语言运行时 (CLR) 类型转换为消息头的 MessageHeader<T> 类:

public abstract class MessageHeader : ...
{...}
public class MessageHeader<T>
{
public MessageHeader();
public MessageHeader(T content);
public T Content {get;set;}
public MessageHeader GetUntypedHeader(string name,string ns);
... //更多成员
}

 

对于 MessageHeader<T> 的类型参数,可以使用任何可序列化类型或数据合约类型。可以围绕 CLR 类型构造 MessageHeader<T>,然后使用 GetUntypedHeader 方法将其转换为 MessageHeader 并存储在传出消息头中。GetUntypedHeader 要求您提供通用类型参数名称和命名空间。名称和命名空间将用于从标头集合中查找标头。查找操作可通过 MessageHeaders 的 GetHeader<T> 方法来执行。调用 GetHeader<T> 可获取所用 MessageHeader<T> 类型参数的值。

Back to top

ResponseContext 类

由于客户端需要在消息头中传递地址和方法 ID,因而仅仅一个基元类型参数并不能满足要求,而应使用 ResponseContext 类(如图 4 所示,其中省略了一些错误处理代码)。

ResponseContext 提供了一个位置来存储响应地址和 ID。此外,如果客户端要使用单独的错误响应服务,ResponseContext 还提供了一个字段用来存储错误响应服务地址。(图 4 并未体现出这一点,完整代码请参阅本期的下载内容。)

客户端负责构造具有唯一 ID 的 ResponseContext 实例。尽管客户端可以将此 ID 作为构造参数来提供,但它也可以使用 ResponseContext 的构造函数(仅接受响应地址参数),并让该构造函数为此 ID 生成 GUID。ResponseContext 的重要属性是静态 Current 属性。它完全封装了与消息头的交互。通过访问 Current 属性,您便获得了一个理想的编程模型:get 访问器从传入消息头中读取 ResponseContext 实例,而 set 访问器将 ResponseContext 实例存储在传出标头中。

Back to top

客户端编程

客户端可通过对每次调用使用不同的 ResponseContext 实例来为每个方法调用提供一个 ID。客户端需要将 ResponseContext 实例存储在传出消息头中。此外,客户端还必须在新的操作上下文中执行该操作,而不能使用其现有操作上下文。这一要求对于服务和非服务客户端都同样适用。Windows Communication Foundation 使客户端能够通过如下定义的 OperationContextScope 类来对当前线程采用新的操作上下文:

public sealed class OperationContextScope : IDisposable
{
public OperationContextScope(IContextChannel channel);
public OperationContextScope(OperationContext context);
public void Dispose();
}

 

OperationContextScope 是在现有上下文不适宜的情况下转换新上下文的常规技术。OperationContextScope 的构造函数用新上下文替换当前线程的操作上下文。在 OperationContextScope 实例上调用 Dispose 即可恢复原来的上下文(即使它为空)。如果不调用 Dispose,可能会破坏同一线程中需要使用先前上下文的其他对象。因此,OperationContextScope 专用于 using 语句中,仅仅为某一代码作用域提供新的操作上下文(即使遇到异常)。

当构造新的 OperationContextScope 实例时,应向其构造函数提供调用所用代理的内部通道。客户端需要创建新的 OperationContextScope,并在作用域内分配给 ResponseContext.Current。具体步骤如图 5 所示。

为使客户端的工作简单化、自动化,需要使用封装图 5 所示响应服务设置步骤的代理基类。与双向回调不同的是,Windows Communication Foundation 并不提供这样的代理类,因此必须手动创建一个代理类。为简化该任务,我编写了如图 6 所定义的 ResponseClientBase<T>。

要使用 ResponseClientBase<T>,应从其中派生一个具体类,并为类型参数提供排队合约类型。与普通代理的处理方式不同的是,不要再从合约派生子类,而应提供一组类似的方法,这些方法都将返回方法 ID 的字符串,这些字符串不能为 void。(这就是您不能从合约派生子类的原因,因为基于合约的操作不会返回任何内容,所有操作都是单向的)。例如,图 7 显示了使用此排队服务合约的、可识别响应服务的对应代理过程:

[ServiceContract]
interface ICalculator
{
[OperationContract(IsOneWay = true)]
void Add(int number1,int number2);
... //更多操作
}

 

使用 ResponseClientBase<T> 后,图 5 所示的代码将简化为:

string responseAddress =
"net.msmq://localhost/private/MyCalculatorResponseQueue";
CalculatorClient proxy = new CalculatorClient(responseAddress);
string methodId = proxy.Add(2,3);
proxy.Close();

 

ResponseClientBase<T> 的虚拟方法 GenerateMethodId 使用方法 ID 的 GUID。ResponseClientBase<T> 的子类可将其覆盖,并提供任何其他唯一的字符串,如递增的整数。

ResponseClientBase<T> 的构造函数接受响应地址和常规代理参数,如端点名称、地址和绑定。构造函数将响应地址存储在只读的公共字段中。ResponseClientBase<T> 从常规 ClientBase<T> 中派生而来,因此所有构造函数将委托给其各自的基本构造函数。

ResponseClientBase<T> 的核心是 Enqueue 方法。Enqueue 将接受要调用(实际上排列消息)的操作名称和操作参数,创建新的操作上下文作用域,生成新的方法 ID,并将该 ID 和响应地址存储在 ResponseContext 中。它通过设置 ResponseContext.Current 将 ResponseContext 分配给传出消息头,然后使用反射来调用所提供的操作名称。由于使用了反射和后期绑定,因而 ResponseClientBase<T> 不支持合约层次结构或重载操作。对于这些情况,需要进行手动编码,如图 5 所示。

Back to top

服务端编程

排队服务通过 ResponseContext.Current 访问其传入消息头,并从中读取响应地址和方法 ID。服务需要使用该地址来构造响应服务的代理,需要将该 ID 提供给响应服务。服务通常并不会直接使用 ID。

服务可以使用与客户端相同的技术来将方法 ID 传递给响应服务:使用传出消息头将 ID 向外传递给响应服务,而不使用显式参数。与客户端一样,服务也必须通过 OperationContextScope 来采用新的操作上下文,以便能够修改传出标头集合。服务可以编程方式构造响应服务的代理,并将响应地址和 NetMsmqBinding 实例提供给该代理。服务甚至还可以从配置文件中读取绑定设置。具体步骤如图 8 所示。

服务会捕获由业务逻辑操作所引发的所有异常,并使用 ExceptionDetail 对象将各个异常进行包装。服务不会再次引发异常,因为异常会中止用于将响应消息排入响应服务队列中的事务,所以再次引发异常会取消响应。

在 finally 语句中,无论是否出现异常,服务都会作出响应。它使用来自响应上下文的地址和 NetMsmqBinding 的新实例来构造响应服务的代理。服务使用代理的内部通道来播种新的 OperationContextScope。在新的作用域中,服务会将通过设置 Response.Current 而收到的相同响应上下文添加到新环境的传出标头中,然后调用响应服务代理,实际上就是使响应排入队列中。之后,服务会释放上下文作用域并关闭代理。

为使服务从标头中提取响应参数时的工作简单化、自动化,我创建了 ResponseScope<T> 类:

public class ResponseScope<T> : IDisposable where T : class
{
public readonly T Response;
public ResponseScope();
public ResponseScope(string bindingConfiguration);
public ResponseScope(NetMsmqBinding binding);
public void Dispose();
}

 

ResponseScope<T> 是可释放的对象,它会安装新的操作上下文,当它被释放后,作用域将恢复为原操作上下文。为实现自动化(即使遇到异常),应在 using 语句中使用 ResponseScope<T>。

ResponseScope<T> 接受代表响应合约的类型参数,并为 Response 提供相同类型的只读公共字段。Response 是响应服务的代理,ResponseScope<T> 的客户端使用 Response 来调用响应服务上的操作。Response 无需释放,因为 ResponseScope<T> 会在 Dispose 中这样做。ResponseScope<T> 将根据配置文件中的默认端点或使用提供给构造函数的绑定信息(配置节名或实际绑定实例)来将 Response 实例化。图 9 显示了 ResponseScope<T> 的实现。

使用 ResponseScope<T> 后,图 8 所示的 finally 语句将简化为:

finally
{
using(ResponseScope<ICalculatorResponse> scope =
new ResponseScope<ICalculatorResponse>())
{
scope.Response.OnAddCompleted(result,error);
}
}

 

ResponseScope<T> 的构造函数使用 ResponseContext.Current 来提取传入的响应向下文。然后使用通道工厂通过响应服务代理来将 Response 初始化。实现 ResponseScope<T> 的关键在于不要在 using 语句中使用 OperationContextScope。ResponseScope<T> 通过构造新的 OperationContextScope 来建立新的操作上下文。ResponseScope<T> 会封装 OperationContextScope 并保存新创建的 OperationContextScope。在 using 语句中释放 ResponseScope<T> 后,即会恢复原有上下文。然后,ResponseScope<T> 会使用 ResponseContext.Current 将响应上下文添加到新操作上下文的传出标头中,然后返回。

Back to top

响应服务编程

响应服务会访问其传入消息头集合,从中读取方法 ID 并相应作出响应。图 10 演示了此类响应服务一种可能的实现方式。

响应服务通过 ResponseContext.Current 访问其操作上下文传入标头,像排队服务那样提取方法 ID 并处理操作的完成或错误。

Back to top

结束语

排队服务适用于多种应用环境。如果您的应用环境需要服务向客户端报告操作的完成、错误和结果,通过本专栏所提供的助手类可以扩展 Windows Communication Foundation 的基本功能,它们通过一两行代码便补充了这一原本没有的支持功能。

本专栏所介绍的 Windows Communication Foundation 技术(例如与消息头进行交互、替换操作上下文、编写自定义代理基类及自定义上下文)在许多其他情况下也都非常有用,对于自定义框架尤为如此。

posted @ 2007-11-14 14:04  杰仔  阅读(486)  评论(0编辑  收藏  举报