Boost-Asio-C---网络编程秘籍-全-
Boost.Asio C++ 网络编程秘籍(全)
原文:
zh.annas-archive.org/md5/acd48d6a16020ddf6844ca08db15a69f译者:飞龙
前言
在当今以信息为中心的全球化世界中,电信已成为我们生活的重要组成部分。它们渗透到我们日常活动的几乎每个方面,从个人到专业。有时,未能正确及时地传达信息可能会导致重大物质资产损失,甚至造成人员伤亡。
因此,在开发电信软件时,提供最高级别的可靠性非常重要。然而,由于该领域的固有复杂性和现代操作系统提供的低级工具的意外复杂性,这可能是一项真正的挑战任务。
Boost.Asio 库旨在通过引入类型系统和利用面向对象方法来减少意外复杂性,并通过提供高度的可重用性来缩短开发时间。此外,由于该库是跨平台的,使用它实现的应用程序可以在多个平台上构建,这进一步提高了软件质量,同时降低了成本。
本书包含超过 30 个食谱——针对网络编程领域中经常(以及不经常)出现的各种任务的逐步解决方案。所有食谱都利用了 Boost.Asio 库提供的功能,展示了将库应用于执行典型任务和解决不同问题的最佳实践。
本书涵盖的内容
第一章,基础知识,介绍了 Boost.Asio 库提供的基本类,并演示了如何执行基本操作,例如解析 DNS 名称、连接套接字、接受连接等。
第二章,I/O 操作,演示了如何执行单个网络 I/O 操作,包括同步和异步操作。
第三章,实现客户端应用程序,包含演示如何实现不同类型客户端应用程序的食谱。
第四章,实现服务器应用程序,包含演示如何实现不同类型服务器应用程序的食谱。
第五章,HTTP 和 SSL/TLS,涵盖了 HTTP 和 SSL/TLS 协议实现的高级主题。
第六章,其他主题,包括讨论不太流行但仍然相当重要的主题的食谱,例如计时器、套接字选项、复合缓冲区等。
您需要为本书准备的内容
要编译本书中提供的示例,您需要在 Windows 上安装 Visual Studio 2012+ 或者在 Unix 平台上安装 GCC 4.7+。
本书面向的对象
如果你想要使用 Boost.Asio 库增强你的 C++网络编程技能,并了解分布式应用程序开发的原理,这本书正是你所需要的。本书的先决条件是具备 C++11 的基本知识。为了从本书中获得最大收益并理解高级主题,你需要一些多线程的背景经验。
部分
在这本书中,你会找到一些频繁出现的标题(准备就绪、如何操作、工作原理、更多内容,以及相关内容)。
为了清楚地说明如何完成食谱,我们使用以下部分如下:
准备就绪
本节告诉你在食谱中可以期待什么,并描述了为食谱所需的任何软件或初步设置。
如何操作...
本节包含遵循食谱所需的步骤。
工作原理...
本节通常包含对前节发生事件的详细解释。
更多内容…
本节包含有关食谱的附加信息,以便使读者对食谱有更深入的了解。
相关内容
本节提供对食谱中其他有用信息的链接。
规范
在这本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“在 Boost.Asio 中,被动套接字由asio::ip::tcp::acceptor类表示。”
代码块如下设置:
std::shared_ptr<boost::asio::ip::tcp::socket> m_sock;
boost::asio::streambuf m_request;
std::map<std::string, std::string> m_request_headers;
std::string m_requested_resource;
当我们希望将你的注意力引向代码块中的特定部分时,相关的行或项目将以粗体显示:
std::shared_ptr<boost::asio::ip::tcp::socket> m_sock;
boost::asio::streambuf m_request;
std::map<std::string, std::string> m_request_headers;
std::string m_requested_resource;
新术语和重要词汇以粗体显示。
注意
警告或重要注意事项以如下方框显示。
小贴士
小贴士和技巧看起来像这样。
读者反馈
我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
如要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。
如果你在一个主题上有所专长,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com的账户下载所有已购买 Packt Publishing 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
勘误
尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一情况,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
在互联网上侵犯版权材料是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>联系我们,并附上疑似侵权材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。
第一章. 基础
在本章中,我们将涵盖以下内容:
-
创建端点
-
创建主动套接字
-
创建被动套接字
-
解析 DNS 名称
-
将套接字绑定到端点
-
连接套接字
-
接受连接
简介
计算机网络和通信协议显著提高了现代软件的能力,允许不同的应用程序或同一应用程序的独立部分相互通信,以实现共同的目标。有些应用程序以通信为主要功能,例如,即时通讯工具、电子邮件服务器和客户端、文件下载软件等。其他应用程序将网络通信层作为基本组件,在其之上构建主要功能。这类应用程序的例子包括网络浏览器、网络文件系统、分布式数据库管理系统、媒体流软件、在线游戏、支持网络多人游戏的离线游戏,以及许多其他应用。此外,如今几乎任何应用程序除了其主要功能外,还提供补充功能,包括网络通信。这类功能中最突出的例子是在线注册和自动软件更新。在后一种情况下,更新包从应用程序开发者的远程服务器下载,并安装到用户的计算机或移动设备上。
由两个或更多部分组成的应用程序,每一部分都在独立的计算设备上运行,并通过计算机网络与其他部分进行通信,这种应用程序被称为分布式应用程序。例如,一个网络服务器和一个网络浏览器可以共同被视为一个复杂的分布式应用程序。运行在用户计算机上的浏览器与运行在不同远程计算机上的网络服务器进行通信,以实现一个共同的目标——传输和显示用户请求的网页。
与在单台计算机上运行的传统应用程序相比,分布式应用程序提供了显著的好处。其中最有价值的是以下几项:
-
在两个或更多远程计算设备之间传输数据的能力。这是分布式软件绝对明显且最有价值的优势。
-
能够在网络中的计算机上连接并安装特殊软件,创建能够执行在单台计算机上无法在足够的时间内完成的任务的强大计算系统。
-
能够在网络中有效地存储和共享数据。在计算机网络中,单个设备可以用作数据存储来存储大量数据,而其他设备可以在必要时轻松请求该数据的一部分,而无需在每个设备上保留所有数据的副本。例如,考虑拥有数亿个网站的大型数据中心。最终用户可以通过将请求发送到服务器(通常是互联网)来随时请求所需的网页。无需在用户的设备上保留网站的副本。有一个单一的数据存储(一个网站),数百万用户可以在需要时从这个存储中请求数据。
对于运行在不同计算设备上的两个应用程序要相互通信,它们需要就通信协议达成一致。当然,分布式应用程序的开发者可以自由实现自己的协议。然而,至少有两个原因使得这种情况很少发生。首先,开发这样的协议是一项极其复杂且耗时的任务。其次,这样的协议已经定义、标准化,并且甚至已经在包括 Windows、Mac OS X 和大多数 Linux 发行版在内的所有流行操作系统中实现。
这些协议由 TCP/IP 标准定义。不要被标准的名称所迷惑;它不仅定义了 TCP 和 IP,还定义了许多其他协议,包括一个 TCP/IP 协议栈,其中每个层级都包含一个或多个协议。分布式软件开发者通常处理传输层协议,如 TCP 或 UDP。底层协议通常对开发者隐藏,并由操作系统和网络设备处理。
在这本书中,我们仅涉及满足大多数分布式软件开发者需求的 TCP 和 UDP 协议。如果读者对 TCP/IP 协议栈、OSI 模型或 TCP 和 UDP 协议不熟悉,强烈建议阅读这些主题的相关理论。尽管本书提供了关于这些主题的一些简要信息,但它主要关注在分布式软件开发中使用 TCP 和 UDP 协议的实践方面。
TCP 协议是一种传输层协议,具有以下特点:
-
它是可靠的,这意味着该协议保证消息按正确顺序交付或通知消息未交付。该协议包括错误处理机制,从而免去了开发者需要在应用程序中实现这些机制的需求。
-
它假设建立逻辑连接。在应用程序可以通过 TCP 协议相互通信之前,它必须通过按照标准交换服务消息来建立逻辑连接。
-
它假设点对点通信模型。也就是说,只有两个应用程序可以通过单个连接进行通信。不支持多播消息。
-
它是面向流的。这意味着一个应用程序向另一个应用程序发送的数据被协议解释为字节流。在实践中,这意味着如果一个发送应用程序发送特定块的数据,不能保证它将以相同的块数据在单次传输中送达接收应用程序,也就是说,发送的消息可能会被分割成协议想要的那么多部分,并且每一部分都将单独发送,尽管它们是按正确顺序发送的。
UDP 协议是一种传输层协议,其特性与 TCP 协议的特性(在某种程度上是相反的)。以下是其特性:
-
它是不可靠的,这意味着如果发送者通过 UDP 协议发送消息,不能保证消息会被送达。该协议不会尝试检测或修复任何错误。错误处理的责任完全在开发者。
-
它是无连接的,这意味着在应用程序可以通信之前不需要建立连接。
-
它支持一对一和一对多通信模型。该协议支持多播消息。
-
它是面向数据报的。这意味着协议将数据解释为特定大小的消息,并尝试将它们作为一个整体交付。消息(数据报)要么作为一个整体交付,要么如果协议未能做到这一点,则根本不会交付。
由于 UDP 协议是不可靠的,它通常用于可靠的本地网络。要在互联网(一个不可靠的网络)上进行通信,开发者必须在应用程序中实现错误处理机制。
注意
当需要通过互联网进行通信时,由于可靠性,TCP 协议通常是最佳选择。
正如已经提到的,TCP 和 UDP 协议以及它们所需的底层协议由大多数流行的操作系统实现。分布式应用程序的开发者通过 API 可以使用协议实现。TCP/IP 标准没有标准化协议 API 实现;因此,存在几个 API 实现。然而,基于伯克利套接字 API的实现是最广泛使用的。
伯克利套接字 API 是 TCP 和 UDP 协议 API 的许多可能实现之一。这个 API 是在美国加利福尼亚大学伯克利分校(因此得名)的 20 世纪 80 年代初开发的。它围绕一个称为套接字的抽象对象的概念构建。这个名称是为了将这个对象与常见的电气插座类比。然而,由于伯克利套接字最终证明是一个显著更复杂的概念,这个想法似乎有些失败。
现在,Windows、Mac OS X 和 Linux 操作系统都实现了这个 API(尽管有一些细微的差异),软件开发者可以使用它来在开发分布式应用程序时使用 TCP 和 UDP 协议的功能。

尽管非常流行且广泛使用,但套接字 API 有几个缺陷。首先,因为它被设计为一个非常通用的 API,应该支持许多不同的协议,所以它相当复杂,并且有些难以使用。第二个缺陷是,这是一个 C 风格的函数式 API,类型系统较差,这使得它容易出错,并且更加难以使用。例如,套接字 API 没有提供表示套接字的单独类型。相反,使用内置类型int,这意味着任何int类型的值都可能错误地传递给期望套接字的函数,而编译器不会检测到这个错误。这可能导致运行时崩溃,其根本原因很难找到。
网络编程本身就很复杂,而使用低级的 C 风格套接字 API 则使其更加复杂且容易出错。Boost.Asio 是一个面向对象的 C++库,就像原始的套接字 API 一样,它围绕套接字的概念构建。简而言之,Boost.Asio 封装了原始套接字 API,并为开发者提供了面向对象的接口。它旨在以下几种方式中简化网络编程:
-
它隐藏了原始的 C 风格 API,并为用户提供了一个面向对象的 API
-
它提供了一个丰富的类型系统,这使得代码更易于阅读,并允许在编译时捕获许多错误
-
由于 Boost.Asio 是一个跨平台库,它简化了跨平台分布式应用程序的开发
-
它提供了辅助功能,例如散列-聚集 I/O 操作、基于流的 I/O、基于异常的错误处理以及其他功能
-
该库的设计使其可以相对容易地扩展以添加新的自定义功能
本章介绍了 Boost.Asio 的基本类,并演示了如何使用它们执行基本操作。
创建端点
一个典型的客户端应用程序,在它能够与服务器应用程序通信以使用其服务之前,必须获取服务器应用程序运行的主机的 IP 地址以及与其关联的协议端口号。由一个 IP 地址和一个协议端口号组成的值对,该值对唯一标识了计算机网络上特定主机上运行的特定应用程序,被称为端点。
客户端应用程序通常会从用户直接通过应用程序 UI 或作为命令行参数获取服务器应用程序的 IP 地址和端口号,或者从应用程序的配置文件中读取。
IP 地址可以表示为一个包含点十进制表示法的地址的字符串,如果它是 IPv4 地址(例如,192.168.10.112),或者如果它是 IPv6 地址(例如,FE36::0404:C3FA:EF1E:3829),则使用十六进制表示法。此外,服务器 IP 地址可以以间接形式提供给客户端应用程序,即包含 DNS 名称的字符串(例如,localhost或www.google.com)。另一种表示 IP 地址的方法是整数值。IPv4 地址表示为 32 位整数,IPv6 表示为 64 位整数。然而,由于可读性和可记忆性较差,这种表示法使用得非常少。
如果在客户端应用程序能够与服务器应用程序通信之前提供了 DNS 名称,它必须解析该 DNS 名称以获取运行服务器应用程序的主机的实际 IP 地址。有时,DNS 名称可能映射到多个 IP 地址,在这种情况下,客户端可能需要逐个尝试地址,直到找到可以工作的地址。我们将在本章后面考虑一个描述如何使用 Boost.Asio 解析 DNS 名称的配方。
服务器应用程序还需要处理端点。它使用端点来指定操作系统,它希望监听来自客户端的 IP 地址和协议端口号。如果运行服务器应用程序的主机只有一个网络接口并且分配给它一个 IP 地址,那么服务器应用程序在哪个地址上监听只有一个选择。然而,有时主机可能有多个网络接口和相应的多个 IP 地址。在这种情况下,服务器应用程序面临着一个难题,即选择一个合适的 IP 地址来监听传入的消息。问题是应用程序对诸如底层 IP 协议设置、数据包路由规则、映射到相应 IP 地址的 DNS 名称等细节一无所知。因此,对于服务器应用程序来说,预见客户端发送的消息将通过哪个 IP 地址发送到主机是一个相当复杂(有时甚至无法解决)的任务。
如果服务器应用程序只选择一个 IP 地址来监听传入的消息,它可能会错过路由到主机其他 IP 地址的消息。因此,服务器应用程序通常希望监听主机上可用的所有 IP 地址。这保证了服务器应用程序将接收到达任何 IP 地址和特定协议端口号的所有消息。
总结来说,端点有两个目标:
-
客户端应用程序使用端点来指定它想要与之通信的特定服务器应用程序。
-
服务器应用程序使用端点来指定它希望接收来自客户端的传入消息的本地 IP 地址和端口号。如果主机上有多个 IP 地址,服务器应用程序将希望创建一个特殊的端点,以一次表示所有 IP 地址。
本菜谱解释了如何在客户端和服务器应用程序中创建端点。
准备工作
在创建端点之前,客户端应用程序必须获取表示它将与之通信的服务器的原始 IP 地址和协议端口号。另一方面,由于服务器应用程序通常在所有 IP 地址上监听传入消息,因此它只需要获取一个监听端口号。
在这里,我们不考虑应用程序如何获取原始 IP 地址或端口号。在下面的菜谱中,我们假设 IP 地址和端口号已经被应用程序获取,并在相应算法的开始时可用。
如何做到这一点...
以下算法和相应的代码示例演示了创建端点的两种常见场景。第一个示例演示了客户端应用程序如何创建端点以指定它想要与之通信的服务器。第二个示例演示了服务器应用程序如何创建端点以指定它希望在哪些 IP 地址和端口号上监听来自客户端的传入消息。
在客户端创建端点以指定服务器
以下算法描述了在客户端应用程序中执行以创建指定客户端想要通信的服务器应用程序的端点的步骤。最初,如果这是一个 IPv4 地址,则 IP 地址以点分十进制表示法表示为字符串;如果这是一个 IPv6 地址,则表示为十六进制表示法:
-
获取服务器应用程序的 IP 地址和端口号。IP 地址应指定为点分十进制(IPv4)或十六进制(IPv6)表示法的字符串。
-
将原始 IP 地址表示为
asio::ip::address类的对象。 -
从步骤 2 中创建的地址对象和端口号实例化
asio::ip::tcp::endpoint类对象。 -
端点已准备好用于在 Boost.Asio 通信相关方法中指定服务器应用程序。
以下代码示例演示了算法的可能实现:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. Assume that the client application has already
// obtained the IP-address and the protocol port number.
std::string raw_ip_address = "127.0.0.1";
unsigned short port_num = 3333;
// Used to store information about error that happens
// while parsing the raw IP-address.
boost::system::error_code ec;
// Step 2\. Using IP protocol version independent address
// representation.
asio::ip::address ip_address =
asio::ip::address::from_string(raw_ip_address, ec);
if (ec.value() != 0) {
// Provided IP address is invalid. Breaking execution.
std::cout
<< "Failed to parse the IP address. Error code = "
<< ec.value() << ". Message: " << ec.message();
return ec.value();
}
// Step 3.
asio::ip::tcp::endpoint ep(ip_address, port_num);
// Step 4\. The endpoint is ready and can be used to specify a
// particular server in the network the client wants to
// communicate with.
return 0;
}
创建服务器端点
以下算法描述了在服务器应用程序中执行以创建端点的步骤,该端点指定主机上可用的所有 IP 地址以及服务器应用程序希望监听来自客户端的传入消息的端口号:
-
获取服务器将监听传入请求的协议端口号。
-
创建一个表示服务器运行的主机上所有可用 IP 地址的
asio::ip::address对象的特殊实例。 -
从第二步中创建的地址对象和一个端口号中实例化一个
asio::ip::tcp::endpoint类的对象。 -
端点已准备好用于指定给操作系统,服务器希望监听所有 IP 地址和特定协议端口号上的传入消息。
以下代码示例演示了算法的可能实现。请注意,假设服务器应用程序将通过 IPv6 协议进行通信:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. Here we assume that the server application has
//already obtained the protocol port number.
unsigned short port_num = 3333;
// Step 2\. Create special object of asio::ip::address class
// that specifies all IP-addresses available on the host. Note
// that here we assume that server works over IPv6 protocol.
asio::ip::address ip_address = asio::ip::address_v6::any();
// Step 3.
asio::ip::tcp::endpoint ep(ip_address, port_num);
// Step 4\. The endpoint is created and can be used to
// specify the IP addresses and a port number on which
// the server application wants to listen for incoming
// connections.
return 0;
}
它是如何工作的...
让我们考虑第一个代码示例。它实现的算法适用于充当客户端的应用程序,这是一个主动与服务器建立通信会话的应用程序。客户端应用程序需要提供服务器的一个 IP 地址和协议端口号。这里我们假设这些值已经获得,并在算法开始时可用,这使得第一步的细节是已知的。
获得原始 IP 地址后,客户端应用程序必须以 Boost.Asio 类型系统的术语表示它。Boost.Asio 提供了三个用于表示 IP 地址的类:
-
asio::ip::address_v4: 这代表一个 IPv4 地址 -
asio::ip::address_v6: 这代表一个 IPv6 地址 -
asio::ip::address: 这个与 IP 协议版本无关的类可以表示 IPv4 和 IPv6 地址
在我们的示例中,我们使用asio::ip::address类,这使得客户端应用程序与 IP 协议版本无关。这意味着它可以透明地与 IPv4 和 IPv6 服务器一起工作。
在第二步中,我们使用asio::ip::address类的静态方法from_string()。此方法接受一个以字符串形式表示的原始 IP 地址,解析并验证该字符串,实例化一个asio::ip::address类的对象,并将其返回给调用者。此方法有四个重载。在我们的示例中,我们使用这个方法:
static asio::ip::address from_string(
const std::string & str,
boost::system::error_code & ec);
此方法非常有用,因为它检查传递给它的字符串参数是否包含有效的 IPv4 或 IPv6 地址,如果是,则实例化相应的对象。如果地址无效,该方法将通过第二个参数指定一个错误。这意味着此函数可以用于验证原始用户输入。
在第三步中,我们实例化一个boost::asio::ip::tcp::endpoint类的对象,将其构造函数传递 IP 地址和协议端口号。现在,ep对象可以用于在 Boost.Asio 通信相关函数中指定服务器应用程序。
第二个示例有类似的想法,尽管它与第一个示例略有不同。服务器应用程序通常只提供它应该监听传入消息的协议端口号。不提供 IP 地址,因为服务器应用程序通常希望监听主机上所有可用的 IP 地址上的传入消息,而不仅仅是特定的一个。
为了表示 主机上所有可用的 IP 地址 的概念,asio::ip::address_v4 和 asio::ip::address_v6 类提供了一个静态方法 any(),它实例化了一个代表该概念的相应类的特殊对象。在第 2 步中,我们使用了 asio::ip::address_v6 类的 any() 方法来实例化这样一个特殊对象。
注意,IP 协议版本无关的类 asio::ip::address 不提供 any() 方法。服务器应用程序必须明确指定它是否想要通过 asio::ip::address_v4 或 asio::ip::address_v6 类的 any() 方法返回的对象来接收 IPv4 或 IPv6 地址上的请求。在我们的第二个示例的第 2 步中,我们假设我们的服务器通过 IPv6 协议进行通信,因此调用了 asio::ip::address_v6 类的 any() 方法。
在步骤 3 中,我们创建了一个端点对象,它代表主机上所有可用的 IP 地址和特定的协议端口号。
还有更多...
在我们之前的两个示例中,我们使用了在 asio::ip::tcp 类作用域中声明的 endpoint 类。如果我们查看 asio::ip::tcp 类的声明,我们会看到如下内容:
class tcp
{
public:
/// The type of a TCP endpoint.
typedef basic_endpoint<tcp> endpoint;
//...
}
这意味着这个 endpoint 类是 basic_endpoint<> 模板类的特化,旨在用于通过 TCP 协议进行通信的客户端和服务器。
然而,创建可用于通过 UDP 协议进行通信的客户端和服务器端点的过程同样简单。为了表示这样的端点,我们需要使用在 asio::ip::udp 类作用域中声明的 endpoint 类。以下代码片段演示了如何声明这个 endpoint 类:
class udp
{
public:
/// The type of a UDP endpoint.
typedef basic_endpoint<udp> endpoint;
//...
}
例如,如果我们想在客户端应用程序中创建一个端点,指定我们想要通过 UDP 协议与之通信的服务器,我们只需稍微更改我们示例中步骤 3 的实现。以下是更改后的步骤,其中更改部分已突出显示:
// Step 3.
asio::ip::udp::endpoint ep(ip_address, port_num);
其他所有代码都不需要更改,因为它们与传输协议无关。
在我们第二个示例的实现中,步骤 3 的相同微小更改即可从使用 TCP 通信的服务器切换到使用 UDP 通信的服务器。
参见
-
将套接字绑定到端点 菜单解释了在服务器应用程序中如何使用端点对象
-
连接套接字 菜单解释了在客户端应用程序中如何使用端点对象
创建活动套接字
TCP/IP 标准对我们关于套接字的内容没有说明。此外,它几乎没有告诉我们如何实现 TCP 或 UDP 协议软件 API,通过这个 API,应用程序可以消费这些软件功能。
如果我们查看 RFC 文档#793的第 3.8 节接口,该文档描述了 TCP 协议,我们会发现它只包含 TCP 协议软件 API 必须提供的最小功能集的功能要求。协议软件的开发者对 API 的所有其他方面拥有完全控制权,例如 API 的结构、组成 API 的函数的名称、对象模型、涉及的抽象、附加辅助函数等。每个 TCP 协议软件的开发者都可以自由选择实现其协议接口的方式。
对于 UDP 协议,情况也是一样的:RFC 文档#768中只描述了它的一小部分功能要求,该文档专门针对它。UDP 协议软件 API 的所有其他方面的控制权都保留给了该 API 的开发者。
如本章引言中已提到的,伯克利套接字 API 是最流行的 TCP 和 UDP 协议的 API。它是围绕套接字的概念设计的——一个表示通信会话上下文的抽象对象。在我们能够执行任何网络 I/O 操作之前,我们必须首先分配一个套接字对象,然后将每个 I/O 操作与之关联。
Boost.Asio 借鉴了许多伯克利套接字 API 的概念,并且与之非常相似,我们可以称之为“面向对象的伯克利套接字 API”。Boost.Asio 库包括一个代表套接字概念的类,它提供了与伯克利套接字 API 中找到的类似接口方法。
基本上,有两种类型的套接字。一种是为了发送和接收数据到远程应用程序或与之建立连接建立过程的套接字被称为活动套接字,而被动套接字是用来被动等待来自远程应用程序的连接请求的。被动套接字不参与用户数据传输。我们将在本章后面讨论被动套接字。
这个菜谱解释了如何创建和打开活动套接字。
如何做到这一点...
以下算法描述了在客户端应用程序中执行以创建和打开活动套接字所需的步骤:
-
创建
asio::io_service类的实例或使用之前创建的实例。 -
创建一个代表传输层协议(TCP 或 UDP)以及套接字打算通信的底层 IP 协议(IPv4 或 IPv6)版本的类的对象。
-
创建一个代表所需协议类型的套接字的实例。将
asio::io_service类的对象传递给套接字构造函数。 -
调用套接字的
open()方法,传递代表步骤 2 中创建的协议的对象作为参数。
以下代码示例演示了算法的可能实现。假设套接字打算用于通过 TCP 协议和 IPv4 作为底层协议进行通信:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. An instance of 'io_service' class is required by
// socket constructor.
asio::io_service ios;
// Step 2\. Creating an object of 'tcp' class representing
// a TCP protocol with IPv4 as underlying protocol.
asio::ip::tcp protocol = asio::ip::tcp::v4();
// Step 3\. Instantiating an active TCP socket object.
asio::ip::tcp::socket sock(ios);
// Used to store information about error that happens
// while opening the socket.
boost::system::error_code ec;
// Step 4\. Opening the socket.
sock.open(protocol, ec);
if (ec.value() != 0) {
// Failed to open the socket.
std::cout
<< "Failed to open the socket! Error code = "
<< ec.value() << ". Message: " << ec.message();
return ec.value();
}
return 0;
}
它是如何工作的...
在步骤 1 中,我们实例化 asio::io_service 类的一个对象。这个类是 Boost.Asio I/O 基础设施的一个核心组件。它提供了对底层操作系统的网络 I/O 服务的访问。Boost.Asio 套接字通过这个类的对象访问这些服务。因此,所有套接字类构造函数都需要一个 asio::io_service 对象作为参数。我们将在接下来的章节中更详细地考虑 asio::io_service 类。
在下一步中,我们创建 asio::ip::tcp 类的一个实例。这个类代表一个 TCP 协议。它不提供任何功能,而更像是一个包含描述协议的一组值的结构。
asio::ip::tcp 类没有公共构造函数。相反,它提供了两个静态方法,asio::ip::tcp::v4() 和 asio::ip::tcp::v6(),它们返回一个 asio::ip::tcp 类的对象,该对象代表使用底层 IPv4 或 IPv6 协议的 TCP 协议。
此外,asio::ip::tcp 类包含了一些基本类型的声明,这些类型旨在与 TCP 协议一起使用。其中包含 asio::tcp::endpoint、asio::tcp::socket、asio::tcp::acceptor 以及其他类型。让我们看看在 boost/asio/ip/tcp.hpp 文件中找到的这些声明:
namespace boost {
namespace asio {
namespace ip {
// ...
class tcp
{
public:
/// The type of a TCP endpoint.
typedef basic_endpoint<tcp> endpoint;
// ...
/// The TCP socket type.
typedef basic_stream_socket<tcp> socket;
/// The TCP acceptor type.
typedef basic_socket_acceptor<tcp> acceptor;
// ...
在步骤 3 中,我们创建 asio::ip::tcp::socket 类的一个实例,将 asio::io_service 类的对象作为参数传递给其构造函数。注意,这个构造函数不会分配底层操作系统的套接字对象。真正的操作系统套接字是在步骤 4 中分配的,当我们调用 open() 方法并将指定协议的对象作为参数传递给它时。
在 Boost.Asio 中,打开 套接字意味着将其与描述特定协议的完整参数集关联起来,该协议是套接字打算通过它进行通信的。当 Boost.Asio 套接字对象提供这些参数时,它有足够的信息来分配底层操作系统的真实套接字对象。
asio::ip::tcp::socket 类提供了一个接受协议对象作为参数的构造函数。这个构造函数构建一个套接字对象并打开它。注意,如果这个构造函数失败,它会抛出一个类型为 boost::system::system_error 的异常。以下是一个示例,展示了我们如何将上一个示例中的步骤 3 和 4 结合起来:
try {
// Step 3 + 4 in single call. May throw.
asio::ip::tcp::socket sock(ios, protocol);
} catch (boost::system::system_error & e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: "<< e.what();
}
更多内容...
之前的示例演示了如何创建一个用于通过 TCP 协议通信的主动套接字。创建用于通过 UDP 协议通信的套接字的过程几乎相同。
以下示例演示了如何创建一个活动 UDP 套接字。假设该套接字将用于通过 UDP 协议与 IPv6 作为底层协议进行通信。由于示例与上一个示例非常相似,因此不需要提供解释,应该不难理解:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. An instance of 'io_service' class is required by
// socket constructor.
asio::io_service ios;
// Step 2\. Creating an object of 'udp' class representing
// a UDP protocol with IPv6 as underlying protocol.
asio::ip::udp protocol = asio::ip::udp::v6();
// Step 3\. Instantiating an active UDP socket object.
asio::ip::udp::socket sock(ios);
// Used to store information about error that happens
// while opening the socket.
boost::system::error_code ec;
// Step 4\. Opening the socket.
sock.open(protocol, ec);
if (ec.value() != 0) {
// Failed to open the socket.
std::cout
<< "Failed to open the socket! Error code = "
<< ec.value() << ". Message: " << ec.message();
return ec.value();
}
return 0;
}
参见
-
创建一个被动套接字 烹饪法,正如其名称所暗示的,提供了对被动套接字的讨论,并展示了它们的使用
-
连接套接字 烹饪法解释了活动套接字的一种用途,即连接到远程应用程序
创建一个被动套接字
被动套接字或接受器套接字是一种用于等待通过 TCP 协议与远程应用程序建立连接请求的套接字类型。这个定义有两个重要的含义:
-
被动套接字仅在服务器应用程序或可能同时扮演客户端和服务器角色的混合应用程序中使用。
-
被动套接字仅适用于 TCP 协议。由于 UDP 协议不涉及连接建立,因此在通过 UDP 进行通信时不需要被动套接字。
本烹饪法解释了如何在 Boost.Asio 中创建和打开一个被动套接字。
如何实现...
在 Boost.Asio 中,被动套接字由 asio::ip::tcp::acceptor 类表示。类的名称暗示了该类对象的关键功能——监听并 接受 或处理传入的连接请求。
以下算法描述了执行创建接受器套接字所需的步骤:
-
创建一个
asio::io_service类的实例或使用之前已创建的实例。 -
创建一个代表 TCP 协议及其所需底层 IP 协议版本(IPv4 或 IPv6)的
asio::ip::tcp类的对象。 -
创建一个代表接受器套接字的
asio::ip::tcp::acceptor类的对象,将asio::io_service类的对象传递给其构造函数。 -
调用接受器套接字的
open()方法,将代表步骤 2 中创建的协议的对象作为参数传递。
以下代码示例演示了算法的可能实现。假设接受器套接字打算在 TCP 协议和 IPv6 作为底层协议的情况下使用:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. An instance of 'io_service' class is required by
// socket constructor.
asio::io_service ios;
// Step 2\. Creating an object of 'tcp' class representing
// a TCP protocol with IPv6 as underlying protocol.
asio::ip::tcp protocol = asio::ip::tcp::v6();
// Step 3\. Instantiating an acceptor socket object.
asio::ip::tcp::acceptor acceptor(ios);
// Used to store information about error that happens
// while opening the acceptor socket.
boost::system::error_code ec;
// Step 4\. Opening the acceptor socket.
acceptor.open(protocol, ec);
if (ec.value() != 0) {
// Failed to open the socket.
std::cout
<< "Failed to open the acceptor socket!"
<< "Error code = "
<< ec.value() << ". Message: " << ec.message();
return ec.value();
}
return 0;
}
它是如何工作的...
由于接受器套接字与活动套接字非常相似,因此创建它们的程序几乎相同。因此,这里我们只简要地浏览一下示例代码。有关每个步骤和每个对象在程序中的详细说明,请参阅 创建一个活动套接字 烹饪法。
在步骤 1 中,我们创建一个 asio::io_service 类的实例。这个类是所有需要访问底层操作系统服务的 Boost.Asio 组件所需要的。
在步骤 2 中,我们创建了一个代表具有 IPv6 作为底层协议的 TCP 协议的对象。
然后在步骤 3 中,我们创建asio::ip::tcp::acceptor类的实例,将其构造函数的参数传递给asio::io_service类的对象。正如在活动套接字的情况下,这个构造函数实例化了 Boost.Asio 的asio::ip::tcp::acceptor类对象,但并没有分配底层操作系统的实际套接字对象。
操作系统套接字对象在步骤 4 中分配,在那里我们打开接受器套接字对象,调用其open()方法并将协议对象作为参数传递给它。如果调用成功,接受器套接字对象将被打开并可用于开始监听传入的连接请求。否则,boost::system::error_code类的ec对象将包含错误信息。
参见
- “创建一个活动套接字”配方提供了更多关于
asio::io_service和asio::ip::tcp类的详细信息。
解析 DNS 名称
原始 IP 地址对人类来说非常不便感知和记忆,尤其是如果它们是 IPv6 地址。看看192.168.10.123(IPv4)或8fee:9930:4545:a:105:f8ff:fe21:67cf(IPv6)。记住这些数字和字母的序列可能对任何人都是一个挑战。
为了使网络中的设备能够用人类友好的名称进行标记,引入了域名系统(DNS)。简而言之,DNS 是一个分布式命名系统,允许将人类友好的名称与计算机网络中的设备关联起来。DNS 名称或域名是一个表示计算机网络中设备名称的字符串。
要精确地说,DNS 名称是一个或多个 IP 地址的别名,而不是设备。它并不指代特定的物理设备,而是指可以分配给设备的 IP 地址。因此,DNS 在指定网络中特定服务器应用时引入了间接层次。
DNS 充当一个分布式数据库,存储 DNS 名称到相应 IP 地址的映射,并提供一个接口,允许查询映射到特定 DNS 名称的 IP 地址。将 DNS 名称转换为相应 IP 地址的过程称为DNS 名称解析。现代网络操作系统包含可以查询 DNS 以解析 DNS 名称并提供应用程序用于执行 DNS 名称解析的接口的功能。
当给定一个 DNS 名称时,在客户端能够与相应的服务器应用通信之前,它必须首先解析该名称以获取与该名称关联的 IP 地址。
该配方解释了如何使用 Boost.Asio 执行 DNS 名称解析。
如何做到这一点...
以下算法描述了在客户端应用中执行以解析 DNS 名称以获取主机(零个或多个)运行的服务器应用(零个或多个)的 IP 地址(零个或多个)所需的步骤:
-
获取指定服务器应用程序的 DNS 名称和协议端口号,并将它们表示为字符串。
-
创建一个
asio::io_service类的实例或使用之前创建的实例。 -
创建一个表示 DNS 名称解析查询的
resolver::query类的对象。 -
创建一个适合所需协议的 DNS 名称解析器类的实例。
-
调用解析器的
resolve()方法,将第 3 步中创建的查询对象作为参数传递给它。
以下代码示例演示了算法的可能实现。假设客户端应用程序旨在通过 TCP 协议和 IPv6 作为底层协议与服务器应用程序通信。此外,假设服务器 DNS 名称和端口号已经由客户端应用程序获取,并以字符串的形式表示:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. Assume that the client application has already
// obtained the DNS name and protocol port number and
// represented them as strings.
std::string host = "samplehost.com";
std::string port_num = "3333";
// Step 2.
asio::io_service ios;
// Step 3\. Creating a query.
asio::ip::tcp::resolver::query resolver_query(host,
port_num, asio::ip::tcp::resolver::query::numeric_service);
// Step 4\. Creating a resolver.
asio::ip::tcp::resolver resolver(ios);
// Used to store information about error that happens
// during the resolution process.
boost::system::error_code ec;
// Step 5.
asio::ip::tcp::resolver::iterator it =
resolver.resolve(resolver_query, ec);
// Handling errors if any.
if (ec != 0) {
// Failed to resolve the DNS name. Breaking execution.
std::cout << "Failed to resolve a DNS name."
<< "Error code = " << ec.value()
<< ". Message = " << ec.message();
return ec.value();
}
return 0;
}
它是如何工作的...
在第 1 步中,我们首先获取一个 DNS 名称和一个协议端口号,并将它们表示为字符串。通常,这些参数是由用户通过客户端应用程序的 UI 或作为命令行参数提供的。获取和验证这些参数的过程超出了本食谱的范围;因此,在这里我们假设它们在样本开始时是可用的。
然后,在第 2 步中,我们创建了一个asio::io_service类的实例,该实例由解析器在 DNS 名称解析过程中用于访问底层操作系统的服务。
在第 3 步中,我们创建了一个asio::ip::tcp::resolver::query类的对象。这个对象代表了对 DNS 的查询。它包含一个要解析的 DNS 名称,一个在 DNS 名称解析后用于构建端点对象的端口号,以及一组控制解析过程某些特定方面的标志,这些标志以位图的形式表示。所有这些值都传递给了查询类构造函数。因为服务被指定为协议端口号(在我们的例子中,是3333),而不是服务名称(例如,HTTP、FTP 等),所以我们传递了asio::ip::tcp::resolver::query::numeric_service标志,以明确告知查询对象这一点,使其能够正确解析端口号值。
在第 4 步中,我们创建了一个asio::ip::tcp::resolver类的实例。这个类提供了 DNS 名称解析功能。为了执行解析,它需要底层操作系统的服务,并且它通过传递给其构造函数的asio::io_services类对象来访问这些服务。
DNS 名称解析在第 5 步中通过解析器对象的resolve()方法执行。我们在样本中使用的重载方法接受asio::ip::tcp::resolver::query和system::error_code类的对象。后者将包含描述错误的信息,如果方法失败。
如果成功,该方法返回一个 asio::ip::tcp::resolver::iterator 类型的对象,该迭代器指向表示解析结果的集合的第一个元素。该集合包含 asio::ip::basic_resolver_entry<tcp> 类型的对象。集合中的对象数量与解析产生的总 IP 地址数量相同。每个集合元素包含一个由解析过程生成的 IP 地址实例化的 asio::ip::tcp::endpoint 类对象和一个与相应 query 对象提供的端口号。可以通过 asio::ip::basic_resolver_entry<tcp>::endpoint() 获取器方法访问 endpoint 对象。
asio::ip::tcp::resolver::iterator 类型的默认构造对象表示一个结束迭代器。以下示例演示了如何遍历表示 DNS 名称解析过程结果的集合元素,以及如何访问结果端点对象:
asio::ip::tcp::resolver::iterator it =
resolver.resolve(resolver_query, ec);
asio::ip::tcp::resolver::iterator it_end;
for (; it != it_end; ++it) {
// Here we can access the endpoint like this.
asio::ip::tcp::endpoint ep = it->endpoint();
}
通常情况下,当运行服务器应用程序的主机 DNS 名称解析为多个 IP 地址,并相应地解析为多个端点时,客户端应用程序不知道应该选择多个端点中的哪一个。在这种情况下,常见的做法是逐个尝试与每个端点通信,直到收到期望的响应。
注意,当 DNS 名称映射到多个 IP 地址,其中一些是 IPv4 地址,而另一些是 IPv6 地址时,DNS 名称可能解析为 IPv4 地址、IPv6 地址或两者。因此,结果集合可能包含表示 IPv4 和 IPv6 地址的端点。
还有更多…
要解析 DNS 名称并获取可用于通过 UDP 协议通信的客户端的端点集合,代码非常相似。以下给出了示例,其中突出显示了差异,但没有解释:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. Assume that the client application has already
// obtained the DNS name and protocol port number and
// represented them as strings.
std::string host = "samplehost.book";
std::string port_num = "3333";
// Step 2.
asio::io_service ios;
// Step 3\. Creating a query.
asio::ip::udp::resolver::query resolver_query(host,
port_num, asio::ip::udp::resolver::query::numeric_service);
// Step 4\. Creating a resolver.
asio::ip::udp::resolver resolver(ios);
// Used to store information about error that happens
// during the resolution process.
boost::system::error_code ec;
// Step 5.
asio::ip::udp::resolver::iterator it =
resolver.resolve(resolver_query, ec);
// Handling errors if any.
if (ec != 0) {
// Failed to resolve the DNS name. Breaking execution.
std::cout << "Failed to resolve a DNS name."
<< "Error code = " << ec.value()
<< ". Message = " << ec.message();
return ec.value();
}
asio::ip::udp::resolver::iterator it_end;
for (; it != it_end; ++it) {
// Here we can access the endpoint like this.
asio::ip::udp::endpoint ep = it->endpoint();
}
return 0;
}
参见
-
创建端点 菜单提供了更多关于端点的信息
-
有关 DNS 和域名的更多信息,请参阅 RFC #1034 和 RFC #1035 文档中可找到的系统规范
将套接字绑定到端点
在一个活跃的套接字能够与远程应用程序通信或一个被动套接字能够接受传入的连接请求之前,它们必须与特定的本地 IP 地址(或多个地址)和一个协议端口号关联,即端点。将套接字与特定端点关联的过程称为绑定。当一个套接字绑定到端点时,所有以该端点为目标地址的网络数据包将由操作系统重定向到该特定套接字。同样,从绑定到特定端点的套接字输出的所有数据将通过与该端点中指定的 IP 地址关联的网络接口从主机发送到网络。
一些操作会隐式地绑定未绑定的套接字。例如,将未绑定的活跃套接字连接到远程应用程序的操作,会隐式地将它绑定到由底层操作系统选择的 IP 地址和协议端口号。通常,客户端应用程序不需要显式地将活跃套接字绑定到特定的端点,因为它不需要特定的端点与服务器通信;它只需要任何端点即可。因此,它通常将选择套接字应绑定的 IP 地址和端口号的权利委托给操作系统。然而,在某些特殊情况下,客户端应用程序可能需要使用特定的 IP 地址和协议端口号与远程应用程序通信,因此将显式地将其套接字绑定到该特定端点。我们不会在我们的书中考虑这些情况。
当套接字绑定委托给操作系统时,无法保证每次都会绑定到相同的端点。即使主机上只有一个网络接口和一个 IP 地址,套接字在每次进行隐式绑定时也可能绑定到不同的协议端口号。
与通常不关心其活跃套接字将通过哪个 IP 地址和协议端口号与远程应用程序通信的客户端应用程序不同,服务器应用程序通常需要显式地将其接受器套接字绑定到特定的端点。这一点可以通过服务器端点必须为所有希望与其通信的客户端所知,并且在服务器应用程序重启后应保持不变的事实来解释。
本食谱解释了如何使用 Boost.Asio 将套接字绑定到特定的端点。
如何操作...
以下算法描述了在 IPv4 TCP 服务器应用程序中创建接受器套接字并将其绑定到指定主机上所有可用 IP 地址和特定协议端口号的端点的步骤:
-
获取服务器应监听传入连接请求的协议端口号。
-
创建一个端点,代表主机上所有可用 IP 地址和步骤 1 中获取的协议端口号。
-
创建并打开接受者套接字。
-
调用接受者套接字的
bind()方法,将端点对象作为参数传递给它。
以下代码示例演示了算法的可能实现。假设协议端口号已经由应用程序获取:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. Here we assume that the server application has
// already obtained the protocol port number.
unsigned short port_num = 3333;
// Step 2\. Creating an endpoint.
asio::ip::tcp::endpoint ep(asio::ip::address_v4::any(),
port_num);
// Used by 'acceptor' class constructor.
asio::io_service ios;
// Step 3\. Creating and opening an acceptor socket.
asio::ip::tcp::acceptor acceptor(ios, ep.protocol());
boost::system::error_code ec;
// Step 4\. Binding the acceptor socket.
acceptor.bind(ep, ec);
// Handling errors if any.
if (ec != 0) {
// Failed to bind the acceptor socket. Breaking
// execution.
std::cout << "Failed to bind the acceptor socket."
<< "Error code = " << ec.value() << ". Message: "
<< ec.message();
return ec.value();
}
return 0;
}
工作原理...
我们首先在步骤 1 中获取一个协议端口号。获取此参数的过程超出了本菜谱的范围;因此,在此我们假设端口号已经获取并且可以在示例开始时使用。
在步骤 2 中,我们创建了一个代表主机上所有可用 IP 地址和指定端口号的端点。
在步骤 3 中,我们实例化并打开接受者套接字。我们创建的端点包含有关传输协议和底层 IP 协议版本(IPv4)的信息。因此,我们不需要创建另一个代表协议的对象并将其传递给接受者套接字构造函数。相反,我们使用端点的 protocol() 方法,该方法返回一个 asio::ip::tcp 类的对象,代表相应的协议。
绑定操作在步骤 4 中执行。这是一个相当简单的操作。我们调用接受者套接字的 bind() 方法,将代表应绑定到接受者套接字的端点的对象作为方法的参数。如果调用成功,接受者套接字将绑定到相应的端点,并准备好在该端点上开始监听传入的连接请求。
小贴士
下载示例代码
您可以从 www.packtpub.com 下载您购买的所有 Packt 出版物的示例代码文件。如果您在其他地方购买了此书,您可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给您。
更多内容...
UDP 服务器不建立连接,并使用活动套接字等待传入请求。绑定活动套接字的过程与绑定接受者套接字的过程非常相似。在此,我们提供了一个示例代码,展示了如何将 UDP 活动套接字绑定到指定主机上所有可用 IP 地址和特定协议端口号的端点。代码提供时未加解释:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. Here we assume that the server application has
// already obtained the protocol port number.
unsigned short port_num = 3333;
// Step 2\. Creating an endpoint.
asio::ip::udp::endpoint ep(asio::ip::address_v4::any(),
port_num);
// Used by 'socket' class constructor.
asio::io_service ios;
// Step 3\. Creating and opening a socket.
asio::ip::udp::socket sock(ios, ep.protocol());
boost::system::error_code ec;
// Step 4\. Binding the socket to an endpoint.
sock.bind(ep, ec);
// Handling errors if any.
if (ec != 0) {
// Failed to bind the socket. Breaking execution.
std::cout << "Failed to bind the socket."
<< "Error code = " << ec.value() << ". Message: "
<< ec.message();
return ec.value();
}
return 0;
}
相关链接
-
创建端点 菜单提供了有关端点的更多信息
-
创建活动套接字 菜单提供了有关
asio::io_service和asio::ip::tcp类的更多详细信息,并演示了如何创建和打开活动套接字 -
创建被动套接字 菜单提供了有关被动套接字的信息,并演示了如何创建和打开它们
连接套接字
在一个 TCP 套接字可以使用来与远程应用程序通信之前,它必须与它建立一个逻辑连接。根据 TCP 协议,连接建立过程在于两个应用程序之间交换服务消息,如果成功,则导致两个应用程序逻辑连接并准备好相互通信。
大概来说,连接建立过程看起来是这样的。当客户端应用程序想要与服务器应用程序通信时,它会创建并打开一个活跃的套接字,并在其上发出一个connect()命令,指定一个带有端点对象的目标服务器应用程序。这导致一个连接建立请求消息通过网络发送到服务器应用程序。服务器应用程序接收请求并在其端创建一个活跃的套接字,将其标记为连接到特定的客户端,并回复客户端,确认服务器端已成功建立连接。接下来,客户端收到服务器的确认后,将其套接字标记为连接到服务器,并向它发送一条确认消息,确认客户端端已成功建立连接。当服务器收到客户端的确认消息时,两个应用程序之间的逻辑连接被认为是建立的。
假设两个连接的套接字之间是点对点通信模型。这意味着如果套接字 A 连接到套接字 B,那么它们只能相互通信,不能与任何其他套接字 C 通信。在套接字 A 能够与套接字 C 通信之前,它必须关闭与套接字 B 的连接,并与套接字 C 建立一个新的连接。
本食谱解释了如何使用 Boost.Asio 同步地将套接字连接到远程应用程序。
如何操作…
以下算法描述了在 TCP 客户端应用程序中执行连接活跃套接字到服务器应用程序所需的步骤:
-
获取目标服务器应用程序的 IP 地址和协议端口号。
-
从步骤 1 中获取的 IP 地址和协议端口号创建一个
asio::ip::tcp::endpoint类的对象。 -
创建并打开一个活跃的套接字。
-
调用套接字的
connect()方法,将步骤 2 中创建的端点对象作为参数。 -
如果该方法成功,套接字被认为是连接的,并且可以用来向服务器发送和接收数据。
以下代码示例演示了算法的可能实现:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step 1\. Assume that the client application has already
// obtained the IP address and protocol port number of the
// target server.
std::string raw_ip_address = "127.0.0.1";
unsigned short port_num = 3333;
try {
// Step 2\. Creating an endpoint designating
// a target server application.
asio::ip::tcp::endpoint
ep(asio::ip::address::from_string(raw_ip_address),
port_num);
asio::io_service ios;
// Step 3\. Creating and opening a socket.
asio::ip::tcp::socket sock(ios, ep.protocol());
// Step 4\. Connecting a socket.
sock.connect(ep);
// At this point socket 'sock' is connected to
// the server application and can be used
// to send data to or receive data from it.
}
// Overloads of asio::ip::address::from_string() and
// asio::ip::tcp::socket::connect() used here throw
// exceptions in case of error condition.
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的…
在步骤 1 中,我们从目标服务器获取 IP 地址和协议端口号。获取这些参数的过程超出了本食谱的范围;因此,在此我们假设它们已经获取并且在我们样本的开始时可用。
在第 2 步中,我们创建一个asio::ip::tcp::endpoint类的对象,指定我们打算连接的目标服务器应用程序。
然后,在第 3 步中,实例化并打开一个活动套接字。
在第 4 步中,我们调用套接字的connect()方法,将一个指定目标服务器的端点对象作为参数传递给它。这个函数将套接字连接到服务器。连接是同步进行的,这意味着该方法会阻塞调用线程,直到连接操作建立或发生错误。
注意,在连接之前,我们没有将套接字绑定到任何本地端点。这并不意味着套接字保持未绑定状态。在执行连接建立过程之前,套接字的connect()方法会将套接字绑定到操作系统选择的由 IP 地址和协议端口号组成的端点。
在这个示例中,还有一点需要注意,我们使用了一个connect()方法的重载版本,如果操作失败,它会抛出一个boost::system::system_error类型的异常,同样,我们在第 2 步中使用的asio::ip::address::from_string()静态方法的重载版本也是如此。因此,这两个调用都被包含在一个try块中。这两个方法都有不抛出异常的重载版本,并接受一个boost::system::error_code类的对象,该对象用于在操作失败时将错误信息传递给调用者。然而,在这种情况下,使用异常来处理错误可以使代码结构更清晰。
还有更多...
之前的代码示例展示了当客户端应用程序明确提供了 IP 地址和协议端口号时,如何将套接字连接到由端点指定的特定服务器应用程序。然而,有时客户端应用程序会提供一个 DNS 名称,该名称可能映射到一个或多个 IP 地址。在这种情况下,我们首先需要使用asio::ip::tcp::resolver类提供的resolve()方法解析 DNS 名称。此方法解析 DNS 名称,从解析结果中的每个 IP 地址创建一个asio::ip::tcp::endpoint类的对象,将所有端点对象放入一个集合中,并返回一个asio::ip::tcp::resolver::iterator类的对象,该迭代器指向集合中的第一个元素。
当 DNS 名称解析为多个 IP 地址时,客户端应用程序在决定连接到哪一个时,通常没有理由偏好任何一个 IP 地址。在这种情况下,常见的做法是遍历集合中的端点,并尝试逐个连接到它们,直到连接成功。Boost.Asio 提供了实现此方法的辅助功能。
自由函数asio::connect()接受一个活动套接字对象和一个asio::ip::tcp::resolver::iterator类对象作为输入参数,遍历一组端点,并尝试将套接字连接到每个端点。该函数在成功将套接字连接到其中一个端点或尝试了所有端点但未能将套接字连接到所有端点时停止迭代,并返回。
以下算法演示了连接套接字到由 DNS 名称和协议端口号表示的服务器应用程序所需的步骤:
-
获取运行服务器应用程序的主机的 DNS 名称和服务器端口号,并将它们表示为字符串。
-
使用
asio::ip::tcp::resolver类解析 DNS 名称。 -
创建一个未打开的活动套接字。
-
将套接字对象和在第 2 步中获得的迭代器对象作为参数传递给
asio::connect()函数。
以下代码示例演示了算法的可能实现:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// Step1\. Assume that the client application has already
// obtained the DNS name and protocol port number and
// represented them as strings.
std::string host = "samplehost.book";
std::string port_num = "3333";
// Used by a 'resolver' and a 'socket'.
asio::io_service ios;
// Creating a resolver's query.
asio::ip::tcp::resolver::query resolver_query(host, port_num,
asio::ip::tcp::resolver::query::numeric_service);
// Creating a resolver.
asio::ip::tcp::resolver resolver(ios);
try {
// Step 2\. Resolving a DNS name.
asio::ip::tcp::resolver::iterator it =
resolver.resolve(resolver_query);
// Step 3\. Creating a socket.
asio::ip::tcp::socket sock(ios);
// Step 4\. asio::connect() method iterates over
// each endpoint until successfully connects to one
// of them. It will throw an exception if it fails
// to connect to all the endpoints or if other
// error occurs.
asio::connect(sock, it);
// At this point socket 'sock' is connected to
// the server application and can be used
// to send data to or receive data from it.
}
// Overloads of asio::ip::tcp::resolver::resolve and
// asio::connect() used here throw
// exceptions in case of error condition.
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
注意,在第 3 步中,我们创建套接字时不会打开它。这是因为我们不知道提供的 DNS 名称将解析到的 IP 地址版本。asio::connect()函数在将套接字连接到每个指定了正确协议对象的端点之前打开套接字,如果连接失败则关闭它。
代码示例中的所有其他步骤都不难理解,因此没有提供解释。
参见
-
创建端点菜谱提供了更多关于端点的信息。
-
创建活动套接字菜谱解释了如何创建和打开套接字,并提供了关于
asio::io_service类的更多详细信息。 -
解析 DNS 名称菜谱解释了如何使用解析器类解析 DNS 名称。
-
绑定套接字菜谱提供了更多关于套接字绑定的信息。
接受连接
当客户端应用程序想要通过 TCP 协议与服务器应用程序通信时,它首先需要与该服务器建立一个逻辑连接。为了做到这一点,客户端分配一个活动套接字并在其上发出连接命令(例如通过在套接字对象上调用connect()方法),这将导致连接建立请求消息被发送到服务器。
在服务器端,在服务器应用程序能够接受和处理来自客户端的连接请求之前,必须进行一些安排。在此之前,所有针对此服务器应用程序的连接请求都被操作系统拒绝。
首先,服务器应用程序创建并打开一个接受器套接字,并将其绑定到特定的端点。在此阶段,到达接受器套接字端点的客户端连接请求仍然被操作系统拒绝。为了使操作系统开始接受针对与特定接受器套接字关联的特定端点的连接请求,该接受器套接字必须切换到监听模式。之后,操作系统为与该接受器套接字关联的挂起连接请求分配一个队列,并开始接受针对它的连接请求。
当新的连接请求到达时,它最初由操作系统接收,并将其放入与接受器套接字关联的挂起连接请求队列中。在队列中,连接请求可供服务器应用程序处理。当服务器应用程序准备好处理下一个连接请求时,它将出队一个并处理它。
注意,接受器套接字仅用于与客户端应用程序建立连接,并在后续的通信过程中不被使用。在处理挂起的连接请求时,接受器套接字分配一个新的活动套接字,将其绑定到操作系统选择的端点,并将其连接到已发出该连接请求的相应客户端应用程序。然后,这个新的活动套接字就准备好用于与客户端进行通信。接受器套接字可用于处理下一个挂起的连接请求。
本食谱描述了如何在 TCP 服务器应用程序中使用 Boost.Asio 将接受器套接字切换到监听模式并接受传入的连接请求。
如何做到这一点…
以下算法描述了如何设置接受器套接字,使其开始监听传入的连接,然后如何使用它来同步处理挂起的连接请求。该算法假设在同步模式下只处理一个传入连接:
-
获取服务器将接收传入连接请求的端口号。
-
创建一个服务器端点。
-
实例化和打开一个接受器套接字。
-
将接受器套接字绑定到步骤 2 中创建的服务器端点。
-
调用接受器套接字的
listen()方法,使其开始监听端点上的传入连接请求。 -
实例化一个活动套接字对象。
-
当准备好处理连接请求时,调用接受器套接字的
accept()方法,并将步骤 6 中创建的活动套接字对象作为参数传递。 -
如果调用成功,活动套接字将连接到客户端应用程序,并准备好与其进行通信。
以下代码示例演示了遵循该算法的服务器应用程序的可能实现。在此,我们假设服务器旨在通过 TCP 协议与 IPv4 作为底层协议进行通信:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
// The size of the queue containing the pending connection
// requests.
const int BACKLOG_SIZE = 30;
// Step 1\. Here we assume that the server application has
// already obtained the protocol port number.
unsigned short port_num = 3333;
// Step 2\. Creating a server endpoint.
asio::ip::tcp::endpoint ep(asio::ip::address_v4::any(),
port_num);
asio::io_service ios;
try {
// Step 3\. Instantiating and opening an acceptor socket.
asio::ip::tcp::acceptor acceptor(ios, ep.protocol());
// Step 4\. Binding the acceptor socket to the
// server endpint.
acceptor.bind(ep);
// Step 5\. Starting to listen for incoming connection
// requests.
acceptor.listen(BACKLOG_SIZE);
// Step 6\. Creating an active socket.
asio::ip::tcp::socket sock(ios);
// Step 7\. Processing the next connection request and
// connecting the active socket to the client.
acceptor.accept(sock);
// At this point 'sock' socket is connected to
//the client application and can be used to send data to
// or receive data from it.
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的...
在步骤 1 中,我们获取服务器应用程序绑定其接受者套接字的协议端口号。在这里,我们假设端口号已经获取并且可以在示例开始时使用。
在步骤 2 中,我们创建一个服务器端点,指定运行服务器应用程序的主机上可用的所有 IP 地址和特定的协议端口号。
在步骤 3 中,我们实例化并打开一个接受者套接字,并在步骤 4 中将它绑定到服务器端点。
在步骤 5 中,我们调用接受者的listen()方法,将 BACKLOG_SIZE 常量值作为参数传递。此调用将接受者套接字切换到监听传入连接请求的状态。除非我们在接受者对象上调用listen()方法,否则所有到达相应端点的连接请求都将被操作系统网络软件拒绝。应用程序必须通过此调用显式通知操作系统,它希望开始在特定端点上监听传入的连接请求。
listen()方法接受的参数指定了操作系统维护的队列的大小,该队列用于存放来自客户端的连接请求。请求保持在队列中,等待服务器应用程序将其出队并处理。当队列满时,操作系统将拒绝新的连接请求。
在步骤 6 中,我们创建一个活动套接字对象,但不打开它。我们将在步骤 7 中使用它。
在步骤 7 中,我们调用接受者套接字的accept()方法。此方法接受一个活动套接字作为参数并执行几个操作。首先,它检查与接受者套接字关联的包含挂起连接请求的队列。如果队列为空,则方法会阻塞执行,直到新的连接请求到达接受者套接字绑定的端点,并且操作系统将其放入队列。
如果队列中至少有一个连接请求可用,则队列顶部的请求将被提取出来并处理。传递给accept()方法作为参数的活动套接字将连接到发出连接请求的相应客户端应用程序。
如果连接建立过程成功,accept()方法将返回,活动套接字将被打开并连接到客户端应用程序,可以用来向其发送数据并接收数据。
注意
记住,在处理连接请求时,接受者套接字不会自己连接到客户端应用程序。相反,它打开并连接另一个活动套接字,然后使用该套接字与客户端应用程序进行通信。接受者套接字只监听和处理(接受)传入的连接请求。
注意,UDP 服务器不使用接受器套接字,因为 UDP 协议不涉及连接建立。相反,使用一个绑定到端点并监听传入 I/O 消息的活动套接字,并且这个相同的活动套接字用于通信。
参见
-
创建被动套接字 菜单提供了关于被动套接字的信息,并演示了如何创建和打开它们
-
创建端点 菜单提供了更多关于端点的信息
-
创建活动套接字 菜单解释了如何创建和打开套接字,并提供了关于
asio::io_service类的更多细节 -
绑定套接字 菜单提供了更多关于套接字绑定的信息
第二章。I/O 操作
在本章中,我们将涵盖以下示例:
-
使用固定长度 I/O 缓冲区
-
使用可扩展的流式 I/O 缓冲区
-
同步写入 TCP 套接字
-
同步从 TCP 套接字读取
-
异步写入 TCP 套接字
-
异步从 TCP 套接字读取
-
取消异步操作
-
关闭和断开套接字
简介
I/O 操作是任何分布式应用程序网络基础设施中的关键操作。它们直接参与数据交换的过程。输入操作用于从远程应用程序接收数据,而输出操作允许向它们发送数据。
在本章中,我们将看到几个示例,展示如何执行 I/O 操作以及与之相关的其他操作。此外,我们还将了解如何使用 Boost.Asio 提供的一些类,这些类与 I/O 操作一起使用。
以下是对本章讨论的主题的简要总结和介绍。
I/O 缓冲区
网络编程主要涉及在计算机网络中组织进程间通信。在此上下文中,“通信”意味着在两个或更多进程之间交换数据。从参与此类通信的进程的角度来看,该进程执行 I/O 操作,向其他参与进程发送数据并从它们那里接收数据。
与任何其他类型的 I/O 一样,网络 I/O 涉及使用内存缓冲区,这些缓冲区是在进程的地址空间中分配的连续内存块,用于存储数据。在进行任何类型的输入操作(例如,从文件、管道或通过网络远程计算机读取一些数据)时,数据到达进程,必须在它的地址空间中的某个地方存储,以便它可用于进一步处理。也就是说,当缓冲区派上用场时。在进行输入操作之前,缓冲区被分配,然后在操作期间用作数据目标点。当输入操作完成时,缓冲区包含输入数据,可以被应用程序处理。同样,在进行输出操作之前,数据必须准备并放入输出缓冲区,然后在输出操作中使用,它扮演数据源的角色。
显然,缓冲区是任何执行任何类型 I/O 的应用程序的基本组成部分,包括网络 I/O。这就是为什么对于开发分布式应用程序的开发人员来说,了解如何分配和准备 I/O 缓冲区以在 I/O 操作中使用它们至关重要。
同步和异步 I/O 操作
Boost.Asio 支持两种类型的 I/O 操作:同步和异步。同步操作阻塞调用它们的执行线程,并且只有在操作完成时才会解除阻塞。因此,这种类型操作的名称是同步。
第二种类型是异步操作。当异步操作被启动时,它与一个回调函数或函数对象相关联,当操作完成时,由 Boost.Asio 库调用。这些类型的 I/O 操作提供了极大的灵活性,但可能会显著复杂化代码。操作的启动简单,不会阻塞执行线程,这允许我们在异步操作在后台运行的同时使用线程来运行其他任务。
Boost.Asio 库被实现为一个框架,它利用了控制反转的方法。在启动一个或多个异步操作之后,应用程序将其执行线程之一交给库,然后库使用此线程来运行事件循环并调用应用程序提供的回调来通知它关于先前启动的异步操作完成的详细信息。异步操作的结果作为参数传递给回调。
其他操作
此外,我们还将考虑取消异步操作、关闭和关闭套接字等操作。
取消先前启动的异步操作的能力非常重要。它允许应用程序声明先前启动的操作不再相关,这可能会节省应用程序的资源(CPU 和内存),否则(如果操作继续执行,即使已知没有人再对其感兴趣)将不可避免地浪费。
关闭套接字在需要分布式应用程序的一部分通知另一部分整个消息已发送时很有用,当应用层协议没有提供其他方法来指示消息边界时。
与任何其他操作系统资源一样,当应用程序不再需要套接字时,应将其返回给操作系统。关闭操作允许我们这样做。
使用固定长度 I/O 缓冲区
固定长度的 I/O 缓冲区通常用于 I/O 操作,并在已知要发送或接收的消息大小时扮演数据源或目标的角色。例如,这可以是一个在栈上分配的固定长度的字符数组,其中包含要发送到服务器的请求字符串。或者,这可以是一个在空闲内存中分配的可写缓冲区,用作从套接字读取数据的数据目标点。
在这个菜谱中,我们将看到如何表示固定长度缓冲区,以便它们可以与 Boost.Asio I/O 操作一起使用。
如何做到这一点...
在 Boost.Asio 中,固定长度的缓冲区由以下两个类之一表示:asio::mutable_buffer 或 asio::const_buffer。这两个类都表示一个连续的内存块,该内存块由块的第一个字节的地址及其字节大小指定。正如这些类的名称所暗示的,asio::mutable_buffer 表示可写缓冲区,而 asio::const_buffer 表示只读缓冲区。
然而,在 Boost.Asio 的 I/O 函数和方法中,既不直接使用 asio::mutable_buffer 也不使用 asio::const_buffer 类。相反,引入了 MutableBufferSequence 和 ConstBufferSequence 概念。
MutableBufferSequence 概念指定了一个表示 asio::mutable_buffer 对象集合的对象。相应地,ConstBufferSequence 概念指定了一个表示 asio::const_buffer 对象集合的对象。Boost.Asio 的函数和方法在执行 I/O 操作时接受满足 MutableBufferSequence 或 ConstBufferSequence 概念要求的对象作为其参数,以表示缓冲区。
注意
MutableBufferSequence 和 ConstBufferSequence 概念的完整规范可在 Boost.Asio 文档部分找到,该部分可通过以下链接访问:
-
请参阅
www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/MutableBufferSequence.html了解MutableBufferSequence -
请参阅
www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/ConstBufferSequence.html了解ConstBufferSequence
尽管在大多数使用情况下,单个 I/O 操作只涉及单个缓冲区,但在某些特定情况下(例如,在内存受限的环境中),开发者可能希望使用由多个较小的简单缓冲区组成的复合缓冲区,这些缓冲区分布在进程的地址空间中。Boost.Asio 的 I/O 函数和方法旨在与表示为满足 MutableBufferSequence 或 ConstBufferSequence 概念要求的缓冲区集合的复合缓冲区一起工作。
例如,std::vector<asio::mutable_buffer> 类的对象满足 MutableBufferSequence 概念的要求,因此它可以用于在 I/O 相关函数和方法中表示复合缓冲区。
因此,现在我们知道,如果我们有一个表示为 asio::mutable_buffer 或 asio::const_buffer 类对象的缓冲区,我们仍然不能使用 Boost.Asio 提供的与 I/O 相关的函数或方法。缓冲区必须表示为一个对象,满足 MutableBufferSequence 或 ConstBufferSequence 概念的要求。为此,例如,我们可以通过实例化 std::vector<asio::mutable_buffer> 类的对象并将我们的缓冲区对象放入其中来创建一个由单个缓冲区组成的缓冲区对象集合。现在,缓冲区成为集合的一部分,满足 MutableBufferSequence 要求可以在 I/O 操作中使用。
然而,尽管这种方法可以很好地创建由两个或更多简单缓冲区组成的复合缓冲区,但在处理像表示单个简单缓冲区这样的简单任务时,它看起来过于复杂,以便可以使用 Boost.Asio I/O 函数或方法。幸运的是,Boost.Asio 为我们提供了一种简化单个缓冲区与 I/O 相关函数和方法使用的方法。
asio::buffer() 自由函数有 28 个重载,接受各种缓冲区表示形式,并返回 asio::mutable_buffers_1 或 asio::const_buffers_1 类的对象。如果传递给 asio::buffer() 函数的缓冲区参数是只读类型,则函数返回 asio::const_buffers_1 类的对象;否则,返回 asio::mutable_buffers_1 类的对象。
asio::mutable_buffers_1 和 asio::const_buffers_1 类分别是 asio::mutable_buffer 和 asio::const_buffer 类的适配器。它们提供了一个满足 MutableBufferSequence 和 ConstBufferSequence 概念要求的接口和行为,这使得我们可以将这些适配器作为参数传递给 Boost.Asio I/O 函数和方法。
让我们考虑两个算法和相应的代码示例,描述了如何准备一个内存缓冲区,该缓冲区可以用于 Boost.Asio I/O 操作。第一个算法处理用于输出操作的缓冲区,第二个算法用于输入操作。
准备缓冲区以进行输出操作
以下算法和相应的代码示例描述了如何准备一个可以用于执行输出操作(如 asio::ip::tcp::socket::send() 或 asio::write() 自由函数)的 Boost.Asio 套接字方法的缓冲区:
-
分配一个缓冲区。请注意,此步骤不涉及任何来自 Boost.Asio 的功能或数据类型。
-
用要作为输出使用的数据填充缓冲区。
-
将缓冲区表示为一个满足
ConstBufferSequence概念要求的对象。 -
缓冲区已准备好与 Boost.Asio 输出方法和函数一起使用。
假设我们想向远程应用程序发送字符串 Hello。在我们使用 Boost.Asio 发送数据之前,我们需要正确地表示缓冲区。以下是如何在以下代码中做到这一点的示例:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
std::string buf; // 'buf' is the raw buffer.
buf = "Hello"; // Step 1 and 2 in single line.
// Step 3\. Creating buffer representation that satisfies
// ConstBufferSequence concept requirements.
asio::const_buffers_1 output_buf = asio::buffer(buf);
// Step 4\. 'output_buf' is the representation of the
// buffer 'buf' that can be used in Boost.Asio output
// operations.
return 0;
}
准备输入操作的缓冲区
以下算法和相应的代码示例描述了如何准备可以用于执行输入操作(如 asio::ip::tcp::socket::receive() 或 asio::read() 自由函数)的 Boost.Asio 套接字方法的缓冲区:
-
分配一个缓冲区。缓冲区的大小必须足够大,以便容纳要接收的数据块。请注意,这一步不涉及任何来自 Boost.Asio 的功能或数据类型。
-
使用满足
MutableBufferSequence概念要求的对象来表示缓冲区。 -
缓冲区已准备好,可以与 Boost.Asio 输入方法和函数一起使用。
假设我们想从服务器接收一块数据。为了做到这一点,我们首先需要准备一个缓冲区,数据将存储在其中。以下是如何在以下代码中做到这一点的示例:
#include <boost/asio.hpp>
#include <iostream>
#include <memory> // For std::unique_ptr<>
using namespace boost;
int main()
{
// We expect to receive a block of data no more than 20 bytes
// long.
const size_t BUF_SIZE_BYTES = 20;
// Step 1\. Allocating the buffer.
std::unique_ptr<char[]> buf(new char[BUF_SIZE_BYTES]);
// Step 2\. Creating buffer representation that satisfies
// MutableBufferSequence concept requirements.
asio::mutable_buffers_1 input_buf =
asio::buffer(static_cast<void*>(buf.get()),
BUF_SIZE_BYTES);
// Step 3\. 'input_buf' is the representation of the buffer
// 'buf' that can be used in Boost.Asio input operations.
return 0;
}
它是如何工作的……
这两个示例看起来都很简单直接;然而,它们包含一些细微之处,这些细微之处对于正确使用 Boost.Asio 的缓冲区非常重要。在本节中,我们将详细了解每个示例的工作原理。
准备输出操作的缓冲区
让我们考虑第一个代码示例,它展示了如何准备一个可以与 Boost.Asio 输出方法和函数一起使用的缓冲区。main() 入口函数从实例化 std::string 类的对象开始。因为我们想发送一段文本字符串,所以 std::string 是存储这类数据的良好选择。在下一行,字符串对象被赋予值 Hello。这就是缓冲区分配并填充数据的地方。这一行实现了算法的步骤 1 和 2。
接下来,在缓冲区可以与 Boost.Asio I/O 方法和函数一起使用之前,必须对其进行适当的表示。为了更好地理解为什么需要这样做,让我们看看一个 Boost.Asio 输出函数的例子。以下是代表 TCP 套接字的 Boost.Asio 类的 send() 方法的声明:
template<typename ConstBufferSequence>
std::size_t send(const ConstBufferSequence & buffers);
如我们所见,这是一个模板方法,它接受一个满足 ConstBufferSequence 概念要求的对象作为其参数,该参数代表缓冲区。一个合适的对象是一个复合对象,它代表 asio::const_buffer 类对象的集合,并提供支持对其元素进行迭代的典型集合接口。例如,std::vector<asio::const_buffer> 类的对象适合用作 send() 方法的参数,但 std::string 或 asio::const_bufer 类的对象则不适合。
为了使用我们的 std::string 对象与代表 TCP 套接字的类的 send() 方法,我们可以这样做:
asio::const_buffer asio_buf(buf.c_str(), buf.length());
std::vector<asio::const_buffer> buffers_sequence;
buffers_sequence.push_back(asio_buf);
在前面的代码片段中名为 buffer_sequence 的对象满足 ConstBufferSequence 概念的要求,因此它可以作为套接字对象 send() 方法的参数。然而,这种方法非常复杂。相反,我们使用 Boost.Asio 提供的 asio::buffer() 函数来获取 适配器 对象,我们可以在 I/O 操作中直接使用它们:
asio::const_buffers_1 output_buf = asio::buffer(buf);
在适配器对象实例化后,它可以与 Boost.Asio 输出操作一起使用,以表示输出缓冲区。
为输入操作准备缓冲区
第二个代码示例与第一个非常相似。主要区别在于缓冲区已分配但未填充数据,因为其目的不同。这次,缓冲区旨在在输入操作期间从远程应用程序接收数据。
使用输出缓冲区时,必须正确表示输入缓冲区,以便可以使用 Boost.Asio I/O 方法和函数。然而,在这种情况下,该缓冲区必须表示为一个满足 MutableBufferSequence 概念要求的对象。与 ConstBufferSequence 相反,这个概念表示可变缓冲区的集合,即可以写入的缓冲区。在这里,我们使用 buffer() 函数,它帮助我们创建所需的缓冲区表示。mutable_buffers_1 适配器类对象表示单个可变缓冲区,并满足 MutableBufferSequence 概念的要求。
在第一步中,分配了缓冲区。在这种情况下,缓冲区是在空闲内存中分配的字符数组。在下一步中,实例化了适配器对象,它可以用于输入和输出操作。
注意
缓冲区所有权
重要的是要注意,代表缓冲区的类以及我们考虑的由 Boost.Asio 提供的适配器类(即 asio::mutable_buffer、asio::const_buffer、asio::mutable_buffers_1 和 asio::const_buffers_1)都不拥有底层原始缓冲区的所有权。这些类仅提供对缓冲区的接口,并不控制其生命周期。
参见
-
向 TCP 套接字同步写入 配方演示了如何从固定长度缓冲区向套接字写入数据。
-
从 TCP 套接字同步读取 配方演示了如何从套接字读取数据到固定长度缓冲区。
-
第六章 中 使用复合缓冲区进行分散/收集操作 的配方提供了有关复合缓冲区的更多信息,并演示了如何使用它们。
使用可扩展的流式 I/O 缓冲区
可扩展缓冲区是当向其写入新数据时动态增加其大小的缓冲区。它们通常用于从套接字读取数据,当传入消息的大小未知时。
一些应用层协议没有定义消息的确切大小。相反,消息的边界由消息末尾的特定符号序列表示,或者由发送者在完成消息发送后发出的传输协议服务消息文件结束(EOF)表示。
例如,根据 HTTP 协议,请求和响应消息的头部部分没有固定长度,其边界由四个 ASCII 符号序列表示,即<CR><LF><CR><LF>,这是消息的一部分。在这种情况下,动态可扩展的缓冲区和可以与它们一起工作的函数,这些函数由 Boost.Asio 库提供,非常有用。
在这个菜谱中,我们将了解如何实例化可扩展的缓冲区以及如何向这些缓冲区读写数据。要了解这些缓冲区如何与 Boost.Asio 提供的 I/O 相关方法和函数一起使用,请参阅另请参阅部分中列出的专门针对 I/O 操作的相应菜谱。
如何做到这一点…
可扩展的流式缓冲区在 Boost.Asio 中由asio::streambuf类表示,它是asio::basic_streambuf的typedef:
typedef basic_streambuf<> streambuf;
asio::basic_streambuf<>类是从std::streambuf继承的,这意味着它可以作为 STL 流类的流缓冲区使用。除了这一点之外,Boost.Asio 提供的几个 I/O 函数处理表示为该类对象的缓冲区。
我们可以像处理从std::streambuf类继承的任何流缓冲区类一样处理asio::streambuf类的对象。例如,我们可以将此对象分配给一个流(例如,std::istream、std::ostream或std::iostream,具体取决于我们的需求),然后使用流的operator<<()和operator>>()运算符向流写入和从流读取数据。
让我们考虑一个示例应用程序,其中实例化了一个asio::streambuf对象,向其中写入了一些数据,然后从缓冲区将数据读取回一个std::string类对象:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
asio::streambuf buf;
std::ostream output(&buf);
// Writing the message to the stream-based buffer.
output << "Message1\nMessage2";
// Now we want to read all data from a streambuf
// until '\n' delimiter.
// Instantiate an input stream which uses our
// stream buffer.
std::istream input(&buf);
// We'll read data into this string.
std::string message1;
std::getline(input, message1);
// Now message1 string contains 'Message1'.
return 0;
}
注意,这个示例不包含任何网络 I/O 操作,因为它专注于asio::streambuf类本身及其操作,而不是如何使用这个类进行 I/O 操作。
它是如何工作的…
main()应用程序入口点函数从实例化一个名为buf的asio::streambuf类对象开始。接下来,实例化std::ostream类的输出流对象。buf对象被用作输出流的流缓冲区。
在下一行,将Message1\nMessage2样本数据字符串写入输出流对象,该对象随后将数据重定向到buf流缓冲区。
通常,在典型的客户端或服务器应用程序中,数据将通过 Boost.Asio 输入函数(如asio::read())写入buf流缓冲区,该函数接受一个流缓冲区对象作为参数,并从套接字读取数据到该缓冲区。
现在,我们想要从流缓冲区中读取数据。为此,我们分配一个输入流,并将buf对象作为流缓冲区参数传递给其构造函数。之后,我们分配一个名为message1的字符串对象,然后使用std::getline函数读取buf流缓冲区中当前存储的字符串的一部分,直到分隔符符号\n。
因此,string1对象包含Message1字符串,而buf流缓冲区包含分隔符符号之后的初始字符串的其余部分,即Message2。
参见
- 异步从 TCP 套接字读取配方演示了如何将数据从套接字读取到可扩展的流式缓冲区
同步写入 TCP 套接字
向 TCP 套接字写入是一个输出操作,用于将数据发送到连接到此套接字的远程应用程序。使用 Boost.Asio 提供的套接字进行同步写入是最简单的方式。执行同步写入到套接字的方法会阻塞执行线程,直到数据(至少一些数据)被写入套接字或发生错误才会返回。
在本配方中,我们将了解如何同步地将数据写入 TCP 套接字。
如何做到这一点...
使用 Boost.Asio 库提供的套接字的最基本方式是使用asio::ip::tcp::socket类的write_some()方法。以下是该方法重载之一的声明:
template<
typename ConstBufferSequence>
std::size_t write_some(
const ConstBufferSequence & buffers);
此方法接受一个表示复合缓冲区的对象作为参数,正如其名称所暗示的,从缓冲区写入一些数据到套接字。如果方法成功,返回值指示写入的字节数。这里要强调的是,该方法可能不会发送通过buffers参数提供的所有数据。该方法仅保证在没有错误发生的情况下至少写入一个字节。这意味着,在一般情况下,为了将缓冲区中的所有数据写入套接字,我们可能需要多次调用此方法。
以下算法描述了在分布式应用程序中同步写入 TCP 套接字所需的步骤:
-
在客户端应用程序中,分配、打开和连接一个活动 TCP 套接字。在服务器应用程序中,通过使用接受器套接字接受连接请求来获取一个已连接的活动 TCP 套接字。
-
分配缓冲区并填充要写入套接字的数据。
-
在循环中,根据需要多次调用套接字的
write_some()方法,以发送缓冲区中所有可用的数据。
以下代码示例演示了一个客户端应用程序,它根据该算法操作:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
void writeToSocket(asio::ip::tcp::socket& sock) {
// Step 2\. Allocating and filling the buffer.
std::string buf = "Hello";
std::size_t total_bytes_written = 0;
// Step 3\. Run the loop until all data is written
// to the socket.
while (total_bytes_written != buf.length()) {
total_bytes_written += sock.write_some(
asio::buffer(buf.c_str() +
total_bytes_written,
buf.length() - total_bytes_written));
}
}
int main()
{
std::string raw_ip_address = "127.0.0.1";
unsigned short port_num = 3333;
try {
asio::ip::tcp::endpoint
ep(asio::ip::address::from_string(raw_ip_address),
port_num);
asio::io_service ios;
// Step 1\. Allocating and opening the socket.
asio::ip::tcp::socket sock(ios, ep.protocol());
sock.connect(ep);
writeToSocket(sock);
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
尽管在所提供的代码示例中,写入套接字是在充当客户端的应用程序上下文中执行的,但可以使用相同的方法在服务器应用程序中写入套接字。
它是如何工作的…
main()应用程序入口点函数相当简单。它分配一个套接字,打开,并将其同步连接到远程应用程序。然后,调用writeToSocket()函数,并将套接字对象作为参数传递给它。此外,main()函数包含一个try-catch块,旨在捕获和处理 Boost.Asio 方法和函数可能抛出的异常。
样本中有趣的部分是执行同步写入套接字的writeToSocket()函数。它接受一个套接字对象的引用作为参数。它的前提条件是传递给它的套接字已经连接;否则,函数将失败。
函数开始于分配和填充缓冲区。在这个示例中,我们使用 ASCII 字符串作为要写入套接字的数据,因此我们分配了一个std::string类的对象,并给它赋值为Hello,我们将使用这个作为将要写入套接字的占位符消息。
然后,定义了一个名为total_bytes_written的变量,并将其值设置为0。这个变量用作计数器,用于存储已经写入套接字的字节数。
接下来,运行一个循环,在该循环中调用套接字的write_some()方法。除了缓冲区为空的情况(即buf.length()方法返回值为0)之外,至少执行一次循环迭代,并且至少调用一次write_some()方法。让我们更仔细地看看这个循环:
while (total_bytes_written != buf.length()) {
total_bytes_written += sock.write_some(
asio::buffer(buf.c_str() +
total_bytes_written,
buf.length() - total_bytes_written));
}
当total_bytes_written变量的值等于缓冲区的大小时,终止条件评估为true,即当缓冲区中可用的所有字节都已被写入套接字时。在循环的每次迭代中,total_bytes_written变量的值都会增加write_some()方法返回的值,这个值等于在此方法调用期间写入的字节数。
每次调用write_some()方法时,传递给它的参数都会调整。与原始缓冲区相比,缓冲区的起始字节根据total_bytes_written的值进行偏移(因为前面的write_some()方法调用已经发送了前面的字节),并且缓冲区的大小相应地减少相同的值。
在循环终止后,缓冲区中的所有数据都被写入套接字,并且writeToSocket()函数返回。
值得注意的是,在单次调用 write_some() 方法期间写入套接字的字节数取决于几个因素。在一般情况下,开发者并不知道这一点;因此,不应将其计入考虑。一个演示的解决方案与此值无关,并且根据需要多次调用 write_some() 方法,将缓冲区中所有可用的数据写入套接字。
选项 - send() 方法
asio::ip::tcp::socket 类包含另一个同步将数据写入套接字的方法,名为 send()。此方法有三个重载。其中之一与前面描述的 write_some() 方法等效。它具有完全相同的签名,并提供了完全相同的功能。在某种意义上,这些方法是同义词。
第二个重载与 write_some() 方法相比接受一个额外的参数。让我们看看它:
template<
typename ConstBufferSequence>
std::size_t send(
const ConstBufferSequence & buffers,
socket_base::message_flags flags);
这个额外的参数被命名为 flags。它可以用来指定一个位掩码,表示控制操作的标志。因为这些标志使用得相当少,所以我们不会在本书中考虑它们。有关此主题的更多信息,请参阅 Boost.Asio 文档。
第三个重载与第二个重载等效,但在失败的情况下不会抛出异常。相反,错误信息通过一个额外的 boost::system::error_code 类型的方法输出参数返回。
还有更多...
使用套接字的 write_some() 方法向套接字写入数据对于这样一个简单的操作来说似乎非常复杂。即使我们只想发送由几个字节组成的小消息,我们也必须使用循环、一个变量来跟踪已经写入的字节数,并在循环的每次迭代中正确构造一个缓冲区。这种方法容易出错,并使代码更难以理解。
幸运的是,Boost.Asio 提供了一个免费函数,简化了向套接字写入的过程。这个函数被称为 asio::write()。让我们看看它的一种重载形式:
template<
typename SyncWriteStream,
typename ConstBufferSequence>
std::size_t write(
SyncWriteStream & s,
const ConstBufferSequence & buffers);
此函数接受两个参数。第一个参数名为 s 是一个满足 SyncWriteStream 概念要求的对象的引用。关于要求列表的完整信息,请参阅相应的 Boost.Asio 文档部分,链接为 www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/SyncWriteStream.html。表示 TCP 套接字的 asio::ip::tcp::socket 类的对象满足这些要求,因此可以用作函数的第一个参数。第二个参数名为 buffers 表示缓冲区(简单或复合),并包含要写入套接字的数据。
与套接字对象的write_some()方法不同,后者从缓冲区写入一些数据到套接字,asio::write()函数将缓冲区中所有可用的数据写入套接字。这简化了套接字的写入操作,并使代码更短更干净。
如果我们使用asio::write()函数而不是套接字对象的write_some()方法来向套接字写入数据,那么我们之前示例中的writeToSocket()函数将看起来是这样的:
void writeToSocketEnhanced(asio::ip::tcp::socket& sock) {
// Allocating and filling the buffer.
std::string buf = "Hello";
// Write whole buffer to the socket.
asio::write(sock, asio::buffer(buf));
}
asio::write()函数的实现方式与原始的writeToSocket()函数通过在循环中对套接字对象的write_some()方法进行多次调用而实现的方式相似。
注意
注意,asio::write()函数在刚刚考虑的函数之上还有七个重载。其中一些可能在特定情况下非常有用。请参阅 Boost.Asio 文档以了解更多关于此函数的信息,请参阅www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/write.html。
参见
-
在第三章的实现同步 TCP 客户端配方中,实现客户端应用程序展示了如何实现一个同步 TCP 客户端,该客户端执行同步写入以向服务器发送请求消息
-
在第四章的实现同步迭代 TCP 服务器配方中,实现服务器应用程序展示了如何实现一个同步 TCP 服务器,该服务器执行同步写入以向客户端发送响应消息
从 TCP 套接字中同步读取
从 TCP 套接字中读取是一个输入操作,用于接收连接到此套接字的远程应用程序发送的数据。同步读取是使用 Boost.Asio 提供的套接字接收数据的简单方法。执行同步读取的方法和函数会阻塞执行线程,直到从套接字中读取数据(至少一些数据)或发生错误才会返回。
在这个配方中,我们将看到如何从 TCP 套接字中同步读取数据。
如何做到这一点...
使用 Boost.Asio 库提供的套接字读取数据的最基本方式是asio::ip::tcp::socket类的read_some()方法。让我们看看这个方法的一个重载版本:
template<
typename MutableBufferSequence>
std::size_t read_some(
const MutableBufferSequence & buffers);
此方法接受一个表示可写缓冲区(单个或组合)的对象作为参数,正如其名称所暗示的,从套接字读取一定量的数据到缓冲区。如果方法成功,返回值表示读取的字节数。需要注意的是,无法控制方法将读取多少字节。该方法仅保证如果没有发生错误,至少会读取一个字节。这意味着,在一般情况下,为了从套接字读取一定量的数据,我们可能需要多次调用该方法。
以下算法描述了在分布式应用程序中同步从 TCP 套接字读取数据所需的步骤:
-
在客户端应用程序中,分配、打开并连接一个活动 TCP 套接字。在服务器应用程序中,通过使用接受器套接字接受连接请求来获取一个已连接的活动 TCP 套接字。
-
分配一个足够大的缓冲区,以便能够容纳要读取的预期消息。
-
在循环中,根据需要多次调用套接字的
read_some()方法来读取消息。
以下代码示例演示了一个客户端应用程序,该应用程序按照以下算法操作:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
std::string readFromSocket(asio::ip::tcp::socket& sock) {
const unsigned char MESSAGE_SIZE = 7;
char buf[MESSAGE_SIZE];
std::size_t total_bytes_read = 0;
while (total_bytes_read != MESSAGE_SIZE) {
total_bytes_read += sock.read_some(
asio::buffer(buf + total_bytes_read,
MESSAGE_SIZE - total_bytes_read));
}
return std::string(buf, total_bytes_read);
}
int main()
{
std::string raw_ip_address = "127.0.0.1";
unsigned short port_num = 3333;
try {
asio::ip::tcp::endpoint
ep(asio::ip::address::from_string(raw_ip_address),
port_num);
asio::io_service ios;
asio::ip::tcp::socket sock(ios, ep.protocol());
sock.connect(ep);
readFromSocket(sock);
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
尽管在所提供的代码示例中,从套接字读取是在充当客户端的应用程序上下文中执行的,但同样的方法也可以用于在服务器应用程序中从套接字读取数据。
它是如何工作的...
main()应用程序入口点函数相当简单。首先,它分配一个 TCP 套接字,打开并同步将其连接到远程应用程序。然后,调用readFromSocket()函数,并将套接字对象作为参数传递给它。此外,main()函数包含一个try-catch块,旨在捕获和处理 Boost.Asio 方法和函数可能抛出的异常。
样本中的有趣部分是readFromSocket()函数,它执行从套接字的同步读取。它接受套接字对象的引用作为输入参数。它的前提是传递给它的作为参数的套接字必须是连接的;否则,函数将失败。
函数开始时分配一个名为buf的缓冲区。缓冲区的大小被选择为 7 字节。这是因为在我们这个示例中,我们期望从远程应用程序接收一个正好 7 字节长的消息。
然后,定义一个名为total_bytes_read的变量,并将其值设置为0。该变量用作计数器,用于记录从套接字读取的总字节数。
接下来,运行循环,在其中调用套接字的read_some()方法。让我们更详细地看看这个循环:
while (total_bytes_read != MESSAGE_SIZE) {
total_bytes_read += sock.read_some(
asio::buffer(buf + total_bytes_read,
MESSAGE_SIZE - total_bytes_read));
}
当 total_bytes_read 变量的值等于预期消息的大小,即整个消息已从套接字中读取时,终止条件评估为 true。在循环的每次迭代中,total_bytes_read 变量的值会增加 read_some() 方法返回的值,该值等于在此方法调用期间读取的字节数。
每次调用 read_some() 方法时,传递给它的输入缓冲区都会进行调整。与原始缓冲区相比,缓冲区的起始字节会根据 total_bytes_read 的值进行偏移(因为缓冲区的先前部分已经在前几次调用 read_some() 方法时用从套接字读取的数据填充),并且缓冲区的大小相应地减少相同的值。
循环结束后,现在缓冲区中包含了从套接字中预期读取的所有数据。
readFromSocket() 函数以从接收到的缓冲区中实例化 std::string 类的对象并返回给调用者结束。
值得注意的是,在单次调用 read_some() 方法时,从套接字中读取的字节数取决于多个因素。在一般情况下,这并不为开发者所知;因此,不应将其考虑在内。所提出的解决方案与此值无关,并且根据需要多次调用 read_some() 方法以从套接字中读取所有数据。
替代方案 – receive() 方法
asio::ip::tcp::socket 类包含另一个从套接字同步读取数据的方法,称为 receive()。此方法有三个重载。其中之一与前面描述的 read_some() 方法等效。它具有完全相同的签名,并提供了完全相同的功能。在某种意义上,这些方法是同义词。
与 read_some() 方法相比,第二个重载方法多接受一个额外的参数。让我们来看看它:
template<
typename MutableBufferSequence>
std::size_t receive(
const MutableBufferSequence & buffers,
socket_base::message_flags flags);
这个额外的参数被命名为 flags。它可以用来指定一个位掩码,表示控制操作的标志。因为这些标志很少使用,所以我们不会在本书中考虑它们。有关此主题的更多信息,请参阅 Boost.Asio 文档。
第三个重载与第二个重载等效,但在失败的情况下不会抛出异常。相反,错误信息通过 boost::system::error_code 类型的额外输出参数返回。
还有更多...
使用套接字的 read_some() 方法从套接字读取数据对于这样一个简单的操作来说似乎非常复杂。这种方法要求我们使用循环、一个变量来跟踪已经读取的字节数,并且为循环的每次迭代正确构造一个缓冲区。这种方法容易出错,并使代码更难以理解和维护。
幸运的是,Boost.Asio 提供了一系列免费函数,这些函数在不同的上下文中简化了从套接字同步读取数据。有三个这样的函数,每个函数都有几个重载版本,提供了丰富的功能,有助于从套接字读取数据。
The asio::read() function
asio::read() 函数是三个函数中最简单的一个。让我们看看其中一个重载的声明:
template<
typename SyncReadStream,
typename MutableBufferSequence>
std::size_t read(
SyncReadStream & s,
const MutableBufferSequence & buffers);
此函数接受两个参数。第一个参数名为 s,是一个满足 SyncReadStream 概念要求的对象的引用。关于要求完整列表,请参阅在 www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/SyncReadStream.html 可用的相应 Boost.Asio 文档部分。表示 TCP 套接字的 asio::ip::tcp::socket 类的对象满足这些要求,因此可以用作函数的第一个参数。第二个参数名为 buffers,表示一个缓冲区(简单或复合),数据将从套接字读取到该缓冲区。
与从套接字读取到缓冲区的“某些”数据量的 read_some() 方法相比,asio::read() 函数在单次调用期间从套接字读取数据,直到传递给它的作为参数的缓冲区被填满或发生错误。这简化了从套接字读取的过程,并使代码更短更整洁。
如果我们使用 asio::read() 函数而不是套接字对象的 read_some() 方法来从套接字读取数据,那么前面的示例中的 readFromSocket() 函数将看起来像这样:
std::string readFromSocketEnhanced(asio::ip::tcp::socket& sock) {
const unsigned char MESSAGE_SIZE = 7;
char buf[MESSAGE_SIZE];
asio::read(sock, asio::buffer(buf, MESSAGE_SIZE));
return std::string(buf, MESSAGE_SIZE);
}
在前面的示例中,对 asio::read() 函数的调用将阻塞执行线程,直到恰好读取了 7 个字节或发生错误。与套接字的 read_some() 方法相比,这种方法的优势是显而易见的。
注意
asio::read() 函数有几个重载版本,在特定上下文中提供了灵活性。有关此函数的更多信息,请参阅 www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/read.html 的相应 Boost.Asio 文档部分。
asio::read_until() 函数
asio::read_until() 函数提供了一种从套接字读取数据的方法,直到在数据中遇到指定的模式。该函数有八个重载版本。让我们考虑其中之一:
template<
typename SyncReadStream,
typename Allocator>
std::size_t read_until(
SyncReadStream & s,
boost::asio::basic_streambuf< Allocator > & b,
char delim);
此函数接受三个参数。第一个参数名为 s 是一个满足 SyncReadStream 概念要求的对象的引用。有关要求列表的完整信息,请参阅相应的 Boost.Asio 文档部分,链接为 www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/SyncReadStream.html。asio::ip::tcp::socket 类的对象代表一个 TCP 套接字,它满足这些要求,因此可以用作函数的第一个参数。
第二个参数名为 b 代表一个面向流的可扩展缓冲区,数据将从中读取。最后一个参数名为 delim 指定一个分隔符字符。
asio::read_until() 函数将从 s 套接字读取数据到缓冲区 b,直到遇到由 delim 参数指定的字符,该字符位于数据的读取部分。当遇到指定的字符时,函数返回。
需要注意的是,asio::read_until() 函数的实现方式是按变量大小的块从套接字读取数据(内部使用套接字的 read_some() 方法读取数据)。当函数返回时,缓冲区 b 可能包含分隔符符号之后的某些符号。这可能发生在远程应用程序在分隔符符号之后发送更多数据的情况下(例如,它可能连续发送两条消息,每条消息的末尾都有一个分隔符符号)。换句话说,当 asio::read_until() 函数成功返回时,可以保证缓冲区 b 至少包含一个分隔符符号,但可能包含更多。解析缓冲区中的数据并处理包含分隔符符号之后数据的情形是开发者的责任。
如果我们想要从套接字读取所有数据直到遇到特定符号,我们将这样实现我们的 readFromSocket() 函数。假设消息分隔符为换行 ASCII 符号,\n:
std::string readFromSocketDelim(asio::ip::tcp::socket& sock) {
asio::streambuf buf;
// Synchronously read data from the socket until
// '\n' symbol is encountered.
asio::read_until(sock, buf, '\n');
std::string message;
// Because buffer 'buf' may contain some other data
// after '\n' symbol, we have to parse the buffer and
// extract only symbols before the delimiter.
std::istream input_stream(&buf);
std::getline(input_stream, message);
return message;
}
此示例相当简单直接。因为 buf 可能包含分隔符符号之后的更多符号,我们使用 std::getline() 函数提取分隔符符号之前感兴趣的消息,并将它们放入 message 字符串对象中,然后将其返回给调用者。
注意
read_until() 函数有几个重载版本,提供了更复杂的方式来指定终止条件,例如字符串分隔符、正则表达式或函数对象。有关此主题的更多信息,请参阅相应的 Boost.Asio 文档部分,链接为 www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/read_until.html。
asio::read_at() 函数
asio::read_at() 函数提供了一种从套接字读取数据的方法,从特定的偏移量开始。由于此函数很少使用,它超出了本书的范围。有关此函数及其重载的更多详细信息,请参阅相应的 Boost.Asio 文档部分,链接为 www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/read_at.html。
asio::read()、asio::read_until() 和 asio::read_at() 函数的实现方式与我们的示例中通过在循环中对套接字对象的 read_some() 方法进行多次调用直到满足终止条件或发生错误来实现的原始 readFromSocket() 函数类似。
参见
-
使用可扩展的流式 I/O 缓冲区 菜单展示了如何向
asio::streambuf缓冲区写入和读取数据 -
在 第三章 的 实现客户端应用程序 菜单中,实现同步 TCP 客户端 菜单演示了如何实现一个同步 TCP 客户端,该客户端从套接字同步读取以接收服务器发送的响应消息
-
在 第四章 的 实现服务器应用程序 菜单中,实现同步迭代 TCP 服务器 菜单演示了如何实现一个同步 TCP 服务器,该服务器执行同步读取以接收客户端发送的请求消息
异步写入 TCP 套接字
异步写入是一种灵活且高效地向远程应用程序发送数据的方式。在本菜谱中,我们将看到如何异步写入 TCP 套接字。
如何操作…
在 Boost.Asio 库提供的套接字上异步写入数据的最基本工具是 asio::ip::tcp::socket 类的 async_write_some() 方法。让我们看看该方法的一个重载:
template<
typename ConstBufferSequence,
typename WriteHandler>
void async_write_some(
const ConstBufferSequence & buffers,
WriteHandler handler);
此方法启动写入操作并立即返回。它接受一个表示要写入套接字的数据的缓冲区的对象作为其第一个参数。第二个参数是一个回调,当启动的操作完成时,Boost.Asio 将调用它。此参数可以是函数指针、仿函数或满足 WriteHandler 概念要求的任何其他对象。完整的要求列表可以在 Boost.Asio 文档的相应部分找到,链接为 www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/WriteHandler.html。
回调应具有以下签名:
void write_handler(
const boost::system::error_code& ec,
std::size_t bytes_transferred);
在这里,ec 是一个参数,如果发生错误,则表示错误代码,而 bytes_transferred 参数表示在相应的异步操作期间写入套接字的字节数。
如async_write_some()方法的名字所暗示的,它启动一个操作,目的是从缓冲区向套接字写入一些数据。此方法保证在相应的异步操作中如果没有发生错误,至少将写入一个字节。这意味着,在一般情况下,为了将缓冲区中所有可用的数据写入套接字,我们可能需要执行此异步操作多次。
现在我们知道了关键方法是如何工作的,让我们看看如何实现一个执行异步套接字写入的应用程序。
以下算法描述了执行和实现写入 TCP 套接字异步数据的应用程序所需的步骤。请注意,此算法提供了一个可能实现此类应用程序的方法。Boost.Asio 非常灵活,允许我们通过以多种不同的方式异步写入套接字数据来组织和结构化应用程序:
-
定义一个包含指向套接字对象的指针、缓冲区和用作已写入字节数计数器的变量的数据结构。
-
定义一个回调函数,当异步写入操作完成时将被调用。
-
在客户端应用程序中,分配并打开一个活跃的 TCP 套接字并将其连接到远程应用程序。在服务器应用程序中,通过接受连接请求来获取一个已连接的活跃 TCP 套接字。
-
分配一个缓冲区并填充要写入套接字的数据。
-
通过调用套接字的
async_write_some()方法来启动异步写入操作。指定在第 2 步中定义的函数作为回调。 -
在
asio::io_service类的对象上调用run()方法。 -
在回调中增加已写入字节数的计数器。如果已写入的字节数少于要写入的总字节数,则启动一个新的异步写入操作来写入数据的下一部分。
让我们实现一个示例客户端应用程序,该应用程序根据前面的算法执行异步写入。
我们从添加include和using指令开始:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
接下来,根据算法的第 1 步,我们定义一个包含指向套接字对象的指针、包含要写入的数据的缓冲区以及包含已写入字节数的计数器变量的数据结构:
// Keeps objects we need in a callback to
// identify whether all data has been written
// to the socket and to initiate next async
// writing operation if needed.
struct Session {
std::shared_ptr<asio::ip::tcp::socket> sock;
std::string buf;
std::size_t total_bytes_written;
};
在第 2 步中,我们定义了一个回调函数,当异步操作完成时将被调用:
// Function used as a callback for
// asynchronous writing operation.
// Checks if all data from the buffer has
// been written to the socket and initiates
// new asynchronous writing operation if needed.
void callback(const boost::system::error_code& ec,
std::size_t bytes_transferred,
std::shared_ptr<Session> s)
{
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
return;
}
s->total_bytes_written += bytes_transferred;
if (s->total_bytes_written == s->buf.length()) {
return;
}
s->sock->async_write_some(
asio::buffer(
s->buf.c_str() +
s->total_bytes_written,
s->buf.length() -
s->total_bytes_written),
std::bind(callback, std::placeholders::_1,
std::placeholders::_2, s));
}
现在,我们先跳过第 3 步,并在一个单独的函数中实现第 4 步和第 5 步。让我们把这个函数叫做writeToSocket():
void writeToSocket(std::shared_ptr<asio::ip::tcp::socket> sock) {
std::shared_ptr<Session> s(new Session);
// Step 4\. Allocating and filling the buffer.
s->buf = std::string("Hello");
s->total_bytes_written = 0;
s->sock = sock;
// Step 5\. Initiating asynchronous write operation.
s->sock->async_write_some(
asio::buffer(s->buf),
std::bind(callback,
std::placeholders::_1,
std::placeholders::_2,
s));
}
现在,我们回到第 3 步,并在main()应用程序入口点函数中实现它:
int main()
{
std::string raw_ip_address = "127.0.0.1";
unsigned short port_num = 3333;
try {
asio::ip::tcp::endpoint
ep(asio::ip::address::from_string(raw_ip_address),
port_num);
asio::io_service ios;
// Step 3\. Allocating, opening and connecting a socket.
std::shared_ptr<asio::ip::tcp::socket> sock(
new asio::ip::tcp::socket(ios, ep.protocol()));
sock->connect(ep);
writeToSocket(sock);
// Step 6.
ios.run();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的…
现在,让我们追踪应用程序的执行路径,以便更好地理解它是如何工作的。
应用程序由单个线程运行,在此上下文中调用应用程序的main()入口点函数。请注意,Boost.Asio 可能会为某些内部操作创建额外的线程,但它保证不会在这些线程的上下文中执行任何应用程序代码。
main()函数分配、打开并同步地将套接字连接到远程应用程序,然后通过传递套接字对象的指针调用writeToSocket()函数。此函数启动异步写入操作并返回。我们稍后将考虑此函数。main()函数继续调用asio::io_service类对象的run()方法,其中 Boost.Asio 捕获执行线程,并在异步操作完成时使用它来调用相关的回调函数。
asio::os_service::run()方法在至少有一个挂起的异步操作时阻塞。当最后一个挂起的异步操作的最后一个回调完成时,此方法返回。
现在,让我们回到writeToSocket()函数并分析其行为。它首先在空闲内存中分配Session数据结构的一个实例。然后,它分配并填充缓冲区,以包含要写入套接字的数据。之后,将套接字对象的指针和缓冲区存储在Session对象中。由于套接字的async_write_some()方法可能不会一次性将所有数据写入套接字,我们可能需要在回调函数中启动另一个异步写入操作。这就是为什么我们需要Session对象,并且我们将其分配在空闲内存中而不是栈上;它必须存活直到回调函数被调用。
最后,我们启动异步操作,调用套接字对象的async_write_some()方法。此方法的调用相对复杂,因此让我们更详细地考虑这一点:
s->sock->async_write_some(
asio::buffer(s->buf),
std::bind(callback,
std::placeholders::_1,
std::placeholders::_2,
s));
第一个参数是包含要写入套接字的数据的缓冲区。由于操作是异步的,Boost.Asio 可能在操作启动和回调调用之间的任何时刻访问此缓冲区。这意味着缓冲区必须保持完整,并且必须在回调调用之前可用。我们通过将缓冲区存储在Session对象中,而Session对象又存储在空闲内存中,来保证这一点。
第二个参数是在异步操作完成后要调用的回调。Boost.Asio 将回调定义为一种概念,它可以是一个函数或函数对象,接受两个参数。回调的第一个参数指定在操作执行过程中发生的错误(如果有)。第二个参数指定操作写入的字节数。
因为我们想向回调函数传递一个额外的参数,即指向相应 Session 对象的指针,该对象作为操作的上下文,所以我们使用 std::bind() 函数构建一个函数对象,并将指向 Session 对象的指针作为第三个参数附加到该对象上。然后,将这个函数对象作为回调参数传递给套接字对象的 async_write_some() 方法。
由于它是异步的,async_write_some() 方法不会阻塞执行线程。它启动写入操作并返回。
实际的写入操作由 Boost.Asio 库和底层操作系统在幕后执行,当操作完成或发生错误时,会调用回调函数。
当被调用时,名为 callback 的回调函数(在我们的示例应用程序中直接称为 callback)首先检查操作是否成功或发生错误。在后一种情况下,错误信息会被输出到标准输出流,并且函数返回。否则,总写入字节数会增加由操作产生的字节数。然后,我们检查写入套接字的总字节数是否等于缓冲区的大小。如果这些值相等,这意味着所有数据都已写入套接字,没有更多的工作要做。回调函数返回。然而,如果缓冲区中仍有要写入的数据,则会启动一个新的异步写入操作:
s->sock->async_write_some(
asio::buffer(
s->buf.c_str() +
s->total_bytes_written,
s->buf.length() –
s->total_bytes_written),
std::bind(callback, std::placeholders::_1,
std::placeholders::_2, s));
注意缓冲区开始部分是如何根据已写入的字节数进行偏移的,以及缓冲区大小相应地减少了多少。
作为回调,我们使用 std::bind() 函数指定相同的 callback() 函数,并附加一个额外的参数——Session 对象,就像我们在启动第一个异步操作时做的那样。
异步写入操作的启动和后续回调调用的周期会重复,直到缓冲区中的所有数据都写入套接字或发生错误。
当 callback 函数返回而不启动新的异步操作时,在 main() 函数中调用的 asio::io_service::run() 方法会解除执行线程的阻塞并返回。main() 函数也会返回。这时,应用程序退出。
还有更多...
尽管前面示例中描述的 async_write_some() 方法允许异步地将数据写入套接字,但基于它的解决方案相对复杂且容易出错。幸运的是,Boost.Asio 提供了一种更方便的方法,使用自由函数 asio::async_write() 来异步写入套接字数据。让我们考虑它的一个重载版本:
template<
typename AsyncWriteStream,
typename ConstBufferSequence,
typename WriteHandler>
void async_write(
AsyncWriteStream & s,
const ConstBufferSequence & buffers,
WriteHandler handler);
此函数与套接字的async_write_some()方法非常相似。其第一个参数是一个满足AsyncWriteStream概念要求的对象。关于要求完整列表,请参阅相应的 Boost.Asio 文档部分,www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/AsyncWriteStream.html。asio::ip::tcp::socket类的对象满足这些要求,因此可以与该函数一起使用。
asio::async_write()函数的第二个和第三个参数与前面示例中描述的 TCP 套接字对象的async_write_some()方法的第一个和第二个参数类似。这些参数是包含要写入的数据的缓冲区,以及表示回调的函数或对象,当操作完成时将被调用。
与套接字的async_write_some()方法不同,后者启动从缓冲区到套接字的写入操作,写入一些数据量,而asio::async_write()函数启动的操作则是写入缓冲区中所有可用的数据。在这种情况下,回调函数仅在缓冲区中所有数据都写入套接字或发生错误时被调用。这简化了套接字的写入操作,并使代码更短更整洁。
如果我们将之前的示例修改为使用asio::async_write()函数而不是套接字对象的async_write_some()方法来异步写入套接字数据,那么我们的应用程序将变得更加简单。
首先,我们不需要跟踪写入套接字字节数,因此,Session结构变得更小:
struct Session {
std::shared_ptr<asio::ip::tcp::socket> sock;
std::string buf;
};
其次,我们知道当回调函数被调用时,这意味着缓冲区中的所有数据都已写入套接字或发生了错误。这使得回调函数变得更加简单:
void callback(const boost::system::error_code& ec,
std::size_t bytes_transferred,
std::shared_ptr<Session> s)
{
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
return;
}
// Here we know that all the data has
// been written to the socket.
}
asio::async_write()函数是通过零个或多个对套接字对象的async_write_some()方法的调用实现的。这类似于我们初始示例中的writeToSocket()函数的实现。
注意
注意,asio::async_write()函数有三个额外的重载,提供了额外的功能。在某些特定情况下,其中一些可能非常有用。有关此函数的更多信息,请参阅 Boost.Asio 文档,www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/async_write.html。
参见
-
同步写入 TCP 套接字配方描述了如何同步地将数据写入 TCP 套接字
-
在第三章实现客户端应用程序中的实现异步 TCP 客户端配方(ch03.html "第三章。实现客户端应用程序"),展示了如何实现一个异步 TCP 客户端,该客户端执行异步写入 TCP 套接字以向服务器发送请求消息。
-
在第四章实现服务器应用程序中的实现异步 TCP 服务器配方(ch04.html "第四章。实现服务器应用程序"),展示了如何实现一个异步 TCP 服务器,该服务器执行异步写入 TCP 套接字以向客户端发送响应消息。
异步从 TCP 套接字读取
异步读取是一种灵活且高效地从远程应用程序接收数据的方式。在本配方中,我们将了解如何从 TCP 套接字异步读取数据。
如何做到这一点...
Boost.Asio 库提供的用于异步从 TCP 套接字读取数据的最基本工具是asio::ip::tcp::socket类的async_read_some()方法。以下是该方法的一个重载示例:
template<
typename MutableBufferSequence,
typename ReadHandler>
void async_read_some(
const MutableBufferSequence & buffers,
ReadHandler handler);
此方法启动一个异步读取操作并立即返回。它接受一个表示可变缓冲区的对象作为其第一个参数,数据将从套接字读取到该对象中。第二个参数是 Boost.Asio 在操作完成时调用的回调。此参数可以是函数指针、仿函数或满足ReadHandler概念要求的任何其他对象。完整的要求列表可以在 Boost.Asio 文档的相应部分中找到,请参阅www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/ReadHandler.html。
回调应具有以下签名:
void read_handler(
const boost::system::error_code& ec,
std::size_t bytes_transferred);
在这里,ec是一个参数,如果发生错误,则通知错误代码,而bytes_transferred参数指示在相应的异步操作期间从套接字中读取了多少字节。
如async_read_some()方法的名字所暗示的,它启动一个操作,目的是从套接字到缓冲区读取一些数据。如果未发生错误,此方法保证在相应的异步操作期间至少读取一个字节。这意味着,在一般情况下,为了从套接字读取所有数据,我们可能需要执行此异步操作多次。
既然我们已经了解了关键方法的工作原理,让我们看看如何实现一个从套接字执行异步读取的应用程序。
以下算法描述了实现一个从套接字异步读取数据的应用程序所需的步骤。请注意,此算法提供了一个可能的实现此类应用程序的方法。Boost.Asio 非常灵活,允许我们通过以不同方式从套接字异步读取数据来组织和结构化应用程序:
-
定义一个包含指向套接字对象的指针、一个缓冲区、一个定义缓冲区大小的变量以及一个用作读取的字节数计数器的变量的数据结构。
-
定义一个回调函数,当异步读取操作完成时将被调用。
-
在客户端应用程序中,分配并打开一个活动 TCP 套接字,然后将其连接到远程应用程序。在服务器应用程序中,通过接受连接请求来获取一个已连接的活动 TCP 套接字。
-
分配一个足够大的缓冲区,以便预期的消息可以容纳。
-
通过调用套接字的
async_read_some()方法并指定步骤 2 中定义的函数作为回调来启动异步读取操作。 -
在
asio::io_service类的对象上调用run()方法。 -
在回调中,增加读取的字节数计数器。如果读取的字节数少于要读取的总字节数(即预期消息的大小),则启动一个新的异步读取操作以读取下一部分数据。
让我们实现一个示例客户端应用程序,该程序将根据前面的算法执行异步读取。
我们从添加include和using指令开始:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
接下来,根据步骤 1,我们定义一个包含名为sock的套接字对象指针、名为buf的缓冲区指针、名为buf_size的变量(包含缓冲区大小)以及包含已读取的字节数的total_bytes_read变量的数据结构:
// Keeps objects we need in a callback to
// identify whether all data has been read
// from the socket and to initiate next async
// reading operation if needed.
struct Session {
std::shared_ptr<asio::ip::tcp::socket> sock;
std::unique_ptr<char[]> buf;
std::size_t total_bytes_read;
unsigned int buf_size;
};
在步骤 2 中,我们定义了一个回调函数,当异步操作完成时将被调用:
// Function used as a callback for
// asynchronous reading operation.
// Checks if all data has been read
// from the socket and initiates
// new reading operation if needed.
void callback(const boost::system::error_code& ec,
std::size_t bytes_transferred,
std::shared_ptr<Session> s)
{
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
return;
}
s->total_bytes_read += bytes_transferred;
if (s->total_bytes_read == s->buf_size) {
return;
}
s->sock->async_read_some(
asio::buffer(
s->buf.get() +
s->total_bytes_read,
s->buf_size -
s->total_bytes_read),
std::bind(callback, std::placeholders::_1,
std::placeholders::_2, s));
}
让我们暂时跳过步骤 3,并在一个单独的函数中实现步骤 4 和 5。让我们把这个函数命名为readFromSocket():
void readFromSocket(std::shared_ptr<asio::ip::tcp::socket> sock) {
std::shared_ptr<Session> s(new Session);
// Step 4\. Allocating the buffer.
const unsigned int MESSAGE_SIZE = 7;
s->buf.reset(new char[MESSAGE_SIZE]);
s->total_bytes_read = 0;
s->sock = sock;
s->buf_size = MESSAGE_SIZE;
// Step 5\. Initiating asynchronous reading operation.
s->sock->async_read_some(
asio::buffer(s->buf.get(), s->buf_size),
std::bind(callback,
std::placeholders::_1,
std::placeholders::_2,
s));
}
现在,我们回到步骤 3,并在应用程序的main()入口点函数中实现它:
int main()
{
std::string raw_ip_address = "127.0.0.1";
unsigned short port_num = 3333;
try {
asio::ip::tcp::endpoint
ep(asio::ip::address::from_string(raw_ip_address),
port_num);
asio::io_service ios;
// Step 3\. Allocating, opening and connecting a socket.
std::shared_ptr<asio::ip::tcp::socket> sock(
new asio::ip::tcp::socket(ios, ep.protocol()));
sock->connect(ep);
readFromSocket(sock);
// Step 6.
ios.run();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的…
现在,让我们跟踪应用程序的执行路径,以便更好地理解它是如何工作的。
应用程序由单个线程运行;在这个上下文中,调用应用程序的main()入口点函数。请注意,Boost.Asio 可能会为某些内部操作创建额外的线程,但它保证不会在那些线程的上下文中调用应用程序代码。
main()函数开始于分配、打开并将套接字连接到远程应用程序。然后,它调用readFromSocket()函数,并将套接字对象的指针作为参数传递。readFromSocket()函数启动一个异步读取操作并返回。我们稍后将考虑这个函数。main()函数继续调用asio::io_service类的对象的run()方法,其中 Boost.Asio 捕获执行线程,并在异步操作完成时使用它来调用相关的回调函数。
asio::io_service::run() 方法会阻塞,直到至少有一个挂起的异步操作。当最后一个挂起的操作的最后一个回调完成时,此方法返回。
现在,让我们回到 readFromSocket() 函数并分析其行为。它首先在空闲内存中分配 Session 数据结构的一个实例。然后,它分配一个缓冲区并将指向它的指针存储在先前分配的 Session 数据结构实例中。将套接字对象的指针和缓冲区的大小存储在 Session 数据结构中。因为套接字的 async_read_some() 方法可能不会一次性读取所有数据,我们可能需要在回调函数中启动另一个异步读取操作。这就是为什么我们需要 Session 数据结构,以及为什么我们在空闲内存中而不是在栈上分配它的原因。这个结构和其中驻留的所有对象至少必须持续到回调被调用。
最后,我们启动异步操作,调用套接字对象的 async_read_some() 方法。这个方法的调用有些复杂;因此,让我们更详细地看看它:
s->sock->async_read_some(
asio::buffer(s->buf.get(), s->buf_size),
std::bind(callback,
std::placeholders::_1,
std::placeholders::_2,
s));
第一个参数是要读取数据的缓冲区。由于操作是异步的,Boost.Asio 可能在任何时刻(从操作启动到回调被调用之间)访问这个缓冲区。这意味着缓冲区必须保持完整并在回调被调用之前可用。我们通过在空闲内存中分配缓冲区并将其存储在 Session 数据结构中来保证这一点,而 Session 数据结构本身也是在空闲内存中分配的。
第二个参数是一个在异步操作完成后要调用的回调函数。Boost.Asio 将回调定义为一种概念,它可以是一个函数或仿函数,接受两个参数。回调的第一个参数指定在操作执行过程中发生的错误(如果有)。第二个参数指定操作读取的字节数。
因为我们想向我们的回调函数传递一个额外的参数,即指向相应 Session 对象的指针,该对象作为操作的上下文——我们使用 std::bind() 函数来构造一个函数对象,我们将指向 Session 对象的指针作为第三个参数附加到该函数对象上。然后,将这个函数对象作为回调参数传递给套接字对象的 async_write_some() 方法。
因为它是异步的,所以 async_write_some() 方法不会阻塞执行线程。它启动读取操作然后返回。
实际的读取操作由 Boost.Asio 库和底层操作系统在幕后执行,当操作完成或发生错误时,会调用回调。
当调用时,名为 callback 的回调函数(在我们的示例应用程序中直接称为 callback)首先检查操作是否成功或发生错误。在后一种情况下,错误信息会被输出到标准输出流,并且函数返回。否则,总读取字节数会增加操作结果读取的字节数。然后,我们检查从套接字读取的总字节数是否等于缓冲区的大小。如果这两个值相等,这意味着缓冲区已满,没有更多工作要做。回调函数返回。然而,如果缓冲区中仍有空间,我们需要继续读取;因此,我们启动一个新的异步读取操作:
s->sock->async_read_some(
asio::buffer(s->buf.get(), s->buf_size),
std::bind(callback,
std::placeholders::_1,
std::placeholders::_2,
s));
注意,缓冲区的开始位置会根据已读取的字节数进行偏移,并且缓冲区的大小会相应减少。
作为回调,我们使用 std::bind() 函数指定相同的 callback 函数,以附加一个额外的参数——Session 对象。
异步读取操作启动和后续回调调用的周期会一直重复,直到缓冲区满或发生错误。
当 callback 函数返回而不启动新的异步操作时,在 main() 函数中调用的 asio::io_service::run() 方法将执行线程解锁并返回。此时,main() 函数也会返回。这就是应用程序退出的时刻。
还有更多...
尽管前面示例中描述的 async_read_some() 方法允许异步从套接字读取数据,但基于它的解决方案相对复杂且容易出错。幸运的是,Boost.Asio 提供了一种更方便的方式异步从套接字读取数据:免费函数 asio::async_read()。让我们考虑其重载之一:
template<
typename AsyncReadStream,
typename MutableBufferSequence,
typename ReadHandler>
void async_read(
AsyncReadStream & s,
const MutableBufferSequence & buffers,
ReadHandler handler);
此函数与套接字的 async_read_some() 方法非常相似。它的第一个参数是一个满足 AsyncReadStream 概念要求的对象。关于要求的完整列表,请参阅相应的 Boost.Asio 文档部分,链接为 www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/AsyncReadStream.html。asio::ip::tcp::socket 类的对象满足这些要求,因此可以与该函数一起使用。
asio::async_read() 函数的第二个和第三个参数与前面示例中描述的 TCP 套接字对象的 async_read_some() 方法的第一个和第二个参数类似。这些参数用作数据目的点的缓冲区以及表示回调的函数或对象,当操作完成时将被调用。
与async_read_some()方法不同,后者启动操作,从套接字读取一些数据到缓冲区,asio::async_read()函数启动的操作是从套接字读取数据,直到作为参数传递给它的缓冲区满为止。在这种情况下,当读取的数据量等于提供的缓冲区大小时,或者当发生错误时,会调用回调函数。这简化了从套接字读取的过程,并使代码更短更整洁。
如果我们将之前的示例修改为使用asio::async_read()函数而不是套接字对象的async_read_some()方法来异步从套接字读取数据,那么我们的应用程序将变得更加简单。
首先,我们不需要跟踪从套接字读取的字节数;因此,Session结构变得更小:
struct Session {
std::shared_ptr<asio::ip::tcp::socket> sock;
std::unique_ptr<char[]> buf;
unsigned int buf_size;
};
其次,我们知道当回调函数被调用时,这意味着要么已从套接字读取了预期数量的数据,要么发生了错误。这使得回调函数变得更加简单:
void callback(const boost::system::error_code& ec,
std::size_t bytes_transferred,
std::shared_ptr<Session> s)
{
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
return;
}
// Here we know that the reading has completed
// successfully and the buffer is full with
// data read from the socket.
}
asio::async_read()函数是通过零个或多个调用套接字对象的async_read_some()方法实现的。这与我们初始示例中的readFromSocket()函数的实现方式相似。
注意
注意,asio::async_read()函数有三个额外的重载,提供了额外的功能。在某些特定情况下,其中一些可能非常有用。请参阅 Boost.Asio 文档了解详情,链接为www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/async_read.html。
参见
-
从 TCP 套接字同步读取配方描述了如何从 TCP 套接字同步读取数据
-
在第三章的实现异步 TCP 客户端配方中,实现客户端应用程序,展示了如何实现一个异步 TCP 客户端,该客户端从 TCP 套接字异步读取以接收服务器发送的响应消息
-
在第四章的实现异步 TCP 服务器配方中,实现服务器应用程序,展示了如何实现一个异步 TCP 服务器,该服务器从 TCP 套接字异步读取以接收客户端发送的请求消息
取消异步操作
有时,在异步操作已启动但尚未完成时,应用程序中的条件可能会发生变化,使得启动的操作变得无关紧要或过时,没有人对操作完成感兴趣。
此外,如果启动的异步操作是对用户命令的反应,那么在操作执行过程中,用户可能会改变主意。用户可能想要取消之前发出的命令,并可能想要发出不同的命令或决定退出应用程序。
考虑这样一个情况,用户在典型的网络浏览器地址栏中输入一个网站地址并按下Enter键。浏览器立即启动 DNS 名称解析操作。当 DNS 名称解析并获取相应的 IP 地址后,它启动连接操作以连接到相应的 Web 服务器。当建立连接后,浏览器启动异步写入操作以向服务器发送请求。最后,当请求发送后,浏览器开始等待响应消息。根据服务器应用程序的响应速度、通过网络传输的数据量、网络状态和其他因素,所有这些操作可能需要相当长的时间。而在等待请求的网页加载时,用户可能会改变主意,在页面加载完成之前,用户可能在地址栏中输入另一个网站地址并按下Enter。
另一个(极端)的情况是,客户端应用程序向服务器应用程序发送请求并开始等待响应消息,但服务器应用程序在处理客户端请求时,由于自身中的错误而陷入死锁。在这种情况下,用户将不得不永远等待响应消息,并且永远不会收到它。
在这两种情况下,客户端应用程序的用户将受益于在操作完成之前取消他们启动的操作的能力。一般来说,提供一个用户可以取消可能需要明显时间的操作的能力是一个好的实践。因为网络通信操作可能持续不可预测的长时间,所以在通过网络通信的分布式应用程序中支持操作的取消非常重要。
Boost.Asio 库提供的异步操作的一个好处是它们可以在启动后的任何时刻取消。在这个菜谱中,我们将看到如何取消异步操作。
如何实现它...
以下算法提供了使用 Boost.Asio 初始化和取消异步操作的步骤:
-
如果应用程序旨在在 Windows XP 或 Windows Server 2003 上运行,则定义启用这些 Windows 版本的异步操作取消的标志。
-
分配并打开一个 TCP 或 UDP 套接字。它可能是客户端或服务器应用程序中的活动套接字或被动(接受者)套接字。
-
定义一个用于异步操作的回调函数或仿函数。如果需要,在这个回调中实现一段代码分支,用于处理操作被取消的情况。
-
启动一个或多个异步操作,并指定步骤 4 中定义的函数或对象作为回调。
-
启动一个额外的线程并使用它来运行 Boost.Asio 事件循环。
-
在套接字对象上调用
cancel()方法以取消与此套接字相关联的所有挂起的异步操作。
让我们考虑一个客户端应用程序的实现,该应用程序按照所提出的算法设计,首先启动一个异步连接操作,然后取消该操作。
根据步骤 1,为了在 Windows XP 或 Windows Server 2003 上编译和运行我们的代码,我们需要定义一些标志来控制 Boost.Asio 库对底层 OS 机制的使用行为。
默认情况下,当编译为 Windows 版本时,Boost.Asio 使用 I/O 完成端口框架来异步运行操作。在 Windows XP 和 Windows Server 2003 上,该框架在操作取消方面存在一些问题和限制。因此,Boost.Asio 要求开发者明确通知他们希望在目标 Windows 版本的应用程序中启用异步操作取消功能,尽管已知存在这些问题。为此,必须在包含 Boost.Asio 头文件之前定义BOOST_ASIO_ENABLE_CANCELIO宏。否则,如果未定义此宏,而应用程序的源代码包含对异步操作、取消方法和函数的调用,则编译将始终失败。
换句话说,当目标 Windows XP 或 Windows Server 2003 时,必须定义BOOST_ASIO_ENABLE_CANCELIO宏,并且应用程序需要取消异步操作。
为了消除 Windows XP 和 Windows Server 2003 上使用 I/O 完成端口框架带来的问题和限制,我们可以在包含 Boost.Asio 头文件之前定义另一个名为BOOST_ASIO_DISABLE_IOCP的宏。定义此宏后,Boost.Asio 在 Windows 上不使用 I/O 完成端口框架;因此,与异步操作取消相关的问题消失。然而,I/O 完成端口框架的可扩展性和效率优势也随之消失。
注意,与异步操作取消相关的所述问题和限制在 Windows Vista 和 Windows Server 2008 及以后的版本中不存在。因此,当目标这些版本的 Windows 时,取消操作可以正常工作,除非有其他原因需要禁用 I/O 完成端口框架的使用。有关此问题的更多详细信息,请参阅asio::ip::tcp::cancel()方法的文档部分,链接为www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/basic_stream_socket/cancel/overload1.html。
在我们的示例中,我们将考虑如何构建一个跨平台应用程序,当在编译时针对 Windows,可以从 Windows XP 或 Windows Server 2003 开始运行。因此,我们定义了BOOST_ASIO_DISABLE_IOCP和BOOST_ASIO_ENABLE_CANCELIO宏。
为了在编译时确定目标操作系统,我们使用Boost.Predef库。这个库为我们提供了宏定义,允许我们识别代码编译环境的参数,作为目标操作系统家族及其版本、处理器架构、编译器等。有关此库的更多详细信息,请参阅 Boost.Asio 文档部分,www.boost.org/doc/libs/1_58_0/libs/predef/doc/html/index.html。
要使用Boost.Predef库,我们需要包含以下头文件:
#include <boost/predef.h> // Tools to identify the OS.
然后,我们检查代码是否正在为 Windows XP 或 Windows Server 2003 编译,如果是,我们定义BOOST_ASIO_DISABLE_IOCP和BOOST_ASIO_ENABLE_CANCELIO宏:
#ifdef BOOST_OS_WINDOWS
#define _WIN32_WINNT 0x0501
#if _WIN32_WINNT <= 0x0502 // Windows Server 2003 or earlier.
#define BOOST_ASIO_DISABLE_IOCP
#define BOOST_ASIO_ENABLE_CANCELIO
#endif
#endif
接下来,我们包含常见的 Boost.Asio 头文件和标准库<thread>头文件。我们还需要后者,因为我们在应用程序中会创建额外的线程。此外,我们指定一个using指令,使 Boost.Asio 类和函数的名称更短,更方便使用:
#include <boost/asio.hpp>
#include <iostream>
#include <thread>
using namespace boost;
然后,我们定义应用程序的main()入口点函数,它包含应用程序的所有功能:
int main()
{
std::string raw_ip_address = "127.0.0.1";
unsigned short port_num = 3333;
try {
asio::ip::tcp::endpoint
ep(asio::ip::address::from_string(raw_ip_address),
port_num);
asio::io_service ios;
std::shared_ptr<asio::ip::tcp::socket> sock(
new asio::ip::tcp::socket(ios, ep.protocol()));
sock->async_connect(ep,
sock
{
// If asynchronous operation has been
// cancelled or an error occured during
// execution, ec contains corresponding
// error code.
if (ec != 0) {
if (ec == asio::error::operation_aborted) {
std::cout << "Operation cancelled!";
}
else {
std::cout << "Error occured!"
<< " Error code = "
<< ec.value()
<< ". Message: "
<< ec.message();
}
return;
}
// At this point the socket is connected and
// can be used for communication with
// remote application.
});
// Starting a thread, which will be used
// to call the callback when asynchronous
// operation completes.
std::thread worker_thread([&ios](){
try {
ios.run();
}
catch (system::system_error &e) {
std::cout << "Error occured!"
<< " Error code = " << e.code()
<< ". Message: " << e.what();
}
});
// Emulating delay.
std::this_thread::sleep_for(std::chrono::seconds(2));
// Cancelling the initiated operation.
sock->cancel();
// Waiting for the worker thread to complete.
worker_thread.join();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的…
现在,让我们分析应用程序的工作原理。
我们的示例客户端应用程序由一个单一的功能组成,即应用程序的main()入口点函数。此函数从根据算法的第 2 步分配和打开 TCP 套接字开始。
接下来,在套接字上启动异步连接操作。提供给方法的回调实现为一个 lambda 函数。这对应于算法的第 3 步和第 4 步。注意,在回调函数中确定操作是否被取消。当异步操作被取消时,回调被调用,其参数指定了错误代码,包含在 Boost.Asio 中定义的与操作系统相关的错误代码asio::error::operation_aborted。
然后,我们创建一个名为worker_thread的线程,该线程将用于运行 Boost.Asio 事件循环。在这个线程的上下文中,回调函数将由库调用。worker_thread线程的入口点函数相当简单。它包含一个try-catch块和对asio::io_service对象run()方法的调用。这对应于算法的第 5 步。
在创建工作线程之后,主线程将休眠 2 秒钟。这是为了让连接操作有更多的时间进行,并模拟实际应用程序中用户发出的两个命令之间的延迟;例如,一个网页浏览器。
根据算法的最后一步 6,我们调用套接字对象的cancel()方法来取消已启动的连接操作。此时,如果操作尚未完成,它将被取消,并且相应的回调将使用一个指定包含asio::error::operation_aborted值的错误代码的参数来调用,以通知操作已被取消。然而,如果操作已经完成,调用cancel()方法将没有效果。
当回调函数返回时,工作线程会退出事件循环,因为没有更多的挂起异步操作需要执行。因此,线程会退出其入口点函数。这导致主线程运行到完成。最终,应用程序退出。
更多内容...
在前面的示例中,我们考虑了与活动 TCP 套接字相关联的异步连接操作的取消。然而,任何与 TCP 和 UDP 套接字都相关联的操作都可以以类似的方式取消。在操作启动后,应在相应的套接字对象上调用cancel()方法。
此外,asio::ip::tcp::resolver或asio::ip::udp::resolver类的async_resolve()方法,用于异步解析 DNS 名称,可以通过调用解析器对象的cancel()方法来取消。
所有由 Boost.Asio 提供的相应免费函数启动的异步操作也可以通过在传递给免费函数的第一个参数的对象上调用cancel()方法来取消。此对象可以代表套接字(活动或被动)或解析器。
参见
-
在第三章的实现异步 TCP 客户端配方中,实现客户端应用程序,演示了如何构建一个支持异步操作取消功能的更复杂的客户端应用程序。
-
第一章基础知识中的配方演示了如何同步连接套接字和解析 DNS 名称。
关闭和关闭套接字
在一些使用 TCP 协议进行通信的分布式应用程序中,需要传输没有固定大小和特定字节序列的消息,并标记其边界。这意味着接收方在从套接字读取消息时,无法通过分析消息本身的大小或内容来确定消息的结束位置。
解决此问题的一种方法是将每条消息结构化为一个逻辑头部分和一个逻辑体部分。头部分具有固定的大小和结构,并指定体部分的大小。这允许接收方首先读取并解析头部分,找出消息体的大小,然后正确读取消息的其余部分。
这种方法相当简单,并且被广泛使用。然而,它带来了一些冗余和额外的计算开销,这在某些情况下可能是不可以接受的。
当一个应用程序为发送给对等方的每条消息使用单独的套接字时,可以采用另一种方法,这是一种相当流行的做法。这种方法的想法是在消息写入套接字后,由消息发送者关闭套接字的发送部分。这会导致发送一个特殊的服务消息给接收者,告知接收者消息已结束,发送者将不会使用当前连接发送任何其他内容。
第二种方法比第一种方法提供了更多的好处,并且因为它属于 TCP 协议软件的一部分,所以它对开发者来说很容易使用。
套接字上的另一种操作,即关闭,看起来可能类似于关闭,但实际上它与关闭操作非常不同。关闭套接字意味着将套接字及其所有其他相关资源返回给操作系统。就像内存、进程或线程、文件句柄或互斥锁一样,套接字是操作系统的资源。并且像任何其他资源一样,套接字在分配、使用且不再由应用程序需要后,应返回给操作系统。否则,可能会发生资源泄漏,这最终可能导致资源耗尽,并导致应用程序故障或整个操作系统的不稳定。
当套接字未关闭时可能出现的严重问题使得关闭操作变得非常重要。
关闭 TCP 套接字与关闭套接字之间的主要区别在于,如果已经建立了连接,关闭操作会中断连接,并最终释放套接字并将其返回给操作系统,而关闭操作仅禁用套接字的写入、读取或两者操作,并向对等应用程序发送一个服务消息来通知这一事实。关闭套接字永远不会导致释放套接字。
在这个菜谱中,我们将看到如何关闭和关闭 TCP 套接字。
如何做到这一点...
在这里,我们将考虑一个由两部分组成的分布式应用程序:客户端和服务器,以便更好地理解如何使用套接字关闭操作来使基于分布式应用程序部分之间基于随机大小的二进制消息的应用层协议更加高效和清晰。
为了简单起见,客户端和服务器应用程序中的所有操作都是同步的。
客户端应用程序
客户端应用程序的目的是分配套接字并将其连接到服务器应用程序。在建立连接后,应用程序应准备并发送一个请求消息,通过在消息写入后关闭套接字来通知其边界。
在请求发送后,客户端应用程序应读取响应。响应的大小是未知的;因此,读取应一直进行,直到服务器关闭其套接字以通知响应边界。
我们通过指定include和using指令开始客户端应用程序:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
接下来,我们定义一个函数,该函数接受一个指向连接到服务器的套接字对象的引用,并使用此套接字与服务器进行通信。让我们称这个函数为communicate():
void communicate(asio::ip::tcp::socket& sock) {
// Allocating and filling the buffer with
// binary data.
const char request_buf[] = {0x48, 0x65, 0x0, 0x6c, 0x6c,
0x6f};
// Sending the request data.
asio::write(sock, asio::buffer(request_buf));
// Shutting down the socket to let the
// server know that we've sent the whole
// request.
sock.shutdown(asio::socket_base::shutdown_send);
// We use extensible buffer for response
// because we don't know the size of the
// response message.
asio::streambuf response_buf;
system::error_code ec;
asio::read(sock, response_buf, ec);
if (ec == asio::error::eof) {
// Whole response message has been received.
// Here we can handle it.
}
else {
throw system::system_error(ec);
}
}
最后,我们定义一个应用程序的main()入口点函数。此函数分配和连接套接字,然后调用之前步骤中定义的communicate()函数:
int main()
{
std::string raw_ip_address = "127.0.0.1";
unsigned short port_num = 3333;
try {
asio::ip::tcp::endpoint
ep(asio::ip::address::from_string(raw_ip_address),
port_num);
asio::io_service ios;
asio::ip::tcp::socket sock(ios, ep.protocol());
sock.connect(ep);
communicate(sock);
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
服务器应用程序
服务器应用程序旨在分配一个接受器套接字并被动等待连接请求。当连接请求到达时,它应接受该请求并从连接到客户端的套接字读取数据,直到客户端应用程序在其端关闭套接字。在收到请求消息后,服务器应用程序应通过关闭套接字来发送响应消息并通知其边界。
我们通过指定include和using指令开始客户端应用程序:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
接下来,我们定义一个函数,该函数接受一个指向连接到客户端应用程序的套接字对象的引用,并使用此套接字与客户端进行通信。让我们称这个函数为processRequest():
void processRequest(asio::ip::tcp::socket& sock) {
// We use extensible buffer because we don't
// know the size of the request message.
asio::streambuf request_buf;
system::error_code ec;
// Receiving the request.
asio::read(sock, request_buf, ec);
if (ec != asio::error::eof)
throw system::system_error(ec);
// Request received. Sending response.
// Allocating and filling the buffer with
// binary data.
const char response_buf[] = { 0x48, 0x69, 0x21 };
// Sending the request data.
asio::write(sock, asio::buffer(response_buf));
// Shutting down the socket to let the
// client know that we've sent the whole
// response.
sock.shutdown(asio::socket_base::shutdown_send);
}
最后,我们定义应用程序的main()入口点函数。此函数分配一个接受器套接字并等待传入的连接请求。当连接请求到达时,它获取一个连接到客户端应用程序的活动套接字,并通过传递一个连接套接字对象到其中调用之前步骤中定义的processRequest()函数:
int main()
{
unsigned short port_num = 3333;
try {
asio::ip::tcp::endpoint ep(asio::ip::address_v4::any(),
port_num);
asio::io_service ios;
asio::ip::tcp::acceptor acceptor(ios, ep);
asio::ip::tcp::socket sock(ios);
acceptor.accept(sock);
processRequest(sock);
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
关闭套接字
为了关闭一个分配的套接字,应在asio::ip::tcp::socket类的相应对象上调用close()方法。然而,通常不需要显式执行此操作,因为如果未显式关闭,套接字对象的析构函数会关闭套接字。
它是如何工作的...
服务器应用程序首先启动。在其main()入口点函数中,分配了一个接受器套接字,打开它,并将其绑定到端口3333,然后开始等待来自客户端的传入连接请求。
然后,启动客户端应用程序。在其main()入口点函数中,分配了一个活动套接字,打开它,并将其连接到服务器。在建立连接后,调用communicate()函数。在这个函数中,所有有趣的事情都发生了。
客户端应用程序向套接字写入请求消息,然后调用套接字的shutdown()方法,并将asio::socket_base::shutdown_send常量作为参数传递。这个调用关闭了套接字的发送部分。此时,向套接字写入被禁用,且无法恢复套接字状态使其再次可写:
sock.shutdown(asio::socket_base::shutdown_send);
在客户端应用程序中关闭套接字被视为服务器应用程序中到达服务器的协议服务消息,通知对等应用程序已关闭套接字。Boost.Asio 通过asio::read()函数返回的错误代码将此消息传递给应用程序代码。Boost.Asio 库将此代码定义为asio::error::eof。服务器应用程序使用此错误代码来确定客户端何时完成发送请求消息。
当服务器应用程序接收到完整的请求消息时,服务器和客户端交换它们的角色。现在,服务器在其端向套接字写入数据,即响应消息,客户端应用程序在其端读取此消息。当服务器完成将响应消息写入套接字后,它关闭其套接字的发送部分,以表示整个消息已发送到其对等方。
同时,客户端应用程序在asio::read()函数中被阻塞,读取服务器发送的响应,直到函数返回错误代码等于asio::error::eof,这表示服务器已发送完响应消息。当asio::read()函数返回此错误代码时,客户端知道它已读取整个响应消息,然后可以开始处理它:
system::error_code ec;
asio::read(sock, response_buf, ec);
if (ec == asio::error::eof) {
// Whole response message has been received.
// Here we can handle it.
}
注意,在客户端关闭其套接字的发送部分后,它仍然可以从套接字读取数据,因为套接字的接收部分独立于发送部分保持打开状态。
参见
-
同步写入 TCP 套接字配方演示了如何同步地将数据写入 TCP 套接字。
-
同步从 TCP 套接字读取配方演示了如何同步地从 TCP 套接字读取数据。
-
第五章中的实现 HTTP 客户端应用程序和实现 HTTP 服务器应用程序配方演示了在实现 HTTP 协议中如何使用套接字关闭。
第三章:实现客户端应用
在这一章中,我们将涵盖以下主题:
-
实现同步 TCP 客户端
-
实现同步 UDP 客户端
-
实现异步 TCP 客户端
简介
客户端是分布式应用的一部分,它通过与其他应用部分(称为服务器)通信来消费它提供的服务。另一方面,服务器是分布式应用的一部分,它被动地等待来自客户端的请求。当请求到达时,服务器执行请求的操作,并将操作结果作为响应发送回客户端。
客户端的关键特征是它需要服务器提供的服务,并且它需要与该服务器建立通信会话以消费该服务。服务器的关键特征是它通过提供请求的服务来响应来自客户端的请求。
我们将在下一章中考虑服务器。在这一章中,我们将专注于客户端应用,并将详细考虑几种类型。
客户端应用的分类
客户端应用可以根据它们与服务器通信所使用的传输层协议进行分类。如果客户端使用 UDP 协议,则称为UDP 客户端。如果它使用 TCP 协议,则相应地称为TCP 客户端。当然,还有许多其他传输层协议,客户端应用可能用于通信。此外,还有多协议客户端,可以在多个协议上通信。然而,这些超出了本书的范围。在这一章中,我们将专注于纯 UDP 和 TCP 客户端,因为它们是最受欢迎的,并且在今天的通用软件中是最常用的。
关于在分布式应用的部分之间选择哪种传输层协议进行通信的决定,应在应用设计的早期阶段基于应用规范做出。因为 TCP 和 UDP 协议在概念上不同,所以在应用开发过程的后期阶段从其中一个切换到另一个可能会相当困难。
另一种根据客户端是同步还是异步来分类客户端应用的方法。同步客户端应用使用同步套接字 API 调用,这些调用会阻塞执行线程,直到请求的操作完成或发生错误。因此,一个典型的同步 TCP 客户端会使用asio::ip::tcp::socket::write_some()方法或asio::write()免费函数向服务器发送请求,然后使用asio::ip::tcp::socket::read_some()方法或asio::read()免费函数接收响应。这些方法和函数是阻塞的,这使得客户端是同步的。
与同步客户端应用相对的是,异步客户端应用使用异步套接字 API 调用。例如,异步 TCP 客户端可能使用asio::ip::tcp::socket::async_write_some()方法或asio::async_write()免费函数向服务器发送请求,然后使用asio::ip::tcp::socket::async_read_some()方法或asio::async_read()免费函数异步接收响应。
由于同步客户端的结构与异步客户端的结构显著不同,因此关于应用哪种方法的决策应在应用设计阶段尽早做出,并且这个决策应基于对应用要求的仔细分析。此外,还应考虑可能的应用演变路径和未来可能出现的新要求。
同步与异步
通常情况下,每种方法都有其优缺点。当同步方法在某种情况下给出更好的结果时,在另一种情况下可能完全不可接受。在后一种情况下,应使用异步方法。让我们比较两种方法,以更好地理解在什么情况下使用每种方法更有利。
同步方法的主要优势是其简单性。与功能上相等的异步客户端相比,同步客户端的开发、调试和支持要容易得多。由于异步客户端使用的异步操作在代码的其他地方(主要是在回调中)完成,而不是在它们开始的地方,因此异步客户端更复杂。通常,这需要在空闲内存中分配额外的数据结构来保持请求和回调函数的上下文,还涉及到线程同步和其他可能使应用程序结构相当复杂且容易出错的额外操作。大多数这些额外操作在同步客户端中都不是必需的。此外,异步方法引入了额外的计算和内存开销,在某些条件下使其不如同步方法高效。
然而,同步方法有一些功能限制,这通常使得这种方法不可接受。这些限制包括在操作开始后无法取消同步操作,或无法为其设置超时,以便在运行时间超过一定时间后中断。与同步操作相反,异步操作可以在操作开始后的任何时刻取消,直到操作完成之前。
想象一个典型的现代网络浏览器。请求取消是一个客户端应用程序的重要功能。在发出加载特定网站的命令后,用户可能会改变主意并决定在页面加载完成之前取消命令。从用户的角度来看,如果不能在页面完全加载之前取消命令,将会非常奇怪。因此,在这种情况下,同步方法不是一个好的选择。
除了上述复杂性和功能上的差异之外,这两种方法在并行运行多个请求时的效率也有所不同。
想象一下我们正在开发一个网络爬虫,这是一个遍历网站页面并处理它们以提取一些有趣信息的应用程序。给定一个包含大量网站(比如说几百万个)的文件,应用程序应该遍历文件中列出的每个网站的页面,然后处理每个页面。自然地,该应用程序的一个关键要求是尽可能快地完成任务。考虑到这些要求,我们应该选择哪种方法,同步还是异步?
在我们回答这个问题之前,让我们从客户端应用程序的角度考虑请求生命周期的各个阶段及其时间。从概念上讲,请求生命周期由以下五个阶段组成:
-
准备请求:此阶段涉及准备请求消息所需的任何操作。这一步骤的持续时间取决于应用程序解决的特定问题。在我们的例子中,这可能是从输入文件中读取下一个网站地址并构建一个符合 HTTP 协议的请求字符串。
-
从客户端向服务器发送请求:此阶段假设请求数据通过网络从客户端传输到服务器。这一步骤的持续时间不依赖于客户端应用程序。它取决于网络的特性和当前状态。
-
服务器处理请求:这一步骤的持续时间取决于服务器的特性和其当前负载。在我们的例子中,服务器应用程序是一个网络服务器,请求处理包括构建请求的网页,这可能涉及 I/O 操作,如读取文件和从数据库加载数据。
-
从服务器向客户端发送响应:与第 2 阶段类似,此阶段也假设通过网络传输数据;然而,这次方向相反——从服务器到客户端。这一阶段的持续时间不依赖于客户端或服务器。它只取决于网络的特性和状态。
-
客户端处理响应:这一阶段的时间取决于客户端应用程序打算执行的具体任务。在我们的例子中,这可能是扫描网页,提取有趣的信息并将其存储到数据库中。
注意,为了简化,我们省略了如连接建立和连接关闭等低级子阶段,这些在使用 TCP 协议时很重要,但对我们请求生命周期概念模型中的实质性价值不大。
正如我们所见,只有在第 1 和第 5 阶段,客户端才会执行与请求相关的有效工作。在第一阶段结束时启动了请求数据的传输后,客户端必须等待请求生命周期的下一个三个阶段(第 2、第 3 和第 4 阶段)才能接收响应并处理它。
现在,让我们带着请求生命周期的各个阶段在心中,看看当我们应用同步和异步方法来实现我们的示例网络爬虫时会发生什么。
如果我们采用同步方法,处理单个请求的执行线程将在请求生命周期的第 2-4 阶段处于休眠状态,只有在第 1 和第 5 阶段,它才会执行有效的工作(为了简化,我们假设第 1 和第 5 阶段不包括会阻塞线程的指令)。这意味着操作系统的资源,即线程,被使用得不够高效,因为在很多情况下,它只是在无所事事,而此时还有很多工作要做——数百万个其他页面需要请求和处理。在这种情况下,异步方法似乎更有效率。采用异步方法,线程在请求生命周期的第 2-4 阶段不会阻塞,它可以有效地用于执行另一个请求的第 1 或第 5 阶段。
因此,我们指导单个线程处理不同请求的不同阶段(这被称为重叠),这导致线程使用得更加高效,从而提高了应用程序的整体性能。
然而,异步方法并不总是比同步方法更有效率。正如所提到的,异步操作意味着额外的计算开销,这意味着异步操作的整体持续时间(从开始到完成)略大于等效的同步操作。这意味着,如果第 2-4 阶段的平均总持续时间小于异步方法每个请求的时延开销,那么同步方法就变得更为高效,因此可能被认为是正确的选择。
评估请求生命周期阶段 2-4 的总持续时间以及异步方法的开销通常是通过实验来完成的。持续时间可能会有显著差异,这取决于请求和响应传输的网络属性和状态,以及服务请求的服务器应用程序的属性和负载级别。
示例协议
在本章中,我们将考虑三个配方,每个配方都演示了如何实现特定类型的客户端应用程序:同步 UDP 客户端、同步 TCP 客户端和异步 TCP 客户端。在所有配方中,假设客户端应用程序使用以下简单的应用层协议与服务器应用程序通信。
服务器应用程序接受一个表示为 ASCII 字符串的请求。该字符串具有以下格式:
EMULATE_LONG_COMP_OP [s]<LF>
其中 [s] 是一个正整数值,<LF> 是 ASCII 换行符。
服务器将此字符串解释为执行持续 [s] 秒的虚拟操作请求。例如,请求字符串可能如下所示:
"EMULATE_LONG_COMP_OP 10\n"
这意味着发送此请求的客户端希望服务器执行持续 10 秒的虚拟操作,然后向它发送响应。
与请求一样,服务器返回的响应由一个 ASCII 字符串表示。它可以是 OK<LF>,如果操作成功完成,或者 ERROR<LF>,如果操作失败。
实现同步 TCP 客户端
同步 TCP 客户端是符合以下声明的分布式应用程序的一部分:
-
在客户端-服务器通信模型中充当客户端
-
使用 TCP 协议与服务器应用程序通信
-
使用 I/O 和控制操作(至少是那些与服务器通信相关的 I/O 操作),这些操作会阻塞执行线程,直到相应的操作完成或发生错误
典型的同步 TCP 客户端按照以下算法工作:
-
获取服务器应用程序的 IP 地址和协议端口号。
-
分配一个活动套接字。
-
与服务器应用程序建立连接。
-
与服务器交换消息。
-
关闭连接。
-
释放套接字。
本配方演示了如何使用 Boost.Asio 实现同步 TCP 客户端应用程序。
如何操作...
以下代码示例演示了使用 Boost.Asio 实现同步 TCP 客户端应用程序的可能实现。客户端使用本章引言部分中描述的应用层协议:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
class SyncTCPClient {
public:
SyncTCPClient(const std::string& raw_ip_address,
unsigned short port_num) :
m_ep(asio::ip::address::from_string(raw_ip_address),
port_num),
m_sock(m_ios) {
m_sock.open(m_ep.protocol());
}
void connect() {
m_sock.connect(m_ep);
}
void close() {
m_sock.shutdown(
boost::asio::ip::tcp::socket::shutdown_both);
m_sock.close();
}
std::string emulateLongComputationOp(
unsigned int duration_sec) {
std::string request = "EMULATE_LONG_COMP_OP "
+ std::to_string(duration_sec)
+ "\n";
sendRequest(request);
return receiveResponse();
};
private:
void sendRequest(const std::string& request) {
asio::write(m_sock, asio::buffer(request));
}
std::string receiveResponse() {
asio::streambuf buf;
asio::read_until(m_sock, buf, '\n');
std::istream input(&buf);
std::string response;
std::getline(input, response);
return response;
}
private:
asio::io_service m_ios;
asio::ip::tcp::endpoint m_ep;
asio::ip::tcp::socket m_sock;
};
int main()
{
const std::string raw_ip_address = "127.0.0.1";
const unsigned short port_num = 3333;
try {
SyncTCPClient client(raw_ip_address, port_num);
// Sync connect.
client.connect();
std::cout << "Sending request to the server... "
<< std::endl;
std::string response =
client.emulateLongComputationOp(10);
std::cout << "Response received: " << response
<< std::endl;
// Close the connection and free resources.
client.close();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的...
示例客户端应用程序由两个主要组件组成——SyncTCPClient 类和应用程序入口点函数 main(),其中 SyncTCPClient 类用于与服务器应用程序通信。让我们分别考虑每个组件。
SyncTCPClient 类
SyncTCPClient类是样本中的关键组件。它实现了并提供对通信功能的访问。
该类有三个私有成员如下:
-
asio::io_service m_ios: 这是提供对操作系统通信服务访问的对象,这些服务由套接字对象使用 -
asio::ip::tcp::endpoint m_ep: 这是一个指定服务器应用程序的端点 -
asio::ip::tcp::socket m_sock: 这是用于通信的套接字
类中的每个对象都旨在与单个服务器应用程序通信;因此,类的构造函数接受服务器 IP 地址和协议端口号作为其参数。这些值用于在构造函数的初始化列表中实例化m_ep对象。套接字对象m_sock也在构造函数中实例化和打开。
三个公共方法构成了SyncTCPClient类的接口。第一个名为connect()的方法相当简单;它执行套接字与服务器的连接。close()方法关闭连接并关闭套接字,这会导致操作系统中的套接字及其相关资源被释放。
第三个接口方法是emulateLongComputationOp(unsigned int duration_sec)。该方法是在其中执行 I/O 操作的地方。它从根据协议准备请求字符串开始。然后,请求被传递到类的私有方法sendRequest(const std::string& request),该方法将其发送到服务器。当请求发送并且sendRequest()方法返回时,调用receiveResponse()方法从服务器接收响应。当收到响应时,receiveResponse()方法返回包含响应的字符串。之后,emulateLongComputationOp()方法将响应返回给其调用者。
让我们更详细地看看sendRequest()和receiveResponse()方法。
sendRequest()方法具有以下原型:
void sendRequest(const std::string& request)
其目的是将作为参数传递给它的字符串发送到服务器。为了将数据发送到服务器,使用了asio::write()免费同步函数。函数在请求发送后返回。这就是sendRequest()方法的所有内容。基本上,它所做的只是,完全委托其工作给asio::write()免费函数。
发送请求后,我们现在想从服务器接收响应。这是SyncTCPClient类的receiveResponse()方法的目的。为了执行其工作,该方法使用asio::read_until()免费函数。根据应用层协议,服务器发送的响应消息的长度可能不同,但必须以\n符号结束;因此,我们在调用函数时指定此符号作为分隔符:
asio::streambuf buf;
asio::read_until(m_sock, buf, '\n');
该函数阻塞执行线程,直到遇到来自服务器的消息中的\n符号。当函数返回时,流缓冲区buf包含响应。然后将数据从buf缓冲区复制到response字符串,并将后者返回给调用者。emulateLongComputationOp()方法随后将响应返回给其调用者——main()函数。
关于SyncTCPClient类需要注意的一点是,它不包含与错误处理相关的代码。这是因为该类仅使用那些在失败时抛出异常的 Boost.Asio 函数和对象方法的重载。假设类的用户负责捕获和处理异常。
main()入口点函数
此函数作为SyncTCPClient类的用户。在获取服务器 IP 地址和协议端口号(这部分在示例中省略)后,它实例化并使用SyncTCPClient类的一个对象与服务器通信,以使用其服务,主要是模拟在服务器上执行 10 秒的虚拟计算操作。此函数的代码简单且易于理解,因此无需额外的注释。
参见
- 第二章,I/O 操作,包括提供详细讨论如何执行同步 I/O 的配方
实现同步 UDP 客户端
同步 UDP 客户端是符合以下声明的分布式应用程序的一部分:
-
在客户端-服务器通信模型中充当客户端
-
使用 UDP 协议与服务器应用程序通信
-
使用 I/O 和控制操作(至少是那些与服务器通信相关的 I/O 操作)阻塞执行线程,直到相应的操作完成或发生错误
典型的同步 UDP 客户端按照以下算法工作:
-
获取客户端应用程序打算与之通信的每个服务器的 IP 地址和协议端口号。
-
分配一个 UDP 套接字。
-
与服务器交换消息。
-
释放套接字。
此配方演示了如何使用 Boost.Asio 实现同步 UDP 客户端应用程序。
如何做到这一点...
以下代码示例演示了使用 Boost.Asio 实现同步 UDP 客户端应用程序的可能方法。假设客户端使用 UDP 协议,底层为 IPv4 协议进行通信:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
class SyncUDPClient {
public:
SyncUDPClient() :
m_sock(m_ios) {
m_sock.open(asio::ip::udp::v4());
}
std::string emulateLongComputationOp(
unsigned int duration_sec,
const std::string& raw_ip_address,
unsigned short port_num) {
std::string request = "EMULATE_LONG_COMP_OP "
+ std::to_string(duration_sec)
+ "\n";
asio::ip::udp::endpoint ep(
asio::ip::address::from_string(raw_ip_address),
port_num);
sendRequest(ep, request);
return receiveResponse(ep);
};
private:
void sendRequest(const asio::ip::udp::endpoint& ep,
const std::string& request) {
m_sock.send_to(asio::buffer(request), ep);
}
std::string receiveResponse(asio::ip::udp::endpoint& ep) {
char response[6];
std::size_t bytes_recieved =
m_sock.receive_from(asio::buffer(response), ep);
m_sock.shutdown(asio::ip::udp::socket::shutdown_both);
return std::string(response, bytes_recieved);
}
private:
asio::io_service m_ios;
asio::ip::udp::socket m_sock;
};
int main()
{
const std::string server1_raw_ip_address = "127.0.0.1";
const unsigned short server1_port_num = 3333;
const std::string server2_raw_ip_address = "192.168.1.10";
const unsigned short server2_port_num = 3334;
try {
SyncUDPClient client;
std::cout << "Sending request to the server #1 ... "
<< std::endl;
std::string response =
client.emulateLongComputationOp(10,
server1_raw_ip_address, server1_port_num);
std::cout << "Response from the server #1 received: "
<< response << std::endl;
std::cout << "Sending request to the server #2... "
<< std::endl;
response =
client.emulateLongComputationOp(10,
server2_raw_ip_address, server2_port_num);
std::cout << "Response from the server #2 received: "
<< response << std::endl;
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的...
示例由两个主要组件组成——SyncUDPClient类和应用程序入口点函数main(),后者使用SyncUDPClient类与两个服务器应用程序通信。让我们分别考虑每个组件。
SyncUDPClient类
SyncUDPClient类是示例中的关键组件。它实现了服务器通信功能,并为用户提供访问。
该类有两个私有成员如下:
-
asio::io_service m_ios:这是提供对操作系统通信服务访问的对象,这些服务由套接字对象使用 -
asio::ip::udp::socket m_sock:这是用于通信的 UDP 套接字
m_sock套接字对象在类的构造函数中被实例化和打开。由于客户端打算使用 IPv4 协议,我们将asio::ip::udp::v4()静态方法返回的对象传递给套接字的open()方法,以指定套接字使用 IPv4 协议。
由于SyncUDPClient类实现了基于 UDP 协议的通信,UDP 是一种无连接协议,因此该类的一个单独对象可以用来与多个服务器通信。该类的接口由一个单一的方法组成——emulateLongComputationOp()。这个方法可以在SyncUDPClient类的对象实例化后立即用来与服务器通信。以下是这个方法的原型:
std::string emulateLongComputationOp(
unsigned int duration_sec,
const std::string& raw_ip_address,
unsigned short port_num)
除了表示请求参数的duration_sec参数外,该方法还接受服务器 IP 地址和协议端口号。此方法可以多次调用以与不同的服务器通信。
该方法首先根据应用层协议准备一个请求字符串并创建一个指定目标服务器应用程序的端点对象。然后,请求字符串和端点对象被传递到类的私有方法sendRequest(),该方法将请求消息发送到指定的服务器。当请求发送并且sendRequest()方法返回时,调用receiveResponse()方法从服务器接收响应。
当收到响应时,receiveResponse()方法返回包含响应的字符串。然后,emulateLongComputationOp()方法将响应返回给其调用者。sendRequest()方法使用套接字对象的send_to()方法将请求消息发送到特定的服务器。让我们看看这个方法的声明:
template <typename ConstBufferSequence>
std::size_t send_to(const ConstBufferSequence& buffers,
const endpoint_type& destination)
该方法接受一个包含请求的缓冲区和指定缓冲区内容应发送到的服务器端点的端点对象作为参数,并阻塞直到整个缓冲区发送完毕,或者发生错误。请注意,如果该方法在没有错误的情况下返回,这仅意味着请求已被发送,并不意味着服务器已收到请求。UDP 协议不保证消息的传递,并且不提供检查数据报是否已成功在服务器端接收或在传输过程中丢失的方法。
发送请求后,我们现在想要从服务器接收响应。这是SyncUDPClient类的receiveResponse()方法的目的。该方法首先分配一个将保存响应消息的缓冲区。我们选择缓冲区的大小,以便它可以容纳服务器根据应用层协议可能发送的最大消息;这条消息是一个由六个 ASCII 符号组成的ERROR\n字符串,因此长度为 6 个字节;因此,这是我们的缓冲区大小 - 6 个字节。因为缓冲区足够小,所以我们将其分配在栈上。
要读取从服务器到达的响应数据,我们使用套接字对象的receive_from()方法。以下是该方法的原型:
template <typename MutableBufferSequence>
std::size_t receive_from(const MutableBufferSequence& buffers,
endpoint_type& sender_endpoint)
此方法将来自由sender_endpoint对象指定的服务器的数据报复制到由buffers参数指定的缓冲区。
关于套接字对象的receive_from()方法有两点需要注意。首先,这个方法是同步的,它会在数据报从指定的服务器到达之前阻塞执行线程。如果数据报永远不会到达(例如,在前往客户端的路上丢失),该方法将永远不会解除阻塞,整个应用程序将会挂起。其次,如果来自服务器的数据报的大小大于提供的缓冲区的大小,该方法将失败。
接收到响应后,创建一个std::string对象,用响应字符串初始化,并将其返回给调用者——emulateLongComputationOp()方法。然后,它将响应返回给其调用者——main()函数。
SyncUDPClient类不包含错误处理相关的代码。这是因为它只使用那些在失败时抛出异常的 Boost.Asio 函数和对象方法的那些重载。假设类的用户负责捕获和处理异常。
main()入口点函数
在此函数中,我们使用SyncUDPClient类与两个服务器应用程序进行通信。首先,我们获取目标服务器应用程序的 IP 地址和端口号。然后,我们实例化SyncUDPClient类的对象,并调用对象的emulateLongComputationOp()方法两次,以同步地从两个不同的服务器消费相同的服务。
参见
- 第二章,I/O 操作,包括提供详细讨论如何执行同步 I/O 的食谱。
实现异步 TCP 客户端
如本章引言部分所述,最简单的异步客户端在结构上比等效的同步客户端更复杂。当我们向异步客户端添加如请求取消等特性时,它变得更加复杂。
在这个菜谱中,我们将考虑一个支持异步执行请求和请求取消功能的异步 TCP 客户端应用程序。以下是该应用程序将满足的要求列表:
-
用户输入应该在单独的线程中处理——用户界面线程。这个线程不应该被阻塞在明显的时间段内。
-
用户应该能够向不同的服务器发出多个请求。
-
用户应该在之前发出的请求完成之前能够发出新的请求。
-
用户应该在请求完成之前能够取消之前发出的请求。
如何做到这一点...
由于我们的应用程序需要支持请求取消,我们首先指定启用 Windows 上请求取消的设置:
#include <boost/predef.h> // Tools to identify the OS.
// We need this to enable cancelling of I/O operations on
// Windows XP, Windows Server 2003 and earlier.
// Refer to "http://www.boost.org/doc/libs/1_58_0/
// doc/html/boost_asio/reference/basic_stream_socket/
// cancel/overload1.html" for details.
#ifdef BOOST_OS_WINDOWS
#define _WIN32_WINNT 0x0501
#if _WIN32_WINNT <= 0x0502 // Windows Server 2003 or earlier.
#define BOOST_ASIO_DISABLE_IOCP
#define BOOST_ASIO_ENABLE_CANCELIO
#endif
#endif
然后,我们包含必要的头文件并指定方便的 using 指令:
#include <boost/asio.hpp>
#include <thread>
#include <mutex>
#include <memory>
#include <iostream>
using namespace boost;
我们继续定义一个表示回调函数指针的数据类型。因为我们的客户端应用程序将是异步的,我们需要一个作为请求完成通知机制的概念。稍后,我们将清楚地了解为什么需要它以及它是如何被使用的:
// Function pointer type that points to the callback
// function which is called when a request is complete.
typedef void(*Callback) (unsigned int request_id,
const std::string& response,
const system::error_code& ec);
接下来,我们定义一个数据结构,其目的是在执行过程中保持与特定请求相关的数据。让我们称它为 Session:
// Structure represents a context of a single request.
struct Session {
Session(asio::io_service& ios,
const std::string& raw_ip_address,
unsigned short port_num,
const std::string& request,
unsigned int id,
Callback callback) :
m_sock(ios),
m_ep(asio::ip::address::from_string(raw_ip_address),
port_num),
m_request(request),
m_id(id),
m_callback(callback),
m_was_cancelled(false) {}
asio::ip::tcp::socket m_sock; // Socket used for communication
asio::ip::tcp::endpoint m_ep; // Remote endpoint.
std::string m_request; // Request string.
// streambuf where the response will be stored.
asio::streambuf m_response_buf;
std::string m_response; // Response represented as a string.
// Contains the description of an error if one occurs during
// the request life cycle.
system::error_code m_ec;
unsigned int m_id; // Unique ID assigned to the request.
// Pointer to the function to be called when the request
// completes.
Callback m_callback;
bool m_was_cancelled;
std::mutex m_cancel_guard;
};
所有 Session 数据结构包含的字段的目的将在我们继续前进时变得清晰。
接下来,我们定义一个提供异步通信功能的类。让我们称它为 AsyncTCPClient:
class AsyncTCPClient : public boost::noncopyable {
class AsyncTCPClient : public boost::noncopyable {
public:
AsyncTCPClient(){
m_work.reset(new boost::asio::io_service::work(m_ios));
m_thread.reset(new std::thread([this](){
m_ios.run();
}));
}
void emulateLongComputationOp(
unsigned int duration_sec,
const std::string& raw_ip_address,
unsigned short port_num,
Callback callback,
unsigned int request_id) {
// Preparing the request string.
std::string request = "EMULATE_LONG_CALC_OP "
+ std::to_string(duration_sec)
+ "\n";
std::shared_ptr<Session> session =
std::shared_ptr<Session>(new Session(m_ios,
raw_ip_address,
port_num,
request,
request_id,
callback));
session->m_sock.open(session->m_ep.protocol());
// Add new session to the list of active sessions so
// that we can access it if the user decides to cancel
// the corresponding request before it completes.
// Because active sessions list can be accessed from
// multiple threads, we guard it with a mutex to avoid
// data corruption.
std::unique_lock<std::mutex>
lock(m_active_sessions_guard);
m_active_sessions[request_id] = session;
lock.unlock();
session->m_sock.async_connect(session->m_ep,
this, session
{
if (ec != 0) {
session->m_ec = ec;
onRequestComplete(session);
return;
}
std::unique_lock<std::mutex>
cancel_lock(session->m_cancel_guard);
if (session->m_was_cancelled) {
onRequestComplete(session);
return;
}
asio::async_write(session->m_sock,
asio::buffer(session->m_request),
this, session
{
if (ec != 0) {
session->m_ec = ec;
onRequestComplete(session);
return;
}
std::unique_lock<std::mutex>
cancel_lock(session->m_cancel_guard);
if (session->m_was_cancelled) {
onRequestComplete(session);
return;
}
asio::async_read_until(session->m_sock,
session->m_response_buf,
'\n',
this, session
{
if (ec != 0) {
session->m_ec = ec;
} else {
std::istream strm(&session->m_response_buf);
std::getline(strm, session->m_response);
}
onRequestComplete(session);
});});});
};
// Cancels the request.
void cancelRequest(unsigned int request_id) {
std::unique_lock<std::mutex>
lock(m_active_sessions_guard);
auto it = m_active_sessions.find(request_id);
if (it != m_active_sessions.end()) {
std::unique_lock<std::mutex>
cancel_lock(it->second->m_cancel_guard);
it->second->m_was_cancelled = true;
it->second->m_sock.cancel();
}
}
void close() {
// Destroy work object. This allows the I/O thread to
// exits the event loop when there are no more pending
// asynchronous operations.
m_work.reset(NULL);
// Wait for the I/O thread to exit.
m_thread->join();
}
private:
void onRequestComplete(std::shared_ptr<Session> session) {
// Shutting down the connection. This method may
// fail in case socket is not connected. We don’t care
// about the error code if this function fails.
boost::system::error_code ignored_ec;
session->m_sock.shutdown(
asio::ip::tcp::socket::shutdown_both,
ignored_ec);
// Remove session form the map of active sessions.
std::unique_lock<std::mutex>
lock(m_active_sessions_guard);
auto it = m_active_sessions.find(session->m_id);
if (it != m_active_sessions.end())
m_active_sessions.erase(it);
lock.unlock();
boost::system::error_code ec;
if (session->m_ec == 0 && session->m_was_cancelled)
ec = asio::error::operation_aborted;
else
ec = session->m_ec;
// Call the callback provided by the user.
session->m_callback(session->m_id,
session->m_response, ec);
};
private:
asio::io_service m_ios;
std::map<int, std::shared_ptr<Session>> m_active_sessions;
std::mutex m_active_sessions_guard;
std::unique_ptr<boost::asio::io_service::work> m_work;
std::unique_ptr<std::thread> m_thread;
};
这个类是我们示例中的关键组件,提供了应用程序的大部分功能。这些功能通过类的公共接口提供给用户,该接口包含三个公共方法:
-
void emulateLongComputationOp(unsigned int duration_sec, const std::string& raw_ip_address, unsigned short port_num, Callback callback, unsigned int request_id): 这个方法向服务器发起请求 -
void cancelRequest(unsigned int request_id): 这个方法取消由request_id参数指定的先前启动的请求 -
void close(): 这个方法阻塞调用线程,直到所有当前正在运行的任务完成,并初始化客户端。当这个方法返回时,AsyncTCPClient类的相应实例不能再使用。
现在,我们定义一个将作为回调函数使用的函数,我们将将其传递给 AsyncTCPClient::emulateLongComputationOp() 方法。在我们的情况下,这个函数相当简单。如果请求成功完成,它将输出请求执行的结果和响应消息到标准输出流:
void handler(unsigned int request_id,
const std::string& response,
const system::error_code& ec)
{
if (ec == 0) {
std::cout << "Request #" << request_id
<< " has completed. Response: "
<< response << std::endl;
} else if (ec == asio::error::operation_aborted) {
std::cout << "Request #" << request_id
<< " has been cancelled by the user."
<< std::endl;
} else {
std::cout << "Request #" << request_id
<< " failed! Error code = " << ec.value()
<< ". Error message = " << ec.message()
<< std::endl;
}
return;
}
handler() 函数的签名对应于之前定义的函数指针类型 Callback。
现在我们已经拥有了所有必要的组件,我们定义了应用程序的入口点——main()函数,该函数展示了如何使用上面定义的组件与服务器进行通信。在我们的示例函数中,main()通过初始化三个请求并取消其中一个来模拟人类用户的行为:
int main()
{
try {
AsyncTCPClient client;
// Here we emulate the user's behavior.
// User initiates a request with id 1.
client.emulateLongComputationOp(10, "127.0.0.1", 3333,
handler, 1);
// Then does nothing for 5 seconds.
std::this_thread::sleep_for(std::chrono::seconds(5));
// Then initiates another request with id 2.
client.emulateLongComputationOp(11, "127.0.0.1", 3334,
handler, 2);
// Then decides to cancel the request with id 1.
client.cancelRequest(1);
// Does nothing for another 6 seconds.
std::this_thread::sleep_for(std::chrono::seconds(6));
// Initiates one more request assigning ID3 to it.
client.emulateLongComputationOp(12, "127.0.0.1", 3335,
handler, 3);
// Does nothing for another 15 seconds.
std::this_thread::sleep_for(std::chrono::seconds(15));
// Decides to exit the application.
client.close();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
};
它是如何工作的…
我们的示例客户端应用程序使用两个执行线程。第一个线程——UI 线程——负责处理用户输入和初始化请求。第二个线程——I/O 线程——负责运行事件循环并调用异步操作的回调例程。这种配置使我们能够使我们的应用程序的用户界面保持响应。
启动应用程序 – main()入口点函数
main()函数在 UI 线程的上下文中被调用。此函数模拟了发起和取消请求的用户的行为。首先,它创建AsyncTCPClient类的实例,然后调用其emulateLongComputationOp()方法三次以初始化三个异步请求,每次指定不同的目标服务器。第一个请求(分配 ID 为 1 的请求)在请求初始化几秒钟后通过调用cancelRequest()方法被取消。
请求完成 – handler()回调函数
在main()函数中初始化的所有三个请求,handler()都被指定为回调函数。无论请求完成的原因是什么——无论是成功完成还是出现错误——该函数都会被调用。此外,当请求被用户取消时,该函数也会被调用。该函数接受以下三个参数:
-
unsigned int request_id:这包含请求的唯一标识符。这是在请求初始化时分配给请求的相同标识符。 -
std::string& response:这包含响应数据。此值仅在请求成功完成且未被取消时才被认为是有效的。 -
system::error_code& ec:如果在请求的生命周期中发生错误,此对象包含错误信息。如果请求被取消,它包含asio::error::operation_aborted值。
在我们的示例中,handler()函数相当简单。根据传递给它的参数的值,它输出有关已完成请求的信息。
AsyncTCPClient 类 – 初始化
正如已经提到的,所有与服务器应用程序通信的功能都隐藏在AsyncTCPClient类中。这个类有一个不接受任何参数的非空构造函数,执行两个操作。首先,它通过将名为m_ios的asio::io_service类实例传递给其构造函数来实例化asio::io_service::work类的对象。然后,它启动一个线程,该线程调用m_ios对象的run()方法。asio::io_service::work类的对象保持线程运行的事件循环,在没有挂起的异步操作时防止事件循环退出。启动的线程在我们的应用程序中扮演 I/O 线程的角色;在这个线程的上下文中,分配给异步操作的回调将被调用。
AsyncTCPClient类 – 启动请求
emulateLongComputationOp()方法旨在启动一个异步请求。它接受五个参数。第一个参数名为duration_sec,表示根据应用层协议的请求参数。raw_ip_address和port_num指定了请求应该发送到的服务器。下一个参数是一个指向回调函数的指针,当请求完成时将被调用。我们将在本节稍后讨论回调。最后一个参数request_id是请求的唯一标识符。此标识符与请求相关联,并在以后引用它时使用,例如,当需要取消它时。
emulateLongComputationOp()方法从准备请求字符串和分配一个Session结构实例开始,该实例保留与请求相关的数据,包括用于与服务器通信的套接字对象。
然后,打开套接字并将指向Session对象的指针添加到m_active_sessions映射中。这个映射包含指向所有活动请求的Session对象的指针,即那些已经发起但尚未完成的请求。当请求完成时,在调用相应的回调之前,与该请求关联的Session对象的指针将从映射中移除。
request_id参数用作添加到映射中的相应Session对象的键。我们需要缓存Session对象,以便在用户决定取消先前发起的请求时能够访问它们。如果我们不需要支持请求的取消,我们可以避免使用m_active_sessions映射。
我们使用m_active_session_guard互斥锁来同步对m_active_sessions映射的访问。同步是必要的,因为映射可以从多个线程访问。项目在 UI 线程中添加,在调用回调的 I/O 线程中移除,当相应的请求完成时。
现在,当相应的Session对象的指针被缓存时,我们需要通过调用 socket 的async_connect()方法将 socket 连接到服务器:
session->m_sock.async_connect(session->m_ep,
this, session
{
// ...
});
将指定我们想要连接的服务器的端点对象和当连接完成或发生错误时将被调用的回调函数作为参数传递给此方法。在我们的示例中,我们使用 lambda 函数作为回调函数。emulateLongComputationOp()方法中的最后一条语句是对 socket 的async_connect()方法的调用。当async_connect()返回时,emulateLongComputationOp()也会返回,这意味着请求已经被发起。
让我们更仔细地看看我们传递给async_connect()作为回调的 lambda 函数。以下是它的代码:
this, session
{
if (ec != 0) {
session->m_ec = ec;
onRequestComplete(session);
return;
}
std::unique_lock<std::mutex>
cancel_lock(session->m_cancel_guard);
if (session->m_was_cancelled) {
onRequestComplete(session);
return;
}
asio::async_write(session->m_sock,
asio::buffer(session->m_request),
this, session
{
//...
});
}
回调函数首先检查传递给它的ec参数的错误代码,其值如果不同于零,则表示相应的异步操作已失败。在失败的情况下,我们将ec值存储在相应的Session对象中,调用类的onRequestComplete()私有方法,并将Session对象作为参数传递给它,然后返回。
如果ec对象指定成功,我们锁定m_cancel_guard互斥锁(请求描述符对象的成员)并检查请求是否尚未被取消。关于取消请求的更多详细信息将在本节后面的cancelRequest()方法中提供。
如果我们看到请求尚未被取消,我们将通过调用 Boost.Asio 自由函数async_write()来发送请求数据到服务器,从而启动下一个异步操作。再次,我们传递给它一个 lambda 函数作为回调。这个回调与在异步连接操作启动时传递给anync_connect()方法的回调非常相似。我们首先检查错误代码,然后如果它指示成功,我们检查请求是否已被取消。如果没有,我们启动下一个异步操作async_read_until(),以便从服务器接收响应:
this, session{
if (ec != 0) {
session->m_ec = ec;
onRequestComplete(session);
return;
}
std::unique_lock<std::mutex>
cancel_lock(session->m_cancel_guard);
if (session->m_was_cancelled) {
onRequestComplete(session);
return;
}
asio::async_read_until(session->m_sock,
session->m_response_buf, '\n',
this, session
{
// ...
});
}
再次,我们将一个 lambda 函数作为回调参数传递给async_read_until()自由函数。这个回调函数相当简单:
this, session
{
if (ec != 0) {
session->m_ec = ec;
} else {
std::istream strm(&session->m_response_buf);
std::getline(strm, session->m_response);
}
onRequestComplete(session);
}
它检查错误代码,在成功的情况下,将接收到的响应数据存储在相应的Session对象中。然后,调用AsyncTCPClient类的私有方法onRequestComplete(),并将Session对象作为参数传递给它。
onRequestComplete()方法在请求完成时被调用,无论结果如何。它在请求成功完成时被调用,在请求在其生命周期的任何阶段失败时被调用,或者当它被用户取消时被调用。此方法的目的是在执行清理操作后,调用emulateLongComputationOp()方法的调用者提供的回调。
onRequestComplete() 方法首先关闭套接字。请注意,在这里我们使用套接字的 shutdown() 方法的重载版本,它不会抛出异常。我们不在乎连接关闭失败,因为这在我们这个场景中不是一个关键操作。然后,我们从 m_active_sessions 映射中删除相应的条目,因为请求已经完成,因此它不再活跃。最后一步,调用用户提供的回调函数。在回调函数返回后,请求生命周期结束。
AsyncTCPClient 类 – 取消请求
现在,让我们看看 AsyncTCPClient 类的 cancelRequest() 方法。这个方法接受要取消的请求的标识符作为参数。它首先在 m_active_sessions 映射中查找与指定请求对应的 Session 对象。如果找到了,它就会在这个 Session 对象中存储的套接字对象上调用 cancel() 方法。这会导致与该套接字对象关联的当前正在运行的非阻塞操作中断。
然而,有可能在 cancelRequest() 方法被调用时,一个异步操作已经完成,而下一个操作尚未开始。例如,想象一下,I/O 线程现在正在运行与特定套接字关联的 async_connect() 操作的回调。在这个时候,与该套接字关联的没有正在进行的异步操作(因为下一个异步操作 async_write() 尚未启动);因此,对这个套接字调用 cancel() 将没有效果。这就是为什么我们使用一个额外的标志 Session::m_was_cancelled,正如其名称所暗示的,它表示请求是否已被取消(或者更准确地说,是否由用户调用了 cancelRequest() 方法)。在异步操作的回调中,我们在启动下一个异步操作之前查看这个标志的值。如果我们看到这个标志被设置(这意味着请求已被取消),我们不会启动下一个异步操作,而是中断请求执行并调用 onRequestComplete() 方法。
在 cancelRequest() 方法和异步操作(如 async_connect() 和 async_write())的回调中,我们使用 Session::m_cancel_guard 互斥锁来强制执行以下操作顺序:请求可以在回调中测试 Session::m_was_cancelled 标志值之前或之后被取消。这种顺序保证了当用户调用 cancelRequest() 方法时,请求能够被正确取消。
AsyncTCPClient 类 – 关闭客户端
在客户端被使用且不再需要后,应该适当地关闭它。AsyncTCPClient 类的 close() 方法允许我们这样做。首先,此方法销毁 m_work 对象,允许 I/O 线程在所有异步操作完成后退出事件消息循环。然后,它连接 I/O 线程以等待其退出。
在 close() 方法返回后,AsyncTCPClient 类的相应对象不能再使用。
还有更多...
在所提供的示例中,AsyncTCPClient 类实现了一个异步的 单线程 TCP 客户端。它使用一个线程来运行事件循环并处理请求。通常,当请求速率较低时,响应的大小不会很大,请求处理器不会执行响应的复杂和耗时处理(请求生命周期的第 5 阶段);一个线程就足够了。
然而,当我们希望客户端处理数百万个请求并尽可能快地处理它们时,我们可能希望将我们的客户端转换为 多线程 的,这样多个线程可以真正同时运行多个请求。当然,这假设运行客户端的计算机是多核或多处理器计算机。如果应用程序运行的线程数量超过计算机中安装的核心或处理器的数量,可能会因为线程切换开销的影响而减慢应用程序的速度。
实现多线程 TCP 客户端应用程序
为了将我们的单线程客户端应用程序转换为多线程应用程序,我们需要对其进行一些更改。首先,我们需要将代表单个 I/O 线程的 AnyncTCPClient 类的 m_thread 成员替换为指向 std::thread 对象的指针列表,这些对象将代表一组 I/O 线程:
std::list<std::unique_ptr<std::thread>> m_threads;
接下来,我们需要更改类的构造函数,使其接受一个表示要创建的 I/O 线程数量的参数。此外,构造函数应该启动指定的 I/O 线程数量,并将它们全部添加到运行事件循环的线程池中:
AsyncTCPClient(unsigned char num_of_threads){
m_work.reset(new boost::asio::io_service::work(m_ios));
for (unsigned char i = 1; i <= num_of_threads; i++) {
std::unique_ptr<std::thread> th(
new std::thread([this](){
m_ios.run();
}));
m_threads.push_back(std::move(th));
}
}
就像客户端的单线程版本一样,每个线程都会调用 m_ios 对象的 run() 方法。结果,所有线程都被添加到由 m_ios 对象控制的线程池中。池中的所有线程都将用于调用相应的异步操作完成回调。这意味着在多核或多处理器计算机上,多个回调可以在不同的线程中真正同时运行,每个线程在不同的处理器上;而在客户端的单线程版本中,它们将按顺序执行。
在创建每个线程后,将其指针放入 m_threads 列表中,以便我们以后可以访问线程对象。
此外,最后的更改是在 close() 方法中。在这里,我们需要连接列表中的每个线程。这是更改后的方法看起来像:
void close() {
// Destroy work object. This allows the I/O threads to
// exit the event loop when there are no more pending
// asynchronous operations.
m_work.reset(NULL);
// Waiting for the I/O threads to exit.
for (auto& thread : m_threads) {
thread->join();
}
}
在销毁了work对象之后,我们遍历 I/O 线程列表,并加入每个线程以确保它们都已经退出。
多线程 TCP 客户端应用程序已准备就绪。现在,当我们创建多线程AsyncTCPClient类的对象时,应该将指定用于处理请求的线程数量传递给类的构造函数。该类的所有其他使用方面与单线程版本相同。
参见
-
第二章,I/O 操作,包括提供详细讨论如何使用 TCP 套接字执行异步 I/O 以及如何取消异步操作的配方。
-
来自第六章的使用计时器配方,其他主题,展示了如何使用 Boost.Asio 提供的计时器。计时器可以用来实现异步操作超时机制。
第四章:实现服务器应用程序
在本章中,我们将涵盖以下主题:
-
实现一个同步迭代 TCP 服务器
-
实现一个同步并行 TCP 服务器
-
实现一个异步 TCP 服务器
简介
服务器是分布式应用的一部分,它提供其他应用部分消费的服务或服务——客户端。客户端通过与服务器的通信来消费它提供的服务。
通常,服务器应用程序在客户端-服务器通信过程中扮演被动角色。在启动期间,服务器应用程序连接到主机上的一个特定已知端口(这意味着,它对潜在客户端来说是已知的,或者客户端至少可以在运行时从某个已知注册表中获取它)。之后,它被动地等待来自客户端到达该端口的请求。当请求到达时,服务器通过执行其提供服务的规范来处理它(提供服务)。
根据特定服务器提供的服务,请求处理可能意味着不同的事情。例如,一个 HTTP 服务器通常会读取请求消息中指定的文件内容并将其发送回客户端。一个代理服务器会简单地将客户端的请求重定向到另一个服务器进行实际处理(或者可能是另一轮重定向)。其他更具体的服务器可能提供对客户端在请求中提供的数据进行复杂计算的服务,并将此类计算的结果返回给客户端。
并非所有服务器都扮演被动角色。一些服务器应用程序可能会在没有等待客户端首先发送请求的情况下向客户端发送消息。通常,这样的服务器充当通知者,并通知客户端一些有趣的事件。在这种情况下,客户端可能根本不需要向服务器发送任何数据。相反,它们被动地等待来自服务器的通知,并在收到通知后相应地做出反应。这种通信模型被称为推送式通信。这种模型在现代网络应用中越来越受欢迎,提供了额外的灵活性。
因此,对服务器应用程序的第一种分类方式是按照它们执行的功能(或功能)或提供给客户端的服务(或服务)。
另一个明显的分类维度是服务器用于与客户端通信的传输层协议。
TCP 协议在当今非常流行,许多通用服务器应用程序都使用它进行通信。其他更具体的服务器可能使用 UDP 协议。同时通过 TCP 和 UDP 协议提供服务的混合服务器应用程序属于第三类,被称为多协议服务器。在本章中,我们将考虑几种类型的 TCP 服务器。
服务器的一个特点是它服务客户端的方式。一个迭代服务器逐个服务客户端,这意味着它不会在完成当前正在服务的客户端之前开始服务下一个客户端。一个并行服务器可以并行服务多个客户端。在单处理器计算机上,并行服务器会交错进行与多个客户端的通信的不同阶段,同时在单个处理器上运行它们。例如,在连接到一个客户端并等待其请求消息的同时,服务器可以切换到连接第二个客户端,或者从第三个客户端读取请求;之后,它可以切换回第一个客户端继续为其服务。这种并行性被称为伪并行性,因为处理器只是在几个客户端之间切换,但并不真正地同时服务它们,这在单处理器上是无法实现的。
在多处理器计算机上,当服务器同时使用不同的硬件线程为每个客户端服务多个客户端时,可以实现真正的并行性。
迭代服务器相对简单易实现,可以在请求率足够低,以至于服务器有足够的时间在下一个请求到来之前完成一个请求的处理时使用。很明显,迭代服务器是不可扩展的;向运行此类服务器的计算机添加更多处理器不会增加服务器的吞吐量。另一方面,并行服务器可以处理更高的请求率;如果实现得当,它们是可扩展的。在多处理器计算机上运行的真正并行服务器可以处理比在单处理器计算机上运行的相同服务器更高的请求率。
从实现的角度来看,另一种对服务器应用程序进行分类的方法是按照服务器是同步的还是异步的。一个同步服务器使用同步套接字 API 调用,这些调用会阻塞执行线程直到请求的操作完成,或者发生错误。因此,一个典型的同步 TCP 服务器会使用如asio::ip::tcp::acceptor::accept()这样的方法来接受客户端连接请求,使用asio::ip::tcp::socket::read_some()从客户端接收请求消息,然后使用asio::ip::tcp::socket::write_some()将响应消息发送回客户端。这三个方法都是阻塞的。它们会阻塞执行线程直到请求的操作完成,或者发生错误,这使得使用这些操作的服务器是同步的。
与同步服务器应用相反,异步服务器应用使用异步套接字 API 调用。例如,异步 TCP 服务器可能使用asio::ip::tcp::acceptor::async_accept()方法异步接受客户端连接请求,使用asio::ip::tcp::socket::async_read_some()方法或asio::async_read()自由函数异步接收来自客户端的请求消息,然后使用asio::ip::tcp::socket::async_write_some()方法或asio::async_write()自由函数异步向客户端发送响应消息。
由于同步服务器应用程序的结构与异步服务器应用程序的结构显著不同,因此应该在服务器应用程序设计阶段早期做出应用哪种方法的决定,并且这个决定应该基于对应用程序要求的仔细分析。此外,还应考虑可能出现的应用演变路径和新需求。
通常,每种方法都有其优点和缺点。当同步方法在某种情况下产生更好的结果时,在另一种情况下可能完全不可接受;在这种情况下,异步方法可能是正确的选择。让我们比较两种方法,以更好地理解每种方法的优点和缺点。
与异步方法相比,同步方法的主要优势在于其简单性。同步服务器比功能上等效的异步服务器更容易实现、调试和支持。由于异步操作在代码中完成的位置与它们启动的位置不同,异步服务器更复杂。通常,这需要在空闲内存中分配额外的数据结构以保持请求的上下文,实现回调函数、线程同步以及其他可能使应用程序结构相当复杂且容易出错的功能。在同步服务器中,大多数这些功能都不是必需的。此外,异步方法引入了额外的计算和内存开销,这可能在某些情况下使其不如同步方法高效。
然而,同步方法有一些功能限制,这通常使其不可接受。这些限制包括无法在同步操作开始后取消操作,或为其分配超时,以便在运行时间过长时中断它。与同步操作相反,异步操作可以在操作开始后的任何时刻取消。
同步操作无法取消的事实显著限制了同步服务器应用的领域。公开可用的使用同步操作的服务器容易受到攻击者的攻击。如果这样的服务器是单线程的,一个恶意客户端就足以阻止服务器,不允许其他客户端与其通信。攻击者使用的恶意客户端连接到服务器,但不向其发送任何数据,而后者在同步读取函数或方法中被阻塞,这阻止了它为其他客户端提供服务。
这样的服务器通常用于安全且受保护的私有网络环境,或作为在单个计算机上运行的应用程序内部的一部分,该应用程序使用这样的服务器进行进程间通信。当然,同步服务器的另一个可能的应用领域是实现一次性原型。
除了上述描述的结构复杂性和功能差异之外,这两种方法在处理大量以高频率发送请求的客户端时的效率和可扩展性也存在差异。使用异步操作的服务器比同步服务器更高效和可扩展,尤其是在它们运行在具有原生支持异步网络 I/O 的多处理器计算机上时。
样例协议
在本章中,我们将探讨三个配方,描述如何实现同步迭代 TCP 服务器、同步并行 TCP 服务器和异步 TCP 服务器。在所有配方中,假设服务器通过以下故意简化的(为了清晰起见)应用层协议与客户端通信。
服务器应用程序接受表示为 ASCII 字符串的请求消息,其中包含一系列以换行 ASCII 符号结束的符号。服务器忽略换行符号之后的所有符号。
服务器在收到请求后执行一些模拟操作,并以如下恒定消息进行回复:
"Response\n"
这样的简单协议使我们能够专注于实现服务器,而不是它提供的服务。
实现同步迭代 TCP 服务器
同步迭代 TCP 服务器是满足以下标准的一个分布式应用程序的组成部分:
-
在客户端-服务器通信模型中充当服务器角色
-
通过 TCP 协议与客户端应用程序通信
-
使用 I/O 和控制操作,这些操作会阻塞执行线程,直到相应的操作完成或发生错误。
-
以串行、逐个的方式处理客户端
典型的同步迭代 TCP 服务器按照以下算法工作:
-
分配一个接受器套接字并将其绑定到特定的 TCP 端口。
-
运行循环直到服务器停止:
-
等待来自客户端的连接请求。
-
当连接请求到达时接受客户端的连接请求。
-
等待来自客户端的请求消息。
-
读取请求消息。
-
处理请求。
-
向客户端发送响应消息。
-
关闭与客户端的连接并释放套接字。
-
本示例演示了如何使用 Boost.Asio 实现一个同步迭代 TCP 服务器应用程序。
如何做到这一点…
我们通过定义一个负责读取请求消息、处理它并发送响应消息的类来开始实现我们的服务器应用程序。此类代表服务器应用程序提供的单个服务,因此我们将它命名为 Service:
#include <boost/asio.hpp>
#include <thread>
#include <atomic>
#include <memory>
#include <iostream>
using namespace boost;
class Service {
public:
Service(){}
void HandleClient(asio::ip::tcp::socket& sock) {
try {
asio::streambuf request;
asio::read_until(sock, request, '\n');
// Emulate request processing.
inti = 0;
while (i != 1000000)
i++;
std::this_thread::sleep_for(
std::chrono::milliseconds(500));
// Sending response.
std::string response = "Response\n";
asio::write(sock, asio::buffer(response));
}
catch (system::system_error&e) {
std::cout << "Error occured! Error code = "
<<e.code() << ". Message: "
<<e.what();
}
}
};
为了保持简单,在我们的示例服务器应用程序中,我们实现了一个模拟服务,它仅模拟执行某些操作。请求处理模拟包括执行许多增量操作来模拟消耗 CPU 的操作,然后让控制线程休眠一段时间来模拟读取文件或与外围设备同步通信等操作。
注意
Service 类相当简单,只包含一个方法。然而,在现实世界应用程序中代表服务的类通常会更为复杂且功能更丰富,尽管主要思想保持不变。
接下来,我们定义另一个代表高级 接受者 概念的类(与代表 asio::ip::tcp::acceptor 类的低级概念相比)。此类负责接受来自客户端的连接请求,并实例化 Service 类的对象,这些对象将为连接的客户端提供服务。让我们相应地命名这个类为 Acceptor:
class Acceptor {
public:
Acceptor(asio::io_service&ios, unsigned short port_num) :
m_ios(ios),
m_acceptor(m_ios,
asio::ip::tcp::endpoint(
asio::ip::address_v4::any(),
port_num))
{
m_acceptor.listen();
}
void Accept() {
asio::ip::tcp::socket sock(m_ios);
m_acceptor.accept(sock);
Service svc;
svc.HandleClient(sock);
}
private:
asio::io_service&m_ios;
asio::ip::tcp::acceptor m_acceptor;
};
此类拥有一个名为 m_acceptor 的 asio::ip::tcp::acceptor 类型的对象,用于同步接受传入的连接请求。
此外,我们还定义了一个代表服务器本身的类。这个类相应地命名为 Server:
class Server {
public:
Server() : m_stop(false) {}
void Start(unsigned short port_num) {
m_thread.reset(new std::thread([this, port_num]() {
Run(port_num);
}));
}
void Stop() {
m_stop.store(true);
m_thread->join();
}
private:
void Run(unsigned short port_num) {
Acceptor acc(m_ios, port_num);
while (!m_stop.load()) {
acc.Accept();
}
}
std::unique_ptr<std::thread>m_thread;
std::atomic<bool>m_stop;
asio::io_servicem_ios;
};
此类提供了一个由两个方法组成的接口—Start() 和 Stop(),分别用于启动和停止服务器。循环在由 Start() 方法产生的单独线程中运行。Start() 方法是非阻塞的,而 Stop() 方法会阻塞调用线程,直到服务器停止。
对 Server 类的彻底检查揭示了服务器实现的一个严重缺点—在某些情况下,Stop() 方法可能永远不会返回。关于这个问题及其解决方法的讨论将在本食谱的后面提供。
最终,我们实现了应用程序入口点函数 main(),演示了如何使用 Server 类:
int main()
{
unsigned short port_num = 3333;
try {
Server srv;
srv.Start(port_num);
std::this_thread::sleep_for(std::chrono::seconds(60));
srv.Stop();
}
catch (system::system_error&e) {
std::cout << "Error occured! Error code = "
<<e.code() << ". Message: "
<<e.what();
}
return 0;
}
它是如何工作的…
示例服务器应用程序由四个组件组成—Server、Acceptor、Service 类和应用程序入口点函数 main()。让我们考虑每个组件是如何工作的。
Service 类
Service类是整个应用程序中的关键功能组件。虽然其他组件在其目的上是基础设施性的,但这个类实现了服务器提供给客户端的实际功能(或服务)。
这个类很简单,只包含一个HandleClient()方法。这个方法接受一个表示连接到客户端的套接字的对象作为其输入参数,并处理该特定客户端。
在我们的示例中,这种处理很简单。首先,从套接字中同步读取请求消息,直到遇到新的换行 ASCII 符号\n。然后,处理请求。在我们的情况下,我们通过运行一个模拟循环执行一百万次递增操作,然后让线程休眠半秒钟来模拟处理。之后,准备响应消息,并同步发送回客户端。
Boost.Asio I/O 函数和方法可能抛出的异常在HandleClient()方法中被捕获和处理,并且不会传播到方法调用者,这样如果处理一个客户端失败,服务器仍然可以继续工作。
根据特定应用程序的需求,Service类可以被扩展并增加提供所需服务的功能。
接受者类
Acceptor类是服务器应用程序基础设施的一部分。当创建时,它实例化一个接受器套接字对象m_acceptor,并调用其listen()方法以开始监听来自客户端的连接请求。
这个类公开了一个名为Accept()的单个公共方法。当调用此方法时,会实例化一个名为sock的asio::ip::tcp::socket类对象,代表一个活动套接字,并尝试接受一个连接请求。如果有待处理的连接请求可用,则处理连接请求,并将活动套接字sock连接到新的客户端。否则,此方法会阻塞,直到新的连接请求到达。
然后,创建了一个Service对象的实例,并调用其HandleClient()方法。连接到客户端的sock对象被传递到这个方法中。HandleClient()方法会阻塞,直到与客户端的通信和请求处理完成,或者发生错误。当HandleClient()方法返回时,Acceptor类的Accept()方法也返回。现在,接受者已准备好接受下一个连接请求。
类的Accept()方法的一次执行完成了一个客户端的完整处理周期。
服务器类
如其名称所示,Server类代表一个可以通过类的接口方法Start()和Stop()进行控制的服务器。
Start() 方法启动服务器的启动。它产生一个新的线程,从 Server 类的 Run() 私有方法开始执行并返回。Run() 方法接受一个名为 port_num 的单个参数,指定接受器套接字应在哪个协议端口上监听传入的连接请求。当调用时,该方法首先实例化一个 Acceptor 类的对象,然后启动一个循环,在该循环中调用 Acceptor 对象的 Accept() 方法。当 m_stop 原子变量的值变为 true 时,循环终止,这发生在对 Server 类的相应实例调用 Stop() 方法时。
Stop() 方法同步停止服务器。它不会返回,直到在 Run() 方法中启动的循环被中断并且由 Start() 方法产生的线程完成其执行。为了中断循环,将原子变量 m_stop 的值设置为 true。之后,Stop() 方法在 Run() 方法中运行循环的线程表示对象 m_thread 上调用 join() 方法,等待它退出循环并完成执行。
所提出的实现有一个显著的缺点,即服务器可能无法立即停止。更严重的是,有可能服务器根本不会停止,并且 Stop() 方法将永远阻塞其调用者。问题的根本原因在于服务器对客户端行为的强依赖性。
如果在 Run() 方法中检查循环终止条件之前,调用 Stop() 方法并将原子变量 m_stop 的值设置为 true,则服务器几乎立即停止,并且不会出现任何问题。然而,如果在服务器线程在 acc.Accept() 方法中阻塞等待来自客户端的下一个连接请求,或者在 Service 类中的某个同步 I/O 操作中等待来自已连接客户端的请求消息,或者等待客户端接收响应消息时调用 Stop() 方法,则服务器无法停止,直到这些阻塞操作完成。因此,例如,如果在调用 Stop() 方法时没有挂起的连接请求,则服务器将不会停止,直到新的客户端连接并得到处理,在一般情况下可能永远不会发生,这将导致服务器永远阻塞。
在本节稍后,我们将考虑解决这一缺点的可能方法。
main() 入口点函数
这个函数演示了服务器的使用方法。它创建了一个名为 srv 的 Server 类实例,并调用其 Start() 方法来启动服务器。由于服务器被表示为一个在其自己的控制线程中运行的活跃对象,因此 Start() 方法立即返回,而运行方法 main() 继续执行。为了让服务器运行一段时间,主线程被置于休眠状态 60 秒。当主线程醒来后,它会在 srv 对象上调用 Stop() 方法来停止服务器。当 Stop() 方法返回时,main() 函数也返回,我们的示例应用程序退出。
当然,在实际应用中,服务器会作为对用户输入或任何其他相关事件的反应而停止,而不是在模拟的 60 秒后,或者在服务器启动运行结束后。
消除缺点
正如已经提到的,所提出的实现有两个缺点,这显著限制了其适用性。第一个问题是,如果在服务器线程阻塞等待传入连接请求时调用 Stop() 方法,可能无法停止服务器,因为没有连接请求到达。第二个问题是,服务器可以被单个恶意(或存在错误的)客户端轻易挂起,使其对其他客户端不可用。为了挂起服务器,客户端应用程序可以简单地连接到服务器,并且永远不向它发送任何请求,这将使服务器应用程序在阻塞输入操作中永远挂起。
这两个问题的根本原因是在服务器中使用阻塞操作(对于同步服务器来说这是自然的)。解决这两个问题的合理且简单的方法是为阻塞操作分配超时时间,这将保证服务器会定期解除阻塞以检查是否已发出停止命令,并且强制丢弃长时间不发送请求的客户。然而,Boost.Asio 不提供取消同步操作或为它们分配超时时间的方法。因此,我们应该尝试找到其他方法来使我们的同步服务器更加响应和稳定。
让我们考虑解决这两个缺点的方法。
在合理的时间内停止服务器
由于在没有任何挂起的连接请求时,使接受者套接字的 accept() 同步方法非阻塞的唯一合法方法是向接受者正在监听的端口发送一个模拟连接请求,我们可以使用以下技巧来解决问题。
在Server类的Stop()方法中,在将m_stop原子变量的值设置为true之后,我们可以创建一个虚拟活动套接字,将其连接到同一个服务器,并发送一些虚拟请求。这将保证服务器线程将离开接受者的accept()方法,并最终检查m_stop原子变量的值,发现其值等于true,这将导致循环终止并完成Acceptor::Accept()方法。
在描述的方法中,假设服务器通过向自己发送消息(实际上是从 I/O 线程发送到工作线程的消息)来停止自己。另一种方法是有特殊客户端(独立应用程序),它会连接并发送特殊服务消息(例如,stop\n)到服务器,服务器将解释为停止信号。在这种情况下,服务器将外部控制(来自不同的应用程序),Server类不需要有Stop()方法。
处理服务器的漏洞
很遗憾,没有分配超时的 I/O 操作阻塞的性质是这样的,它可以很容易地挂起使用此类操作的迭代服务器,使其对其他客户端不可访问。
显然,为了保护服务器免受这种漏洞的影响,我们需要重新设计它,使其永远不会被 I/O 操作阻塞。实现这一目标的一种方法是通过使用非阻塞套接字(这将使我们的服务器变为响应式)或使用异步 I/O 操作。这两种选择都意味着我们的服务器不再同步。我们将在本章的其他菜谱中考虑这些解决方案。
分析结果
如上所述,使用 Boost.Asio 实现的同步迭代服务器中固有的漏洞不允许在公共网络上使用,因为存在服务器被恶意者滥用的风险。通常,同步服务器会在封闭和保护的环境中用于客户端被精心设计,以确保它们不会使服务器挂起。
迭代同步服务器的另一个局限性是它们不可扩展,无法利用多处理器硬件。然而,它们的优点——简单性——是为什么这种类型的服务器在许多情况下是一个好的选择的原因。
参见
- 第二章,I/O 操作,包括提供关于如何执行同步 I/O 的详细讨论的菜谱。
实现同步并行 TCP 服务器
同步并行 TCP 服务器是分布式应用的一部分,满足以下标准:
-
在客户端-服务器通信模型中充当服务器
-
通过 TCP 协议与客户端应用程序通信
-
使用 I/O 和控制操作,直到相应的操作完成或发生错误,才会阻塞执行线程
-
可以同时处理多个客户端
一个典型的同步并行 TCP 服务器按照以下算法工作:
-
分配一个接受者套接字并将其绑定到特定的 TCP 端口。
-
运行循环直到服务器停止:
-
等待来自客户端的连接请求
-
接受客户端的连接请求
-
在控制线程的上下文中启动一个线程:
-
等待来自客户端的请求消息
-
读取请求消息
-
处理请求
-
向客户端发送响应消息
-
关闭与客户端的连接并释放套接字
-
-
此配方演示了如何使用 Boost.Asio 实现同步并行 TCP 服务器应用程序。
如何做到这一点…
我们开始实现我们的服务器应用程序,通过定义一个处理单个客户端的类,该类通过读取请求消息、处理它并发送响应消息来处理单个客户端。此类代表服务器应用程序提供的一个单一服务,因此我们将它命名为Service:
#include <boost/asio.hpp>
#include <thread>
#include <atomic>
#include <memory>
#include <iostream>
using namespace boost;
class Service {
public:
Service(){}
void StartHandligClient(
std::shared_ptr<asio::ip::tcp::socket> sock) {
std::thread th(([this, sock]() {
HandleClient(sock);
}));
th.detach();
}
private:
void HandleClient(std::shared_ptr<asio::ip::tcp::socket> sock) {
try {
asio::streambuf request;
asio::read_until(*sock.get(), request, '\n');
// Emulate request processing.
int i = 0;
while (i != 1000000)
i++;
std::this_thread::sleep_for(
std::chrono::milliseconds(500));
// Sending response.
std::string response = "Response\n";
asio::write(*sock.get(), asio::buffer(response));
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = "
<< e.code() << ". Message: "
<< e.what();
}
// Clean-up.
delete this;
}
};
为了保持简单,在我们的示例服务器应用程序中,我们实现了一个模拟服务,它仅模拟执行某些操作。请求处理模拟包括执行许多增量操作来模拟消耗 CPU 的操作,然后让控制线程休眠一段时间来模拟同步的 I/O 操作,如读取文件或与外围设备通信。
注意
Service类相当简单,只包含一个方法。然而,在现实世界的应用中,代表服务的类通常会更为复杂且功能更丰富,尽管主要思想保持不变。
接下来,我们定义另一个类,它代表一个高级的接受者概念(与asio::ip::tcp::acceptor类所代表的低级概念相比)。此类负责接受来自客户端的连接请求,并实例化Service类的对象,这些对象将为连接的客户端提供服务。让我们称它为Acceptor:
class Acceptor {
public:
Acceptor(asio::io_service& ios, unsigned short port_num) :
m_ios(ios),
m_acceptor(m_ios,
asio::ip::tcp::endpoint(
asio::ip::address_v4::any(),
port_num))
{
m_acceptor.listen();
}
void Accept() {
std::shared_ptr<asio::ip::tcp::socket>
sock(new asio::ip::tcp::socket(m_ios));
m_acceptor.accept(*sock.get());
(new Service)->StartHandligClient(sock);
}
private:
asio::io_service& m_ios;
asio::ip::tcp::acceptor m_acceptor;
};
此类拥有一个名为m_acceptor的asio::ip::tcp::acceptor类对象,用于同步接受传入的连接请求。
此外,我们还定义了一个代表服务器的类。类名相应地命名为——Server:
class Server {
public:
Server() : m_stop(false) {}
void Start(unsigned short port_num) {
m_thread.reset(new std::thread([this, port_num]() {
Run(port_num);
}));
}
void Stop() {
m_stop.store(true);
m_thread->join();
}
private:
void Run(unsigned short port_num) {
Acceptor acc(m_ios, port_num);
while (!m_stop.load()) {
acc.Accept();
}
}
std::unique_ptr<std::thread>m_thread;
std::atomic<bool>m_stop;
asio::io_servicem_ios;
};
此类提供了一个由两个方法组成的接口——Start()和Stop(),分别用于启动和停止服务器。循环在Start()方法启动的单独线程中运行。Start()方法是非阻塞的,而Stop()方法是阻塞的。它会阻塞调用线程,直到服务器停止。
对Server类进行彻底检查揭示了服务器实现的一个严重缺点——Stop()方法可能会永远阻塞。下面提供了关于此问题及其解决方法的讨论。
最终,我们实现了应用程序的入口点函数main(),演示了如何使用Server类:
int main()
{
unsigned short port_num = 3333;
try {
Server srv;
srv.Start(port_num);
std::this_thread::sleep_for(std::chrono::seconds(60));
srv.Stop();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = "
<< e.code() << ". Message: "
<< e.what();
}
return 0;
}
它是如何工作的…
样本服务器应用程序由四个组件组成——Server、Acceptor 和 Service 类以及应用程序入口点函数 main()。让我们考虑每个组件是如何工作的。
Service 类
Service 类是整个应用程序中的关键功能组件。虽然其他组件构成了服务器的基础设施,但此类实现了服务器提供给客户端的实际功能(或服务)。
此类在其接口中有一个名为 StartHandlingClient() 的单一方法。此方法接受一个指向表示连接到客户端的 TCP 套接字的对象的指针作为其输入参数,并开始处理该特定客户端。
此方法启动一个控制线程,从类的 HandleClient() 私有方法开始执行,在那里执行实际的同步处理。启动线程后,StartHandlingClient() 方法通过将其从代表它的 std::thread 对象中分离出来来“释放”线程。之后,StartHandlingClient() 方法返回。
如其名称所暗示的,HandleClient() 私有方法处理客户端。在我们的示例中,这种处理是微不足道的。首先,从套接字中同步读取请求消息,直到遇到新的换行 ASCII 符号 \n。然后,处理请求。在我们的情况下,我们通过运行一个模拟循环执行一百万次递增操作,然后让线程休眠半秒钟来模拟处理。之后,准备响应消息并发送回客户端。
当发送响应消息时,与当前正在运行的 HandleClient() 方法关联的 Service 类对象被 delete 操作符删除。当然,类的设计假设其实例将通过 new 操作符在自由内存中分配,而不是在栈上。
根据特定应用程序的需求,Service 类可以被扩展并丰富以提供所需的服务功能。
Acceptor 类
Acceptor 类是服务器应用程序基础设施的一部分。当构造时,它实例化一个接受器套接字对象 m_acceptor 并调用其 listen() 方法以开始监听来自客户端的连接请求。
此类公开了一个名为 Accept() 的单一公共方法。当调用此方法时,它实例化一个名为 sock 的 asio::ip::tcp::socket 类对象,代表一个活动套接字,并尝试接受一个连接请求。如果有挂起的连接请求可用,则处理连接请求并将活动套接字 sock 连接到新的客户端。否则,此方法会阻塞,直到新的连接请求到达。
然后,在空闲内存中分配了一个Service对象的实例,并调用了其StartHandlingClient()方法。将sock对象作为输入参数传递给此方法。StartHandlingClient()方法在客户端将被处理的环境中创建了一个线程,并立即返回。当StartHandlingClient()方法返回时,Acceptor类的Accept()方法也返回了。现在,接受者已准备好接受下一个连接请求。
注意,Acceptor不拥有Service类的对象。相反,当Service类完成其工作时,该对象将自行销毁。
Server类
如其名称所示,Server类代表一个可以通过类的Start()和Stop()接口方法进行控制的服务器。
Start()方法启动服务器的启动过程。它创建了一个新线程,该线程从Server类的Run()私有方法开始执行并返回。Run()方法接受一个名为port_num的单个参数,指定了接受者套接字应监听协议端口的编号,以便接收传入的连接请求。当被调用时,该方法首先实例化一个Acceptor类的对象,然后启动一个循环,在该循环中调用Acceptor对象的Accept()方法。当在Server类的相应实例上调用Stop()方法时,m_stop原子变量的值变为true,循环终止。
Stop()方法同步停止服务器。它不会返回,直到Run()方法中启动的循环被中断,并且Start()方法创建的线程完成其执行。为了中断循环,将原子变量m_stop的值设置为true。之后,Stop()方法在Run()方法中运行的循环对应的m_thread对象上调用join()方法,以等待其完成执行。
所展示的实现有一个显著的缺点,即服务器可能无法立即停止。更甚者,存在服务器根本无法停止的可能性,并且Stop()方法将永远阻塞其调用者。问题的根本原因在于服务器对客户端行为的强依赖。
如果在Run()方法中检查循环终止条件之前调用Stop()方法并将原子变量m_stop的值设置为true,则服务器几乎会立即停止,并且不会发生任何问题。然而,如果在服务器线程在acc.Accept()方法中阻塞等待来自客户端的下一个连接请求,或者在Service类中的某个同步 I/O 操作中等待来自已连接客户端的请求消息或客户端接收响应消息时调用Stop()方法,则服务器无法停止,直到这些阻塞操作完成。因此,例如,如果在调用Stop()方法时没有挂起的连接请求,服务器将不会停止,直到新的客户端连接并得到处理,在一般情况下可能永远不会发生,可能导致服务器永久阻塞。
在本节稍后,我们将考虑解决此缺点的方法。
main()入口点函数
此功能演示了服务器的使用方法。它创建了一个名为srv的Server类实例,并调用其Start()方法来启动服务器。因为服务器被表示为一个在其自己的控制线程中运行的活跃对象,所以Start()方法会立即返回,运行main()方法的线程继续执行。为了使服务器运行一段时间,主线程被休眠 60 秒。当主线程醒来后,它会调用srv对象的Stop()方法来停止服务器。当Stop()方法返回时,main()函数也会返回,我们的示例应用程序退出。
当然,在实际应用中,服务器会作为对用户输入或任何其他相关事件的反应而停止,而不是在服务器启动运行后的 60 秒后。
消除缺点
使用 Boost.Asio 库实现的同步并行服务器应用程序的固有缺点与之前配方中考虑的同步迭代服务器应用程序的缺点相似。请参阅实现同步迭代 TCP 服务器配方,以了解缺点的讨论和消除它们的方法。
参见
-
配方实现同步迭代 TCP 服务器提供了关于同步迭代和同步并行服务器固有的缺点以及消除它们的可能方法的更多详细信息。
-
第二章,I/O 操作,包括提供如何执行同步 I/O 的详细讨论的配方
实现异步 TCP 服务器
异步 TCP 服务器是满足以下标准的一个分布式应用程序的组成部分:
-
在客户端-服务器通信模型中充当服务器
-
通过 TCP 协议与客户端应用程序通信
-
使用异步 I/O 和控制操作
-
可以同时处理多个客户端
一个典型的异步 TCP 服务器按照以下算法工作:
-
分配一个接受者套接字并将其绑定到特定的 TCP 端口。
-
启动异步接受操作。
-
在 Boost.Asio 事件循环中创建一个或多个控制线程并将它们添加到线程池中。
-
当异步接受操作完成时,启动一个新的操作以接受下一个连接请求。
-
启动异步读取操作以从连接的客户端读取请求。
-
当异步读取操作完成时,处理请求并准备响应消息。
-
启动异步写入操作以向客户端发送响应消息。
-
当异步写入操作完成时,关闭连接并释放套接字。
注意,前一个算法中的第四步开始的步骤可能根据具体应用程序中具体异步操作的相对时间顺序以任意顺序执行。由于服务器的异步模型,即使在单处理器计算机上运行服务器时,步骤的执行顺序也可能不成立。
这个配方演示了如何使用 Boost.Asio 实现异步 TCP 服务器应用程序。
如何做到这一点...
我们通过定义一个类来开始实现我们的服务器应用程序,该类负责通过读取请求消息、处理它并发送响应消息来处理单个客户端。这个类代表了服务器应用程序提供的一个单一服务。让我们称它为 Service:
#include <boost/asio.hpp>
#include <thread>
#include <atomic>
#include <memory>
#include <iostream>
using namespace boost;
class Service {
public:
Service(std::shared_ptr<asio::ip::tcp::socket> sock) :
m_sock(sock)
{}
void StartHandling() {
asio::async_read_until(*m_sock.get(),
m_request,
'\n',
this
{
onRequestReceived(ec,
bytes_transferred);
});
}
private:
void onRequestReceived(const boost::system::error_code& ec,
std::size_t bytes_transferred) {
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
onFinish();
return;
}
// Process the request.
m_response = ProcessRequest(m_request);
// Initiate asynchronous write operation.
asio::async_write(*m_sock.get(),
asio::buffer(m_response),
this
{
onResponseSent(ec,
bytes_transferred);
});
}
void onResponseSent(const boost::system::error_code& ec,
std::size_t bytes_transferred) {
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
}
onFinish();
}
// Here we perform the cleanup.
void onFinish() {
delete this;
}
std::string ProcessRequest(asio::streambuf& request) {
// In this method we parse the request, process it
// and prepare the request.
// Emulate CPU-consuming operations.
int i = 0;
while (i != 1000000)
i++;
// Emulate operations that block the thread
// (e.g. synch I/O operations).
std::this_thread::sleep_for(
std::chrono::milliseconds(100));
// Prepare and return the response message.
std::string response = "Response\n";
return response;
}
private:
std::shared_ptr<asio::ip::tcp::socket> m_sock;
std::string m_response;
asio::streambuf m_request;
};
为了保持简单,在我们的示例服务器应用程序中,我们实现了一个模拟服务,它仅模拟执行某些操作。请求处理模拟包括执行许多增量操作来模拟消耗 CPU 的操作,然后让控制线程休眠一段时间来模拟同步的 I/O 操作,例如读取文件或与外围设备通信。
Service 类的每个实例都旨在通过读取请求消息、处理它并发送响应消息来处理一个连接的客户端。
接下来,我们定义另一个类,它代表一个高级 接受者 概念(与由 asio::ip::tcp::acceptor 类代表的低级概念相比)。这个类负责接受来自客户端的连接请求并实例化 Service 类的对象,该对象将为连接的客户端提供服务。让我们称它为 Acceptor:
class Acceptor {
public:
Acceptor(asio::io_service&ios, unsigned short port_num) :
m_ios(ios),
m_acceptor(m_ios,
asio::ip::tcp::endpoint(
asio::ip::address_v4::any(),
port_num)),
m_isStopped(false)
{}
// Start accepting incoming connection requests.
void Start() {
m_acceptor.listen();
InitAccept();
}
// Stop accepting incoming connection requests.
void Stop() {
m_isStopped.store(true);
}
private:
void InitAccept() {
std::shared_ptr<asio::ip::tcp::socket>
sock(new asio::ip::tcp::socket(m_ios));
m_acceptor.async_accept(*sock.get(),
this, sock
{
onAccept(error, sock);
});
}
void onAccept(const boost::system::error_code&ec,
std::shared_ptr<asio::ip::tcp::socket> sock)
{
if (ec == 0) {
(new Service(sock))->StartHandling();
}
else {
std::cout<< "Error occured! Error code = "
<<ec.value()
<< ". Message: " <<ec.message();
}
// Init next async accept operation if
// acceptor has not been stopped yet.
if (!m_isStopped.load()) {
InitAccept();
}
else {
// Stop accepting incoming connections
// and free allocated resources.
m_acceptor.close();
}
}
private:
asio::io_service&m_ios;
asio::ip::tcp::acceptor m_acceptor;
std::atomic<bool>m_isStopped;
};
这个类拥有一个名为 m_acceptor 的 asio::ip::tcp::acceptor 类对象,用于异步接受传入的连接请求。
此外,我们还定义了一个代表服务器本身的类。该类名称相应地命名为 Server:
class Server {
public:
Server() {
m_work.reset(new asio::io_service::work(m_ios));
}
// Start the server.
void Start(unsigned short port_num,
unsigned int thread_pool_size) {
assert(thread_pool_size > 0);
// Create and start Acceptor.
acc.reset(new Acceptor(m_ios, port_num));
acc->Start();
// Create specified number of threads and
// add them to the pool.
for (unsigned int i = 0; i < thread_pool_size; i++) {
std::unique_ptr<std::thread> th(
new std::thread([this]()
{
m_ios.run();
}));
m_thread_pool.push_back(std::move(th));
}
}
// Stop the server.
void Stop() {
acc->Stop();
m_ios.stop();
for (auto& th : m_thread_pool) {
th->join();
}
}
private:
asio::io_servicem_ios;
std::unique_ptr<asio::io_service::work>m_work;
std::unique_ptr<Acceptor>acc;
std::vector<std::unique_ptr<std::thread>>m_thread_pool;
};
本类提供了一个包含两个方法——Start() 和 Stop() 的接口。Start() 方法接受服务器应监听传入连接请求的协议端口号以及要添加到池中的线程数作为输入参数,并启动服务器。Stop() 方法停止服务器。Start() 方法是非阻塞的,而 Stop() 方法是阻塞的。它会阻塞调用线程,直到服务器停止并且所有运行事件循环的线程退出。
最后,我们实现了应用程序入口点函数 main(),它演示了如何使用 Server 类的对象:
const unsigned intDEFAULT_THREAD_POOL_SIZE = 2;
int main()
{
unsigned short port_num = 3333;
try {
Server srv;
unsigned intthread_pool_size =
std::thread::hardware_concurrency() * 2;
if (thread_pool_size == 0)
thread_pool_size = DEFAULT_THREAD_POOL_SIZE;
srv.Start(port_num, thread_pool_size);
std::this_thread::sleep_for(std::chrono::seconds(60));
srv.Stop();
}
catch (system::system_error&e) {
std::cout << "Error occured! Error code = "
<<e.code() << ". Message: "
<<e.what();
}
return 0;
}
它是如何工作的...
示例服务器应用程序由四个组件组成——Service、Acceptor 和 Service 类以及一个应用程序入口点函数 main()。让我们考虑每个组件是如何工作的。
Service 类
Service 类是应用程序中的关键功能组件。虽然其他组件构成了服务器的基础设施,但此类实现了服务器提供给客户端的实际功能(或服务)。
本类的单个实例旨在通过读取请求、处理它,然后发送响应消息来处理单个已连接客户端。
类的构造函数接受一个表示连接到特定客户端的套接字的共享指针作为参数,并缓存此指针。此套接字将用于稍后与客户端应用程序通信。
Service 类的公共接口由一个名为 StartHandling() 的单一方法组成。此方法通过启动异步读取操作来处理客户端,该操作从客户端读取请求消息,并将 onRequestReceived() 方法指定为回调。启动异步读取操作后,StartHandling() 方法返回。
当请求读取完成或发生错误时,会调用回调方法 onRequestReceived()。此方法首先通过测试包含操作完成状态码的 ec 参数来检查读取是否成功。如果读取以错误结束,则将相应的消息输出到标准输出流,然后调用 onFinish() 方法。之后,onRequestReceived() 方法返回,导致客户端处理过程中断。
如果请求消息已成功读取,则调用 ProcessRequest() 方法来执行请求的操作并准备响应消息。当 ProcessRequest() 方法完成并返回包含响应消息的字符串时,启动异步写入操作以将此响应消息发送回客户端。onResponseSent() 方法被指定为回调。
当写入操作完成(或发生错误)时,会调用 onResponseSent() 方法。此方法首先检查操作是否成功。如果操作失败,则将相应的消息输出到标准输出流。接下来,调用 onFinish() 方法以执行清理。当 onFinish() 方法返回时,客户端处理的全周期被认为是完成的。
ProcessRequest() 方法是类的核心,因为它实现了服务。在我们的服务器应用程序中,我们有一个模拟服务,它运行一个模拟循环,执行一百万次自增操作,然后使线程休眠 100 毫秒。之后,生成模拟响应消息并返回给调用者。
根据特定应用程序的需求,Service 类及其 ProcessRequest() 方法可以被扩展和丰富,以提供所需的服务功能。
Service 类被设计成当其任务完成时,其对象会自行删除。删除操作在类的 onFinish() 私有方法中执行,该方法在客户端处理周期结束时被调用,无论操作是否成功或出错:
void onFinish() {
delete this;
}
接收者类
Acceptor 类是服务器应用程序基础设施的一部分。其构造函数接受一个端口号,它将在此端口号上监听传入的连接请求作为其输入参数。此类的对象包含一个名为 m_acceptor 的 asio::ip::tcp::acceptor 类的实例,该实例在 Acceptor 类的构造函数中构建。
Acceptor 类公开了两个公共方法——Start() 和 Stop()。Start() 方法旨在指示 Acceptor 类的对象开始监听并接受传入的连接请求。它将 m_acceptor 接收器套接字置于监听模式,然后调用类的 InitAccept() 私有方法。InitAccept() 方法反过来构建一个活动套接字对象,并启动异步接受操作,在接收器套接字对象上调用 async_accept() 方法,并将表示活动套接字的对象作为参数传递给它。Acceptor 类的 onAccept() 方法被指定为回调。
当连接请求被接受或发生错误时,会调用回调方法 onAccept()。此方法首先检查在异步操作执行过程中是否发生了任何错误,通过检查其输入参数 ec 的值。如果操作成功完成,则创建 Service 类的一个实例,并调用其 StartHandling() 方法,该方法开始处理已连接的客户端。否则,在出错的情况下,将相应的消息输出到标准输出流。
接下来,检查m_isStopped原子变量的值,以查看是否已对Acceptor对象发出停止命令。如果是(这意味着已对Acceptor对象调用Stop()方法),则不会启动新的异步接受操作,并且关闭低级接受器对象。此时,Acceptor停止监听并接受来自客户端的传入连接请求。否则,调用InitAccept()方法来启动一个新的异步接受操作,以接受下一个传入的连接请求。
正如之前提到的,Stop()方法指示Acceptor对象在当前运行的异步接受操作完成后不要启动下一个异步接受操作。然而,此方法不会取消当前正在运行的接受操作。
Server类
如其名称所示,Server类代表一个服务器本身。该类的公共接口由两个方法组成:Start()和Stop()。
Start()方法启动服务器。它接受两个参数。第一个参数名为port_num,指定服务器应监听传入连接的协议端口号。第二个参数名为thread_pool_size,指定要添加到运行事件循环和传递异步操作完成事件的线程池中的线程数。此参数非常重要,应该谨慎选择,因为它直接影响到服务器的性能。
Start()方法首先实例化一个Acceptor类的对象,该对象将用于接受传入的连接,然后通过调用其Start()方法启动它。之后,它通过调用asio::io_service对象的run()方法,创建一组工作线程,每个线程都被添加到线程池中。此外,所有std::thread对象都被缓存到m_thread_pool成员向量中,以便在服务器停止时可以稍后连接这些线程。
Stop()方法首先停止Acceptor对象acc,调用其Stop()方法。然后,它调用asio::io_service对象m_ios上的stop()方法,使得之前调用m_ios.run()的所有线程尽快加入线程池并退出,丢弃所有挂起的异步操作。之后,Stop()方法通过遍历缓存于m_thread_pool向量中的所有std::thread对象,等待线程池中的所有线程退出。
当所有线程退出时,Stop()方法返回。
main()入口点函数
此函数演示了服务器使用方法。首先,它创建了一个名为srv的Server类对象。因为Server类的Start()方法需要一个由线程池构成的多个线程传递给它,所以在启动服务器之前,计算池的最优大小。在并行应用程序中通常使用的通用公式是计算机处理器数量的两倍来找到最优的线程数。我们使用std::thread::hardware_concurrency()静态方法来获取处理器数量。然而,因为这个方法可能无法完成其任务返回 0,所以我们回退到由常量DEFAULT_THREAD_POOL_SIZE表示的默认值,在我们的情况下等于 2。
当线程池大小计算完毕后,调用Start()方法来启动服务器。Start()方法不会阻塞。当它返回时,运行main()方法的线程继续执行。为了允许服务器运行一段时间,主线程被休眠 60 秒。当主线程醒来时,它调用srv对象的Stop()方法来停止服务器。当Stop()方法返回时,main()函数也返回,我们的应用程序退出。
当然,在实际应用中,服务器会作为对某些相关事件(如用户输入)的反应而停止,而不是当某个虚拟的时间段过去时。
参见
-
第二章,I/O 操作,包括提供详细讨论如何执行同步 I/O 的配方。
-
来自第六章的使用计时器配方,其他主题,展示了如何使用 Boost.Asio 提供的计时器。计时器可以用来实现异步操作超时机制。
第五章:HTTP 和 SSL/TLS
在本章中,我们将涵盖以下主题:
-
实现 HTTP 客户端应用程序
-
实现 HTTP 服务器应用程序
-
为客户端应用程序添加 SSL/TLS 支持
-
为服务器应用程序添加 SSL/TLS 支持
简介
本章涵盖两个主要主题。第一个是 HTTP 协议的实现。第二个是 SSL/TLS 协议的使用。让我们简要地考察每个主题。
HTTP 协议是一个运行在 TCP 协议之上的应用层协议。它在互联网上被广泛使用,允许客户端应用程序从服务器请求特定资源,并允许服务器将请求的资源传输回客户端。此外,HTTP 还允许客户端上传数据和向服务器发送命令。
HTTP 协议假设几种通信模型或方法,每个方法都针对特定目的而设计。最简单的方法称为GET,它假设以下事件流程:
-
HTTP 客户端应用程序(例如,网页浏览器)生成一个包含有关特定资源(位于服务器上)信息的需求消息,并使用 TCP 作为传输层协议将其发送到 HTTP 服务器应用程序(例如,Web 服务器)。
-
当 HTTP 服务器应用程序收到客户端的请求后,它会解析该请求,从存储(例如,从文件系统或数据库)中提取请求的资源,并将其作为 HTTP 响应消息的一部分发送回客户端。
请求和响应消息的格式由 HTTP 协议定义。
HTTP 协议定义了其他几种方法,允许客户端应用程序主动发送数据或上传资源到服务器,删除服务器上的资源,并执行其他操作。在本章的食谱中,我们将考虑GET方法的实现。因为 HTTP 协议的方法在原则上相似,实现其中一种方法可以为其他方法的实现提供很好的提示。
本章还涉及另一个主题:SSL 和 TLS 协议。安全套接字层(SSL)和传输层安全性(TLS)协议在 TCP 协议之上运行,旨在实现以下两个主要目标:
-
提供一种使用数字证书验证每个通信参与者的方式
-
保护通过底层 TCP 协议传输的数据安全
SSL 和 TLS 协议在 Web 上广泛使用,尤其是在 Web 服务器中。大多数可能向其发送敏感数据(密码、信用卡号码、个人信息等)的 Web 服务器都支持 SSL/TLS 启用通信。在这种情况下,所谓的 HTTPS(通过 SSL 的 HTTP)协议被用来允许客户端验证服务器(有时服务器可能需要验证客户端,尽管这种情况很少见),并通过加密传输的数据来确保数据安全,即使被截获,对犯罪分子来说这些数据也是无用的。
注意
Boost.Asio 不包含 SSL/TLS 协议的实现。相反,它依赖于 OpenSSL 库,Boost.Asio 提供了一套类、函数和数据结构,这些结构简化了 OpenSSL 提供的功能的用法,使应用程序的代码更加统一和面向对象。
在本章中,我们不会考虑 OpenSSL 库或 SSL/TLS 协议的细节。这些主题不在此书的范围之内。相反,我们将简要介绍 Boost.Asio 提供的特定工具,这些工具依赖于 OpenSSL 库,并允许在网络应用程序中实现 SSL/TLS 协议的支持。
这两个配方演示了如何构建客户端和服务器应用程序,它们使用 SSL/TLS 协议来确保其通信的安全性。为了使应用程序的 SSL/TLS 相关方面更加生动和清晰,考虑的应用程序的其他方面都被尽可能地简化。客户端和服务器应用程序都是同步的,并基于本书其他章节中的配方。这使我们能够比较基本的 TCP 客户端或服务器应用程序及其支持 SSL/TLS 的高级版本,并更好地理解向分布式应用程序添加 SSL/TLS 支持需要做什么。
实现 HTTP 客户端应用程序
HTTP 客户端是分布式软件的一个重要类别,由许多应用程序表示。网络浏览器是这个类的一个突出代表。它们使用 HTTP 协议从网络服务器请求网页。然而,今天 HTTP 协议不仅用于网络。许多分布式应用程序使用此协议来交换任何类型的自定义数据。在设计分布式应用程序时,选择 HTTP 作为通信协议通常比开发自定义协议要好得多。
在这个配方中,我们将考虑使用 Boost.Asio 实现 HTTP 客户端,该客户端满足以下基本要求:
-
支持 HTTP
GET请求方法 -
异步执行请求
-
支持请求取消
让我们继续到实现部分。
如何做到这一点…
由于我们客户应用程序的一个要求是支持取消尚未完成但已启动的请求,我们需要确保所有目标平台都启用了取消功能。因此,我们首先配置 Boost.Asio 库,以便启用请求取消。有关异步操作取消相关问题的更多详细信息,请参阅第二章中的取消异步操作配方,I/O 操作:
#include <boost/predef.h> // Tools to identify the OS.
// We need this to enable cancelling of I/O operations on
// Windows XP, Windows Server 2003 and earlier.
// Refer to "http://www.boost.org/doc/libs/1_58_0/
// doc/html/boost_asio/reference/basic_stream_socket/
// cancel/overload1.html" for details.
#ifdef BOOST_OS_WINDOWS
#define _WIN32_WINNT 0x0501
#if _WIN32_WINNT <= 0x0502 // Windows Server 2003 or earlier.
#define BOOST_ASIO_DISABLE_IOCP
#define BOOST_ASIO_ENABLE_CANCELIO
#endif
#endif
接下来,我们包含 Boost.Asio 库的头文件,以及我们将需要实现应用程序的一些标准 C++库组件的头文件:
#include <boost/asio.hpp>
#include <thread>
#include <mutex>
#include <memory>
#include <iostream>
using namespace boost;
现在,在我们能够跳转到实现构成我们客户端应用程序的类和函数之前,我们必须进行一项与错误表示和处理相关的准备工作。
在实现 HTTP 客户端应用程序时,我们需要处理三类错误。第一类是由执行 Boost.Asio 函数和类方法时可能发生的许多错误表示的。例如,如果我们对一个表示尚未打开的套接字的对象的 write_some() 方法进行调用,该方法将返回操作系统依赖的错误代码(通过抛出异常或通过方法重载使用的输出参数,具体取决于所使用的方法重载),表示在未打开的套接字上执行了无效操作。
第二个类包括由 HTTP 协议定义的既错误又非错误的状态。例如,服务器作为对客户端特定请求的响应返回的状态码 200,表示客户端的请求已成功完成。另一方面,状态码 500 表示在执行请求操作时,服务器发生了错误,导致请求未能完成。
第三个类包括与 HTTP 协议本身相关的错误。如果服务器发送一条消息作为对客户端请求的正确响应,并且这条消息不是一个正确结构的 HTTP 响应,客户端应用程序应该有方法来用错误代码表示这一事实。
第一类错误的错误代码定义在 Boost.Asio 库的源代码中。第二类的状态码由 HTTP 协议定义。第三类在别处没有定义,我们应该在我们的应用程序中自行定义相应的错误代码。
我们定义一个单一的错误代码,它代表一个相当通用的错误,表明从服务器接收到的消息不是一个正确的 HTTP 响应消息,因此客户端无法解析它。让我们将这个错误代码命名为 invalid_response:
namespace http_errors {
enum http_error_codes
{
invalid_response = 1
};
然后,我们定义一个表示错误类别的类,它包括上面定义的 invalid_response 错误代码。让我们将这个类别命名为 http_errors_category:
class http_errors_category
: public boost::system::error_category
{
public:
const char* name() const BOOST_SYSTEM_NOEXCEPT
{ return "http_errors"; }
std::string message(int e) const {
switch (e) {
case invalid_response:
return "Server response cannot be parsed.";
break;
default:
return "Unknown error.";
break;
}
}
};
然后,我们定义这个类的静态对象、返回对象实例的函数以及接受我们自定义类型 http_error_codes 的错误代码的 make_error_code() 函数的重载:
const boost::system::error_category&
get_http_errors_category()
{
static http_errors_category cat;
return cat;
}
boost::system::error_code
make_error_code(http_error_codes e)
{
return boost::system::error_code(
static_cast<int>(e), get_http_errors_category());
}
} // namespace http_errors
在我们可以在应用程序中使用新的错误代码之前,我们需要执行的最后一个步骤是让 Boost 库知道 http_error_codes 枚举的成员应该被视为错误代码。为此,我们将以下结构定义包含到 boost::system 命名空间中:
namespace boost {
namespace system {
template<>
struct is_error_code_enum
<http_errors::http_error_codes>
{
BOOST_STATIC_CONSTANT(bool, value = true);
};
} // namespace system
} // namespace boost
由于我们的 HTTP 客户端应用程序将是异步的,当客户端在发起请求时,将需要提供一个指向回调函数的指针,该函数将在请求完成时被调用。我们需要定义一个表示此类回调函数指针的类型。
当回调函数被调用时,需要传递一些参数,这些参数清楚地指明了以下三件事:
-
哪个请求已经完成
-
响应是什么
-
请求是否成功完成,如果没有,则表示发生的错误的错误代码
注意,稍后我们将定义代表 HTTP 请求和 HTTP 响应的 HTTPRequest 和 HTTPResponse 类,但现在我们使用前置声明。以下是回调函数指针类型声明的样子:
class HTTPClient;
class HTTPRequest;
class HTTPResponse;
typedef void(*Callback) (const HTTPRequest& request,
const HTTPResponse& response,
const system::error_code& ec);
HTTPResponse 类
现在,我们可以定义一个类来表示作为对请求的响应发送给客户端的 HTTP 响应消息:
class HTTPResponse {
friend class HTTPRequest;
HTTPResponse() :
m_response_stream(&m_response_buf)
{}
public:
unsigned int get_status_code() const {
return m_status_code;
}
const std::string& get_status_message() const {
return m_status_message;
}
const std::map<std::string, std::string>& get_headers() {
return m_headers;
}
const std::istream& get_response() const {
return m_response_stream;
}
private:
asio::streambuf& get_response_buf() {
return m_response_buf;
}
void set_status_code(unsigned int status_code) {
m_status_code = status_code;
}
void set_status_message(const std::string& status_message) {
m_status_message = status_message;
}
void add_header(const std::string& name,
const std::string& value)
{
m_headers[name] = value;
}
private:
unsigned int m_status_code; // HTTP status code.
std::string m_status_message; // HTTP status message.
// Response headers.
std::map<std::string, std::string> m_headers;
asio::streambuf m_response_buf;
std::istream m_response_stream;
};
HTTPResponse 类相当简单。它的私有数据成员代表 HTTP 响应的各个部分,如响应状态码和状态消息,以及响应头和主体。它的公共接口包含返回相应数据成员值的函数,而私有方法允许设置这些值。
将要定义的表示 HTTP 请求的 HTTPRequest 类被声明为 HTTPResponse 的朋友。我们将看到 HTTPRequest 类的实例如何使用 HTTPResponse 类的私有方法在收到响应消息时设置其数据成员的值。
HTTPRequest 类
接下来,我们定义一个类来表示包含基于用户提供的类信息构建 HTTP 请求消息、将其发送到服务器以及接收和解析 HTTP 响应消息的功能的 HTTP 请求。
这个类是我们应用程序的核心,因为它包含了大部分的功能。
然后,我们将定义一个代表 HTTP 客户端的 HTTPClient 类,其职责将限于维护所有 HTTPRequest 对象共有的单个 asio::io_service 类的实例,并作为 HTTPRequest 对象的工厂。因此,我们将 HTTPClient 类声明为 HTTPRequest 类的朋友,并将 HTTPRequest 类的构造函数设为私有:
class HTTPRequest {
friend class HTTPClient;
static const unsigned int DEFAULT_PORT = 80;
HTTPRequest(asio::io_service& ios, unsigned int id) :
m_port(DEFAULT_PORT),
m_id(id),
m_callback(nullptr),
m_sock(ios),
m_resolver(ios),
m_was_cancelled(false),
m_ios(ios)
{}
构造函数接受两个参数:一个指向 asio::io_service 类对象的引用和一个名为 id 的无符号整数。后者包含请求的唯一标识符,由类的用户分配,允许区分不同的请求对象。
然后,我们定义构成类公共接口的方法:
public:
void set_host(const std::string& host) {
m_host = host;
}
void set_port(unsigned int port) {
m_port = port;
}
void set_uri(const std::string& uri) {
m_uri = uri;
}
void set_callback(Callback callback) {
m_callback = callback;
}
std::string get_host() const {
return m_host;
}
unsigned int get_port() const {
return m_port;
}
const std::string& get_uri() const {
return m_uri;
}
unsigned int get_id() const {
return m_id;
}
void execute() {
// Ensure that precorditions hold.
assert(m_port > 0);
assert(m_host.length() > 0);
assert(m_uri.length() > 0);
assert(m_callback != nullptr);
// Prepare the resolving query.
asio::ip::tcp::resolver::query resolver_query(m_host,
std::to_string(m_port),
asio::ip::tcp::resolver::query::numeric_service);
std::unique_lock<std::mutex>
cancel_lock(m_cancel_mux);
if (m_was_cancelled) {
cancel_lock.unlock();
on_finish(boost::system::error_code(
asio::error::operation_aborted));
return;
}
// Resolve the host name.
m_resolver.async_resolve(resolver_query,
this
{
on_host_name_resolved(ec, iterator);
});
}
void cancel() {
std::unique_lock<std::mutex>
cancel_lock(m_cancel_mux);
m_was_cancelled = true;
m_resolver.cancel();
if (m_sock.is_open()) {
m_sock.cancel();
}
}
公共接口包括允许类用户设置和获取 HTTP 请求参数的方法,例如运行服务器的 DNS 名称、协议端口号和请求资源的 URI。此外,还有一个方法允许设置一个回调函数指针,当请求完成时将被调用。
execute() 方法启动请求的执行。此外,cancel() 方法允许在请求完成之前取消已启动的请求。我们将在食谱的下一部分考虑这些方法的工作原理。
现在,我们定义一组私有方法,其中包含大部分实现细节。首先,我们定义一个用于异步 DNS 名称解析操作回调的方法:
private:
void on_host_name_resolved(
const boost::system::error_code& ec,
asio::ip::tcp::resolver::iterator iterator)
{
if (ec != 0) {
on_finish(ec);
return;
}
std::unique_lock<std::mutex>
cancel_lock(m_cancel_mux);
if (m_was_cancelled) {
cancel_lock.unlock();
on_finish(boost::system::error_code(
asio::error::operation_aborted));
return;
}
// Connect to the host.
asio::async_connect(m_sock,
iterator,
this
{
on_connection_established(ec, iterator);
});
}
接下来,我们定义一个用于异步连接操作回调的方法,该操作是在刚刚定义的on_host_name_resolved()方法中启动的:
void on_connection_established(
const boost::system::error_code& ec,
asio::ip::tcp::resolver::iterator iterator)
{
if (ec != 0) {
on_finish(ec);
return;
}
// Compose the request message.
m_request_buf += "GET " + m_uri + " HTTP/1.1\r\n";
// Add mandatory header.
m_request_buf += "Host: " + m_host + "\r\n";
m_request_buf += "\r\n";
std::unique_lock<std::mutex>
cancel_lock(m_cancel_mux);
if (m_was_cancelled) {
cancel_lock.unlock();
on_finish(boost::system::error_code(
asio::error::operation_aborted));
return;
}
// Send the request message.
asio::async_write(m_sock,
asio::buffer(m_request_buf),
this
{
on_request_sent(ec, bytes_transferred);
});
}
我们定义的下一个方法——on_request_sent()——是一个回调,在将请求消息发送到服务器后被调用:
void on_request_sent(const boost::system::error_code& ec,
std::size_t bytes_transferred)
{
if (ec != 0) {
on_finish(ec);
return;
}
m_sock.shutdown(asio::ip::tcp::socket::shutdown_send);
std::unique_lock<std::mutex>
cancel_lock(m_cancel_mux);
if (m_was_cancelled) {
cancel_lock.unlock();
on_finish(boost::system::error_code(
asio::error::operation_aborted));
return;
}
// Read the status line.
asio::async_read_until(m_sock,
m_response.get_response_buf(),
"\r\n",
this
{
on_status_line_received(ec, bytes_transferred);
});
}
然后,我们需要另一个回调方法,当从服务器接收到响应消息的第一部分,即状态行时,该方法会被调用:
void on_status_line_received(
const boost::system::error_code& ec,
std::size_t bytes_transferred)
{
if (ec != 0) {
on_finish(ec);
return;
}
// Parse the status line.
std::string http_version;
std::string str_status_code;
std::string status_message;
std::istream response_stream(
&m_response.get_response_buf());
response_stream >> http_version;
if (http_version != "HTTP/1.1"){
// Response is incorrect.
on_finish(http_errors::invalid_response);
return;
}
response_stream >> str_status_code;
// Convert status code to integer.
unsigned int status_code = 200;
try {
status_code = std::stoul(str_status_code);
}
catch (std::logic_error&) {
// Response is incorrect.
on_finish(http_errors::invalid_response);
return;
}
std::getline(response_stream, status_message, '\r');
// Remove symbol '\n' from the buffer.
response_stream.get();
m_response.set_status_code(status_code);
m_response.set_status_message(status_message);
std::unique_lock<std::mutex>
cancel_lock(m_cancel_mux);
if (m_was_cancelled) {
cancel_lock.unlock();
on_finish(boost::system::error_code(
asio::error::operation_aborted));
return;
}
// At this point the status line is successfully
// received and parsed.
// Now read the response headers.
asio::async_read_until(m_sock,
m_response.get_response_buf(),
"\r\n\r\n",
this
{
on_headers_received(ec,
bytes_transferred);
});
}
现在,我们定义一个作为回调的方法,当从服务器接收到响应消息的下一段——响应头块时,该方法会被调用。我们将它命名为on_headers_received():
void on_headers_received(const boost::system::error_code& ec,
std::size_t bytes_transferred)
{
if (ec != 0) {
on_finish(ec);
return;
}
// Parse and store headers.
std::string header, header_name, header_value;
std::istream response_stream(
&m_response.get_response_buf());
while (true) {
std::getline(response_stream, header, '\r');
// Remove \n symbol from the stream.
response_stream.get();
if (header == "")
break;
size_t separator_pos = header.find(':');
if (separator_pos != std::string::npos) {
header_name = header.substr(0,
separator_pos);
if (separator_pos < header.length() - 1)
header_value =
header.substr(separator_pos + 1);
else
header_value = "";
m_response.add_header(header_name,
header_value);
}
}
std::unique_lock<std::mutex>
cancel_lock(m_cancel_mux);
if (m_was_cancelled) {
cancel_lock.unlock();
on_finish(boost::system::error_code(
asio::error::operation_aborted));
return;
}
// Now we want to read the response body.
asio::async_read(m_sock,
m_response.get_response_buf(),
this
{
on_response_body_received(ec,
bytes_transferred);
});
return;
}
此外,我们还需要一个处理响应最后一部分——响应体的方法。以下方法用作回调,在从服务器接收到响应体后被调用:
void on_response_body_received(
const boost::system::error_code& ec,
std::size_t bytes_transferred)
{
if (ec == asio::error::eof)
on_finish(boost::system::error_code());
else
on_finish(ec);
}
最后,我们定义了on_finish()方法,它作为所有从execute()方法开始执行路径(包括错误路径)的最终点。当请求完成时(无论是成功还是失败),该方法会被调用,其目的是调用HTTPRequest类用户提供的回调,通知它请求已完成:
void on_finish(const boost::system::error_code& ec)
{
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
}
m_callback(*this, m_response, ec);
return;
}
我们需要一些与HTTPRequest类的每个实例相关联的数据字段。在这里,我们声明类的对应数据成员:
private:
// Request parameters.
std::string m_host;
unsigned int m_port;
std::string m_uri;
// Object unique identifier.
unsigned int m_id;
// Callback to be called when request completes.
Callback m_callback;
// Buffer containing the request line.
std::string m_request_buf;
asio::ip::tcp::socket m_sock;
asio::ip::tcp::resolver m_resolver;
HTTPResponse m_response;
bool m_was_cancelled;
std::mutex m_cancel_mux;
asio::io_service& m_ios;
需要添加的最后一项是关闭括号,以指定HTTPRequest类定义的结束:
};
HTTPClient 类
我们应用中需要添加的最后一个类是负责以下三个功能的类:
-
为了建立线程策略
-
在 Boost.Asio 事件循环的线程池中创建和销毁线程,以执行异步操作的完成事件
-
作为
HTTPRequest对象的工厂
我们将这个类命名为HTTPClient:
class HTTPClient {
public:
HTTPClient(){
m_work.reset(new boost::asio::io_service::work(m_ios));
m_thread.reset(new std::thread([this](){
m_ios.run();
}));
}
std::shared_ptr<HTTPRequest>
create_request(unsigned int id)
{
return std::shared_ptr<HTTPRequest>(
new HTTPRequest(m_ios, id));
}
void close() {
// Destroy the work object.
m_work.reset(NULL);
// Waiting for the I/O thread to exit.
m_thread->join();
}
private:
asio::io_service m_ios;
std::unique_ptr<boost::asio::io_service::work> m_work;
std::unique_ptr<std::thread> m_thread;
};
回调和 main()入口点函数
到目前为止,我们已经有了包含三个类和几个补充数据类型的基本 HTTP 客户端。现在我们将定义两个不是客户端部分,但演示如何使用 HTTP 协议与服务器通信的函数。第一个函数将用作回调,当请求完成时会被调用。它的签名必须与之前定义的Callback函数指针类型相匹配。让我们将我们的回调函数命名为handler():
void handler(const HTTPRequest& request,
const HTTPResponse& response,
const system::error_code& ec)
{
if (ec == 0) {
std::cout << "Request #" << request.get_id()
<< " has completed. Response: "
<< response.get_response().rdbuf();
}
else if (ec == asio::error::operation_aborted) {
std::cout << "Request #" << request.get_id()
<< " has been cancelled by the user."
<< std::endl;
}
else {
std::cout << "Request #" << request.get_id()
<< " failed! Error code = " << ec.value()
<< ". Error message = " << ec.message()
<< std::endl;
}
return;
}
我们需要定义的第二个和最后一个函数是main()应用程序入口点函数,它使用 HTTP 客户端向服务器发送 HTTP 请求:
int main()
{
try {
HTTPClient client;
std::shared_ptr<HTTPRequest> request_one =
client.create_request(1);
request_one->set_host("localhost");
request_one->set_uri("/index.html");
request_one->set_port(3333);
request_one->set_callback(handler);
request_one->execute();
std::shared_ptr<HTTPRequest> request_two =
client.create_request(1);
request_two->set_host("localhost");
request_two->set_uri("/example.html");
request_two->set_port(3333);
request_two->set_callback(handler);
request_two->execute();
request_two->cancel();
// Do nothing for 15 seconds, letting the
// request complete.
std::this_thread::sleep_for(std::chrono::seconds(15));
// Closing the client and exiting the application.
client.close();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
};
它是如何工作的…
现在,让我们考虑我们的 HTTP 客户端是如何工作的。应用程序由五个组件组成,其中包含 HTTPClient、HTTPRequest 和 HTTPResponse 等三个类,以及 handler() 回调函数和 main() 应用程序入口点函数等两个函数。让我们分别考虑每个组件是如何工作的。
HTTPClient 类
一个类的构造函数从创建 asio::io_service::work 对象的实例开始,以确保在没有任何挂起的异步操作时,运行事件循环的线程不会退出此循环。然后,通过在 m_ios 对象上调用 run() 方法,生成一个控制线程并将其添加到池中。这就是 HTTPClient 类执行其第一个和第二个部分功能的地方,即建立线程策略并将线程添加到池中。
HTTPClient 类的第三个功能——作为表示 HTTP 请求的对象的工厂——在其 create_request() 公共方法中执行。此方法在空闲内存中创建 HTTPRequest 类的实例,并返回一个指向它的共享指针对象。该方法接受一个整数值作为输入参数,该值代表要分配给新创建的请求对象的唯一标识符。此标识符用于区分不同的请求对象。
类的公共接口中的 close() 方法销毁 asio::io_service::work 对象,允许线程在所有挂起的操作完成时立即退出事件循环。该方法会阻塞,直到所有线程退出。
HTTPRequest 类
让我们从检查其数据成员及其用途开始,来考虑 HTTPRequest 类的行为。HTTPRequest 类包含 12 个数据成员,其中包含以下内容:
-
请求参数:
std::string m_host; unsigned int m_port; std::string m_uri; -
请求的唯一标识符:
unsigned int m_id; -
指向用户提供的类回调函数的指针,当请求完成时调用:
Callback m_callback; -
用于存储 HTTP 请求消息的字符串缓冲区:
std::string m_request_buf; -
用于与服务器通信的套接字对象:
asio::ip::tcp::socket m_sock; -
用于解析用户提供的服务器主机 DNS 名称的解析器对象:
asio::ip::tcp::resolver m_resolver; -
表示从服务器接收到的响应的
HTTPResponse类的实例:HTTPResponse m_response; -
一个布尔标志和一个支持请求取消功能的
mutex对象(稍后将解释):bool m_was_cancelled; std::mutex m_cancel_mux; -
此外,一个引用到由解析器和套接字对象所需的
asio::io_service类的实例。asio::io_service类的单个实例由HTTPClient类的对象维护:asio::io_service& m_ios;
HTTPRequest 对象的一个实例代表一个单独的 HTTP GET 请求。该类设计得如此,以便发送请求需要执行两个步骤。首先,通过在对象上调用相应的设置方法来设置请求的参数和请求完成时调用的回调函数。然后,作为第二步,调用 execute() 方法来启动请求执行。当请求完成时,调用回调函数。
set_host()、set_port()、set_uri() 和 set_callback() 设置方法允许设置服务器主机 DNS 名称和端口号、请求资源的 URI 以及在请求完成时调用的回调函数。这些方法中的每一个都接受一个参数,并将其值存储在相应的 HTTPRequest 对象的数据成员中。
get_host()、get_port() 和 get_uri() 获取器方法返回由相应的设置方法设置的值。get_id() 获取器方法返回请求对象的唯一标识符,该标识符在实例化时传递给对象的构造函数。
execute() 方法通过启动一系列异步操作来开始请求的执行。每个异步操作执行请求执行过程的一个步骤。
由于请求对象中的服务器主机用 DNS 名称(而不是用 IP 地址)表示,因此在将请求消息发送到服务器之前,必须解析指定的 DNS 名称并将其转换为 IP 地址。因此,请求执行的第一步是 DNS 名称解析。execute() 方法从准备解析查询开始,然后调用解析器对象的 async_resolve() 方法,指定 HTTPRequest 类的 on_host_name_resolve() 私有方法作为操作完成回调。
当服务器主机 DNS 名称解析时,调用 on_host_name_resolved() 方法。此方法传递两个参数:第一个是一个错误代码,指定操作的状态,第二个是可以用于遍历解析过程产生的端点列表的迭代器。
on_host_name_resolved() 方法通过调用 asio::async_connect() 自由函数来启动序列中的下一个异步操作,即套接字连接,传递套接字对象 m_sock 和迭代器参数给它,以便将套接字连接到第一个有效的端点。on_connection_established() 方法被指定为异步连接操作完成回调。
当异步连接操作完成时,会调用on_connection_established()方法。传递给它的第一个参数命名为ec,它指定了操作完成的状态。如果其值等于零,则表示套接字已成功连接到端点之一。on_connection_established()方法使用存储在HTTPRequest对象相应数据成员中的请求参数构建 HTTP GET请求消息。然后,调用asio::async_write()自由函数以异步方式将构建的 HTTP 请求消息发送到服务器。将类中的私有方法on_request_sent()指定为在asio::async_write()操作完成时调用的回调。
请求发送后,如果发送成功,客户端应用程序必须让服务器知道完整的请求已发送,并且客户端不会发送任何其他内容,通过关闭套接字的发送部分来实现。然后,客户端必须等待来自服务器的响应消息。这正是on_request_sent()方法所做的事情。首先,它调用套接字对象的shutdown()方法,指定通过将asio::ip::tcp::socket::shutdown_send作为参数传递给方法来关闭发送部分。然后,它调用asio::async_read_until()自由函数以接收来自服务器的响应。
因为响应可能非常大,而且我们事先不知道其大小,所以我们不想一次性读取它。我们首先只想读取HTTP 响应状态行;然后,分析它后,要么继续读取响应的其余部分(如果我们认为需要的话),要么丢弃它。因此,我们将表示 HTTP 响应状态行结束的\r\n符号序列作为分隔符参数传递给asio::async_read_until()方法。将on_status_line_received()方法指定为操作完成回调。
当接收到状态行时,会调用on_status_line_received()方法。此方法对状态行进行解析,从中提取指定 HTTP 协议版本、响应状态码和响应状态消息的值。每个值都会进行分析以确保正确性。我们期望 HTTP 版本为 1.1,否则认为响应不正确,并中断请求执行。状态码应该是一个整数值。如果字符串到整数的转换失败,则认为响应不正确,并中断其进一步处理。如果响应状态行正确,则请求执行继续。提取的状态码和状态消息存储在m_response成员对象中,并启动请求执行操作序列中的下一个异步操作。现在,我们想要读取响应头块。
根据 HTTP 协议,响应头块以 \r\n\r\n 符号序列结束。因此,为了读取它,我们再次调用 asio::async_read_until() 自由函数,指定字符串 \r\n\r\n 作为分隔符。将 on_headers_received() 方法指定为回调。
当接收到响应头块时,会调用 on_headers_received() 方法。在这个方法中,响应头块被解析并分解成单独的名字-值对,并作为响应的一部分存储在 m_response 成员对象中。
接收并解析了头部信息后,我们想要读取响应的最后部分——响应体。为此,通过调用 asio::async_read() 自由函数启动异步读取操作。将 on_response_body_received() 方法指定为回调。
最终,会调用 on_response_body_received() 方法,通知我们整个响应消息已经接收完毕。因为 HTTP 服务器可能在发送响应消息的最后部分后立即关闭其套接字的发送部分,所以在客户端,最后的读取操作可能以等于 asio::error::eof 的错误代码完成。这不应被视为实际错误,而应视为一个正常事件。因此,如果 on_response_body_received() 方法以等于 asio::error::eof 的 ec 参数被调用,我们将默认构造的 boost::system::error_code 类对象传递给 on_finish() 方法,以指定请求执行已成功完成。否则,on_finish() 方法会以表示原始错误代码的参数被调用。on_finish() 方法反过来会调用 HTTPRequest 类对象客户端提供的回调。
当回调返回时,认为请求处理已完成。
HTTPResponse 类
HTTPResponse 类不提供很多功能。它更像是一个包含表示响应不同部分的数据成员的普通数据结构,定义了获取和设置相应数据成员值的获取器和设置器方法。
所有设置方法都是私有的,只有 HTTPRequest 类的对象可以访问它们(回想一下,HTTPRequest 类被声明为 HTTPResponse 类的朋友)。HTTPRequest 类的每个对象都有一个数据成员,它是 HTTPResponse 类的一个实例。当 HTTPRequest 类的对象接收并解析从 HTTP 服务器接收到的响应时,它会设置其 HTTPResponse 类成员对象中的值。
回调和 main() 入口点函数
这些函数演示了如何使用 HTTPClient 和 HTTPRequest 类来向 HTTP 服务器发送 GET HTTP 请求,然后如何使用 HTTPResponse 类来获取响应。
main()函数首先创建HTTPClient类的一个实例,然后使用它创建两个HTTPRequest类的实例,每个实例代表一个单独的GET HTTP 请求。这两个请求对象都提供了请求参数,然后执行。然而,在第二个请求执行后,第一个请求通过调用其cancel()方法被取消。
handler()函数,作为在main()函数中创建的请求对象的完成回调,无论请求成功、失败或被取消,每次请求完成时都会被调用。handler()函数分析传递给它的错误代码和请求以及响应对象,并将相应的消息输出到标准输出流。
参见
-
来自第三章的实现异步 TCP 客户端配方提供了关于如何实现异步 TCP 客户端的更多信息。
-
来自第六章的使用定时器配方,其他主题,展示了如何使用 Boost.Asio 提供的定时器。定时器可以用来实现异步操作超时机制。
实现 HTTP 服务器应用程序
现在,市场上有很多 HTTP 服务器应用程序。然而,有时需要实现一个定制的应用程序。这可能是一个小型简单的服务器,支持 HTTP 协议的特定子集,可能带有自定义扩展,或者可能不是一个 HTTP 服务器,而是一个支持类似 HTTP 或基于 HTTP 的通信协议的服务器。
在这个配方中,我们将考虑使用 Boost.Asio 实现基本 HTTP 服务器应用程序。以下是我们的应用程序必须满足的一组要求:
-
它应该支持 HTTP 1.1 协议
-
它应该支持
GET方法 -
它应该能够并行处理多个请求,也就是说,它应该是一个异步并行服务器
事实上,我们已经在考虑实现部分满足指定要求的服务器应用程序。在第四章中,名为实现服务器应用程序的配方展示了如何实现一个异步并行 TCP 服务器,该服务器根据一个虚拟的应用层协议与客户端进行通信。所有的通信功能以及协议细节都被封装在一个名为Service的单个类中。在该配方中定义的其他所有类和函数在目的上都是基础设施性的,并且与协议细节隔离。因此,当前的配方将基于第四章中的配方,在这里我们只考虑Service类的实现,因为其他组件保持不变。
注意
注意,在本配方中,我们不考虑应用程序的安全性。在将服务器公开之前,请确保服务器受到保护,尽管它按照 HTTP 协议正确运行,但由于安全漏洞,它可能被犯罪分子所利用。
现在让我们继续实现 HTTP 服务器应用程序。
准备中…
因为本配方中演示的应用程序基于第四章中名为实现异步 TCP 服务器的配方中的其他应用程序,所以在继续进行本配方之前,有必要熟悉那个配方。
如何做…
我们开始我们的应用程序,通过包含包含我们将要使用的数据类型和函数的声明和定义的头文件:
#include <boost/asio.hpp>
#include <boost/filesystem.hpp>
#include <fstream>
#include <atomic>
#include <thread>
#include <iostream>
using namespace boost;
接下来,我们开始定义提供 HTTP 协议实现的Service类。首先,我们声明一个静态常量表,包含 HTTP 状态码和状态消息。表的定义将在Service类定义之后给出:
class Service {
static const std::map<unsigned int, std::string>
http_status_table;
类的构造函数接受一个参数——指向连接到客户端的套接字实例的共享指针。以下是构造函数的定义:
public:
Service(std::shared_ptr<boost::asio::ip::tcp::socket> sock) :
m_sock(sock),
m_request(4096),
m_response_status_code(200), // Assume success.
m_resource_size_bytes(0)
{};
接下来,我们定义一个构成Service类公共接口的单个方法。该方法通过将指向连接到套接字的客户端实例的指针传递给Service类的构造函数,启动与套接字连接的客户端的异步通信会话:
void start_handling() {
asio::async_read_until(*m_sock.get(),
m_request,
"\r\n",
this
{
on_request_line_received(ec,
bytes_transferred);
});
}
然后,我们定义一组私有方法,这些方法执行接收和处理客户端发送的请求,解析和执行请求,并将响应发送回去。首先,我们定义一个处理HTTP 请求行的方法:
private:
void on_request_line_received(
const boost::system::error_code& ec,
std::size_t bytes_transferred)
{
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
if (ec == asio::error::not_found) {
// No delimiter has been found in the
// request message.
m_response_status_code = 413;
send_response();
return;
}
else {
// In case of any other error –
// close the socket and clean up.
on_finish();
return;
}
}
// Parse the request line.
std::string request_line;
std::istream request_stream(&m_request);
std::getline(request_stream, request_line, '\r');
// Remove symbol '\n' from the buffer.
request_stream.get();
// Parse the request line.
std::string request_method;
std::istringstream request_line_stream(request_line);
request_line_stream >> request_method;
// We only support GET method.
if (request_method.compare("GET") != 0) {
// Unsupported method.
m_response_status_code = 501;
send_response();
return;
}
request_line_stream >> m_requested_resource;
std::string request_http_version;
request_line_stream >> request_http_version;
if (request_http_version.compare("HTTP/1.1") != 0) {
// Unsupported HTTP version or bad request.
m_response_status_code = 505;
send_response();
return;
}
// At this point the request line is successfully
// received and parsed. Now read the request headers.
asio::async_read_until(*m_sock.get(),
m_request,
"\r\n\r\n",
this
{
on_headers_received(ec,
bytes_transferred);
});
return;
}
接下来,我们定义一个旨在处理和存储包含请求头的请求头块的方法:
void on_headers_received(const boost::system::error_code& ec,
std::size_t bytes_transferred)
{
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
if (ec == asio::error::not_found) {
// No delimiter has been fonud in the
// request message.
m_response_status_code = 413;
send_response();
return;
}
else {
// In case of any other error - close the
// socket and clean up.
on_finish();
return;
}
}
// Parse and store headers.
std::istream request_stream(&m_request);
std::string header_name, header_value;
while (!request_stream.eof()) {
std::getline(request_stream, header_name, ':');
if (!request_stream.eof()) {
std::getline(request_stream,
header_value,
'\r');
// Remove symbol \n from the stream.
request_stream.get();
m_request_headers[header_name] =
header_value;
}
}
// Now we have all we need to process the request.
process_request();
send_response();
return;
}
此外,我们还需要一个可以执行满足客户端发送的请求所需操作的方法。我们定义了process_request()方法,其目的是从文件系统中读取请求资源的内容并将其存储在缓冲区中,以便将其发送回客户端:
void process_request() {
// Read file.
std::string resource_file_path =
std::string("D:\\http_root") +
m_requested_resource;
if (!boost::filesystem::exists(resource_file_path)) {
// Resource not found.
m_response_status_code = 404;
return;
}
std::ifstream resource_fstream(
resource_file_path,
std::ifstream::binary);
if (!resource_fstream.is_open()) {
// Could not open file.
// Something bad has happened.
m_response_status_code = 500;
return;
}
// Find out file size.
resource_fstream.seekg(0, std::ifstream::end);
m_resource_size_bytes =
static_cast<std::size_t>(
resource_fstream.tellg());
m_resource_buffer.reset(
new char[m_resource_size_bytes]);
resource_fstream.seekg(std::ifstream::beg);
resource_fstream.read(m_resource_buffer.get(),
m_resource_size_bytes);
m_response_headers += std::string("content-length") +
": " +
std::to_string(m_resource_size_bytes) +
"\r\n";
}
最后,我们定义一个方法来组合响应消息并将其发送到客户端:
void send_response() {
m_sock->shutdown(
asio::ip::tcp::socket::shutdown_receive);
auto status_line =
http_status_table.at(m_response_status_code);
m_response_status_line = std::string("HTTP/1.1 ") +
status_line +
"\r\n";
m_response_headers += "\r\n";
std::vector<asio::const_buffer> response_buffers;
response_buffers.push_back(
asio::buffer(m_response_status_line));
if (m_response_headers.length() > 0) {
response_buffers.push_back(
asio::buffer(m_response_headers));
}
if (m_resource_size_bytes > 0) {
response_buffers.push_back(
asio::buffer(m_resource_buffer.get(),
m_resource_size_bytes));
}
// Initiate asynchronous write operation.
asio::async_write(*m_sock.get(),
response_buffers,
this
{
on_response_sent(ec,
bytes_transferred);
});
}
当响应发送完成时,我们需要关闭套接字,让客户端知道已经发送了完整的响应,并且服务器将不再发送更多数据。为此,我们定义了on_response_sent()方法:
void on_response_sent(const boost::system::error_code& ec,
std::size_t bytes_transferred)
{
if (ec != 0) {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message();
}
m_sock->shutdown(asio::ip::tcp::socket::shutdown_both);
on_finish();
}
我们需要定义的最后一个方法是执行清理并删除Service对象实例的方法,当通信会话完成且对象不再需要时:
// Here we perform the cleanup.
void on_finish() {
delete this;
}
当然,在我们的类中我们需要一些数据成员。我们声明以下数据成员:
private:
std::shared_ptr<boost::asio::ip::tcp::socket> m_sock;
boost::asio::streambuf m_request;
std::map<std::string, std::string> m_request_headers;
std::string m_requested_resource;
std::unique_ptr<char[]> m_resource_buffer;
unsigned int m_response_status_code;
std::size_t m_resource_size_bytes;
std::string m_response_headers;
std::string m_response_status_line;
};
为了完成表示服务的类的定义,我们需要做的最后一件事是定义之前声明的静态成员http_status_table并填充数据——HTTP 状态码和相应的状态消息:
const std::map<unsigned int, std::string>
Service::http_status_table =
{
{ 200, "200 OK" },
{ 404, "404 Not Found" },
{ 413, "413 Request Entity Too Large" },
{ 500, "500 Server Error" },
{ 501, "501 Not Implemented" },
{ 505, "505 HTTP Version Not Supported" }
};
我们的Service类现在就准备好了。
它是如何工作的…
让我们从考虑Service类的数据成员开始,然后转向其功能。Service类包含以下非静态数据成员:
-
std::shared_ptr<boost::asio::ip::tcp::socket> m_sock:这是一个指向连接到客户端的 TCP 套接字对象的共享指针 -
boost::asio::streambuf m_request:这是一个缓冲区,请求消息被读取到其中 -
std::map<std::string, std::string> m_request_headers:这是一个映射,当解析 HTTP 请求头块时,请求头会被放入其中 -
std::string m_requested_resource:这是客户端请求的资源 URI -
std::unique_ptr<char[]> m_resource_buffer:这是一个缓冲区,在将请求资源的内容作为响应消息的一部分发送到客户端之前,存储请求资源的内容 -
unsigned int m_response_status_code:这是 HTTP 响应状态码 -
std::size_t m_resource_size_bytes:这是请求资源的内容的尺寸 -
std::string m_response_headers:这是一个包含正确格式化的响应头块的字符串 -
std::string m_response_status_line:这包含一个响应状态行
现在我们知道了Service类数据成员的目的,让我们追踪它是如何工作的。在这里,我们只考虑Service类的工作方式。关于服务器应用程序的所有其他组件及其工作方式的描述,请参阅第四章中名为实现异步 TCP 服务器的配方,实现服务器应用程序。
当客户端发送 TCP 连接请求并且该请求在服务器上被接受(这发生在Acceptor类中,在本食谱中不予考虑)时,会创建Service类的一个实例,并将指向连接到该客户端的 TCP 套接字对象的共享指针传递给其构造函数。套接字指针存储在Service对象的数据成员m_sock中。
此外,在构造Service对象期间,m_request流缓冲区成员变量被初始化为 4096,这设置了缓冲区的最大字节数。限制请求缓冲区的大小是一种安全措施,有助于保护服务器免受可能尝试发送非常长的虚拟请求消息并耗尽服务器应用程序可用内存的恶意客户端。对于正确的请求,4096 字节的缓冲区大小绰绰有余。
在构造Service类的一个实例之后,Acceptor类会调用其start_handling()方法。从这个方法开始,异步方法调用的序列开始执行,该序列执行请求接收、处理和响应发送。start_handling()方法立即启动一个异步读取操作,调用asio::async_read_until()函数以接收客户端发送的 HTTP 请求行。on_request_line_received()方法被指定为回调。
当调用on_request_line_received()方法时,我们首先检查指定操作完成状态的错误代码。如果状态码不等于零,我们考虑两种选项。第一种选项——当错误代码等于asio::error::not_found值时——意味着从客户端接收的字节数超过了缓冲区的大小和 HTTP 请求行(\r\n符号序列)的分隔符尚未遇到。这种情况由 HTTP 状态码 413 描述。我们将m_response_status_code成员变量的值设置为 413,并调用send_response()方法,该方法启动向客户端发送指定错误响应的操作。我们将在本节稍后考虑send_response()方法。此时,请求处理已完成。
如果错误代码既不表示成功也不等于asio::error::not_found,这意味着发生了其他我们无法恢复的错误,因此,我们只是输出有关错误的信息,并且根本不向客户端回复。调用on_finish()方法进行清理,并中断与客户端的通信。
最后,如果 HTTP 请求行的接收成功,它将被解析以提取 HTTP 请求方法、标识请求资源的 URI 以及 HTTP 协议版本。因为我们的示例服务器只支持 GET 方法,如果请求行中指定的方法与 GET 不同,则进一步请求处理将被中断,并向客户端发送包含错误代码 501 的响应,告知客户端请求中指定的方法不被服务器支持。
同样,客户端在 HTTP 请求行中指定的 HTTP 协议版本将被检查,以确保它被服务器支持。因为我们的服务器应用程序只支持版本 1.1,如果客户端指定的版本不同,则向客户端发送包含 HTTP 状态代码 505 的响应,并中断请求处理。
从 HTTP 请求行中提取的 URI 字符串存储在 m_requested_resource 数据成员中,并将随后使用。
当接收到并解析 HTTP 请求行时,我们继续读取请求消息,以便读取请求头部块。为此,调用 asio::async_read_until() 函数。因为请求头部块以 \r\n\r\n 符号序列结束,所以这个符号序列被作为分隔符参数传递给函数。指定 on_headers_received() 方法作为操作完成回调。
on_headers_received() 方法执行类似于 on_request_line_received() 方法中执行的错误检查。在出现错误的情况下,请求处理中断。在成功的情况下,解析 HTTP 请求头部块并将其分解为单独的名称-值对,然后存储在 m_request_headers 成员映射中。在解析头部块之后,依次调用 process_request() 和 send_response() 方法。
process_request() 方法的目的是读取请求中指定的作为 URI 的文件,并将其内容放入缓冲区,然后从该缓冲区发送内容到客户端,作为响应消息的一部分。如果指定的文件在服务器根目录中找不到,则将 HTTP 状态代码 404(页面未找到)作为响应消息的一部分发送给客户端,并中断请求处理。
然而,如果找到请求的文件,首先计算其大小,然后在空闲内存中分配相应大小的缓冲区,并在该缓冲区中读取文件内容。
在此之后,将一个名为 content-length 的 HTTP 头部添加到 m_response_headers 字符串数据成员中,该头部指定了响应体的尺寸。此数据成员代表响应头部块,其值将随后作为响应消息的一部分使用。
到这一点,构建 HTTP 响应消息所需的所有成分都已可用,我们可以继续准备并发送响应给客户端。这是在send_response()方法中完成的。
send_response()方法从关闭套接字的接收端开始,让客户端知道服务器将不再从它那里读取任何数据。然后,它从http_status_table静态表中提取与存储在m_response_status_code成员变量中的状态代码相对应的响应状态消息。
接下来,构建 HTTP 响应状态行,并根据 HTTP 协议将头部块附加带有分隔符号序列\r\n。到这一点,响应消息的所有组件——响应状态行、响应头部块和响应体——都已准备好发送给客户端。这些组件以缓冲区向量的形式组合,每个缓冲区由asio::const_buffer类的实例表示,并包含响应消息的一个组件。缓冲区向量体现了一个由三部分组成的复合缓冲区。当这个复合缓冲区构建时,它被传递给asio::async_write()函数以发送给客户端。Service类的on_response_sent()方法被指定为回调。
当响应消息发送并且调用on_response_sent()回调方法时,它首先检查错误代码,如果操作失败则输出日志消息;然后关闭套接字并调用on_finish()方法。on_finish()方法反过来删除在调用它的上下文中Service对象的实例。
到这一点,客户端处理已经完成。
参见
-
来自第四章的实现异步 TCP 服务器配方,实现服务器应用程序,提供了更多关于如何实现作为本配方基础的异步 TCP 服务器的信息。
-
来自第六章的使用定时器配方,其他主题,展示了如何使用 Boost.Asio 提供的定时器。定时器可以用来实现异步操作超时机制。
为客户端应用程序添加 SSL/TLS 支持
客户端应用程序通常使用 SSL/TLS 协议发送敏感数据,如密码、信用卡号码、个人信息。SSL/TLS 协议允许客户端验证服务器并加密数据。服务器的验证允许客户端确保数据将被发送到预期的接收者(而不是恶意者)。数据加密保证即使传输的数据在途中被拦截,拦截者也无法使用它。
本食谱演示了如何使用 Boost.Asio 和 OpenSSL 库实现一个支持 SSL/TLS 协议的同步 TCP 客户端应用程序。本食谱中演示的 TCP 客户端应用程序名为 实现同步 TCP 客户端,来自 第三章,实现客户端应用程序,将其作为本食谱的基础,并对它进行了某些代码更改和添加,以便添加对 SSL/TLS 协议的支持。与同步 TCP 客户端的基础实现不同的代码被 突出显示,以便与 SSL/TLS 支持相关的代码能更好地与其他代码区分开来。
准备工作…
在开始本食谱之前,必须安装 OpenSSL 库,并将项目链接到它。有关库安装或项目链接的步骤超出了本书的范围。有关更多信息,请参阅 OpenSSL 库文档。
此外,由于本食谱基于另一个名为 实现同步 TCP 客户端 的食谱,来自 第三章,实现客户端应用程序,强烈建议在继续之前熟悉它。
如何做到这一点…
以下代码示例演示了支持 SSL/TLS 协议以验证服务器并加密传输数据的同步 TCP 客户端应用程序的可能实现。
我们从添加 include 和 using 指令开始我们的应用程序:
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <iostream>
using namespace boost;
<boost/asio/ssl.hpp> 头文件包含提供与 OpenSSL 库集成的类型和函数。
接下来,我们定义一个扮演同步 SSL/TLS 启用 TCP 客户端角色的类:
class SyncSSLClient {
public:
SyncSSLClient(const std::string& raw_ip_address,
unsigned short port_num) :
m_ep(asio::ip::address::from_string(raw_ip_address),
port_num),
m_ssl_context(asio::ssl::context::sslv3_client),
m_ssl_stream(m_ios, m_ssl_context)
{
// Set verification mode and designate that
// we want to perform verification.
m_ssl_stream.set_verify_mode(asio::ssl::verify_peer);
// Set verification callback.
m_ssl_stream.set_verify_callback(this->bool{
return on_peer_verify(preverified, context);
});
}
void connect() {
// Connect the TCP socket.
m_ssl_stream.lowest_layer().connect(m_ep);
// Perform the SSL handshake.
m_ssl_stream.handshake(asio::ssl::stream_base::client);
}
void close() {
// We ignore any errors that might occur
// during shutdown as we anyway can't
// do anything about them.
boost::system::error_code ec;
m_ssl_stream.shutdown(ec); // Shutdown SSL.
// Shut down the socket.
m_ssl_stream.lowest_layer().shutdown(
boost::asio::ip::tcp::socket::shutdown_both, ec);
m_ssl_stream.lowest_layer().close(ec);
}
std::string emulate_long_computation_op(
unsigned int duration_sec) {
std::string request = "EMULATE_LONG_COMP_OP "
+ std::to_string(duration_sec)
+ "\n";
send_request(request);
return receive_response();
};
private:
bool on_peer_verify(bool preverified,
asio::ssl::verify_context& context)
{
// Here the certificate should be verified and the
// verification result should be returned.
return true;
}
void send_request(const std::string& request) {
asio::write(m_ssl_stream, asio::buffer(request));
}
std::string receive_response() {
asio::streambuf buf;
asio::read_until(m_ssl_stream, buf, '\n');
std::string response;
std::istream input(&buf);
std::getline(input, response);
return response;
}
private:
asio::io_service m_ios;
asio::ip::tcp::endpoint m_ep;
asio::ssl::context m_ssl_context;
asio::ssl::stream<asio::ip::tcp::socket>m_ssl_stream;
};
现在我们实现 main() 应用程序入口点函数,它使用 SyncSSLClient 类通过 SSL/TLS 协议验证服务器并与其安全通信:
int main()
{
const std::string raw_ip_address = "127.0.0.1";
const unsigned short port_num = 3333;
try {
SyncSSLClient client(raw_ip_address, port_num);
// Sync connect.
client.connect();
std::cout << "Sending request to the server... "
<< std::endl;
std::string response =
client.emulate_long_computation_op(10);
std::cout << "Response received: " << response
<< std::endl;
// Close the connection and free resources.
client.close();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的…
示例客户端应用程序由两个主要组件组成:SyncSSLClient 类和 main() 应用程序入口点函数,后者使用 SyncSSLClient 类通过 SSL/TLS 协议与服务器应用程序通信。让我们分别考虑每个组件的工作原理。
SyncSSLClient 类
SyncSSLClient 类是我们应用程序中的关键组件。它实现了通信功能。
该类有四个私有数据成员,如下所示:
-
asio::io_service m_ios:这是一个提供对操作系统通信服务的对象,这些服务由套接字对象使用。 -
asio::ip::tcp::endpoint m_ep:这是一个指定服务器应用程序的端点。 -
asio::ssl::context m_ssl_context:这是一个表示 SSL 上下文的对象;基本上,这是一个围绕 OpenSSL 库中定义的SSL_CTX数据结构包装的包装器。此对象包含用于通过 SSL/TLS 协议进行通信的其他对象和函数的全球设置和参数。 -
asio::ssl::stream<asio::ip::tcp::socket> m_ssl_stream:这表示一个包装 TCP 套接字对象的流,并实现了所有 SSL/TLS 通信操作。
类中的每个对象都旨在与单个服务器进行通信。因此,类的构造函数接受一个 IP 地址和一个协议端口号作为输入参数,指定服务器应用程序。这些值用于在构造函数的初始化列表中实例化m_ep数据成员。
接下来,实例化SyncSSLClient类的m_ssl_context和m_ssl_stream成员。我们将asio::ssl::context::sslv23_client值传递给m_ssl_context对象的构造函数,以指定上下文将仅由充当客户端的应用程序使用,并且我们希望支持包括 SSL 和 TLS 多个版本的多个安全协议。此由 Boost.Asio 定义的值对应于 OpenSSL 库中定义的SSLv23_client_method()函数返回的连接方法表示的值。
SSL 流对象m_ssl_stream在SyncSSLClient类的构造函数中设置。首先,将对方验证模式设置为asio::ssl::verify_peer,这意味着我们希望在握手过程中执行对方验证。然后,我们设置一个验证回调方法,该方法将在从服务器收到证书时被调用。对于服务器发送的证书链中的每个证书,回调都会被调用一次。
类的on_peer_verify()方法被设置为对方验证回调是一个虚拟的。证书验证过程超出了本书的范围。因此,该函数简单地始终返回true常量,这意味着证书验证成功,而没有执行实际的验证。
三个公共方法构成了SyncSSLClient类的接口。名为connect()的方法执行两个操作。首先,将 TCP 套接字连接到服务器。SSL 流对象底层的套接字由 SSL 流对象的lowest_layer()方法返回。然后,使用m_ep作为参数(指定要连接的端点)调用套接字上的connect()方法:
// Connect the TCP socket.
m_ssl_stream.lowest_layer().connect(m_ep);
在 TCP 连接建立后,在 SSL 流对象上调用handshake()方法,这将导致握手过程的启动。此方法是同步的,直到握手完成或发生错误才返回:
// Perform the SSL handshake.
m_ssl_stream.handshake(asio::ssl::stream_base::client);
在 handshake() 方法返回后,TCP 和 SSL(或 TLS,具体取决于握手过程中商定的协议)连接都建立,并且可以执行有效通信。
close() 方法通过在 SSL 流对象上调用 shutdown() 方法来关闭 SSL 连接。shutdown() 方法是同步的,并且会阻塞,直到 SSL 连接关闭或发生错误。在此方法返回后,相应的 SSL 流对象不能再用于传输数据。
第三个接口方法是 emulate_long_computation_op(unsigned int duration_sec)。这个方法是在这里执行 I/O 操作的地方。它从根据应用层协议准备请求字符串开始。然后,请求被传递到类的 send_request(const std::string& request) 私有方法,该方法将其发送到服务器。当请求发送并且 send_request() 方法返回时,调用 receive_response() 方法从服务器接收响应。当收到响应时,receive_response() 方法返回包含响应的字符串。之后,emulate_long_computation_op() 方法将响应消息返回给其调用者。
注意,emulate_long_computation_op()、send_request() 和 receive_response() 方法几乎与在 SyncTCPClient 类中定义的相应方法相同,SyncTCPClient 类是同步 TCP 客户端应用程序的一部分,该应用程序在第三章,实现客户端应用程序中演示,我们将其用作 SyncSSLClient 类的基础。唯一的区别是,在 SyncSSLClient 中,将 SSL 流对象 传递给相应的 Boost.Asio I/O 函数,而在 SyncTCPClient 类中,将这些函数传递给 套接字对象。提到的其他方法方面是相同的。
main() 入口点函数
此函数充当 SyncSSLClient 类的用户。在获取服务器 IP 地址和协议端口号后,它实例化并使用 SyncSSLClient 类的对象来验证并安全地与服务器通信,以使用其服务,即通过执行 10 秒的虚拟计算来模拟服务器上的操作。此函数的代码简单且易于理解;因此,不需要额外的注释。
参见
- 第三章,实现客户端应用程序中的实现同步 TCP 客户端配方提供了有关如何实现作为本配方基础的同步 TCP 客户端的更多信息。
为服务器应用程序添加 SSL/TLS 支持
当服务器提供的服务假设客户端将敏感数据(如密码、信用卡号、个人信息等)传输到服务器时,通常会在服务器应用程序中添加 SSL/TLS 协议支持。在这种情况下,向服务器添加 SSL/TLS 协议支持允许客户端验证服务器,并建立一个安全通道,以确保在传输过程中敏感数据得到保护。
有时,服务器应用程序可能想使用 SSL/TLS 协议来验证客户端;然而,这种情况很少见,通常使用其他方法来确保客户端的真实性(例如,在登录邮件服务器时指定用户名和密码)。
本菜谱演示了如何使用 Boost.Asio 和 OpenSSL 库实现一个支持 SSL/TLS 协议的同步迭代 TCP 服务器应用程序。菜谱中演示的同步迭代 TCP 服务器应用程序名为 实现同步迭代 TCP 服务器,来自 第四章,实现服务器应用程序,本菜谱以此为基础,并对它进行了一些代码更改和添加,以便添加对 SSL/TLS 协议的支持。与同步迭代 TCP 服务器的基础实现不同的代码被 突出显示,以便与代码的其他部分更好地区分开来,直接相关的代码与 SSL/TLS 支持相关。
准备工作…
在开始本菜谱之前,必须安装 OpenSSL 库,并将项目链接到它。有关库安装或项目链接的步骤超出了本书的范围。有关更多信息,请参阅官方 OpenSSL 文档。
此外,因为本菜谱基于另一个名为 实现同步迭代 TCP 服务器 的菜谱,来自 第四章,实现服务器应用程序,所以在继续之前,强烈建议熟悉它。
如何实现…
以下代码示例演示了支持 SSL/TLS 协议的同步 TCP 服务器应用程序的可能实现,允许客户端验证服务器并保护传输中的数据。
我们从包含 Boost.Asio 库头文件以及我们将需要在应用程序中实现的一些标准 C++ 库组件的头文件开始我们的应用程序:
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <thread>
#include <atomic>
#include <iostream>
using namespace boost;
<boost/asio/ssl.hpp> 头文件包含提供与 OpenSSL 库集成的类型和函数。
接下来,我们定义一个类,该类通过读取请求消息、处理它,然后发送响应消息来处理单个客户端。这个类代表服务器应用程序提供的一个单一服务,并相应地命名为 Service:
class Service {
public:
Service(){}
void handle_client(
asio::ssl::stream<asio::ip::tcp::socket>& ssl_stream)
{
try {
// Blocks until the handshake completes.
ssl_stream.handshake(
asio::ssl::stream_base::server);
asio::streambuf request;
asio::read_until(ssl_stream, request, '\n');
// Emulate request processing.
int i = 0;
while (i != 1000000)
i++;
std::this_thread::sleep_for(
std::chrono::milliseconds(500));
// Sending response.
std::string response = "Response\n";
asio::write(ssl_stream, asio::buffer(response));
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = "
<< e.code() << ". Message: "
<< e.what();
}
}
};
接下来,我们定义另一个代表高级 接受者 概念的类(与由asio::ip::tcp::acceptor类表示的低级接受者相比)。这个类负责接受来自客户端的连接请求并实例化Service类的对象,该对象将为连接的客户端提供服务。这个类被称为Acceptor:
class Acceptor {
public:
Acceptor(asio::io_service& ios, unsigned short port_num) :
m_ios(ios),
m_acceptor(m_ios,
asio::ip::tcp::endpoint(
asio::ip::address_v4::any(),
port_num)),
m_ssl_context(asio::ssl::context::sslv23_server)
{
// Setting up the context.
m_ssl_context.set_options(
boost::asio::ssl::context::default_workarounds
| boost::asio::ssl::context::no_sslv2
| boost::asio::ssl::context::single_dh_use);
m_ssl_context.set_password_callback(
this
-> std::string
{return get_password(max_length, purpose);}
);
m_ssl_context.use_certificate_chain_file("server.crt");
m_ssl_context.use_private_key_file("server.key",
boost::asio::ssl::context::pem);
m_ssl_context.use_tmp_dh_file("dhparams.pem");
// Start listening for incoming connection requests.
m_acceptor.listen();
}
void accept() {
asio::ssl::stream<asio::ip::tcp::socket>
ssl_stream(m_ios, m_ssl_context);
m_acceptor.accept(ssl_stream.lowest_layer());
Service svc;
svc.handle_client(ssl_stream);
}
private:
std::string get_password(std::size_t max_length,
asio::ssl::context::password_purpose purpose) const
{
return "pass";
}
private:
asio::io_service& m_ios;
asio::ip::tcp::acceptor m_acceptor;
asio::ssl::context m_ssl_context;
};
现在我们定义一个代表服务器本身的类。这个类被相应地命名为—Server:
class Server {
public:
Server() : m_stop(false) {}
void start(unsigned short port_num) {
m_thread.reset(new std::thread([this, port_num]() {
run(port_num);
}));
}
void stop() {
m_stop.store(true);
m_thread->join();
}
private:
void run(unsigned short port_num) {
Acceptor acc(m_ios, port_num);
while (!m_stop.load()) {
acc.accept();
}
}
std::unique_ptr<std::thread> m_thread;
std::atomic<bool> m_stop;
asio::io_service m_ios;
};
最终,我们实现了main()应用程序入口点函数,该函数演示了如何使用Server类。此函数与我们在第四章中定义的配方中的函数相同:
int main()
{
unsigned short port_num = 3333;
try {
Server srv;
srv.start(port_num);
std::this_thread::sleep_for(std::chrono::seconds(60));
srv.stop();
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = "
<< e.code() << ". Message: "
<< e.what();
}
return 0;
}
注意,服务器应用程序的最后两个组件,即Server类和main()应用程序入口点函数,与我们在第四章中定义的相应组件相同,该组件是我们为这个配方所采用的基。
它是如何工作的…
示例服务器应用程序由四个组件组成:Service、Acceptor和Server类以及main()应用程序入口点函数,它演示了如何使用Server类。由于Server类和main()入口点函数的源代码和目的与我们在第四章中定义的相应组件相同,该组件是我们为这个配方所采用的基,我们在这里将不讨论它们。我们只考虑更新以支持 SSL/TLS 协议的Service和Acceptor类。
服务类
Service类是应用程序中的关键功能组件。虽然其他组件在其目的上是基础设施性的,但这个类实现了客户端所需的实际功能(或服务)。
Service类相当简单,仅包含一个方法handle_client()。作为其输入参数,此方法接受一个表示封装了连接到特定客户端的 TCP 套接字的 SSL 流对象的引用。
方法从通过在ssl_stream对象上调用handshake()方法执行 SSL/TLS 握手开始。此方法是同步的,直到握手完成或发生错误,它不会返回。
在握手完成之后,从 SSL 流中同步读取一个请求消息,直到遇到新的换行 ASCII 符号 \n。然后,处理请求。在我们的示例应用程序中,请求处理非常简单且是模拟的,包括运行一个循环执行一百万次递增操作,然后让线程休眠半秒钟。之后,准备响应消息并发送回客户端。
Boost.Asio 函数和方法可能抛出的异常在 handle_client() 方法中被捕获和处理,不会传播到方法的调用者,这样,如果处理一个客户端失败,服务器仍然可以继续工作。
注意,handle_client() 方法与我们在本章作为此菜谱基础的 第四章 中定义的 实现一个同步迭代 TCP 服务器 菜谱中定义的相应方法非常相似。不同之处在于,在这个菜谱中,handle_client() 方法操作一个代表 SSL 流的对象,而不是在方法的基本实现中操作代表 TCP 套接字的对象。此外,在这个菜谱中定义的方法还执行了一个额外的操作——SSL/TLS 握手。
接受者类
Acceptor 类是服务器应用程序基础设施的一部分。这个类的每个对象都拥有一个名为 m_ssl_context 的 asio::ssl::context 类的实例。这个成员代表一个 SSL 上下文。基本上,asio::ssl::context 类是 OpenSSL 库中定义的 SSL_CTX 数据结构的包装器。这个类的对象包含用于 SSL/TLS 协议通信过程中其他对象和函数的全局设置和参数。
当 m_ssl_context 对象被实例化时,其构造函数传入一个 asio::ssl::context::sslv23_server 值,以指定 SSL 上下文仅由充当 服务器 角色的应用程序使用,并且应该支持多个安全协议,包括 SSL 和 TLS 的多个版本。这个由 Boost.Asio 定义的值对应于 OpenSSL 库中定义的 SSLv23_server_method() 函数返回的连接方法值。
SSL 上下文在 Acceptor 类的构造函数中进行配置。上下文选项、密码回调以及包含数字证书、私钥和 Diffie-Hellman 协议参数的文件都在那里指定。
在配置 SSL 上下文之后,在 Acceptor 类的构造函数中调用 listen() 方法,以便开始监听来自客户端的连接请求。
Acceptor 类公开了一个单一的 accept() 公共方法。当调用此方法时,首先实例化一个名为 ssl_stream 的 asio::ssl::stream<asio::ip::tcp::socket> 类的对象,代表与底层 TCP 套接字的 SSL/TLS 通信通道。然后,在 m_acceptor 接收器对象上调用 accept() 方法以接受一个连接。ssl_stream 的 lowest_layer() 方法返回的拥有 TCP 套接字的对象作为输入参数传递给 accept() 方法。当建立新的连接时,创建 Service 类的一个实例,并调用其 handle_client() 方法,该方法执行与客户端的通信和请求处理。
参见
- 来自 第四章 的 实现同步迭代 TCP 服务器 菜谱,实现服务器应用程序,提供了更多关于如何实现作为本菜谱基础的同步 TCP 服务器的信息。
第六章。其他主题
在本章中,我们将介绍以下食谱:
-
使用复合缓冲区进行散射/收集操作
-
使用定时器
-
获取和设置套接字选项
-
执行基于流的 I/O
简介
本章包含四个食谱,这些食谱与之前章节中展示的核心 Boost.Asio 概念略有不同,涵盖了大多数典型用例。但这并不意味着本章中展示的食谱不重要。相反,它们非常重要,甚至在某些特定情况下是关键的。然而,在典型的分布式应用程序中,它们的使用频率较低。
尽管大多数应用程序可能不需要散射/收集 I/O 操作和复合缓冲区,但对于某些将消息的不同部分保存在单独缓冲区中的应用程序来说,这些功能可能非常实用和方便。
Boost.Asio 定时器是一个强大的工具,允许测量时间间隔。通常,这用于为可能持续很长时间的操作设置截止日期,并在运行一定时间后未完成这些操作时中断它们。考虑到 Boost.Asio 不提供为可能长时间运行的操作指定超时的方式,这种工具对于许多分布式应用程序来说是至关重要的。此外,Boost.Asio 提供的定时器还可以用于解决与网络通信无关的其他任务。
允许获取和设置套接字选项的工具同样非常重要。在开发简单的网络应用程序时,开发者可能对套接字配备的默认选项值感到非常满意,这些选项值在套接字对象实例化时自动设置。然而,在更复杂的情况下,可能绝对有必要通过自定义选项值来重新配置套接字。
Boost.Asio 类封装了套接字并提供了一个类似流的接口,这使得我们能够创建简单而优雅的分布式应用程序。而且,简单性被认为是优秀软件的关键特征之一。
现在,让我们详细考虑所提到的主题。
使用复合缓冲区进行散射/收集操作
第二章中“使用固定长度 I/O 缓冲区”的食谱介绍了简单的 I/O 缓冲区,但只是略微触及了散射/收集操作和复合缓冲区。在本食谱中,我们将更详细地考虑这个主题。
复合缓冲区基本上是一个复杂的缓冲区,由两个或更多简单缓冲区(内存的连续块)组成,这些缓冲区分布在进程的地址空间中。这种缓冲区在两种情况下特别有用。
第一种情况是当应用程序需要缓冲区来存储在将其发送到远程应用程序之前的消息,或者接收远程应用程序发送的消息。问题是消息的大小如此之大,以至于可能由于进程地址空间碎片化而无法分配足够存储它的单个连续缓冲区。在这种情况下,分配多个较小的缓冲区,其总大小足以存储数据,并将它们组合成一个复合缓冲区是解决问题的良好方法。
另一种情况实际上是第一种情况的反转。由于应用程序设计的特定性,要发送到远程应用程序的消息被分成几个部分并存储在不同的缓冲区中,或者如果需要将接收自远程应用程序的消息分成几个部分,每个部分都应该存储在单独的缓冲区中以供进一步处理。在这两种情况下,将几个缓冲区组合成一个复合缓冲区,然后使用分散发送或收集接收操作将是解决问题的良好方法。
在这个菜谱中,我们将看到如何创建复合缓冲区并在分散/收集 I/O 操作中使用它们。
准备工作...
为了理解本菜谱中呈现的内容,熟悉第二章中“使用固定长度 I/O 缓冲区”菜谱的内容是有益的,该菜谱提供了 Boost.Asio 固定长度 I/O 缓冲区的一般概述。因此,建议在继续进行此菜谱之前熟悉“使用固定长度 I/O 缓冲区”菜谱。
如何操作...
让我们考虑两个算法和相应的代码示例,描述了如何创建和准备用于 Boost.Asio I/O 操作的复合缓冲区。第一个算法处理用于收集输出操作的复合缓冲区,第二个算法用于分散输入操作。
准备用于收集输出操作的复合缓冲区
以下是一个算法和相应的代码示例,描述了如何准备用于与套接字方法(如asio::ip::tcp::socket::send()或自由函数如asio::write())执行输出操作的复合缓冲区:
-
分配所需数量的内存缓冲区以执行当前任务。请注意,此步骤不涉及任何来自 Boost.Asio 的功能或数据类型。
-
用要输出的数据填充缓冲区。
-
创建一个满足
ConstBufferSequence或MultipleBufferSequence概念要求的类的实例,代表一个复合缓冲区。 -
将简单缓冲区添加到复合缓冲区中。每个简单缓冲区应表示为
asio::const_buffer或asio::mutable_buffer类的实例。 -
复合缓冲区已准备好与 Boost.Asio 输出函数一起使用。
假设我们想要将字符串Hello my friend!发送到远程应用程序,但我们的消息被分割成了三部分,并且每一部分都存储在单独的缓冲区中。我们可以做的是将我们的三个缓冲区表示为一个复合缓冲区,然后,在输出操作中使用它。以下是如何在以下代码中实现它的方法:
#include <boost/asio.hpp>
using namespace boost;
int main()
{
// Steps 1 and 2\. Create and fill simple buffers.
const char* part1 = "Hello ";
const char* part2 = "my ";
const char* part3 = "friend!";
// Step 3\. Create an object representing a composite buffer.
std::vector<asio::const_buffer> composite_buffer;
// Step 4\. Add simple buffers to the composite buffer.
composite_buffer.push_back(asio::const_buffer(part1, 6));
composite_buffer.push_back(asio::const_buffer(part2, 3));
composite_buffer.push_back(asio::const_buffer(part3, 7));
// Step 5\. Now composite_buffer can be used with Boost.Asio
// output operations as if it was a simple buffer represented
// by contiguous block of memory.
return 0;
}
准备用于输入操作的复合缓冲区
以下是一个算法和相应的代码示例,描述了如何准备用于socket方法(如asio::ip::tcp::socket::receive()或自由函数如asio::read())输入操作的复合缓冲区:
-
分配所需的内存缓冲区数量以执行当前任务。缓冲区大小的总和必须等于或大于预期接收到的消息的大小。请注意,此步骤不涉及任何 Boost.Asio 的功能或数据类型。
-
创建一个满足
MutableBufferSequence概念要求的类的实例,该类表示一个复合缓冲区。 -
将简单缓冲区添加到复合缓冲区中。每个简单缓冲区应表示为
asio::mutable_buffer类的实例。 -
复合缓冲区已准备好用于 Boost.Asio 输入操作。
让我们想象一个假设的情况,我们想要从服务器接收 16 字节长的消息。然而,我们没有可以容纳整个消息的缓冲区。相反,我们有三个缓冲区:6 字节、3 字节和 7 字节长。为了创建一个可以接收 16 字节数据的缓冲区,我们可以将我们的三个小缓冲区合并成一个复合缓冲区。以下是如何在以下代码中实现它的方法:
#include <boost/asio.hpp>
using namespace boost;
int main()
{
// Step 1\. Allocate simple buffers.
char part1[6];
char part2[3];
char part3[7];
// Step 2\. Create an object representing a composite buffer.
std::vector<asio::mutable_buffer> composite_buffer;
// Step 3\. Add simple buffers to the composite buffer object.
composite_buffer.push_back(asio::mutable_buffer(part1,
sizeof(part1)));
composite_buffer.push_back(asio::mutable_buffer(part2,
sizeof(part2)));
composite_buffer.push_back(asio::mutable_buffer(part3,
sizeof(part3)));
// Now composite_buffer can be used with Boost.Asio
// input operation as if it was a simple buffer
// represented by contiguous block of memory.
return 0;
}
它是如何工作的…
让我们看看第一个示例是如何工作的。它从分配三个只读缓冲区开始,这些缓冲区填充了消息字符串Hello my friend!的部分。
在下一步中,创建了一个std::vector<asio::const_buffer>类的实例,这是复合缓冲区的具体体现。这个实例被赋予了相应的名称,composite_buffer。因为std::vector<asio::const_buffer>类满足ConstBufferSequence的要求,所以它的对象可以用作复合缓冲区,并且可以作为表示数据源的参数传递给 Boost.Asio 的聚集输出函数和方法。
在第 4 步中,我们的三个缓冲区中的每一个都被表示为asio::const_buffer类的实例,并添加到复合缓冲区中。因为所有与固定大小缓冲区一起工作的 Boost.Asio 输出函数和方法都设计为也可以与复合缓冲区一起工作,所以我们的composite_buffer对象可以像简单缓冲区一样使用。
第二个示例与第一个示例非常相似。唯一的区别是,由于在这个示例中创建的复合缓冲区旨在用作数据目的地(而不是像第一个示例中的数据源),因此添加到其中的三个简单缓冲区被创建为可写缓冲区,并且在添加到复合缓冲区时表示为 asio::mutable_buffer 类的实例。
关于第二个示例的另一件事是,由于在这个示例中创建的复合缓冲区是由可变缓冲区组成的,因此它可以用于聚集输出和分散输入操作。在这个特定的示例中,初始缓冲区(part1、part2 和 part3)没有填充任何数据,它们包含垃圾数据;因此,除非它们填充了有意义的数据,否则在输出操作中使用它们是没有意义的。
参见
-
第二章中的使用固定长度 I/O 缓冲区食谱提供了有关固定大小简单缓冲区更多信息。
-
第二章 I/O 操作中的使用可扩展的流式 I/O 缓冲区食谱演示了如何使用 Boost.Asio 提供的类,代表不同类型的缓冲区——可扩展缓冲区。
使用计时器
时间是软件系统(尤其是分布式应用程序)的一个重要方面。因此,硬件计时器——一种用于测量时间间隔的设备——是任何计算机和所有现代操作系统的基本组件,所有现代操作系统都提供了允许应用程序使用它的接口。
与计时器相关的有两个典型用例。第一个用例假设应用程序想要知道当前时间,并要求操作系统找出它。第二个用例是当应用程序要求操作系统在经过一定时间后通知它(通常是通过调用回调函数)时。
当涉及到使用 Boost.Asio 开发分布式应用程序时,第二个用例尤为重要,因为计时器是实现异步操作超时机制的唯一方式。
Boost.Asio 库包含几个实现计时器的类,我们将在本食谱中考虑这些类。
如何做到这一点...
Boost.Asio 库提供了两个模板类来实现计时器。其中之一是 asio::basic_deadline_timer<>,在 Boost.Asio 1.49 版本发布之前,这是唯一可用的。在版本 1.49 中,引入了第二个计时器 asio::basic_waitable_timer<> 类模板。
asio::basic_deadline_timer<> 类模板被设计为与 Boost.Chrono 库兼容,并在内部依赖于它提供的功能。这个模板类有些过时,并且功能有限。因此,我们不会在本食谱中考虑它。
相反,一个较新的 asio::basic_waitable_timer<> 类模板,与 C++11 chrono 库兼容,更加灵活,并提供了更多功能。Boost.Asio 包括三个 typedefs,用于从 asio::basic_waitable_timer<> 模板类泛型派生的类:
typedef basic_waitable_timer< std::chrono::system_clock >
system_timer;
typedef basic_waitable_timer< std::chrono::steady_clock >
steady_timer;
typedef basic_waitable_timer< std::chrono::high_resolution_clock >
high_resolution_timer;
asio::system_timer 类基于 std::chrono::system_clock 类,它代表一个系统范围内的实时时钟。这个时钟(以及相应的计时器)会受到当前系统时间外部变化的影响。因此,当我们需要设置一个在某个绝对时间点(例如,13 小时 15 分钟 45 秒)通知我们的计时器时,asio::system_timer 类是一个好选择,考虑到计时器设置后系统时钟的偏移。然而,这个计时器在测量时间间隔(例如,从现在起 35 秒)方面并不擅长,因为系统时钟的偏移可能会导致计时器比实际间隔早或晚到期。
asio::steady_timer 类基于 std::chrono::steady_clock 类,它代表一个不受系统时钟变化影响的稳定时钟。这意味着 asio::steady_timer 是测量间隔的一个好选择。
最后一个计时器类 asio::high_resolution_timer 是基于 std::chrono::high_resolution_clock 类,它代表一个高精度系统时钟。在需要高精度时间测量的情况下可以使用它。
在使用 Boost.Asio 库实现的分布式应用程序中,计时器通常用于实现异步操作的超时周期。异步操作开始后(例如,asio::async_read()),应用程序将启动一个计时器,该计时器在一段时间后到期,即“超时周期”。当计时器到期时,应用程序检查异步操作是否已完成,如果没有完成,则认为操作超时,并将其取消。
由于稳定计时器不受系统时钟偏移的影响,它是实现超时机制的最佳选择。
注意
注意,在某些平台上,稳定的时钟不可用,代表 std::chrono::steady_clock 的相应类表现出与 std::chrono::system_clock 相同的行为,这意味着它就像后者一样,会受到系统时钟变化的影响。建议参考平台和相应的 C++标准库实现文档,以了解稳定的时钟是否真正是“稳定的”。
让我们考虑一个多少有些不切实际但具有代表性的示例应用程序,该应用程序演示了如何创建、启动和取消 Boost.Asio 计时器。在我们的示例中,我们将逐一创建和启动两个稳定的计时器。当第一个计时器到期时,我们将取消第二个计时器,在它有机会到期之前。
我们从包含必要的 Boost.Asio 头文件和 using 指令开始我们的示例应用程序:
#include <boost/asio/steady_timer.hpp>
#include <iostream>
using namespace boost;
接下来,我们定义我们应用程序中唯一的组件:main() 入口点函数:
int main()
{
就像几乎所有的非平凡 Boost.Asio 应用程序一样,我们需要一个 asio::io_service 类的实例:
asio::io_service ios;
然后,我们创建并启动第一个 t1 定时器,该定时器被设置为在 2 秒后过期:
asio::steady_timer t1(ios);
t1.expires_from_now(std::chrono::seconds(2));
然后,我们创建并启动第二个 t2 定时器,该定时器被设置为在 5 秒后过期。它应该肯定比第一个定时器晚过期:
asio::steady_timer t2(ios);
t2.expires_from_now(std::chrono::seconds(5));
现在,我们定义并设置一个回调函数,当第一个定时器过期时将被调用:
t1.async_wait(&t2 {
if (ec == 0) {
std::cout << "Timer #2 has expired!" << std::endl;
}
else if (ec == asio::error::operation_aborted) {
std::cout << "Timer #2 has been cancelled!"
<< std::endl;
}
else {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message()
<< std::endl;
}
t2.cancel();
});
然后,我们定义并设置另一个回调函数,当第二个定时器过期时将被调用:
t2.async_wait([](boost::system::error_code ec) {
if (ec == 0) {
std::cout << "Timer #2 has expired!" << std::endl;
}
else if (ec == asio::error::operation_aborted) {
std::cout << "Timer #2 has been cancelled!"
<< std::endl;
}
else {
std::cout << "Error occured! Error code = "
<< ec.value()
<< ". Message: " << ec.message()
<< std::endl;
}
});
在最后一步,我们在 asio::io_service 类的实例上调用 run() 方法:
ios.run();
return 0;
}
现在,我们的示例应用程序已经准备好了。
它是如何工作的…
现在,让我们跟踪应用程序的执行路径,以更好地理解它是如何工作的。
main() 函数从创建 asio::io_service 类的实例开始。我们需要它,因为就像套接字、接受者、解析器以及由 Boost.Asio 库定义的其他使用操作系统服务的组件一样,定时器也需要 asio::io_service 类的实例。
在下一步中,我们实例化了第一个定时器 t1,然后对其调用 expires_from_now() 方法。此方法将定时器切换到非过期状态并启动它。它接受一个表示定时器应在之后过期的时距的参数。在我们的示例中,我们传递一个表示 2 秒时距的参数,这意味着从定时器开始的那一刻起,2 秒后定时器将过期,所有等待此定时器过期事件的等待者都将被通知。
接下来,创建第二个名为 t2 的定时器,然后启动它并设置为在 5 秒后过期。
当两个定时器都启动后,我们异步等待定时器的过期事件。换句话说,我们在每个定时器上注册回调函数,这些回调函数将在相应的定时器过期时被调用。为此,我们调用定时器的 async_wait() 方法,并将回调函数的指针作为参数传递。async_wait() 方法期望其参数是一个具有以下签名的函数的指针:
void callback(
const boost::system::error_code& ec);
回调函数接受一个单个的 ec 参数,它指定了等待完成的状况。在我们的示例应用程序中,我们使用 lambda 函数作为两个定时器的过期回调。
当两个定时器的过期回调都设置好后,在 ios 对象上调用 run() 方法。该方法会阻塞,直到两个定时器都过期。在调用 run() 方法的线程上下文中,将使用该线程来调用过期回调。
当第一个计时器到期时,相应的回调函数被调用。它检查等待完成状态,并向标准输出流输出相应的消息。然后通过在t2对象上调用cancel()方法取消第二个计时器。
取消第二个计时器导致到期回调以状态码调用,通知计时器在到期之前被取消。第二个计时器的到期回调检查到期状态,并向标准输出流输出相应的消息,然后返回。
当两个回调都完成后,run()方法返回,main()函数的执行继续到末尾。这是应用程序执行完成的时候。
获取和设置套接字选项
可以通过更改其各种选项的值来配置套接字的属性及其行为。当套接字对象被实例化时,其选项具有默认值。在许多情况下,默认配置的套接字是完美的选择,而在其他情况下,可能需要通过更改其选项的值来微调套接字,以便满足应用程序的要求。
在这个配方中,我们将了解如何使用 Boost.Asio 获取和设置套接字选项。
准备工作...
此配方假设熟悉第一章中提供的内容,基础知识。
如何操作...
每个可以通过 Boost.Asio 提供的功能设置或获取其值的套接字选项都由一个单独的类表示。支持 Boost.Asio 设置或获取套接字选项的类的完整列表可以在 Boost.Asio 文档页面上找到,网址为www.boost.org/doc/libs/1_58_0/doc/html/boost_asio/reference/socket_base.html。
注意,此页面上列出的表示套接字选项的类比可以从本地套接字(底层操作系统的对象)设置或获取的选项要少。这是因为 Boost.Asio 仅支持有限数量的套接字选项。为了设置或获取其他套接字选项的值,开发者可能需要通过添加表示所需选项的类来扩展 Boost.Asio 库。然而,关于扩展 Boost.Asio 库的主题超出了本书的范围。我们将专注于如何使用库中开箱即用的套接字选项进行操作。
让我们考虑一个假设的情况,我们希望将套接字接收缓冲区的大小增加到现在的两倍。为此,我们首先需要获取缓冲区的当前大小,然后将其乘以二,最后将乘法后的值设置为新的接收缓冲区大小。
以下示例演示了如何在以下代码中执行此操作:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
try {
asio::io_service ios;
// Create and open a TCP socket.
asio::ip::tcp::socket sock(ios, asio::ip::tcp::v4());
// Create an object representing receive buffer
// size option.
asio::socket_base::receive_buffer_size cur_buf_size;
// Get the currently set value of the option.
sock.get_option(cur_buf_size);
std::cout << "Current receive buffer size is "
<< cur_buf_size.value() << " bytes."
<< std::endl;
// Create an object representing receive buffer
// size option with new value.
asio::socket_base::receive_buffer_size
new_buf_size(cur_buf_size.value() * 2);
// Set new value of the option.
sock.set_option(new_buf_size);
std::cout << "New receive buffer size is "
<< new_buf_size.value() << " bytes."
<< std::endl;
}
catch (system::system_error &e) {
std::cout << "Error occured! Error code = " << e.code()
<< ". Message: " << e.what();
return e.code().value();
}
return 0;
}
它是如何工作的...
我们的示例由一个单一组件组成:main()入口点函数。这个函数从创建一个asio::io_service类的实例开始。然后,使用这个实例创建一个代表 TCP 套接字的对象。
注意套接字类构造函数的使用,它创建并打开了套接字。在我们能够获取或设置特定套接字对象上的选项之前,相应的套接字必须被打开。这是因为,在 Boost.Asio 套接字对象打开之前,相应操作系统的底层套接字对象尚未分配,因此没有可以设置选项或从中获取选项的对象。
接下来,实例化了一个asio::socket_base::receive_buffer_size类的实例。这个类代表了一个控制套接字接收缓冲区大小的选项。为了获取该选项的当前值,需要在套接字对象上调用get_option()方法,并将选项对象的引用作为参数传递给它。
get_option()方法通过传递给它的参数类型推断出请求的选项。然后,它将相应的选项值存储在选项对象中并返回。可以通过调用表示相应选项的对象的value()方法来从对象中获取选项的值,该方法返回选项的值。
在获取接收缓冲区大小选项的当前值并将其输出到标准输出流之后,为了设置该选项的新值,main()函数继续创建一个名为new_buf_size的asio::socket_base::receive_buffer_size类的新实例。这个实例与第一个实例cur_buf_size具有相同的选项,但包含新值。新的选项值作为构造函数的参数传递给选项对象。
在构造包含新的接收缓冲区大小选项值的选项对象之后,将对该对象的引用作为参数传递给套接字的set_option()方法。与get_option()类似,这个方法通过传递给它的参数类型推断出要设置的选项,然后设置相应的选项值,使新值等于存储在选项对象中的值。
在最后一步,新的选项值被输出到标准输出流。
执行基于流的 I/O
当正确使用时,流和基于流的 I/O 的概念在表达力和优雅性方面非常强大。有时,应用程序的大部分源代码可能由基于流的 I/O 操作组成。如果通过网络通信模块通过基于流的操作实现,此类应用程序的源代码可读性和可维护性将会提高。
幸运的是,Boost.Asio 提供了允许我们以基于流的方式实现进程间通信的工具。在本例中,我们将看到如何使用它们。
如何做到这一点...
Boost.Asio 库包含 asio::ip::tcp::iostream 封装类,它为 TCP 套接字对象提供类似 I/O 流的接口,这使得我们可以用基于流的操作来表示进程间通信操作。
让我们考虑一个利用 Boost.Asio 提供的基于流的 I/O 的 TCP 客户端应用程序。当使用这种方法时,TCP 客户端应用程序变得像以下代码一样简单:
#include <boost/asio.hpp>
#include <iostream>
using namespace boost;
int main()
{
asio::ip::tcp::iostream stream("localhost", "3333");
if (!stream) {
std::cout << "Error occurred! Error code = "
<< stream.error().value()
<< ". Message = " << stream.error().message()
<< std::endl;
return -1;
}
stream << "Request.";
stream.flush();
std::cout << "Response: " << stream.rdbuf();
return 0;
}
它是如何工作的...
示例 TCP 客户端非常简单,仅由一个组件组成:main() 入口点函数。main() 函数从创建 asio::ip::tcp::iostream 类的实例开始,该实例封装了一个 TCP 套接字,并为它提供了一个类似 I/O 流的接口。
stream 对象使用接受服务器 DNS 名称和协议端口号的构造函数构建,并自动尝试解析 DNS 名称,然后尝试连接到该服务器。请注意,端口号以字符串形式表示,而不是整数。这是因为传递给此构造函数的两个参数都直接用于创建解析器查询,该查询需要端口号以字符串形式表示(它应该表示为服务名称,如 http、ftp 等,或者表示为字符串的端口号,如 "80"、"8081"、"3333" 等)。
或者,我们可以使用默认构造函数来构建 stream 对象,该构造函数不执行 DNS 名称解析和连接。然后,当对象被构建时,我们可以通过指定 DNS 名称和协议端口号来调用其上的 connect() 方法,以执行解析并连接套接字。
接下来,测试流对象的当前状态,以确定连接是否成功。如果流对象处于不良或错误状态,则将适当的消息输出到标准输出流,并退出应用程序。asio::ip::tcp::iostream 类的 error() 方法返回 boost::system::error_code 类的实例,该实例提供了关于流中发生的最后错误的详细信息。
然而,如果流已成功连接到服务器,则在其上执行输出操作,向服务器发送字符串 Request。之后,在流对象上调用 flush() 方法,以确保所有缓冲数据都推送到服务器。
在最后一步,对流执行输入操作,以读取从服务器接收的所有数据作为响应。接收到的消息输出到标准输出流。之后,main() 函数返回,应用程序退出。
还有更多...
我们不仅可以使用asio::ip::tcp::iostream类以流式方式实现客户端的 I/O,还可以在服务器端执行 I/O 操作。此外,这个类允许我们为操作指定超时时间,这使得基于流的 I/O 比正常的同步 I/O 更有优势。让我们看看这是如何实现的。
实现服务器端 I/O
以下代码片段演示了如何使用asio::ip::tcp::iostream类实现一个简单的基于流的 I/O 服务器:
// ...
asio::io_service io_service;
asio::ip::tcp::acceptor acceptor(io_service,
asio::ip::tcp::endpoint(asio::ip::tcp::v4(), 3333));
asio::ip::tcp::iostream stream;
acceptor.accept(*stream.rdbuf());
std::cout << "Request: " << stream.rdbuf();
stream << "Response.";
// ...
这个代码片段演示了一个简单服务器应用程序的源代码片段。它创建了接受者和asio::ip::tcp::iostream类的实例。然后,有趣的事情发生了。
在acceptor对象上调用accept()方法。该方法传递一个对象作为参数,该参数是通过在stream对象上调用rdbuf()方法返回的指针。stream对象的rdbuf()方法返回指向流缓冲区对象的指针。这个流缓冲区对象是一个从asio::ip::tcp::socket类继承的类的实例,这意味着asio::ip::tcp::iostream类对象使用的流缓冲区扮演两个角色:一个是流缓冲区,另一个是套接字。因此,这个“双重”流缓冲区/套接字对象可以用作正常的活动套接字来连接和与客户端应用程序通信。
当连接请求被接受并且建立了连接后,与客户端的进一步通信将以流式风格进行,就像在客户端应用程序中执行的那样,正如在之前的菜谱中所示。
设置超时间隔
因为asio::ip::tcp::stream类提供的 I/O 操作会阻塞执行线程,并且它们可能需要运行相当长的时间,所以该类提供了一种设置超时时间的方法。当超时时间耗尽时,如果当前有操作正在阻塞线程,则会中断该操作。
超时间隔可以通过asio::ip::tcp::stream类的expires_from_now()方法设置。此方法接受超时间隔的持续时间作为输入参数并启动内部计时器。如果在计时器到期时 I/O 操作仍在进行中,则该操作被视为超时,因此被强制中断。


浙公网安备 33010602011771号