当我们的文件传输,没了“传输层”(特指TCP)。 怎么保证很大的文件(比如 4K 视频)能顺利的从飞机发送到手机?
这是我当时面临的问题,我们飞机有两种通信方式:
- 通过 wifi 连接, 走 TCP 或 UDP
- 通过大遥控器的地面端芯片,与飞机的天空端芯片建立无线连接, 手机通过 USB 线与大遥控器连接
(只有物理层和链路层)
按当时芯片厂商的说法:“我们有做地面端与天空端重传,他在大部分情况下是可靠的”
这就是我面临的问题, 不稳定的重传等于没重传,不完全保证的包序等于乱序
索引
若对端与端之间,逐个字节解析协议的实现不太了解,请按 4->3->1->2 阅读(若您是面试官顺序浏览即可)
最终协议方案
为了能 短时间内 稳定的 实现文件下载功能
最终做了如下实现, 具体发展过程见 问题分析思路
通道类型:
通道分为两个(芯片第三方规定的)
- 数传通道:双向小容量通道, 用于传递数传消息(下载请求,查询请求等)
- 文件通道:大容量单向通道,用于传递 流式 数据,可用于大文件传输
传输方式:
Client 与 Server 通信,不提供 ”文件“ 概念, 只提供 ”指定的数据包“
接收方收到的第一个请求和第二个请求之间,可以没有先后关系,可以不是同一个文件。按协议要求的数据返回即可。
下载一个文件 由 Client(下载端) 管理,通过多次请求下载,组装成完整文件
(这一部分在具体实现部分,结合代码说明)
- Client 负责获取文件信息
- Client 负责根据文件大小,分成合适的下载片段
- Client 负责建立文件索引,供内部判断每个片段是否已下载
- Client 负责建立请求策略,比如是否多个片段同时请求,比如是否建立动态区间(建议目前只做单线程下载)
- Client 负责在丢包后,根据文件索引重新请求
Server 端只负责收到指令后,返回指令指定的数据, 无需处理丢包/重传
例:Client 需要请求下载一个大小为 100 的文件
Client 通过数传通道向 Server 发送:查询文件信息
Server 通过数传通道给 Client 响应:返回文件信息
Client 通过数传通道向 Server 发送: seqNo = 1,range = 0~100, name = “XX”, 一些其他参数
Server 通过文件通道向 Client 发送: 该文件 0~100 的数据
Client 保存该文件 0~100 的数据
…
Client 通过数传通道向 Server 发送: seqNo = 2, range = 101~200, name = “XX” 一些其他参数
Server 通过文件通道向 Client 发送:101~200 的文件数据。
Client 保存该文件 101~20- 的数据
ps. 为什么这么处理: 早期仿 TCP 的方式,需要维护状态,需要两端缓存丢包,需要都做重传处理。让事情复杂化了
目前这种 Server 无状态的方式,在现阶段能较快实现,并且逻辑简单保证稳定性
协议:
(省略数传获取文件信息部分, 仅以文件传输做示例)
通信协议:

传输协议:

流程
-
数传通道: client ⇀ server:
Command(seqNo, fileID, params)MediaServerProto.CommandQueryMedias
- 数传通道:server ⇀ client:
Ack(seqNo, error, info)MediaServerProto.CommandQueryMediasMediaServerProto.CommandQueryMedias
- 数传通道: client ⇀ server:
CommandCommandDownloadMedia(seqNo, name, range, is_thumb)CommandDownloadLog
- 文件通道: server ⇀ client:stream【数据包头 +
DataStream(seqNo, range)】MediaFileDataOTAFileDataLogFileData
- 数传通道: client ⇀ server:
CommandCommandDownloadMedia(seqNo, name, range, is_thumb)CommandDownloadLog
- 文件通道:server ⇀ client:stream【数据包头 +
DataStream(seqNo, range)】MediaFileDataOTAFileDataLogFileData
USB 通信的完整模块, 数传 + 图传 + 文件传输怎么实现
通信基础是: APP 通过 USB 连接遥控器, 遥控器与飞机无线连接
(iOS 提供的 usb 通信类叫 EASession)
数据协议类型:(每个代表一个独立大模块)
- 飞机数传数据: 飞机通过遥控器透传到 APP
- 飞机图传数据: 飞机通过遥控器透传到 APP
- 遥控器数传数据: 遥控器直接发给 APP
- 飞机文件数据: 飞机通过遥控器透传到 APP
数据通道:(可类比为开了 3 个 socket 口)
- 数传通道: 飞机数传数据, 遥控器数传数据
- 图传通道: 飞机图传数据
- 文件通道: 飞机文件数据
数据类型:(所涉及的协议大类, 内部还有细分, 比如飞机数传有 40 enum,每个 enum 代表一个对象)
- 飞机数传指令: 数传通道
- 遥控器数传指令: 数传通道
- 遥控器射频数据: 数传通道
- 飞机 MediaServer 数据: 数传通道
- 视频帧数据: 图传通道
- 文件流数据:文件通道
- Debug 日志数据: 文件通道
数据结构

EASessionController(commandSession,videoSession,fileSession): 真正的通信类, 收发数据由他完成- 可以理解为网络中的 socket
- 根据上面的通道数量, 开了 3 个 EASession 对象
- 收到的数据,不管是流数据,一包数据,一个字节,都会调用 delegate(
CommandParser). 由 parser 解析数据
CommandParser,VideoParser,FileParser: socket 接口收到数据后的接收类, 用于将字节码拼成协议- 不知道类比为啥, 就是个字节码的解析器
- 每个通道对应一个 Parser,公有 3 个
- 内部用状态机实现, 来一个 Byte 改一个状态, 直到拼凑出完整的一包数据, 对外输出
- 完整的一包数据会封装成下面的
Packet
Packet: 对应协议定义的类, 有效数据的最小单元- 可以理解为网络中收到的一帧数据
- 一个 Packet 类代表一种协议,内部字段直接对应协议的字段(类比 【ip 协议 + payload】)
- 对照上面通道数据量, 实现了 3 个 packet
- 提供对协议内部 payload 的
- (id *)parseToXXX, 解析函数(具体解析是根据数据类型,代理给具体类的类方法) (类比, 【ip 协议 + payload(TCP)】, 【ip 协议 + payload(UDP)】)
XXXObject:业务层使用的对象, 从Packet中的 payload 部分解析出来- 类比为 http 层收到一个一段 json
- 内部带了
-(instancetype)initWithData:(NSData*)data, 用于将 payload 的数据解析为自己的对象 - 直接对应是上面的数据类型。 但只是最外层类型,里面还有很多子字段,整套协议下来有几百个数据结构
AccessoryManager: 管理器,- 管理 usb 插入拔出连接状态
- 管理所有
EASession的声明周期 - 管理 数据读写的 队列
- 提供外部数据监听接口, 在
EASession调用CommandParser解析出数据后对外分发 - 是外部对通信模块的所有出口
FileServerBridge: 给 RN 提供的,文件下载桥接类- 内部提供文件下载的全局变量控制(速率等)
- 提供创建下载任务功能
DownloadTask
DownloadTask: 下载任务管理类, 接收一个文件名,管理整个下载流程- 负责根据情况管理,开始,结束,取消下载任务。
- 负责下载 loop, 处理超时,创建下载指令,判断完成与否
- 负责数据接收, 校验数据,将数据放入缓存
- 负责文件写入, 从缓存中读取连续数据,写入文件中
实现部分
CommandParser: 解析器, 实质是个状态机
参考代码
所谓状态机就是内部做了一个超级大的 switch-case, 实现很简单(但我做了这么多年数据解析,也是前段时间学习后才能做得这么巧妙)
每收到一个字节,判断是否是当前等待的值一致(协议包头都是有约定每个字节的值的,都相等就以为数据正确),一致就将状态改为等待下一个字节,不一致就证明数据有问题,重置状态机。

DownloadTask: 下载管理器属于应用层, 通过调用 Accessory 实现收发消息
参考代码: USBDownloadTask.h USBDownloadTask.m
整体流程分为三部分:发数据,收数据,写文件,三个部分逻辑上相互独立
发数据: 会决定生成重传/新文件范围的指令。 并将发送出去的指令装成RequestingInfoRequestingInfo内部有一个NSMutableSet *receivedRangeStart,用来缓存接收的数据,并计算是否收完@interface RequestingInfo() @property(nonatomic, strong)NSMutableSet *receivedRangeStart; @property(nonatomic, assign)NSUInteger rangeEnd; @property(nonatomic, assign)NSUInteger retryCount; @property(nonatomic, assign)NSUInteger dataLength; @end NSMutableDictionary<NSNumber *, RequestingInfo *> *requestingCommands收数据: 接收到的数据,先判断是否当前 task 的,然后找到对应的RequestingInfo,更新其中的Set一旦RequestingInfo收满了自己需要接收的数据,就会从requestingCommands中移除放入writeCache写文件:从writeCache中按起始位置最小优先顺序,获取要写入的数据。对比位置是否有效。如果有效就会执行写入 然后从writeCache中移除
三个行为逻辑上互相没有关系:
发数据执行在requestQueue, 是一个 loop,条件允许会一直执行收数据执行在数据来源的 Queue,来一次数据执行一次写数据执行在writeQueue, 也是一个 loop,会一直写文件直到writeCache被清空发数据和收数据都会用到requestingCommands,收数据和写数据都会用到writeCachetaskQueue专门用于处理requestingCommands和writeCache,对这里集合的修改都会在此 。另外还有些 Task 其他事物
发数据流程如下图
收数据及写数据流程如图:

以下类不太复杂,接口如数据结构图所示,就不展开了(标题是代码):
EASessionController主要是对系统 API 的调用, 包含创EASession,扔进Runloop,写,读,调用Parser解析Packet是个包容器,字段就等于协议的字段。 协议长什么样不了解的话,可以看前情提要AccessoryManager管理类, 外界实质的操作接口,提供读解析成对象的数据或者写一个对象的接口, 内部做处理。看上图就够了
分析过程
需求
首先,需求:不论通信稳定与否,带宽大小如何,都要保证在一个可能乱序,可能掉包,可能重复的通信环境中
数据不能错乱,要完整的收到 大文件 数据
面临的情况:
- 手机通过 usb 与遥控器直接通信(数据可靠), 遥控器与飞机通过无线通信(会受无线噪声干扰,距离影响,不保证数据可靠)
- 通信可以类比于 网络协议, 但只有 ”物理层“ 和 ”链路层“。 没有 TCP 协议保障传输可靠性(这是一对一的通信,不需要网络层)
要做的事情是
设计一个 APP 与飞机的 传输层协议 , 满足以下要求:
- 丢包 和 数据异常时能重传
- 对包乱序能做好处理
- 能处理传输过程中的无效包
- 能处理多文件同时下载的情况
TCP 怎么处理:
早期思路参考 TCP 协议,过程中逐渐发现面对的场景远没有 TCP 复杂。所以最后做成工期要求内,比较稳定 TCP 不完全一样的传输协议 我们先看 TCP 怎么解决问题的:
- 连接管理(三握四挥),目的 :一是 管理状态和资源,二是 防止因无效请求带来的错误/资源浪费
- 确认应答机制(ack 为请求序号 + 1),目的 :发现丢包
- 重传机制(超时重传,快速重传,SACK DSACK等),目的: 解决丢包
- 窗口机制,目的: 提供网络利用率
- 流控制(窗口数量)控制机制,目的 :适应网络拥堵
我怎么解决面临的问题:
站在巨人的肩膀上, 看我们的问题
(下文,Server 指的是我们的无人机, 其内有个 MediaServer 模块)
是否需要连接管理?(三握四挥)
A:我们是一对一连接,通信通路和资源就是等着对方用的, 所以无需用协议约定连接。 当然,无效数据的情况还是要处理的 (ps.连接状态更依赖业务控制,因为底层能连接,不代表飞机是活着的)
”确认应答“,”重传”,”窗口机制“,一块看:
什么情况下,发一包数据,必须得接收方回 seq+1
A: 一. 包有上下文关系; 二. 当前消息是单向的
比如:
- +1 是为了确认连续的数据,丢了中间哪部分,提供后文预测用,用以排查丢包与否。 如果一次只有一包,前后发送没有关系可以不专门采用这种方式
- 如果 发个指令,接收端必须回消息(比如”起飞指令“,飞机必须告知成功还是失败), 那也没必要再设计确认应答机制回复一次。
文件传输我能采用哪些方式?
一. APP 发一条/几条下载指令, 然后飞机把一个文件所有数据发过来。 文件发送方式,用多线程同时多次发送(既窗口机制);接收方式,用确认应答; 丢包处理,用快速重传或 SACK 传输的两端都要维护发送和接收状态,要缓存收到的包,和丢失哪些包的记录
最早差点用了这种实现方式,直到实现讨论时,发现窗口管理,确认应答,加重传,双方都要有很多逻辑处理,不是一时半会能 稳定&快速 搞定的
其中复杂的大头主要在, 消息应答和利用消息应答缓存接收到的数据,实现对应包重传和接受的状态管理。
我们一定要以文件为单位,并为传输过程做状态管理吗,需要以文件角度做重传和管理, 因为是数据有前后依赖关系。
如果我们将行为简化为收 一个 返回 一个 ack ,在 server 端,所有发送行为都是只发一包就结束, 那对 server 端而言,就只有第一个指令,第二个指令,第三个指令的区别, 指令之间没有关系 不需要管理状态。
Server 端只执行一个行为,收到一条指令,发一包数据回去。 不管收到与否,不管重传 那逻辑就非常简单了。 又好实现又稳定
二. Server 端不再有”文件“ 概念,而是收一条指令发一个回调。 (根据 APP 指定的文件名和文件范围)
APP 下载一个文件的行为,变成发送多条指令每条指令指定不同的文件范围。 单条指令自己管理超时和重传。并在接收文件连续时,将内容写入磁盘
(APP 还是有重传,但逻辑上不是一个文件,而是针对一条指令,有指令自己记录重传)
这是我们第一版采用的实现方式,而后发现一个弊端。 因为芯片的限制, APP 给数据到遥控器需要毫秒级的间隔, 飞机端发出的一包数据有 K 级大小限制。 这导致 APP 发指令必须限制在 几毫秒 一个指令的频率。 而一包数据又受限于发送端的大小。 导致无法利用满带宽。
为了解决这个问题,我们让飞机一次不再只返回一包, 而是由发送端指定大小/限制大小 = n 包。 但是在概念上,Server 还是无状态
Server 端只执行一个行为,收到一条指令,返回多包数据。 不管接收,不管重传
三. APP 向 Server 发送指定文件名和指定数据范围的请求, Server 端根据接口能力拆成多包返回。
Server 端不管接收与否,没有重传概念。 APP 指令自己处理重传, 再由文件写入模块,从缓存中读出数据写入文件。
这样,再配合 APP 多线程控制发送和指定文件范围,就能实现用满流量的文件传输
”滑动窗口机制“
因为上面已经将通信流量交由 APP 控制, 同时 APP 能从另一个地方估算出当前的带宽。 所以滑动窗口机制并无必要,APP 通过估算得到的带宽,控制请求时的数据量。 (时刻注意这是一对一场景,飞机只为 APP 服务)
前提介绍
智能设备如果不走 http 协议, 那通常看到的数据协议是像下面这样的

比如蓝牙,比如 usb 通信,比如不走 http 网络通信。理解上可以“类比”为 C 中结构体的一段内存(其实应用层往下的协议都像上面这样)
struct Packet {
unit16_t header;
unit16_t messageID[2];
unit16_t numSum[2];
unit8_t index;
unit16_t length;
uint16_t checkSum[2];
struct Payload *data;
}
不同的是,通信过程这些字节可能一次全收到。可能一次收一半,可能一次收几包。由发送端和通信方式决定, 具体的这里不展开。
下面介绍 “usb 通信模块实现” 会提到收包解析的代码实现,这里要介绍的一点是:
定义协议:就是约定通信的两端,在通信时数据包中需要包含什么信息(字段),以利用这些字段达成某个目的
举个例子🌰:
上图除了 payload 外,其他的header,length,numsum,index,check sum 是很多数据协议里常见的部分。
header: 告诉接受者, 你已经遇到一条全新的数据包了length: 我们这个包有多长,你要把这一段数据都算在这包里,类比为 C 中的sizeof(packet)numsum: 我这 payload 部分很长, 可能分成了几包过来,这里告诉你一共几包
我们定义协议,就是在为一段数据包的每个部分赋予含义,供程序接收后做对应处理。
payload 前面部分: 称为包头,格式基本固定,通常由两端底层决定(类比网络层中的“链路层”):
复杂点带分包的长这样:
简单点的这样:

payload 是数据部分: * 通常,简单点,不考虑通信协议,我们只用 Payload 完成业务上的接口需求。 那对 Payload 的协议定义,完全可以等同于给 web 后台定接口,按业务需求定义(比如 第一个字节是 MessageType, 第二个字节开始是 Params) 这样的接口定义我们在工作中都遇到过很多,考虑业务需求即可, 这里就不细聊这个了 * 下文实现的,主要是解决: 不具有稳定重传, 不保证包序 通信问题。 就需要在上面协议的基础上,扩展 payload, 为 payload 加上一些字段,用来支撑我们实现功能
以下图为示范:

看网间层, 可以直接类比,IP 首部就是上面的 包头, 后面的 【UDP 首部+数据】 其实就是 payload 我们如果要实现自己的协议, 比如在 IP 层的基础上实现 UDP 协议, 就是将 payload 的一部分,拿出来定义协议, 就是上面的 UDP 首部。