25. Socket与粘包问题
1. Socket概念
Socket允许应用程序通过它发送或接收数据,对其进行像对文件一样的打开、读写和关闭等操作,从而允许应用程序将I/O插入到网络中,并与网络中的其他应用程序进行通信。Socket是应用层与传输层之间的接口,提供了一种标准的通信方式,使得不同的程序能够在网络上进行数据交换。
Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。
在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面
对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
所以,我们无需深入理解tcp/udp协议,socket已经为我们封装好了,我们只需要遵循socket的规定去编程,写出的程序自然就是遵循tcp/udp标准的。
也有人将socket说成ip+port
ip是用来标识互联网中的一台主机的位置
而port是用来标识这台机器上的一个应用程序
ip地址是配置到网卡上的
而port是应用程序开启的
ip与port的绑定就标识了互联网中独一无二的一个应用程序
而程序的pid是同一台机器上不同进程或者线程的标识
本机中的不同程序之间交互 借助 SOCKET
2. 套接字
2.1 套接字起源
套接字起源于 20 世纪 70 年代加利福尼亚大学伯克利分校版本的 Unix,即人们所说的 BSD Unix。
因此,有时人们也把套接字称为“伯克利套接字”或“BSD 套接字” 一开始,套接字被设计用在同 一台主机上多个应用程序之间的通讯,这也被称进程间通讯或 IPC。
套接字有两种:基于文件型的、基于网络型的
2.2 基于文件型的套接字
名称:AF_UNIX
unix一切皆文件,基于文件的套接字调用的就是底层的文件系统来取数据,两个套接字进程运行在同一机器,可以通过访问同一个文件系统间接完成通信
2.3 基于网络型的套接字
名称: AF_INET
2.4 套接字工作流程
以打电话模型来说明
(1)服务端---接电话
1.拥有一台手机
2.插上手机卡
3.开机等待别人打电话进来
4.接听来电
5.听到对方说话信息
6.给对方回话
7.挂断电话
8.关机
(2)客户端---打电话
1.拥有一台手机
2.插上手机卡
3.获取对方手机号,拨打电话
4.向对方说话
5.听到对方回话
6.挂断电话
7.关机
3. TCP套接字
3.1 TCP套接字模型一
打电话模型
# (1)服务端---接电话
import socket
# 1.拥有一台手机 family:使用的是基于网络的套接字族 type:流式套接字 proto是一个默认参数为-1,当默认-1时该值为0
server = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0)
# 2.插上手机卡
addr = '127.0.0.1'
port = 9000
server.bind((addr, port)) # 绑定(主机,端口号)到套接字
# 3.开机等别人打电话进来,括号内为空默认5个
server.listen(5)
# 4.别人打电话进来,接听电话
client_socket, client_addr = server.accept()
print(client_socket)
# <socket.socket fd=404, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 9000), raddr=('127.0.0.1', 8307)>
print(client_addr)
# ('127.0.0.1', 8307)
# 5.接收对方的信息
info_from_client = client_socket.recv(1024) # 一次接收多少字节的数据
print(f'对方信息内容为:{info_from_client.decode("utf-8")}') # 传输的数据格式为二进制,解码后再展示
# 对方信息内容为:你好,这里是客户端
# 6.给对方回信息
info_to_client = f'你好,这里是服务端'
client_socket.send(info_to_client.encode("utf-8")) # 编码后再传输
# 7.挂断电话
client_socket.close()
# 8.关机
server.close()
# (2)客户端---打电话
import socket
# 1.拥有一台手机
client = socket.socket() # 括号里面不填,默认为基于网络套接字模型、tcp协议
# 2.插上手机卡
addr = '127.0.0.1'
port = 9001
# 3.获取对方手机号并拨打
client.connect(('127.0.0.1', 9000))
# 4.向对方说话
info_to_server = f'你好,这里是客户端'
client.send(info_to_server.encode()) # 编码后才能传输
# 5.听到对方回话
info_from_server = client.recv(1024) # 一次接收1024字节的数据
print(f'对方回复内容为:{info_from_server.decode()}') # 解码
# 对方回复内容为:你好,这里是服务端
# 6.挂断电话 7.关机
client.close()
模板
# (1)服务端
import socket
server = socket.socket() # 创建服务端对象
server.bind(('127.0.0.1', 9000)) # 绑定ip与端口
server.listen(5) # 监听客户端的连接
conn, addr = server.accept() # 接收到客户端的连接
recv_info = conn.recv(1024) # 接收客户端的二进制数据
send_info = '' # 向客户端发送数据
conn.send(send_info.encode('utf-8'))
conn.close() # 断开客户端连接
server.close() # 关闭服务端
# (2)客户端
import socket
client = socket.socket() # 创建客户端对象
client.connect(('127.0.0.1', 9000)) # 连接服务端ip和端口,在这个例子中不写客户端ip和端口也行,只要有对方的就能进行通信
send_info = '' # 发送数据
client.send(send_info.encode())
recv_info = client.recv(1024) # 接收服务端返回的信息
client.close() # 关闭客户端
3.2 套接字函数
(1)服务端套接字函数
s.bind() 绑定(主机,端口号)到套接字
s.listen() 开始TCP监听
s.accept() 被动接受TCP客户的连接,(阻塞式)等待连接的到来
(2)客户端套接字函数
s.connect() 主动初始化TCP服务器连接
s.connect_ex() connect()函数的扩展版本,出错时返回出错码,而不是抛出异常
(3)公共用途的套接字函数
s.recv() 接收TCP数据
s.send() 发送TCP数据(send在待发送数据量大于己端缓存区剩余空间时,数据丢失,不会发完)
s.sendall() 发送完整的TCP数据(本质就是循环调用send,sendall在待发送数据量大于己端缓存区剩余空间时,数据不丢失,循环调用send直到发完)
s.recvfrom() 接收UDP数据
s.sendto() 发送UDP数据
s.getpeername() 连接到当前套接字的远端的地址
s.getsockname() 当前套接字的地址
s.getsockopt() 返回指定套接字的参数
s.setsockopt() 设置指定套接字的参数
s.close() 关闭套接字
3.3TCP套接字模型二(通信循环)
# (1)服务端
import socket
server = socket.socket()
server.bind(('127.0.0.1', 9000))
server.listen(5)
while True:
conn, addr = server.accept() # 用来获取不同的客户端连接对象
while True: # 持续的和当前连接好的客户端进行交互
recv_info = conn.recv(1024) # 接收客户端的二进制数据
print(f'对方信息内容为:{recv_info.decode()}')
send_info = recv_info.decode().upper() # 收到客户端的小写字母后向客户端返回大写字母
conn.send(send_info.encode())
# (2)客户端 import socket client = socket.socket() client.connect(('127.0.0.1', 9000)) # 连接服务端ip和端口,在这个例子中不写客户端ip和端口也行,只要有对方的就能进行通信 while True: send_info = input('请输入给服务端的数据:') client.send(send_info.encode()) recv_info = client.recv(1024) print(f'对方回复信息内容为:{recv_info.decode()}')
3.4 TCP套接字模型三(空数据阻塞问题)
在模型二中,如果在客户端敲回车输入空数据会导致服务端和客户端阻塞
为了解决阻塞问题,在客户端输入数据时要加以判断
# (1)服务端
import socket
server = socket.socket()
server.bind(('127.0.0.1', 9000))
server.listen(5)
while True:
conn, addr = server.accept() # 用来获取不同的客户端连接对象
while True: # 持续的和当前连接好的客户端进行交互
recv_info = conn.recv(1024) # 接收客户端的二进制数据
print(f'对方发送的信息内容为:{recv_info.decode()}')
send_info = recv_info.decode().upper() # 收到客户端的小写字母后向客户端返回大写字母
conn.send(send_info.encode())
# (2)客户端
import socket
client = socket.socket()
client.connect(('127.0.0.1', 9000)) # 连接服务端ip和端口,在这个例子中不写客户端ip和端口也行,只要有对方的就能进行通信
while True:
send_info = input('请输入给服务端的数据:')
if not send_info: # 判断客户端输入内容是否是空数据,解决了空数据阻塞问题
continue
client.send(send_info.encode())
recv_info = client.recv(1024)
print(f'对方回复信息内容为:{recv_info.decode()}')
3.5 端口问题
有时在重启服务端时会遇到端口已经被占用问题,这是由于服务端仍然存在四次挥手的time_wait状态在占用地址
Windows中的解决办法:
#加入一条socket配置,重用ip和端口
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) #就是它,在bind前加
server.bind(('127.0.0.1', 9000))
Linux中的解决办法:
发现系统存在大量TIME_WAIT状态的连接,通过调整linux内核参数解决
vi /etc/sysctl.conf
编辑文件,加入以下内容
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_fin_timeout = 30
参数说明
net.ipv4.tcp_syncookies = 1
# 表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭;
net.ipv4.tcp_tw_reuse = 1
# 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
net.ipv4.tcp_tw_recycle = 1
# 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
net.ipv4.tcp_fin_timeout
# 修改系統默认的 TIMEOUT 时间
然后执行 /sbin/sysctl -p 让参数生效。
/sbin/sysctl -p
当加上了以socket配置有时重启服务会遇到如下问题,这个问题的原因是当前服务没有正常被杀死,导致端口被占用,无法再在当前端口上启动服务
Windows中的解决办法:
查看端口号进程
netstat -ano|findstr 端口号
此时能看到一串进程,最后位置的数字即为进程号
C:\Users\HalaMadrid>netstat -ano|findstr 9000
TCP 127.0.0.1:9000 0.0.0.0:0 LISTENING 2036
终止进程
taskkill /pid 进程号 /F
C:\Users\HalaMadrid>taskkill /pid 2036 /F
成功: 已终止 PID 为 2036 的进程。
重启服务端,问题解决
Linux中的解决办法:
ps aux|grep 端口号
kill 进程号 -9
kill 命令格式
使用kill -l命令列出所有可用的信号。
最常被使用的信号是1/9/15:
1(HUP):重新加载进程。
9 (KILL):杀死进程。
15(TERM):完美地停止一个进程。
kill pid //同下-15默认的安全停止进程
kill -15 pid //
kill -9 pid //彻底杀死进程
3.6 TCP套接字模型四(发送指令让对方停止)
# (1)服务端
import socket
server = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 解决端口被占用的问题
server.bind(('127.0.0.1', 9000))
server.listen(5)
while True:
conn, addr = server.accept() # 用来获取不同的客户端连接对象
print(f"客户端地址:{addr}")
while True:
try:
info_from_client = conn.recv(1024)
if not info_from_client: # 判断客户端是否已经断开连接,如果客户端断开连接,则这里接收到的数据是空,就会一直循环
break
if info_from_client.decode() == 'q': # 客户端发送q时也停止
break
print(f'客户端发来的信息为:{info_from_client.decode()}')
info_to_client = input('请输入回复给客户端的信息:')
conn.send(info_to_client.encode())
except Exception as e:
break
conn.close()
# server.close()
当服务端运行在conn.close()之后运行server.close()时,由于服务端已将套接字彻底关闭,报以下错误
# (2)客户端
import socket
client = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) # type与TCP的有区别
client.connect(('127.0.0.1', 9000))
while True:
info_to_server = input('发给服务端信息的内容为:')
if not info_to_server: # 判断客户端输入的内容是否为空数据,解决了空数据导致客户端服务端阻塞问题
continue
client.send(info_to_server.encode())
if info_to_server == 'q':
break
info_from_server = client.recv(1024)
if info_from_server.decode('utf8') == 'q':
break
print(f"服务端回复的信息内容为:{info_from_server.decode('utf8')}")
如果是客户端要开多个进程就在client编辑配置
在运行---edit configuations 里面可以勾选 multiple instance (允许当前实例多开)
多开的客户端对象在链接服务端之后,服务端没办法接收到除了第一个链接的客户端以外的客户端数据
原因是当前的服务端只有一个单进程没有多进程所以只能接收到一个客户端的数据交互
如果想要让第二个客户端和服务端交互就必须将第一个客户端结束才能进行交互
4. UDP套接字
4.1 UDP套接字模板
udp是无链接的,先启动哪一端都不会报错
服务端
server = socket() #创建一个服务器的套接字
server.bind() #绑定服务器套接字
inf_loop: #服务器无限循环
conn = server.recvfrom()/conn.sendto() # 对话(接收与发送)
server.close() # 关闭服务器套接字
客户端
client = socket() # 创建客户套接字
comm_loop: # 通讯循环
client.sendto()/client.recvfrom() # 对话(发送/接收)
client.close() # 关闭客户套接字
4.2 UDP套接字模型一
(1)服务端
import socket
server = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) # type与TCP的有区别
server.bind(('127.0.0.1', 9000)) # 参数以元组形式
info_from_client, client_addr = server.recvfrom(1024)
print(f'对方发来的信息内容为:{info_from_client.decode("utf-8")}')
info_to_client = info_from_client.decode("utf-8").upper() # 收到客户端的小写字母返回对应大写字母
server.sendto(info_to_client.encode('utf-8'), client_addr)
# (2)客户端
import socket
client = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) # type与TCP的有区别
info_to_server = input('发给服务端信息的内容为:')
client.sendto(info_to_server.encode('utf8'), ('127.0.0.1', 9000))
info_from_server = client.recv(1024)
print(f"服务端回复的信息内容为:{info_from_server.decode('utf8')}")
4.3 UDP套接字模型二(通信循环)
# (1)服务端
import socket
server = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) # type与TCP的有区别
server.bind(('127.0.0.1', 9000)) # 参数以元组形式
while True: # 持续地和连接好的客户端进行交互
info_from_client, client_addr = server.recvfrom(1024)
print(f'对方发来的信息内容为:{info_from_client.decode("utf-8")}')
info_to_client = info_from_client.decode("utf-8").upper() # 收到客户端的小写字母返回对应大写字母
server.sendto(info_to_client.encode('utf-8'), client_addr)
# (2)客户端
import socket
client = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) # type与TCP的有区别
while True:
info_to_server = input('发给服务端信息的内容为:')
if not info_to_server: # 判断客户端输入的内容是否为空数据,解决了空数据导致客户端服务端阻塞问题
continue
client.sendto(info_to_server.encode('utf8'), ('127.0.0.1', 9000))
info_from_server = client.recv(1024)
print(f"服务端回复的信息内容为:{info_from_server.decode('utf8')}")
5. 粘包问题
5.1 概念
TCP协议是流式协议,数据源源不断的传入到服务端中,但是服务端可以接收的信息长度是有限的
当接收指定长度的信息后,服务端进行打印,剩余的其它数据会被缓存到内存中
当再次执行其它命令时,新的数据会叠加到上次没有完全打印的信息的后面,造成数据的错乱
服务端想打印新的命令的数据时,打印的其实是上一次没有打印完的数据
TCP协议中才有粘包问题,UDP协议中不存在该问题
01234 --- 一次只能传3个字节 012
56789 --- 345
5.2 解决思路
拿到数据的总量recv_total_size
recv_now_size=0,每接收一次, recv_now_size+=本次接收的长度
直到recv_now_size=recv_total_size表示接收信息完毕,结束循环
5.3 问题引出与解决
问题:
在客户端输入ipconfig,在服务端执行该命令并获取执行命令后的结果,服务端返回给客户端时每次只能接收1024字节的数据,没有接收完的数据会随着下一次客户端的输入而发送
解决思路:
(1)服务端如何让客户端知道数据量?
服务端运行命令得到字符串结果---字符串转为二进制数据---计算二进制数据的大小
发送二进制数据的大小(使用struct模块将二进制数据大小打包成四个字节的二进制)---发送字符串结果
(2)客户端如何从总的数据中依次提取出所有数据?
使用struct模块进行解包
# (1)服务端
import socket
import struct
import subprocess
def run(order):
# 特别要注意subprocess模块在win和Linux中的用法
obj = subprocess.Popen(
[order,], # 需要执行的命令, win的命令必须放在列表中
shell=True, # win和Linux都是True
stdout=subprocess.PIPE, # 正确的结果放入管道中
stderr=subprocess.PIPE, # 错误的结果放入管道中
)
# 读取结果
true_result = obj.stdout.read().decode('gbk')
false_result = obj.stderr.read().decode('gbk')
return true_result if true_result else false_result # 将结果返回
server = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 防止端口被占用
server.bind(('127.0.0.1', 9000))
server.listen(5)
while True:
conn, addr = server.accept()
print(f'客户端的ip和端口为:{addr}')
while True:
try:
info_from_client = conn.recv(1024)
# 判断客户端是否已断开连接,如果客户端已断开连接则这里接收到的数据是空,就会一直循环
if not info_from_client:
break
command = info_from_client.decode('utf-8')
if command == 'q':
break
print(f'客户端发送过来的指令为:{command}')
info_to_client = run(order=command) # 获取命令运行结果
info_to_client_bytes = info_to_client.encode('utf-8') # 转成二进制
# 统计二进制数据大小
size = len(info_to_client_bytes)
print(size)
# 用struct模块将上面的字节总数打包成四个字节的二进制数据
pack_size = struct.pack('i', size) # 二进制数据才能在TCP中传输
# 发送数据
conn.send(pack_size) # 先发送数据量大小
conn.send(info_to_client_bytes) # 再发送总数据
except Exception as e:
break
conn.close()
# (2)客户端
import socket
import struct
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 9000))
while True:
info_to_server = input('请输入给服务端的数据:')
if not info_to_server: # 解决输入空数据导致客户端服务端阻塞问题
continue
if info_to_server == 'q':
break
client.send(info_to_server.encode('utf-8'))
pack_size = client.recv(4) # 最先接收到的是四个字节的二进制数据
size = struct.unpack('i', pack_size)[0] # 二进制类型转换为数字类型
# 遍历每一次的固定值提取数据
count = 0
data = b'' # 因为每次从服务端传输过来的二进制数据,定义一个空的二进制类型用于拼接
cache_size = 1024
while count < size:
data += client.recv(cache_size) # 每次提取1024字节数据拼接到总数据中
count += cache_size # 计算已经接收了多少数据
result = data.decode('utf-8')
if result == 'q':
break
print(f'服务端传输过来的信息为\n{result}')
5.4 struct模块补充
x --- 填充字节
c --- char类型,占1字节
b --- signed char类型,占1字节
B --- unsigned char类型,占1字节
h --- short类型,占2字节
H --- unsigned short类型,占2字节
i --- int类型,占4字节
I --- unsigned int类型,占4字节
l --- long类型,占4字节(32位机器上)或者8字节(64位机器上)
L --- unsigned long类型,占4字节(32位机器上)或者8字节(64位机器上)
q --- long long类型,占8字节
Q --- unsigned long long类型,占8字节
f --- float类型,占4字节
d --- double类型,占8字节
s --- char[]类型,占指定字节个数,需要用数字指定长度
p --- char[]类型,跟s一样,但通常用来表示字符串
? --- bool类型,占1字节
5.5 TCP套接字传输图片文件
# (1)服务端
import socket
import struct
import os
import json
import hashlib
def read_picture(file_name):
file_path = os.path.join(os.path.dirname(__file__), file_name + '.jpeg')
with open(file_path, 'rb') as f1:
picture_data = f1.read()
return picture_data
server = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 防止端口被占用
server.bind(('127.0.0.1', 9000))
server.listen(5)
while True:
conn, addr = server.accept()
print(f'客户端的ip和端口为:{addr}')
while True:
try:
info_from_client = conn.recv(1024)
# 判断客户端是否已断开连接,如果客户端已断开连接则这里接收到的数据是空,就会一直循环
if not info_from_client:
break
filename = info_from_client.decode('utf-8')
if filename == 'q':
break
pic_bytes = read_picture(file_name=filename) # 读取客户端发送过来的文件名的图片二进制
size = len(pic_bytes) # 统计二进制数据的大小
tool = hashlib.md5() # 验证数据的完整性
tool.update(pic_bytes)
# 拼接一个数据字典:文件名、文件哈希值、文件类型、文件大小
file_info_dict = {'filename': filename, 'size': size, 'filetype': 'jpeg', 'hash': tool.hexdigest()}
file_info_dict_str = json.dumps(file_info_dict)
file_info_dict_bytes = file_info_dict_str.encode('utf-8')
# 用struct模块将字典长度打包为四个字节的二进制
pack_dict = struct.pack('i', len(file_info_dict_bytes))
conn.send(pack_dict) # 发送字典长度打包后的数据
conn.send(file_info_dict_bytes) # 发送字典二进制数据
conn.send(pic_bytes) # 发送图片二进制
except Exception as e:
break
conn.close()
# (2)客户端
import socket
import struct
import hashlib
import json
import os
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 9000))
while True:
info_to_server = input('请输入传输给服务端的文件名:')
if not info_to_server: # 解决输入空数据导致客户端服务端阻塞问题
continue
if info_to_server == 'q':
break
client.send(info_to_server.encode('utf-8'))
# 按照服务端发送顺序接收的内容依次为:字典长度打包后的数据、字典二进制数据、图片二进制
pack_dict = client.recv(4) # 接收字典长度打包后的四个字节二进制
dict_size = struct.unpack('i', pack_dict)[0] # 解包为十进制数字
file_info_dict_bytes = client.recv(dict_size) # 接收服务端发送的第二个数据,长度为字典长度(十进制数字)
file_info_dict = json.loads(file_info_dict_bytes) # 重要:json数据编码后可以用loads直接转换为python数据,不用进行解码
pic_size = file_info_dict.get('size') # 获取图片大小
# 遍历每一次的固定值提取数据
count = 0
data = b'' # 因为每次从服务端传输过来的二进制数据,定义一个空的二进制类型用于拼接
cache_size = 1024
while count < pic_size:
data += client.recv(cache_size) # 每次提取1024字节数据拼接到总数据中
count += cache_size # 计算已经接收了多少数据
# 计算拼接总数据的哈希值
tool2 = hashlib.md5()
tool2.update(data)
# 从字典中取出原本的哈希值
pre_hash = file_info_dict.get('hash')
# 校验哈希
if pre_hash != tool2.hexdigest():
print('文件已经损坏')
break
# 文件名称拼接
filename = file_info_dict.get('filename')
filetype = file_info_dict.get('filetype')
file_path = os.path.join(os.path.dirname(__file__), filename + '2' + '.' + filetype)
with open(file_path, 'wb') as f1:
f1.write(data)