利用 Windows Vista 和 WCF 中强大的 P2P 通信功能
从传统意义来说,P2P 应用程序开发所面临的难题包括:需要开发用于消息交换的专用协议;必须查找并连接隐藏在“网络地址转换”(NAT) 或防火墙背后的某应用程序的实例;需要支持在广域网 (WAN) 中定位各应用程序所需的惯常基础结构。尽管上述难题可以克服,但已造成大量阻碍,这样就导致我们很多人都从未想过 P2P 应用程序所能提供的杰出的协作功能。
这些阻碍将随着 Windows Vista™ 和附带的 .NET Framework 3.0 版本的发布而明显改善。在将 Windows Vista 的增强功能与“对等名称解析协议”(PNRP)、People Near Me (PNM) 和 Windows® Communication Foundation 中引进的 PeerChannel 功能相结合的情况下,我们又向 P2P 应用程序跨进了一大步。我个人期望在发布 Windows Vista 之后,P2P 的舞台会更加绚丽多彩。
Windows Vista 中的 P2P 开发是一个极其广泛的主题,没有任何一篇文章可以将其阐述完整。因此,我不会去尝试完成这样一个不可能的任务,而是会介绍 Windows Vista 中一些不同的 P2P 技术,并为您对 P2P 开发工作提供背景资料。
除了假定您已基本了解 Windows 窗体之外,我还假设您已略微熟悉 Windows Communication Foundation 应用程序的编写。如果情况并非如此,您可能希望通过阅读 Windows SDK 或 wcf.netfx3.com/content/resources.aspx 中的部分内容开始起步。
P2P 基本原理:网格网络
在深入探讨具体的 P2P 技术之前,研究 P2P 应用程序的一些基本原理很重要。对初学者而言,P2P 应用程序是一个与该应用程序的其他实例直接相连的应用程序。在 P2P 语言中,该应用程序的每个实例都叫做一个节点。通常将这些节点连接和命名后的组合称为网格。因此,推动 P2P 应用程序开发的技术经常被称作网格技术。PNRP、PeerChannel(在 Windows Communication Foundation 中)和 PNM 都是 Windows Vista 中网格技术的例子。
网格技术 所有 Windows Vista 网格技术所生成的网格在拓扑结构上都大致相同。通常来讲,网格拓扑是网格中各节点间连接模式的抽象体现。为了解释清楚,请在脑海中设想一个网格。我敢断定,您所设想的网格在某种程度上与图 1 中所示的网格相似。
图 1 全连接网格
图 1 所示网格中四个节点中的每个节点都与网格中的其他所有节点相连。换言之,如果网格中有 N 个节点,则每个节点都保持 N-1 个连接。我们将达到此标准的网格视为全连接网格。全连接网格很少被用作适合的方法;要了解原因,让我们注意一下各节点间的连接。
网格中的节点基本上都使用现有的常用传输方式进行通信。与所有的现代操作系统一样,Windows Vista 利用 TCP/IP 和 UDP 进行网络通信。如果 TCP/IP 是全连接网格所选的传输方式,那么由 N 个节点组成的全连接网格中的每个节点都必须创建或接收 N-1 个套接字。随着 N 值的增加,此模型显然变得不可行。例如,如果假定一个 N=1000 的情况,那么每个节点都将需要保持 999 个套接字,这简直就行不通。
要解决可伸缩性和 WAN 连接性的问题,您必须诉诸于部分连接的网格,如图 2 中所示。顾名思义,部分连接网格中的节点只与网格中的其他少量节点相连。就 P2P 来说,这些相邻的节点被称作邻居。通常来说,部分连接网格对每个节点的资源需求更少,从而大幅提高了网格的可伸缩性。从理论上讲,部分连接网格的规模可以扩大到包括全球所有计算机上的所有应用程序。
图 2 部分连接网格
加入网格 节点加入网格的方式取决于所使用的网格技术,但通常来讲,预期节点必须用网格名来解析网格中已有的一个或多个节点的物理地址。如果您假设一个部分连接网格,网格名的解析结果就是网格中可用物理地址的一个子集。在接收到网格中一个或多个物理节点的物理地址后,预期节点必须立即连接到这些地址中的一个、部分或全部地址。连接到网格之后,新添加的节点必须立即让自己准备好响应来自其他预期节点的后续的网格名解析请求。
网格名解析是一个复杂的主题。这种复杂性在很大程度上是因为,在许多情况下,网格名解析要依赖于一个或更多的其他网格。为清楚说明问题,请想一下美国邮政局使用的网格。再具体点说,假定我需要向我的朋友 Rusty 邮寄一个包裹。要邮寄这个包裹,我可能需要去一个邮局。如果我不知道距离我最近的邮局地点,我会到 Internet 上查找最近邮局的地址。从抽象意义上说,要“连接”到美国邮政局的网格需要我先访问所有网格中最大的网格(即 Internet)才能解析最近的节点的地址。换言之,您可以使用一个网格去解析包含在另一个网格中的地址。我将在本文的 PNRP 部分详细讨论这个概念。
与其他节点通信 一旦节点连接到某网格,它就可以通过以下两种方式之一与其他节点进行通信:网格扩散(也称多方消息传送),或定向消息传送。顾名思义,网格扩散是试图将消息发送到网格中的所有节点。一般而言,网格中的某节点可通过将消息发送到它的所有邻居而将消息传播到其他所有节点。在接收到消息后,初始发送节点的邻居负责将该消息转发给它的邻居,以此类推。相比之下,定向消息传送指的是试图将消息直接发送到网格中的某特定节点。在部分连接网格中,初始发送节点可能未与目标接收节点相连。如果真是这样,则初始发送节点必须将消息发送给它的一个或多个邻居。其中某个邻居可能会与目标接收节点相连。如果是这样,该邻居就会将消息转发给目标接收节点。如果不是这样,则该邻居会推测它的哪个邻居自身可能会与目标接收节点相连。
网格很少处于静态。在多数 P2P 应用程序中,节点可能会频繁加入和离开网格,可能是由于网络连接变动引起,也可能是由于在使用即时消息传送应用程序时,用户启动和终止应用程序引起。除了网格中的自然变动之外,多数网格技术都具备一些维护自身的机制。通常而言,网格维护的目标就是修复或调整网格以使其更高效或更稳健地运行。要注意到每个网格技术实现网格维护的方式都不同,这一点很重要。
顾名思义,PNRP 旨在基于网格名等要素来解析物理地址。PNRP 可供带有 Advanced Networking Pack 的 Windows XP Service Pack 1 (SP1) 以及 Windows XP SP2 和 Windows XP Professional x64 Edition 使用。Windows Vista 也将附带 PNRP 版本 2。从最简单的层次看,PNRP 本身就是一个采用 Windows 服务形式的 P2P 应用程序,并且 PNRP 节点的网格专用于发现加入到其他网格中的节点的物理地址.
PNRP 和 IPv6 PNRP 构建于 Internet 协议版本 6 (IPv6) 之上。由于 IPv6 对于多数开发人员而言还相当陌生,因此在讨论 PNRP 机制之前,非常有必要提及 IPv6 的至少一个重要方面。在 IPv6 中,地址是一个 128 位的值(这可能产生约 3.4×1038 种地址组合)。这种大小的 IPv6 地址池使 IPv6 的最重要功能之一 - 端对端寻址变为可能,即使这些地址被分割为多个子网并隐藏在 NAT 之后。
PNRP 示例 PNRP 功能的原型、结构、错误代码均在 Windows SDK 的 p2p.h 头文件中定义。如果某应用程序想要向 PNRP 注册一个网格名,它必须用非托管代码通过 Windows API 实现,或用托管代码通过公共语言运行库 (CLR) 的 P/Invoke 工具实现。目前,在 Windows API 的 PNRP 部分的 .NET Framework 中未包含托管包装。但您可以通过使用 netsh 命令行实用程序来访问 PRNP。通过 netsh,您可以按以下所示向 PNRP 注册一个新的 PNRP 名:
c:\temp>netsh netsh>p2p pnrp peer netsh p2p pnrp peer>add 0.justinsmith Ok.
命令行参数 0.justinsmith 即是 P2P 名。当此命令执行时,PNRP 基础结构会生成一个 PNRP ID,将该 PNRP ID 与 P2P 名相关联,并为该 PNRP ID 分配一个 IPv6 和 IPv4 地址。如果您访问另一台已安装 PNRP 并已将其启动的计算机,则可以用以下 netsh 命令解析网格名 0.justinsmith:
netsh p2p pnrp peer>resolve 0.justinsmith Resolve started... Found: Comment: gonzo Addresses: [0000:0000:0000:0000:0000:0000:0000:0001]:8350 udp 192.168.42.100:8350 tcp
现在需要解释一下该解析命令的输出。首先,Comment 字段代表注册了 0.justinsmith 的计算机名称(我在 Muppets 的基础上为我的计算机命名)。该字段从 netsh 自动填充,并且不能用作解析进程的一部分。其次,注意一下分配给该节点的 IPv6 和 IPv4 地址。这是 netsh 和允许通过 IPv4 网络传送 IPv6 通信量的 Teredo 转移技术共同具备的一个功能。诚然,我只是浅显地阐述了 PNRP,但我已经表明了 PNRP 使我可以用 P2P 名来解析 IP 地址。
PeerChannel
Windows Communication Foundation 的主要优点之一就是,它为许多不同类型的分布式应用程序提供了一个通用的编程模型。例如,编写一个基于 TCP/IP 通过二进制编码消息进行通信的分布式应用程序所需的代码与编写一个基于 HTTP 通过符合 WS-* 的可互操作的消息进行通信的分布式应用程序所需的代码惊人地相似。Windows Communication Foundation 的一个鲜为人知的功能就是它支持使用这同一个通用编程模型来构建 P2P 应用程序。由于 Windows Communication Foundation 对 P2P 应用程序的支持,可能会有人将其视为一种网格技术,但实际上,只有 PeerChannel Windows Communication Foundation 模块才专用于构建 P2P 应用程序。因此,PeerChannel 这个术语通常用于指代 Windows Communication Foundation 的 P2P 功能。无论 Windows Communication Foundation 中的 PeerChannel 指代的是什么,它实际上消除了通常与 P2P 应用程序开发相伴的所有复杂性,并且在我看来,它是 P2P 应用程序开发领域的一个创新性突破。
PeerChannel 网格 PeerChannel 网格是专为消息扩散而设计。但 PeerChannel 包含了可将消息传播到网格的一部分而不是整个网格的机制。因此,更准确地说,PeerChannel 网格是专为多方消息传送而设计。
PeerChannel 网格的结构由每个节点所连接的邻居数量所控制。为此,PeerChannel 网格会主动维护网格的结构。这种维护的作用就是使网格性能稳健并且分布均匀。更具体点说,网格中的节点会设法将连接的邻居数保持在两到七个之间。这些阈值可使对本地节点的资源需求与保持网格稳健性之间取得平衡。
如果某节点在进入网格时带有三个邻居,然后其中两个邻居离开网格,则该节点将开始一个维护周期以试图获取新的邻居连接。同样,如果一个节点连接的邻居少于七个,则它将接受新连接,直到它具有七个邻居连接时为止。当一个 PeerChannel 节点连有三个邻居时,则认为它处于理想的连接状态,但一个节点将接受多达七个邻居,以便低于邻居数最低阈值的节点可以迅速获得新的邻居。应注意的是,您的应用程序代码不能更改这些阈值或对网格的维护实施任何控制,这一点很重要。这些细节问题完全由 PeerChannel 基础结构逐个节点地处理。
PeerChannel 提供了 PNRP 解析程序和自定义解析程序,以供用作预期节点发现网格中已有节点地址的方法。无论选择哪个解析方法,主旨都是一样的:将网格名传递到解析程序并接收网格中其他节点的 IP 地址列表。一旦解析进程生成地址列表,预期 PeerChannel 节点就并发连接到每个地址。当 PeerChannel 网格中已有的某节点收到其中一个连接请求时,它可以接受或拒绝该连接。如果接受该连接,则现有节点会向新连接的节点发送一条欢迎消息,消息中除了其他内容之外,还包含网格中其他节点的地址列表。如果拒绝该连接,则现有节点会向预期节点发送一条拒绝消息,消息中包含拒绝理由以及网格中其他节点的地址列表。
这里的重要一点是,网格名解析(通过 PNRP 解析程序或自定义解析程序执行)不是向 PeerChannel 中预期节点返回地址列表的唯一方式。与将网格名解析作为预期节点获取地址的唯一方法相比,此特性可使节点更快速地进入理想的连接状态。此外,这一特性使网格中的节点可对节点拥有的邻居数(这一因素会进而影响网格的稳健性)进行控制。
PeerChannel 网格内的通信被调整为尽量减少重复性的消息传递。当网格中的某节点向该网格发送消息时,实际上是在向其邻居发送消息。当收到消息后,每个邻居都会检验该消息,然后将其转发给自己的邻居。如果一个 PeerChannel 节点从某邻居收到消息,它不会将此消息转发回该邻居。此外,如果一个 PeerChannel 节点经常从某邻居那里收到先前已收到并处理过的消息,则与该邻居的连接可能会在下一个维护周期中终止。这些功能将通过每个节点上的本地高速缓存来实现。在内部,PeerChannel 网格中的每个节点都会对 WS-Addressing 消息 ID 的值和传送消息的邻居的标识符进行缓存处理。节点在决定向哪些邻居传送该消息时会检查这个高速缓存。将这些功能相结合后,会将网格调整为以最低的重复率和网络带宽消耗来向网格中的各节点传送消息。
如前所述,PeerChannel 节点还可以将消息发送给网格中节点的子集。这可以通过向消息分配一个跳跃计数来实现,这实际上是跟踪转发消息所经由的节点数的方式。不要将这种机制与定向消息传送相混淆,定向消息传送是将消息传送目标锁定在某个特定节点。更确切地说,跳跃计数是模糊界定接收消息的节点数的方式。例如,如果一个 PeerChannel 节点(节点 A)有三个邻居且在跳跃计数为 1 的情况下向网格发送一个消息,则该消息将被传送到三个节点。同样,如果节点 A 的每个邻居也都有三个唯一邻居且节点 A 在跳跃计数为 2 的情况下向网格发送一个消息,则该消息将被传送到九个节点。但如果节点 A 的任一邻居有共用的邻居,则此数字将相应减少。
从物理上说,跳跃计数在消息中被表示为标头块中的一个整数。当某节点收到带有跳跃计数的消息时,它会检查跳跃计数的值。如果该值大于零,节点会单调递减此跳跃计数,然后将带有递减后的跳跃计数值的消息转发给相应邻居。如果所接收消息中包含的跳跃计数为 0,则不转发该消息。另需注意的重要一点是,跳跃计数标头块被排除在消息签名之外,因此更改这个值不会影响到应用于消息的数字签名的完整性,而且会防止产生与重复生成数字签名并将其序列化到消息相应部分中所关联的开销。
PeerChannel 示例 让我们用 PeerChannel 和 Windows 窗体来构建一个简单的 P2P 应用程序,称之为 PictureViewer。顾名思义,该应用程序的用途是允许网格中的所有节点可以查看同一张图片。从高层次来说,构建此应用程序所需的步骤如下所示:
1. |
定义基本的 Windows 窗体样板代码。 |
2. |
向窗体添加控件。 |
3. |
定义必需的 Windows Communication Foundation 服务合约。 |
4. |
编写连接到网格和从网格接收消息所需的 Windows Communication Foundation 代码。 |
5. |
编写向网格中其他节点发送消息所需的代码。 |
图 3 显示了完成后的应用程序。步骤 1 和 2 是开发任何 Windows 窗体应用程序时所必需的步骤,因此我将不在这里对其说明。对于任何 Windows Communication Foundation 应用程序,开发过程的第一步是定义服务合约。PeerChannel 要使用的服务合约类似于其他 Windows Communication Foundation 合约,只不过 PeerChannel 需要所有的 OperationContractAttribute 批注都将 IsOneWay 实例属性设置为 true。此属性规定消息的接收方不应发送回复。如果想要接收方发送回复,可以将服务合约定义为双向合约,但每个 OperationContractAttribute 批注仍必须将 IsOneWay 实例属性设置为 true。就此例而言,我不会创建一个双向合约(Windows SDK 中有几个双向合约的示例)。我要使用的合约如下所示:
[ServiceContract] interface IPictureViewer { [OperationContract(IsOneWay = true)] void SharePicture(Stream stream); }
图 3 PictureViewer P2P 应用程序
请注意,SharePicture 接口方法用 OperationContractAttribute 属性加以批注,并且 IsOneWay 实例属性被设置为 true。SharePicture 操作将 System.IO.Stream 视为一个参数,因为此操作将被用于向网格中其他节点传送图片的字节。
在定义了我们的服务合约后,现在就该添加 Windows Communication Foundation 代码,该代码会将我们的应用程序连接到 PeerChannel 网格并且被动等待来自网格的消息。首先,在窗体中实现新定义的服务合约。然后,定义类型 ServiceHost 的字段。所接收的消息将被发送到 frmPictureViewer 类型的单个实例。要表明此功能,我必须将正确的 ServiceBehavior 分配给 frmPictureViewer 类型。这两个步骤如图 4所示。
接下来,我必须对 ServiceHost 进行实例化,添加端点并开始侦听外来的消息。由于我正在构建 Windows 窗体应用程序,因此实现此操作的逻辑位置就是窗体的构造函数,如图 5 所示。
此时,我已经完成了要连接到网格并侦听消息所需的所有步骤。与标准的 Windows Communication Foundation 代码相比,不同之处只在于 Uri 的方案 (net.p2p)、所使用的绑定 (NetPeerTcpBinding) 以及所增添的基于密码的安全性。请注意,我已选择将网格密码直接置于代码中,这一点很重要。如果想要使网格密码保密,请不要在您当前所使用的应用程序中如此操作。
只要调用了 ServiceHost.Open,我们的应用程序就会尝试通过 PNRP 来解析网格名 (pictureView)。此时,我可以通过运行 netsh 命令以列出注册的对等名称来验证我们的 PeerChannel 应用程序是否正在使用 PNRP。如果 PNRP 可以将网格名解析为一个或多个 IP 地址,则我们的应用程序将尝试连接到这些节点。如果不是这样,则该节点将成为网格中的第一个节点。如前所述,现有节点将通过发送欢迎消息或拒绝消息来接受或拒绝连接。这里的重要一点是,这种情况有可能在对 ServiceHost.Open 的调用返回后发生。
将消息发送到其他节点 在共享图片之前,我必须首先加载图片。实现此操作所需的代码是 Windows 窗体基本代码:首先,对 OpenFileDialog 进行实例化,获得一个 Stream,将该 Stream 转换为 Image,然后通过 PictureBox.Image 属性引用 Image。等一下,这不是 SharePicture 方法所执行的操作吗?事实上,确实如此。从本质上说,要将图像加载到 PictureBox 中,我只需要调用 SharePicture 方法,将从 OpenFileDialog.OpenFile 返回的 Stream 作为一个参数传递。
要将包含图片的消息发送到网格中的其他节点,我必须编写几行代码,但是此代码与您在其他任何 Windows Communication Foundation 应用程序中编写的代码几乎相同。起初,我需要在类型 ChannelFactory<IPictureViewer> 和 IPictureViewer 的窗体中定义一些字段。接下来,我需要在窗体的构造函数中将这些变量实例化。这些步骤如图 6 所示。
请注意,所使用的网格密码和证书(用于创建消息的数字签名)与设置 ServiceHost 时所用的必须是同一个。除此之外,此代码要与非 PeerChannel Windows Communication Foundation 应用程序中所需的代码相同。
既然我已经构建了自己的发送基础结构,我就可以使用它向网格中的其他节点发送消息。为此,我只需为共享按钮编写一个事件处理程序即可,如下所示:
private void btnShare_Click(object sender, EventArgs e) { using(MemoryStream stream = new MemoryStream()) { Image image = pbView.Image; image.Save(stream, ImageFormat.Jpeg); // 将图像存储到 stream 中 stream.Position = 0; // 复位位置 channel.SharePicture(stream); // 向网格发送消息 } }
简言之,PeerChannel 大大简化了 P2P 应用程序的开发。PictureViewer 的完全功能版约有 150 行源代码,其中大部分代码专用于 Windows 窗体基础结构。
People Near Me
PNM 是集成在 Windows Vista 之中的一种网格技术,它允许邻近的设备组和人员组相互发现、连接、邀请并进行协作。PNM 特别适用于这样一些任务:在咖啡店与邻座其他几个人一起玩游戏;与同事共享您的桌面;甚至连接到会议室中的投影仪,等等。PNM 提供的这些功能如此强大,我们有理由假设,一旦它被发布,开发人员社区就将会找到新的、具有独创性的方式来利用此技术。重要的是,要注意 PNM 是一项完全自选的网格技术,在默认情况下是关闭状态。
除其他应用程序之外,PNM 体系结构包含了一个称为 p2phost.exe 的 P2P 应用程序。此进程运行时,将通过连接到其他计算机上 p2phost.exe 的实例来创建网格。通常而言,此网格的用途是定向消息传送。更确切地说,PNM 用于解析本地节点并与这些本地节点的子集进行通信。PNM API 作为 Windows API 的一部分提供,并且多半程度上侧重于配置 p2phost.exe 的行为。
总的来说,PNM API 的主要类别包括函数、结构、事件和错误代码,通过这些类别可以向 PNM 注册应用程序、邀请其他人加入协作会话、启动已注册的应用程序、创建持久性合约并邀请不再属于本地的联系人。提要栏中的“真实环境中 People Near Me 的示例”将例示此过程。请注意,不支持应用程序使用 PNM 进行通信。就 PictureViewer 而言,这表示在提要栏中说明的 Tom 和 Harry 的 PictureViewer 实例之间传递的消息仍由 PeerChannel 来处理。