Python编程——基于TCP的套接字通信包【开源】

提示:转载请注明出处,如果该文章对您有帮助,请点赞支持下    


前言

基于TCP的套接字通信是深入学习Python程序的必备技能之一,套接字不仅可以用于网络编程,在本地不同进程之间的通信不同编程语言的程序通信中也应用十分广泛。
本篇文章是在之前
了解套接字编程接口的基础上进一步扩展,写出一套真正可用于实际程序通信的代码

本篇文章将会集中研究套接字通信中的分包和粘包问题自定义通信协议的方法,方便入门套接字的同学进阶,本篇文章的代码将会在GitHub上开源,之后还会推出C#版本等。

如果您不了解基础的基于Python的TCP套接字接口,可以先简单阅读我的博客Python编程——基于TCP的套接字简单通信,如果觉得对您有帮助,欢迎收藏、点赞。


一、TCP套接字的优点与不足

众所周知,套接字是主流语言提供的用于进行网络进程通信的程序接口,其中最常见的是基于TCP协议的套接字编程,基于TCP协议的套接字可以保证:

可靠传播。在一般情况下,数据传输过程中数据不会发生丢失,并且数据发送和接收的顺序不会改变,即我发送一个“Hello”和“World”,对方一定会按顺序先后收到“Hello”和“World”。

数据可靠。在一般情况下,数据传输过程中无论数据包被如何组合拆分等,都不会添加与发送信息无关的无效信息,即数据不会被污染,按照指定编码方式进行解码即可还原原来的信息。

但是在实际进行TCP编程的过程中,如果使用原生的套接字接口,在处理消息通信时,需要处理以下两个典型问题:

1、分包和粘包问题

(1)分包问题

TCP是以(Segment)为单位发送数据的,建立TCP链接后,有一个最大消息长度(MSS)。如果应用层数据包超过MSS,就会把应用层数据包拆分,分成两个段来发送。这个时候接收端的应用层就要拼接这两个TCP包,才能正确处理数据。

简单理解,分包现象就是一次传输的数据过多时,TCP协议会自动将本次的数据拆分成多个消息包进行发送。

例如,发送方发送字符串”helloworld”,接收方却接收到了两个字符串”hello”和”world”。

(2)粘包问题

在某些特殊环境下,TCP为了提高网络的利用率,会使用一个叫做Nagle的算法。该算法是指,发送端即使有要发送的数据,如果很少的话,会延迟发送。如果应用层给TCP传送数据很快的话,就会把两个应用层数据包“粘”在一起,TCP最后只发一个TCP数据包给接收端。

简单理解,粘包现象就是当网络繁忙时,TCP协议会将多份小的消息包打包成一个消息包进行发送。

例如,发送方发送两个字符串”hello”+”world”,接收方却一次性接收到了”helloworld”。

2、自定义通信协议

TCP套接字可以提供基本的字符串类型的信息传输,但是字符串可以代表什么含义,怎么解释它需要开发者来自定义。更重要的是,为了解决分包和粘包问题,我们也必须自定义一个简单的通信协议。

通信协议听起来很高大上,其实本质上就是发送方接收方达成的约定,对每个完整的消息包格式做出规定,接收数据时按照这个格式进行检查,从而得到一个个正确的消息包。

最简单的消息格式就是我们在消息正文的前方加入一个固定长度的数字表示消息正文的长度,这样就可以基本解决分包和粘包问题。

但是为了之后可以判断我们的消息类型,也为了便于设计客户端和服务端,我们这里稍微复杂一点,即本文定义的消息格式如下:

本文定义的消息格式
消息头消息正文
正文长度(bodySize)指令类型(cmd)是否回复(recv)正文
无符号32位整型变量无符号32位整型变量无符号32位整型变量——
4字节4字节4字节——

 

接下来我们就会详细介绍如何实现上述设计。

二、自定义通信协议类

我们自定义一个通信协议类MsgProtol来帮助我们实现发送的消息的打包和拆包功能,同时为了区分不同的消息类型,我们会先定义一个枚举类MsgCmd

1.定义消息类型

这个枚举类十分简单,不再介绍。

from enum import Enum

class MsgCmd(Enum):
    INFORM = 1 # 通知,不需要回复
    REQUEST = 2 # 请求,需要回复
    PARAM =3 # 参数

2、定义消息协议类

我们先接受这个消息协议类定义的全局变量,之后我们在本节设计的每个方法都是该类的公有方法。

class MsgProtol(object):
    def __init__(self):
        self.dataBuffer = bytes() # 接收消息的缓存
        self.headerSize = 12 # 3*4,消息头的字节总数

3、打包方法

打包方法的逻辑比较简单,就是将由整数组成的消息头和其他类型组成的消息正文分别编码成字节格式,然后组合在一起。

首先我们知道的是,网络上传输的数据格式都是字节形式(byte)。

消息头都是整数形式,我们使用struct来打包,struct可以将python中的整形数字打包成字节形式,这也是网络编程中打包数字最常见的方式;

消息正文使用的json来打包,json打包是将python的字典打包成json对象格式,本质上还是字符串,然后我们调用字符串的编码格式转换成字节。

为什么我们要专门转换成json对象格式?

Json对象可以转换Python字典,字典可以帮助我们传输参数;

Json对象格式十分通用,可以方便地在不同语言进行转换。

详细的打包方法如下: 

def pack(self,cmd,_body,_recv=1):
    body = json.dumps(_body) # 将消息正文转换成Json格式
    header = [body.__len__(),cmd,_recv] # 将消息头按顺序组成一个列表
    headPack= struct.pack("!3I", *header) #  使用struct打包消息头,得到字节编码
    sendData = headPack+body.encode()  # 将消息头字节和消息正文字节组合在一起
    return sendData

一个简单的调用案例如下,需要注意其中传入cmd的形式,其必须是整形数字:

# 自定义消息
data={}
data["source"]="客户端"
data["msg"]="你好"
# 打包消息
msg = self.msg_protol.pack(MsgCmd.PARAM.value,data,_recv=0) 
# 发送消息
self._socket.send(msg)  

4、解包方法

解包比打包要复杂一点,因为我们要处理一下分包和粘包问题。首先我们看一下我们的解包方法如何调用:

            while True:
                msg = self._client_socket.recv(self._buffer_size)# 接收消息
                flag = self.msg_protol.unpack(msg,self._handle_msg)# 解封消息包

上述是服务端的调用代码,因为我们的服务端要时刻接收客户端消息,所以我们这里使用了一个 循环,如果想暂停,可以判断flag。

接下来我们看解包方法:

            self.dataBuffer += data
            while True:
                # 数据量不足消息头部时跳出函数继续接收数据
                if len(self.dataBuffer) < self.headerSize:
                    #print("数据包(%s Byte)小于消息头部长度,跳出小循环" % len(self.dataBuffer))
                    break
                # struct中:!代表Network order,3I代表3个unsigned int数据
                headPack = struct.unpack('!3I', self.dataBuffer[:self.headerSize])# 解码出消息头部
                # 获取消息正文长度
                bodySize = headPack[0]
                # 分包情况处理,跳出函数继续接收数据
                if len(self.dataBuffer) < self.headerSize + bodySize:
                    #print("数据包(%s Byte)不完整(总共%s Byte),跳出小循环" % (len(self.dataBuffer), self.headerSize + bodySize))
                    break
                # 读取消息正文的内容
                body = self.dataBuffer[self.headerSize:self.headerSize + bodySize]
                msgHandler(headPack,body.decode())
                # 粘包情况的处理,获取下一个数据包部分
                self.dataBuffer = self.dataBuffer[self.headerSize + bodySize:]
            if len(self.dataBuffer)!=0:
                return True # 继续接收消息
            else:
                return False  # 不再接收消息

在上述代码中,我们首先将本次接收到的data放入我们的缓存中,然后此时判断缓存大小,如果不够消息头长度,说明此时接收的还远远不够,跳出循环,解包方法返回外部继续接收数据data;

假如此时再次进入,data再次加入我们的缓存后,已经超过了消息头长度,那么我们就可以将消息头解包出来得到消息正文长度,然后和消息正文长度+消息头长度的和比较,就能判断这个消息包是否完整;

如果不完整继续跳出循环去接收数据data,如果已经完成则解码出消息正文body,将消息头headpack和消息正文body传入msgHandler去处理该完整的消息即可。

此时我们从缓存中取出上一个已经交付处理的完整消息包的长度,继续该循环来看剩下的缓存中是否还有数据。

如果我们不想无限制的接收消息,例如客户端,那么只需要在经过上述处理后判断下此时的缓存大小是否为0,如果为0则表示此时没有残余数据,可以不用继续接收数据,如果不为0,则表示现在的消息数据还没有处理完应该继续接收。

三、定义服务端套接字类

此时我们已经定义好了通信协议类,接下来只需要定义稍微复杂的服务端,由于我们已经默认读者具备套接字的基础知识,所以我们这里会简单过一下各个重要方法。

1、套接字的初始化、连接和关闭

(1)套接字的初始化

import json
import logging
import socket
from MsgProtol import MsgCmd,MsgProtol

logging.basicConfig(level=logging.INFO) # 定义日志输出的水平
logger = logging.getLogger("server")

class ServerSocket(object):
    def __init__(self, ip="127.0.0.1", port=5006):
        self.ip = ip  # ip地址
        self.port = port  # 端口号
        self._buffer_size = 12000  # 接收客户端消息的内存大小
        self._is_init_socket = False  # 套接字是否初始化
        self._recv_init_msg = False  # 是否接收到客户端连接发送来的初始化参数
        self.msg_protol = MsgProtol() # 网络消息,为了调用其打包和解包功能
        self._init_socket() # 初始化套接字
        self._socket.settimeout(30)  # 设置套接字的监听时间
        self._conn_client() # 连接客户端

    def _init_socket(self):
        """初始化套接字"""
        try:
            # 初始化Socket
            self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 创建Socket对象
            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 设置Socket对象
            ip_port = (self.ip, self.port) # 定义IP地址和端口号
            logging.info("服务端的IP地址是 {}:{}".format(self.ip,self.port))
            self._socket.bind(ip_port)# 绑定IP地址
            self._socket.setblocking(False)# 设置阻塞模式为False
            self._is_init_socket = True
        except socket.error:
            self._is_init_socket = True
            self.close()
            raise socket.error("无法初始化服务端套接字 ")

(2)套接字的关闭

    def close(self):
        logger.info("服务端套接字正在关闭..._loaded:" + str(self._recv_init_msg) + " _open_socket:" + str(self._is_init_socket))
        # 当服务端套接字被初始化过并且连接到了客户端连接则关闭客户端套接字
        if self._recv_init_msg & self._is_init_socket:
            self._client_socket.send(b"EXIT") # 通知客户端关闭连接
            self._client_socket.close() #关闭客户端调节
            self._recv_init_msg = False
        # 当服务套接字被初始化过
        if self._is_init_socket:
            self._socket.close() # 关闭服务端套接字
            self._is_init_socket = False
        else:
            raise socket.error("无法关闭服务端套接字,因为没有初始化")

(3)套接字的连接

    def _conn_client(self):
        """连接客户端"""
        try:
            try:
                self._socket.listen(1)# 开启侦听
                self._client_socket, addr = self._socket.accept()# 接收到请求
                logging.info('接收到客户端连接')
                self._client_socket.settimeout(30) # 设置客户端套接字的连接时长
            except socket.timeout as e:
                raise socket.error("客户端连接超时")
            self._recv_bytes() # 接收消息
        except socket.error:
            logging.debug("连接客户端出错")
            self.close()
            raise

2、套接字的接收、分类处理消息和发送

(1)套接字的接收

 def _recv_bytes(self):
        """接收客户端的字节消息,按照消息长度切分成一个完整的字符串"""
        try:
            flag = True
            logger.info("开始接收消息...")
            while True:
                msg = self._client_socket.recv(self._buffer_size)# 接收消息
                flag = self.msg_protol.unpack(msg,self._handle_msg)# 解封消息包
            #logger.info("接收消息结束...")
        except socket.timeout as e:
            logging.debug("客户端发送消息超时, 即将关闭客户端套接字")
            self.close()

(2)套接字的分类处理消息

    def _handle_msg(self,headPack,body):
        """分类处理接收到的消息字符串"""
        if  self._is_init_socket:
            # 数据处理
            cmd = MsgCmd(headPack[1]).name  # 获取Code的值
            is_recv = headPack[2]
            logging.info("收到1个数据包->bodySize:{}, cmd:{},recv:{}".format(headPack[0],cmd,is_recv))
            p = json.loads(body)  # 将字符串解码并且反序列化为json对象
            # 检查消息类型
            if cmd == "EEXIT":
                self.close()
                return
            elif cmd == "INFORM":
                print(p)
            elif cmd == "PARAM":
                # 存储参数信息
                self._log_path = p["logPath"]
                self._brain_names = p["brainNames"]
                print(self._log_path)
                print(self._brain_names)
                self._recv_init_msg = True
                if is_recv ==1:
                    print("应该发送消息")
                    self._send(MsgCmd.INFORM.value,"这是消息")
            else:
                logging.error("\n未知的cmd:{0}".format(cmd))

(3)套接字的发送

    def _send(self,cmd,data,is_recv=0):
        """发送一个消息字符串"""
        try:
            #obj = {"type": MsgType.REQUEST.name, "msg":msg,"time": time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time()))}
            msg = self.msg_protol.pack(cmd, data,is_recv)
            self._client_socket.send(msg)  # 发送消息
            logger.info("发送1个数据包->cmd:" + MsgCmd(cmd).name + " body:" + str(data))
        except socket.error:
            raise

四、定义测试分包和粘包的客户端套接字类

为了测试我们的服务端是否可以处理分包和粘包的问题,我们特意写了一份测试代码,该代码主要参考了博客

if __name__ == '__main__':
    client = socket.socket()
    client.connect(ADDR)

    # 正常数据包定义
    ver = 1
    body = json.dumps(dict(hello="world"))
    print(body)
    cmd = 1
    header = [body.__len__(),cmd,ver]
    headPack = struct.pack("!3I", *header)
    sendData1 = headPack+body.encode()

    # 分包数据定义
    ver = 2
    body = json.dumps(dict(hello="world2"))
    print(body)
    cmd = 1
    header = [body.__len__(),cmd,ver]
    headPack = struct.pack("!3I", *header)
    sendData2_1 = headPack+body[:2].encode()
    sendData2_2 = body[2:].encode()

    # 粘包数据定义
    ver = 3
    body1 = json.dumps(dict(hello="world3"))
    print(body1)
    cmd = 1
    header = [body.__len__(),cmd,ver]
    headPack1 = struct.pack("!3I", *header)

    ver = 4
    body2 = json.dumps(dict(hello="world4"))
    print(body2)
    cmd = 1
    header = [body.__len__(),cmd,ver]
    headPack2 = struct.pack("!3I", *header)

    sendData3 = headPack1+body1.encode()+headPack2+body2.encode()

    # 正常数据包
    client.send(sendData1)
    time.sleep(3)

    # 分包测试
    client.send(sendData2_1)
    time.sleep(0.2)
    client.send(sendData2_2)
    time.sleep(3)

    # 粘包测试
    client.send(sendData3)
    time.sleep(3)
    client.close()

五、完整的开源项目信息

为了便于同学学习,本文所讲的演示项目将进行开源,首先看下这个小项目的4份代码的作用:

代码模块主要功能
MsgProtol.py网络通信的消息协议,提供消息打包和解包功能
ServerSocket.py提供基于Python程序的服务端通信功能
ClientSocket.py提供基于Python程序的客户端通信功能
testPack.py本模块用来设计测试服务端处理分包和粘包的能力


完整的项目开源信息如下:

MNetSocket-Python
开发者MRL Liu
编程语言Python3
项目描述基于TCP的套接字通信包,可以自定义通信协议,处理分包和粘包
博客https://blog.csdn.net/qq_41959920/article/details/115380403
GitHub

 https://github.com/MagicDeveloperDRL/MNetSocket-Python

参考博客

 https://blog.csdn.net/yannanxiu/article/details/52096465


备注

开源不易,如果您觉得该文章对您帮助,欢迎点赞收藏,Github也欢迎各位前来淘金。

  • 11
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

魔法攻城狮MRL

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值