目录
一、网络编程基础概念
1. 项目架构
C/S(Client Server)架构
像钉钉、QQ、微信这类应用采用的就是 C/S 架构。其优点在于可以将一些不常修改的文件缓存到本地客户端,不过需要用户安装本地客户端。
B/S(Browser Server)架构
教务管理系统通常使用 B/S 架构,它其实是 C/S 架构的一种特殊形式。优点是不需要安装本地客户端,方便维护和统一更新,但要求服务器性能足够优秀,能够处理高并发,往往需要服务器集群支持。
2. 网络通信相关概念
- MAC 地址:是一个唯一的设备物理地址,类似于身份证。
- IP 地址:这台设备在网络中的标识位置,例如
172.16.1.48
,127.0.0.1
是本地回环地址,也可表示为localhost
。IP 地址的范围是XXX.XXX.XXX.XXX
,理论上有256*256*256*256
种组合。 - 端口号:每个程序的标识位置,有效端口范围是 0 - 65535,其中 0 - 1024 为系统备用端口。
ip + 端口
可以准确找到某一台设备的某一款软件。 - 路由器:连接不同网段的路由设备。
- 网关:某一个网段的入口和出口。
- 子网掩码:通过
IP 地址 & 子网掩码
可以得到真实网段,例如172.16.1.11 & 255.255.255.0
。
3. 五层模型
- 应用层:如
http
协议,例如http://ssssss
。 - 传输层:主要有
tcp/udp
两种协议。 - 网络层:涉及
ip
地址和端口。 - 数据链路层:使用
arp
协议,通过ip
查找mac
地址,与网卡相关。 - 物理层:以电信号进行数据传输。
二、核心技术 Socket
(一)Socket 编程步骤
TCP 服务端步骤
- 导入
socket
模块:import socket
- 创建
socket
对象:sk = socket.socket()
- 绑定 IP 地址和端口号:
sk.bind((ip, port))
- 开始监听客户端连接:
sk.listen()
- 接受客户端连接:
conn, addr = sk.accept()
- 进行消息交互:使用
conn.send()
和conn.recv()
发送和接收消息 - 关闭与客户端的连接:
conn.close()
- 关闭服务器套接字:
sk.close()
TCP 客户端步骤
- 导入
socket
模块:import socket
- 创建
socket
对象:sk = socket.socket()
- 连接到服务器:
sk.connect((ip, port))
- 进行消息交互:使用
sk.send()
和sk.recv()
发送和接收消息 - 关闭客户端套接字:
sk.close()
UDP 服务端步骤
- 导入
socket
模块:import socket
- 创建 UDP
socket
对象:sk = socket.socket(type=socket.SOCK_DGRAM)
- 绑定 IP 地址和端口号:
sk.bind((ip, port))
- 接收和发送消息:使用
sk.recvfrom()
和sk.sendto()
接收和发送消息 - 关闭 UDP 套接字:
sk.close()
UDP 客户端步骤
- 导入
socket
模块:import socket
- 创建 UDP
socket
对象:sk = socket.socket(type=socket.SOCK_DGRAM)
- 发送和接收消息:使用
sk.sendto()
和sk.recvfrom()
发送和接收消息 - 关闭 UDP 套接字:
sk.close()
(二)TCP Socket 通信
1. 基本 TCP 服务端
import socket
# 创建 socket 对象,默认使用 TCP 协议
sk = socket.socket()
# 绑定 IP 地址和端口号,这里使用本地回环地址和 8080 端口
sk.bind(("192.168.134.1", 8080))
# 开始监听客户端的连接请求,参数未指定表示使用默认值
sk.listen()
print("服务器启动,等待客户端连接...")
# 接受客户端的连接,返回一个新的套接字对象 conn 和客户端的地址 addr
conn, addr = sk.accept()
# 打印新的套接字对象和客户端地址
print(conn)
print(addr)
# 关闭与客户端的连接
conn.close()
# 关闭服务器套接字
sk.close()
此代码创建了一个简单的 TCP 服务器,绑定到指定地址和端口,等待客户端连接,连接成功后打印相关信息,最后关闭连接。
2. 基本 TCP 客户端
import socket
# 创建 TCP 套接字对象
sk = socket.socket()
# 连接到指定的服务器地址和端口
sk.connect(("192.168.134.1", 8080))
# 关闭客户端套接字
sk.close()
该代码创建了一个简单的 TCP 客户端,连接到指定的服务器后关闭连接。
3. 带消息交互的 TCP 服务端
import socket
# 创建 TCP 套接字对象
sk = socket.socket()
# 绑定 IP 地址和端口号
sk.bind(("172.16.1.48", 8080))
# 开始监听客户端连接
sk.listen()
print("服务器启动,等待客户端连接...")
# 接受客户端连接
conn, addr = sk.accept()
# 向客户端发送消息,需要将字符串编码为字节类型
conn.send("你好,客户端".encode("utf-8"))
# 接收客户端发送的消息,最多接收 100 字节
msg = conn.recv(100)
# 打印接收到的消息,需要将字节类型解码为字符串
print(msg.decode("utf-8"))
# 关闭与客户端的连接
conn.close()
# 关闭服务器套接字
sk.close()
该代码在基本服务器的基础上,增加了与客户端的消息交互,服务器先向客户端发送消息,再接收客户端消息。
4. 带消息交互的 TCP 客户端
import socket
# 创建 TCP 套接字对象
sk = socket.socket()
# 连接到指定的服务器地址和端口
sk.connect(("172.16.1.48", 8080))
# 接收服务器发送的消息,最多接收 100 字节
msg = sk.recv(100)
# 向服务器发送消息,需要将字符串编码为字节类型
sk.send("你好,服务器".encode("utf-8"))
# 关闭客户端套接字
sk.close()
此代码在基本客户端的基础上,增加了与服务器的消息交互,先接收服务器消息,再向服务器发送消息。
5. 支持多客户端的 TCP 服务端
import socket
# 创建 TCP 套接字对象
sk = socket.socket()
# 绑定 IP 地址和端口号
sk.bind(("172.16.1.48", 8080))
# 开始监听客户端连接
sk.listen()
while True:
print("服务器启动,等待客户端连接...")
# 接受客户端连接
conn, addr = sk.accept()
while True:
print("等待客户端发送消息")
# 接收客户端消息,最多接收 1024 字节,并解码为字符串
msg_client = conn.recv(1024).decode("utf-8")
print("客户端消息:" + msg_client)
# 如果客户端发送 "886",则退出内层循环,结束与该客户端的通信
if msg_client == "886":
break
# 服务器输入要发送给客户端的消息
msg_server = input("请输入发送给客户端的消息")
# 发送消息给客户端,需要将字符串编码为字节类型
conn.send(msg_server.encode("utf-8"))
# 如果服务器发送 "886",则退出内层循环,结束与该客户端的通信
if msg_server == "886":
break
# 关闭与当前客户端的连接
conn.close()
# 关闭服务器套接字
sk.close()
此代码实现了一个支持多个客户端连接的 TCP 服务器,服务器会不断循环接受新的客户端连接,并与每个客户端进行消息交互,直到客户端或服务器发送 "886" 结束通信。
6. 支持持续交互的 TCP 客户端
import socket
# 创建 TCP 套接字对象
sk = socket.socket()
# 连接到指定的服务器地址和端口
sk.connect(("172.16.1.48", 8080))
while True:
# 客户端输入要发送给服务器的消息
msg_server = input("请输入发送给服务器的消息")
# 发送消息给服务器,需要将字符串编码为字节类型
sk.send(msg_server.encode("utf-8"))
# 如果客户端发送 "886",则退出循环,结束通信
if msg_server == "886":
break
print("准备接收服务器的消息")
# 接收服务器消息,最多接收 1024 字节,并解码为字符串
msg_client = sk.recv(1024).decode("utf-8")
print("服务器消息:" + msg_client)
# 如果服务器发送 "886",则退出循环,结束通信
if msg_client == "886":
break
# 关闭客户端套接字
sk.close()
该代码实现了一个支持持续消息交互的 TCP 客户端,客户端可以不断向服务器发送消息,并接收服务器的响应,直到客户端或服务器发送 "886" 结束通信。
(三)UDP Socket 通信
1. UDP 服务端
import socket
# 创建 UDP 套接字对象,SOCK_DGRAM 表示使用 UDP 协议
sk = socket.socket(type=socket.SOCK_DGRAM)
# 绑定 IP 地址和端口号
sk.bind(('172.16.1.48', 8080))
while True:
print("等待客户端发送消息")
# 接收客户端消息,最多接收 1024 字节,并返回消息和客户端地址
msg_client, addr = sk.recvfrom(1024)
# 打印接收到的消息,需要将字节类型解码为字符串
print(msg_client.decode("utf-8"), addr)
# 如果客户端发送 "exit",则退出循环,结束通信
if msg_client.decode("utf-8") == "exit":
break
# 服务器输入要发送给客户端的消息
msg_server = input("请输入要发送给客户端的消息...")
# 发送消息给客户端,需要将字符串编码为字节类型,并指定客户端地址
sk.sendto(msg_server.encode(), addr)
# 如果服务器输入 "exit",则继续循环
if msg_server == "exit":
continue
# 关闭 UDP 套接字
sk.close()
此代码创建了一个 UDP 服务器,绑定到指定地址和端口,不断接收客户端消息并做出响应,直到客户端或服务器发送 "exit" 结束通信。
2. UDP 客户端
import socket
# 创建 UDP 套接字对象
sk = socket.socket(type=socket.SOCK_DGRAM)
while True:
# 客户端输入要发送给服务器的消息
msg_client = input("请输入要发送给服务器的消息...")
# 发送消息给服务器,需要将字符串编码为字节类型,并指定服务器地址和端口
sk.sendto(msg_client.encode(), ("172.16.1.48", 8080))
# 如果客户端发送 "exit",则退出循环,结束通信
if msg_client == "exit":
break
print("等待接收服务器的消息")
# 接收服务器消息,最多接收 1024 字节,并返回消息和服务器地址
msg_server, addr = sk.recvfrom(1024)
# 打印接收到的消息,需要将字节类型解码为字符串
print(msg_server.decode("utf-8"), addr)
# 如果服务器发送 "exit",则退出循环,结束通信
if msg_server.decode("utf-8") == "exit":
break
# 关闭 UDP 套接字
sk.close()
该代码创建了一个 UDP 客户端,不断向服务器发送消息并接收服务器响应,直到客户端或服务器发送 "exit" 结束通信。
(四)自定义封装的 UDP 通信
1. 自定义封装类
import socket
class MySocket:
def __init__(self):
# 创建 UDP 套接字对象
self.sk = socket.socket(type=socket.SOCK_DGRAM)
# 定义默认的编码方式为 UTF-8
self.encoding = 'utf-8'
def bind(self, address):
# 绑定 IP 地址和端口号
self.sk.bind(address)
def my_sendto(self, message, address):
# 将消息编码为字节类型并发送到指定地址
self.sk.sendto(message.encode(self.encoding), address)
def my_recv(self, buffer_size=1024):
# 接收消息,最多接收 buffer_size 字节
data, addr = self.sk.recvfrom(buffer_size)
# 将接收到的字节数据解码为字符串
decoded_data = data.decode(self.encoding)
return decoded_data, addr
def close(self):
# 关闭 UDP 套接字
self.sk.close()
代码解释
__init__
方法:初始化MySocket
类的实例,创建一个 UDP 套接字对象,并设置默认的编码方式为 UTF - 8。bind
方法:用于绑定 IP 地址和端口号,调用底层套接字的bind
方法。my_sendto
方法:将消息编码为字节类型,并发送到指定的地址。my_recv
方法:接收消息,最多接收buffer_size
字节的数据,然后将接收到的字节数据解码为字符串。close
方法:关闭 UDP 套接字。
2. 自定义封装的 UDP 服务端
from udp_manager import MySocket
# 创建自定义的 UDP 套接字对象,自动使用 UTF - 8 编码
sk = MySocket()
# 绑定本地回环地址和端口 8080
sk.bind(("127.0.0.1", 8080))
# 接收客户端消息,自动解码
msg, addr = sk.my_recv(1024)
# 打印消息和客户端地址
print(msg, addr)
# 关闭套接字
sk.close()
代码解释
__init__
方法:初始化MySocket
类的实例,创建一个 UDP 套接字对象,并设置默认的编码方式为 UTF - 8。bind
方法:用于绑定 IP 地址和端口号,调用底层套接字的bind
方法。my_sendto
方法:将消息编码为字节类型,并发送到指定的地址。my_recv
方法:接收消息,最多接收buffer_size
字节的数据,然后将接收到的字节数据解码为字符串。close
方法:关闭 UDP 套接字。
3. 自定义封装的 UDP 客户端
from udp_manager import MySocket
# 创建自定义的 UDP 套接字对象
sk = MySocket()
# 向服务器发送消息
sk.my_sendto("你好服务器", ("127.0.0.1", 8080))
# 关闭套接字
sk.close()
代码解释
- 导入
MySocket
类,创建一个MySocket
对象。 - 向服务器发送消息。
- 关闭套接字。
三、三次握手
1. 什么是三次握手?
三次握手是TCP(传输控制协议)在建立连接时使用的过程,确保客户端和服务器双方具备可靠的双向通信能力。它通过三次报文交互确认双方的发送和接收功能正常。
2. 三次握手的具体步骤
假设客户端(Client)和服务器(Server)建立连接:
-
第一次握手(SYN)
-
客户端 → 服务器:发送
SYN=1
(同步序列号)、随机生成一个初始序列号seq=x
。 -
目的:客户端告知服务器“我想建立连接,我的初始序列号是x”。
-
-
第二次握手(SYN + ACK)
-
服务器 → 客户端:发送
SYN=1
、ACK=1
(确认),确认号ack=x+1
(表示已收到客户端的seq=x
),并随机生成服务器的初始序列号seq=y
。 -
目的:服务器回应“我收到你的请求了,同意建立连接,我的初始序列号是y”。
-
-
第三次握手(ACK)
-
客户端 → 服务器:发送
ACK=1
,确认号ack=y+1
(表示已收到服务器的seq=y
),序列号seq=x+1
(因第一次握手的SYN
占用了一个序号)。 -
目的:客户端确认服务器的响应,连接正式建立。
-
3. 为什么需要三次握手?
-
防止历史重复连接初始化造成的资源浪费
如果只有两次握手,服务器无法区分当前连接是否是因网络延迟而重传的旧SYN
请求,可能导致无效连接占用资源。 -
同步双方初始序列号(ISN)
TCP依赖序列号保证数据有序性和可靠性。三次握手确保双方均确认对方的初始序列号,为后续数据传输奠定基础。 -
双向通信能力验证
三次握手确认客户端和服务器双方的发送和接收能力均正常:-
第一次握手:服务器确认客户端发送正常。
-
第二次握手:客户端确认服务器收发正常。
-
第三次握手:服务器确认客户端接收正常。
-
4. 类比生活中的例子
想象打电话的场景:
-
客户端:“喂,听得到吗?”(SYN)
-
服务器:“听得到,你能听到我吗?”(SYN + ACK)
-
客户端:“能听到!”(ACK)
——至此双方确认通信正常,开始对话。
5. 常见问题
-
为什么不是两次或四次?
-
两次无法确认客户端的接收能力,可能导致服务器盲目发送数据。
-
四次多余,三次已能可靠确认双方能力。
-
-
SYN洪泛攻击(SYN Flood)
攻击者伪造大量SYN
报文但不完成握手,耗尽服务器资源。防御方式包括SYN Cookie
机制。
6. 总结
三次握手是TCP可靠性的核心机制之一,通过三次交互确保连接双方具备通信能力,同时避免无效连接占用资源。理解这一过程对学习网络协议和排查连接问题至关重要。
四、Python网络编程中的"粘包"问题
(一)什么是粘包问题?
专业解释:在网络编程中,粘包(TCP粘包)是指发送方发送的若干数据包到达接收方时粘成了一个包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。
通俗理解:想象你正在通过快递发送几本书给朋友。理想情况下,每本书应该单独包装发送。但实际可能发生两种情况:1) 几本书被塞进同一个箱子发送(粘包);2) 一本书被拆分成多个包裹发送(拆包)。
(二)为什么会出现粘包?
专业术语:
-
TCP是面向连接的流式协议
-
数据传输采用Nagle算法优化
-
接收缓冲区数据堆积
通俗解释:TCP为了传输效率,会把多个小数据块合并发送(就像快递公司把多个小包裹合并装箱),而且不保留数据边界。就像水流一样,你无法直接看出哪里是一滴水的开始和结束。
(三)粘包问题演示代码
# 服务端代码
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('127.0.0.1', 8888))
server.listen(5)
conn, addr = server.accept()
data = conn.recv(1024) # 接收数据
print("Received:", data.decode())
conn.close()
server.close()
# 客户端代码
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 8888))
# 发送两条消息
client.send("Hello".encode())
client.send("World".encode())
client.close()
运行上述代码,服务端可能会一次性打印"HelloWorld",而不是分开的"Hello"和"World"。
(四)解决粘包的四种方案
1. 固定长度消息
专业术语:定长报文
实现方式:每条消息都固定长度,不足部分填充空字符。
# 发送方 message = "Hello".ljust(10, ' ') # 固定10字节 client.send(message.encode()) # 接收方 data = conn.recv(10) # 每次固定接收10字节 print("Received:", data.decode().strip())
优点:实现简单
缺点:浪费带宽,不适合变长数据
2. 特殊分隔符
专业术语:分隔符界定法(Delimiter-based framing)
实现方式:用特殊字符(如\n)标记消息结束。
# 发送方
client.send("Hello\n".encode())
client.send("World\n".encode())
# 接收方
buffer = ""
while True:
data = conn.recv(1024).decode()
if not data:
break
buffer += data
while "\n" in buffer:
message, buffer = buffer.split("\n", 1)
print("Received:", message)
优点:适合文本协议
缺点:二进制数据中可能出现分隔符冲突
3. 消息长度前缀
专业术语:长度前缀法(Length-prefixed framing)
实现方式:在消息前添加长度信息。
# 发送方
message = "Hello"
client.send(len(message).to_bytes(4, 'big') + message.encode())
# 接收方
length_data = conn.recv(4)
length = int.from_bytes(length_data, 'big')
message = conn.recv(length).decode()
print("Received:", message)
优点:高效可靠
缺点:实现稍复杂
4. 使用标准协议
专业术语:应用层协议封装
推荐方案:直接使用现成的协议如HTTP、WebSocket等,它们已经解决了粘包问题。
(五)实际项目中的选择建议
-
简单文本通信:使用分隔符法(\n)
-
二进制数据传输:使用长度前缀法
-
复杂应用:直接使用现成协议(如HTTP/WebSocket)
(六)总结
粘包问题是TCP流式传输的特性导致的,不是bug而是需要考虑的特性。理解其原理后,通过合适的拆包策略可以完美解决。在实际开发中,根据数据类型和业务需求选择最适合的方案即可。
专业术语回顾:
-
粘包(TCP粘包)
-
流式协议(Stream Protocol)
-
定长报文(Fixed-length message)
-
分隔符界定法(Delimiter-based framing)
-
长度前缀法(Length-prefixed framing)