Windows 8 网络

Windows 8 和 WebSocket 协议

Kenny Kerr

 

WebSocket 协议的目标是在由客户端(专门负责建立连接和发起请求/响应对)起支配地位的满负荷 Web 环境中提供双向通信。 它最终允许应用程序以 Web 友好的方式享用 TCP 的更多优势。 考虑到 WebSocket 协议仅仅是由 Internet 工程任务组在 2011 年 12 月进行标准化的,而当我写这篇文章时,万维网联盟仍在斟酌是否对此协议进行标准化 — 但令人惊讶的是,Windows 8 如何做到了全面包容这一新的 Internet 技术。

在本文中,我首先介绍 WebSocket 协议的工作原理,并解释它与更大的 TCP/IP 套件之间的关系。 接着探讨 Windows 8 通过哪些不同方法,使编程人员能够轻松地从其应用程序中采用这一新技术。

为何采用 WebSocket?

此协议的主要目标是为基于浏览器的应用程序提供标准和高效的方法,以便在请求/响应对外部与服务器自由地通信。 回顾前些年,所有 Web 开发人员都热衷于谈论异步 JavaScript 和 XML (AJAX),以及如何通过这些技术处理动态和交互方案 — 当然这些技术能够实现这些目标,不过,作为这些技术的灵魂,XMLHttpRequest 对象仍然只允许浏览器发出 HTTP 请求。 如果服务器要通过带外方式向客户端发送消息,那该怎么办? 这时,WebSocket 协议就有用武之地了。 它不仅允许服务器向客户端发送消息,而且不需要 HTTP 开销就能实现这一点,同时提供与原始 TCP 连接速度接近的双向通信。 如果没有 WebSocket 协议,Web 开发人员不得不过度使用 HTTP,即轮询服务器是否有更新、使用 Comet 样式的编程技术以及利用许多 HTTP 连接,而协议占用大量开销只是为了使应用程序保持最新状态。 服务器过载,带宽被浪费,Web 应用程序过度复杂化。 WebSocket 协议能够以您意想不到的简单和高效方式解决这些问题,但在介绍其工作原理之前,我需要提供一些基础和历史背景。

TCP/IP 套件

TCP/IP 是用于实现 Internet 体系结构的协议套件或相关协议的集合。 它经过多年的演化,发展为当前的形式。 自 20 世纪 60 年代以来,这一领域发生了巨大变化,首先出现了包交换网络的概念。 计算机的速度比以往快得多,软件的要求越来越高,而 Internet 迅速发展为包罗万象的信息、通信和交互网络,目前它已成为大量常用软件的中枢。

TCP/IP 套件由许多以开放系统互联 (OSI) 分层模型为基础松散建模的层组成。 尽管没有特别详细地描绘不同层的协议,但 TCP/IP 已明确证明了自己的有效性,而分层问题已通过巧妙地结合硬件和软件设计得到了解决。 将 TCP/IP 划分为层(尽管各层之间的界限比较模糊)已成功帮助 TCP/IP 随着时间推移适应硬件和技术的变化,并允许具有不同技能的编程人员在不同的抽象级别进行编程,帮助构建协议堆栈自身或构建利用其各种工具的应用程序。

最底层是物理协议(包括有线介质访问控制和 Wi-Fi 等技术),同时提供物理连接以及本地寻址和错误检测。 大多数编程人员不会过多地考虑这些协议。

在堆栈中上移可到达 Internet 协议 (IP),它本身位于网络层中,允许 TCP/IP 变为可跨不同物理层互操作的状态。 它负责将计算机地址映射到物理地址,并将来自计算机的数据包路由到计算机。

接下来是一些辅助协议,我们可能会就他们所在的层级发生争论,但它们实际上是为自动配置、名称解析、发现、路由优化和诊断等事项提供必要的支持角色。

随着我们在分层堆栈中继续上移,会看到传输协议和应用程序协议。 传输协议负责对来自更低层的数据包进行多路复用和取消多路复用,其目的在于,即使只有一个物理层和网络层,大量不同应用程序也可以共享通信通道。 传输层通常还提供进一步的错误检测、可靠的传输甚至与性能相关的功能(如拥堵和流控制)。 应用层传统上是 HTTP(由 Web 浏览器和服务器实现)和 SMTP(由电子邮件客户端和服务器实现)等协议的所在层。 随着这一领域对 HTTP 等协议的依赖程度开始增加,协议的实现已向下进入到操作系统的深度:一方面是为了改善性能,另一方面是为了在不同应用程序之间共享实现。

TCP 和 HTTP

在 TCP/IP 套件的各协议中,一般编程人员最熟悉的可能是位于传输层的 TCP 和用户数据报协议 (UDP)。 这两个协议都定义了“端口”抽象,这些协议将此概念与 IP 地址结合使用,以便当数据包到达和发送数据包时,对数据包进行多路复用和取消多路复用。

尽管 UDP 广泛用于其他 TCP/IP 协议(如动态主机配置协议和 DNS)并已被广泛用于专用网络应用程序,但总体而言,它在 Internet 中的采用广泛程度比不上其同类 TCP 协议。 另一方面,TCP 已广泛应用于各个领域,这在很大程度上得益于 HTTP。 尽管 TCP 的复杂性远远高于 UDP,但这种复杂性在很大程度上隐藏于应用层之后,在这个位置,应用程序可以享用 TCP 的优势而不受其复杂性影响。

TCP 在计算机之间提供可靠的数据流,而数据流的实现极其复杂。 它本身与数据包排序和数据重构、错误检测和恢复、拥堵控制和性能、超时、重新传输等相关。 然而,应用程序只看到端口之间的双向连接,并假定发送和接收的数据将按顺序正确进行传输。

当代的 HTTP 预先假定具有可靠的面向连接的协议,而 TCP 无疑是一个明显和普遍的选择。 在此模型中,HTTP 用作客户端/服务器协议。 客户端打开到服务器的 TCP 连接。 然后,它发送请求,服务器对请求进行评估和响应。 每时每刻,这一过程会在世界各地重复无数次。

当然,这对 TCP 所提供的功能进行了简化和限制。 TCP 允许双方同时发送数据。 一端不需要等待另一端发送请求,就可以进行响应。 但是,这种简化确实允许服务器端缓存响应,这对 Web 的可伸缩性具有重大影响。 但 HTTP 之所以如此受欢迎,毫无疑问得益于其初始简单性。 而 TCP 为二进制数据提供了双向通道(如果您需要,可提供一对数据流);HTTP 在响应消息前面提供请求消息,两种都由 ASCII 字符组成,但消息正文(如果有)则可能采用其他方式进行编码。 简单的请求可能如下所示:

GET /resource HTTP/1.1\r\n
host: example.com\r\n
\r\n

每行均以回车符 (\r) 和换行符 (\n) 结尾。 第一行称作请求行,指定访问资源的方法(此处为 GET)、资源的路径,并最后指定要使用的 HTTP 版本。 与较低层协议相似,HTTP 通过此资源路径提供多路复用和取消多路复用。 紧跟此请求行之后是一个或多个标头行。 标头由上述示例中阐述的名称和值组成。 某些标头是必需的(如主机),而大多数标头不是必需的,只是帮助浏览器和服务器更高效地通信或协商特性和功能。

响应可能如下所示:

HTTP/1.1 200 OK\r\n
content-type: text/html\r\n
content-length: 1307\r\n
\r\n
<!DOCTYPE HTML><html> ...
</html>

格式基本上是相同的,但不是请求行,响应行确认要使用的 HTTP 版本、状态代码 (200) 以及状态代码的说明。 状态代码 200 向客户端指明请求处理成功,且任何结果都会紧跟在任何标头行之后。 例如,服务器可能通过返回状态代码 404 指明所请求的资源不存在。 标头与请求中标头的格式相同。 在此情况下,content-type 标头向浏览器通知消息正文中所请求的资源将被解释为 HTML,而 content-length 标头告知浏览器消息正文包含多少个字节。 这一点很重要,因为当您回调时,HTTP 消息将流经 TCP,而后者不提供消息边界。 如果没有内容长度,HTTP 应用程序需要使用各种试探法来确定任何消息正文的长度。

这看起来确实非常简单,证明 HTTP 的设计确实简单直接。 但 HTTP 不再如此简单。 现今的 Web 浏览器和服务器是现代化的程序并带有成千上万个相互关联的功能,而 HTTP 就是需要与它们保持相同步伐的驮马。 复杂性在很大程度上源自对速度的需要。 现在可以用标头来协商消息正文的压缩,使用缓存和过期标头来避免传输消息正文等。 目前已开发了各种技术,通过组合不同的资源来减少 HTTP 请求的数量。 内容传送网络 (CDN) 现已遍布整个世界,尝试使经常访问的资源与访问这些资源的 Web 浏览器更近。

尽管具有所有这些优势,但对于许多 Web 应用程序而言,如果可以通过某种方法偶尔脱离 HTTP 并返回 TCP 的流处理模型,则可以实现更大的可伸缩性甚至简单性。 这正是 WebSocket 协议所能提供的。

WebSocket 握手

WebSocket 协议在某种程度上非常适合 TCP/IP 套件(超越 TCP 并与 HTTP 并置)。 向 Internet 引入新协议面临的其中一个难题是:要在一定程度上使不计其数的路由器、代理和防火墙认为根本没有发生任何变化。 WebSocket 协议通过乔装成 HTTP,然后在同一个基础 TCP 连接之上切换为其自己的 WebSocket 数据传输,从而实现这一目标。 通过这种方法,许多毫无猜疑的中间方不必进行升级,就允许 WebSocket 通信在其网络连接中穿行。 实际上,这种方法并非始终如此顺利地发挥作用,因为一些过度热衷的路由器会伪造 HTTP 请求和响应,尝试重新编写这些内容以适合其自己的需要,如代理缓存、地址转换或资源转换。 一种有效的短期解决方案是通过安全通道(传输层安全性 (TLS))使用 WebSocket 协议,原因是这倾向于使篡改保持最小程度。

WebSocket 协议从各种来源(包括 IP、UDP、TCP 和 HTTP)借来创意,并使这些概念以更简单的形式供 Web 浏览器和其他应用程序使用。 这一切都从握手开始,握手在外观和操作方式上设计为与 HTTP 请求/响应对很相似。 一般并不执行握手过程,以便客户端或服务器在一定程度上相互欺骗对方使用 WebSocket,而不是欺骗各种中间方认为它只是用于提供 HTTP 的另一个 TCP 连接。 事实上,WebSocket 协议专门设计为防止任何当事方受骗而意外接受连接。 最初是客户端发送握手,也就是针对所有意图和目的的 HTTP 请求,如下所示:

GET /resource HTTP/1.1\r\n
host: example.com\r\n
upgrade: websocket\r\n
connection: upgrade\r\n
sec-websocket-version: 13\r\n
sec-websocket-key: E4WSEcseoWr4csPLS2QJHA==\r\n
\r\n

如上所示,没有任何内容可阻止这成为完全有效的 HTTP 请求。 毫无猜疑的中间方应只是将此请求传递到服务器,而服务器甚至可能是兼扮 WebSocket 服务器的 HTTP 服务器。 此示例中的请求行指定标准 GET 请求。 这也意味着,WebSocket 服务器可能允许单个服务器为多个端点提供服务,就像大多数 HTTP 服务器那样。 host 标头是 HTTP 1.1 所要求的且目的相同 — 确保双方对在共享的托管方案中托管域达成一致。 upgrade 标头和 connection 标头也是标准的 HTTP 标头,客户端使用这些标头来请求升级连接中使用的协议。 HTTP 客户端有时使用这一技术来转换到安全的 TLS 连接,尽管这种情况很少见。 然而,这些标头是 WebSocket 协议所要求的。 具体而言,upgrade 标头指示连接应升级到 WebSocket 协议,而 connection 标头指定此 upgrade 标头是特定于连接的,这意味着它不得由代理通过进一步的连接来进行通信。

必须加入 sec-websocket-version 标头,且其值必须为 13。 如果服务器是 WebSocket 服务器但不支持此版本,它将中止握手,同时返回相应的 HTTP 状态代码。 正如您稍后将看到的那样,即使服务器不了解 WebSocket 协议并愉快地返回了成功响应,客户端也设计为中止此连接。

sec-websocket-key 标头实际上是 WebSocket 握手的关键所在。 WebSocket 协议的设计者希望确保服务器无法从实际上不是 WebSocket 客户端的客户端接受连接。 他们不希望恶意脚本构造表单提交或使用 XMLHttpRequest 对象通过 sec-* 标头来伪造 WebSocket 连接。 为了向双方证明正在建立合法连接,客户端握手中也必须存在 sec-websocket-key 标头。 此值必须是随机选择的 16 字节数字(理想情况下应为密码强度高的随机值),以安全性用语表示为“现时”(随后针对此标头值对其进行 base64 编码)。

在发送客户端握手后,客户端等待回复,以验证服务器确实愿意并能够建立 WebSocket 连接。 假定服务器不反对,则它可能发送服务器握手作为 HTTP 响应,如下所示:

HTTP/1.1 101 OK
upgrade: websocket\r\n
connection: upgrade\r\n
sec-websocket-accept: 7eQChgCtQMnVILefJAO6dK5JwPc=\r\n
\r\n

再次强调,这是绝对有效的 HTTP 响应。 响应行包括 HTTP 版本后跟状态代码,但服务器并不响应指示成功的常规 200 代码,而是响应标准 101 代码,指示服务器了解升级请求并愿意转换协议。 状态的英文说明绝对没什么差别。 这些说明可能是“很好”、“切换到 WebSocket”或甚至是随机的马克·吐温引语。 重要的是状态代码,客户端必须确保此代码是 101。 例如,服务器可能使用 401 状态代码拒绝此请求并要求客户端进行身份验证,然后才接受 WebSocket 客户端握手。 但是,成功的响应必须包括 upgrade 标头和 connection 标头,以确认 101 状态代码专门指切换至 WebSocket 协议,以再次避免任何人受骗。

最后,为了验证握手,客户端确保 sec-websocket-accept 标头存在于响应中且其值正确。 服务器不需要对客户端发送的以 base64 编码的值进行解码。 它只采用此字符串,连接众所周知的 GUID 的字符串表示形式,并对此组合与 SHA-1 算法进行哈希处理,以生成 20 字节的值;然后对该值进行 base64 编码并将其用作 sec-websocket-accept 标头的值。 然后,客户端轻松地验证服务器确实按要求完成了操作,这样毫无疑问,双方都同意建立 WebSocket 连接。

如果一切进展顺利,此时就会建立有效的 WebSocket 连接,并且双方可以使用 WebSocket 数据帧随心所欲地同时双向通信。 通过研究 WebSocket 协议,我们了解到此协议是在经历 Web 不安全灾难后设计的。 与其之前的大多数协议不同,WebSocket 协议设计时就融入了安全性。 此协议还要求,如果客户端实际上是 Web 浏览器,则客户端包括 origin 标头。 这就允许浏览器提供保护措施以阻止跨来源攻击。 当然,这只在可信托管环境的上下文(如浏览器上下文)中才有意义。

WebSocket 数据传输

WebSocket 协议的目的就是使 Web 回到由 IP 和 TCP 提供的相对高性能、低开销的通信模型,而不增加任何进一步的复杂性和开销。 因此,一旦握手完成,WebSocket 开销将保持为最小状态。 它在 TCP 基础之上提供了数据包分帧机制,而提到 TCP 就会让人想起 TCP 自身所基于的 IP 数据包优先级划分,UDP 正是由于这种 IP 数据包优先级划分而得以盛行,但 WebSocket 协议不存在妨碍那些协议发挥作用的数据包大小限制。 尽管 TCP 提供了基于流的抽象,但 WebSocket 为应用程序提供了基于消息的抽象。 尽管 TCP 流是通过段来传输的,但 WebSocket 消息是作为帧序列来传输的。 这些帧通过同一个 TCP 连接进行传输,这样自然就认为是可靠的序列传输。 分帧协议比较详尽,但专门设计成极其小,在很多情况下,只需增加很少的分帧开销字节。 数据帧可以在完成公开握手之后的任意时刻,由客户端或服务器进行传输。

每帧都包括一个操作码,用于描述帧类型以及负载大小。 此负载表示应用程序可能要通信的实际数据以及任何预先安排的扩展数据。 有趣的是,此协议允许对消息进行分片。 如果您具有很强的网络背景,则可能会提醒您 IP 级分片的性能复杂性以及 TCP 避免分片所产生的麻烦。 但 WebSocket 有关分片的概念却截然不同。 此处的概念是允许 WebSocket 协议提供网络数据包的便利性,但没有大小限制。 如果发送方不知道所发送消息的确切长度,也可以对消息分片,此时每帧指示它提供的数据量以及是否为最后一个片段。 此外,此帧只是指明它是包含二进制数据还是 UTF-8 编码文本。

同时也定义控制帧,控制帧主要用于结束连接,但也可以用作心跳以对另一个端点执行 ping 操作,从而确保它仍然响应或帮助使 TCP 连接保持活动状态。 最后,我应指出,如果您碰巧正在使用网络协议分析器(如 Wireshark)反复琢磨由客户端发送的 WebSocket 帧,则您可能注意到数据帧似乎包含已编码的数据。 WebSocket 协议要求屏蔽从客户端发送到服务器的所有数据帧。 屏蔽涉及一个简单的算法,也即使用屏蔽键对数据字节执行“XOR”运算。 屏蔽键包含在帧内,因此这并不意味着某种荒谬的安全功能,尽管它确实与安全性有关。 如上面所述,WebSocket 协议的设计者花了大量精力来处理各种与安全性相关的方案,以试图预测协议可能受到攻击的不同方式。 所分析的一种此类攻击手段涉及到通过危害 Internet 的基础结构(此处是代理服务器)来间接攻击 WebSocket 协议。 毫无怀疑的代理服务器可能并未意识到 WebSocket 握手愿意接受 GET 请求,因此可能受骗而缓存由攻击者发起的假冒 GET 请求的数据,结果导致某些用户的缓存内容受到污染。 使用新键屏蔽每帧,通过确保帧不是可预测的并因此不会在传输过程中被曲解,可以减轻这一特定的威胁。 这种攻击比较多,毫无疑问,研究人员将及时发现进一步的可能攻击。 然而,务必清楚地看到,设计人员已花费了很长时间试图预测许多攻击类型。

Windows 8 和 WebSocket 协议

深入地了解 WebSocket 协议无疑是很有帮助的,此外,如此范围广泛的支持还为用户利用此类平台工作提供了大量帮助,毫无疑问 Windows 8 提供了这些优势。 我们来看看您使用 WebSocket 协议的一些方式,这并不要求您实际自行实施此协议。

Windows 8 提供了 Microsoft .NET Framework,通过 Windows 运行时(同时针对本机代码和托管代码)支持客户端,并让您在 C++ 中使用 Windows HTTP 服务 (WinHTTP) API 创建 WebSocket 客户端。 最后,IIS 8 提供了本机 WebSocket 模块,自然而然 Internet Explorer 为 WebSocket 协议提供了本机支持。 这是不同环境的组合,但更令人惊讶的是 Windows 8 只包含单个 WebSocket 实现,并在所有这些环境之间共享这一实现。 WebSocket 协议组件 API 实现所有用于握手和分帧的协议规则,而不会实际创建任何类型的网络连接。 然后,不同的平台和运行时可能使用这一公共实现,并将其挂接到所选的网络堆栈中。

.NET 客户端和服务器

.NET Framework 提供了对 ASP.NET 的扩展并提供了 HttpListener(它本身基于由 IIS 使用的本机 HTTP 服务器 API),以便为 WebSocket 协议提供服务器支持。 对于 ASP.NET,您只需编写一个 HTTP 处理程序,由它负责调用新的 HttpContext.AcceptWebSocketRequest 方法以便在特定的端点上接受 WebSocket 请求。 您可以使用 HttpContext.IsWebSocketRequest 属性验证该请求实际上是 WebSocket 客户端握手。 在 ASP.NET 外部,您只需使用 HttpListener 类即可托管 WebSocket 服务器。 实现也主要在这两者之间共享。 图 1 提供了此类服务器的一个简单示例。

图 1 使用 HttpListener 的 WebSocket 服务器

static async Task Run()
{
  HttpListener s = new HttpListener();
  s.Prefixes.Add("http://localhost:8000/ws/");
  s.Start();
  var hc = await s.GetContextAsync();
  if (!hc.Request.IsWebSocketRequest)
  {
    hc.Response.StatusCode = 400;
    hc.Response.Close();
    return;
  }
  var wsc = await hc.AcceptWebSocketAsync(null);
  var ws = wsc.WebSocket;
  for (int i = 0; i != 10; ++i)
  {
    await Task.Delay(2000);
    var time = DateTime.Now.ToLongTimeString();
    var buffer = Encoding.UTF8.GetBytes(time);
    var segment = new ArraySegment<byte>(buffer);
    await ws.SendAsync(segment, WebSocketMessageType.Text,
      true, CancellationToken.None);
  }
  await ws.CloseAsync(WebSocketCloseStatus.NormalClosure,
    "Done", CancellationToken.None);
}

此处我使用一个 C# 异步方法来使代码保持序列化和连贯性,但实际上代码都是异步的。 首先注册端点并等待传入的请求。 然后,我检查此请求是否确实有资格成为 WebSocket 握手,如果没有资格,则返回 400“请求失败”状态代码。 接着,我调用 AcceptWebSocketAsync 以接受客户端握手,并等待握手完成。 此时,我可以使用 WebSocket 对象自由地进行通信。 在此示例中,服务器发送 10 个 UTF-8 帧,每个帧都包含时间,相互间有很短的延迟。 每帧都使用 SendAsync 命令以异步方式发送。 此方法功能非常强大,可以整体或以片段方式发送 UTF-8 或二进制帧。 第三个参数(此处为 true)指示对 SendAsync 的这一调用表示消息结束。 这样,您就可以重复使用此方法来发送将进行分片的长消息。 最后,CloseAsync 方法用于执行 WebSocket 连接的干净关闭,同时发送结束控制帧并等待客户端确认其自己的结束帧。

在客户端,新的 ClientWebSocket 类在内部使用 HttpWebRequest 对象来提供连接到 WebSocket 服务器的能力。 图 2 提供了一个可用于连接到图 1 中所示服务器的客户端的简单示例。

图 2 使用 ClientWebSocket 的 WebSocket 客户端

static async Task Client()
{
  ClientWebSocket ws = new ClientWebSocket();
  var uri = new Uri("ws://localhost:8000/ws/");
  await ws.ConnectAsync(uri, CancellationToken.None);
  var buffer = new byte[1024];
  while (true)
  {
    var segment = new ArraySegment<byte>(buffer);
    var result =
      await ws.ReceiveAsync(segment, CancellationToken.None);
    if (result.MessageType == WebSocketMessageType.Close)
    {
      await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "OK",
        CancellationToken.None);
      return;
    }
    if (result.MessageType == WebSocketMessageType.Binary)
    {
      await ws.CloseAsync(WebSocketCloseStatus.InvalidMessageType,
        "I don't do binary", CancellationToken.None);
      return;
    }
    int count = result.Count;
    while (!result.EndOfMessage)
    {
      if (count >= buffer.Length)
      {
        await ws.CloseAsync(WebSocketCloseStatus.InvalidPayloadData,
          "That's too long", CancellationToken.None);
        return;
      }
      segment =
        new ArraySegment<byte>(buffer, count, buffer.Length - count);
      result = await ws.ReceiveAsync(segment, CancellationToken.None);
      count += result.Count;
    }
    var message = Encoding.UTF8.GetString(buffer, 0, count);
    Console.WriteLine("> " + message);
  }
}

此处,我使用 ConnectAsync 方法建立连接并执行 WebSocket 握手。 注意,URL 使用新的“ws”URI 方案来将此标识为 WebSocket 端点。 对于 HTTP,用于 ws 的默认端口为端口 80。 还定义了“wss”方案,以表示安全的 TLS 连接并使用对应的端口 443。 然后,客户端在循环中调用 ReceiveAsync,以接收服务器愿意发送的尽可能多的帧。 一旦收到,首先将检查帧,以查看它是否表示结束控制帧。 在此,客户端通过发送其自己的结束帧来进行响应,同时允许服务器迅速结束连接。 然后,客户端检查此帧是否包含二进制数据,如果包括,则它将结束连接,并显示一个错误指示不支持此帧类型。 最后,可以读取帧数据。 若要容纳分片的消息,while 循环会一直等待,直到收到最后一个片段。 新的 ArraySegment 结构用于管理缓冲区偏移,以便正确地重新组合各帧。

WinRT 客户端

Windows 运行时对于 WebSocket 协议的支持受到的限制有点多。 只支持客户端,必须完全缓冲分片的 UTF-8 消息,然后才能读取它们。 只能使用此 API 对二进制消息进行流传输。 图 3 提供了一个也可用于连接到图 1 中所示服务器的客户端的简单示例。

图 3 使用 Windows 运行时的 WebSocket 客户端

static async Task Client()
{
  MessageWebSocket ws = new MessageWebSocket();
  ws.Control.MessageType = SocketMessageType.Utf8;
  ws.MessageReceived += (sender, args) =>
  {
    var reader = args.GetDataReader();
    var message = reader.ReadString(reader.UnconsumedBufferLength);
    Debug.WriteLine(message);
  };
  ws.Closed += (sender, args) =>
  {
    ws.Dispose();
  };
  var uri = new Uri("ws://localhost:8000/ws/");
  await ws.ConnectAsync(uri);
}

此示例尽管也是用 C# 编写的,但其大部分依赖于事件处理程序,C# 异步方法用处不大,只能允许 MessageWebSocket 对象以异步方式连接。 代码相当简单,但有点古怪。 一旦收到并可以读取整个(可能是分片的)消息后,就会调用 MessageReceived 事件处理程序。 即使已收到整个消息并且它只能是 UTF-8 字符串,它也存储在流中,并且必须使用 DataReader 对象来读取内容并返回字符串。 最后,Closed 事件处理程序让您了解服务器已发送了结束控制帧,但对于 .NET ClientWebSocket 类,您仍要负责将结束控制帧发回到服务器。 然而,MessageWebSocket 类只在对象自身立即要损坏之前才发送此帧。 为了使这种情况在 C# 立即发生,我需要调用 Dispose 方法。

原型 JavaScript 客户端

很少有人怀疑 JavaScript 是 WebSocket 协议在其中发挥最大作用的环境并且 API 极其简单。 下面详细介绍了它连接到图 1 中所示的服务器所需的所有代码:

var ws = new WebSocket("ws://localhost:8000/ws/");
ws.onmessage = function (args)
{
  var time = args.data;
  ...
};

与 Windows 中的其他 API 不同,浏览器负责在收到结束控制帧时自动结束 WebSocket 连接。 当然,您可以显式结束连接或处理 onclose 事件,但您不需要采取任何进一步的措施就可以完成结束握手。

用于 C++ 的 WinHTTP 客户端

当然,也可以从本机 C++ 中使用 WinRT WebSocket 客户端 API,但如果您正在寻求更高的控制措施,则 WinHTTP 非常适合您。 图 4 提供了使用 WinHTTP 连接到图 1 中所示服务器的简单示例。 此示例以同步模式使用 WinHTTP API 以实现简洁性,但其效果与采用异步方式一样好。

图 4 使用 WinHTTP 的 WebSocket 客户端

auto s = WinHttpOpen( ...
);
auto c = WinHttpConnect(s, L"localhost", 8000, 0);
auto r = WinHttpOpenRequest(c, nullptr, L"/ws/", ...
);
WinHttpSetOption(r, WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, nullptr, 0);
WinHttpSendRequest(r, ...
);
VERIFY(WinHttpReceiveResponse(r, nullptr));
DWORD status;
DWORD size = sizeof(DWORD);
WinHttpQueryHeaders(r,
  WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
  WINHTTP_HEADER_NAME_BY_INDEX,
  &status,
  &size,
  WINHTTP_NO_HEADER_INDEX);
ASSERT(HTTP_STATUS_SWITCH_PROTOCOLS == status);
auto ws = WinHttpWebSocketCompleteUpgrade(r, 0);
char buffer[1024];
DWORD count;
WINHTTP_WEB_SOCKET_BUFFER_TYPE type;
while (NO_ERROR ==
  WinHttpWebSocketReceive(ws, buffer, sizeof(buffer), &count, &type))
{
  if (WINHTTP_WEB_SOCKET_CLOSE_BUFFER_TYPE == type)
  {
    WinHttpWebSocketClose(
      ws, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0);
    break;
  }
  if (WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE == type ||
    WINHTTP_WEB_SOCKET_BINARY_FRAGMENT_BUFFER_TYPE == type)
  {
    WinHttpWebSocketClose(
      ws, WINHTTP_WEB_SOCKET_INVALID_DATA_TYPE_CLOSE_STATUS, nullptr, 0);
    break;
  }
  std::string message(buffer, count);
  while (WINHTTP_WEB_SOCKET_UTF8_FRAGMENT_BUFFER_TYPE == type)
  {
    WinHttpWebSocketReceive(ws, buffer, sizeof(buffer), &count, &type);
    message.append(buffer, count);
  }
  printf("> %s\n", message.c_str());
}

对于所有 WinHTTP 客户端,您都需要创建 WinHTTP 会话、连接和请求对象。 这没有什么新内容,因此我跳过一些细节。 在实际发送请求之前,您需要对请求设置新的 WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET 选项,以指示 WinHTTP 执行 WebSocket 握手。 然后,就可以使用 WinHttpSendRequest 函数发送此请求了。 接着,使用常规的 WinHttpReceiveResponse 函数来等待响应,在这种情况下将包括 WebSocket 握手的结果。 一如既往,若要确定请求的结果,应专门调用 WinHttpQueryHeaders 函数来读取从服务器返回的状态代码。 此时,WebSocket 连接已建立,您可以开始直接使用它。 WinHTTP API 自然会为您处理分帧事宜,此功能是通过一个新的 WinHTTP WebSocket 对象公开的,而此对象是通过对请求对象调用 WinHttpWebSocketCompleteUpgrade 函数检索到的。

现在完成了从服务器接收消息的过程(至少在概念上是如此),其方式与图 2 中的示例非常相似。 WinHttpWebSocketReceive 函数等待接收下一个数据帧。 它还让您读取任何类型的 WebSocket 消息的片段,图 4 中的示例说明了如何在循环中完成此过程。 如果收到了结束控制帧,则将使用 WinHttpWebSocketClose 函数将匹配的结束帧发送到服务器。 如果收到了二进制数据帧,则会以相似方式结束连接。 请记住,这样只能结束 WebSocket 连接。 您仍然需要调用 WinHttpCloseHandle 来释放 WinHTTP WebSocket 对象,因为您必须对您拥有的所有 WinHTTP 对象结束连接。 句柄包装类(例如我在 2011 年 7 月的专栏文章“C++ 和 Windows API”中介绍的一个句柄包装类,文章网址为 msdn.microsoft.com/magazine/hh288076)将获得成功。

WebSocket 协议是 Web 应用程序领域中的一项极大的创新技术,尽管它比较简单,但它对于较大的 TCP/IP 协议套件而言的确是一个受欢迎的新成员。 我毫不怀疑 WebSocket 协议很快就会变得像 HTTP 本身一样无处不在,帮助各种应用程序和所连接的系统更轻松和更高效地进行通信。 Windows 8 已履行了自己的那部分责任,它提供了一套全面的 API 来构建 WebSocket 客户端和服务器。

Kenny Kerr 是一位热衷于本机 Windows 开发的软件专家。 您可以通过 kennykerr.ca 与他联系。

衷心感谢以下技术专家对本文的审阅: Piotr Kulaga 和 Henri-Charles Machalani