Skip to content

Latest commit

 

History

History
520 lines (336 loc) · 42.4 KB

12.md

File metadata and controls

520 lines (336 loc) · 42.4 KB

十二、网络和安全

网络编程变得越来越流行。大多数计算机都与互联网相连,现在越来越多的应用依赖于互联网。从可能需要互联网连接的简单程序更新到依赖稳定互联网连接的应用,网络编程正在成为应用开发的一个必要部分。

直到最近的标准更新,C++ 语言才支持网络。网络支持被推迟到以后的标准,很可能会推迟到 C++ 23。然而,我们可以通过处理一个网络应用来提前为发布做准备。我们还将讨论网络的标准扩展,并看看在该语言中支持网络会是什么样子。本章将集中讨论联网的主要原理和驱动设备间通信的协议。作为一名程序员,设计一个网络应用是对你技能的极大补充。

开发人员经常面临的主要问题之一是应用的安全性。无论是与正在处理的输入数据相关,还是用成熟的模式和实践进行编码,应用的安全性都必须是第一位的。这对于网络应用尤其重要。在本章中,我们还将深入研究 C++ 中安全编程的技术和最佳实践。

我们将在本章中讨论以下主题:

  • 计算机网络导论
  • C++ 中的套接字和套接字编程
  • 设计网络应用
  • 理解 C++ 程序中的安全问题
  • 在项目开发中利用安全编程技术

技术要求

g++ 编译器连同-std=c++ 2a选项将用于编译本章中的示例。

你可以在https://github.com/PacktPublishing/Expert-CPP找到本章的源文件。

用 C++ 发现网络编程

两台计算机通过网络相互作用。计算机使用一种叫做网络适配器网络接口控制器的特殊硬件组件连接到互联网。计算机上安装的操作系统提供了与网络适配器一起工作的驱动程序;也就是说,为了支持网络通信,计算机必须安装带有支持网络堆栈的操作系统的网络适配器。所谓堆栈,我们指的是数据从一台计算机传输到另一台计算机时所经历的修改层。例如,在浏览器上打开网站会呈现通过网络收集的数据。这些数据作为一系列 0 和 1 被接收,然后被转换成网络浏览器更容易理解的形式。分层在网络中至关重要。我们今天所知的网络通信由几个符合我们将在这里讨论的现场视察模型的层组成。网络接口控制器是支持开放系统互连 ( OSI )模型的物理和数据链路层的硬件组件。

现场视察模型旨在标准化各种设备之间的通信功能。设备在结构和组织上有所不同。这涉及到硬件和软件。例如,使用运行安卓操作系统的英特尔中央处理器的智能手机不同于运行苹果操作系统卡特琳娜的苹果笔记本电脑。区别不在于前述产品背后的名称和公司,而在于硬件和软件的结构和组织。为了消除网络通信中的差异,提出了一套标准化的协议和互通功能作为现场视察模型。我们前面提到的层如下:

  • 应用层

  • 表示层

  • 会话层

  • 传输层

  • 网路层

  • 数据链路层

  • 物理层

更简化的模型包括以下四层:

  • 应用:处理特定应用的细节。
  • 传输:提供两台主机之间的数据传输。
  • 网络 : 处理网络中数据包的传输。
  • 链接:这包括操作系统中的设备驱动,以及电脑内部的网络适配器。

链接(或数据链接)层包括操作系统中的设备驱动程序,以及计算机中的网络适配器。

为了理解这些层,让我们假设您正在使用桌面应用发送消息,例如 Skype电报。当您键入消息并点击发送按钮时,消息会通过网络到达目的地。在这种情况下,让我们假设您正在向您的朋友发送一条短信,该朋友的计算机上安装了相同的应用。从高层的角度来看,这似乎很简单,但是这个过程是复杂的,即使是最简单的消息在到达目的地之前也要经历很多转换。首先,当你点击发送按钮时,文本信息被转换成二进制形式。网络适配器使用二进制文件运行。它的基本功能是通过介质发送和接收二进制数据。除了通过网络发送的实际数据之外,网络适配器还应该知道数据的目的地址。目的地址是附加到用户数据的许多属性之一。我们所说的用户数据是指您键入并发送给朋友的文本。目标地址是您朋友计算机的唯一地址。键入的文本与目的地地址和发送到目标所需的其他信息打包在一起。您朋友的计算机(包括网络适配器、操作系统和消息传递应用)接收并解包数据。该包中包含的文本随后由消息传递应用呈现在屏幕上。

本章开头提到的几乎每一个现场视察层都将其特定的报头添加到通过网络发送的数据中。下图描述了来自应用层的数据在被移动到目的地之前是如何与报头堆积在一起的:

OSI model

请看上图中的第一行(第应用层)。数据是您输入消息应用以便发送给朋友的文本。在每一层中,一直到物理层,数据都封装有特定于现场视察模型每一层的报头。另一端的计算机接收并检索打包的数据。在每一层中,它会删除特定于该层的标头,并将包的其余部分向上移动到下一层。最后,数据到达你朋友的消息应用。

作为程序员,我们最关心的是编写能够通过网络发送和接收数据的应用,而无需深入研究各层的细节。然而,我们需要对层如何在更高的层次上用头来扩充数据有一个最低限度的了解。让我们了解网络应用在实践中是如何工作的。

幕后的网络应用

安装在设备上的网络应用通过网络与安装在其他设备上的其他应用通信。在本章中,我们将讨论通过互联网协同工作的应用。下图显示了这种交流的高级概述:

最底层的通信是物理层,它通过介质传输数据位。在这种情况下,一种媒介是网线(也可以考虑无线通信)。用户应用从较低层次的网络通信中抽象出来。程序员需要的一切都是由操作系统提供的。操作系统实现网络通信的底层细节,如传输控制协议 / 互联网协议 ( TCP / IP )套件。

每当应用需要访问网络时,无论是局域网还是互联网,它都会请求操作系统提供一个访问点。操作系统通过使用网络适配器和与硬件通信的特定软件来提供网络网关。

更详细的说明如下:

操作系统提供了与其网络子系统一起工作的应用编程接口。程序员应该关心的主要抽象是套接字。我们可以将套接字视为通过网络适配器发送其内容的文件。套接字是通过网络连接两台计算机的接入点,如下图所示:

从程序员的角度来看,套接字是一种允许我们在应用中通过网络实现数据的结构。套接字是发送或接收数据的连接点;也就是说,应用也通过套接字接收数据。操作系统根据请求为应用提供套接字。一个应用可以有多个套接字。客户端-服务器架构中的客户端应用通常使用单个套接字运行。现在,让我们详细研究套接字编程。

使用套接字编程网络应用

正如我们前面提到的,套接字是网络通信上的抽象。我们将它们视为常规文件——写入套接字的所有内容都由操作系统通过网络发送到目的地。通过网络接收到的所有信息都被写入套接字——同样,是由操作系统写入的。这样,操作系统为网络应用提供了双向通信。

假设我们运行两个不同的网络应用。例如,我们打开网络浏览器上网,并使用消息应用(如 Skype)与朋友聊天。网络浏览器代表客户端-服务器网络架构中的客户端应用。在这种情况下,服务器是用请求的数据进行响应的计算机。例如,我们在网络浏览器的地址栏中键入一个地址,然后在屏幕上看到结果网页。每当我们访问一个网站时,网络浏览器都会向操作系统请求一个套接字。在编码方面,网络浏览器使用操作系统提供的应用编程接口创建一个套接字。我们可以用一个更具体的前缀来描述套接字:客户端套接字。为了让服务器处理客户端请求,运行 web 服务器的计算机必须侦听传入的连接;也就是说,服务器应用创建一个用于监听连接的服务器套接字。

只要在客户机和服务器之间建立了连接,数据通信就可以继续进行。下图描述了对 facebook.com 的网络浏览器请求:

注意上图中的一组数字。这个叫互联网协议 ( IP ) 地址。IP 地址是我们向设备传输数据所需的位置。有数十亿台设备连接到互联网。为了对它们进行独特的区分,每个设备都公开一个代表其地址的唯一数值。使用 IP 协议建立连接,这就是为什么我们称之为 IP 地址。一个 IP 地址由四组 1 字节长度的数字组成。它的点分十进制表示形式是 X.X.X.X,其中 X 是 1 字节的数字。每个位置的值范围从 0 到 255。更具体地说,它是一个版本 4 的 IP 地址。现代系统使用版本 6 地址,这是数字和字母的组合,提供了更广泛的可用地址值。

创建套接字时,我们将本地计算机的 IP 地址分配给它;也就是说,我们将套接字绑定到地址。当使用套接字向网络中的另一个设备发送数据时,我们应该设置它的目的地址。目的地址由该设备上的另一个套接字持有。为了在两个设备之间建立连接,我们使用两个插座。可能会出现一个合理的问题——如果设备上运行着几个应用会怎么样?如果我们运行几个应用,每个应用都为自己创建了一个套接字,会怎么样?哪一个应该接收传入的数据?

要回答这些问题,请仔细阅读前面的图表。您应该会在 IP 地址末尾的冒号后面看到一个数字。那叫端口号。端口号是一个 2 字节长的数字,由操作系统分配给套接字。由于 2 字节的长度限制,操作系统不能为套接字分配超过 65,536 个唯一端口号;也就是说,不能有超过 65,536 个同时运行的进程或线程通过网络进行通信(但是,有重用套接字的方法)。除此之外,还有为特定应用保留的端口号。这些端口称为众所周知的端口,范围从 0 到 1023。它们是为特权服务保留的。例如,HTTP 服务器的端口号是 80。这并不意味着它不能使用其他端口。

让我们学习如何在 C++ 中创建套接字。我们将设计一个封装可移植操作系统接口 ( POSIX )插座的包装类,也称为 BerkeleyBSD 插座。它有一套标准的套接字编程函数。网络编程的 C++ 扩展将是对该语言的巨大补充。工作草案包含有关网络接口的信息。我们将在本章后面讨论这个问题。在此之前,让我们尝试为现有的和低级别的库创建我们自己的网络包装器。当我们使用 POSIX 套接字时,我们依赖于操作系统的 API。操作系统提供了一个表示用于创建套接字、发送和接收数据等的函数和对象的应用编程接口。

POSIX 将套接字表示为文件描述符。我们几乎把它当作普通文件来使用。文件描述符遵循为数据输入/输出提供公共接口的 UNIX 哲学。以下代码使用socket()函数(在<sys/socket.h>标题中定义)创建一个套接字:

int s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

socket()功能的声明如下:

int socket(int domain, int type, int protocol);

所以,AF_INETSOCK_STREAMIPPROTO_TCP都是数值。domain 参数指定套接字的协议系列。我们使用AF_INET来指定 IPv4 协议。对于 IPv6,我们使用AF_INET6。第二个参数指定套接字的类型,也就是说,它是面向流的还是数据报套接字。对于每种特定的类型,都应该相应地指定最后一个参数。在前面的例子中,我们用IPPROTO_TCP指定了SOCK_STREAM传输控制协议 ( TCP )代表了一种可靠的面向流的协议。这就是为什么我们将类型参数设置为SOCK_STREAM。在我们实现一个简单的套接字应用之前,让我们了解更多关于网络协议的信息。

网络协议

网络协议是定义应用之间相互通信的规则和数据格式的集合。例如,网络浏览器和网络服务器通过超文本传输协议 ( HTTP )进行通信。HTTP 更像是一组规则,而不是传输协议。传输协议是所有网络通信的基础。传输协议的一个例子是 TCP。当我们提到 TCP/IP 套件时,我们指的是在 IP 上实现 TCP。我们可以将互联网协议 ( IP )视为互联网通信的心脏。

它提供主机到主机的路由和寻址。我们通过互联网发送或接收的一切都被打包成一个 IP 包。以下是 IPv4 数据包的外观:

IP 报头重 20 字节。它结合了将数据包从源地址传送到目的地址所需的标志和选项。在 IP 协议领域,我们通常称一个数据包为数据报。每一层都有其特定的数据包术语。更细心的专家谈到将 TCP 段封装到 IP 数据报中。称它们为小包完全没问题。

较高级别的每个协议都将元信息附加到通过网络发送和接收的数据中;例如,TCP 数据被封装在一个 IP 数据报中。除了这些元信息,协议还定义了在两个或更多设备之间完成数据传输时应该执行的基本规则和操作。

You can find more detailed information in specific documents called **Request for Comments **(RFCs). For example, RFC 791 describes the Internet Protocol, while RFC 793 describes the Transmission Control Protocol.

许多流行的应用——文件传输、电子邮件、网络和其他——使用 TCP 作为它们的主要传输协议。例如,HTTP 协议定义了从客户端传输到服务器的消息的格式,反之亦然。实际的传输是使用传输协议进行的——在本例中是 TCP。然而,HTTP 标准并没有将 TCP 限制为唯一的传输协议。

下图说明了在将数据传递到较低级别之前附加到数据的 TCP 头:

注意源端口号和目的端口号。这些是区分操作系统中正在运行的进程的唯一标识符。另外,看看序列号和确认号。它们是特定于 TCP 的,用于传输可靠性。

实际上,使用 TCP 是因为它具有以下特点:

  • 丢失数据的重传
  • 订单交货
  • 数据完整性
  • 拥塞控制和避免

IP (互联网协议的简称)不可靠。它不关心丢失的数据包,这就是为什么 TCP 处理丢失数据包的重传。它用唯一的标识符标记每个数据包,该标识符应该由传输的另一端确认。如果发送方没有收到数据包的确认码 ( 确认),协议将重新发送数据包(次数有限)。以正确的顺序接收数据包也很重要。TCP 对收到的数据包进行重新排序,以表示正确排序的信息。这就是为什么,在网上听音乐时,我们不听歌曲开头的结尾。

数据包的重新传输可能会导致另一个被称为网络拥塞的问题。当节点无法足够快地发送数据包时,就会出现这种情况。数据包会被卡住一段时间,不必要的重传会增加它们的数量。TCP 的各种实现采用算法来避免拥塞。

它维护一个拥塞窗口,这是决定可以发送的数据量的一个因素。TCP 使用慢启动机制,在初始化连接后缓慢增加拥塞窗口。虽然协议在相应的评论请求 ( RFC )中进行了描述,但是有很多机制在操作系统中实现不同。

栅栏的另一边是用户数据报协议 ( UDP )。这两者的主要区别是 TCP 可靠。这意味着,在网络数据包丢失的情况下,它会重新发送相同的数据包,直到到达指定的目的地。由于其可靠性,通过 TCP 的数据传输被认为比使用 UDP 花费更长的时间。UDP 并不能保证我们能够正确无误地传送数据包。相反,开发人员应该注意重新发送、检查和验证数据传输。需要快速通信的应用往往依赖于 UDP。例如,视频通话应用或在线游戏使用 UDP 是因为它的速度。即使有几个包在传输过程中丢失,也不会影响用户体验。玩游戏或在视频聊天中与朋友交谈时出现小故障比等待几秒钟游戏或视频的下一帧要好。

TCP 比 UDP 慢的主要原因之一是 TCP 的连接启动过程中步骤较多。下图显示了 TCP 中连接建立的过程,也称为三次握手:

客户端向服务器发送SYN数据包时,会选择一个随机数。服务器将该随机数递增 1,选择另一个随机数,然后用SYN-ACK数据包进行回复。客户端将从服务器接收到的两个数字递增 1,并通过向服务器发送最后一个ACK来完成握手。三次握手成功完成后,客户端和服务器可以相互传输数据包。这个连接建立过程适用于每个 TCP 连接。握手的细节对网络应用的开发者是隐藏的。我们创建套接字并开始监听传入的连接。

请注意两种端点类型之间的区别。其中一个就是客户。在实现网络应用时,我们应该明确区分客户端和服务器,因为它们有不同的实现。这也与插座的类型有关。当创建服务器套接字时,我们让它监听传入的连接,而客户端不监听——它发出请求。下图描述了客户端和服务器的某些函数及其调用顺序:

当在代码中创建套接字时,我们指定套接字的协议和类型。当我们想要在两个端点之间建立可靠的连接时,我们会选择 TCP。有趣的是,我们可以使用传输协议,如 TCP,来构建我们自己的协议。假设我们定义了一种特殊的文档格式来发送和接收,以便有效地处理通信。例如,每个文档都应该以单词 PACKT 开头。HTTP 的工作原理是一样的。它使用 TCP 进行传输,并通过它定义通信格式。在 UDP 的情况下,我们还应该为通信设计和实现可靠性策略。上图显示了 TCP 如何在两个端点之间建立连接。客户端向服务器发送SYN请求。服务器用SYN-ACK响应来回答,让客户端知道继续握手没问题。最后,客户端向服务器回复一个ACK,声明连接已正式建立。他们想交流多久就交流多久。

Synchronize (SYN) and ACK are protocol-defined terms that have become common in network programming. 

UDP 不是这样工作的。它将数据发送到目的地,而不用担心已建立的连接。如果使用 UDP 但需要一定的可靠性,应该自己实现;例如,通过检查数据的一部分是否到达目的地。要检查它,您可以等待目的地用自定义的ACK数据包回答。大多数面向可靠性的实现可能会重复已经存在的协议,例如 TCP。但是,有很多场景你并不需要它们;例如,您不需要拥塞避免,因为您不需要将同一个数据包发送两次。

我们在前一章设计了一个策略游戏。假设游戏是在线的,你在和一个真正的对手玩,而不是一个自动的敌方玩家。游戏的每一帧都是基于通过网络接收的数据呈现的。如果我们努力使数据传输可靠,增加数据完整性,并确保没有数据包丢失,用户体验可能会因为玩家的不同步而受到伤害。这个场景非常适合使用 UDP。我们可以在没有重传策略的情况下实现数据传输,从而压缩游戏速度。当然,使用 UDP 并不强迫我们避免可靠性。在同样的场景中,我们可能需要确保玩家成功接收到数据包。例如,当玩家投降时,我们应该确保对手收到消息。因此,我们可以有条件的可靠性基于数据包的优先级。UDP 在网络应用中提供了灵活性和速度。

让我们看一下一个 TCP 服务器应用的实现。

设计网络应用

与完全与网络相关的应用相比,使用需要网络连接的小型子系统设计应用的方法有所不同。后者的一个例子可能是用于文件存储和同步的客户机-服务器应用(如 Dropbox)。它由服务器和客户端组成,其中客户端安装为桌面或移动应用,也可以用作文件资源管理器。Dropbox 控制的系统中文件的每次更新都会立即与服务器同步。这样,您将始终将文件保存在云中,并且可以通过互联网连接在任何地方访问它们。

我们将为文件存储和操作设计一个类似的简化服务器应用。服务器的主要任务如下:

  • 从客户端应用接收文件
  • 将文件存储在指定位置
  • 根据请求向客户端发送文件

参考第 10 章设计全球通用的应用,我们可以进入应用的以下顶层设计:

上图中的每个矩形代表一个类或与特定任务相关的类的集合。例如,存储管理器处理与存储和检索文件相关的一切。在这一点上,它是否使用文件、位置、数据库等类与我们没有多大关系。

客户端管理器是一个类或一组类,表示处理与验证或授权客户端(客户端,我们指的是客户端应用)相关的一切,保持与客户端的稳定连接,从客户端接收文件,向客户端发送文件,等等。

我们在本章中特别强调了网络作为一个感兴趣的实体。与网络连接相关的一切,以及与客户端之间的数据传输,都是通过网络处理的。现在,让我们看看我们可以使用什么功能来设计网络类(为了方便起见,我们将称之为网络管理器)。

使用 POSIX 套接字

正如我们前面提到的,像socket()bind()accept()这样的函数是大多数 Unix 系统默认支持的库函数。之前我们收录了<sys/socket.h>文件。除此之外,我们还需要其他几个头文件。让我们实现经典的 TCP 服务器示例,并将其包装在文件传输应用服务器的网络模块中。

正如我们前面提到的,服务器端开发与客户端开发的区别在于套接字的类型及其行为。虽然双方都使用套接字进行操作,但是服务器端套接字会持续监听传入的连接,而客户端套接字会启动与服务器的连接。对于等待连接的服务器套接字,我们创建一个套接字,并将其绑定到服务器的 IP 地址和客户端将尝试连接的端口号。下面的 C 代码表示 TCP 服务器套接字的创建和绑定:

int s = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(port);
server.sin_addr.s_addr = INADDR_ANY;

bind(s, (struct sockaddr*)&server, sizeof(server));

第一个调用创建一个套接字。第三个参数设置为 0,这意味着将根据套接字的类型选择默认协议。类型作为第二个参数SOCK_STREAM传递,默认情况下使协议值等于IPPROTO_TCPbind()功能将套接字与指定的 IP 地址和端口号绑定。我们在sockaddr_in结构中指定了它们,它结合了网络地址相关的细节。

Although we skipped this in the preceding code, you should consider checking the calls to socket() and bind() functions (and other functions in POSIX sockets) against errors. Almost all of them return -1 in the event of an error.

另外,注意htons()功能。它负责将其参数转换为网络字节顺序。这个问题隐藏在计算机的设计方式中。一些机器(例如英特尔微处理器)使用小端字节排序,而其他机器使用大端排序。小端排序将最低有效字节放在第一位。大端排序将最高有效字节放在第一位。下图显示了两者之间的区别:

网络字节顺序是独立于特定机器体系结构的惯例。htons()功能将提供的端口号从主机字节顺序(小-大端)转换为网络字节顺序(独立于机器)。

就这样,插座准备好了。现在,我们应该指定它已为传入连接做好准备。为此,我们使用listen()函数:

listen(s, 5);

顾名思义,它监听传入的连接。传递给listen()函数的第二个参数指定了服务器在丢弃新的传入请求之前将排队的连接数。在前面的代码中,我们将5指定为最大值。在高负载环境中,我们会增加这个数字。最大数值由<sys/socket.h>标题中定义的SOMAXCONN常数指定。

积压数量的选择(listen()功能的第二个参数)基于以下因素:

  • 如果短时间内连接请求的速率很高,积压数量应该有一个较大的值。
  • 服务器处理传入连接的持续时间。时间越短,积压值越小。

当一个连接启动正在发生时,我们可以要么放弃它,要么接受它并继续处理该连接。这就是为什么我们在下面的代码片段中使用accept()函数:

struct sockaddr_in client;
int addrlen;
int new_socket = accept(s, (struct sockaddr_in*)&client, &addrlen);
// use the new_socket

在前面的代码中需要考虑的两件事如下:

  • 首先,接受的套接字连接信息被写入客户端的sockaddr_in结构。我们可以从那个结构中收集关于客户的所有必要信息。
  • 接下来,注意accept()函数的返回值。这是一个新的套接字,用来处理来自特定客户端的请求。对accept()函数的下一次调用将返回另一个值,该值将代表具有单独连接的另一个客户端。我们应该妥善处理这个问题,因为accept()的电话不通;也就是说,它等待新的连接请求。我们将修改前面的代码,以便它接受在不同线程中处理的多个连接。

前面代码中带有注释的最后一行表示new_socket可用于接收数据或向客户端发送数据。让我们看看如何实现这一点,然后开始设计我们的Networking类。要读取套接字接收的数据,我们需要使用recv()功能,如下所示:

char buffer[BUFFER_MAX_SIZE]; // define BUFFER_MAX_SIZE based on the specifics of the server
recv(new_socket, buffer, sizeof(buffer), 0);
// now the buffer contains received data

recv()函数取一个char*缓冲区将数据写入其中。它在sizeof(buffer)停止写作。该函数的最后一个参数是我们可以设置用于读取的附加标志。您应该考虑多次调用该函数来读取大于BUFFER_MAX_SIZE的数据。

最后,为了通过套接字发送数据,我们调用send()函数,如下所示:

char msg[] = "From server with love";
send(new_socket, msg, sizeof(msg), 0);

至此,我们已经涵盖了实现服务器应用所需的几乎所有功能。现在,让我们将它们包装在一个 C++ 类中,并结合多线程,这样我们就可以同时处理客户端请求。

实现一个 POSIX 套接字包装类

让我们设计并实现一个类,作为基于网络的应用的起点。该类的主界面如下所示:

class Networking
{
public:
  void start_server();

public:
  std::shared_ptr<Networking> get_instance();
  void remove_instance();

private:
  Networking();
  ~Networking();

private:
  int socket_;
  sockaddr_in server_;
  std::vector<sockaddr_in> clients_;

private:
  static std::shared_ptr<Networking> instance_ = nullptr;
  static int MAX_QUEUED_CONNECTIONS = 1;
};

Networking类是单例是很自然的,因为我们希望单个实例监听传入的连接。拥有多个对象也很重要,每个对象代表与客户端的独立连接。让我们逐渐让班级设计变得更好。之前,我们看到每个新的客户端套接字都是在服务器套接字侦听并接受连接请求之后创建的。

之后,我们可以通过新的客户端套接字发送或接收数据。服务器的操作类似于下图所示:

也就是说,在接受每个传入的连接之后,我们将有一个单独的套接字用于连接。我们将它们存储在Networking类的clients_向量中。因此,我们可以在一个函数中编写创建服务器套接字、侦听和接受新连接的主要逻辑,如果需要,该函数可以并发工作。start_server()功能是服务器监听传入连接的起点。下面的代码块说明了这一点:

void Networking::start_server()
{
  socket_ = socket(AF_INET, SOCK_STREAM, 0);
  // the following check is the only one in this code snippet
  // we skipped checking results of other functions for brevity, 
  // you shouldn't omit them in your code
  if (socket_ < 0) { 
    throw std::exception("Cannot create a socket");
  }

  struct sockaddr_in server;
  server.sin_family = AF_INET;
  server.sin_port = htons(port);
  server.sin_addr.s_addr = INADDR_ANY;

  bind(s, (struct sockaddr*)&server, sizeof(server));
  listen(s, MAX_QUEUED_CONNECTIONS);
 // the accept() should be here
}

现在,我们已经到了应该接受传入连接的时候了(参见前面代码片段中的注释)。我们这里有两个选择(其实不止两个选择,但我们只讨论其中两个)。我们可以将对accept()的调用直接放到start_server()函数中,或者我们可以实现一个单独的函数,只要适用,Networking类用户就会调用这个函数。

It's not a bad practice to have specific exception classes for each error case that we have in the project. The preceding code might be rewritten when considering custom exceptions. You can do that as a homework project.

其中一个选项在start_server()函数中有accept()函数,它将每个新连接推入clients_向量,如下所示:

void Networking::start_server()
{
  // code omitted for brevity (see in the previous snippet)
  while (true) {
    sockaddr_in client;
    int addrlen;
    int new_socket = accept(socket_, (sockaddr_in*)&client, &addrlen);
    clients_.push_back(client);
  }
}

是的,我们使用了无限循环。这听起来可能很糟糕,但是只要服务器还在运行,它就必须接受新的连接。然而,我们都知道无限循环会阻止代码的执行;也就是说,它永远不会离开start_server()功能。我们将我们的网络应用作为一个项目进行了介绍,该项目至少有三个组件:客户端管理器、存储管理器和我们目前正在设计的组件-Networking类。

一个组件的执行不能以不好的方式影响其他组件;也就是说,我们可以使用线程使一些组件在后台运行。在线程上下文中运行的start_server()函数是一个很好的解决方案,尽管我们现在应该关心我们在第 8 章 、并发和多线程中讨论的同步问题。

另外,注意前面循环的不完全性。接受连接后,将客户端数据推入clients_向量。我们应该考虑使用另一种结构,因为我们还需要存储套接字描述符以及客户端。我们可以使用std::undordered_map将套接字描述符映射到客户端连接信息,但是简单的std::pairstd::tuple就可以了。

但是,让我们更进一步,创建一个表示客户端连接的自定义对象,如下所示:

class Client
{
public:
  // public accessors

private:
  int socket_;
  sockaddr_in connection_info_;
};

我们将修改Networking类,以便它存储一个Client对象的向量:

std::vector<Client> clients_;

现在,我们可以改变设计方法,让Client对象负责发送和接收数据:

class Client
{
public:
  void send(const std::string& data) {
    // wraps the call to POSIX send() 
  }
  std::string receive() {
    // wraps the call to POSIX recv()
  }

  // code omitted for brevity 
};

更好的是,我们可以将一个std::thread对象附加到Client类,这样每个对象都可以在一个单独的线程中处理数据传输。但是,您应该注意不要让系统挨饿。传入连接的数量可能会急剧增加,服务器应用将会停滞不前。我们将在下一节讨论安全问题时讨论这个场景。建议您利用线程池来帮助我们重用线程,并控制程序中运行的线程数量。

类的最终设计取决于我们接收和发送给客户端的数据类型。至少有两种不同的方法。其中之一是连接到客户端,接收必要的数据,然后关闭连接。第二种方法是实现客户端和服务器通信的协议。虽然听起来很复杂,但协议可能很简单。

它还具有可扩展性,使应用更加健壮,因为随着项目的发展,您可以支持更多的功能。在下一节中,当我们讨论如何保护网络服务器应用时,我们将回到设计用于验证客户端请求的协议。

保护 C++ 代码

与许多语言相比,C++ 在安全编码方面有点难掌握。有很多指导方针提供了如何以及如何避免 C++ 程序中的安全风险的建议。我们在第 1 章构建 C++ 应用中讨论的最受欢迎的问题之一是使用预处理器宏。我们使用的示例包含以下宏:

#define DOUBLE_IT(arg) (arg * arg)

这个宏使用不当会导致难以发现的逻辑错误。在下面的代码中,程序员期望将16打印到屏幕上:

int res = DOUBLE_IT(3 + 1);
std::cout << res << std::endl;

输出为7。这里的问题是arg参数周围缺少括号;也就是说,前面的宏应该重写如下:

#define DOUBLE_IT(arg) ((arg) * (arg))

虽然这个例子很流行,但我们强烈建议尽可能避免使用宏。C++ 提供了大量可以在编译时处理的构造,例如constexprconstevalconstinit——即使语句有constexpr替代。如果需要在代码中进行编译时处理,请使用它们。当然,还有模块,期待已久的语言补充。你应该更喜欢在任何地方使用带有无处不在的防护装置的模块#include:

module my_module;
export int test;

// instead of

#ifndef MY_HEADER_H
#define MY_HEADER_H
int test
#endif 

它不仅更安全,而且更高效,因为模块只处理一次(我们可以将它们视为预编译头)。

尽管我们不希望您因为安全问题而变得偏执,但您几乎应该处处小心。通过学习这种语言的怪癖和古怪之处,你可以避免这些问题中的大部分。此外,一个好的做法是使用最新的功能来替换或修复以前版本的缺点。例如,考虑以下create_array()函数:

// Don't return pointers or references to local variables
double* create_array()
{
  double arr[10] = {0.0};
  return arr;
}

create_array()函数的调用者留下一个指向不存在的数组的指针,因为arr有一个自动存储持续时间。如果需要,我们可以用更好的替代代码替换前面的代码:

#include <array>

std::array<double> create_array()
{
  std::array<double> arr;
  return arr;
}

字符串被视为字符数组,是许多缓冲区溢出问题背后的原因。最常见的问题之一是将数据写入字符串缓冲区,而忽略其大小。在这方面,std::string类是 C 字符串的更安全的替代品。但是,在支持遗留代码时,使用strcpy()等函数时要小心,如下例所示:

#include <cstdio>
#include <cstring>

int main()
{
  char small_buffer[4];
  const char* long_text = "This text is long enough to overflow small buffers!";
 strcpy(small_buffer, long_text);
}

考虑到在法律上,small_buffer应该在末尾有一个空终止符,它将只处理long_text字符串的前三个字符。然而,打电话给strcpy()后出现了以下情况:

在实现网络应用时,您应该更加小心。来自客户端连接的大部分数据应该被正确处理,缓冲区溢出并不罕见。让我们学习如何使网络应用更加安全。

保护网络应用

在本书的前一部分,我们设计了一个使用套接字连接接收客户端数据的网络应用。除了大多数侵入系统的病毒来自外部世界这一事实之外,网络应用也有这种自然的趋势,即让计算机面对互联网上的各种威胁。首先,无论何时运行网络应用,系统中都存在一个开放端口。知道您的应用正在监听的确切端口的人可以通过伪造协议数据来入侵。我们将在这里主要讨论网络应用的服务器端;但是,这里的一些主题也适用于客户端应用。

您应该做的第一件事是合并客户端授权和身份验证。这是两个容易混淆的术语。注意不要互换使用;它们是不同的:

  • 认证是验证客户端访问的过程。这意味着不是每个传入的连接请求都会立即得到服务。在与客户端之间传输数据之前,服务器应用必须确保客户端是已知的客户端。就像我们通过输入电子邮件和密码来访问社交网络平台一样,客户端的身份验证定义了客户端是否有权访问系统。

  • 授权则恰恰定义了客户端在系统中可以做什么。它是提供给特定客户端的一组权限。例如,我们在前一节中讨论的客户端应用能够将文件上传到系统。迟早,你可能想要合并付费订阅,并为付费客户提供更广泛的功能;例如,允许他们创建文件夹来组织文件。因此,当客户端请求创建文件夹时,我们可能希望授权该请求来发现客户端是否有权这样做。

当客户端应用启动与服务器的连接时,服务器得到的只是连接细节(IP 地址、端口号)。为了让服务器知道谁是客户端应用的幕后黑手(实际用户),客户端应用会发送用户的凭据。通常,此过程包括向用户发送唯一标识符(如用户名或电子邮件地址)以及访问系统的密码。然后,服务器根据其数据库检查这些凭据,并验证是否应该授予客户端访问权限。客户端和服务器之间的这种通信形式可能是简单的文本传输或格式化的对象传输。

例如,服务器定义的协议可能要求客户端以下列形式发送一个 JavaScript 对象符号 ( JSON )文档:

{
  "email": "myemail@example.org",
  "password": "notSoSIMPLEp4s8"
}

来自服务器的响应允许客户端继续或更新其用户界面,让用户知道操作的结果。在登录时使用任何 web 或网络应用时,您可能会遇到几种情况。例如,输入错误的密码可能会导致服务器返回Invalid username or password错误。

除了这第一个必要步骤之外,验证来自客户端应用的每一条数据也是明智的。如果检查电子邮件字段的大小,可能很容易避免缓冲区溢出。例如,当客户端应用有意试图破坏系统时,可能会发送一个值非常大的 JSON 对象。那张支票由服务器承担。防止安全缺陷从数据验证开始。

另一种形式的安全攻击是每秒钟从单个或多个客户端发出太多请求。例如,一个客户端应用在 1 秒钟内发出数百个身份验证请求,会导致服务器集中处理这些请求,并浪费资源来尝试为所有请求提供服务。最好检查客户端请求的速率,例如,将它们限制为每秒一个请求。

这些形式的攻击(有意或无意)被称为拒绝服务 ( DOS )攻击。更高级的 DOS 攻击形式是从多个客户端向服务器发出大量请求。这种形式叫做分布式拒绝服务 ( 分布式拒绝服务)攻击。一个简单的方法可能是将试图通过每秒发出多个请求来使系统崩溃的 IP 地址列入黑名单。作为网络应用的程序员,在开发应用时,您应该考虑这里描述的所有问题以及本书范围之外的许多其他问题。

摘要

在这一章中,我们介绍了用 C++ 设计网络应用。从第一个版本开始,C++ 就缺乏对网络的内置支持。C++ 23 标准计划最终将其引入该语言。

我们首先介绍了网络的基础知识。完全理解网络需要很多时间,但是在以任何与网络相关的方式实现应用之前,每个程序员都必须知道几个基本概念。这些基本概念包括现场视察模型中的分层和不同类型的传输协议,如 TCP 和 UDP。对任何程序员来说,了解 TCP 和 UDP 之间的差异都是必要的。正如我们所知,TCP 在套接字之间建立了可靠的连接,套接字是程序员在开发网络应用时遇到的下一件事。这是两个应用实例的连接点。每当我们需要通过网络发送或接收数据时,我们应该定义一个套接字,并像处理常规文件一样处理它。

我们在应用开发中使用的所有抽象和概念都由操作系统处理,最终由网络适配器处理。这是一种能够通过网络介质发送数据的设备。从媒体接收数据并不能保证安全。网络适配器接收来自介质的任何信息。为了确保我们正确处理传入的数据,我们还应该注意应用的安全性。本章的最后一节是关于编写安全代码和验证输入,以确保不会对程序造成伤害。保护您的程序是确保程序高质量的一个很好的步骤。开发程序的最好方法之一是彻底测试它们。你可能还记得,在第 10 章设计世界就绪应用中,我们讨论了软件开发步骤,并解释了最重要的步骤之一解释了一旦编码阶段完成,测试程序。测试之后,你很可能会发现很多 bug。其中一些 bug 很难重现和修复,这就是调试的作用。

下一章是关于用正确的方法测试和调试你的程序。

问题

  1. 列出现场视察模型的所有七层。
  2. 端口号有什么意义?
  3. 为什么要在网络应用中使用套接字?
  4. 描述应该在服务器端执行的操作序列,以便使用 TCP 套接字接收数据。
  5. TCP 和 UDP 有什么区别?
  6. 为什么不应该在代码中使用宏定义?
  7. 在实现服务器应用时,您如何区分不同的客户端应用?

进一步阅读