络编程中应用层(基于TCP/UDP)的协议设计
对于初涉网络编程的开发人员来说在通信协议的设计上一般会有所困惑。一般的网络编程书籍上也较少涉及这方面的内容。估计是觉得太简单了。这块确实是不难但如果不了解又很容易出篓子或者绕弯路。下面我就来谈谈基于TCP/UDP的协议设计。1、基于TCP的协议设计TCP是基于流的协议。但大部分网络应用一般会有个更小的处理单元我们称之为帧FRAME。是否分帧如上所述大部分网络应用是需要分帧的。举IM为例用户登录是一个帧用户发送文本信息是一个帧。少部分应用可以不需要分帧比如echo服务器接收到什么直接回复即可转发服务器同样是接收到数据直接转给目标机器更常见的情况是一个TCP连接只发送/处理一个请求之后就直接关闭这种也就没必要分帧了。考虑到除了学习网络编程没人做echo server。所以只要服务端不是一次连接只处理一个请求或者纯转发就应该采用分帧的设计。如何分帧注意帧是业务处理的单元是具体应用Care的但这不关TCP的事情初学者往往认为tcp这端 write一次tcp那端就会read一次然后惊呼“粘包”、“丢包”其实这都是程序处理不当。在这边推荐一本书籍《TCP/IP协议详解 卷1》挺薄的看完可以减少很多对TCP的错误认识。实际上发送方发送一帧接收方可能要N次才能读取完成而且可能同时读到下帧的数据。那要怎么在接收方把一帧数据不多不少的读取出来呢常用做法有两个基于长度和基于终结符Delimiter。基于长度就是在帧前先发送帧的长度一般用固定长度的字节来发送此长度比如2个字节(最大帧长不能大于655354个字节。ps我也见过使用可变长度的字节来发送此长度比如netty中的ProtobufVarint32FrameDecoder看代码那是相当的蛋疼我觉得完全是折腾自己强烈不推荐。使用基于长度的分帧方式接受方处理流程一般是这样“读取固定长度的字节 - 解析出帧长 - 读取帧长字节 - 处理帧”。基于终结符Delimiter最典型的应用就是HTTP协议了使用/r/n/r/n作为终结符。使用基于终结符的分帧方式接收方的处理流程一般是这样“读数据 - 在读取的数据中定位终结符 - 没找到将数据缓存 - 继续读数据 - 定位终结符 - 找到终结符将终结符之前的数据作为一帧进行处理”。使用终结符的方式务必要考虑转义问题不然在帧的数据中出现终结符乐子就大了。注意不管采用哪种方式在开发的时候都需要考虑最大帧长的问题。不然如果对方说要发送4G长度的帧恶意or程序错误真的去new 4G字节的缓存或者对方一直发送数据没有终结符。都可能造成程序内存耗尽。一般来说基于长度的分帧方式。开发更简单程序执行效率也更高使用更广泛些。基于终结符也不是一无是处可读性更好容易模拟和测试(如用telnet。下面重点讨论基于长度的分帧方式。基于长度的的帧设计length based frame design一般来说我们会将帧分为帧头frame header一般是固定长度和帧体frame body一般是可变长度也有固定长度的。如上所述最简单的帧头只要一个字段——帧长。但在实际应用中一个典型的帧头可能还有以下字段a消息类型message type在一个网络应用中往往有多种类型的帧。比如对于IM有登陆/登出/发送消息/……。接收方需要根据帧头的消息类型字段解码出不同种类的消息交给相应处理模块进行处理。也就是帧的结构是Length-Type-MessageLength-Type可以视为帧头Message是帧体。消息类型一般也是使用固定长度比如Length 4个字节Type 4个字节那么帧头的长度就是8个字节。接收方处理流程“读帧头长度字节数据 - 解码帧头获得长度和消息类型 - 读帧体长度字节数据 - 根据消息类型解码消息 - 处理消息”。Length-Type-Message结构的帧设计是使用最广泛的普适性最好也最精简的设计。b请求序列号serials这个不是必选项但我觉得对于非echo式的服务echo式的服务总是客户端发送请求-服务端针对该请求应答应答保证严格按照请求顺序加上这个字段肯定不后悔。这样对于乱序如果有消息队列后台线程池很正常的执行结果才能够和请求对上号从而做出正确的处理。一般来说高性能的服务端要保证响应的严格有序是比较麻烦和影响性能的。c版本号version很多人这么用但我觉得大部分情况下这不是个好主意。帧头应该放大部分/全部帧都需要的字段。而版本号可能只有少数包如登录会用到所以放到登录包体里可能更合适。单独维护每个协议的版本工作量会比较大开发起来会比较繁琐易错。至于担心解码失败更好的方式是采用类似Protobuf这种可以向下兼容的编解码方案。注意在帧头设计时应该要尽可能的精简和通用因为帧头长度是每个帧都需要的额外开销。如果某个字段如序列号只有少数帧会使用到完全可以放在帧体里去。反之如果某个字段大部分包都有却不定义在包头会导致难以统一处理增加开发工作量。这些需要根据具体业务需求来进行权衡没有统一的答案。举个例子Length-Type-Message结构适用于大部分情况但如果业务要求每个帧都需要表明操作者在帧头增加UID字段变成Length-Type-UID-Message程序的开发会更简单。帧体的设计帧体就是字段的集合举个例子登录帧体包含用户名、密码这两个字段只是举例现实的登录包往往复杂得多。在帧体设计上大家往往也是八仙过海各显神通。比如基于XML、json基于字段Pos举登录包为例就先写/读用户名再写/读密码。这种方式不是太好很难向下兼容比如登录包需要在用户名和密码间加一个用户状态如果服务端/客户端没有同步升级就会斯巴达。我甚至见过狂野得离谱的直接使用C struct的这种脑残到爆兼容性渣不说类对齐可以用pragma pack避免不一致、byte order、机器字长都会造成麻烦。比较推荐的做法骚年用Google Protobuf吧如果要可读性好json相比XML更省带宽。2、基于UDP的协议设计一般来说UDP的服务器要比TCP简单得多不过如果要实现基于UDP的可靠消息传输就当我没说。而且udp本来就是基于数据包的协议。write/read是可以一一对应的不考虑丢包所以不需要有长度字段/终结符。但是要注意为了避免丢包率过高udp包的长度一般不应该大于1500字节大概为了安全起见我一般保证小于1K嘿如果数据量较大就需要分包了这是比TCP麻烦的地方。典型的UDP的协议设计就是Type-Message。Type长度固定用于说明消息类型Message是消息体和tcp的帧体设计同样即可。