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

目录

    • [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;                                              // 程序结束
}
相关推荐
by__csdn1 小时前
ES6新特性全攻略:JavaScript的现代革命
开发语言·前端·javascript·typescript·ecmascript·es6·js
foxsen_xia1 小时前
go(基础10)——错误处理
开发语言·后端·golang
robch1 小时前
Java后端优雅的实现分页搜索排序-架构2
java·开发语言·架构
她说..1 小时前
在定义Java接口参数时,遇到整数类型,到底该用int还是Integer?
java·开发语言·java-ee·springboot
Evand J1 小时前
【PSINS进阶例程】雷达三维跟踪与EKF轨迹滤波。带坐标转换,观测为斜距、方向角、俯仰角。MATLAB编写,附下载链接
开发语言·matlab·psins·雷达观测
专业开发者1 小时前
Android 位置服务(LBS)客户支持指南
开发语言·php
cws2004011 小时前
微软系统中AD域用户信息及状态报表命令介绍
开发语言·microsoft·php
熬了夜的程序员1 小时前
【RUSTFS】rustfs的go语言sdk
开发语言·后端·golang
Hello.Reader1 小时前
Rocket 0.5 快速上手3 分钟跑起第一个 Rust Web 服务
开发语言·前端·rust