网络通信协议设计与实现分析

协议是什么,简单来说:协议是两个实体之间正确交流信息的规则。而实体这个词语的含义非常广泛,它可以是我们传统意义上的网络通信节点,网络应用程序,还可以是两个进程,甚至还可以是两个线程。只要两个可以划分的独立体需要出现信息,那么它们之间都存在着协议。唯一的差别就是协议的传输方式和复杂度。只要有交流,就会有协议!

协议嵌套

正确性是最重要的前提条件,正确性主要指的是在至少在理论上满足需求,并没有明显的缺陷。其次是实现简单,这是最重要的一条,所有的协议要使用起来都必须经过实现这一个步骤,协议设计起来看起来很好,但是实现上用起是非常麻烦,那么这个设计的这协议显然是非常失败的,这也是最常见的一种典型性失败。设计一个能够简单实现的协议看起来并不是一个十分困难的事情,但是如果兼顾安全、效率和扩展性的话,那么设计出一个简单实现的协议就变得非常难了。所以协议设计的精髓可以归为一句话。

设计一种正确,实现简单但是安全、高效并且扩展性高的通信方式。

##前提条件 本文的主要目标是针对网络应用中的协议进行比较深入详细的分析,并且总结出一套可以使用的协议设计原理。既然是网络协议那么就有几个非常重要的前提条件。

本文所讨论的通信协议主要是基于可靠传输的网络协议,也就是说在一般的网络中主要以TCP为载体的可靠传输网络。而基于UDP的网络协议暂时不考虑,因为使用不可靠传输网络需要考虑的问题非常多,包丢失、包乱序、网络拥塞等等,这将会把一个原本比较简单的问题极大的复杂化。为了突出重点,所以默认情况下,本文下方的传输协议就运行在TCP以及类似的可靠传输网络上面,如果有例外会进行额外标注。

以TCP为载体,这个载体是协议设计里面的一个重要概念。TCP本身是一种协议,它主要解决了在不可靠传输网络中最优化传输可靠数据问题,而在TCP之下有IP层,在IP层之下有数据链路层。每一层都解决了某一个特定问题,每一层都是不同的协议,而这种协议是层级嵌套的。A协议被B协议封装、B协议也被C协议封装,最后C协议通过网络库传输到对端。这种特征类似于数据结构,所以我们常常称一组嵌套的协议为协议栈

协议嵌套

上图就是大家所常见的协议嵌套的一般模型,以常见的HTTP为例,HTTP协议包头可以看成是A,而HTTP包头里面携带的数据是B,而以下就是操作系统提供的网络协议栈了。

安全是一个大问题,这是协议在设计的时候考虑的重点。但是并不能够盲目的考虑,在讨论安全的时候我们需要建立在一定的基础之上才有实际的意义。我们这个文档所讨论的协议是应用层协议,所以会建立在操作系统所提供的栈之上。

这是操作系统栈的问题,显然并不是应用层协议能够解决的问题。所以我们讨论的安全主要集中在以下几个方面

  1. 如果接收到的数据是错误的格式,那么应该怎么处理?
  2. 协议格式是正确的,但是所请求的对象、资源或者方法并不存在应该怎样处理?
  3. 如果客户端访问太过频繁,明显超过协议设计时的预期应该怎么办?
  4. 如果是基于TCP协议栈,但是客户端与服务端建立了过多的连接,但是并不发送数据,将服务器资源占用完了,怎么办?

##从简单开始 所有的通信协议都是为了交互有用的信息,而有用的信息在软件中就是需求,所以接下来我们从需求开始从一个简单的协议一步一步的扩展到一系列复杂的需求中去。 ###一个简单的延迟测试服务

####需求描述

有一个客户端一个服务器,客户端能够有办法测试出数据从客户端发送到服务器,再从服务器返回到客户端这个过程中的网络服务器处理速度相对延迟情况,精确到毫秒级。

####解决方案分析 这是一个再简单不过的需求,我们很容易想到一种简单的解决方案:客户端使用一个32bits(4bytes)长度的数据保存本机自启动到当前的毫秒时间,然后发送给服务器,服务器再原封不动的返回这个数据。客户端接收到回复之后计算前后时间差,就可以得出网络和服务器处理的延迟情况。多请求几次就可以得到相对的延迟情况,这个动态的数据就变得十分有意义了。

对于服务端来说,这是一个典型的ECHO Server,服务端只需要把自己所收到的任务数据原封不动的返回就可以了,立即返回。而客户端每一次发送一个4bytes的数据,每一次接收4bytes的数据,功能非常清晰,实现也非常简单。

前面已经说过,我们本文所讨论的所有协议不做特别说明默认都运行在TCP之上。因为UDP由于其乱序和丢失的情况会将问题复杂化,不利于讨论问题的核心。不过对于上面这个简单的需求,我们还是完全可以考虑使用UDP的,甚至针对这个需求来说UDP比TCP更加合理方便。不管是用TCP还是UDP,我们所设计的简单协议已经满足了上面的需求了。不过还需要做一些实现和验证性分析。 从实现上来看,如果使用UDP协议,那么每一次都往服务器发送一个固定大小的包,等待返回并且计算就已经可以了。由于是无连接的,所以服务器能够处理非常多的请求,效率非常高。而UDP协议里面的包乱序和包丢失现象都不会影响到需求。只要收到服务器回复的任何数据都能够计算延时,甚至更加准确。

而对于使用TCP实现来说,我们可以每一次都与服务器建立一个连接请求,然后发送回复一个数据包,再关闭连接。这样的短连接测试出来的延时是多次交互之后的延迟时间,可能比较长,这样每一次都建立一个新的连接也比较浪费服务器的资源,所以可以考虑建立一个TCP的长连接,这样建立一次连接与服务器进行多次交互,效率更高。

如果不讨论这些问题,从理论上来说协议上是没有问题的,但是当一个实现者拿到这个协议的时候总会遇到这些问题,然后就会按照自己的情况进行不同的实现,这就造成了不同的实现有可能不兼容的现象。

FTP协议中关于当前目录列表的格式就没有进行明确的定义,这就造成了不同的FTP实现有不同的文件格式。当前世面上的FTP服务器,运行在Windows平台下的FTP服务器有自己目录格式,运行在Linux系统下的FTP服务器有自己的目录格式,有一些软件还有自己定义的目录格式。

当我们在实现FTP客户端的时候就会发现,里面最麻烦的问题就是解析目录格式,在RFC 959的FTP协议定义里面对文件的目录有这样的描述:

Since the information on a file may vary widely from system to system, this information may be hard to use automatically in a program, but may be quite useful to a human user.

如果客户端发送过多数据,或者过少数据。正常情况下协议定义的是四个字节为一组,如果服务器在实现时每一次需要读取到四个字节的数据之后再返回给客户端的话,在发送过少数据的情况下,服务器端势必会等待数据,对服务器端来说必然不是一件好事。同样数据过多,服务器每一次处理4个字节,也太过繁忙了。所以在实现的时候应该将服务器实现为标准的Echo Server,服务器端每一次应该不管数据长度,尽量读取数据,然后直接返回。当数据返回完之后,再处理后面的数据。这样就可以有效的避免客户端恶意发送数据的情况。

同时我们还建议,客户端应该有一个超时时间,如果发送请求之后,在一定时间内没有收到服务器的回复则超时,超时时间建议设置为4s(与ICMP的超时时间一样)。

这样安全吗?还不够安全?甚至不够正确!我们没有对客户端与服务端之间交互的频率做规定。虽然应该将服务器实现为标准的Echo Server,但是考虑到我们的需求,如果客户端太过平凡发送请求或者一次发送过多请求都不能够更好的测试出“网络和服务器处理速度的相对延迟”情况。所以我们建议客户端应该每一次只发送一个请求,收到上一次的请求或者超时之后,再发送下一个请求,不应该一次性发送两个请求。同时建议客户端在接收到回复至少1s之后,再发送下一个请求。

如果实现的时候使用的是TCP长连接,那么这个时候就必须要考虑NAT和防火墙对连接的影响,因为在默认的情况下NAT和防火墙会主动取消在“老化时间(Aging Time)”没有数据传输的TCP连接的映射关系,导致双方连接异常。所以如果是使用TCP长连接的形式实现,客户端必须在一个合理的时间内向服务器发送一次数据,建议时间为6分钟。如果服务器端检测客户端在一定时间内都没有数据,应该将客户端的连接断开。

看到上面的必须应该不应该建议这样的字眼没。这些都是在RFC文档里面经常出现的字眼。都是在实现上面的中肯提议。

这样实现正确么?理论上已经算是正确了,但是不得不再提一点。上面的需求是要求测试客户端与服务端的相对延迟时间,而TCP默认情况下的Nagle算法会让发送数据包的速度比预想中的要慢,所以延迟测试可能并不准确,这就需要长时间固定频率的测试,这种情况下的这种结果才比较有意义。

为什么我们要去考虑TCP和UDP的实现呢? 理论上在协议设计的时候需要尽量考虑到协议与传输分离,但是在实际的应用过程中,TCP和UDP已经成为互联网里面事实上的传输选择。如果简单的定义协议而不考虑实际的应用情况的话,就会违背上面所提出的协议设计原则实现简单,这样不接地气的协议定义出来就是空中楼阁,看起来很好,实际上并不怎么样。

协议设计的难点也就是在这里,正确、安全、效率这三个要素我们都有可能考虑到,但是实现方便却常常并不容易与这三个要素兼容。这才是协议设计里面的难点和体现经验和功力所在,后面将会对这个问题进行更加深入的讨论。

####小结 通过上面的分析,我们已经对这个协议的方方面面进行了讨论,现在我们已经有了一个完全可以使用,在正确性、易用性、效率和安全上面都有一定保障的协议。

###添加一类简单的请求

我们已经有了关于客户端与服务端之间频率测试的协议,它可以使用UDP和TCP短连接和长连接实现。现在我们希望在这个协议上面添加新的功能。

有一个客户端一个服务器,客户端能够有办法测试出数据从客户端发送到服务器,再从服务器返回到客户端这个过程中的网络服务器处理速度相对延迟情况,精确到毫秒级。

客户端能够直接向服务器发送不定长度命令,服务器端对每一条命令进行一次相应的回应,命令是有序的,无状态的。

####解决方案分析

这个需求相对来说定义的比较模糊,与上一条需求结合起来,我们可以说协议有两个重要的功能,一个是可以测试相对延迟,一个是可以传输其它命令。所以在协议里面需要有一个字段的概念,某一个字做段就应该表明这一条命令是一个测试延迟请求还是其它命令请求。

#####基于字符串协议的解决方案

根据现在的需求,我们可以简单的定义一个字符串协议:

协议里面是一个完整的字符串,字符串分为两部分,第一部分是命令类型,然后加一个“/”,第二部分是命令参数。由于这是一个字符串协议,所以里面的字符串都是可见字符串,有一些字符串保留,保留字符串以HTTP1.1 URL定义的保留字符以及转义规则为准,字符串以“\r\n”结束,参数的最大长度为2048 [1]。

对于延迟测试需求,命令类型分为两类:ECHO和其它类型,ECHO/之后是一个整型字符串数字,服务器直接将这个请求返回。而“其它类型”/之后添加的是用户自定义的命令参数。

字符串协议

在这里,有三点特别注意的部分

  1. 协议字段字符串请求分为两部分,命令类型和命令参数。
  2. 协议内容字符串里面都是可见字符,转义方式以HTTP 1.1协议的定义URL转义字符为准。
  3. 结束符号字符串的结束符号为”\r\n”
关于结束符

结束符一般是用来区分请求长度的。在基于UDP的传输中,理论上是不需要结束符的,而基于TCP这种流式传输需要一个结束符以区分一个完整的包。依然遵守上面的原则,协议应该尽量与传输层无关,所以在定义协议的时候最好定义一个结束符。

在基于字符串的协议中,结束符号一般以一个或者两个特殊的字符结束,在整个字符串里面,结束符都是唯一的,在字符串内容里面不能够出现这个结束符,所以上面的协议的定义里面我们特别定义转义字符的概念,以便能够更好的加以区分结束符。

而基于二进制的协议中,一般在一个命令的前面加上一个数据包头,在包头里面加入一个数据长度,用于区分接下来的数据有多长,这样就可以保证得到一个完整的数据包。

当然也可以使用一个特殊的字符在一段二进制尾部用于区分一条消息,不过这种方式需要保证前面所出现的二进制数据不会出现相似的结束符,这就需要前面的相似的二进制进行转义。这显然是一个比较愚蠢的行为,所以一般不这样干。

除了上面字符串里面的尾结束符方式和二进制中的头长度方式之外,还有一种是将两者结合起来的方式。这种协议即可以传输二进制数据,也可以传输文本数据,可扩展性比较高。例如HTTP和SIP协议里面,它们的协议头都是以“\r\n”为结束符的几行字段,,整个包头以两个”\r\n”结束,而头TCP实现里面,都会有一个“Content-Length”字段,后面所跟的数值表示的是头之下的body里面的数据长度。所以在后面的已知数据长度的body里面就可以附加任何类型的数据,而不用担心冲突。

#####基于二进制协议的解决方案

上面既然提到基于二进制的数据包头,那么我们也定义一种二进制协议的解决方案:

基于二进制的解决方案,数据可以分为两部分,一部分是包头,一部分是命令参数。包头分为两部分,第一部分十六位,表示后面的数据长度,另一个部分也是十六位表示命令类型。再后面就是第一部分所示字节长度的命令参数。

对于延迟测试需求,数据包头第一部分数值为4表示包头后面会跟四个字节的数据,数据包头的第二部分数据为0,表示这是一个延迟测试服务请求,为1,则表示是请求命令,参数的最大长度也为2048 [1]。

二进制协议

#####四大要求分析

正确性

虽然我们并没有定义里面的具体请求是什么,但是现在这两种协议,考虑其结束符、长度、以及类型。在理论上至少是正确的。从满足原本的延迟测试里面也可以看作是一种请求。

实现简单

从实现上来看,字符串协议的难点在于字符转义,这显然只是一个小问题。而二进制的实现需要一个数据包头,在数据包头里面,我们定义的时候让包头长度为32位,也是为了实现的时候简单。

在二进制实现里面使用TCP读取数据包的时候,可以先读取前面32位,然后再根据读出来的数据包长度读取后面的数据,读完之后再给上层应用。理论上我们就以这种模式一直读下去,是没有任何问题的,因为TCP提供的可靠的传输能够保证数据的正确性和顺序性,所以读取了一个数据包之后,下一个数据包也会是一个正常的数据包。

但是在真正的使用的时候,网络上的调试是非常重要的。有时候我们使用抓包工具会抓到数据包的片段,从里面的数据分析某一种错误是出现在服务端还是客户端。所以这个时候能够区分一个完整的数据包是非常重要的。

但是基于我们上面的二进制协议设计,显然对于一个网络数据包片段是非常难以区分哪里是一个数据请求包的开始,哪里是一个数据包的结束的?这个时候一般在这个二进制数据的最前端会加入几个固定字符,以标明这是一个数据包的开始。

所以我们再一次对二进制协议进行定义,需要在包头前面再加32位的空间,里面存放四个固定字符,假如是:”\(\)“。

实现上的时候我们怎么实现呢,当只有一个延迟测试需求的时候,我们可以选择UDP、TCP短连接和TCP长连接。而在增加命令请求的需求之后,我们的选择面就变小了。这是一个正常的过程,需求越复杂,那么实现的方法也将会慢慢减少。

UDP协议可以使用么?如果使用UDP协议的话,我们就可以直接避免进行包的区分,UDP自动可以区分这些包。但同时会产生丢包的情况,需求一的时候已经讨论过,如果只是延迟测试的话,UDP丢包并不会造成太大的影响,但是在需求二里,一般性的请求如果丢包的话,就非常容易造成业务上面的混乱,除非常在协议的定义的时候严格考虑到这样的情况,否则实现很麻烦。综上所述,建议在需求二的时候,实现中不采用UDP。

TCP的短连接实现方案。每一次请求的时候都建立一个连接,然后发送请求,接收请求,断开连接。

TCP长连接的方案,要考虑到延迟测试的频率,不能够太高,也不能够太低。同时应该遵守前面所提出的实现上的建议。

二进制协议

安全性

如何保证协议的安全性?可以看到,我们在里面特别额外定义了数据参数的长度为2048个字节。这个就是对长度的重要定义,当然这个长度是针对可能的应用而言的,在更加具体的应用中,可能会定义其它长度,但是不管怎么说,长度是一个必要的定义。这对服务端的安全来说非常有效,可以从协议上避免超长数据包的攻击。

2048这个长度的定义是参考Windows IE的HTTP URL的最大长度[1](原始IE长度是2083,这里不刻意与这个值相似),HTTP里面并没有明确的定义一个URL的最大长度。导致了不同的浏览器对这个长度有不同的定义[2]/[3]。

对于字符串的协议来说,其实还有一个不安全的地方。前面有一个字符串用以表示命令类型,但是这个字符串长度有多少呢?我们没有定义长度,这个长度就有可能成为不安全的因素。所以我们还必须限制字符命令类型的长度为16个字符。而对于二进制协议,前面的包头的长度都是固定的,所以没有这种问题发生。

还有一个超时问题,如果某一个恶意用户慢慢的发送数据包请求,那么有可能将整个服务端拖垮,所以这个时候,就需要定义一个超时时间,当服务端接收到一个命令的第一个字节到最后一次字节的时间不能够超过40秒[4]。如果超过这个时间,服务端应该主动断开这个连接。

在使用TCP短连接的时候,当服务端回复了一个请求之后,应该由服务端主动将这个客户端连接断开。

在考虑协议安全的时候,我们一般考虑可控端的安全。在这里,一般的应用开发者的可控端大部分都是服务端。所以协议的安全也重点考虑的是服务端的安全问题。

效率问题

这个协议效率高么,从真正的实现上来看,二进制协议的效率比字符串的效率要高一些。而针对我们当前的需求来说,这个协议应该是比较精简高效的协议了。

扩展性

扩展性,其实和上面的实现简单高度相关的,扩展性是建立在实现简单的基础之上。要达到扩展容易,而实现上更容易这个目标往往是比较难的。

这个协议的扩展性高么?可以从需求和协议的定义中看出来,这个协议实际上只定义了一种延迟测试的具体功能,而剩下的请求功能并没有详细的定义。这是一个协议,但是更准确的说,这可能只能够算是一种协议框架。因为这个协议可以衍生出非常多的功能。

现在当这个协议的阶段只能够说定义了一个协议的头,而里面的具体的内容需要额外去定义。那么如何去定义扩展呢,在一般的RFC定义文档中,针对 某一些协议一般都有一系列的补充描述扩展协议。这两个还是有一些差别的。不管是对命令类型还是对命令参数的新定义都可以归属于扩展协议里面。只要与原始的协议兼容,同时不超出原本协议的定义,扩展都应该是有效的。

协议一般分为两类,一类是类似于HTTP\SIP\XMPP这一类的协议,在定义的时候基本上只对消息的头进行了详细的定义,而里面都有一个body字段,用于用户的扩展。 而类似于FTP等这一类协议则是一种确定性的协议,每一条命令都定义得非常清楚,应用的情况也定义得比较清楚。而在真正应用过程中,成型的产品里面的协议必须已经是经过了明确的定义。

扩展性协议里面比较出名的应该算是XMPP(Extensible Messaging and Presence Protocol)协议,除了核心协议RFC 6120\6121(旧版本RFC 3920\3921)之外,旗下有非常多的XEP扩展形成了一系列的协议族,对用户的扩展支持也非常方便。XMPP协议在笔者看来,应该算是当前世界上功能最全的协议族了,基本上所有的互联网信息交互需求,在这个协议里面都能够找到相应的定义和扩展,即使当前没有定义,而扩展起来也非常简单。

####小总

至此为止,对这一小节所提出的需求,已经基本上满足了。我们提出了两种解决方案,一种是基于文本字符串的,一种是基于二进制形式的。

综合上面的正确性、效率、安全和扩展性分析,我们将整个协议重新定义一次:

对于字符串协议:

协议由一个完整的字符串组成,字符串分为两部分,第一部分是命令类型,然后加一个“/”,第二部分是命令参数。由于这是一个字符串协议,所以里面的字符串都是可见字符串,有一些字符串保留,保留字符串以HTTP1.1 URL定义的保留字符以及转义规则为准,字符串以“\r\n”结束,参数的最大长度为2048 [1],命令类型的最大长度是32个字节。

对于延迟测试需求,命令类型分为两类:ECHO和其它类型,ECHO/之后是一个整型字符串数字,服务器直接将这个请求返回。而“其它类型”/之后添加的是用户自定义的命令参数。

对于二进制协议:

协议由二进制组成,数据可以分为两部分,一部分是包头,一部分是命令参数。

包头分为三个部分,第一部分三十二位二进制,使用固定的四个字符”\(\)“,用于更好的在各种情况下区分数据包。第二部分十六位,表示后面的数据长度,第三个部分也是十六位表示命令类型。再后面就是第一部分所示字节长度的命令参数。

对于延迟测试需求,数据包头第二部分数值为4表示包头后面会跟四个字节的数据,数据包头的第二部分数据为0,表示这是一个延迟测试服务请求,其它则表示是请求命令,参数的最大长度也为2048 [1]。

除此之外,他们还有共同的额外定义:

不建议使用UDP协议,使用TCP连接的时候,一条完整的命令从接收到第一个字节都接收到一个完整的数据包不应该超过40秒。

在TCP短连接中,服务端接收到一个请求回复之后,应该主动断开与客户端的连接。发生超时或者数据格式错误的情况,服务端也应该主动断开与客户端之间的连接。


针对TCP协议来说,

##应用

当前所了解的协议

##需求变更模型


##引用