Skip to content

Latest commit

 

History

History
779 lines (554 loc) · 41.1 KB

File metadata and controls

779 lines (554 loc) · 41.1 KB

七、网络编程

第 6 章管道、先进先出(FIFO)、消息队列和共享内存中,我们学习了不同的 IPC 技术,以允许在同一台机器上运行的进程相互通信。在本章中(补充了第 6 章管道、先进先出(FIFO)、消息队列和共享内存中的内容),您将了解在两台不同的计算机上运行的两个进程如何实现相同的结果。这里介绍的主题是当今互联网如何工作的基础。你将通过实践学习面向连接和面向无连接通信的区别,定义端点的特征,最后两个食谱将教你如何使用 TCP/IP 和 UDP/IP。

本章将涵盖以下主题:

  • 学习面向连接的通信基础知识
  • 学习面向无连接通信的基础知识
  • 了解什么是通信端点
  • 学习使用 TCP/IP 与另一台机器上的进程通信
  • 学习使用 UDP/IP 与另一台机器上的进程通信
  • 处理字符顺序

技术要求

为了让您立即开始使用这些程序,我们设置了一个 Docker 映像,其中包含了我们在整本书中需要的所有工具和库。它基于 Ubuntu 19.04。

要进行设置,请执行以下步骤:

  1. www.docker.com下载安装 Docker 引擎。
  2. 使用docker pull kasperondocker/system_programming_cookbook:latest从 Docker Hub 中拉出图像。
  3. 图像现在应该可以使用了。输入docker images查看图像。
  4. 你现在至少应该有kasperondocker/system_programming_cookbook了。
  5. 使用docker run -it --cap-add sys_ptrace kasperondocker/system_programming_cookbook:latest /bin/bash运行带有交互式外壳的 Docker 图像。
  6. 运行容器上的外壳现已可用。使用root@39a5a8934370/# cd /BOOK/获取所有程序,按章节列出。

需要--cap-add sys_ptrace参数来允许 Docker 容器中的 GNU 项目调试器 ( GDB )设置断点,默认情况下 Docker 是不允许的。要在同一容器上启动第二个外壳,运行docker exec -it container-name bash命令。您可以从docker ps命令中获取容器名称。

Disclaimer: The C++ 20 standard has been approved (that is, technically finalized) by WG21 in a meeting in Prague at the end of February. This means that the GCC compiler version that this book uses, 8.3.0, does not include (or has very, very limited support for) the new and cool C++ 20 features. For this reason, the Docker image does not include the C++ 20 recipe code. GCC keeps the development of the newest features in branches (you have to use appropriate flags for that, for example, -std=c++ 2a); therefore, you are encouraged to experiment with them by yourself. So, clone and explore the GCC contracts and module branches and have fun.

学习面向连接的通信基础知识

如果你坐在办公桌前浏览互联网,很可能你使用的是一种面向连接的交流方式。当您通过 HTTP 或 HTTPS 请求页面时,在实际通信发生之前,您的机器和您试图联系的服务器之间就已经建立了连接。互联网通信事实上的标准是传输控制协议 ( TCP )。在本章中,您将了解它是什么以及它为什么重要,您还将了解(在命令行上)什么是连接。

怎么做...

在本节中,我们将探索命令行的使用,以了解当我们与远程机器建立连接时会发生什么。具体来说,我们将学习 TCP/IP 连接的内部方面。让我们完成以下步骤:

  1. 在 Docker 映像运行时,打开一个外壳,键入以下命令,然后按进入:
tcpdump -x tcp port 80
  1. 打开另一个外壳,输入以下命令,按进入:
telnet amazon.com 80
  1. 在第一个 shell 中,您将看到类似如下的输出:

所有这些看起来很神秘,但实际上很简单。下一部分将向您详细解释它是如何工作的。

它是如何工作的...

面向连接的通信基于两个实体之间建立连接的假设。在这一节中,我们将探究到底什么是连接。

第一步使用tcpdump ( man tcpdump),这是一个命令行工具,可以转储网络上的所有流量。在我们的例子中,它将来自端口80的所有 TCP 流量写入标准输出,以十六进制表示显示数据。一旦按下进入,则tcpdump将切换到收听模式。

第二步使用telnet与在amazon.com的端口80上运行的远程服务建立连接。一旦按下进入,片刻后,连接将建立。

在第三步中,我们看到本地机器通过telnet(或man telnet,给它取全名)服务和位于amazon.com的远程机器(翻译成 IP)之间的连接输出。首先要记住的是,TCP 中的连接是一个三步过程,称为三次握手。客户端发送 SYN ,服务器回复 SYN+ACK ,客户端回复 ACK 。下图显示了 TCP 报头规范:

客户端和服务器在SYN|SYN+ACK|ACK阶段交换什么数据才能成功建立连接?让我们一步步来。

  1. 客户端发送 SYN 到服务器(amazon.com):

先从0xe8f40x050说起(以太网头在此之前,不在本章范围内)。从前面的 TCP 报头中我们可以看到,前两个字节代表源端口(0xe8f4 = 59636),后两个字节代表目的端口(0x0050 = 80)。在接下来的四个字节中,客户端设置一个称为序列号的随机数:0x9bd0 | 0xb114。在这种情况下,不会设置确认号。为了将此数据包标记为 SYN ,客户端必须将 SYN 位设置为1,实际上接下来两个字节的值是0xa002,二进制为1010 0000 0000 0010。我们可以看到第二位到最后一位被设置为 1(将此与 TCP 报头进行比较,如前面的截图所示)。

  1. 服务器发送 SYN+ACK 给客户端:

从客户端接收到 SYN 的服务器必须用 SYN+ACK 进行响应。去掉前 16 个字节,即以太网报头,我们可以看到以下内容:2 个字节表示源端口(0x0050 = 80),第二个 2 个字节表示目的端口(0xe8f4 = 59636)。然后我们开始看到一些有趣的事情:服务器在序列号中放一个随机数,在这个例子中是0x1afe = | 0x5e1e,在确认号中是从客户端接收的序列号+ 1 = 0x9bd0 | 0xb11**5**。我们了解到,服务器必须将标志设置为 SYN+ACK ,并且根据 TCP 报头,通过将两个字节设置为0x7012 = 0111 0000 000**1** 00**1**0来正确实现规范。突出显示的部分分别是确认同步。然后,TCP 数据包被发送回客户端。

  1. 客户端向服务器发送确认(amazon.com):

三次握手算法的最后一步是接收客户端发送给服务器的确认包。消息由两个字节组成,分别代表源端口(0xe8f4 = 59636)和目的端口(0x050=80);这次的序列号包含服务器最初从客户端接收的值0x9bd0 | 0xb115;并且确认号包含从服务器+ 1 接收的随机值:0x1afe = | 0x5e1**f**。最后通过设置0x5010 = 0101 0000 000**1** 0000值发送确认(该值高亮显示的部分为确认;将其与之前的 TCP 报头图片进行比较)。

还有更多...

到目前为止,您所学习的协议在 RFC 793(https://tools.ietf.org/html/rfc793)中有所描述。如果互联网起作用,那是因为所有的网络供应商、设备驱动程序实现和许多程序都完美地实现了这个 RFC(和其他相关标准)。TCP RFC 定义的内容比我们在本食谱中了解到的要多得多,本食谱严格侧重于连通性。它定义了流量控制(通过窗口的概念)和可靠性(通过序列号和其中的确认的概念)。

请参见

  • 学习使用 TCP/IP 与另一台机器上的进程进行通信食谱以编程方式展示了两台机器上的两个进程如何进行通信。正如我们将看到的,连接部分隐藏在系统调用中。
  • 第 3 章处理进程和线程,了解进程和线程的更新。

学习面向无连接通信的基础知识

学习面向连接的通信基础知识食谱中,我们了解到具有流量控制的面向连接的通信是可靠的。要使两个过程进行交流,我们必须先建立联系。这显然是以性能为代价的,我们不能总是为此付出代价——例如,当您观看在线电影时,可用带宽可能不足以支持 TCP 附带的所有功能。

在这种情况下,底层通信机制很可能是无连接的。用于无连接通信的事实上的标准协议是用户数据协议 ( UDP ),它与 TCP 处于同一逻辑级别。在这个食谱中,我们将学习 UDP 在命令行上的样子。

怎么做...

在本节中,我们将使用tcpdumpnetcast ( nc)来分析 UDP 上的无连接链路:

  1. 在 Docker 映像运行时,打开一个外壳,键入以下命令,然后按进入:
tcpdump -i lo udp port 45998 -X
  1. 让我们打开另一个外壳,输入以下命令,按进入:
echo -n "welcome" | nc -w 1 -u localhost 45998
  1. 在第一个 shell 中,您将看到类似如下的输出:

这看起来也很神秘,但实际上很简单。下一节将详细解释这些步骤。

它是如何工作的...

在 UDP 连接中,没有连接的概念。在这种情况下,一个数据包被发送到接收器。没有流量控制,链路不可靠。从下图中可以看出,UDP 报头确实非常简单:

步骤 1 通过打印hexASCII中每个数据包的数据,使用loopback接口(-i lo)上的UDP协议,使用tcpdump监听端口45998

步骤 2 使用netcast命令nc ( man nc)向本地主机发送包含字符串welcome的 UDP 数据包(-u)。

步骤 3 显示了 UDP 协议的详细信息。我们可以看到,源端口(发送方随机选择)为0xdb255 = 56101,目的端口正确设置为0xb3ae = 459998。接下来,我们将长度设置为0x000f = 15,校验和设置为0xfe22 = 65058。长度为15字节,因为7字节是接收数据的长度,8字节是 UDP 报头的长度(源端口+目的端口+长度+校验和)。

没有重传,没有控制流,没有连接。无连接链路实际上只是发送方发送给接收方的消息,而接收方知道它可能没有收到。

还有更多...

我们已经讨论了连接,并在 UDP 报头中看到了源端口和目的端口的概念。发送方和接收方的地址存储在其他地方,在 IP (简称互联网 协议层,逻辑上就在 UDP 层下面。IP 层包含发送方和接收方地址(IP 地址)的信息,用于将 UDP 数据包从客户端路由到服务器,反之亦然。

在 RFC 768 中,在https://www.ietf.org/rfc/rfc768.txt详细定义了 UDP。

请参见

  • 第 1 章系统编程入门,查看命令管道
  • 学习面向无连接通信的基础知识与 TCP 协议进行比较的方法

了解什么是通信端点

当两个实体相互通信时,它们基本上交换信息。为了实现这一点,每个实体都必须清楚将信息发送到哪里。从程序员的角度来看,参与通信的每个实体都必须有一个明确的端点。这个食谱将教你什么是端点,并将在命令行上显示如何识别它们。

怎么做...

在本节中,我们将使用netstat命令行实用程序来检查和了解端点是什么:

  1. 在 Docker 映像运行时,打开一个外壳,键入以下命令,然后按进入:
b07d3ef41346:/# telnet amazon.com 443
  1. 打开第二个外壳并键入以下命令:
b07d3ef41346:/# netstat -ntp

下一节将解释这两个步骤。

它是如何工作的...

步骤 1 中,我们使用telnet实用程序连接到本地机器,端口443上有amazon.com远程主机(HTTP)。该命令的输出如下:

它在等待命令,我们不会发送命令,因为我们真正关心的是连接。

步骤 2 中,我们想知道我们在本地机器(localhost)和远程主机(amazon.com端口443)之间建立的连接的细节。为此,我们在步骤 2 中执行了命令。输出如下:

我们可以从这个命令行的输出中检索到什么信息?我们可以检索到一些非常有用的信息。让我们从前面的截图中了解一下,从左到右阅读代码:

  • tcp代表连接类型。这是一种面向连接的连接,这意味着本地和远程主机经历了我们在学习面向连接的通信基础知识食谱中看到的三次握手。
  • Recv-Q是包含本地主机上当前进程要处理的数据的队列。
  • Send-Q是一个队列,包含本地主机上当前进程要发送给远程进程的数据。
  • Local Address是 IP 地址和端口号的组合,它真正代表了我们通信的第一个端点,本地端点。从编程的角度来看,这样的端点通常被称为Socket,它是一个整数,本质上代表IPPORT。在这种情况下,端点是172.17.0.2:40850
  • Foreign AddressLocal Address一样,是IPPORT的组合,代表远程端点,本例中为176.32.98.166:443。注意443是一个众所周知的端口,代表https服务。
  • State表示两个端点之间的连接状态,在本例中为ESTABLISHED
  • PID/Program Name,或者在我们的例子中,65 / telnet,代表使用两个端点与远程主机通信的本地进程。

当程序员谈论socket时,他们谈论的是通信的每个端点的IPPORT。正如我们所看到的,Linux 使得分析通信的端点和它们所连接的进程变得容易。

需要强调的一个重要方面是PORT代表一种服务。在我们的示例中,本地进程 telnet 使用端口80处的 IP 176.32.98.166与远程主机连接,我们知道一个 HTTP 守护程序正在该端口运行。但是我们如何知道特定服务的端口号呢?由 IANA (简称互联网号码分配机构)维护的知名端口列表(https://www . iana . org/assignments/service-name-port-Numbers/service-name-port-Numbers . XHTML)为服务分配PORTS。例如,HTTPS 服务预计在PORT 443运行,sftp(安全文件传输协议的简称)在PORT 22运行,等等。

还有更多...

port信息是一个 16 位无符号整数值(即unsigned int),由 IANA(https://www.iana.org/)维护,分为以下范围:

  • 0-1023:知名端口。众所周知的端口,例如 HTTP、SFTP 和 HTTPS。
  • 1024-49151:注册端口。组织可以要求注册的端口。
  • 49152-65535:动态、私有或短暂端口。免费使用。

请参见

  • 学习面向无连接通信的基础知识学习没有连接的通信是如何工作的
  • 学习面向连接的通信基础知识学习连接通信如何工作的方法
  • 学习使用 TCP/IP 与另一台机器上的进程通信学习如何开发面向连接的程序
  • 学习使用 UDP/IP 与另一台机器上的进程通信学习如何开发面向无连接的程序

学习使用 TCP/IP 与另一台机器上的进程通信

这个食谱将向您展示如何使用面向连接的机制来连接两个程序。这个食谱将使用 TCP/IP,这是互联网上事实上的标准。到目前为止,我们已经了解到 TCP/IP 是一种可靠的通信形式,它的连接分三个阶段进行。现在是时候写一个程序来学习如何让两个程序相互通信了。虽然使用的语言将是 C++,但通信部分将使用 Linux 系统调用编写,因为它不受 C++ 标准库的支持。

*# 怎么做...

我们将开发两个程序,一个客户端和一个服务器。服务器将在准备接受传入连接的特定端口上启动并listen。客户端将启动并连接到由 IP 和端口号标识的服务器:

  1. 在 Docker 映像运行的情况下,打开一个 shell 并创建一个新文件clientTCP.cpp。让我们添加一些稍后需要的标题和常量:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <iostream>

constexpr unsigned int SERVER_PORT = 50544;
constexpr unsigned int MAX_BUFFER = 128;
  1. 现在开始写main法吧。我们从初始化socket开始,获取与服务器相关的信息:
int main(int argc, char *argv[])
{
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) 
    {
        std::cerr << "socket error" << std::endl;
        return 1;
    }
    struct hostent* server = gethostbyname(argv[1]);
    if (server == nullptr) 
    {
        std::cerr << "gethostbyname, no such host" << std::endl;
        return 2;
    }
  1. 接下来,我们要connect到服务器,但是我们需要正确的信息,即serv_addr:
    struct sockaddr_in serv_addr;
    bzero((char *) &serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    bcopy((char *)server->h_addr, 
          (char *)&serv_addr.sin_addr.s_addr, 
          server->h_length);
    serv_addr.sin_port = htons(SERVER_PORT);
    if (connect(sockfd, (struct sockaddr *) &serv_addr, sizeof
        (serv_addr)) < 0)
    {
        std::cerr << "connect error" << std::endl;
        return 3;
    }
  1. 服务器会用连接ack回复,所以我们称之为read方法:
    std::string readBuffer (MAX_BUFFER, 0);
    if (read(sockfd, &readBuffer[0], MAX_BUFFER-1) < 0)
    {
        std::cerr << "read from socket failed" << std::endl;
        return 5;
    }
    std::cout << readBuffer << std::endl;
  1. 我们现在可以通过调用write系统调用将数据发送到服务器:
    std::string writeBuffer (MAX_BUFFER, 0);
    std::cout << "What message for the server? : ";
    getline(std::cin, writeBuffer);
    if (write(sockfd, writeBuffer.c_str(), strlen(write
        Buffer.c_str())) < 0) 
    {
        std::cerr << "write to socket" << std::endl;
        return 4;
    }
  1. 最后,让我们看一下清洁部分,我们必须关闭插座:
    close(sockfd);
    return 0;
}
  1. 现在让我们开发服务器程序。在第二个 shell 中,我们创建了serverTCP.cpp文件:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <iostream>
#include <arpa/inet.h>

constexpr unsigned int SERVER_PORT = 50544;
constexpr unsigned int MAX_BUFFER = 128;
constexpr unsigned int MSG_REPLY_LENGTH = 18;
  1. 在第二个 shell 中,首先,我们需要一个socket描述符来标识我们的连接:
int main(int argc, char *argv[])
{
     int sockfd =  socket(AF_INET, SOCK_STREAM, 0);
     if (sockfd < 0)
     {
          std::cerr << "open socket error" << std::endl;
          return 1;
     }

     int optval = 1;
     setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, (const
       void *)&optval , sizeof(int));
  1. 我们必须将socket绑定到本地机器上的端口和serv_addr:
     struct sockaddr_in serv_addr, cli_addr;
     bzero((char *) &serv_addr, sizeof(serv_addr));
     serv_addr.sin_family = AF_INET;
     serv_addr.sin_addr.s_addr = INADDR_ANY;
     serv_addr.sin_port = htons(SERVER_PORT);
     if (bind(sockfd, (struct sockaddr *) &serv_addr, sizeof
        (serv_addr)) < 0)
     {
          std::cerr << "bind error" << std::endl;
          return 2;
     }
  1. 接下来,我们必须等待并接受任何传入的连接:
     listen(sockfd, 5);
     socklen_t clilen = sizeof(cli_addr);
     int newsockfd = accept(sockfd, (struct sockaddr *) &cli_addr, 
         &clilen);
     if (newsockfd < 0)
     {
          std::cerr << "accept error" << std::endl;
          return 3;
     }
  1. 一旦我们获得连接,我们记录谁连接到标准输出(使用他们的 IP 和端口),并发送确认确认:
     std::cout << "server: got connection from = "
               << inet_ntoa(cli_addr.sin_addr)
               << " and port = " << ntohs(cli_addr.sin_port)
                  << std::endl;
     write(incomingSock, "You are connected!", MSG_REPLY_LENGTH);
  1. 我们建立了联系(三次握手,记得吗?),所以现在我们可以读取来自客户端的任何数据:
     std::string buffer (MAX_BUFFER, 0);
     if (read(incomingSock, &buffer[0], MAX_BUFFER-1) < 0)
     {
          std::cerr << "read from socket error" << std::endl;
          return 4;
     }
     std::cout << "Got the message:" << buffer << std::endl;
  1. 最后,我们关闭两个插座:
     close(incomingSock);
     close(sockfd);
     return 0;
}

我们已经写了相当多的代码,所以是时候解释所有这些是如何工作的了。

它是如何工作的...

客户机和服务器都有一个非常通用的算法,为了让您理解和概括这个概念,我们必须对其进行描述。客户端的算法如下:

socket() -> connect() -> send() -> receive()

这里,connect()receive()是阻塞调用(即调用程序将等待它们的完成)。connect这个短语特别启动了我们在学习面向连接的交流基础食谱中详细描述的三次握手。

服务器的算法如下:

socket() -> bind() -> listen() -> accept() -> receive() -> send()

在这里,acceptreceive正在阻止通话。现在让我们详细分析客户机和服务器的代码。

客户端代码分析如下:

  1. 第一步只包含正确使用我们在前面的客户端算法部分列出的四个 API 所需的必要包含。请注意,纯 C++ 风格的常量不是使用#define宏定义的,而是使用constexpr定义的。区别在于后者由编译器管理,而前者由预处理器管理。根据经验,您应该始终尝试依赖编译器。
  2. socket()系统调用创建了一个套接字描述符,我们将其命名为sockfd,它将用于向/从服务器发送和接收信息。这两个参数表明该套接字将是 TCP ( SOCK_STREAM )/IP ( PF_INET)套接字类型。一旦我们有了有效的套接字描述符,在调用connect方法之前,我们需要知道服务器的详细信息;为此,我们使用gethostbyname()方法,给定一个类似localhost的字符串,它将返回一个指向struct hostent *的指针,其中包含关于主机的信息。
  3. 我们现在准备调用connect()方法,它将处理三次握手过程。通过查看它的原型(man connect),我们可以看到它和套接字一样需要一个const struct sockaddr *address结构,所以我们需要将各自的信息复制到其中,并传递给connect();这就是为什么我们使用utility方法bcopy() ( bzero()只是一个在使用前重置sockaddr结构的辅助方法)。
  4. 我们现在准备发送和接收数据。一旦连接建立,服务器将发送确认消息(You are connected!)。你有没有注意到我们正在使用read()方法通过套接字从服务器接收信息?这就是在 Linux 环境中编程的美丽和简单。一个方法可以支持多个接口——事实上,我们可以用同一个方法来读取文件,用套接字接收数据,以及做其他许多事情。
  5. 我们可以向服务器发送消息。你可能已经猜到了,使用的方法是write()。我们将socket传递给它,它标识连接、我们希望服务器接收的消息以及消息的长度,以便 Linux 知道何时停止从缓冲区读取。
  6. 像往常一样,我们需要关闭、清理和释放所有使用的资源。在这种情况下,我们只需要使用close()方法关闭套接字,传递套接字描述符。

服务器代码分析如下:

  1. 我们使用与客户端类似的代码,但是包括一些头和三个定义的常数,我们将在后面使用和解释。
  2. 我们必须通过调用socket() API 来定义套接字描述符。请注意,客户端和服务器之间没有区别。我们只需要一个能够管理 TCP/IP 类型连接的套接字。
  3. 我们必须将上一步创建的套接字描述符绑定到网络接口,并将其移植到本地机器上。我们使用bind()方法来实现,该方法将一个地址(const struct sockaddr *address作为第二个参数传递)分配给作为第一个参数传递的套接字描述符。调用setsockopt()方法只是为了避免绑定错误Address already in use
  4. 我们通过调用listen()应用编程接口开始监听任何传入的连接。listen()系统调用非常简单:它获取我们正在监听的socket描述符和待处理连接队列中要保留的最大连接数,在我们的例子中,我们将其设置为5。然后我们在套接字描述符上调用accept()accept方法是一个阻塞调用:这意味着它将阻塞,直到有新的传入连接可用,然后它将返回一个表示套接字描述符的整数。cli_addr结构填充了连接的信息,我们用它来记录谁连接了(IPport)。
  5. 这一步只是第 10 步的逻辑延续。一旦服务器接受了一个连接,我们就登录到连接的标准输出(根据他们的IPport)。我们通过查询由accept方法填写在cli_addr结构中的信息来做到这一点。
  6. 在这一步中,我们通过read()系统调用从连接的客户端接收信息。我们传入输入、传入连接的套接字描述符、buffer数据将被保存的位置,以及我们想要读取的数据的最大长度(MAX_BUFFER-1)。
  7. 然后,我们清理并释放所有最终使用和/或分配的资源。在这种情况下,我们必须关闭用于服务器的两个套接字描述符(sockfd和用于传入连接的incomingSock)。

通过构建和运行服务器和客户端(按此顺序),我们得到以下输出:

  • 服务器构建和输出如下:

  • 客户端构建和输出如下:

这证明了我们在这个食谱中学到了什么。

还有更多...

我们如何改进服务器应用来管理多个并发的传入连接?我们实现的服务器算法是顺序的;在listen()之后,我们只是等待accept()直到结束,在那里我们关闭连接。作为练习,您应该完成以下步骤:

  1. accept()上运行一个无限循环,这样一个服务器就可以随时为客户服务。
  2. 为每个接受的连接分出一个新线程。可以使用std::threadstd::async来实现。

另一个重要的实践是关注客户端和服务器之间交换的数据。通常,他们同意使用他们都知道的协议。它可能是一个 web 服务器,在这种情况下,它将涉及客户端和服务器之间的 HTML、文件、资源等的交换。如果它是一个监控系统,它可能是一个由特定标准定义的协议。

请参见

  • 第 3 章处理进程和线程,刷新你对进程和线程如何工作以改进这里描述的服务器解决方案的记忆
  • 学习面向连接的通信基础知识学习 TCP 连接如何工作的方法
  • 学习什么是通信端点的诀窍是学习什么是端点以及它与套接字的关系

学习使用 UDP/IP 与另一台机器上的进程通信

当一个进程与另一个进程通信时,可靠性并不总是决定通信机制的主要标准。有时,我们需要的是快速通信,而没有负担或连接、流量控制以及 TCP 协议为使其可靠而实现的所有其他控制。视频流、互联网协议语音 ( VoIP )呼叫以及许多其他情况都是如此。在本食谱中,我们将学习如何编写 UDP 代码,使两个(或多个)进程相互通信。

怎么做...

我们将开发两个程序,一个客户端和一个服务器。服务器将启动,将套接字绑定到本地地址,然后只从客户端接收数据:

  1. 在 Docker 映像运行的情况下,打开一个 shell,创建一个新文件serverUDP.cpp,并添加一些我们稍后需要的标题和常量:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <iostream>
#include <arpa/inet.h>
constexpr unsigned int SERVER_PORT = 50544;
constexpr unsigned int MAX_BUFFER = 128;
  1. main函数中,我们必须实例化DATAGRAM 类型的套接字,并设置每次重新运行服务器时重用该地址的选项:
int main(int argc, char *argv[])
{
     int sockfd =  socket(AF_INET, SOCK_DGRAM, 0);
     if (sockfd < 0) 
     {
          std::cerr << "open socket error" << std::endl;
          return 1;
     }
     int optval = 1;
     setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, (const void 
         *)&optval , sizeof(int));
  1. 我们必须用本地地址绑定我们创建的套接字:
     struct sockaddr_in serv_addr, cli_addr;
     bzero((char *) &serv_addr, sizeof(serv_addr));
     serv_addr.sin_family = AF_INET;  
     serv_addr.sin_addr.s_addr = INADDR_ANY;  
     serv_addr.sin_port = htons(SERVER_PORT);
     if (bind(sockfd, (struct sockaddr *) &serv_addr, sizeof
        (serv_addr)) < 0)
     {
          std::cerr << "bind error" << std::endl;
          return 2;
     }
  1. 我们现在准备接收来自客户端的数据包,这次使用recvfrom应用编程接口:
     std::string buffer (MAX_BUFFER, 0);
     unsigned int len;
     if (recvfrom(sockfd, &buffer[0], 
                  MAX_BUFFER, 0, 
                  (struct sockaddr*)& cli_addr, &len) < 0)
     {
          std::cerr << "recvfrom failed" << std::endl;
          return 3;
     }
     std::cout << "Got the message:" << buffer << std::endl;
  1. 我们希望通过sendto应用编程接口向客户端发送确认消息:
     std::string outBuffer ("Message received!");
     if (sendto(sockfd, outBuffer.c_str(), 
                outBuffer.length(), 0, 
                (struct sockaddr*)& cli_addr, len) < 0)
     {
          std::cerr << "sendto failed" << std::endl;
          return 4;
     }
  1. 最后,我们可以关闭套接字:
     close(sockfd);
     return 0; 
}
  1. 现在让我们创建客户端程序。在另一个 shell 中,创建文件clientUDP.cpp:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <iostream>

constexpr unsigned int SERVER_PORT = 50544;
constexpr unsigned int MAX_BUFFER = 128;
  1. 我们必须实例化datagram类型的套接字:
int main(int argc, char *argv[])
{
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) 
    {
        std::cerr << "socket error" << std::endl;
        return 1;
    }
  1. 我们需要获取主机信息,以便能够识别我们想要将数据包发送到的服务器,我们通过调用gethostbyname API 来实现这一点:
    struct hostent* server = gethostbyname(argv[1]);
    if (server == NULL) 
    {
        std::cerr << "gethostbyname, no such host" << std::endl;
        return 2;
    }
  1. 让我们将主机信息复制到sockaddr_in结构中,以识别服务器:
    struct sockaddr_in serv_addr, cli_addr;
    bzero((char *) &serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    bcopy((char *)server->h_addr, 
          (char *)&serv_addr.sin_addr.s_addr, 
          server->h_length);
    serv_addr.sin_port = htons(SERVER_PORT);
  1. 我们最终可以使用套接字描述符、来自用户的消息和服务器地址向服务器发送消息:
    std::string outBuffer (MAX_BUFFER, 0);
    std::cout << "What message for the server? : ";
    getline(std::cin, outBuffer);
    unsigned int len = sizeof(serv_addr);
    if (sendto(sockfd, outBuffer.c_str(), MAX_BUFFER, 0, 
               (struct sockaddr *) &serv_addr, len) < 0)
    {
        std::cerr << "sendto failed" << std::endl;
        return 3;
    }
  1. 我们知道服务器会回复一个确认,所以让我们用recvfrom方法来接收它:
    std::string inBuffer (MAX_BUFFER, 0);
    unsigned int len_cli_add;
    if (recvfrom(sockfd, &inBuffer[0], MAX_BUFFER, 0, 
                 (struct sockaddr *) &cli_addr, &len_cli_add) < 0)
    {
        std::cerr << "recvfrom failed" << std::endl;
        return 4;
    }
    std::cout << inBuffer << std::endl;
  1. 最后,像往常一样,我们负责关闭和释放所有使用的结构:
    close(sockfd);
    return 0;
}

让我们深入代码,看看所有这些是如何工作的。

它是如何工作的...

学习使用 TCP/IP 与另一台机器上的进程通信食谱中,我们学习了客户端和服务器的 TCP 算法。UDP 算法更简单,如您所见,缺少连接部分:

UDP 客户端的算法:

socket() ->  sendto() -> recvfrom()

UDP 服务器的算法:

socket() -> bind() ->  recvfrom() -> sendto()

请注意它们现在有多简单——例如,在这种情况下,服务器不为和传入连接提供listen

服务器端代码分析如下:

  1. 我们刚刚定义了一些头和两个常量,它们表示服务器将公开服务的端口(SERVER_PORT)和数据的最大大小(MAX_BUFFER)。

  2. 在这一步中,我们定义了套接字(sockfd),就像我们在 TCP 代码中所做的那样,但是这次我们使用了SOCK_DGRAM (UDP)类型。为了避免Address already in use的绑定问题,我们设置了允许套接字重用地址的选项。

  3. 接下来是bind呼叫。它接受int socketconst struct sockaddr *addresssocklen_t address_len的参数,这些参数基本上是套接字、绑定套接字的地址以及地址结构的长度。在address变量中,我们指定我们正在监听所有可用的本地网络接口(INADDR_ANY),并且我们将使用互联网协议版本 4 ( AF_INET)。

  4. 我们现在可以使用recvfrom方法开始接收数据。该方法将套接字描述符(sockfd)、用于存储数据的缓冲区(buffer)、我们可以存储的最大数据大小、用于设置接收消息的特定属性的标志(在本例中为0)、数据报发送方的地址(cli_addr)以及地址长度(len)作为输入。最后两个参数被填充返回,这样我们就知道谁发送了数据报。

  5. 我们现在可以向客户端发送确认。我们使用sendto方法。由于 UDP 是无连接协议,我们没有连接客户端,所以我们需要以某种方式传递这些信息。我们通过将由recvfrom方法填写的cli_addr连同长度(len)传递给sendto方法来实现。除此之外,我们还需要传递套接字描述符(sockfd)、要发送的缓冲区(outBuffer)、缓冲区的长度(outBuffer.length())和标志(在本例中为0)。

  6. 然后,我们只需要在程序结束时进行清理。我们必须用close()方法关闭套接字描述符。

客户端代码分析如下:

  1. 在这一步中,我们找到了与在serverUDP.cpp源文件中SERVER_PORTMAX_BUFFER相同的标题。

  2. 我们必须通过调用socket方法来定义数据报类型的套接字,再次作为输入传递AF_INETSOCK_DGRAM

  3. 因为我们需要知道向谁发送数据报,所以客户端应用在命令行上将我们传递给gethostbyname的服务器地址(例如localhost)作为输入,而gethostbyname返回主机地址(server)。

  4. 我们使用server变量来填充serv_addr结构,该结构用于标识我们要将数据报发送到的服务器的地址(serv_addr.sin_addr.s_addr)、端口(serv_addr.sin_port)和协议家族(AF_INET)。

  5. 然后我们可以使用sendto方法,通过传递sockfdoutBufferMAX_BUFFER的参数、设置为0的标志、服务器的地址serv_addr及其长度(len)向服务器发送用户消息。同样,客户端在这个阶段不知道谁是消息的接收者,因为它没有连接到任何人,这就是为什么serv_addr结构必须正确填写,以便它包含有效的地址。

  6. 我们知道服务器会发回一个应用 ACK ,所以我们必须接收它。我们调用recvfrom方法,该方法将套接字描述符(sockfd)作为输入,将返回的数据存储在(buffer)中的缓冲区,我们可以获得的最大数据大小,以及设置为0的标志。recvfrom返回消息发送者的地址及其长度,我们分别存储在cli_addrlen中。

我们先运行服务器,然后运行客户端。

按照以下步骤运行服务器:

按照以下步骤运行客户端:

这显示了 UDP 是如何工作的。

还有更多...

另一种使用 UDP 协议的方式,作为一种无连接通信,是以多播或广播格式发送数据报。多播是一种用于向多个主机发送相同数据报的通信技术。代码不会改变;我们只需要设置多播组的 IP,这样它就知道将消息发送到哪里。这是一种便捷高效的一对多通信方式,节省了大量带宽。另一种选择是以广播模式发送数据报。我们要用172.30.255.255形式的子网掩码设置接收方的 IP。该消息将发送给同一子网中的所有主机。

请通过以下步骤改进服务器代码:

  1. recvfrom()上建立一个无限循环,这样你就可以随时准备好服务器来服务客户。
  2. 为每个接受的连接启动一个新线程。可以使用std::threadstd::async来实现。

请参见

  • 第 3 章处理进程和线程,刷新进程和线程如何工作来改进这里描述的服务器解决方案
  • 学习面向无连接通信的基础知识学习 UDP 连接如何工作的方法
  • 学习什么是通信端点的诀窍是学习什么是端点以及它与套接字的关系

处理字符顺序

在系统级编写代码可能意味着要处理不同的处理器架构。这样做的时候,在 C++ 20 之前,有一件事情是程序员必须自己处理的,那就是 endianness 。Endianness 指的是数字的二进制表示中的字节顺序。幸运的是,最后一个 C++ 标准帮助我们在编译时输入端序信息。这个食谱将教你如何知道字节序,并编写可以在小字节序和大字节序架构上运行的代码。

怎么做...

我们将开发一个在编译时查询机器的程序,这样我们就可以有意识地决定如何处理以不同格式表示的数字:

  1. 我们需要包含<bit>头文件;然后我们可以使用std::endian枚举:
#include <iostream>
#include <bit>

int main()
{ 
    if (std::endian::native == std::endian::big)
        // prepare the program to read/write 
        // in big endian ordering.
        std::cout << "big" << std::endl;
    else if (std::endian::native == std::endian::little)
        // prepare the program to read/write 
        // in little endian ordering.
        std::cout << "little" << std::endl; 

 return 0;
}

让我们在下一节中仔细看看这有什么影响。

它是如何工作的...

大端和小端是数据表示的两种主要类型。小端排序格式意味着最低有效字节(也称为 LSB )位于最高地址,而在大端机器中,最高有效字节(也称为 MSB )位于最低地址。十六进制值0x1234的表示示例如下:

| | 地址 | 地址+1(字节) | | 大端 | 12 | 34 | | 小端 | 34 | 12 |

步骤 1 中代码片段的主要目标是回答这个问题:我如何知道我正在处理的是什么机器架构?新的 C++ 20 枚举std::endian帮助我们完美地解决了这个问题。怎么做?嗯,首先从 T2 的端序意识来说。将std::endian作为 C++ 标准库的一部分有助于程序员随时查询底层机器的 endian 架构。第二:对于共享资源,两个程序必须就一种格式达成一致(就像 TCP 协议一样,即以网络顺序发送信息),以便阅读器(或接收器,如果通过网络交换数据)可以进行适当的转换。

另一个问题是:我该怎么办?你应该做两件事:一是与应用观点有关,二是与网络有关。在这两种情况下,如果您的应用与另一台具有不同 endian 格式的机器交换数据(交换的文件或共享的文件系统等),或者通过互联网将数据发送到具有不同体系结构的机器,那么您必须确保您的数据被理解。为此,可以使用htonntoh宏和好友;这确保了号码从主机转换到网络(对于hton)和从网络转换到主机(对于ntoh)。我们不得不提到,大多数互联网协议使用大端格式,这就是为什么,如果你从大端机器调用hton,该函数将不执行任何转换的原因。

英特尔 x86 系列和 AMD64 系列处理器均采用小端格式,而 IBM z/Architecture、飞思卡尔和所有摩托罗拉 68000 传统处理器均采用大端格式。有些处理器(如 PowerPC)可以切换字符顺序。

还有更多...

理论上,除了小端和大端之外,数据表示格式确实存在。一个例子是霍尼韦尔 316 小型计算机使用的中端格式。

请参见

  • 学习使用 TCP/IP 与另一台机器上的进程通信配方
  • 学习使用 UDP/IP 与另一台机器上的进程通信配方*