1. 写作缘起

几年前,我在一家农业物联网公司,负责解决其物联网产品线。我们当时基于.net平台打造了一套实时数据采集系统,可以把数以百万级的传感器传送回来的数据采集入库并根据这些数据进行建模。在搭建这套实时数据采集系统的时候,高并发高可用被首次提出,同时要求系统不会有太大的时延。一旦有时延,也就意味着损失。比如一个有3000头猪的猪舍,假设空气温度达到了比较高的水平,但是采集探头采集的数据上传到服务器管道中,由于被积压了5分钟后才被处理,那么主动预警系统打开风机的时候,也许已经晚了,这五分钟的时间里,上百头小猪仔因为温度过高的缘故死于非命。当然,鱼塘,蔬菜大棚等也有类似的场景。

当时在打造此系统的时候,我们用的还是.net,翻阅了很多源码,查阅了很多资料,最后我们基于SocketAsyncEventArgs来打造一个自己的物联网服务端。当时在.net里面,还没有一款能够匹敌netty的开源组件出来,这就导致我们不仅要处理心跳,而且还要处理粘包,甚至缓冲区都需要自己来处理,一旦消息没被及时拿出来,那么后到的数据会将之前的数据一股脑儿的覆盖。从底层来实现这些功能的好处是让我们对服务端的编写有了非常清楚的认知,但是也由于思虑不全带来非常多的坑。可以说那几年是踩着TCP的坑走过来的。最后我们基于SocketAsyncEventArgs封装了我们自己的物联网通讯框架:TinySocket。在那个时候,彼时的联想佳沃蓝莓基地依旧用数据库轮询的方式来支持物联网设备,和他们对接的时候,发现经常会因为遇到网络层面的问题而愁眉不展,而彼时的我们却因为我们可以在任何设备上自动/手动控制我们的设备而高兴不已。因为她的可靠度极高。

后来,离开了那里,但是怀着要打造一个能支撑巨流量的物联网高并发和高可用架构的梦想,而选择了互联网公司来进行深造。也是在这个时候,我从.net平台转到了java平台,也正是在这个时候,我有缘认识了netty,一个仿佛是为了解决我当年的各种问题而生的框架,虽和她只有一面之缘,但是那一刻,我决定将她纳入麾下,情定终生也许用在此刻再合适不过了。因为她有成熟的架构,普适的解决方式,优雅的接入方式,良好的社区支持,成熟的商业产品。这些特性,让我们无法拒绝使用。

由于对netty的执迷,导致我说起了过往,止不住的文字流淌,接下来我们就转入正题吧。

在数据传输过程中,由于网络的不确定性,每个数据包都有可能遭遇形式各样的问题,诸如掉线,网络变差等,所以到达的时候,这些数据包有可能乱序,也有可能丢失。所以为了应对这些异常状况,TCP协议在其内部通过序列号来保证数据包乱序的问题,同时通过确认号来保证数据包丢失的问题。所以基于TCP协议实现的上层应用,都认为TCP传输是可靠的。但是通过一些网络抓包工具,可以窥见其具体实现数据包有序和防丢失的过程,感兴趣的可以自己去试试。

那么上面提到序列号和确认号,究竟是什么呢?我们来看一下:

  •   Sequence Number: 顺序号,意即数据包的序号,主要用来解决数据包乱序问题。

  •   Acknowledgement Number:确认号,意即数据包用来进行双端消息确认的号码,主要用来解决网络传输过程中,数据丢包的问题。

在TCP进行数据传输的过程中,主机A传输数据给主机B,假设第一次A传输512字节的数据给B,那么seq=1;当B收到这512字节的时候,会将seq进行累加来避免乱序,在这里,B会将seq重新设置为512+1,然后回传给A,A收到B传回来的seq=513的时候,就知道第一个数据包已经传给了B。如果A收到B的回复,发现B没有收到数据包的话,那么将会进行重发操作,这样来防止丢包。

下面来说下TCP的标志位,一共有6种:

  •      SYN(synchronous建立联机)

  •       ACK(acknowledgement 确认)

  •       PSH(push传送)

  •       FIN(finish结束)

  •       RST(reset重置)

  •       URG(urgent紧急)

第一次握手:建立连接时,客户端发送syn包(syn=j)到服务器,并进入SYN_SEND状态,等待服务器确认;     

第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态;   

第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手。

完成三次握手,客户端与服务器开始传送数据.

更多详细的信息,推荐阅读斯坦福大学的Transmission Control Protocol (TCP)的这篇短小精悍的文章。

大略讲解了下TCP的基础,我们接下来开始我们的netty之旅吧。由于JDK内置的NIO操作类库并非我们的讲解要点,所以这里我不会过多的进行讲解,直接从netty讲起吧。

2. 网络通讯基础,包含(粘包拆包,编解码,鉴权认证,心跳检测,断线重连)

在设计网络通讯框架的时候,有些设计点是必须被考虑进去的,这些设计点可以说是不可或缺的。接下来我们就一一梳理并进行讲解。

 

>>粘包拆包

粘包拆包,顾名思义,粘包,就是指数据包黏在一块了;拆包,则是指完整的数据包被拆开了。由于TCP通讯过程中,会将数据包进行合并后再发出去,所以会有这两种情况发生,但是UDP通讯则不会。下面我们以两个数据包A,B来讲解具体的粘包拆包过程:

bb0a0099-2e12-4191-be8a-1d4c031552be

第一种情况,A数据包和B数据包被分别接收且都是整包状态,无粘包拆包情况发生,此种情况最佳。

f9b580f9-bd49-4dd0-b9e7-742e63f6276f

第二种情况,A数据包和B数据包在一块儿且一起被接收,此种情况,即发生了粘包现象,需要进行数据包拆分处理。

a1f0cd86-ac4d-45a7-b79f-20c8dba93fed

第三种情况,A数据包和B数据包的一部分先被接收,然后收到B数据包的剩余部分,此种情况,即发生了拆包现象,即B数据包被拆分。

a1cfac00-7f70-4183-a57e-becbb95ac9e1

第四种情况,A数据包的一部分先被接收,然后收到A数据包的剩余部分和B数据包的完整部分,此种情况,即发生了拆包现象,即A数据包被拆分。

fd5757c1-63dd-4c8b-a28d-24c9c4b88fe9

第五种情况,也是最复杂的一种,先收到A数据包的部分,然后收到A数据包剩余部分和B数据包的一部分,最后收到B数据包的剩余部分,此种情况也发生了拆包现象。

上面五种粘包拆包现象的发生,其实归根到底,原因有三:

  (1) 应用程序write写入的字节大小大于套接口发送缓冲区大小。

  (2) 进行MSS大小的TCP分段。

  (3) 以太网帧的payload大于MTU进行IP分片。

我们来详细讲解一下。

对于(1)中的内容,我们可以认定为应用程序内部自身的缓冲区,此缓冲区因为大小不同会导致连续写入的数据太长被截断,从而导致一个完整的业务消息体被分为两段发送出去。

对于(2)中的内容,其实是TCP协议里面的MSS大小,此大小会决定发送的数据包的长度。属于协议层面的缓冲区。

对于(3)中的内容,则属于网卡自身的缓冲区大小,属于硬件层面。

既然了解了粘包拆包发生的原因了,那么有什么办法来应对呢?由于不同业务有不同的实现方式,所以一般情况下都会采用如下的解决方式来进行处理:

(1)  数据消息固定长度,比如说1024字节,接收方接收到数据,以1024字节为单位进行截取即可。如若当前接收到的数据不够1024字节,可以等后续的数据到达后,以1024为单位进行截取。适用于数据结构固定长度的场合。

(2)  数据消息采用分隔符,比如用换行符或者使用竖线分隔等,依据具体的业务来进行。在进行数据处理的时候,可以根据这些分隔符来截取数据。适用于数据结构长度不固定的场合。前面提到的物联网采集端通讯协议就是采用的此种做法。

(3)  数据消息包含数据头和数据体,数据头中包含数据长度,此种做法可以让数据定义更为灵活多变,但是会让数据结构变得臃肿,非常适合于自定义通讯协议的场合中。

(4)  其他根据具体业务而衍生出来的处理方式。比如Dubbo通讯协议等。

 

>>编解码

当我们将数据从本机发到远端的时候,我们需要将数据转换为二进制放到缓冲区,然后发送出去,这叫做编码。当我们接收远端数据到本机的时候,我们需要将缓冲区的二进制数据还原为对象,这叫做解码。

由于目前能够进行这种编解码的组件非常的多,比如ProtoBuffer,ProtoStuff,Marshalling,MessagePack等,由于这些组件有性能上的差别和使用简便性方面的差别,所以需要自己通过Benchmark来选择最适合自己业务的。由于ProtoStuff是对ProtoBuffer的封装,省去了我们手写协议文件的烦恼,且性能上的损耗在可以接收范围内,所以我们接下来的讲解均以此组件来进行。

 

>>鉴权认证

双端的机器在进行通讯的时候,必须要进行身份认证后才能进行连接,此举可以防止非法用户通过构造数据包来非法访问服务数据的作用。此鉴权认证发生在双方机器第一次进行连接通讯的时候,客户端必须先发送鉴权认证的数据包给服务端,服务端对此客户端进行鉴权认证,如果鉴权认证不通过(比如客户端ip在黑名单中或者客户端的请求token无效等),则拒绝连接。

其实这种鉴权认证就类似咱们访问网页时候,需要先进行用户登录的情况一样。虽然此种做法无法百分之百的保证非法用户的访问,但是可以在极大程度上提升服务端的安全性能。

 

>>心跳检测

双端的机器在进行通讯的时候,由于链路保持在活跃状态,所以不会导致链路中断。但是一旦当一方机器(比如说客户端)由于网络变差,网络闪断,机器挂掉等原因导致掉线,那么此种情况下,服务端是感知不到客户端掉线的。所以这里需要利用心跳包来检测客户端的这种行为。心跳包的实现方式有多种,但是无外乎如下几种情况:

(1)  服务端发送心跳包给客户端,客户端接收到后计数清零,当客户端在规定的时间间隔内(比如1分钟)没有接收到服务端发送的心跳包,则计数器递增一次,累积递增三次,则视为服务端掉线。此种方式主要检测服务端存活。比如物联网采集模块中,就需要客户端实时检测服务端的存活。

(2)  客户端发送心跳给服务端,服务端接收到后计数清零,当服务端在规定的时间间隔内(比如1分钟)没有接收到客户端发送的心跳包,则计数器递增一次,累积递增三次,则视为客户端掉线。此种方式主要检测客户端存活。比如IM通讯软件中,通过此方法可以检测哪个用户掉线,然后将此掉线用户广播给其他用户告知掉线信息。

(3)  客户端发送心跳给服务端,服务端接收后计数清零,同时服务端给客户端发送一个心跳包,客户端接收后计数清零。当双端任何一方未能及时收到心跳包,则计数器进行递增,累积递增三次,则视为对方掉线。此种方式可以同时检测服务端和客户端的存活。

当然,上面是我经常用到的三种心跳包设计模式,如果有更好的设计方式,还请指教。

 

>>断线重连

客户端由于种种原因,导致和服务端的连接中断,此种情况下,需要考虑到重连。此种机制可最大程度的保证整体服务的稳定性和可用性。所以其重要性毋庸置疑。

上面就是在设计通讯组件的时候,必须要考虑的诸多细节,由于不同的业务对这些细节的依赖度有高有低,所以在实际设计的时候,可以依据业务来进行详细定制或者粗粒度实现,由此出发,打造一套自己的通讯组件,不是什么难事儿了。

上面都是一些理论点,如何将这些理论点变成实践,则是接下来要讲的内容了。Netty,终于要出场了。

 

3. 自定义协议栈。

封装一个通用的通讯组件所具备的一些要点,已经讲解的比较全面和清楚了,但是只是理论知识,本着实践出真知的态度,我们决定利用上面的知识点来打造一款自己的通讯协议,这个通讯协议会在基于CS模型(Client-Server)的通讯组件上进行信息传输。本次我们将采用Netty作为通讯组件的底层,ProtoStuff作为编解码的工具。接下来就开始吧。

 

>>编解码

在Netty中,编码是指将数据转换为缓冲区中的二进制数据,对应的编码类是MessageToByteEncoder,此类中的write方法可以将消息对象进行编码,然后写入到发送管道中。由于在此类中,encode编码方法是abstract的,所以需要用户来自己实现,我们就以ProtoStuff来书写一下。而解码则是指将缓冲区中的二进制数据转换为数据对象,对应的解码类是ByteToMessageDecoder,类似的,我们需要自己实现decode的编码方法,因为它也是abstract的。

首先我们需要封装一个SerializeUtil通用类出来,此类只包含基于ProtoStuff实现的serialize(Object object)和deserialize(byte[] data, Class<T> clazz)出来,具体封装如下:

2a17475f-30b8-41a7-89ff-ffe34030ca11

由于Netty提供了MessageToByteEncoder和ByteToMessageDecoder这两个类供我们进行编码解码,所以我们需要分别继承这两个类来实现我们的编码器,解码器。

首先来看看编码器,主要是将二进制数据放入管道中。

9af8e4c6-873b-4d53-b9b4-960bfd9a6768

然后来看看解码器,主要是将二进制数据提取出来并转换为消息对象。

86715cba-2ad7-4404-840b-68a62dbb161d

注意这里我们并非直接继承自ByteToMessageDecoder来实现,是因为单纯的继承自这个类,需要我们自己手动处理粘包拆包的情况,比较麻烦。所以我们继承自LengthFieldBasedFrameDecoder这个用来处理粘包拆包的类,此类正是继承自ByteToMessageDecoder,所以大大简化了我们的工作。粘包拆包的具体实现,后面我们会详细讲解。

从上面的代码中,我们就可以看到在Netty中,实现自己的编码解码器是多么的简单和方便。需要注意的是,在解码的时候,由于ByteBuf本身的readerIndex和writeIndex机制,在读取的时候需要用readBytes来使得readerIndex索引后移,不可以用getBytes来操作,否则会导致readerIndex不能向后移动,从而导致netty did not read anything but decoded a message的错误,这个错误的意思就是你当前读取的数据是空的,无法转化为消息对象,原因是因为我们之前已经读过此数据了,由于readerIndex未更新,导致我们读取的是空数据。关于readerIndex和writIndex更多详细内容,可以翻阅此文,我在这里做了更加详细的讲解。

 

>>粘包拆包

在Netty中,已经提供好了粘包拆包的公共类库,他们是:LineBasedFrameDecoder,StringDecoder,LengthFieldBasedFrameDecoder,DelimiterBasedFrameDecoder,FixedLengthFrameDecoder。其中StringDecoder扩展自MessageToMessageDecoder类,其他的几个均扩展自ByteToMessageDecoder类。为什么扩展自ByteToMessageDecoder类呢?因为粘包拆包发生在从缓冲区中将二进制数据读取出来的过程中,而ByteToMessageDecoder类,是将二进制数据转换为具体的消息对象的类,所以这些类库继承自这个类也是理所当然的事情了。接下来我们对这些粘包拆包工具进行一一讲解和实践。

LineBasedFrameDecoder:遍历ByteBuf中的可读字节,然后看是否有\n或者\r\n,如果存在,就认为当前寻找的消息体已经找寻完毕。同时此类也支持最大长度的数据匹配,当读取的数据长度已达到最大长度但是仍旧没有找到\n或者\r\n换行结束符的时候,将会抛出异常,同时忽略掉之前读取的异常码流。

StringDecoder:将接收到的内容转换为String串。

将LineBasedFrameDecoder+StringDecoder组合起来,就可以形成按行进行切分的文本解码器,使用这种组合来进行粘包拆包处理,非常可靠易用。由于此组合只支持数据消息含有结束换行符的,所以只适合简单的纯文本场合。

LengthFieldBasedFrameDecoder:此解码器主要是通过消息头部附带的消息体的长度来进行粘包拆包操作的。由于其配置参数过多(maxFrameLength,lengthFieldOffset,lengthFieldLength,lengthAdjustment,initialBytesToStrip等),所以可以最大程度的保证能用消息体长度字段来进行消息的解码操作。这些不同的配置参数可以组合出不同的粘包拆包处理效果。

DelimiterBasedFrameDecoder:此解码器主要通过设定分隔符来进行消息的粘包拆包处理。

FixedLengthFrameDecoder:此解码器主要是通过设置固定数据长度来进行消息的粘包拆包处理。

 

>>鉴权认证

此包为Client连接Server的时候,需要发送的第一个数据包,Server端接收到此包的内容后,通过业务解析,来对当前请求登录的Client进行鉴权操作。如果操作成功,则允许登录,否则拒绝登录。由于业务解析这块不属于我们重点讲解的内容,在示例代码中,我们以简单的鉴权操作来进行延时讲解:

首先,Client端连接到Server端,当链路Active的时候,Client端开始发送鉴权申请。

787e3535-118a-4f2f-827c-d6599098defc

然后,Server端接收到Client的鉴权申请,进行鉴权操作:

0d0cba8e-0807-4692-bb8e-4a51ff8c8cae

当Server端鉴权成功之后,会将鉴权成功的信息发送给Client端,Client端接收到鉴权成功的信息后,打印出鉴权成功信息:

874de5e0-c908-4655-9bce-0e1ce1fc26ac

这样,一个鉴权认证的基本流程就出来了,从Client端到Server端,然后再到Client端。由于鉴权的具体方式和业务关联性比较高,所以可以利用具体鉴权业务进行替换即可。

 

>>心跳检测

当鉴权通过之后,Client端和Server端的正常通讯建立。可以进行业务消息的交流。但是由于网络原因等会造成Client和Server的交流中断,而且此种中断是无法被感知的,所以Client端的心跳检测设计如下:

c508e8a3-b1f4-4159-afc0-e4b2bb66db9f

从代码可以看出,我们的HeartBeatTask会以固定5秒的频率向Server端发送一次心跳信息,如果收到Server端的心跳回复,则打印出来。

然后来看看Server端的心跳检测代码:

7b8677eb-1a45-41c2-8b03-e4876544782d

从代码可以看出,Server端收到Client端的心跳包后,会打印出来,然后构建另一个心跳包回复给Client端,也就是向Client端报告我还活着。

这样,通过一来一去的心跳包检测机制,就可以对Server端和Client端进行探活操作,避免业务上的不可用问题。

 

>>断线重连

为了提高高可用性,可以对Client端加上此项特性保证服务的可用率。Client端示例代码如下:

bedd335b-4eda-424c-99aa-48e8b3533783

由于Client关闭后,会跑到finally代码块中,所以在这里可以进行重连操作。

 

>>服务端编写

首先来看看Netty创建服务端的时序图:

image

从图示可以看出,ServerBootstrap实例是出发点;然后绑定EventLoopGroup线程池;之后设置并绑定服务端Channel,绑定各种Handler;最后就绑定到本机进行监听。此时Selector会一直进行轮询操作,一旦发现注册的Channel处于Ready状态,则执行Handler链调用。

由于以上所有的组件都准备齐全,所以我们这里可以很方便的进行服务端编码了:

d4f94d7c-aa4a-45c7-b539-715fde2007c7

从代码中我们可以看到,之前讲过的鉴权认证,编码解码,粘包拆包等都体现在了服务端Handler中,所以非常的简介明了。

 

>>客户端编写

首先来看看Netty创建客户端的时序图:

image

从图示可以看出,BootStrap是出发点;然后设置EventLoopGroup线程池;之后设置并绑定客户端Channel和各种Handler;最后通过Connect方法进行服务端连接操作。其实和服务端差别不大。由于其设计也涉及到鉴权认证,编码解码,粘包拆包等,所以编码是有些类似的:

e6e4e4ff-1e86-41b2-bb32-6b1b073a333e

好了,到了这里,我们就已经能够打造出来一个通用的通讯框架了,此框架虽然简单,但是胜在囊括了各种必须的设计元素。可以作为指导框架进行业务逻辑的耦合设计,避免出现设计过程中因为缺乏指导思想导致设计出来的东西不符合业务需求,比如高可用需求。

 

上面就是Netty初级应用,我们介绍了在设计一个简单通讯框架过程中所涉及到的比较重要的特性,接下来的篇章,我们将会讲解如何设计分布式服务框架等一些中级内容,希望您能够继续驻足品尝。

posted on 2019-03-08 20:22  程序诗人  阅读(4464)  评论(47编辑  收藏  举报