1. 现在我们已经了解了一些重要的网络应用程序,让我们来探讨一下网络应用程序是如何实际创建的。 回想一下 2.1 节,典型的网络应用程序由位于两个不同端系统中的一对程序组成——一个客户端程序和一个服务器程序。 执行这两个程序时,会创建一个客户端进程和一个服务器进程,这些进程通过读取和写入套接字来相互通信。 因此,在创建网络应用程序时,开发人员的主要任务是为客户端和服务器程序编写代码。<br />有两种类型的网络应用程序。一种类型是其操作在协议标准中指定的实现,例如 RFC 或其他一些标准文档;这种应用程序有时被称为“开放(open)”,因为指定其操作的规则是众所周知的。对于这样的实现,客户端和服务器程序必须符合 RFC 规定的规则。例如,客户端程序可以是 HTTP 协议客户端的一个实现,在 2.2 节中描述并在 RFC 2616 中精确定义;类似地,服务器程序可以是 HTTP 服务器协议的一个实现,在 RFC 2616 中也有精确定义。 如果一个开发人员为客户端程序编写代码,另一个开发人员为服务器程序编写代码,并且两个开发人员都仔细遵循规则RFC的话,两个程序就能互通了。事实上,当今的许多网络应用程序都涉及由独立开发人员创建的客户端和服务器程序之间的通信——例如,谷歌 Chrome 浏览器与 Apache Web 服务器通信,或 BitTorrent 客户端与 BitTorrent 跟踪器通信。<br />另一种类型的网络应用程序是专有网络应用程序(proprietary network application)。 在这种情况下,客户端和服务器程序使用尚未在 RFC 或其他地方公开发布的应用层协议。 单个开发人员(或开发团队)创建客户端和服务器程序,开发人员可以完全控制代码中的内容。 但是由于代码没有实现开放协议,其他独立开发人员将无法开发与应用程序互操作的代码。<br />在本节中,我们将研究开发客户端 - 服务器应用程序的关键问题,我们将通过查看实现非常简单的客户端 - 服务器应用程序的代码来“动手”。在开发阶段,开发人员必须做出的首要决定之一是应用程序是通过 TCP 还是通过 UDP 运行。回想一下,TCP 是面向连接的,它提供了一个可靠的字节流通道(byte-stream channel),数据通过它在两个终端系统之间流动。 UDP 是无连接的,将独立的数据包从一个终端系统发送到另一个终端系统,对传输没有任何保证。还记得当客户端或服务器程序实现由 RFC 定义的协议时,它应该使用与该协议关联的众所周知的端口号;相反,在开发专有应用程序时,开发人员必须小心避免使用此类众所周知的端口号。 (第 2.1 节简要讨论了端口号。第 3 章更详细地介绍了它们。)<br />我们通过一个简单的UDP应用程序和一个简单的TCP应用程序来介绍UDPTCP套接字编程。 我们在 Python 3 中展示了简单的 UDP TCP 应用程序。我们可以用 JavaC C++ 编写代码,但我们选择 Python 主要是因为 Python 清楚地公开了关键的套接字概念。 使用 Python 的代码行更少,每一行都可以毫无困难地向新手程序员解释。 但是,如果您不熟悉 Python,则无需害怕。 如果您有 JavaC C++ 编程经验,您应该能够轻松地理解代码。<br />如果您对使用 Java 进行客户端-服务器编程感兴趣,我们鼓励您查看本教科书的配套网站; 事实上,您可以在 Java 中找到本节(和相关实验)中的所有示例。 对于对 C 中的客户端-服务器编程感兴趣的读者,有几个很好的参考资料 [Donahoo 2001; 史蒂文斯 1997 年; 弗罗斯特 1994]; 我们下面的 Python 示例与 C 具有相似的外观和感觉。

2.7.1 用UDP套接字编程 Socket Programming with UDP

在本小节中,我们将编写使用 UDP 的简单客户端-服务器程序; 在下一节中,我们将编写使用 TCP 的类似程序。
回忆 2.1 节,运行在不同机器上的进程通过向套接字发送消息来相互通信。 我们说过,每个进程类似于一所房子,进程的套接字类似于一扇门。 应用程序位于房屋门的一侧; 传输层协议驻留在外面世界的门的另一边。 应用程序开发人员可以控制套接字应用层一侧的一切; 然而,它对传输层侧几乎没有控制。
现在让我们仔细看看使用 UDP 套接字的两个通信进程之间的交互。 在发送进程可以将数据包推出套接字门之前,使用 UDP 时,它必须首先将目标地址附加到数据包中。 数据包通过发送方的套接字后,Internet会在接收过程中使用这个目的地址将数据包通过Internet路由到套接字。 当数据包到达接收套接字时,接收进程将通过套接字检索数据包,然后检查数据包的内容并采取适当的行动。
所以您现在可能想知道,附加到数据包的目标地址是什么? 正如您所料,目标主机的 IP 地址是目标地址的一部分。 通过在数据包中包含目标 IP 地址,Internet 中的路由器将能够通过 Internet 将数据包路由到目标主机。 但是因为一台主机可能正在运行许多网络应用程序进程,每个进程都有一个或多个套接字,所以还需要识别目标主机中的特定套接字。 创建套接字时,会为其分配一个称为端口号(port number)的标识符。 因此,如您所料,数据包的目标地址还包括套接字的端口号。 总之,发送进程将一个目标地址附加到数据包上,该地址由目标主机的 IP 地址和目标套接字的端口号组成。
此外,我们很快就会看到,发送方的源地址——由源主机的 IP 地址和源套接字的端口号组成——也附加到数据包中。 但是,将源地址附加到数据包通常不是由 UDP 应用程序代码完成的。 相反,它由底层操作系统自动完成。
我们将使用以下简单的客户端-服务器应用程序来演示 UDP 和 TCP 的套接字编程:

  1. 客户端从键盘中读取一行字符(数据)并将数据发送到服务器。
  2. 服务器接收数据并将字符转换为大写。
  3. 服务器将修改后的数据发送给客户端。
  4. 客户端接收修改后的数据并在其屏幕上显示该行。

图 2.27 突出显示了通过 UDP 传输服务进行通信的客户端和服务器的主要套接字相关活动。
image.png
Figure 2.27 ♦ The client-server application using UDP
现在让我们动手看看这个简单应用程序的 UDP 实现的客户端-服务器程序对。 我们还在每个程序之后提供详细的逐行分析。 我们将从 UDP 客户端开始,它将向服务器发送一条简单的应用程序级报文(application-level message)。 为了让服务器能够接收和应答客户端的报文,它必须准备好并运行——也就是说,它必须在客户端发送报文之前作为一个进程运行。
客户端程序称为UDPClient.py,服务器程序称为UDPServer.py。 为了强调关键问题,我们特意提供了最少的代码。 “好代码”肯定会有更多的辅助行,特别是用于处理错误情况。 对于这个应用,我们任意选择了
12000 作为服务器端口号。

UDPClient.py

下面是应用程序的客户端代码:

  1. from socket import *
  2. serverName = 'hostname'
  3. serverPort = 12000
  4. clientSocket = socket(AF_INET, SOCK_DGRAM)
  5. message = input('Input lowercase sentence:')
  6. clientSocket.sendto(message.encode(),(serverName, serverPort))
  7. modifiedMessage, serverAddress = clientSocket.recvfrom(2048)
  8. print(modifiedMessage.decode())
  9. clientSocket.close()
  1. 现在让我们看看 UDPClient.py 中的各行代码。
  1. from socket import *

socket 模块构成了 Python 中所有网络通信的基础。 通过包含这一行,我们将能够在我们的程序中创建套接字。

  1. serverName = 'hostname'
  2. serverPort = 12000
  1. 第一行将变量 serverName 设置为字符串“hostname”。 在这里,我们提供一个字符串,其中包含服务器的 IP 地址(例如“128.138.32.126”)或服务器的主机名(例如“cis.poly.edu”)。 如果我们使用主机名,则将自动执行 DNS 查找以获取 IP 地址。)第二行将整数变量 serverPort 设置为 12000
  1. clientSocket = socket(AF_INET, SOCK_DGRAM)

这一行创建了客户端的套接字,称为 clientSocket。 第一个参数表示地址族(family); 特别是,AF_INET 表示底层网络正在使用 IPv4。 (现在不用担心——我们将在第 4 章讨论 IPv4。)第二个参数表示套接字的类型为 SOCK_DGRAM,这意味着它是一个 UDP 套接字(而不是 TCP 套接字)。 请注意,我们在创建时没有指定客户端套接字的端口号; 相反,我们让操作系统为我们做这件事。 既然已经创建了客户端进程的门,我们将要创建一条报文通过门发送。

  1. message = input('Input lowercase sentence:')

input() 是 Python 中的内置函数。 执行此命令时,客户端的用户被提示“Input lowercase sentence:”,然后用户使用键盘输入一行,并将其放入变量 message 中。 现在我们有一个套接字和一条消息,我们将希望通过套接字将消息发送到目标主机。

  1. clientSocket.sendto(message.encode(), (serverName, serverPort))
  1. 在上面这行中,我们首先将消息从字符串类型转换为字节类型(byte type),因为我们需要将字节发送到套接字中; 这是通过 encode() 方法完成的。 sendto() 方法将目标地址(serverNameserverPort)附加到报文中,并将生成的数据包(packet)发送到进程的套接字 clientSocket (如前所述,源地址也附加到数据包上,尽管这是自动完成的,而不是由代码明确完成的。)通过 UDP 套接字发送客户端到服务器的报文就是这么简单! 发送数据包后,客户端等待从服务器接收数据。
  1. modifiedMessage, serverAddress = clientSocket.recvfrom(2048)
  1. 通过上面的代码,当一个数据包从 Internet 到达客户端的套接字时,数据包的数据被放入变量 modifiedMessage 中,数据包的源地址被放入变量 serverAddress 中。 变量 serverAddress 包含服务器的 IP 地址和服务器的端口号。 UDPClient 程序实际上并不需要这个服务器地址信息,因为它从一开始就知道服务器地址; 但是这行 Python 仍然提供了服务器地址。 recvfrom 方法也将缓冲区大小 2048 作为输入。 (此缓冲区大小适用于大多数用途。)
  1. print(modifiedMessage.decode())

在将消息从字节(bytes)转换为字符串(string)后,该行在用户的显示器上打印出 modifiedMessage。 它应该是用户输入的原始行,但现在大写。

  1. clientSocket.close()
  1. 此行关闭套接字。 然后该过程终止。

UDPServer.py

现在让我们看看应用程序的服务器端:

  1. from socket import *
  2. serverPort = 12000
  3. serverSocket = socket(AF_INET, SOCK_DGRAM)
  4. serverSocket.bind(('', serverPort))
  5. print("The server is ready to receive")
  6. while True:
  7. message, clientAddress = serverSocket.recvfrom(2048)
  8. modifiedMessage = message.decode().upper()
  9. serverSocket.sendto(modifiedMessage.encode(),
  10. clientAddress)
  1. 注意UDPServer的开头和UDPClient类似。 它还导入套接字模块,还将整数变量 serverPort 设置为 12000,并创建一个 SOCK_DGRAM 类型的套接字(UDP 套接字)。 UDPClient明显不同的第一行代码是:
  1. serverSocket.bind(('', serverPort))
  1. 上面的行将端口号 12000 绑定(即分配)到服务器的套接字。 因此,在 UDPServer 中,代码(由应用程序开发人员编写)明确地为套接字分配了一个端口号。 以这种方式,当任何人向服务器 IP 地址的端口 12000 发送数据包时,该数据包将被定向到此套接字。 UDPServer然后进入while循环; while 循环将允许 UDPServer 无限期地接收和处理来自客户端的数据包。 while 循环中,UDPServer 等待数据包到达。
  1. message, clientAddress = serverSocket.recvfrom(2048)
  1. 这行代码类似于我们在 UDPClient 中看到的。 当数据包到达服务器的套接字时,数据包的数据被放入变量 message 中,数据包的源地址被放入变量 clientAddress 中。 变量 clientAddress 包含客户端的 IP 地址和客户端的端口号。 在这里,UDPServer 会利用这个地址信息,因为它提供了一个回邮地址(return address),类似于普通邮政邮件的回邮地址。 有了这个源地址信息,服务器现在知道它应该把它的应答定向到哪里。
  1. modifiedMessage = message.decode().upper()
  1. 该行是我们简单应用程序的核心。 它获取客户端发送的行,并在将 message 转换为字符串后,使用 upper() 方法将其大写。
  1. serverSocket.sendto(modifiedMessage.encode(), clientAddress)
  1. 最后一行将客户端的地址(IP 地址和端口号)附加到大写消息(将字符串转换为字节之后),并将结果数据包发送到服务器的套接字。 (如前所述,**服务器地址也附加到数据包上,尽管这是自动完成的,而不是由代码明确完成的。**)然后 Internet 会将数据包传送到该客户端地址。 服务器发送数据包后,它保持在 while 循环中,等待另一个 UDP 数据包到达(来自任何主机上运行的任何客户端)。<br />为了测试这对程序,您在一个主机上运行 UDPClient.py,在另一台主机上运行 UDPServer.py 确保在 UDPClient.py 中包含服务器的正确主机名或 IP 地址。 接下来,在服务器主机中执行 UDPServer.py,编译的服务器程序。 这会在服务器中创建一个进程,该进程在被某个客户端联系之前一直处于空闲状态。 然后在客户端执行 UDPClient.py,编译好的客户端程序。 这会在客户端中创建一个进程。 最后,要在客户端使用该应用程序,您可以键入一个句子,后跟一个回车符(carriage return)。<br />要开发您自己的 UDP 客户端-服务器应用程序,您可以先稍微修改客户端或服务器程序。 例如,不是将所有字母都转换为大写,服务器可以计算字母 s 出现的次数并返回该数字。 或者你可以修改客户端,让用户在收到大写的句子后,可以继续向服务器发送更多的句子。

2.7.2 用TCP套接字编程 Socket Programming with TCP

与 UDP 不同,TCP 是面向连接的协议(connection-oriented protocol)。 这意味着在客户端和服务器可以开始相互发送数据之前,它们首先需要握手并建立 TCP 连接。 TCP 连接的一端连接到客户端套接字,另一端连接到服务器套接字。 在创建 TCP 连接时,我们将客户端套接字地址(IP 地址和端口号)和服务器套接字地址(IP 地址和端口号)与它相关联。 TCP 连接建立后,当一侧想要向另一侧发送数据时,它只是通过其套接字将数据放入 TCP 连接中。 这与 UDP 不同,UDP 中的服务器必须在将数据包放入套接字之前将目标地址附加到数据包。
现在让我们仔细看看 TCP 中客户端和服务器程序的交互。 客户端负责发起与服务器的联系。 为了让服务器能够对客户端的初始联系(initial contact)做出反应,服务器必须做好准备。 这意味着两件事。 首先,就像UDP的情况一样,TCP服务器必须作为一个进程运行,然后客户机才尝试发起联系。 其次,服务器程序必须有一个特殊的门——更准确地说,是一个特殊的套接字——它欢迎来自运行在任意主机上的客户端进程的一些初始联系。 使用我们的房子/门类比进程/套接字,我们有时将客户端的初始联系称为“敲欢迎门(knocking on the welcoming door)”。
随着服务器进程的运行,客户端进程可以发起到服务器的 TCP 连接。 这是在客户端程序中通过创建 TCP 套接字来完成的。 客户端创建TCP套接字时,指定了服务器中欢迎套接字(welcoming socket)的地址,即服务器主机的IP地址和套接字的端口号。 创建好自己的套接字后,客户端发起三向握手,并与服务器建立TCP连接。 发生在传输层内的三向握手对客户端和服务器程序是完全不可见的。
在三次握手过程中,客户端进程敲响了服务器进程的欢迎门。 当服务器“听到(hears)”敲门声时,它会创建一扇新门——更准确地说,是一个专用于该特定客户端的新套接字。 在我们下面的例子中,欢迎门是一个我们称之为 serverSocket 的 TCP 套接字对象; 新创建的专用于建立连接的客户端的套接字称为 connectionSocket。 第一次遇到 TCP 套接字的学生有时会混淆欢迎套接字(它是所有想要与服务器通信的客户端的初始联系点(initial point of contact)),以及随后为与每个客户端通信而创建的新创建的服务器端的每个连接套接字(connection socket)。
从应用程序的角度来看,客户端的套接字和服务器的连接套接字是通过管道(pipe)直接连接的。 如图 2.28 所示,客户端进程可以向其套接字发送任意字节,TCP 保证服务器进程将(通过连接套接字(connection socket))按照发送的顺序接收每个字节。 因此,TCP 在客户端和服务器进程之间提供了可靠的服务。 此外,正如人们可以进出同一扇门一样,客户端进程不仅向其套接字发送字节,还从其套接字接收字节; 类似地,服务器进程不仅从它的连接套接字(connection socket)接收字节,而且还将字节发送到它的连接套接字(connection socket)。
image.png
Figure 2.28 ♦ The TCPServer process has two sockets
我们使用相同的简单客户端-服务器应用程序来演示使用 TCP 的套接字编程:客户端向服务器发送一行数据,服务器将该行大写并将其发送回客户端。 图 2.29 突出显示了通过 TCP 传输服务进行通信的客户端和服务器的主要套接字相关活动。
image.png
Figure 2.29 ♦ The client-server application using TCP

TCPClient.py

下面是应用程序的客户端代码:

  1. from socket import *
  2. serverName = 'servername'
  3. serverPort = 12000
  4. clientSocket = socket(AF_INET, SOCK_STREAM)
  5. clientSocket.connect((serverName,serverPort))
  6. sentence = input('nput lowercase sentence:')
  7. clientSocket.send(sentence.encode())
  8. modifiedSentence = clientSocket.recv(1024)
  9. print('From Server: ', modifiedSentence.decode())
  10. clientSocket.close()
  1. 现在让我们看一下代码中与 UDP 实现显着不同的各行。 第一行是创建客户端套接字。
  1. clientSocket = socket(AF_INET, SOCK_STREAM)
  1. 这一行创建了客户端的套接字,称为 clientSocket 第一个参数再次表明底层网络正在使用 IPv4 第二个参数表示套接字是 SOCK_STREAM 类型,这意味着它是一个 TCP 套接字(而不是一个 UDP 套接字)。 请注意,我们在创建客户端套接字时再次没有指定它的端口号; 相反,我们让操作系统为我们做这件事。 现在下一行代码与我们在 UDPClient 中看到的非常不同:
  1. clientSocket.connect((serverName,serverPort))
  1. 回想一下,**在客户端可以使用 TCP 套接字向服务器发送数据(反之亦然)之前,必须首先在客户端和服务器之间建立 TCP 连接。** 上面这行启动了客户端和服务器之间的 TCP 连接。 connect() 方法的参数是连接的服务器端的地址。 **这行代码执行完后,进行三向握手,客户端和服务端建立TCP连接。**
  1. sentence = input('Input lowercase sentence:')
  1. UDPClient一样,上面从用户那里得到一句话。 字符串 sentence 持续收集字符,直到用户通过键入回车符结束该行。 下一行代码也和 UDPClient 有很大不同:
  1. clientSocket.send(sentence.encode())
  1. 上面这行通过客户端的套接字将语句发送到 TCP 连接中。 请注意,该程序并未像 UDP 套接字那样显式地创建数据包并将目标地址附加到数据包。 相反,客户端程序只是将字符串 sentence 中的字节放入 TCP 连接中。 然后客户端等待从服务器接收字节。
  1. modifiedSentence = clientSocket.recv(2048)
  1. 当字符从服务器到达时,它们被放入字符串 modifiedSentence 中。 字符继续在 modifiedSentence 中累积,直到该行以回车符结束。 打印大写的句子后,我们关闭客户端的套接字:
  1. clientSocket.close()
  1. 最后一行关闭套接字,从而关闭客户端和服务器之间的 TCP 连接。 **它导致客户端中的 TCP 向服务器中的 TCP 发送 TCP 报文(参见第 3.5 节)。**

TCPServer.py

现在让我们来看看服务器程序。

  1. from socket import *
  2. serverPort = 12000
  3. serverSocket = socket(AF_INET,SOCK_STREAM)
  4. serverSocket.bind(('',serverPort))
  5. serverSocket.listen(1)
  6. print('The server is ready to receive')
  7. while True:
  8. connectionSocket, addr = serverSocket.accept()
  9. sentence = connectionSocket.recv(1024).decode()
  10. capitalizedSentence = sentence.upper()
  11. connectionSocket.send(capitalizedSentence.encode())
  12. connectionSocket.close()
  1. 现在让我们看看与 UDPServer TCPClient 显着不同的行。 TCPClient 一样,服务器使用以下内容创建 TCP 套接字:
  1. serverSocket = socket(AF_INET,SOCK_STREAM)
  1. UDPServer 类似,我们将服务器端口号 serverPort 与此套接字相关联:
  1. serverSocket.bind(('',serverPort))
  1. 但是对于 TCPserverSocket 将成为我们欢迎套接字(welcoming socket)。 建立这扇欢迎门后,我们将等待并监听(listen)一些客户敲门:
  1. serverSocket.listen(1)
  1. 这一行让服务器监听(listen)来自客户端的 TCP 连接请求。 该参数指定排队连接的最大数量(maximum number of queued connections)(至少 1 个)。
  1. connectionSocket, addr = serverSocket.accept()
  1. 当客户端敲门时,程序调用 serverSocket accept() 方法,**该方法在服务器中创建一个新的套接字,称为 connectionSocket,专用于该特定客户端。** 然后客户端和服务器完成握手,在客户端的 clientSocket 和服务器的 connectionSocket 之间创建 TCP 连接。 建立 TCP 连接后,客户端和服务器现在可以通过连接相互发送字节。 使用 TCP,从一侧发送的所有字节只保证到达另一侧,但也保证按顺序到达。
  1. connectionSocket.close()
  1. 在这个程序中,将修改后的语句发送给客户端后,我们关闭**连接套接字(connection socket)**。 **但是由于 serverSocket 保持打开状态,另一个客户端现在可以敲门并向服务器发送一个 sentence 进行修改。**<br />这完成了我们对 TCP 中套接字编程的讨论。 鼓励您在两个不同的主机上运行这两个程序,并修改它们以实现略有不同的目标。 您应该将 UDP 程序对与 TCP 程序对进行比较,看看它们有何不同。 您还应该完成第 249 章末尾描述的许多套接字编程作业。 最后,我们希望有一天,在掌握这些和更高级的套接字程序后,您将编写自己的流行网络应用程序,变得非常富有和著名,并记住这本教科书的作者!