网络编程
Socket编程 |
一、客户端/服务端架构
Socker就是为了完成C/S架构的开发,客服端/服务端架构即C/S架构。包括:硬件C/S架构(比如:打印机),软件C/S架构(比如网站是服务端,你的浏览器是客户端)。
二、OSI七层
在学socket之前首先要学习互联网协议,对ios七层有一定的了解。
三、socket层
在上面图中,我们并没看到socket层,那么它到底在哪一层呢?看下图就能看出
从图中可以看出socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,socket其实就是一个门面模式,它吧复杂的TCP/IP协议族隐藏在socket接口后面,对用户来说,一组简单的接口就是全部,让socket去组织数据,以符合指定的协议。
四、套接字
1.套接字的分类
套接字有两种,分别是基于文件型的(AF_UNIX)和基于网络型的(AF_INET)。
2.套接字工作流程
套接字的工作流程很像生活中打电话的场景。你给一个朋友打电话,首先拨号,朋友听到电话铃声后接听电话,这时候你们就建立起了连接,就可以进行通信了。等通信结束,挂断电话结束此次通信。下面我来就用图来分析套接字的工作流程。
先从服务器端说起。服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。
下面我们来介绍socket模块的用法:
import socket #导入socket模块 tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) #获取tcp/ip套接字 udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #获取udp/ip套接字
套接字函数:
#服务端套接字函数 #.bind() 绑定(主机,端口号)到套接字 #.listen() 开始TCP监听 #.accept() 被动接受TCP客户的连接,(阻塞式)等待连接的到来 #客户端套接字函数 #.connect() 主动初始化TCP服务器连接 #.connect_ex() connect()函数的扩展版本,出错时返回出错码,而不是抛出异常
#公共用途的套接字函数 #.recv() 接收TCP数据 #.send() 发送TCP数据(send在待发送数据量大于己端缓存区剩余空间时,数据丢失,不会发完) #.sendall() 发送完整的TCP数据(本质就是循环调用send,sendall在待发送数据量大于己端缓存区剩余空间时,数据不丢失,循环调用send直到发完) #.recvfrom() 接收UDP数据 #.sendto() 发送UDP数据 #.getpeername() 连接到当前套接字的远端的地址 #.getsockname() 当前套接字的地址 #.getsockopt() 返回指定套接字的参数 #.setsockopt() 设置指定套接字的参数 #.close() 关闭套接字 #面向锁的套接字函数 #.setblocking() 设置套接字的阻塞与非阻塞模式 #.settimeout() 设置阻塞套接字操作的超时时间 #.gettimeout() 得到阻塞套接字操作的超时时间 #面向文件的套接字函数 #.fileno() 套接字的文件描述符 #.makefile() 创建一个与该套接字相关的文件
3.基于TCP的套接字
tcp是基于链接的,必须先启动服务端,然后再客户端去链接服务端。
下面我们来看一个简单的基于tcp的套接字通信,模拟打电话流程:
#服务端 import socket phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM) #买手机,socket.AF_INET基于网络通信,socket.SOCK_STREAM代表TCP协议 phone.bind(('127.0.0.1',8000)) #绑定手机卡 phone.listen(5) #开机,listen相当于半连接池 print('等电话中') conn,addr = phone.accept() #等电话 #conn电话链接、addr对方的手机号 msg = conn.recv(1024) #收消息 print('客户端发来的消息是:',msg) conn.send(msg.upper()) #发消息 conn.close() #挂电话 phone.close() #关机 #客户端 import socket phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM) #买手机 phone.connect(('127.0.0.1',8000)) #拨通电话 phone.send('hello'.encode('utf-8')) #发消息 data = phone.recv(1024) #收消息 print('收到服务端发来的消息是:',data)
上面代码只能实现一次链接和一次通信,那么我们怎么实现多次链接和多次通信呢?请看下面代码实现:
from socket import * ip_port = ('127.0.0.1',8080) back_log = 5 buffer_size = 1024 tcp_server = socket(AF_INET,SOCK_STREAM) tcp_server.bind(ip_port) tcp_server.listen(back_log) while True: conn,addr = tcp_server.accept() #服务端阻塞 print('双向链接是',conn) print('客户端地址',addr) while True: try: data = conn.recv(buffer_size) print('客户端发来的消息是',data.decode('utf-8')) conn.send(data.upper()) except Exception: break conn.close() tcp_server.close()
from socket import * ip_port = ('127.0.0.1',8080) back_log = 5 buffer_size = 1024 tcp_client = socket(AF_INET,SOCK_STREAM) tcp_client.connect(ip_port) while True: msg = input('>>:').strip() if not msg:continue tcp_client.send(msg.encode('utf-8')) print('客户端已经发送消息') data = tcp_client.recv(buffer_size) print('收到服务端发来的消息',data.decode('utf-8')) tcp_client.close()
在这里,客户端可以开多个,只不过不能实现并发。
其中有一个问题可能会在重启服务端时遇到:
这个是由于你的服务端仍然存在四次挥手的time_wait状态在占用地址。
解决方法:加入一条socket配置,重用ip和端口,即
phone=socket(AF_INET,SOCK_STREAM) phone.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) #就是它,在bind前加 phone.bind(('127.0.0.1',8080))
4.基于UDP的套接字
udp是无链接的,先启动哪一端都不会报错。
下面我们来实现简单udp通信:
from socket import * ip_port = ('127.0.0.1',8080) buffer_size = 1024 udp_server = socket(AF_INET,SOCK_DGRAM) #SOCK_DGRAM代表的udp协议 udp_server.bind(ip_port) while True: data,addr = udp_server.recvfrom(buffer_size) print(data) udp_server.sendto(data.upper(),addr)
from socket import * ip_port = ('127.0.0.1',8080) buffer_size = 1024 udp_client = socket(AF_INET,SOCK_DGRAM) #SOCK_DGRAM代表的udp协议 while True: msg = input('>>:').strip() udp_client.sendto(msg.encode('utf-8'),ip_port) data,addr = udp_client.recvfrom(buffer_size) print(data.decode('utf-8'))
由于udp无连接,所以可以同时多个客户端去跟服务端通信。
时间服务器也是基于udp的套接字实现的,具体代码如下:
from socket import * import time ip_port = ('127.0.0.1',8080) buffer_size = 1024 udp_server = socket(AF_INET,SOCK_DGRAM) #SOCK_DGRAM代表的udp协议 udp_server.bind(ip_port) while True: data,addr = udp_server.recvfrom(buffer_size) print(data) if not data: fmt = '%Y-%m-%d %X' else: fmt = data.decode('utf-8') back_time = time.strftime(fmt) udp_server.sendto(back_time.encode('utf-8'),addr)
from socket import * ip_port = ('127.0.0.1',8080) buffer_size = 1024 udp_client = socket(AF_INET,SOCK_DGRAM) #SOCK_DGRAM代表的udp协议 while True: msg = input('>>:').strip() udp_client.sendto(msg.encode('utf-8'),ip_port) data,addr = udp_client.recvfrom(buffer_size) print('ntp服务器的标准时间是',data.decode('utf-8'))
五、粘包
1.粘包现象
我们先看一个简单的粘包现象,代码如下:
from socket import * ip_port = ('127.0.0.1',8080) back_log = 5 buffer_size = 1024 tcp_server = socket(AF_INET,SOCK_STREAM) tcp_server.bind(ip_port) tcp_server.listen(back_log) conn,addr = tcp_server.accept() data = conn.recv(buffer_size) print('第一次数据',data) data1 = conn.recv(buffer_size) print('第二次数据',data1) data2 = conn.recv(buffer_size) print('第三次数据',data2)
from socket import * ip_port = ('127.0.0.1',8080) back_log = 5 buffer_size = 1024 tcp_client = socket(AF_INET,SOCK_STREAM) tcp_client.connect(ip_port) tcp_client.send('hello'.encode('utf-8')) tcp_client.send('world'.encode('utf-8')) tcp_client.send('alex'.encode('utf-8'))
这是一个基于tcp的套接字,我们先执行服务端,再执行客户端,看看结果是啥?
第一次数据 b'helloworldalex' 第二次数据 b'' 第三次数据 b''
很明显客户端发的数据都一次被服务端接收,这就是属于一种粘包现象。
既然基于tcp的套接字会发生粘包现象,那么基于udp的套接字是否会发生?我们在基于udp写一段代码:
from socket import * ip_port = ('127.0.0.1',8080) buffer_size = 1024 udp_server = socket(AF_INET,SOCK_DGRAM) udp_server.bind(ip_port) data = udp_server.recvfrom(buffer_size) print('第一次',data) data1 = udp_server.recvfrom(buffer_size) print('第二次',data1) data2 = udp_server.recvfrom(buffer_size) print('第三次',data2)
from socket import * ip_port = ('127.0.0.1',8080) buffer_size = 1024 udp_client = socket(AF_INET,SOCK_DGRAM) udp_client.sendto(b'hello',ip_port) udp_client.sendto(b'world',ip_port) udp_client.sendto(b'alex',ip_port)
执行程序的结果为:
第一次 (b'hello', ('127.0.0.1', 52753)) 第二次 (b'world', ('127.0.0.1', 52753)) 第三次 (b'alex', ('127.0.0.1', 52753))
很明显基于udp的套接字,在运行时不会发生粘包。
2.什么是粘包
所谓粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。
我要明白只有tcp有粘包现象,udp永远不会粘包,为什么呢?我们首先需要掌握一个socket收发消息的原理,根据下图我们进行讲解:
发送端可以是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据,也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议,这也是容易出现粘包问题的原因。而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。怎样定义消息呢?可以认为对方一次性write/send的数据为一个消息,需要明白的是当对方send一条信息的时候,无论底层怎样分段分片,TCP协议层会把构成整条消息的数据段排序完成后才呈现在内核缓冲区。
那么在什么情况下会发生粘包?
- 发送端需要等缓冲区满才发送出去,造成粘包(发送数据时间间隔很短,数据了很小,会合到一起,产生粘包)
- 接收方不及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包)
我们上面写的粘包属于第一种。第二种的具体实现如下:
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()
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'))
3.解决粘包方法
粘包问题的根源在于,接收端不知道发送端将要传送的字节流的长度,所以解决粘包的方法就是围绕,如何让发送端在发送数据前,把自己将要发送的字节流总大小让接收端知晓,然后接收端来一个死循环接收完所有数据。
第一种解决方法:
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去发送该字节流长度,这种方式会放大网络延迟带来的性能损耗。
第二种解决方法:
import socket,struct,json import subprocess phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM) phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) #就是它,在bind前加 phone.bind(('127.0.0.1',8080)) phone.listen(5) while True: conn,addr=phone.accept() while True: cmd=conn.recv(1024) if not cmd:break print('cmd: %s' %cmd) res=subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) err=res.stderr.read() print(err) if err: back_msg=err else: back_msg=res.stdout.read() conn.send(struct.pack('i',len(back_msg))) #先发back_msg的长度 conn.sendall(back_msg) #在发真实的内容 conn.close()
import socket,time,struct 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')) l=s.recv(4) x=struct.unpack('i',l)[0] print(type(x),x) # print(struct.unpack('I',l)) r_s=0 data=b'' while r_s < x: r_d=s.recv(1024) data+=r_d r_s+=len(r_d) # print(data.decode('utf-8')) print(data.decode('gbk')) #windows默认gbk编码
第二种解决方法为字节流加上自定义固定长度报头,报头中包含字节流长度,然后一次send到对端,对端在接收时,先从缓存中取出定长的报头,然后再取真实数据。在这个我们用到了struct模块,该模块可以把一个类型转成固定长度的bytes。
六、认证客户端的链接合法性
如何在分布式系统中实现一个简单的客户端链接认证功能呢?我们可以利用hmac+加盐的方式来实现:
from socket import * import hmac,os secret_key=b'hello' 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)
from socket import * import hmac,os secret_key=b'hello' 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实现并发
我们都知道udp是无链接的,所以它本身就是并发。我们这主要都基于tcp的套接字实现并发,那么该如何实现并发?主要是基于tcp的套接字的两个循环,分别是链接循环、通信循环。
import socketserver class MyServer(socketserver.BaseRequestHandler): def handle(self): print('conn is',self.request) #conn print('addr is',self.client_address) #addr while True: try: #收消息 data = self.request.recv(1024) if not data:break print('收到客户端的消息是',data) #发消息 self.request.sendall(data.upper()) except Exception as e: print(e) break if __name__ == '__main__': s = socketserver.ThreadingTCPServer(('127.0.0.1',8080),MyServer) #ThreadingTCPServer是多线程 # s = socketserver.ForkingTCPServer(('127.0.0.1',8080),MyServer) #ForkingTCPServer是多进程 s.serve_forever()
from socket import * import subprocess import struct from functools import partial ip_port = ('127.0.0.1',8080) buffer_size = 1024 tcp_client = socket(AF_INET,SOCK_STREAM) tcp_client.connect(ip_port) while True: cmd = input('>>:').strip() if not cmd:continue if cmd == 'quit':break tcp_client.send(cmd.encode('utf-8')) data = tcp_client.recv(buffer_size) print('收到服务端发来的消息:',data.decode('utf-8')) tcp_client.close()
在这里客户端可以开多个,既能实现链接循环,也能实现通信循环。