第二章 应用层(套接字编程)

目录

    • [2.7 套接字编程:生成网络应用](#2.7 套接字编程:生成网络应用)
      • [2.7.1 UDP套接字编程](#2.7.1 UDP套接字编程)
      • [2.7.2 TCP套接字编程](#2.7.2 TCP套接字编程)

2.7 套接字编程:生成网络应用

这小节要解决的问题其实就一句话:

"我们以前只是在概念上说 Web、邮件这些应用是怎么工作的,现在要亲自写程序,让两个进程在网络上通过**套接字(socket)**说话。"

1. "典型的网络应用是一对程序"

书上说:

典型的网络应用是由一对程序(即客户程序和服务器程序)组成的,它们位于两个不同的端系统中。

翻译一下:

  • 一个网络应用 = 至少两段程序:
    • 客户端程序(运行在一台机器上)
    • 服务器程序(运行在另一台机器上)
  • 两台机器就是"两个端系统"。

你可以把它想成:

  • 你手机上的 微信 app 是一个客户端程序;
  • 腾讯机房里的一台服务器上跑着 微信服务器程序

2. "当运行这两个程序时,创建了一个客户进程和一个服务器进程"

  • "程序"是写好放在磁盘里的代码。
  • 一旦你点开运行,操作系统就会把这段程序"放活",变成一个正在运行的进程

所以:

  • 客户端程序 → 运行后 → 客户进程
  • 服务器程序 → 运行后 → 服务器进程

3. "它们通过套接字读写数据进行通信"

书上说:

同时它们通过从套接字读出和写入数据在彼此之间进行通信。

  • 套接字 socket = 进程通往网络的"门" / "插座"。
  • 客户和服务器进程:
    • 往自己的 socket 里 写数据 → 数据被交给操作系统,通过 TCP/UDP 送到对方。
    • 从 socket 里 读数据 → 得到别人发来的内容。

你可以想像:

  • 进程 = 房间
  • 网络 = 走廊
  • socket = 房门
    进程把信从门口"塞出去",对方进程从自己门口"拿进来"。

4. "开发者创建网络应用,主要任务是什么?"

开发者创建一个网络应用时,其主要任务就是编写客户程序和服务器程序的代码。

意思就是:

  • 作为应用层程序员,你 不需要自己实现 TCP、UDP、IP 那些东西(已经在操作系统里了)。
  • 你要做的就是写两个程序:
    • 客户端代码
    • 服务器端代码
      通过 socket 调接口,收发数据。

两类网络应用程序:开放的 vs 专用的

书上接下来分两类:

1. 第一类:基于"公开协议标准"的应用(称为开放应用)

一类是由协议标准(如一个 RFC 或某种其他标准文档)中所定义的操作的实现;这样的应用程序有时称为"开放"的...

解释:

  • 互联网里的很多协议是公开的,比如 HTTP、SMTP、FTP 等。
  • 这些协议的具体"规则"写在文档里(RFC)。
  • 任何人都可以照着 RFC 写自己的客户端程序或服务器程序。

所以这种应用叫"开放的":

  • 规则公开
  • 谁都能实现
    只要大家都遵守 RFC,互相之间就能正常通信。

例如:

某客户程序可能是 HTTP 协议客户端的一种实现... 该协议由 RFC2616 明确定义;

  • 你的浏览器 = HTTP 客户端的实现;
  • Apache / Nginx = HTTP 服务器的实现;
  • 双方都按 RFC2616 这个文档的规则来收发报文,所以互通

重点:
不同公司写的程序,只要严格按照同一个 RFC 来写,就算没商量过,也能互联互通。

2. 第二类:专用的 / 私有协议的应用

另一类网络应用程序是专用的网络应用程序。

  • 这种程序使用的"协议"没有公开写在 RFC 里,而是这个开发者/团队自己定的,只在自己公司内部知道。

RFC 就是:

Request For Comments(征求意见稿)

简单理解:

  • 互联网的各种"规矩"(比如 HTTP、TCP、IP、SMTP 邮件协议......)
    都写在一份一份公开的技术文档里;
  • 这些文档大多数名字都叫 RFC + 编号 ,例如:
    • RFC 2616:早期的 HTTP/1.1 协议说明;
    • RFC 791:IP 协议;
    • RFC 793:TCP 协议。
  • 客户 + 服务器程序都由同一个开发者/团队写,他们自家的人当然知道规则;但别人不知道,就没法写兼容的程序。

书上说:

但是因为这些代码并没有实现一个开放的协议,其它独立的开发者将不能开发出与该应用程序交互的代码。

简单理解:

  • 开放应用:像"普通话规范都写在字典里",谁学谁会说,大家都能交流。
  • 专用应用:像"只有自己小团队知道的一种暗号",别人听不懂,也没法说。

现实例子:

  • 很多公司内部的业务系统、游戏私有协议、某些早期的 P2P 软件协议,都是专用的。

本节要做什么?------亲手写一个简单的客户端--服务器程序

书上说:

在本节中,我们将考察研发一个客户--服务器应用程序中的关键问题...

他们打算:

  1. 用一个非常简单的小例子 来带你写:
    • 一个客户端程序
    • 一个服务器端程序
  2. 重点不是业务功能,而是:
    • 怎么用 TCPUDP
    • 怎么用 套接字 API

1. 第一个决策:用 TCP 还是 UDP?

开发者必须最先做的一个决定是,应用程序是运行在 TCP 上还是运行在 UDP 上。

回忆一下:

  • TCP
    • 面向连接
    • 提供可靠的字节流
  • UDP
    • 无连接
    • 按"独立的报文"发送
    • 不保证一定送到 / 不乱序

书上这里只是提醒:

前面讲过 TCP 是面向连接的... UDP 是无连接的... 不对交付提供任何保证。

2. 端口号(port number)再提醒一下

前面也讲过当客户或服务器程序实现了一个由某 RFC 定义的协议时,它应当使用与该协议关联的周知端口号;

意思是:

  • 一些常见协议的端口号是"约定俗成 + RFC 规定"的:
    • HTTP:80
    • HTTPS:443
    • SMTP:25
  • 如果你写的是"标准 HTTP 服务器",就应该监听 80 端口,这样浏览器才知道往 80 发。

与之相反,当研发一个专用应用程序时,研发者必须注意避免使用这些周知端口号。

  • 你自己写一个乱七八糟的实验程序,就别占用 80、443 这种别人约好要用的端口。
  • 一般就随便挑个比较大的数字(比如 12000),只要不和系统里别的服务撞就行。

准备动手写代码:用 Python 做例子

我们用一个简单的 UDP 应用程序和一个简单的 TCP 应用程序来介绍 UDP 和 TCP 套接字编程。

  • 先写 UDP 版本
  • 再写 TCP 版本
  • 语言用 Python 3,因为代码短、好看、逻辑清楚。

同时书上说:

  • 你也可以用 Java / C / C++ 来写,一样的思路;
  • 只是 Python 更适合教学,行数少,结构清晰;
  • 如果你有 Java / C 基础,可以去他们网站上看对应的完整代码和实验。

最后那一堆参考文献(Donahoo、Stevens 等)是在推荐:

如果想学 C 语言的网络编程,可以去看这些经典教材。

对你现在来说知道就行,不用看。

2.7.1 UDP套接字编程

这一小节就是:

"我们来从零写一个用 UDP 通信的客户端和服务器,看一下它们是怎么通过套接字互动的。"

1. 再复习一下"进程通过套接字通信"这件事

2.1 节讲过,运行在不同机器上的进程彼此通过套接字发送报文来进行通信。

你现在可以把"套接字"牢牢记成:

  • "这扇门后面是网络"
  • 进程往门里塞东西 → 经网络出去
  • 从门里拿东西 → 是别人从网络送来的

2. 使用 UDP 时要做的事:把地址附在"分组"上

书上说:

在发送进程能够将数据分组推出套接字之前,若是使用 UDP 时,必须先将目的地的地址附在该分组之上。

什么意思?

  • 你是发送方进程。
  • 你准备了一小块数据(书里叫"分组",其实就是一坨 bytes)。
  • 你如果是用 UDP 发,就必须告诉操作系统:"这坨东西要发给哪台主机、哪一个端口"。

所以要附上:

  • 目的主机 IP 地址
  • 目的端口号(也就是对方那个 socket 的"门牌号")

3. IP 地址 + 端口号 = 能找到"哪台机器上的哪个进程"

接下来书上开始解释:

因此你可能现在想知道,附在分组上的目的地址包含了什么?

  • 分组里的目的地址其实分两层:
    1. IP 地址:告诉网络"要送到哪台主机"
    2. 端口号 (port):告诉目的主机"要交给哪一个应用进程"

为什么要这样分开?

  • 因为一台主机可能同时跑:
    • Web 服务器(80)
    • 邮件服务器(25)
    • 你自己写的 UDP 程序(比如 12000)
  • 路由器只负责根据 IP 地址 把分组送到正确的主机;
  • 主机内部再根据 端口号 把分组交给对应进程。

所以书上说:

当生成一个套接字时,就为它分配一个称为端口号(port number)的标识符。

... 发送进程为分组附上的目的地址也包括该套接字的端口号。

一句话:
IP 地址 定位"哪台计算机";端口号 定位"这台计算机上的哪个应用进程"。

4. 源地址也要附上,但这一步是操作系统自动做的

此外... 发送方的源地址也是由源主机的 IP 地址和源套接字的端口号组成,该源地址也要附在分组之上。

为什么要源地址?

  • 方便对方回信:
    • 对方看"你是谁发来的"(源 IP + 源端口)
    • 回包的时候就把它作为"目的地址"。

然而,将源地址附在分组之上通常并不是由 UDP 应用程序代码所为,而是由底层操作系统自动完成的。

意思是:

  • 你写 Python 程序的时候 不需要 手动填"我是 10.0.0.1:12345";
  • 操作系统知道:
    • 你是哪个 socket(端口号)
    • 你这台机器的 IP 地址
      它会自动把源地址填到报文头里。

应用程序干了什么?------书上给了 4 步:

  1. 客户从键盘读入一行字符(数据),并把这些数据发给服务器;
  2. 服务器收到后,把这些字符转成大写;
  3. 服务器把修改后的字符串再发回给客户端;
  4. 客户端收到后,在屏幕上打印出来。

所以这个小程序本质上就是一个"大写转换器":

  • 客户端:输入 "hello world"
  • 服务器:收到后改成 "HELLO WORLD"
  • 再发回去,客户端输出

它看起来很无聊,但正好能展示:

  • 客户端如何:
    • 创建 socket
    • 给出服务器 IP、端口
    • send
    • recv
  • 服务器如何:
    • 创建 socket
    • 绑定端口
    • recvfrom(得到数据 + 客户地址)
    • sendto(把结果发回去)

1. 服务器端(左边)

1)创建套接字:

创建套接字,port = x:
serverSocket = socket(AF_INET, SOCK_DGRAM)

  • socket() 是创建套接字的系统调用 / 函数。
  • AF_INET:表示使用 IPv4 地址族。
  • SOCK_DGRAM:表示 UDP(数据报套接字)。
  • 这一步之后你有了一个"门"(serverSocket),接下来要把它"装在某个门牌号上"(绑定端口)。

书上实际上还会有一步 bind(...),图里省略了细节。

2)从 serverSocket 读 UDP 报文段

从 serverSocket 读 UDP 报文段

  • 服务器进入一个循环,等待客户端发来的 UDP 分组。
  • 一旦有数据到达目的端口 x,对应的 socket 就会"收到"。
  • 程序调用 recvfrom() 之类的函数获取数据。

3)向 serverSocket 写响应,指定客户地址和端口号

向 serverSocket 写响应,指定客户地址、端口号

  • 服务器处理完(把小写改成大写)之后,要回给客户端。
  • 因为是 UDP,它必须知道:
    • 对方 IP 地址
    • 对方端口号
  • 这些信息其实在 recvfrom() 的时候一起拿到了,所以直接塞给 sendto() 就行。

2. 客户端(右边)

1)创建套接字

创建套接字:
clientSocket = socket(AF_INET, SOCK_DGRAM)

  • 同样是 UDP 套接字,类型是 SOCK_DGRAM
  • 客户端没说要 bind 哪个端口,操作系统会自动给它分配一个临时端口号(比如 54321)。

2)创建具有 serverIP 和 port = x 的数据报,经 clientSocket 发送数据报

意思是:

  • 客户端准备一块数据(你从键盘读到的那一行字符串)。
  • 调用 sendto(message, (serverIP, x))
    • serverIP:服务器那台机器的 IP
    • x:服务器监听的端口号(比如 12000)

3)从 clientSocket 读数据报

  • 客户端调用 recvfrom() 或类似函数,阻塞等待服务器的回应。
  • 收到后得到服务器发回的大写字符串。

4)关闭 clientSocket

  • 通信结束,调用 close() 把这个套接字关闭。
  • UDP 本身没有"断开连接"这个概念,关闭只是告诉操作系统"我不再用这个 socket 了"。

3. 程序文件名和端口号

书最后一行说:

客户程序被称为 UDPClient.py,服务器程序被称为 UDPServer.py。... 对于本应用程序,我们任意选择了 12000 作为服务器的端口号。

解释:

  • 他们会在后面给两个 Python 文件:
    • UDPClient.py ------ 客户端代码
    • UDPServer.py ------ 服务器端代码
  • 服务器监听的端口号他们随便选了一个 12000
    • 不和"周知端口"冲突
    • 范围也在合法端口号里(0--65535)

还有一句:

为了强调关键问题,我们有意提供最少的代码。"好代码"无疑将具有更多辅助性的代码行,特别是用于处理出现差错的情况。

意思就是:

  • 书里的代码只放"最核心的几行",让你能一眼看清流程。
  • 真正工程里会加很多:
    • 错误检查
    • 超时判断
    • 异常处理
      等等,这些他们暂时都省略了。

UDPClient.py 逐行讲解

python 复制代码
from socket import *  # 将socket的函数、常量全部导入进来
serverName = 'hostname'  # 真实使用换成服务器地址/IP
serverPort = 12000	# 服务器监听的UDP端口号
clientSocket = socket(AF_INET, SOCK_DGRAM) # 创建一个客户端套接字,变量名为clientsocket
# AF_INET IPv4协议族;AF_INET6是ipv6
# SOCK_DGRAM 数据报套接字UDP;SOCK_STREAM是TCP
# 没有给客户端socket指定端口号,OS会自动分配一个临时端口
message = raw_input('Input lowercase sentence: ') # 输入函数
clientSocket.sendto(message.encode(), (serverName, serverPort))
modifiedMessage, serverAddress = clientSocket.recvfrom(2048)
print(modifiedMessage.decode())
clientSocket.close()

5)message = raw_input('Input lowercase sentence: ')

  • raw_input() 是 Python2 的输入函数(Python3 里是 input())。
  • 执行到这行时,程序会在屏幕上显示提示语:
    • Input lowercase sentence:
  • 用户在键盘上输入一行内容(比如 hello world),回车后:
    • 这一行字符串就被保存到变量 message 里。

6)clientSocket.sendto(message.encode(), (serverName, serverPort))

这一行比较关键,拆开看:

  1. message.encode()
    • Python 里的字符串是"字符序列";
    • 发送网络数据时,需要字节序列
    • encode() 把字符串 → 字节(默认 UTF-8)。
  2. sendto(数据, (服务器地址, 服务器端口))
    • sendto() 是 UDP 套接字用来发送报文的函数;
    • 第一个参数:要发出去的字节序列;
    • 第二个参数:是一个二元组 (serverName, serverPort),表示 目的地
      • serverName 会被解析成 IP;
      • serverPort 就是我们设的 12000。
  3. 操作系统拿到这些信息之后会做几件事:
    • 在 UDP 头里填上:
      • 源 IP + 源端口(自动填)
      • 目的 IP + 目的端口(你提供的)
    • 封装成 IP 包 → 交给底层网络发出去。

所以这一行就把"你输入的一行小写字母句子"包装成一个 UDP 报文,扔给服务器了。

7)modifiedMessage, serverAddress = clientSocket.recvfrom(2048)

  • recvfrom(2048)
    • clientSocket接收一个 UDP 报文;
    • 2048 是缓冲区大小,表示"最多一次收 2048 字节";
    • 返回两个东西:
      1. modifiedMessage: 收到的"数据部分"(字节序列);
      2. serverAddress: 发送方的地址(一个 (ip, port) 的元组)。
  • 这行的意思是:
    • 等待服务器回一个数据报;
    • 收到后,把数据放到 modifiedMessage
    • 把服务器的 IP + 端口放到 serverAddress

在这个例子里,其实客户端已经知道服务器 IP 和端口了,所以 serverAddress 这个变量并没有真正用到,只是让你看看"源地址也可以拿到"。

8)print(modifiedMessage.decode())

  • modifiedMessage 是"字节序列",要在屏幕上打印需要转成"字符串";
  • decode() 把字节 → 字符串;
  • 打印出来的就是服务器改成大写后的句子,比如:HELLO WORLD

9)clientSocket.close()

  • 把客户端的套接字关掉;
  • 让操作系统把相关资源回收;
  • 此时这个进程也马上结束。

UDPServer.py 逐行讲解

python 复制代码
from socket import *
serverPort = 12000 # 指定服务器要监听的端口号 = 12000。
serverSocket = socket(AF_INET, SOCK_DGRAM)
serverSocket.bind(('', serverPort))
print("The server is ready to receive")
while True:
    message, clientAddress = serverSocket.recvfrom(2048)
    modifiedMessage = message.decode().upper()
    serverSocket.sendto(modifiedMessage.encode(), clientAddress)

1)from socket import *

和客户端一样:导入 socket 模块所有东西。

2)serverPort = 12000

  • 指定服务器要监听的端口号 = 12000。
  • 客户端发包时就要指向这个端口。

3)serverSocket = socket(AF_INET, SOCK_DGRAM)

  • 创建一个 UDP 套接字,跟客户端类型一样:
    • IPv4 (AF_INET)
    • UDP (SOCK_DGRAM)
  • 变量名 serverSocket

4)serverSocket.bind(('', serverPort))

很重要的一行

  • bind(本机地址, 端口号) 的作用是:
    • 把这个套接字"绑定"到指定 IP + 端口上;
    • 以后发到这个 IP + 这个端口的 UDP 包,就会交给这个 socket。
  • 这里第一个参数是 ''(空字符串),表示:
    • 不指定特定网卡地址,听本机所有 IP 上 12000 端口的包;
    • 等价于 C 里面的 INADDR_ANY

所以这行可以理解成:

"让 serverSocket 在本机所有网卡的 12000 端口上,等着接 UDP 包"。

5)print("The server is ready to receive")

  • 纯提示语:告诉你"服务器已经准备好了,正在监听"。

6)while True:

  • 无限循环;
  • 说明服务器会一直运行,随时处理客户端的请求。

7)message, clientAddress = serverSocket.recvfrom(2048)

  • 跟客户端那行类似,但这次是从 服务器的 socket 上收包。
  • 直到有客户端往 服务器IP:12000 发包,这里才会返回:
    • message:客户端发来的那行小写字符串(字节)
    • clientAddress:客户端的地址 (clientIP, clientPort)

这个 clientAddress 后面要用来回包,相当于信封上写的"寄信人地址"。

8)modifiedMessage = message.decode().upper()

  • message.decode():字节 → 字符串;
  • .upper():把字符串里的字母变成大写;
  • 得到新的字符串 modifiedMessage,比如把 hello world 变成 HELLO WORLD

9)serverSocket.sendto(modifiedMessage.encode(), clientAddress)

  • 把修改后的字符串 modifiedMessage
    • encode() → 字节;
    • 通过 sendto() 发回给刚才那个 clientAddress(客户端的 IP + 端口)。
  • 这样客户端就能收到大写后的句子。

10)循环继续

  • while True 不停地转:
    • 来一个包 → 处理 → 回一个包;
    • 服务器一直运行,直到你手动 Ctrl+C 停止。

在一台机器同时当客户端 & 服务器

  • 服务器进程监听 本机 IP:12000
  • 客户端进程向 127.0.0.1:12000(或本机真实 IP + 12000)发 UDP 包;
  • 操作系统通过 端口号进程的 socket 来区分不同程序;
  • 只要是两个不同进程,一个当 server、一个当 client,就可以在同一台机器上互相收发。

做实验的步骤就是:

  1. 在 Ubuntu 上打开两个终端(Terminal 1、Terminal 2);
  2. Terminal 1 运行服务器程序;
  3. Terminal 2 运行客户端程序,目标 IP 写 127.0.0.1
  4. 效果跟两台机器是一样的。

1)127.0.0.1 = 回环地址 / 本机地址 / localhost

  • 127.0.0.1 是一段专门保留的 IP ,代表"我自己这台机器"。
  • 你经常看到的 localhost,其实就等价于 127.0.0.1

可以这样理解:

平时你寄快递,要写目标地址:北京市xx小区xx号。

如果你写"寄给我自己",快递这趟其实根本不出小区,直接在你家门口转一圈又塞回你家信箱。

这就有点像 127.0.0.1 ------ "发给我自己"。

2)和"真 IP 地址"的区别

  • 真 IP :比如 192.168.1.10
    • 网络包会走网卡、走交换机、路由器,真的在网络上"飞一圈"。
    • 别的机器上只要能访问到这个 IP,就可以连到你。
  • 127.0.0.1
    • 数据包不会跑到网卡上,操作系统直接在内部"打个转就回来";
    • 只有本机能用 ,别的机器完全访问不到你的 127.0.0.1

所以:

  • 在同一台机器上测试 客户端 & 服务器 → 写 127.0.0.1 就行;
  • 跨两台机器测试 → 客户端的目标 IP 必须写"服务器那台机子的真实 IP"(比如 192.168.1.10),不能写 127.0.0.1

下面给你完整、可以直接编译运行的 C 代码。

c++ 复制代码
// udp_client.cpp - 简化 UDP 客户端,对应 UDPClient.py

#include <iostream>
#include <string>
#include <cstring>
#include <arpa/inet.h>   // socket, sockaddr_in, inet_pton
#include <unistd.h>      // close

int main() {
    // 1. 创建 UDP 套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    // 2. 准备服务器地址:这里用 127.0.0.1 表示"本机"
    sockaddr_in serv{};
    serv.sin_family = AF_INET;
    serv.sin_port   = htons(12000);                  // 服务器端口 12000
    inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr); // 目标 IP = 本机

    // 3. 从键盘读一行小写句子
    std::cout << "Input lowercase sentence: ";
    std::string msg;
    std::getline(std::cin, msg);

    // 4. 发送给服务器
    sendto(sockfd,
           msg.c_str(), msg.size(),        // 要发的数据和长度
           0,
           (sockaddr*)&serv, sizeof(serv)  // 目的 IP + 端口
    );

    // 5. 等待服务器返回
    char buf[2048];
    ssize_t n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0, nullptr, nullptr);
    if (n > 0) {
        buf[n] = '\0';  // 补上 C 字符串结尾
        std::cout << "From server: " << buf << std::endl;
    }

    // 6. 关闭套接字
    close(sockfd);
    return 0;
}
c++ 复制代码
// udp_server.cpp - 简化 UDP 服务器,对应 UDPServer.py

#include <iostream>
#include <cstring>
#include <arpa/inet.h>   // socket, sockaddr_in, inet_ntop
#include <unistd.h>      // close

int main() {
    // 1. 创建 UDP 套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    // 2. 绑定到本机的 12000 端口
    sockaddr_in serv{};
    serv.sin_family      = AF_INET;
    serv.sin_addr.s_addr = htonl(INADDR_ANY);  // 相当于 Python 里的 '',监听所有本地 IP
    serv.sin_port        = htons(12000);

    bind(sockfd, (sockaddr*)&serv, sizeof(serv));

    std::cout << "The server is ready to receive" << std::endl;

    // 3. 一直循环:收数据 -> 改大写 -> 发回去
    while (true) {
        char buf[2048];
        sockaddr_in cli{};
        socklen_t cli_len = sizeof(cli);

        // 收到客户端发来的 UDP 包
        ssize_t n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0,
                             (sockaddr*)&cli, &cli_len);
        if (n <= 0) continue;

        buf[n] = '\0';

        // 把收到的内容改成大写(等价于 Python 的 .upper())
        for (ssize_t i = 0; i < n; ++i) {
            if (buf[i] >= 'a' && buf[i] <= 'z') {
                buf[i] = buf[i] - 'a' + 'A';
            }
        }

        // 把结果发回刚才那个客户端
        sendto(sockfd, buf, n, 0,
               (sockaddr*)&cli, cli_len);
    }

    close(sockfd);
    return 0;
}

2.7.2 TCP套接字编程

与 UDP 不同,TCP 是一个面向连接的协议。

翻成人话:

  • UDP:像寄明信片
    • 不提前打招呼;
    • 直接写上对方地址就扔出去;
    • 可能丢、可能乱序、也不知道对方收没收到。
  • TCP:像先打电话,再聊天
    1. 先"拨号 → 接通"(建立连接),两边互相确认;
    2. 接通之后,才开始持续说话;
    3. 电话没挂之前,双方可以不停说话;
    4. 运营商保证:对方听到的顺序跟你说的顺序一样(可靠、有序)。

所以对于 TCP:

客户与服务器在能互相发送数据之前,必须先建立一个 TCP 连接。

这条连接就是"客户进程的套接字 ↔ 服务器进程的套接字"之间的一条虚拟通道。

和 UDP 的"附地址方式"有啥区别?

UDP 里你看到的是:

  • 每发一个分组,都在分组上附上目的 IP + 目的端口
  • "分组去哪儿"完全靠每个分组头部的地址。

TCP 不一样:

  1. 一开始建立连接时
    客户端(TCP 套接字)和服务器(TCP 套接字)之间通过"三次握手"建立关联:
    • 握手阶段里会用到双方的 IP+端口;
    • 握手成功后就有了一条"逻辑连接"。
  2. 之后发送数据时
    程序只管往这个连接里写字节就行了:
    • 不用每次 send() 时再指定目的 IP 和端口;
    • 内核知道"这条连接是从我这里到哪一台主机的哪个端口",自动帮你把报文加上正确的头。

书上说:

TCP 连接的一端与客户套接字相关联,另一端与服务器套接字相关联...使用创建的 TCP 连接,当一侧向另一侧发送数据时,它只需在其套接字上写数据。

这就是"面向连接"的含义。

服务器在 TCP 里有什么特别的职责?

书接着说:现在来看 TCP 客户程序和服务器程序的交互。

1)服务器必须先运行起来(和 UDP 一样)

第一,与 UDP 中的情形一样,TCP 服务器在客户试图发起接触前必须作为进程运行起来。

  • UDP:服务器得先 bindrecvfrom,不然没有人接你的明信片。
  • TCP:服务器也必须先跑起来,不然客户端 connect() 会直接失败(连接不上)。

2)TCP 服务器必须有一个"特别的套接字":欢迎套接字

第二,服务器程序必须具有一扇特殊的门,更精确地说是一个特殊的套接字,该门(该套接字)欢迎运行在任何主机上的客户端进程的某种初始接触。

这就是图里的 "欢迎套接字(welcome socket)" ,书里的变量名一般叫 serverSocketwelcomeSocket

你可以这么想象:

  • 一栋楼的大铁门 = 欢迎套接字
    负责接新客人:有人按门铃 → 看看是谁 → 给它开个通道。
  • 客人进楼后,每个客人和楼里某个办公室之间还会有一条专用走廊 (后面讲的 连接套接字)。

欢迎套接字的作用:

  1. 绑定一个固定端口(例如 12000);
  2. listen() 说:我开始听连接请求了;
  3. accept() 等待客户端来"敲门"。

连接套接字:给每个客户端开一条专用通路

书上说:

当该服务器"听"到敲门声时,它将生成一扇新的门(更精确地讲是一条 TCP 套接字),它专用于某个特定的客户...称为连接套接字(connectionSocket)。

流程是:

  1. 客户端调用 connect() 连服务器的欢迎套接字;
  2. 服务器端的 accept() 成功返回时:
    • 内核创建了一个新的套接字(连接套接字);
    • 这个新套接字专门服务这个客户端;
    • accept() 返回的就是这个新 socket 的句柄(Python 变量 connectionSocket,C 里是 connfd)。

于是:

  • serverSocket:一直在那儿 listen + accept,负责"接客";
  • 每来一个新客户端,就得到一个新的 connectionSocket
    • 以后这个客户端收发数据都只走 connectionSocket
    • 客户端和服务器之间的 TCP 连接,其实就是
      客户端的 clientSocket ↔ 服务器的 connectionSocket

图 2-27 画的就是这个关系:

  • 客户进程里只有一个 客户套接字
  • 服务器进程里有两个:
    • 欢迎套接字(上面那个);
    • 为某个客户生成的连接套接字(下面那个)。

TCP 保证什么?字节流 + 可靠 + 双向

书上说:

TCP 保证服务器进程能够按发送的顺序接收(通过连接套接字)每个字节。

  • TCP 把应用数据看作连续字节流
    • 你可以随便 send() 几次、每次几字节;
    • 对方 recv() 时看到的是按顺序排列的字节序列;
    • 不丢、不重、不乱(除非连接真的崩了,会报错)。

客户进程不仅能向它的套接字发出字节,也能从中接收字节;

类似地,服务器进程不仅从它的连接套接字接收字节,也能向其发送字节。

  • 所以 TCP 连接是双向的
    • 客户端可以发,也可以收;
    • 服务器也可以发、可以收;
    • 完全是"两边都能说话的电话线"。

TCP 客户--服务器程序的流程图

看一下图 2-28 的流程(我帮你翻成一口气的步骤):

服务器端流程

  1. 创建欢迎套接字

    复制代码
    serverSocket = socket()

    实际上是:socket(AF_INET, SOCK_STREAM),表示 TCP 套接字。

  2. 等待入连接请求

    复制代码
    connectionSocket = serverSocket.accept()
    • accept() 这里阻塞;
    • 某个客户端 connect 过来时,内核就会返回一个新的 connectionSocket
  3. 从 connectionSocket 读请求

    • 其实就是 recv(),从 TCP 连接里读字节。
  4. 向 connectionSocket 写响应

    • send() 把处理好的数据写回去。
  5. 关闭 connectionSocket

    • 结束这次会话;
    • 欢迎套接字仍然打开,可以继续等待下一个客户。

客户端流程

  1. 创建 clientSocket

    复制代码
    clientSocket = socket()

    用的也是 AF_INET, SOCK_STREAM(TCP)。

  2. 用 clientSocket 连接服务器

    复制代码
    clientSocket.connect((serverIP, port=x))
    • 指定服务器 IP 和端口;
    • 连接成功后,TCP 三次握手完成,通道建立。
  3. 使用 clientSocket 发送请求

    • send() 把想发的内容塞进 TCP 连接。
  4. 从 clientSocket 读响应

    • recv() 等待服务器回来的字节。
  5. 关闭 clientSocket

    • 连接结束。

TCP 服务器:tcp_server.c

c 复制代码
// tcp_server.c  极简 TCP 服务器:收到一条消息 -> 打印 -> 原样发回

#include <stdio.h>      // 为了使用 printf
#include <string.h>     // 为了使用 memset
#include <unistd.h>     // 为了使用 read、write、close
#include <arpa/inet.h>  // 为了使用 socket、sockaddr_in、htons、htonl 等

int main() {                                                // 主函数,程序入口
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);         // 创建一个 TCP 套接字,用来"监听"别人连进来

    struct sockaddr_in addr;                                // 定义一个结构体,保存"服务器自己的地址"(IP+端口)
    memset(&addr, 0, sizeof(addr));                        // 把这块内存全部清零,避免有脏数据
    addr.sin_family      = AF_INET;                        // 说明用 IPv4
    addr.sin_port        = htons(12000);                   // 端口号设为 12000,htons = 主机字节序 -> 网络字节序
    addr.sin_addr.s_addr = htonl(INADDR_ANY);              // INADDR_ANY 表示本机所有 IP,都监听

    bind(listenfd, (struct sockaddr*)&addr, sizeof(addr)); // 把"监听套接字"和上面这个地址绑在一起

    listen(listenfd, 1);                                   // 让这个套接字进入"监听状态",最多排队 1 个连接
    printf("server: waiting for connection...\n");         // 打个提示,告诉我们服务器已经在等人连了

    int connfd = accept(listenfd, NULL, NULL);             // 在这里"阻塞"等待客户端连上来,成功后得到一个"连接套接字"

    char buf[1024];                                        // 准备一个缓冲区,用来存放收到的数据
    int n = read(connfd, buf, sizeof(buf) - 1);            // 从连接里读数据(最多读 1023 个字节)
    buf[n] = '\0';                                         // 在读到的最后面补一个 '\0',变成 C 风格字符串

    printf("server received: %s\n", buf);                  // 在服务器终端上打印一下,看看收到了什么

    write(connfd, buf, n);                                 // 把同样的数据原样写回给客户端(不改大小写)

    close(connfd);                                         // 关掉这条连接套接字
    close(listenfd);                                       // 关掉监听套接字
    return 0;                                              // 程序正常结束
}

TCP 客户端:tcp_client.c

c 复制代码
// tcp_client.c  极简 TCP 客户端:输入一条消息 -> 发给服务器 -> 打印服务器返回

#include <stdio.h>      // printf、fgets
#include <string.h>     // strlen、memset
#include <unistd.h>     // read、write、close
#include <arpa/inet.h>  // socket、sockaddr_in、htons、inet_pton

int main() {                                                // 主函数
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);           // 创建一个 TCP 套接字,用作"客户端套接字"

    struct sockaddr_in addr;                                // 用来保存"服务器的地址"(IP+端口)
    memset(&addr, 0, sizeof(addr));                        // 清零
    addr.sin_family = AF_INET;                             // IPv4
    addr.sin_port   = htons(12000);                        // 服务器端口也是 12000,要和服务端保持一致
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);       // 把字符串形式的 IP "127.0.0.1" 转成二进制,127.0.0.1 = 本机

    connect(sockfd, (struct sockaddr*)&addr, sizeof(addr)); // 主动"拨号":连接到上面的服务器 IP+端口

    char buf[1024];                                        // 准备一个缓冲区存要发送的内容
    printf("Input a sentence: ");                          // 提示用户输入
    fgets(buf, sizeof(buf), stdin);                        // 从键盘读一行(带回车)
    int n = strlen(buf);                                   // 算一下这行字符串有多长

    write(sockfd, buf, n);                                 // 把这行内容写进 TCP 连接,发给服务器

    n = read(sockfd, buf, sizeof(buf) - 1);                // 再从连接里读服务器回来的数据
    buf[n] = '\0';                                         // 在末尾补 '\0',变成字符串

    printf("From server: %s\n", buf);                      // 打印服务器回来的内容

    close(sockfd);                                         // 关闭这个客户端套接字
    return 0;                                              // 程序结束
}
相关推荐
JaguarJack17 小时前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo18 小时前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack2 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理2 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
feifeigo1232 天前
matlab画图工具
开发语言·matlab
dustcell.2 天前
haproxy七层代理
java·开发语言·前端
norlan_jame2 天前
C-PHY与D-PHY差异
c语言·开发语言
多恩Stone2 天前
【C++入门扫盲1】C++ 与 Python:类型、编译器/解释器与 CPU 的关系
开发语言·c++·人工智能·python·算法·3d·aigc
QQ4022054962 天前
Python+django+vue3预制菜半成品配菜平台
开发语言·python·django
QQ5110082852 天前
python+springboot+django/flask的校园资料分享系统
spring boot·python·django·flask·node.js·php