Python课程回顾(day27)
socket编程
socket介绍
socket其实就是TCP或UPD协议与应用程序之间的一种抽象层,更确切的说它只是一组接口,在设计模式中,socket其实就是一个门面模式,它将一系列基于TCP或UDP的复杂操作隐藏在socket后面,对于用户来说一组简单的接口就是全部,让socket去组织数据以符合指定的协议。所以我们其实不需要去深入理解TCP与UDP协议,socket已经为我们封装好了一系列的接口,我们只需要根据socket的规定去写程序,那我们写出的程序自然就是根据TCP与UDP协议的。
举一个生活中的例子
假设你要给一个朋友打电话,首先呢你必须得有一个电话吧,其次你还得有电话卡,再然后你要知道你朋友的电话是多少,然后你根据号码打过去你的朋友还得接听,然后你们交流过后相互挂掉电话,这样才算一个完整的通讯。
基于TCP协议的工作流程图
socket: 你的电话或对方的电话
bind: 插入电话卡
listren: 等待接电话
accept: 接电话的按钮
connect: 打电话的按钮
write,read:交流
close: 挂电话
我们先从服务器端说起,服务器端先初始化socket,然后与IP端口进行绑定bind,对端口进行监听,接着调用accept阻塞等待客户端的链接。假设在这时有一个客户端初始化了一个客户端的socket,然后进行链接服务器connect,如果链接成功,那么服务器与客户端的链接就算建立完成,服务端接收并处理由客户端发来的一系列请求,并将结果返回给客户端,客户端读取相应的数据并关闭链接,然后交互结束。
基于TCP协议的基本通信
服务端代码:
# 可以直接导入socket, 也可以导入socket的所有功能, 推荐使用第二种, 节省代码 from socket import * # 创建服务端对象 server = socket() # 给服务端绑定固定的IP和我们写的应用程序的端口 # 测试专用的本机IP默认为127.0.0.1, 该IP地址指向的就是本机IP # 以后写程序时要绑定具体的IP地址 server.bind(('127.0.0.1', 8888)) # 监听发起的链接请求, 设置同一时间的最大请求数量 # 注意:是最大请求数量,不是最大链接数量 server.listen(5) # 接收由客户端返回的信息, 会以元组形式返回两个值 # 第一个是客户端发送的消息或指令 # 第二个是客户端的地址 conn, client_address = server.accept() # 设置接收的最大消息数量 # 注:收发消息均是以字节类型进行收发 res = conn.recv(1024) # 解码并打印消息 print('来自客户端的消息:', res.decode('utf-8')) # 将处理结果发送到客户端(返回元数据的大写) conn.send(res.upper()) # 断开链接 conn.close() # 关闭服务器 server.close()
客户端代码: from socket import * # 创建客户端对象 client = socket() # 客户端要建立链接的服务器IP地址与端口 client.connect(('127.0.0.1', 8888)) # 客户端发送数据 client.send(b'hello') # 接收服务端发来的处理结果 res = client.recv(1024) # 解码打印 print('来自服务端的消息:', res.decode('utf-8')) 输出结果为:HELLO
我们要知道,我们在使用应用程序与服务端交互的时候并不可能只产生一次通信就结束,例如我们的QQ微信聊天,所以单纯的一次通信其实并不能解决客户端的问题,我们不能在客户端发送一次数据之后就关闭链接,正确的做法是要等待客户端处理完问题之后由客户端主动断开链接,在这个过程中我们是要与客户端进行不断的交互的。
加上通信循环
服务端代码: from socket import * server = socket() server.bind(('127.0.0.1', 8888)) server.listen(5) conn, client_address = server.accept() # 服务端循环接收由客户端发送的数据并处理后发送 while True: res = conn.recv(1024) print('来自客户端的消息:', res.decode('utf-8')) conn.send(res.upper()) conn.close() server.close()
客户端代码: from socket import * client = socket() client.connect(('127.0.0.1', 8888)) # 客户端循环发送数据 while True: msg = input('>>:').strip() client.send(msg.encode('utf-8')) res = client.recv(1024) print('来自服务端的消息:', res.decode('utf-8'))
基于上面的代码,我们基本实现了与用户不断的交互并一直处理用户发来的数据与请求,但问题是,我们的客户端并不是只有一个,那么面对成千上万个的客户端上面的代码也行不通,因为我们在与一个客户端进行交互之后就彻底断开链接并关闭了服务器,很显然我们的服务器是不可以关闭的,所以我们在与第一个客户端断开链接之后也要与下一个客户端进行链接。
加上链接循环
服务端代码: from socket import * server = socket() server.bind(('127.0.0.1', 8888)) server.listen(5) # 循环接收客户端发来的链接请求 while True: conn, client_address = server.accept() # 服务端循环接收由客户端发送的数据并处理后发送 while True: # 我们不能确定客户端是怎样断开链接的 # 而客户端又一定会在事情做完之后断开链接 # 所以我们要捕获相应的异常 try: res = conn.recv(1024) # 若客户端发送空数据则判断是否接收到客户端的数据 if not res: conn.close() break print('来自客户端的消息:', res.decode('utf-8')) conn.send(res.upper()) except ConnectionResetError: print('客户端异常关闭') break # 会在客户端异常关闭后回收与客户端的链接 conn.close() server.close()
from socket import * client = socket() client.connect(('127.0.0.1', 8888)) # 客户端循环发送数据 while True: msg = input('>>:').strip() if not msg: print('不能发送空字符') continue if msg == 'q': break client.send(msg.encode('utf-8')) res = client.recv(1024) print('来自服务端的消息:', res.decode('utf-8'))
当然,我们的服务器不可能是都放在我们身边,假设我们的程序较大时就必须要有一个专门的服务器来带动我们的整个程序,而服务器对硬件的要求无疑的很高的,通常情况下服务器大都是放在机房内,而机房通常又设立在距离公司较远的地方来有人专门看管,那么我们又如何查看或检查我们的服务器呢?硬件是有人专门维护的,而面对服务器与客户端交互的一些突发情况我们又该怎么处理呢?很显然我们不可能每次都跑去机房处理,那么这时候就需要再编写一个服务端软件来远程的去控制我们的服务器。
TCP的远程执行命令
比如我们想查看一下服务器的相关信息:
服务器代码: from socket import * import subprocess server = socket() server.bind(('127.0.0.1', 8888)) server.listen(5) while True: conn, client_address = server.accept() while True: try: res = conn.recv(1024) if not res: conn.close() break # 通过subprocess模块来远程执行cmd的命令 obj = subprocess.Popen(res.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # 获取执行正确的信息 stdout = obj.stdout.read() # 获取执行错误的信息 stderr = obj.stderr.read() # 由于subprocess返回的本身就是字节类型所以不用encode conn.send(stdout)
conn.send(atderr) except ConnectionResetError: print('客户端异常关闭') break conn.close()
客户端代码: from socket import * client = socket() client.connect(('127.0.0.1', 8888)) while True: cmd = input('>>:').strip() if not cmd: print('不能发送空字符') continue if cmd == 'q': break client.send(cmd.encode('utf-8')) res = client.recv(1024) # subprocess返回的是Unicode编码, 解码时要使用gbk print('来自服务端的消息:', res.decode('gbk'))
从上图我们可以看出,我们所输出的命令确实得到了反馈的信息,比如dir等等,但其实我们使用recv接收的最大字节数就只有1024个字节,而dir命令的结果大概也就300多个字节,若我们需要查看的数据量较大时就会出现一种叫做粘包的问题,粘包问题则是由于要发送的数据远大于recv的接收量导致的,它会先发送我们所设置的recv所能接收的最大数据量的数据,然后将剩余的数据残留在传输管道中,直到下次执行send时再将剩余的数据量发送,若数据量更大则会分多次发送,这就直接导致了我们可能不知道它是否是一份完整的数据,若我们再执行第二次命令时也不知道数据是否是正确数据,有人会说,调大recv的接收量不就可以了。问题是我们是不可能知道数据的总量为多少,而设置recv的接收量也是有限制的,这是第一点。
第二点,很明显我们在发送数据时是分两次进行发送的,一份是执行的正确数据,一份是执行错误信息,而我们在接收时接收到的也是黏在一起的数据,而这个问题则是TCP协议的流式协议所导致的,它会将数据量较小且间隔时间较短的多次数据合并到一起发送,以便减少IO次数,但对于我们则是在很大程度上限制了我们。
那么我们要怎么解决粘包问题呢?
解决TCP的粘包问题:
服务端代码: from socket import * import subprocess import struct server = socket() server.bind(('127.0.0.1', 8888)) server.listen(5) while True: conn, client_address = server.accept() while True: try: res = conn.recv(1024) if not res: conn.close() break obj = subprocess.Popen(res.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout = obj.stdout.read() stderr = obj.stderr.read() # 先计算数据的总长度, 若想分开发送就单独计算正确或错误数据的总长度 # 此处合并一起发 total_size = len(stdout) + len(stderr) # 将数据的总长度使用struct的i格式打包成固定的4个字节的报头 # 接收端在接收到这四个字节之后会将报头解包得到数据的总长度 # 然后接收端会按照数据的总长度循环接收数据,直到接收完为止 header = struct.pack('i', total_size) # 要优先发送报头 conn.send(header) # 此处发一次或两次TCP协议都会合并成一次发送 conn.send(stdout) conn.send(stderr) except ConnectionResetError: print('客户端异常关闭') break conn.close()
客户端代码: from socket import * import struct client = socket() client.connect(('127.0.0.1', 8888)) while True: cmd = input('>>:').strip() if not cmd: print('不能发送空字符') continue if cmd == 'q': break client.send(cmd.encode('utf-8')) # 先接收固定长度的报头 header = client.recv(4) # 使用struct将报头解包得到具体的数据长度(struct会将整形打包成固定的bytes,i模式打包成4个bytes,q模式打包成8个bytes) # 反解会得到一个元组类型, 第一个就是报头的总长度 total_size = struct.unpack('i', header)[0] # 得到报头的总长度之后要循环接收, 因为我们不知道数据的总长度 # 定义一个变量来记录已经接收的字节数量 receive_size = 0 # 定义一个变量来记录接收的结果, 每循环一次则加一次结果 res = b'' # 使用while循环接收, 条件为 已接收的字节数量小于总字节数量 while receive_size < total_size: # 接收数据, 并设置单次接收的最大字节数量 receive_data = client.recv(1024) # 每接收一次都将结果加到上面定义的变量后面 res += receive_data # 已接收数量要加上本次接收的字节数量 receive_size += len(receive_data) # 打印总结果 print('来自服务端的消息:', res.decode('gbk'))
TCP的自定义报头
基于TCP流式协议的特性,以后我们在收发数据时已经可以解决粘包问题了,但其实我们一开始所发送的报头只是单纯的去描述一个数据的总长度的,而通常情况下发送的报头都是有对数据的整体描述,比如文件的名称,文件的大小,或已经加密好的MD5值。而对数据的描述我们就可以使用字典来进行存放,接收端再接收时也会得到一个字典,再根据字典内key取得相应的值,比如校验我们的MD5值等等。另外就是struct打包成固定的4个字节或8个字节其实是有上限的,若数据长度真的比较大,比如99999999999999999999999999999999999999,struct就不能再进行打包了,但若将这个数字存到字典中,再将字典进行编码并计算得到的bytes长度则就不会很大,通常也就是几百个bytes,然后计算bytes长度得到一个整形并使用struct将这个整形打包岂不是戳戳有余呢?
总的流程大概就是
1.定义报头(字典格式)
2.将字典变为字符串并编码得到bytes
3.使用struct将bytes的长度打包成简单的几个字节并发送
4.接收端接收到固定的字节
5.使用struct反解包得到第二步的内容
6.获取定义时的字典得到具体内容
7.根据字典内的key得到数据的总长度(也可以根据key获取其他,比如文件名或MD5值)
8.循环接收数据的总长度
自定义报头示例:
服务器端: import socket import subprocess import json import struct server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(('127.0.0.1', 3344)) server.listen(5) while True: conn, client = server.accept() while True: try: cmd = conn.recv(1024) obj = subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout = obj.stdout.read() stderr = obj.stderr.read() total_size = len(stdout) + len(stderr) # 1.自定义报头 head_dic = { 'MD5': '2e5d8aa3dfa8ef34ca5131d20f9dad51', 'filename': 'a.txt', 'total_size': total_size } # 2.首先考虑若跨平台要使用通用的数据格式 # 字典不能直接转换bytes所以要先将字典转为json格式的字符串 head_json = json.dumps(head_dic) # 3.再通过json格式的字符串encode成bytes head_bytes = head_json.encode('utf-8') # 4.根据解码出的bytes得到报头的长度 # 使用struct中的i格式进行打包得到4个bytes的首先发送 head_size = len(head_bytes) conn.send(struct.pack('i', head_size)) # 此时已经有了固定的报头长度且客户端也只会根据报头的长度进行收取并解析报头的内容 # 此时再发送报头的内容客户端则会根据报头的内容顺利接收 conn.send(head_bytes) # 发送真实数据 conn.send(stdout) conn.send(stderr) except ConnectionResetError: break conn.close()
接收端: import socket import struct import json client = socket.socket() client.connect(('127.0.0.1', 3344)) while True: cmd = input('>>:') if not cmd: continue client.send(cmd.encode('utf-8')) # 首先接收报头的长度,并根据服务端的打包格式进行解包得到4个bytes # 拿到元组类型的第一个值就是报头的长度 head_size = struct.unpack('i', client.recv(4))[0] # 再根据报头的长度接收得到报头的具体内容(bytes类型) head_bytes = client.recv(head_size) # 由于服务端发送时使用的是json格式序列化的 # 此时进行解码会得到一个json格式的字符串 head_json = head_bytes.decode('utf-8') # 将json格式的字符串反序列化得到初始化报头的字典 head_dic = json.loads(head_json) print(head_dic) # 根据报头内的格式拿到具体的总数据长度 total_size = head_dic['total_size'] # 开始循环接收 receive_size = 0 res = b'' while receive_size < total_size: receive_data = client.recv(1024) res += receive_data receive_size += len(receive_data) print(res.decode('gbk'))
UDP协议
UDP协议又称之为数据包协议,即在每次发送数据时都会发送一个完整的数据报,即不会出现粘包问题。之前我们提到过的UDP不可靠协议主要就不可靠在发送端发送的数据不会等待接收ACK的确认信息。TCP协议在每次发完数据时会等待接收端返回的ACK信息,在该阶段TCP会不会将数据从缓存中清空,若接收端在一定时间内没有返回ACK确认信息则TCP协议会每隔一段时间进行再次发送。所以这就导致了UDP协议进行传输数据时极易出现丢包现象,但传输效率要高于TCP,所以两者都算各有优缺点,那么在对数据完整性要求较高时我们就应该使用TCP协议,例如转账、下载文件、数据校验等等。针对数据完整性要求不高时我们就应该使用UDP协议进行传输,例如网络直播,在线观看电影等等。
基于UDP协议的基本通信
服务端代码: # 导入socket模块 import socket # 创建服务端对象,type要指定SOCK_DGRAM(数据报协议) server = socket.socket(type=socket.SOCK_DGRAM) # 服务端也要绑定本机IP与设置端口 server.bind(('127.0.0.1', 10000)) # 通信循环 while True: # UDP通信的客户端会以元组类型返回用户发送的信息与用户的IP和端口 # 关键字recvfrom: res, client_address = server.recvfrom(1024) # 解码并打印信息 print(res) # 直接使用server对象并使用sendto发送信息 # sendto的两个参数分别是要发送的信息与用户的地址 server.sendto(res.upper(), client_address)
客户端代码: # 导入socket模块 from socket import * # 创建客户端对象,要指定type与服务端一致 client = socket(AF_INET, SOCK_DGRAM) # 循环通信 while True: msg = input('>>:').strip() if not msg: continue if msg == 'q': break # 将要发送的信息编码并使用sendto发送到指定IP与端口的服务端 client.sendto(msg.encode('utf-8'), ('127.0.0.1', 10000)) # 接收服务端返回的数据 res, server_address= client.recvfrom(1024) print(res.decode('utf-8'))