Python之路(第三十一篇) 网络编程:简单的tcp套接字通信、粘包现象

时间:2024-01-19 19:20:32

一、简单的tcp套接字通信

套接字通信的一般流程

服务端

  server = socket() #创建服务器套接字
server.bind() #把地址绑定到套接字,网络地址加端口
server.listen() #监听链接
inf_loop: #服务器无限循环
conn,addr = server.accept() #接受客户端链接,建立链接conn
conn_loop: #通讯循环
conn.recv()/conn.send() #通过建立的链接conn不断的对话(接收与发送消息)
conn.close() #关闭客户端套接字链接conn
server.close() #关闭服务器套接字(可选)

  

客户端

  
  client = socket()    # 创建客户套接字
client.connect() # 尝试连接服务器,用ip+port
comm_loop: # 通讯循环
client.send()/client.recv() # 对话(发送/接收)消息
client.close() # 关闭客户套接字

  

套接字通信例子

socket通信流程与打电话流程类似,我们就以打电话为例实现简单的tcp套接字通信

服务端

  
  import socket

# 1.买手机
phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM) # 基于网络通信的 基于tcp通信的套接字

# 2.绑定手机卡(IP地址) 运行这个软件的电脑IP地址 ip和端口都应该写到配置文件中
phone.bind(('127.0.0.1',8080)) # 端口0-65535 0-1024 给操作系统,127.0.0.1是本机地址即本机之间互相通信

# 3.开机
phone.listen(5) # 5 代表最大挂起的链接数

# 4.等电话链接
print('服务器运行啦...')
# res = phone.accept() #底层 就是 tcp 三次握手
# print(res)
conn,client_addr = phone.accept() # conn 电话线 拿到可以收发消息的管道 conn链接

while True: #通信循环,可以不断的收发消息
# 5.收发消息
data = conn.recv(1024) # 1024个字节 1.单位:bytes 2.1024代表最大接收1024个bytes
print(data)

conn.send(data.upper())

# 6.挂电话
conn.close()

  

客户端

  
  import socket

# 1.买手机 客户端的phone 相当于服务端的 conn
phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM) # 基于网络通信的 基于tcp通信的套接字

# 2.拨号 (服务端的ip 和服务端的 端口)
phone.connect(('127.0.0.1',8080)) #phone 拿到可以发收消息的管道 链接对象phone,建立了与服务端的链接

while True:
# 3.发收消息 bytes型
msg = input("请输入:")
phone.send(msg.encode('utf-8'))
data = phone.recv(1024)
print(data)

# 4.关闭
phone.close()

  

注意:这里的发消息收消息都不能为空,否则会出现错误。

这里只能接收一个链接,不能循环接收链接,即打一次电话不能再打了,只能重新开机(重新运行程序)再打,

所以这里要加上链接循环。

加上链接循环

服务端

  
  import socket

phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
#这里是重用ip和端口,防止出现地址被占用的情况,即time_wait状态
phone.bind(('127.0.0.1',8080))
phone.listen(5)
while True: #连接循环 没有并发 但可一个一个 接收客户端的请求,一个链接结束,另外一个链接进来
print('服务器开始运行啦...')
conn,client_addr = phone.accept() # 现在没并发 只能一个一个
print(client_addr)

while True:
try: # try...except 出异常适合windows 出异常这里指客户端断开,防止服务端直接终止
data = conn.recv(1024)
if not data:break #linux客户端意外断开,这里接收的就是空,防止接收为空的情况
print('客户端数据:',data)
conn.send(data.upper())
except ConnectionResetError:
break
conn.close()
phone.close()

# 针对客户端意外断开的两种情况
#使用try ...except 是防止客户端意外断开产生
# ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接。
# 错误,针对windows系统

# linux客户端意外断开,这里接收的就是空,防止接收为空的情况
# 用if 判断接收的消息是否为空

  

客户端

  
  import socket
phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.connect(('127.0.0.1',8080))
while True:
msg = input('msg>>>:').strip() # ''
if not msg:continue #防止输入为空的情况
phone.send(msg.encode('utf-8')) # b''
data = phone.recv(1024)
print(data.decode('utf-8')) #解码

phone.close()

  

附:一个服务端,多个客户端,将一个客户端复制多个相同的文件,同时运行多个相同代码的客户端文件即可实现多个客户端链接服务端,但是这种链接不是同时的,只能一个客户端通信完,另外一个客户端在连接池(backlog设置的内容)里等着,等一个链接结束才能开始通信。

可能会遇到的问题

Python之路(第三十一篇) 网络编程:简单的tcp套接字通信、粘包现象

Python之路(第三十一篇) 网络编程:简单的tcp套接字通信、粘包现象

这个是由于你的服务端仍然存在四次挥手的time_wait状态在占用地址(如果不懂,请深入研究1.tcp三次握手,四次挥手 2.syn洪水攻击 3.服务器高并发情况下会有大量的time_wait状态的优化方法),即之前用的端口在系统中仍未清理

解决方法

方法1

  #加入一条socket配置,重用ip和端口

phone=socket(AF_INET,SOCK_STREAM)
phone.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) #就是它,在bind前加
phone.bind(('127.0.0.1',8080))

  

方法2

  在linux系统中
发现系统存在大量TIME_WAIT状态的连接,通过调整linux内核参数解决,
vi /etc/sysctl.conf

编辑文件,加入以下内容:
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_fin_timeout = 30 然后执行 /sbin/sysctl -p 让参数生效。 net.ipv4.tcp_syncookies = 1 表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭;

net.ipv4.tcp_tw_reuse = 1 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;

net.ipv4.tcp_tw_recycle = 1 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。

net.ipv4.tcp_fin_timeout 修改系統默认的 TIMEOUT 时间

  

二、基于tcp实现远程执行命令

模拟ssh远程执行命令 ,执行命令即Windows的命令提示行里输入命令,在linux的终端输入命令

通过tcp模拟执行命令并获得结果,这里需要用到subprocess模块

  如何执行系统命令: 并拿到执行结果
  import os
  os.system # 只能拿到 运行结果 0 执行成功 非0 失败
  一般用:
      import subprocess
      obj = subprocess.Popen('dir d:',shell=True) # shell 启了一个cmd
      把命令结果丢到管道里面:
          subprocess.Popen('dir d:',shell=True,
                stdout=subprocess.PIPE)
print(obj.stdout.read().decode('gbk'))拿到命令的结果
print(obj.stderr.read().decode('gbk'))拿到产生的错误,Windows系统用'gbk'编码,linux用'utf-8'编码
#且只能从管道里读一次结果

例子

服务端

  import socket
import subprocess

ip_port = ("127.0.0.1",8000)
buffer_size = 1024
tcp_server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
tcp_server.bind(ip_port)
tcp_server.listen(5)

while True:
print("服务器开始运行啦")
conn,addr = tcp_server.accept()
# print("conn是",conn)
while True:
try:
# 1、收到命令
cmd = conn.recv(buffer_size)
print("收到客户端的命令",cmd.decode("utf-8"))
# 2、执行命令,拿到结果
p = subprocess.Popen(cmd.decode("utf-8"),stdout=subprocess.PIPE,stdin=subprocess.PIPE,stderr=subprocess.PIPE,shell=True)
res_cmd_err = p.stderr.read()
res_cmd_out = p.stdout.read() #这里产生的结果Windows的编码是'gbk',linux是'utf-8'
# print("res_cmd——out",res_cmd_out)
if res_cmd_err: #出现错误
res_cmd = res_cmd_err
conn.send(res_cmd)
else:
if not res_cmd_out: #命令正常执行,但没有返回值
res_cmd = "命令执行成功!"
conn.send(res_cmd.encode("gbk")) #3、将结果返回给客户端,注意Windows和linux的编码不同
else:
conn.send(res_cmd_out)
except Exception as e:
print(e)
break
conn.close()

  

客户端

  
  import socket

ip_port = ("127.0.0.1",8000)
buffer_size = 1024
tcp_client = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
tcp_client.connect(ip_port)
while True:
# 1、发命令
cmd = input("请输入命令:").strip()
if not cmd:continue
if cmd == "quit":break
tcp_client.send(cmd.encode("utf-8"))
# 2、接收命令,但是这里接收的数据量可能大于buffersize,即一次接收不完,下次通信接收的是上次未接收完的数据,就会产生粘包现象
res = tcp_client.recv(buffer_size)
print(res.decode("gbk")) #注意Windows和linux的编码不同
tcp_client.close()

  

三、tcp粘包现象

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

socket收发消息的底层原理

Python之路(第三十一篇) 网络编程:简单的tcp套接字通信、粘包现象

Python之路(第三十一篇) 网络编程:简单的tcp套接字通信、粘包现象

收发消息流程

1、发送方的应用程序将字节要发送的消息复制到自己的缓存(内存),操作系统(os)通过调用网卡将缓存的消息发送到接收方的网卡

2、接收方网卡将消息存在自己操作系统的缓存中,接收方的应用程序从自己的缓存中取出消息

总结

1、程序的内存和os(操作系统)的内存两个内存互相隔离,程序的内存是用户态 的内存,操作系统的内存是内核态的内存

2、发送消息是将用户态的内存复制给内核态的内存

3、发送方遵循tcp协议将消息通过网卡发送给接收方,接收方通知接收方的操作系统调用网卡接收数据,还要讲内存态的消息复制到用户态的内存

4、发送方消息复制给自己内核态的内存速度快时间短,接收方要通知OS收消息,还要复制消息,用时长

不管是recv还是send都不是直接接收对方的数据,而是操作自己的操作系统内存,不是一个send对应一个recv

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

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

发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一个TCP段。若连续几次需要send的数据都很少,通常TCP会根据优化算法(Nagle算法,将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包)把这些数据合成一个TCP段后一次发送出去,这样接收方就收到了粘包数据。

两种情况下会发生粘包

1、发送端需要等缓冲区满才发送出去,造成粘包(发送数据时间间隔很短,数据了很小,会合到一起,产生粘包)

2、接收方不及时接收缓冲区的包,或者由于buffersize的限制,一次接收不完,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包)

例子

服务端

  
  import socket
import time
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('127.0.0.1', 9999))
server.listen(5)

print('... 开始运行...')
conn, addr = server.accept()

#data1 = conn.recv(1024)

data1 = conn.recv(1) # 当只取一个字符的时候,剩下的数据还在缓存池里面,下次接收时间很短的话,
# 会继续把上次没接收完的一起取出来,就发生的粘包现象
print('第一次', data1)

data2 = conn.recv(1024)
print('第二次', data2)

conn.close()
server.close()

  

客户端

  # 两次send:数据量小,时间间隔很短,会发生粘包

import socket
import time

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 9999))

client.send('hello'.encode('utf-8'))

# time.sleep(1) #两次send直接隔一段时间,不会发生粘包现象

client.send('world'.encode('utf-8'))

client.close()

  

四、解决粘包问题

粘包问题产生的根源是接收方不知道一次提取多少字节的数据,那么需要发送方在发送数据前告知接收方我这次要发送多少字节的数据即可。

解决方式的简单版

先用struct 发送固定长度的消息,传递要发送消息的长度,然后按照这个长度接收消息

服务端

  import socket
import subprocess
import struct

ip_port = ("127.0.0.1",9001)
back_log = 5
buffer_size = 1024

tcp_server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)

while True:
conn,addr = tcp_server.accept()
print("服务器开始运行啦!")
while True:
try:
cmd = conn.recv(buffer_size)
if not cmd: break
p = subprocess.Popen(cmd.decode("utf-8"), stderr=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, shell=True)
err = p.stderr.read()
if err:
res_cmd = err
else:
res_cmd = p.stdout.read()
if not res_cmd:
res_cmd = "执行成功!".encode("gbk")
print("命令已经执行!")

# 第一步:获取结果消息的长度
length = len(res_cmd)
# 第二步:将结果消息的长度封装为一个固定长度的报头
header = struct.pack("i", length)
# 第三步:先向接收方发送报头,使接收方知道真正接收的消息是多长,
# 然后根据这个长度来重复循环接收消息
conn.send(header)
conn.send(res_cmd)
except Exception as e:
print(e)
break
conn.close()

  

客户端

  import socket
import struct

ip_port = ("127.0.0.1", 9001)
buffer_size = 1024

tcp_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_client.connect(ip_port)

while True:
cmd = input("请输入命令:")
if not cmd: continue
if cmd == "quit": break
tcp_client.send(cmd.encode("utf-8"))
# 第一步:接收一个固定长度的报头
header = tcp_client.recv(4)
# 第二步:解码获取报头里隐藏的真实要接收消息的长度
res_length = struct.unpack("i", header)[0]
# 第三步:根据消息的长度来不断的循环收取消息
recv_data = b""
recv_data_size = 0
while recv_data_size < res_length:
res_cmd = tcp_client.recv(buffer_size)
recv_data = recv_data + res_cmd
recv_data_size = len(recv_data)
print("收取的数据是", recv_data.decode("gbk"))

tcp_client.close()

  

  

解决方式终极版

通过自定义的报头来传递除了消息长度外更多的消息,为传递的消息做一个字典。

服务端

  import socket
import subprocess
import struct
import json

ip_port = ("127.0.0.1", 9000)
back_log = 5
buffer_size = 1024
tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)

while True:
print("服务器开始运行啦!")
conn, address = tcp_server.accept()
while True:
try:
cmd = conn.recv(buffer_size)
if not cmd: continue
p = subprocess.Popen(cmd.decode("utf-8"), stderr=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, shell=True)
err = p.stderr.read()
# print(err)
if err:
res_cmd = err
else:
res_cmd = p.stdout.read()
# print(res_cmd)
# print(res_cmd)
if not res_cmd:
res_cmd = "已经执行啦!".encode("gbk")
res_length = len(res_cmd)
# 第一步:制作自定制的字典作为报头,存储多种信息
header_dict = {
"filename": "a.txt",
"md5": "7887414147774415",
"size": res_length
}
# 第二步:将字典序列化转为json字符串,然后进行编码转成bytes,以便于直接网络发送
header_bytes = json.dumps(header_dict).encode("utf-8")
# 第三步:获得这个报头的长度
header_length = len(header_bytes)
# 第四步:将报头的长度打包成固定的长度,以便接收方先接收报头
send_header = struct.pack("i", header_length)
# 第五步:先发送报头的长度
conn.send(send_header)
# 第六步:发送报头
conn.send(header_bytes)
# 第七步:发送真实的消息
conn.send(res_cmd)
except Exception as e:
print(e)
break
conn.close()

  

客户端

import socket
import struct
import json ip_port = ("127.0.0.1", 9000)
buffer_size = 1024
tcp_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_client.connect(ip_port) while True:
cmd = input("请输入命令:")
if not cmd: continue
if cmd == "quit":break
tcp_client.send(cmd.encode("utf-8"))
# 第一步:接收报头的长度信息
header_length = tcp_client.recv(4)
# 第二步:获取报头的长度,解码获取报头的长度
header_size = struct.unpack("i", header_length)[0]
# 第三步:根据报头的长度信息接收报头信息
header_bytes = tcp_client.recv(header_size).decode("utf-8")
# 第四步:根据接收的报头信息反序列化获得真实的报头
header_dict = json.loads(header_bytes)
print("客户端收到的报头字典是",header_dict)
# 第五步:根据报头字典获取真实消息的长度
res_size = header_dict["size"]
# 第六步:根据获取的真实消息的长度不断循环获取真实消息
data = b""
data_size = 0
while data_size < res_size:
recv_data = tcp_client.recv(buffer_size)
data = data + recv_data
data_size = len(data)
print("接收的数据是", data.decode("gbk")) tcp_client.close()