iOS针对Socket粘包半包处理

前言

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

参考:

粘包和半包问题


socket_package_3.png
socket_package_4.png

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

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

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

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

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

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加解密部分,当一个项目已经很稳定后,代码再看不惯都尽量不要为了代码好看而重构,不然业务都没搞清楚就开始没有收益的重构,问题会越来越多,做技术选型时也不能因为某个技术大家都说好或者新技术就引入使用,一切以项目稳定为重

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容