week6 day2 网络编程之socket的使用和处理粘包问题

一. socket是什么

socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一个接口。在设计模式中,socket其实是就是一个门面模式,它把复杂的TCP/IP协议隐藏在socket接口后面,对用户来说,一组简单的接口就是全部,让socket取组织数据,以符和指定的协议。

所以,我们无需深入理解tcp/udp协议,socket已经为我们封装好了,我们只需要遵循socket的规定去编程,写出程序自然就是遵循tcp/udp协标准的。

二. 套接字发展史及分类(了解)

套接字起源于 20 世纪 70 年代加利福尼亚大学伯克利分校版本的 Unix,即人们所说的 BSD Unix。 因此,有时人们也把套接字称为“伯克利套接字”或“BSD 套接字”。一开始,套接字被设计用在同 一台主机上多个应用程序之间的通讯。这也被称进程间通讯,或 IPC。套接字有两种(或者称为有两个种族),分别是基于文件型的和基于网络型的。 

基于文件类型的套接字家族

套接字家族的名字:AF_UNIX

unix一切皆文件,基于文件的套接字调用的就是底层的文件系统来取数据,两个套接字进程运行在同一机器,可以通过访问同一个文件系统间接完成通信

基于网络类型的套接字家族
套接字家族的名字:AF_INET

(还有AF_INET6被用于ipv6,还有一些其他的地址家族,不过,他们要么是只用于某个平台,要么就是已经被废弃,或者是很少被使用,或者是根本没有实现,所有地址家族中,AF_INET是使用最广泛的一个,python支持很多种地址家族,但是由于我们只关心网络编程,所以大部分时候我么只使用AF_INET)

三. 套接字工作流程和语法规范

一个生活场景。你要给朋友打电话,先拨号,朋友听到电话铃声后接起电话,这是你和朋友就建立起了连接,就可以讲话了。等交流结束,挂断电话结束此次交谈。生活中的场景就解释了这个工作原理。
在这里插入图片描述
先从服务器说起。服务器端先初始化socket(买手机),然后绑定网址与端口号(插入手机卡),进入登录状态listen(开机准备),收到了别人请求建立联系的请求(别人打来电话),建立通道conn,拿到对方的ip地址addr(拿起电话),根据需求决定谁先发送数据send谁先接收数据recv(谁先讲话),说完话后,先关闭通道conn.close()(挂电话),再关掉服务端(可选,phone.close())。

下面是所有的语法规范:

服务端套接字函数:

用法功能
s.bind()绑定(主机,端口号)到套接字
s.listen()开始TCP监听
s.accept()被动接受TCP客户的连接,(阻塞式)等待连接的到来

客户端套接字函数:

用法功能
s.connect()主动初始化TCP服务器连接
s.connect_ex()connect()函数的扩展版本,出错时返回出错码,而不是抛出异常

公共用途的套接字函数:

用法功能
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()关闭套接字

面向锁的套接字方法
s.setblocking()|设置套接字的阻塞与非阻塞模式
s.settimeout()|设置阻塞套接字操作的超时时间
s.gettimeout()|得到阻塞套接字操作的超时时间

面向文件的套接字的函数
s.fileno() 套接字的文件描述符
s.makefile() 创建一个与该套接字相关的文件

四. 基于TCP协议的套接字

下面是代码示范:

# 服务端示范代码
import socket
# 先买手机(实例化一个对象)
phone=socket.socket(socket.AF_INET/socket.AF_UNIX,SOCK_STREAM/SOCK_DGRAM) # SOCK_STREAM是tcp协议,需要建立连接,SOCK_DGRAM是UDP协议
# 插入电话卡
phone.bind(('127.0.0.1',端口号)) # 这个网址应该是服务器自己的ip地址,确保用户端可以访问到客户端。端口号0-65535,1024后面的都可用,自定义一个端口号。
# 开机
phone.listen(5) # listen进入聆听阶段,括号内是半连接池的数量,半连接池是可以排队等待的请求的数量
# 接到了电话
conn,addr=phone.accept() # 建立通路conn,拿到了客户端的地址,解压赋值
# 根据我们之前学到的知识,所有数据传输基于TCP协议必须先建立链接,这里的conn就是链接
conn.send(字节)/conn.recv(字节) # 先发or先收取决于产品需求,客户端那边正好相反,先收or先发
data=conn.recv(字节) # 因为数据层数据的传输都是以二进制数的形式传输的,因此,想把字节形式的数据以字符串数据的形式拿到需要decode
print(data.decode('utf-8'))
# 别忘了传输结束关闭通路.手机可以不用关闭
conn.close()

# 客户端示范代码
import socket
phone=socket.socket(与客户端完全相同的格式选择,ipv4 or unix,tcp or udp)
# 客户端的手机不需要插卡,直接拨号就可以了
phone.connect((小元组内放服务端的ip地址和端口号)) # ip地址和端口号可以确定世界上独一无二的基于网络通信的一个用程序
# 发送或者接收数据
phone.send(字节)/data=phone.recv(字节)
print(data.decode('utf-8'))
# 服务端不需要关机,但客户端必须关机
phone.close()

但是现在客户端和服务端的数据传输只能进行一次,我们能不能进行多次数据传输让用户选择结束传输的时间呢?示范代码如下:

# 服务端先发连接成功通知,再进行数据传输
import socket
phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM) # TCP协议是流式协议,像水流一样
phone.bind(('127.0.0.1',8080))
phone.listen(5)
conn,addr=phone.accept()
phone.send('您已成功连接服务端!'.encode('utf-8'))
while True:
	data=conn.recv(1024)
	print(data.decode('utf-8'))
	send_msg=input('请输入您想回复的信息:').strip()
	conn.send(send_msg.encode('utf-8'))
conn.close()

# 客户端
import socket
phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.connect((服务端ip和端口号))
while True:
	data=phone.recv(1024)
	print(data.decode('utf-8'))
	send_msg=input('>>>(q退出):').strip()
	if send_msg.lower()=='q':
		break
	phone.send(send_msg.encode('utf-8'))
phone.close()

这样虽然实现了交互式的输入输出,但是出现了新的两个问题,bug1如果客户端或者服务端发出了空白消息/换行符,会发现程序卡在了原地,宕机了,这是bug1;bug2是当客户端结束服务后,服务端会报错,报错信息如下:
在这里插入图片描述
这里我们要补充一点底层的知识,再去解决上面的两个问题。

补充知识(重要!!!客户端/服务端收数据到底是跟谁收?)

看起来客户端的send和服务端的recv是同时发生的,我们这样认为是因为这个过程确实发生的速度太快了,以至于我们认为他们是同时发生的,但是事实并不是这样的。客户端连续发送十次数据,服务端只接收一次(让客户端程序原地睡3s模拟等待十次全部发送完毕后,再统一recv),这是否可行呢?当然是可行的。因为无论是发还是收,实际上都是在和自己的缓存打交道,客户端的发并不是直接发给服务端,是发给了自己的缓存;收也不是直接收到了服务端的数据,而是从自己的缓存拿数据。

客户端应用程序------------------------------------服务端应用程序
操作系统--------------------------------------------操作系统
计算机硬件-----------------------------------------计算机硬件

客户端的应用程序想要发送数据,并不是自己可以发送数据的,一定是依赖于操作系统调用硬件中的网卡把数据发出去的,应用程序无法调用网卡,但是操作系统可以。所以应用程序需要发起操作请求,给操作系统说“我要通过套接字发送一些数据,你帮我调用网卡发送一下数据”。然后操作系统通过一层一层操作调用网卡发送数据。所以数据一开始是放在操作系统的缓存里面的,再通过一系列操作将数据送到服务端的缓存里面去。而对于客户端来说同样是这样,它想要发送给操作系统的数据需要发送系统请求,然后先把数据放在缓存里面,等操作系统通过一系列内部操作,将数据传送到客户端计算机的缓存里面去。

基于上面的理论,在通信循环的情况下,客户端可以执行连续十次发送数据的请求,服务端可以立刻只读一次,这样读出的数据只有当第一次发送过来的数据,因为当服务端发起读请求后,应用程序一直在等数据,而连续十次发送数据,到达时间也是有细微差别的,所以第一次数据到缓存中立刻被读出。也可以让服务端原地等待一下,等客户端把十次数据都发送完毕再从缓存中读出,这种现象是socket收发消息的原理决定的。

补充完必备知识后,让我们回到前面的bug的修复。bug1,因为输入客户端or服务端输入为空,说明什么也没有向本机的缓存放,本机操作系统还在等待发送数据的请求,而对面计算机缓存也没有收到任何数据,还在原地等待,这里只需要加一个流程判定,如果输入长度为零,重新输入;至于bug2,因为连接是双向的,但突然客户端强行终止掉了连接的一端(撤掉了桥的一端桥墩子),但是服务端还在等待接收数据,但是连接桥已经断了。windows没有更好的解决方案,因此只能运用异常捕捉解决。可以结合我们之前学过的异常处理解决一下。同时,还必须建立链接循环。能够一直服务客户端。这种连接循环的思想非常重要,但是链接循环套通信循环的方式是暂时折中的方案。

服务端的两个特点

  1. 一直对外提供服务
  2. 并发地服务多个客户端

服务端要做的两件事我们已经总结出来了

  1. 从半连接池中取出发送了链接请求的客户端,与其建立双向链接,拿到链接对象
  2. 与链接对象,建立通信循环

我为什么重复三遍…懂得都懂

这样,我们的代码已经优化成了如下的样子:

重要!!!重要!!!重要 !!!

链接循环的思想非常重要!!!

# 服务端(包含链接循环,即一个客户端断连后,还能有其他客户端接入)
import socket
phone=socket.socket(socket.AF_INET,SOCK_STREAM)
phone.bind(('127.0.0.1',8000))
phone.listen(5)
while True:
	conn,addr=phone.accept()
	while True:
		try:
			res=conn.recv(1024)
			print(res.decode('utf-8'))
			reply=input('请输入要回复的内容:').strip()
			if len(reply)==0:
				continue
			conn.send(reply.encode('utf-8'))
		except Exception:
			break
	conn.close()
# phone.close()省略掉了

# 客户端
import socket
phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.connect((服务器的小元组数据))
while True:
	res=input('请输入您要发送的消息:').strip()
	if len(res)==0:
		continue
	phone.send(res.encode('utf-8'))
	data=phone.recv(1024)
	print(data.decode('utf-8'))
phone.close()

这里我们在listen的时候指定了半连接池,为5,但是这5个真的够吗?我们可以将上面的客户端程序复制粘贴五份,先运行服务端程序,然后逐次运行客户端的程序,目前我们所学知识,只能同时服务一个人(后面多线程可以同时服务多个人),但是可以有五个人在半连接池(招待室)等待,而第六个人想进入半连接池等待的时候,会隔一段时间后报错,这段时间也说明了第六个持续发送了一段时间请求都没有收到回复,才报错。(前面五个人全部阻塞在从服务端接收数据那个阶段了,只是给客户端发送了连接请求)。但是实际情况下因为多线程和单次通讯速度很快,半连接池是很难填满的。

四. 基于UDP协议的套接字

# 服务端
import socket
ip_port=('127.0.0.1',9000)
BUFSIZE=1024
udp_server_client=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)

udp_server_client.bind(ip_port)

while True:
    msg,addr=udp_server_client.recvfrom(BUFSIZE)
    print(msg,addr)

    udp_server_client.sendto(msg.upper(),addr)

# 客户端
import socket
ip_port=('127.0.0.1',9000)
BUFSIZE=1024
udp_server_client=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)

while True:
    msg=input('>>: ').strip()
    if not msg:continue

    udp_server_client.sendto(msg.encode('utf-8'),ip_port)

    back_msg,addr=udp_server_client.recvfrom(BUFSIZE)
    print(back_msg.decode('utf-8'),addr)

qq聊天(因为udp无连接,所以可以同时多个用户端去跟服务端通信。)

# 服务端
import socket
ip_port=('127.0.0.1',8081)
udp_server_sock=socket.socket(socket.AF_INET,socket.SOCK_DGRAM) #买手机
udp_server_sock.bind(ip_port)

while True:
    qq_msg,addr=udp_server_sock.recvfrom(1024)
    print('来自[%s:%s]的一条消息:\033[1;44m%s\033[0m' %(addr[0],addr[1],qq_msg.decode('utf-8')))
    back_msg=input('回复消息: ').strip()

    udp_server_sock.sendto(back_msg.encode('utf-8'),addr)
# 客户端1
import socket
BUFSIZE=1024
udp_client_socket=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)

qq_name_dic={
    '狗哥alex':('127.0.0.1',8081),
    '瞎驴':('127.0.0.1',8081),
    '一棵树':('127.0.0.1',8081),
    '武大郎':('127.0.0.1',8081),
}


while True:
    qq_name=input('请选择聊天对象: ').strip()
    while True:
        msg=input('请输入消息,回车发送: ').strip()
        if msg == 'quit':break
        if not msg or not qq_name or qq_name not in qq_name_dic:continue
        udp_client_socket.sendto(msg.encode('utf-8'),qq_name_dic[qq_name])

        back_msg,addr=udp_client_socket.recvfrom(BUFSIZE)
        print('来自[%s:%s]的一条消息:\033[1;44m%s\033[0m' %(addr[0],addr[1],back_msg.decode('utf-8')))

udp_client_socket.close()
# 客户端2
import socket
BUFSIZE=1024
udp_client_socket=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)

qq_name_dic={
    '狗哥alex':('127.0.0.1',8081),
    '瞎驴':('127.0.0.1',8081),
    '一棵树':('127.0.0.1',8081),
    '武大郎':('127.0.0.1',8081),
}


while True:
    qq_name=input('请选择聊天对象: ').strip()
    while True:
        msg=input('请输入消息,回车发送: ').strip()
        if msg == 'quit':break
        if not msg or not qq_name or qq_name not in qq_name_dic:continue
        udp_client_socket.sendto(msg.encode('utf-8'),qq_name_dic[qq_name])

        back_msg,addr=udp_client_socket.recvfrom(BUFSIZE)
        print('来自[%s:%s]的一条消息:\033[1;44m%s\033[0m' %(addr[0],addr[1],back_msg.decode('utf-8')))

udp_client_socket.close()

五. 粘包现象

须知:只有TCP会产生粘包现象,UDP永远不会粘包。

5.1 什么是粘包现象?

我们之前写过的基于tcp协议的服务端和客户端,最大可收的字节数是1024,那么超过1024的传送过来的的数据该怎么拿到呢?如果单条命令的字节数超过了1024个字节,只取了1024,这时下一次命令的结果又传进来了,该怎么取下一次命令的传入数据呢?我们将在这一大标题下进行详细的讲解。

比如我们想通过服务端执行客户端某些字符串的指令,我们应该怎么操作呢?

# 服务端
import socket
phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.bind((127.0.0.1),8080)
phone.listen(5)
while True:
	conn,addr=phone.accept()
	while True:
		try:
			import subprocess
			order=conn.recv(1024)
			if len(order)==0:break
			ss=subprocess.Popen(oeder.decode('utf-8'),
					shell=True,
					stdout=subprocess.PIPE,
					stuerr=subprocess.PIPE)
			res1=ss.stdout.read()
			res2=ss.stuerr.read()
			conn.send((res1+res1).decode('utf-8'))
		except Exception:
			break
	conn.close()

# 客户端
import socket
phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.connect(('127.0.0.1',8080))
while True:
	ope=input('>>>:').strip()
	if len(ope)==0: continue
	phone.send(ope.encode('utf-8'))
	res=phone.recv(1024)
	print(res.decode('utf-8')) # 如果是windows要用gbk解码
phone.close()

我们用dir命令尝试一下,发现结果没问题。再用tasklist尝试一下,发现结果无法全部打印,在服务端这边拿到了所有的结果,发送给了客户端,客户端因为设置了1024的限制只能拿到前1024个字节。

首先掌握socket收发消息的原理:
在这里插入图片描述
发送端可以是1K1K地发送数据,而接收端地应用程序可以2K2K地接收数据,当然也有可能一次提走3K或6K的数据,或者一次只提走几个字节地数据,也就是说,应用程序所看到的数据是一个整体,或者说一个流(stream),一条消息有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议,这也容易出现粘包问题的原因。而UDP是面向对象的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。怎样定义消息呢?可以认为对方一次性write或者send的数据为一条消息,需要明白对方send一条消息的时候,无论底层怎样分段分片,TCP协议会把构成整条消息的数据段排好序完成后裁成现在内核缓冲区。

例如基于TCP的套接字客户端往服务端上传文件,发送时文件内容是按照一段一段的字节流发送的,在接受方看了,根本不知道该文件的字节流从何处开始,在何处结束。

所谓粘包问题主要还是因为接受方不知道消息之间的界限,不知道一次性提取多少字节的数据造成的

此外,发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集足够多的数据后才发送一个TCP段。若连续几次需要send的数据都很少,通常TCP会根据优化算法把这些数据合成一个TCP段后一次性发送出去,这样接收方就收到了粘包数据。

  1. TCP(transport control protocol,传输控制协议)是面向链接的,面向流的,提供高可靠性服务。收发两端(客户端和服务端)都要有一一成对的socket。因此,发送端为了将多个发往数据量小的数据,合并成一个大的数据块,然后进行封包。这样,接收端,就难以分辨出来了,必须提供科学的拆包机制。即面向流的通信是无消息保护边界的。
  2. UDP(user datagram protocol,用户数据报协议)是无链接的,面向消息的,提供高效率服务。不会使用块的合并优化算法,由于UDP支持的是一对多模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了。即面向消息的通信是有消息保护边界的。
  3. TCP是基于数据流的,于是收发消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而UDP是基于数据报的,即便你输入的内容是空内容(直接回车),那也不是空消息,udp协议会帮你封装上消息头。

UDP的recvfrom是阻塞的,一个recvfrom(x)必须对唯一一个sendto(y),收完了x个字节的数据就算完成,若是y>x数据就丢失,这意味着UDP根本就不会粘包,但是会丢数据,不可靠。

TCP协议不会丢数据,没有收完包,下次接收,会继续上次的继续接收,己端总是在收到ack时才会清除缓冲区的内容。数据传输是可靠的,不会丢包,但是会粘包。

两种情况下会发生粘包

  1. 发送端需要等缓冲区满才发送数据,造成粘包(发送数据时间间隔很短,单次数据量很小,会合到一起,产生粘包)
# 服务端
#_*_coding:utf-8_*_
from socket import *
ip_port=('127.0.0.1',8080)

tcp_socket_server=socket(AF_INET,SOCK_STREAM)
tcp_socket_server.bind(ip_port)
tcp_socket_server.listen(5)

conn,addr=tcp_socket_server.accept()

data1=conn.recv(10)
data2=conn.recv(10)

print('----->',data1.decode('utf-8'))
print('----->',data2.decode('utf-8'))

conn.close()
# 客户端
#_*_coding:utf-8_*_
import socket
BUFSIZE=1024
ip_port=('127.0.0.1',8080)

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
res=s.connect_ex(ip_port)

s.send('hello'.encode('utf-8'))
s.send('feng'.encode('utf-8'))
  1. 接受方不及时接收缓冲区的包,造成了多个包接收(客户端发送了一小段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,造成粘包)
# 服务端
#_*_coding:utf-8_*_
from socket import *
ip_port=('127.0.0.1',8080)

tcp_socket_server=socket(AF_INET,SOCK_STREAM)
tcp_socket_server.bind(ip_port)
tcp_socket_server.listen(5)

conn,addr=tcp_socket_server.accept()

data1=conn.recv(2) #一次没有收完整
data2=conn.recv(10)#下次收的时候,会先取旧的数据,然后取新的

print('----->',data1.decode('utf-8'))
print('----->',data2.decode('utf-8'))

conn.close()
# 客户端
#_*_coding:utf-8_*_
import socket
BUFSIZE=1024
ip_port=('127.0.0.1',8080)

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
res=s.connect_ex(ip_port)

s.send('hello feng'.encode('utf-8'))
拆包的发生情况

当发送缓冲区的长度大于网卡的MTU时,TCP会将这次发送的数据拆成几个数据包发送出去。

补充问题一:为何TCP是可靠传输,UDP是不可靠传输?

(具体可见上一篇博客)TCP在数据传输时,发送端先把数据发送到自己的缓存中,然后协议控制将缓存中的数据发往对端,对端返回一个ack=1,发送端则会清理缓存中的数据,对端返回ack=0,则重新发送数据,所以TCP是可靠的。

补充问题二:send(字节流)recv(1024)sendall()

recv里指定的1024的意思是从缓存里面一次拿出1024个字节的数据。send的字节流是先放入己端缓存,然后由协议控制将缓存内容发往对端,如果待发送的字节流大于缓存剩余空间,那么数据丢失,用sendall就会循环调用send,数据不会丢失。

5.2 如何解决粘包现象?

5.2.1 low B方法解决粘包问题

现在我们来尝试着手解决这个粘包问题。如果我们可以先从服务端拿到执行命令的结果的长度,再按照长度去再一次取数据的话,就不会出现粘包的问题了。因此,第一版解决方案来了!

# 服务端
import socket,subprocess
IP_ADDR=('127.0.0.1',8080)
MAX_SIZE=1024
BACK_LOG=5

def execute(msg):
	ss=subprocess.Popen(
		msg,
		shell=True,
		stdout=subprocess.PIPE,
		stderr=subprocess.PIPE)
	res1=ss.stdout.read()
	res2=ss.stderr.read()
	return res1+res2

phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.bind(IP_ADDR)
phone.listen(BACK_LOG)
while True:
	conn,addr=phone.accept()
	while True:
		try:
			msg=conn.recv(MAX_SIZE)
			res=execute(msg.decode('utf-8'))
			conn.send(str(len(res)).encode('utf-8'))
			conn.send(res)
		except Exception:
			break
	conn.close()

# 客户端
import socket
IP_ADDR=('127.0.0.1',8080)
MAX_SIZE=1024

phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.connect(IP_ADDR)

while True:
	res=input('>>>:').strip()
	if len(res)==0:continue
	phone.send(res.encode('utf-8'))
	length=phone.recv(MAX_SIZE)
	data=phone.recv(int(length.decode('utf-8')))
	print(data.decode('gbk')) # linux和macyong'utf-8',windows用gbk
phone.close()

为何low???

程序的运行速度远远快于网络传输速度,所以在发送一段字节之前,先用send区发送该字节的长度,这种方式会放大网络延迟带来的性能损耗。

5.2.2 正确解决方法

windows在这种情况下,尝试执行了dirtasklist两项命令,都没有出现粘包现象,数据一次性被取出。但是这种问题也没有完全解决粘包问题,bug1如果第一次发送数据头(真实数据的描述信息)时,数据的长度超过客户端限定的单次最大接收字节数(MAX_SIZE)的话,会直接影响到第二次取数据时取出的字节数。当然可以通过提高客户端接收数据的MAX_SIZE来修复,但是治标不治本。bug2如果某条指令在执行完后产生的结果超出了其计算机的缓存大小,一次无法全部放入缓存中,只能分批发送,但是另一边就取了这个数据长度的字节一次,不会再取bug3如果发送数据那端计算机的缓存要大于接收数据那端计算机的缓存的话,接受端的缓存可能无法存下发送端发送过来的全部数据,因此也难以一次把数据全部读取出来,需要多次存取才能完成所有结果存取。

下面我们来着手解决这三个bug。

第一次发送数据头(数据的描述信息),第二次发送的真实数据的想法是没有错的。我们能不能让数据头定长,每次发送数据时,数据头的长度时定长的,这样就可以先拿到数据头的固定长度,解析固定长度获得数据头得到真正长度,再去修改读入数据头时该读入的字节数。这里可以使用struct模块来实现定长struct模块具体用法)。另外可能出bug的地方是取数据的阶段,我们来针对性解决,同时优化。其实我们可以用1024循环取,直到取出的字节数和数据头中字节数相同时,停止循环读取。接下来进入代码实现阶段:

# 服务端
import socket,struct,subprocess
IP_ADDR=('127.0.0.1',8080)
MAX_SIZE=1024
BACK_LOG=5

def execute(msg):
	ss=subprocess.Popen(
		msg,
		shell=True,
		stdout=subprocess.PIPE,
		stderr=subprocess.PIPE)
	res1=ss.stdout.read()
	res2=ss.stderr.read()
	return res1+res2

phone=socket.socket(socket.AFINET,socket.SOCK_STREAM)
phone.bind(IP_ADDR)
phone.listen(BACK_LOG)
while True:
	conn.addr=phone.accept()
	while True:
		try:
			msg=conn.recv(1024)
			res=execute(msg.decode('utf-8'))
			# 先制作数据头,这时的数据头只包括数据的长度
			length=len(res)
			# 考虑到每次数据的长度可能不一样,万一超过服务端的单次最大接受长度,会导致数据无法完全发送,因此我们通过模块发送定长长度
			# 制作定长长度
			fixed_len=struct.pack('i',length)  # 这时得到的fixed_length是一个bytes类型,定长为4,可以直接发送
			conn.send(fixed_length)
			# 发送完数据头后,再发送数据内容
			conn.send(res)
		except Exception:
			break
	conn.close()

# 客户端
import socket,struct
IP_ADDR=('127.0.0.1',8080)
MAX_SIZE=1024

phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.connect(IP_ADDR)

while True:
	res=input('>>>:').strip()
	if not res:continue
	phone.send(res.encode('utf-8'))
	# 1.先接受数据头,数据头的长度为4
	length=phone.recv(4)
	# 拿到的长度是字节类型,我们要unpack成数字类型。拆出来的数字就是要发送字节的长度
	total_size=struct.unpack('i',length)[0]
	# 开始不断取数据,直到取完
	recv_size=0
	while recv_size<total_size:
		# data是bytes类型
		data=phone.recv(MAX_SIZE)
		recv_size+=len(data)
		print(data.decode('gbk'),end='') # mac/linux用utf-8,windows用gbk
phone.close()

通过这个例子,我们简单的制作了数据头(虽然只有只有数据内容的长度),同时让客户端反复读取服务端发送过来的数据,直到数据全部读完。这才是解决粘包问题真正正确的解决方法。

接下来我们尝试制作稍微复杂的数据头,这个稍微复杂的数据头应该包括传输数据的名称,数据量大小以及数据加密的格式。

# 服务端
import socket, subprocess,struct,json


def execute(msg):
    ope = subprocess.Popen(
        msg,
        shell=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE
    )
    res1 = ope.stdout.read()
    res2 = ope.stderr.read()
    return res1 + res2


phone = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # 流式协议=》tcp协议
phone.bind(('127.0.0.1', 8080))

phone.listen(5)
print('服务端启动')

while True:
    conn, client_addr = phone.accept()
    while True:
        try:
            data = conn.recv(1024)
            cmd = data.decode('utf-8')
            res = execute(cmd)
            total_size=len(res)
            # 1.制作头
            header_dic={
                'filename':'a.txt',
                'total_size':total_size,
                'mad5':'123423srewrw'
            }
            json_dic = json.dumps(header_dic)
            json_dic_bytes = json_dic.encode('utf-8')
            # 2.先发头信息(固定长度的bytes):对数据描述信息
            head=struct.pack('i',len(json_dic_bytes))
            # 3.发头信息
            conn.send(head)
            # 4.再发真实的数据:
            conn.send(json_dic_bytes)

            # 接收端要做的事:
            # 1.先接受4个字节,是几天下来要收头字典bytes类型的长度
            # 2.json_str_bytes=phone.recv(4)
            #   json_str=struct.unpack('i',json_str_bytes)
            #  header_dic=json.load(json_str)
            #  得到了头信息的字典,再从字典里面取东西出来
            #  header_dic['total_size']



        except Exception:
            break
    conn.close()

phone.close()
# 客户端
import socket,struct,json

phone = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # 流式协议
phone.connect(('127.0.0.1', 8080))
while True:
    res = input('123:')
    if not res:
        continue
    phone.send(res.encode('utf-8'))
    # 接收端要做的事:
    # 1.先接受4个字节,是几天下来要收头字典bytes类型的长度
    header_kength_pack = phone.recv(4)
    # 2.json_str_bytes=phone.recv(4)
    #   json_str=struct.unpack('i',json_str_bytes)
    header_length=struct.unpack('i',header_kength_pack)[0]
    header_dic_bytes=phone.recv(header_length)
    #  header_dic=json.load(json_str)
    header_dic=json.loads(header_dic_bytes.decode('utf-8'))
    print(header_dic)
    #  得到了头信息的字典,再从字典里面取东西出来
    #  header_dic['total_size']

phone.close()

我们现在只是将服务端执行客户端发来命令后的结果发送给了服务端,我们可不可以自己书写程序模拟上传和下载文件的操作呢?请读者自己尝试进行操作。

FTP作业:上传和下载文件(找pycharm里面)

六、认证客户端的链接合法性

如果你想在分分布式系统中实现一个简单的客户端连接认证功能,又不像SSL那么复杂,那么利用hamc+加盐的方式实现。

# 服务端
#_*_coding:utf-8_*_
from socket import *
import hmac,os

secret_key=b'linhaifeng bang bang bang'
def conn_auth(conn):
    '''
    认证客户端链接
    :param conn:
    :return:
    '''
    print('开始验证新链接的合法性')
    msg=os.urandom(32)
    conn.sendall(msg)
    h=hmac.new(secret_key,msg)
    digest=h.digest()
    respone=conn.recv(len(digest))
    return hmac.compare_digest(respone,digest)

def data_handler(conn,bufsize=1024):
    if not conn_auth(conn):
        print('该链接不合法,关闭')
        conn.close()
        return
    print('链接合法,开始通信')
    while True:
        data=conn.recv(bufsize)
        if not data:break
        conn.sendall(data.upper())

def server_handler(ip_port,bufsize,backlog=5):
    '''
    只处理链接
    :param ip_port:
    :return:
    '''
    tcp_socket_server=socket(AF_INET,SOCK_STREAM)
    tcp_socket_server.bind(ip_port)
    tcp_socket_server.listen(backlog)
    while True:
        conn,addr=tcp_socket_server.accept()
        print('新连接[%s:%s]' %(addr[0],addr[1]))
        data_handler(conn,bufsize)

if __name__ == '__main__':
    ip_port=('127.0.0.1',9999)
    bufsize=1024
    server_handler(ip_port,bufsize)

服务端
# 客户端(合法)
#_*_coding:utf-8_*_
from socket import *
import hmac,os

secret_key=b'linhaifeng bang bang bang'
def conn_auth(conn):
    '''
    验证客户端到服务器的链接
    :param conn:
    :return:
    '''
    msg=conn.recv(32)
    h=hmac.new(secret_key,msg)
    digest=h.digest()
    conn.sendall(digest)

def client_handler(ip_port,bufsize=1024):
    tcp_socket_client=socket(AF_INET,SOCK_STREAM)
    tcp_socket_client.connect(ip_port)

    conn_auth(tcp_socket_client)

    while True:
        data=input('>>: ').strip()
        if not data:continue
        if data == 'quit':break

        tcp_socket_client.sendall(data.encode('utf-8'))
        respone=tcp_socket_client.recv(bufsize)
        print(respone.decode('utf-8'))
    tcp_socket_client.close()

if __name__ == '__main__':
    ip_port=('127.0.0.1',9999)
    bufsize=1024
    client_handler(ip_port,bufsize)
# 客户端(非法,不知道加密方式)
#_*_coding:utf-8_*_
from socket import *

def client_handler(ip_port,bufsize=1024):
    tcp_socket_client=socket(AF_INET,SOCK_STREAM)
    tcp_socket_client.connect(ip_port)

    while True:
        data=input('>>: ').strip()
        if not data:continue
        if data == 'quit':break

        tcp_socket_client.sendall(data.encode('utf-8'))
        respone=tcp_socket_client.recv(bufsize)
        print(respone.decode('utf-8'))
    tcp_socket_client.close()

if __name__ == '__main__':
    ip_port=('127.0.0.1',9999)
    bufsize=1024
    client_handler(ip_port,bufsize)

客户端(非法:不知道加密方式)
# 客户端(非法,不知道secret_key)
#_*_coding:utf-8_*_
__author__ = 'Linhaifeng'
from socket import *
import hmac,os

secret_key=b'linhaifeng bang bang bang1111'
def conn_auth(conn):
    '''
    验证客户端到服务器的链接
    :param conn:
    :return:
    '''
    msg=conn.recv(32)
    h=hmac.new(secret_key,msg)
    digest=h.digest()
    conn.sendall(digest)

def client_handler(ip_port,bufsize=1024):
    tcp_socket_client=socket(AF_INET,SOCK_STREAM)
    tcp_socket_client.connect(ip_port)

    conn_auth(tcp_socket_client)

    while True:
        data=input('>>: ').strip()
        if not data:continue
        if data == 'quit':break

        tcp_socket_client.sendall(data.encode('utf-8'))
        respone=tcp_socket_client.recv(bufsize)
        print(respone.decode('utf-8'))
    tcp_socket_client.close()

if __name__ == '__main__':
    ip_port=('127.0.0.1',9999)
    bufsize=1024
    client_handler(ip_port,bufsize)

七、socketserver实现并发

基于TCP的套接字,关键就是两个循环,一个链接循环,一个通信循环。

socketserver模块中有两大分类:server类(解决链接问题),request类(解决通信问题)。

server类:
在这里插入图片描述
request类:
在这里插入图片描述
继承关系:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
以下述代码为例,分析socketserver源码:

ftpserver=socketserver.ThreadingTCPServer((‘127.0.0.1’,8080),FtpServer)
ftpserver.serve_forever()
查找属性的顺序:ThreadingTCPServer->ThreadingMixIn->TCPServer->BaseServer

  1. 实例化得到ftpserver,先找类ThreadingTCPServer的__init__,在TCPServer中找到,进而执行server_bind,server_active
  2. 找ftpserver下的serve_forever,在BaseServer中找到,进而执行self._handle_request_noblock(),该方法同样是在BaseServer中
  3. 执行self._handle_request_noblock()进而执行request, client_address = self.get_request()(就是TCPServer中的self.socket.accept()),然后执行self.process_request(request, client_address)
    在ThreadingMixIn中找到process_request,开启多线程应对并发,进而执行process_request_thread,执行self.finish_request(request, client_address)
  4. 上述四部分完成了链接循环,本部分开始进入处理通讯部分,在BaseServer中找到finish_request,触发我们自己定义的类的实例化,去找__init__方法,而我们自己定义的类没有该方法,则去它的父类也就是BaseRequestHandler中找…

源码分析总结:

基于tcp的socketserver我们自己定义的类中的

  1. self.server即套接字对象
  2. self.request即一个链接
  3. self.client_address即客户端地址

基于udp的socketserver我们自己定义的类中的

  1. self.request是一个元组(第一个元素是客户端发来的数据,第二部分是服务端的udp套接字对象),如(b’adsf’, <socket.socket fd=200, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=0, laddr=(‘127.0.0.1’, 8080)>)
  2. self.client_address即客户端地址
# FTPServer
import socketserver
import struct
import json
import os
class FtpServer(socketserver.BaseRequestHandler):
    coding='utf-8'
    server_dir='file_upload'
    max_packet_size=1024
    BASE_DIR=os.path.dirname(os.path.abspath(__file__))
    def handle(self):
        print(self.request)
        while True:
            data=self.request.recv(4)
            data_len=struct.unpack('i',data)[0]
            head_json=self.request.recv(data_len).decode(self.coding)
            head_dic=json.loads(head_json)
            # print(head_dic)
            cmd=head_dic['cmd']
            if hasattr(self,cmd):
                func=getattr(self,cmd)
                func(head_dic)
    def put(self,args):
        file_path = os.path.normpath(os.path.join(
            self.BASE_DIR,
            self.server_dir,
            args['filename']
        ))

        filesize = args['filesize']
        recv_size = 0
        print('----->', file_path)
        with open(file_path, 'wb') as f:
            while recv_size < filesize:
                recv_data = self.request.recv(self.max_packet_size)
                f.write(recv_data)
                recv_size += len(recv_data)
                print('recvsize:%s filesize:%s' % (recv_size, filesize))


ftpserver=socketserver.ThreadingTCPServer(('127.0.0.1',8080),FtpServer)
ftpserver.serve_forever()

FtpServer
# FTPClient
import socket
import struct
import json
import os



class MYTCPClient:
    address_family = socket.AF_INET

    socket_type = socket.SOCK_STREAM

    allow_reuse_address = False

    max_packet_size = 8192

    coding='utf-8'

    request_queue_size = 5

    def __init__(self, server_address, connect=True):
        self.server_address=server_address
        self.socket = socket.socket(self.address_family,
                                    self.socket_type)
        if connect:
            try:
                self.client_connect()
            except:
                self.client_close()
                raise

    def client_connect(self):
        self.socket.connect(self.server_address)

    def client_close(self):
        self.socket.close()

    def run(self):
        while True:
            inp=input(">>: ").strip()
            if not inp:continue
            l=inp.split()
            cmd=l[0]
            if hasattr(self,cmd):
                func=getattr(self,cmd)
                func(l)


    def put(self,args):
        cmd=args[0]
        filename=args[1]
        if not os.path.isfile(filename):
            print('file:%s is not exists' %filename)
            return
        else:
            filesize=os.path.getsize(filename)

        head_dic={'cmd':cmd,'filename':os.path.basename(filename),'filesize':filesize}
        print(head_dic)
        head_json=json.dumps(head_dic)
        head_json_bytes=bytes(head_json,encoding=self.coding)

        head_struct=struct.pack('i',len(head_json_bytes))
        self.socket.send(head_struct)
        self.socket.send(head_json_bytes)
        send_size=0
        with open(filename,'rb') as f:
            for line in f:
                self.socket.send(line)
                send_size+=len(line)
                print(send_size)
            else:
                print('upload successful')




client=MYTCPClient(('127.0.0.1',8080))

client.run()

FtpClient

八、作业

在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值