Post

Socket粘包半包

前言

由于最近做的是IM相关项目,经常跟socket打交道,近期一个同事把socket模块重构后经常有用户反馈丢消息,查询了很久也不知道是什么原因,后续经过大量日志及服务端协助定位,发现服务端已经给App推送了消息,但是App一直没有给服务端收到回执,导致消息一直累积丢失。后续经过大量日志定位,发现是iOS端在重构socket模块时,把拆包的逻辑做出问题了。 之前跟安卓和服务端沟通,由于安卓和服务端都是Java代码且使用了Netty框架,加上很多人对socket都很陌生,导致一般人都不知道粘包和半包的问题,其中一个负责架构和优化的一个开发人员对这个了解相对较多,但是也仅仅是了解个大概,具体实现也不清楚,以为是框架自动就实现了。 基于以上情况记录了这篇文章

参考:

粘包和半包问题 image image image image

为什么会有粘包和半包问题? 这是因为 TCP 是面向连接的传输协议,TCP 传输的数据是以流的形式,而流数据是没有明确的开始结尾边界,所以 TCP 也没办法判断哪一段流属于一个消息。

粘包的主要原因: 发送方每次写入数据 < 套接字(Socket)缓冲区大小; 接收方读取套接字(Socket)缓冲区数据不够及时。 半包的主要原因: 发送方每次写入数据 > 套接字(Socket)缓冲区大小; 发送的数据大于协议的 MTU (Maximum Transmission Unit,最大传输单元),因此必须拆包。 小知识点:什么是缓冲区? 缓冲区又称为缓存,它是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。

缓冲区的优势以文件流的写入为例,如果我们不使用缓冲区,那么每次写操作 CPU 都会和低速存储设备也就是磁盘进行交互,那么整个写入文件的速度就会受制于低速的存储设备(磁盘)。但如果使用缓冲区的话,每次写操作会先将数据保存在高速缓冲区内存上,当缓冲区的数据到达某个阈值之后,再将文件一次性写入到磁盘上。因为内存的写入速度远远大于磁盘的写入速度,所以当有了缓冲区之后,文件的写入速度就被大大提升了。

经过一系列的调查,了解到socket在推送数据时,可能一次推送的刚好就是一段完整的发送的数据,也有可能包含多个消息的数据,当然也可能是半个等不足一个完整数据各种情况都存在,要解决各种无序的情况,需要对收到的数据进行拆包。一般通用的拆包方案主要有3种
1、包的长度固定长度,类似于Netty里的FixedLengthFrameDecoder
2、使用特殊字符作为分包条件,类似于Netty里的DelimiterBasedFrameDecoder
3、自定义规则,比如在包头部几个字节定义每个包的长度,类似于Netty里的LengthFieldBasedFrameDecoder

由于iOS端使用的是CocoaAsyncSocket框架,处理粘包需要自己写代码实现,这里记录下项目自定义格式的代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class MySocketClient: NSObject, GCDAsyncSocketDelegate {
    private var cacheReceiveData = Data(capacity: 1024*1024) // 最多缓存的数据
    func socket(_ sock: GCDAsyncSocket, didRead data: Data, withTag tag: Int) {
        // 将收到的消息缓存到缓存里
        self.cacheReceiveData.append(data)
        // 将缓存数据进行拆包
        // xx xxxx xxx xxxxxxxx 2个字节的头,4个字节的长度,3个字节的其他,特定长度的内容
        let packageList = MyLengthFieldBasedFrameDecoder.decodePackage(data: self.cacheReceiveData, lengthFieldOffset: 2, lengthFieldLength: 4, initialBytesToStrip: 3)
        // 计算全部解析完成的包的总长度
        let decodePackagesLength = packageList.reduce(0, { $0+$1.count })
        // 获取还未解析的长度
        if (self.cacheReceiveData.count - decodePackagesLength) <= 0 {
            // 全部解析完成
            self.cacheReceiveData.removeAll()
        } else {
            // 还没全部解析完成
            self.cacheReceiveData = self.cacheReceiveData.subdata(in: decodePackagesLength ..< self.cacheReceiveData.count)
        }
    }
}

class MyLengthFieldBasedFrameDecoder {
    /**
    * @param lengthFieldOffset   length字段偏移的地址
    * @param lengthFieldLength   length字段所占的字节长
    * @param initialBytesToStrip 解析时候跳过多少个长度
    */
    class func decodePackage(data: Data, lengthFieldOffset: Int, lengthFieldLength: Int, initialBytesToStrip: Int=0) -> [Data] {
        // 将原始的数据拆包成多个包
        var subDataList: [Data] = []
        // 拆包起始位置
        var subDataFrom: Int = 0
        var needSubData = true
        while needSubData {
            if let subData = MyLengthFieldBasedFrameDecoder.innerPackageFrame(data: data, from: subDataFrom, lengthFieldOffset: lengthFieldOffset, lengthFieldLength: lengthFieldLength, initialBytesToStrip: initialBytesToStrip) {
                subDataList.append(subData)
                subDataFrom = subData.count
            } else {
                needSubData = false
            }
        }
        return subDataList
    }
    class func innerPackageFrame(data: Data, from: Int, lengthFieldOffset: Int, lengthFieldLength: Int, initialBytesToStrip: Int) -> Data? {
        // 固定头开始位置+长度的长度+其他内容+内容长度
        let lengthRange = NSRange(location: from + lengthFieldOffset, length: lengthFieldLength)
        if data.count < lengthRange.upperBound {
            // 字节头部内容不足
            return nil
        }
        // 读取header里包的长度的
        let lengthData = data.subdata(in: lengthRange.lowerBound..<lengthRange.upperBound)
        let contentLength = MyLengthFieldBasedFrameDecoder.int32FromData(data: lengthData)
        // 总长度,约定的header的长度
        let headerLength = lengthFieldOffset + lengthFieldLength
        // 总长度=header的长度+内容的长度
        let totalLength = headerLength + initialBytesToStrip + contentLength
        let subRange = NSRange(location: from, length: totalLength)
        if data.count < subRange.upperBound {
            // 真实数据内容比约定的内容少
            return nil
        }
        let packageData = data.subdata(in: subRange.lowerBound..<subRange.upperBound)
        return packageData
    }
    
    class func int32FromData(data: Data) -> Int {
        var value: UInt32 = 0
        (data as NSData).getBytes(&value, length: data.count)
        let result = UInt32(bigEndian: value)
        return Int(result)
    }
}

发送socket消息时,各端都按约定的相同格式组装数据就可以

感想

关于socket,有很多细节需要处理,尤其在做socket编码解码,socket重连、socket加解密部分,当一个项目已经很稳定后,代码再看不惯都尽量不要为了代码好看而重构,不然业务都没搞清楚就开始没有收益的重构,问题会越来越多,做技术选型时也不能因为某个技术大家都说好或者新技术就引入使用,一切以项目稳定为重

This post is licensed under CC BY 4.0 by the author.