1. 客户端异常退出
# 当客户端非正常断开连接,服务端是没有断开连接的,这时候通道关闭,接收不到东西,就会报错. # (1)针对window:用try..except..捕获异常,跳出循环 # (2)针对linux:ret= conn.recv(1024)会返回空字符串,直接用len(ret)来判断跳出循环. # ------------------------- 例1 --------------------------------- # ### 服务端 ### import socket server = socket.socket() # 创建服务端对象 server.bind(('192.168.3.21', 8000)) # 绑定ip和端口 server.listen(5) # 后面可以排队客户端数目 while 1: print("server is working") conn, addr = server.accept() # 阻塞:永远等待连接 # 字节类型 while 1: data = conn.recv(1024) # 阻塞:永远等待接收 if data == b'exit': break response = data + b' SB' conn.send(response) conn.close() # 与客户端断开连接(放开那把伞) # ### 客户端 ### import socket sk = socket.socket() # 创建客户端对象 sk.connect(('192.168.3.21', 8000)) # 根据ip和端口连接 while 1: name = input("请输入姓名:") sk.send(name.encode('utf-8')) # 字节 if name == 'exit': break response = sk.recv(1024) # 字节 print(response.decode('utf-8')) sk.close() # 关闭自己 # ---------------------------分析例1的问题 ---------------------------- 发现问题: 如果客户端没有走正常逻辑关闭,异常退出,那么服务端的conn.recv(1024)这行代码会报错?ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接。 分析: 为什么会报错? 客户端关闭,断开连接,服务端还在等待连接,那么conn已经不存在了,当然会报错,conn实际上指的就是客户端对象.或者说conn是它递过来的伞,也不存在了. 解决方法:
windows: 用try..except..捕获异常(ConnectionResetError),然后break跳出循环,断开服务端连接
linux: linux中conn关闭会返回空字符串,直接用len(xonn)来判断处理跳出循环,断开服务端连接 # ------------------------------ 例1修改后 ------------------------- # ### 服务端 ### import socket server = socket.socket() server.bind(('192.168.3.21', 8000)) server.listen(5) while 1: print("server is working") conn, addr = server.accept() # 阻塞:永远等待连接 # 字节类型 while 1: # 针对windows try: # windows中需要用try,except捕捉客户端强制关闭的异常 data = conn.recv(1024) # 阻塞:永远等待接收 if data == b'exit': break response = data + b' SB' conn.send(response) except ConnectionResetError: break # 而在linux中conn关闭会返回空字符串,直接用len(conn)判断 # data = conn.recv(1024) # if len(data) == 0: # break # if data == b'exit': # break # response = data + b' SB' # conn.send(response) conn.close() # 与客户端断开连接(放开那把伞)
2. 粘包现象
让我们基于tcp先制作一个远程执行命令的程序(模拟shh) # --------------------- 准备工作1 ----------------------- 我们知道利用 os.popen可以执行终端的命令 现在我们来学习一下subprocess模块中和os.popen一样的功能 # 示例 import subprocess res = subprocess.Popen('dir', shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE) # 从终端出来的结果,windows默认是gbk编码,所以解码也要用gbk print(res.stdout.read().decode('gbk')) 注意注意注意: res=subprocess.Popen(cmd.decode('utf-8'), shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE) 的结果的编码是以当前所在的系统为准的,如果是windows,那么res.stdout.read()读出的就是GBK编码的,在接收端需要用GBK解码且只能从管道里读一次结果,第二次就取的就是空 # ------------------------------- 模拟ssh ------------------------- # ### 服务端 ### import socket import subprocess ip_port = ('192.168.3.21', 8888) BUFSIZE = 1024 tcp_socket_server = socket.socket() tcp_socket_server.bind(ip_port) tcp_socket_server.listen(5) while True: conn,addr=tcp_socket_server.accept() print('客户端',addr,conn) while True: cmd=conn.recv(BUFSIZE) print("接收到的命令:%s" % cmd.decode('utf-8')) if len(cmd) == 0: break res=subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE ) stderr = res.stderr.read() stdout = res.stdout.read() conn.send(stdout) # ### 客户端 ### import socket BUFSIZE = 1024 ip_port = ('192.168.3.21', 8888) s = socket.socket(socket.AF_INET,socket.SOCK_STREAM) s.connect(ip_port) while True: msg=input('>>: ').strip() if len(msg) == 0: continue if msg == 'quit': break s.send(msg.encode('utf-8')) data = s.recv(BUFSIZE) print(data.decode('gbk')) # ---------------------------------------------------------------- 上述程序是基于tcp的socket,在运行时会发生粘包 客户端发送hello,如果服务端 recv(2) ,那只能接收到 he 这两个个字符,然后再recv(2) 一下,可以再接收 ll 两个字符 因为客户端发送的结果长,所以只能把其他的先缓存下来,下次recv的时候再去接收。 这就是粘包,即两次结果粘到一起了。
注意:只有TCP有粘包现象, UDP永远不会粘包
3. 粘包的成因
TCP协议中的数据传递
1、tcp协议的拆包机制 当发送端缓冲区的长度大于网卡的MTU时,tcp会将这次发送的数据拆成几个数据包发送出去。 MTU是Maximum Transmission Unit的缩写。意思是网络上传送的最大数据包。MTU的单位是字节。 大部分网络设备的MTU都是1500。如果本机的MTU比网关的MTU大,大的数据包就会被拆开来传送,这样会产生很多数据包碎片,增加丢包率,降低网络速度。 2、面向流的通信特点和Nagle算法 TCP(transport control protocol,传输控制协议)是面向连接的,面向流的,提供高可靠性服务。 收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。 这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。 即面向流的通信是无消息保护边界的。 对于空消息:tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),也可以被发送,udp协议会帮你封装上消息头发送过去。 可靠黏包的tcp协议:tcp的协议数据不会丢,没有收完包,下次接收,会继续上次继续接收,己端总是在收到ack时才会清除缓冲区内容。数据是可靠的,但是会粘包。 3. 基于TCP协议特点的粘包现象成因 粘包发生的原因是 socket 缓冲区导致的,如图:
你的程序实际上无权直接操作网卡的,你操作网卡都是通过操作系统给用户程序暴露出来的接口,那每次你的程序要给远程发数据时,其实是先把数据从用户态copy到内核态,这样的操作是耗资源和时间的,频繁的在内核态和用户态之前交换数据势必会导致发送效率降低, 因此socket 为提高传输效率,发送方往往要收集到足够多的数据后才发送一次数据给对方。若连续几次需要send的数据都很少,通常TCP socket 会根据优化算法把这些数据合成一个TCP段后一次发送出去,这样接收方就收到了粘包数据。 注意: 粘包现象只存在于TCP中,不存在UDP中. 还是看上图,发送端可以是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据,也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议,这也是容易出现粘包问题的原因。而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。怎样定义消息呢?可以认为对方一次性write/send的数据为一个消息,需要明白的是当对方send一条信息的时候,无论底层怎样分段分片,TCP协议层会把构成整条消息的数据段排序完成后才呈现在内核缓冲区。 例如基于tcp的套接字客户端往服务端上传文件,发送时文件内容是按照一段一段的字节流发送的,在接收方看了,根本不知道该文件的字节流从何处开始,在何处结束 所谓粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的
总结: 1. TCP(transport control protocol,传输控制协议)是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。 即面向流的通信是无消息保护边界的。 2. UDP(user datagram protocol,用户数据报协议)是无连接的,面向消息的,提供高效率服务。不会使用块的合并优化算法,由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了。 即面向消息的通信是有消息保护边界的。 3. tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),那也不是空消息,udp协议会帮你封装上消息头
4. 发生粘包时怎么解决粘包?
4.1 会发生粘包的两种情况 # ----------------- 情况一 发送方的缓存机制 ----------------------- # 发送端需要等缓冲区满才发送出去,造成粘包(发送数据时间间隔很短,数据了很小,会合到一起,产生粘包) # ### 服务端 ### import socket server = socket.socket() server.bind(('192.168.3.21', 8000)) server.listen(5) conn, adrr = server.accept() data1 = conn.recv(10) # 这里一次就接完了 data2 = conn.recv(10) # 这里没接到 print('----->', data1.decode('utf-8')) # -----> hellomaigc print('----->', data2.decode('utf-8')) # -----> conn.close() # ### 客户端 ### import socket sk = socket.socket() sk.connect(('192.168.3.21', 8000)) sk.send(b'hello') sk.send(b'maigc') sk.close() # --------------------- 情况二 接收方的缓存机制 ----------------- # 接收方不及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包) # ### 服务端 ### import socket server = socket.socket() server.bind(('192.168.3.21', 8000)) server.listen(5) conn, adrr = server.accept() data1 = conn.recv(2) # 这里一次没接完 data2 = conn.recv(10) # 这里继续接剩下的 print('----->', data1.decode('utf-8')) # -----> he print('----->', data2.decode('utf-8')) # -----> llomaigc conn.close() # ### 客户端 ### import socket sk = socket.socket() sk.connect(('192.168.3.21', 8000)) sk.send(b'hello') sk.send(b'maigc') sk.close() # ----------------------------------------------------------
# 总结: 黏包现象只发生在tcp协议中: # 1.从表面上看,黏包问题主要是因为发送方和接收方的缓存机制、tcp协议面向流通信的特点。 # 2.实际上,主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的
4.2 怎么解决粘包 # ------------------------------ 解决方案1 ------------------------ # 问题的根源在于,接收端不知道发送端将要传送的字节流的长度,所以解决粘包的方法就是围绕,如何让发送端在发送数据前,把自己将要发送的字节流总大小让接收端知晓,然后接收端来一个死循环接收完所有数据 示意图:
# ### 服务端 ### import socket,subprocess ip_port=('127.0.0.1',8080) s=socket.socket(socket.AF_INET,socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(ip_port) s.listen(5) while True: conn,addr=s.accept() print('客户端',addr) while True: msg=conn.recv(1024) if not msg:break res=subprocess.Popen(msg.decode('utf-8'),shell=True, stdin=subprocess.PIPE, stderr=subprocess.PIPE, stdout=subprocess.PIPE) err=res.stderr.read() if err: ret=err else: ret=res.stdout.read() data_length=len(ret) conn.send(str(data_length).encode('utf-8')) data=conn.recv(1024).decode('utf-8') if data == 'recv_ready': conn.sendall(ret) conn.close() # ### 客户端 ### import socket,time s=socket.socket(socket.AF_INET,socket.SOCK_STREAM) res=s.connect_ex(('127.0.0.1',8080)) while True: msg=input('>>: ').strip() if len(msg) == 0:continue if msg == 'quit':break s.send(msg.encode('utf-8')) length=int(s.recv(1024).decode('utf-8')) s.send('recv_ready'.encode('utf-8')) send_size=0 recv_size=0 data=b'' while recv_size < length: data+=s.recv(1024) recv_size+=len(data) print(data.decode('utf-8')) # 方案存在的问题: 程序的运行速度远快于网络传输速度,所以在发送一段字节前,先用send去发送该字节流长度,这种方式会放大网络延迟带来的性能损耗
# --------------------------- 解决方案2(进阶) ------------------- # 刚刚的方法,问题在于我们我们在发送 # 我们可以借助一个模块,这个模块可以把要发送的数据长度转换成固定长度的字节。这样客户端每次接收消息之前只要先接受这个固定长度字节的内容看一看接下来要接收的信息大小,那么最终接受的数据只要达到这个值就停止,就能刚好不多不少的接收完整的数据了。 # -------- 预备知识:struct模块基本用法 --------- import struct # 压包: 无论这个整型数字是几位,都给他压成4位(固定)的字节 res = struct.pack('i', 1331211111) print(res) print(len(res)) # 解包: 把这个四位的字节解出来就是一个元组,里面是数字 obj = struct.unpack('i', res) print(obj[0]) # ### 服务端 ### import socket import subprocess import struct server = socket.socket() server.bind(('192.168.3.21', 8008)) server.listen(5) while 1: print("server is working") conn, addr = server.accept() # 阻塞:永远等待连接 # 字节类型 while 1: # 针对windows系统 try: cmd = conn.recv(1024).decode('utf-8') # 阻塞:永远等待接收 if cmd == b'exit': break res = subprocess.Popen(cmd, shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE, ) # print("stdout:", res.stdout.read()) # print("stderr:", res.stderr.read()) out = res.stdout.read() # read()代表拿出来,拿出来就是空,,只能拿出来一次 err = res.stderr.read() # 终端获取到的是gbk编码, 并且这里send发送的不能为空 print("响应长度- out:%d err:%d" % (len(out), len(err))) if err: # 构建报头 header_back = struct.pack('i', len(err)) # 发送报头 conn.send(header_back) # 发送数据 conn.send(err) else: # 构建报头 header_back = struct.pack('i', len(out)) # 发送报头 conn.send(header_back) # 发送数据 conn.send(out) except ConnectionResetError: break conn.close() # ### 客户端 ### import socket import struct sk = socket.socket() sk.connect(('192.168.3.21', 8008)) while 1: cmd = input("请输入命令:") sk.send(cmd.encode('utf-8')) # 字节 if cmd == "": continue if cmd == 'exit': break # 发过来的两个数据发生粘包, 但是我只取前四个字节(报头) head_pack = sk.recv(4) # 解包得到元组(长度,) 取索引0 data_size = struct.unpack('i', head_pack)[0] print("data_size", data_size) recv_data_length = 0 # 接收到的数据的长度累积 response_data = b"" # 接收到的字节都叠加放入 # 取了四个字节的报头,剩下的数据也要循环取出,累加到定义的空字节中 while recv_data_length < data_size: # 一次收到的数据(字节) data = sk.recv(1024) # 把数据叠加 response_data += data # 数据长度叠加(这里不加1024,因为后可能还要用到收到的数据长度) recv_data_length += len(data) print(response_data.decode('gbk')) sk.close()
拆包的发生情况: 当发送端缓冲区的长度大于网卡的MTU时,tcp会将这次发送的数据拆成几个数据包发送出去。
补充问题一:为何tcp是可靠传输,udp是不可靠传输 tcp在数据传输时,发送端先把数据发送到自己的缓存中,然后协议控制将缓存中的数据发往对端,对端返回一个ack=1,发送端则清理缓存中的数据,对端返回ack=0,则重新发送数据,所以tcp是可靠的 而udp发送数据,对端是不会返回确认信息的,因此不可靠 补充问题二:send(字节流)和recv(1024)及sendall recv里指定的1024意思是从缓存里一次拿出1024个字节的数据 send的字节流是先放入己端缓存,然后由协议控制将缓存内容发往对端,如果待发送的字节流大小大于缓存剩余空间,那么数据丢失,用sendall就会循环调用send,数据不会丢失
补充知识1:send和sendall
send()的返回值是发送的字节数量,这个数量值可能小于要发送的string的字节数,也就是说可能无法发送string中所有的数据。如果有错误则会抛出异常
sendall()尝试发送string的所有数据,成功则返回None,失败则抛出异常。
所以
socket.sendall("hello/n")
等同于
buffer = "hello/n"
while buffer: # 当buffer没有被发完时继续发送
bytes = socket.send(buffer)
buffer = buffer[bytes:]
补充知识2:
问题:有的同学在重启服务端时可能会遇到
解决方法:
# 加入一条socket配置,重用ip和端口
import socket
from socket import SOL_SOCKET,SO_REUSEADDR
sk = socket.socket()
sk.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) #就是它,在bind前加
sk.bind(('127.0.0.1',8898)) #把地址绑定到套接字
sk.listen() #监听链接
conn,addr = sk.accept() #接受客户端链接
ret = conn.recv(1024) #接收客户端信息
print(ret) #打印客户端信息
conn.send(b'hi') #向客户端发送信息
conn.close() #关闭客户端套接字
sk.close() #关闭服务器套接字(可选)
5. 补充: hashlib经典用法 -- 文件一致性校验
# 首先,我们要知道,hashilib.md5算法,不管多长的数据,加密后都是32位,不可反解,用"加盐"预防撞库,注意update进去的只能是字节. # 但是我们现在要学习的是,hashlib模块追加摘要 # 追加摘要 import hashlib md5 = hashlib.md5() md5.update("hello".encode('utf-8')) md5.update("yuan".encode('utf-8')) # 追加摘要,不会覆盖 data = md5.hexdigest() print(data) print(len(data)) # helloyuan: d843cc930aa76f7799bba1780f578439 # hello: 5d41402abc4b2a76b9719d911017c592 # 先hello再yuan(连着两次update) 跟helloyuan 的摘要一样: d843cc930aa76f7799bba1780f578439 # 解释:追加摘要,就是一个大的数据可以多次update进去,最后拿出来,和一次性update进去的结果是一样的,但是这样做不会浪费内存
# -------------------------- 示例 ------------------------ # ### 一次性update进去 ### md5 = hashlib.md5() with open(r'D:pythonprojectday28 4sshssh-client.py', 'rb') as f: data = f.read() md5.update(data) # 将数据一次update进去 print(md5.hexdigest()) # ### 一行一行update进去(追加摘要) ### md5 = hashlib.md5() with open(r'D:pythonprojectday28 4sshssh-client.py', 'rb') as f: for line in f: md5.update(line) print(md5.hexdigest()) # f.read() # 结果是一样的: # 一次全update进去: 8fa40a6a63db369eced859644f27e961 # 一行一行update进去: 8fa40a6a63db369eced859644f27e961