Python自动化运维 - day8 - socket编程
Python自动化运维 - day8 - socket编程
概述
自从互联网诞生以来,现在基本上所有的程序都是网络程序,很少有单机版的程序了。
计算机网络就是把各个计算机连接到一起,让网络中的计算机可以互相通信。网络编程就是如何在程序中实现两台计算机的通信。
举个例子,当你使用浏览器访问新浪网时,你的计算机就和新浪的某台服务器通过互联网连接起来了,然后,新浪的服务器把网页内容作为数据通过互联网传输到你的电脑上。
由于你的电脑上可能不止浏览器,还有QQ、微信、邮件客户端等,不同的程序连接的别的计算机也会不同,所以,更确切地说,网络通信是两台计算机上的两个进程之间的通信。比如,浏览器进程和新浪服务器上的某个Web服务进程在通信,而QQ进程是和腾讯的某个服务器上的某个进程在通信。
网络编程对所有开发语言都是一样的,Python也不例外。用Python进行网络编程,就是在Python程序本身这个进程内,连接别的服务器进程的通信端口进行通信。
TCP/IP协议基础
计算机为了联网,就必须规定通信协议,早期的计算机网络,都是由各厂商自己规定一套协议,IBM、Apple和Microsoft都有各自的网络协议,互不兼容,这就好比一群人有的说英语,有的说中文,有的说德语,说同一种语言的人可以交流,不同的语言之间就不行了。
后来为了打破这个局面,出现了一套全球通用协议族,叫做互联网协议,互联网协议包含了上百种协议标准,但是最重要的两个协议是TCP和IP协议,所以,大家把互联网的协议简称TCP/IP协议。
通信的时候,双方必须知道对方的标识,好比发邮件必须知道对方的邮件地址。互联网上每个计算机的唯一标识就是IP地址,是由4个点分十进制数组成(例如:12.21.21.41)。
下面是TCP/IP协议分层:
TCP/UDP协议则是建立在IP协议之上的。TCP协议负责在两台计算机之间建立可靠连接,保证数据包按顺序到达。TCP协议会通过握手建立连接,然后,对每个IP包编号,确保对方按顺序收到,如果包丢掉了,就自动重发。相对于TCP(面向连接)来说,UDP则是面向无连接的协议,使用UDP协议时,不需要建立连接,只需要知道对方的IP地址和端口号,就可以直接发数据包。但是,能不能到达就不知道了。虽然用UDP传输数据不可靠,但它的优点是和TCP比,速度快,对于不要求可靠到达的数据,就可以使用UDP协议。
许多常用的更高级的协议都是建立在TCP协议基础上的,比如用于浏览器的HTTP协议、发送邮件的SMTP协议等。
一个IP包除了包含要传输的数据外,还包含源IP地址和目标IP地址,源端口和目标端口。那么端口有什么作用呢?在两台计算机通信时,只发IP地址是不够的,因为同一台计算机上跑着多个网络程序。一个IP包来了之后,到底是交给浏览器还是QQ,就需要端口号来区分。每个网络程序都向操作系统申请唯一的端口号,这样,两个进程在两台计算机之间建立网络连接就需要各自的IP地址和各自的端口号。
TCP编程
Socket成为安全套接字,是网络编程的一个抽象概念。通常我们用一个Socket表示“打开了一个网络链接”,而打开一个Socket需要知道目标计算机的IP地址和端口号,再指定协议类型即可。
客户端:
大多数连接都是可靠的TCP连接。创建TCP连接时,主动发起连接的叫客户端,被动响应连接的叫服务器。
import socket
client = socket.socket(socke.AF_INET,socket.SOCK_STREAM) #指定这个socket链接的协议,以及指定数据流的类型
client.connect(('127.0.0.1',8080)) #连接server端,需要知道服务端的IP和PORT(元组的形式)
client.send('hello'.encode='UTF-8') #发送消息,注意Python3中,传输的数据都是bytes格式的
server_msg = client.recv(1024) #接收1024个字节的数据
print(server_msg)
-
socke.AF_INET 指的是使用 IPv4
- socket.SOCK_STREAM 指定使用面向流的TCP协议
服务端:
和客户端编程相比,服务器编程就要复杂一些。
import socket
server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) # 实例化一个链接
server.bind(('127.0.0.1',8080)) #server端监听一个地址,等待client连接
server.listen(5) # 指定TCP连接池的可用连接个数,Linux中的backlog概念
conn,addr = server.accept() # 接收客户端连接,获取客户端的信息,会返回两个元素,连接标识符,和客户端的地址/端口(元组的形式)
client_msg = conn.recv(1024) #接收1024个字节的数据
print(client_msg)
conn.send(client_msg.upper()) #通过连接标识符发送数据给客户端
conn.close() # 关闭连接
server.close() # 服务端关闭端口
- 小于1024的端口只有管理员才可以指定
通讯循环及客户端发空消息时的问题
抛出问题:
-
通讯不应该是单次的,应该至少是多次的
- 如果我们发送的消息为空的时候,就会卡住,服务端无法接受,客户端无法继续发送
针对问题做如下改进:
服务端:
增加循环,完成通信循环,并且把客户端发来的消息转换成大写的并返回。
import socket
server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
server.bind(('127.0.0.1',8080))
server.listen(5)
print('wait for connect')
conn,addr = server.accept()
print('client connect',addr)
while True: #循环的接受消息
client_msg = conn.recv(1024)
print('client msg :', client_msg)
conn.send(client_msg.upper())
conn.close()
server.close()
客户端:
增加循环,完成通信循环,并且发送的消息由用户来输入,当输入为空的时候,继续循环。
import socket
client = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
client.connect(('127.0.0.1',8080))
while True: #通信循环
msg = input('>>:').strip()
if not msg:continue #当用户输入为空的时候,继续循环
client.send(msg.encode('utf-8'))
server_msg = client.recv(1024)
print(server_msg.decode('utf-8'))
client.close()
链接循环及客户端强制退出时的问题
抛出问题:
- 当客户端异常关闭一个链接的时候,服务端也会产生异常
- windows下会异常退出(由于tcp是双向链接的,客户端异常退出,那么服务端就不能继续循环的收发消息了)
- Linux下会进入死循环(收到了空消息)
- 当一个客户端连接断开,服务端应该可以继续接受其它客户端发来的消息
由于问题集中在服务端,所以对服务端做如下改进:
服务端:
添加链接循环,当一个链关闭时,可以继续接受其他链接。
添加异常处理,当客户端异常关闭时,主动的关闭服务端的链接。
import socket
server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
server.bind(('127.0.0.1',8080))
server.listen(5)
while True: #链接循环
print('wait for connect')
conn,addr = server.accept()
print('client connect',addr)
while True:
try: #Windows下捕捉客户端异常关闭连接
client_msg = conn.recv(1024)
if not client_msg:break #Linux下处理客户端异常退出问题
print('client msg :', client_msg)
conn.send(client_msg.upper())
except (ConnectionResetError,Exception): #except可以同时指定多个异常
break
conn.close()
server.close()
客户端异常关闭时,服务端的异常为:ConnectionResetError,我们可以通过捕捉其,来控制服务端的推出,也可以使用 Exception(通用)异常来捕捉。
模拟远程执行命令
利用socket,远程执行命令,并返回,模拟ssh的效果
- 执行命令使用subprocess模块的Popen和PIPE
- 注意subprocess的Popen模块执行结果就是bytes格式的str,所以不用转换即可直接发送
以上需求都针对服务端,那么对服务端做如下修改
import socket
from subprocess import Popen,PIPE
server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
# server.bind(('192.168.56.200',8080))
server.bind(('127.0.0.1',8080))
server.listen(5)
while True:
print('wait for connect')
conn,addr = server.accept()
print('client connect',addr)
while True:
try:
cmd = conn.recv(1024).strip()
if not cmd:break
p = Popen(cmd.decode('utf-8'),shell=True,stdout=PIPE,stderr=PIPE)
stdout,stderr = p.communicate() #执行的结果就是bytes格式的string
if stderr:
conn.send(stderr)
else:
conn.send(stdout)
except (ConnectionResetError,Exception):
break
conn.close()
server.close()
粘包问题
由于我们在接受和发送数据的时候,都指定了每次接收1024个字节的数据,而发送的数据我们是不可估量的,如果发送的时候超过1024字节,那么在接收端就无法一次收取完毕,这些数据会存放在操作系统缓存中,那么下次再接收1024字节的数据的时候,会从缓存中继续读取,那么就会发生粘包现象。
所谓粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。
只有TCP有粘包现象,UDP永远不会粘包
- UDP是面向报文的,发送方的UDP对应用层交下来的报文,不合并,不拆分,只是在其上面加上首部后就交给了下面的网络层,也就是说无论应用层交给UDP多长的报文,它统统发送,一次发送一个。而对接收方,接到后直接去除首部,交给上面的应用层就完成任务了。因此,它需要应用层控制报文的大小
- TCP是面向字节流的,它把上面应用层交下来的数据看成无结构的字节流来发送,可以想象成流水形式的,发送方TCP会将数据放入“蓄水池”(缓存区),等到可以发送的时候就发送,不能发送就等着,TCP会根据当前网络的拥塞状态来确定每个报文段的大小。
不是server端直接发送,client端直接接收
服务端
- 应用程序是运行在用户态的,发送数据的时候,需要去调用物理网卡,而这个操作是不许允许的,必须预先将运行状态切换为内核态才可以操作网卡发送数据,所以引入操作系统缓存的概念。
- 应用程序把需要进行系统调用(用户态-->内核态的切换)的指令放入操作系统缓存,然后由操作系统统一去执行。
客户端
- 操作系统把从网卡接收到的数据存入操作系统缓存中去,供应用程序读取
- 应用程序直接从操作系统缓存中将数据读出,然后进行处理
整个过程如图:
解决黏包问题的绝招
发生黏包的本质问题是对端不知道我们发送数据的总长度,如果能否让对方提前知道,那么就不会发生粘包现象。
根据TCP报文的格式得到启发:
- 发送真正的数据前,需要预先发送本次传送的报文大小(增加报头)
- 报头的长度必须是固定的
1、引入struct模块
struct.pack() 打包
struct.pack('i',int)
#i表示把数字用4个字节进行表示,这样的话就可以表示2的32次方的数字,已经满足需求
#后面的int表示要打包的数字(要发送的报文长度)
#通过struct.pack 会得到bytes格式的数据,可以直接进行发送
struct.unpack() 解包
struct.unpack('i',obj)
#obj表示收取到数据
#会返回一个元组,元组的第一个元素为对方传过来的报文长度
#可以复制给一个变量来指定接收的报文长度
2、通过struct传递包头解决粘包问题
# 服务端
#!/usr/bin/env python
# Author:Lee Sir
#_*_ coding:utf-8 _*_
import socket
from subprocess import Popen,PIPE
import struct
server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
server.bind(('127.0.0.1',8000))
server.listen(5)
while True:
print('等待连接......')
conn,addr = server.accept()
print('客户端地址为:',addr)
while True:
try:
cmd_bytes = conn.recv(1024)
if not cmd_bytes:continue
cmd_str = cmd_bytes.decode('utf-8')
print('执行的命令是:',cmd_str)
#执行命令
p = Popen(cmd_str,shell=True,stdout=PIPE,stderr=PIPE)
stdout,stderr = p.communicate()
#返回的数据
if stderr:
send_data = stderr
else:
send_data = stdout
#构建报头并发送报头
conn.send(struct.pack('i',len(send_data)))
#发送数据
conn.send(send_data)
except Exception:
break
#客户端
#!/usr/bin/env python
# Author:Lee Sir
#_*_ coding:utf-8 _*_
import socket
import struct
client = socket.socket(socket.AF_INET,socket.SOCK_STREAM) client.connect(('127.0.0.1',8000))
while True:
msg = input('Please input msg: ')
if not msg:continue
client.send(msg.encode('utf-8'))
#接收报头,服务端使用i模式,所以固定是4个字节
server_data_head = client.recv(4)
server_data_len = struct.unpack('i',server_data_head)[0]
#根据传递的报头长度接收报文
server_data = client.recv(server_data_len)
print(server_data.decode('gbk'))
3、当数据量比较大以及需要额外其他数据的场合下,以上的解决方案就有问题
- 数据量非常大,上百T,在打包的时候有可能struct.pack的i模式无法满足需求,因为只能打长度为2的32次方的数据,虽然可以使用Q模式,支持2的64次方,但是也不能准确的预测是否满足数据的最大长度,另外客户端直接接受那么大的数据就显得非常笨拙,也很吃力
- 在下载的场景下,我们可能需要的数据还有文件名、以及hash值
针对上面的问题有以下解决方案:
- 客户端接收的时候分段接收
- 定义字典记录报文的长度,以及其他需求:比如filename,hash值等其他信息
服务端
#!/usr/bin/env python
# Author:Lee Sir
#_*_ coding:utf-8 _*_
import socket
from subprocess import Popen,PIPE
import struct
import json
server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
server.bind(('127.0.0.1',8080))
server.listen(5)
while True:
print('等待连接......')
conn,addr = server.accept()
print('客户端地址为:',addr)
while True:
try:
cmd_bytes = conn.recv(1024)
if not cmd_bytes:continue
cmd_str = cmd_bytes.decode('utf-8')
print('执行的命令是:',cmd_str)
#执行命令
p = Popen(cmd_str,shell=True,stdout=PIPE,stderr=PIPE)
stdout,stderr = p.communicate()
#返回的数据
if stderr:
send_data = stderr
else:
send_data = stdout
#创建报头内容及获取包头长度
file_dict = {'filename':None,'hash':None,'size':len(send_data)}
file_json = json.dumps(file_dict).encode('utf-8')
file_json_len = len(file_json)
#构建报头
file_head = struct.pack('i',file_json_len)
#发送报头长度
conn.send(file_head)
#发送报头
conn.send(file_json)
#发送数据
conn.send(send_data)
except Exception:
break
客户端
#!/usr/bin/env python
# Author:Lee Sir
import socket
import struct
import json
client = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
client.connect(('127.0.0.1',8080))
while True:
msg = input('Please input msg: ')
if not msg:continue
client.send(msg.encode('utf-8'))
#接收报头,服务端使用i模式,所以固定是4个字节
server_file_head = client.recv(4)
server_file_len = struct.unpack('i',server_file_head)[0]
#接收报文头部信息
server_head_file = client.recv(server_file_len)
#报文头部信息
server_head = json.loads(server_head_file.decode('gbk'))
#获取报文的头部信息
server_file_name = server_head['filename']
server_file_hash = server_head['hash']
server_file_size = server_head['size']
#根据传递的报头长度分段接收报文
recv_len = 0
server_data = b''
while recv_len < server_file_size:
recv_data = client.recv(1024)
server_data += recv_data
recv_len += len(recv_data)
print(server_data.decode('gbk'))
UDP编程
使用udp编程和使用tcp编程用于相似的步骤,而因为udp的特性,所以它的服务端不需要监听端口,并且客户端也不需要事先连接服务端。
服务端:
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 指定socket的协议,UDP使用的是SOCK_DGRAM
server.bind(('127.0.0.1', 9999)) # 绑定端口
print('UDP Server is Starting...')
data, addr = server.recvfrom(1024) # 接受(包含数据以及客户端的地址)
print('Received from {}'.format(addr))
server.sendto('hello,{}'.format(addr).encode('utf-8'), addr) # 应答,格式为(应答的数据,客户端的IP和Port元组)
客户端:
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 指定socket的协议,UDP使用的是SOCK_DGRAM
client.sendto('hello world'.encode('utf-8'), ('127.0.0.1', 9999)) # 发送数据,格式为(发送的数据,服务端的IP和Port元组)
print(client.recv(1024).decode('utf-8')) # 同样使用recv来接受服务端的应答数据
PS:UDP的使用与TCP类似,但是不需要建立连接。此外,服务器绑定UDP端口和TCP端口互不冲突,也就是说,UDP的9999端口与TCP的9999端口可以各自绑定。