1. 前言

几年前,我就一直想着要设计一款自己的实时通讯框架,于是出来了TinySocket,她是基于微软的SocketAsyncEventArgs来实现的,由于此类提供的功能很简洁,所以当时自己实现了缓冲区处理,粘包拆包等,彼时的.net平台还没有一款成熟的即时通讯框架出来,所以当这款框架出来的时候,将当时公司的商业项目的核心竞争力提升至行业前三。但是后来随着.net平台上越来越多的即时通讯框架出来,TinySocket也是英雄暮年,经过了诸多版本迭代和诸多团队经手,她不仅变得臃肿,而且也不符合潮流。整体的重构势在必行了。但是我还在等,在等一款真正的即时通讯底层库出来。

都说念念不忘,必有回响。通过不停的摸索后,我发现了netty这套底层通讯库(对号入座,.net下对应的是dotnetty),凭借着之前的经验,第一感觉这就是我要找寻的东西。后来写了一些demo彻底印证了我的猜想,简直是欣喜若狂,想着如果早点发现这个框架,也许就不会那么被动的踩坑了。就这样,我算是开启了自己的netty之旅。

微言netty系列,就是我的netty之旅的一些产出,它结合了我过往的经验来产出一些对大家有用的东西,希望不会让大家失望。

注:本文原理讲解并非以某一种语言为主,但是对于具体场景分析,用的是Java,读者可以类推到其他语言。同时本文并不提供源码级别的原理性讲解,如读者有兴趣,可以自行查找实践。

2. 整体架构模型

言归正传,我们继续netty之旅吧。

分布式服务框架,特点在于分布式,功能在于服务提供,目标在于即时通讯框架整合。由于其能够让服务端和客户端进行解耦,让调用方和被调用方处于网络的两端但是通讯毫无障碍,从而能够扩充整体的业务规模。对于一些业务场景稍微大一些的公司,一般都会采用分布式服务框架。包括目前兴起的微服务设计,更是让分布式服务框架炙手可热。那么我们今天的目标,就是来打造一款手写的分布式服务框架TinyWhale,中文名巨小鲸(手写作品,本文讲解专用, 暂无更多精力打造成开源^_^),接下来让我们开始吧。

说道目前比较流行的分布式服务框架,朗朗上口的有Dubbo,gRpc,Spring cloud等。这些框架无一例外都有着如下图所示的整体架构模型:

3cd145d7-1bad-4740-ab97-5615b04e03c5

整体流程解释如下:

1. 启动注册,指服务端开始启动并将服务注册到注册中心中。注册中心一般用zookeeper或者consul实现。

2. 启动并监听,指客户端启动并监听注册中心的服务列表。

3. 有变更则通知,指客户端订阅的服务列表发生改变,则将更新客户端缓存。

4. 接口调用,指客户端进行接口调用,此调用将首先会向服务端发起连接操作,然后进行鉴权,之后发起接口调用操作。

5. 客户端数据监控,指监控端会监控客户端的行为和数据并做记录。

6. 服务端数据监控,指监控端会监控服务端的行为和数据并做记录。

7. 数据分析并衍生出其他业务策略,指监控端会根据服务端和客户端调用数据,来衍生出新的业务策略,比如熔断,防刷,异地多活等。

当然,上面的流程是比较标准的分布式服务框架所涉及的环节。在实际设计过程中,可以根据具体的使用方式进行调整,比如监控端只监控服务端数据,因为客户端我不用关心。或者客户端不设置服务地址列表缓存,每次调用前都从注册中心重新获取最新的服务地址列表等。

TinyWhale,由于设计的初衷是简单,可靠,高性能,所以这里我们去除了监控端,所以流程5,流程6,流程7都会拿掉,如果有需要使用到监控端的,可以自行根据提供的接口来实现一套,这里将不再对监控端做过多的赘述。

3. 即时通讯框架设计涉及要素

编解码设计

编解码设计任何通讯类框架,编解码处理是无法绕过的一个话题。因为网络上只能流淌字节流,所以这种特性催生了很多的框架。由于这块的工具非常多,诸如ProtoBuf,Marshalling,Msgpack等,所以喜欢用哪个,全凭喜好。这里我用使用ProtoStuff来作为我们的编解码工具,原因有二:其一是易用性,无需编写描述文件;其二是高性能,性能属于T0级别梯队。下面来具体看看吧:

首先看看我们的编解码类:

a1c7ca52-390c-497a-964e-143e980963f7

其中serialize方法,用于将类对象编码成字节数据,然后通过本机发送出去。而deserialize方法,则用于将缓冲区中的字节数据还原为类对象。考虑到设计的简洁性,我这里并未抽象出一个公共的codecInterface和codecFactory来适配不同的编解码工具,大家可以自行来进行设计和适配。

有了编解码的辅助类了,如何集成到Netty中呢?

在Netty中,将对象编码成字节数据的操作,我们可以使用已有的MessageToByteEncoder类来进行操作,继承自此类,然后override encode方法,即可利用自己实现的protostuff工具类来进行编码操作。

40907776-cbc0-4888-9c94-6f4e28fbdec2

同样的,将字节数据解码成对象的操作,我们可以使用已有的ByteToMessageDecoder类来进行操作,继承自此类,然后override decode方法,即可利用自己实现的protostuff工具类来进行解码操作。

粘包拆包设计

之前章节已经讲过,我们直接拿来展示下。

粘包拆包,顾名思义,粘包,就是指数据包黏在一块了;拆包,则是指完整的数据包被拆开了。由于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数据包的剩余部分,此种情况也发生了拆包现象。

至于为什么会发生这种问题,根本原因在于缓冲区中的数据,Server端不大可能一次性的全部发出去,Client端也不大可能一次性正好把数据全部接收完毕。所以针对这些发生了粘包或者拆包的数据,我们需要找到合适的手段来让其形成整包,以便于进行业务处理。好消息是,Netty已经为我们准备了多种处理工具,我们只需要简单的动动代码,就可以了,他们分别是:LineBasedFrameDecoder,StringDecoder,LengthFieldBasedFrameDecoder,DelimiterBasedFrameDecoder,FixedLengthFrameDecoder。

由于上节中,我们讲解了其大概用法,所以这里我们以LengthFieldBasedFrameDecoder来着重讲解其使用方式。

LengthFieldBasedFrameDecoder:顾名思义,固定长度的粘包拆包器,此解码器主要是通过消息头部附带的消息体的长度来进行粘包拆包操作的。由于其配置参数过多(maxFrameLength,lengthFieldOffset,lengthFieldLength,lengthAdjustment,initialBytesToStrip等),所以可以最大程度的保证能用消息体长度字段来进行消息的解码操作。这些不同的配置参数可以组合出不同的粘包拆包处理效果,在此Rpc框架的设计过程中,我的使用方式如下:

be24597e-daed-4c17-bad8-7c78bd29bc63

是不是代码很简单?

翻阅LengthFieldBasedFrameDecoder源码,实现原理一览无余,由于网上讲解足够多,而且源码中的讲解也足够详细,所以这里不再做过多阐释。具体的原理解释可以看这里:LengthFieldBasedFrameDecoder

自定义协议设计

在进行网络通讯的时候,数据包从一端传输到另一端,然后被解析,被消化。这里就涉及到一个知识点,数据包是怎样定义的,才能让另一方识别出数据包所代表的业务含义。这就涉及到自定义传输协议的设计,我们来看看具体怎么设计。

首先,我们需要明确自己定义的协议需要承载哪些业务数据,一般说来包含如下的业务要点:

1. 自定义协议需要让双端识别出哪些包是心跳包

2. 自定义协议需要让双端识别出哪些包是鉴权包

3. 自定义协议需要让双端识别出哪些包是具体的业务包

4. 自定义协议需要让双端识别出哪些包是上下线包等等(本条规则适用于IM系统)

不同的系统在设计的时候,自定义协议的设计是不一样的,比如分布式服务框架,其业务包则需要包含客户端调用了哪个方法,入参中传入了哪些参数等。物联网采集框架,其业务包则需要包含底层采集硬件上传的数据中,哪些数值代表空气温度,哪些数值代表光照强度等。同样的,IM系统则需要知道当前的聊天是谁发出的,想发给谁等等。正是由于不同系统承载的业务不同,所以导致自定义协议种类繁多,不一而足。性能表现也是错落有致。复杂程度更是简繁并举。

那么针对要讲解的分布式服务框架,我们来详细看一下设计方式。

首先定义一个NettyMessage泛型类,此泛型类是一个基础类,包含了会话ID,消息类型,消息体三个字段。这三个字段是服务端和客户端进行数据交换过程中,必传的三个字段,所以整体抽取出来,放到了这里。

98e12d4a-8932-4bd6-a2d0-420484b271e4

然后,针对客户端,定义一个NettyRequest类,包含基本的请求ID,调用的类名称,方法名称,入参类型,入参值。

e7c0174d-2858-40e7-a103-462879edc247

最后,客户端的请求传送到服务端,服务端需要反射调用方法并将结果返回,服务端的NettyResponse类,则包含了请求ID,用于识别请求来自于哪个客户端,error错误,result结果三个字段:

78f8dee2-8f31-4a91-b1e1-99b1bd3d4950

当服务端调用完毕,就会把结果封装到此类中,然后将结果返回给客户端,客户端还原此类,即可拿出自己想要的数据来。

那么这个稍显冗杂的自定义协议就设计完毕了,有人会问,心跳包用这个协议如何识别呢?其实直接实例化NettyMessage类,然后在其type字段中塞入心跳标记值即可,类似如下:

1b082d53-92e8-4fea-8fa3-420d80d3a5c0

而上下线包和鉴权包则也是类似的构造,不通点在于,鉴权包 可能需要往body属性里面放一些鉴权用的用户token等。

鉴权设计

顾名思义,就是进行客户端登录的认证操作。由于客户端不是随意就能连接上来的,所以需要对客户端连接的合法性进行过滤操作,否则很容易造成各种业务或者非业务类的问题,比如数据被盗窃,服务器被压垮等等。那么一般说来,如何进行鉴权设计呢?

e7876f6b-6221-4a28-9e57-2582a8d8e07e

可以看到,上面的鉴权模块里面有三个属性,一个是已登录的用户列表clientList,一个是用户白名单whiteIP,一个是用户黑名单blackIP,在进行用户认证的时候,会通过用户token,白名单,黑名单做验证。由于不同业务的认证方式不一样,所以这里的设计方式也是五花八门。一般说来,分布式服务框架的认证方式依赖于token,也就是服务端的provider启动的时候,会给当前服务分配一个token,客户端进行请求的时候,需要附带上这个token才能够请求成功。由于我这里只是做演示效果,并未利用token进行验证,实际设计的时候,可以附带上token验证即可。

心跳包设计

传统的心跳包设计,基本上是服务端和客户端同时维护Scheduler,然后双方互相接收心跳包信息,然后重置双方的上下线状态表。此种心跳方式的设计,可以选择在主线程上进行,也可以选择在心跳线程中进行,由于在进行业务调用过程中,此种心跳包是没有必要进行发送的,所以在一定程度上会造成资源浪费。严重的甚至会影响业务线程的操作。但是在netty中,心跳包的设计并非按照如上的方式来进行。而是通过检测链路的空闲与否在进行的。链路分为读操作空闲检测,写操作空闲检测,读写操作空闲检测。如果一段时间没有收到客户端的信息,则会触发服务端发送心跳包到客户端,称为读操作空闲检测;如果一段时间没有向客户端发送任何消息,则称为写操作空闲检测;如果一段时间服务端和客户端没有任何的交互行为,则称为读写操作空闲检测。由于空闲检测本身只有在通道空闲的时候才进行检测,而不是固定频率的进行心跳包通讯,所以可以节省网络带宽,同时对业务的影响也很小。

那么就让我们看看在netty中,怎么实现高效的心跳检测吧。

在netty中,进行读写操作空闲检测,需要引入IdleStateHandler类,然后需要我们实现自己的心跳处理Handler,具体设计方式如下:

首先,引入IdleStateHandler和服务端心跳处理Handler

fb267adc-dec0-4f36-9d35-055d0d960f0e

其中读空闲检测为45秒,写空闲检测为45秒,读写空闲检测为120秒,也就是说,如果服务器45秒没有收到客户端发来的消息,就会触发一个回调事件,另外两个同理。具体触发什么事件了呢?我们来看看服务端心跳处理Handler:HeartBeatResponseHandler

fe66f758-317f-456a-8ba0-1d83fcdc188d

可以看到,检测到读空闲,会调用processReadIdle方法来处理,我们进来看看具体处理方式:

62e323ee-003a-4aa2-a995-28649b91a5c8

可以看到,服务端发现一段时间没收到客户端消息后,就会主动给客户端发一次心跳,确认客户端是否存活。如果在第90秒内还没有收到客户端的回复心跳,则会尝试再发一条,同时在客户端上下线状态表中,将当前客户端的未响应次数加一;如果在第135后认为收到客户端的回复心跳,则会尝试重发一条,同时未响应次数再加一,当次数累积到三次的时候,则认为此客户端掉线,此时将会踢掉此客户端。如果是IM系统的话,此时服务端就可以将此客户端的信息告知其他在线用户掉线,这样其他用户就可以在自己的客户端列表中删掉掉线用户。

至于processWriteIdle和processAllIdle方法,均是如上类似原理,至于需要处理,怎么去处理,均是业务自己定制,相当灵活。

很遗憾,在翻阅很多基于Netty的源码中,并未发现此样的实现方式,这也是相当可惜的。

断线重连设计

在实际网络通讯过程中,客户端可能由于网络原因未能及时的响应服务端的心跳请求,从而被服务端踢下线。之所有有这种机制,一方面是为了节省服务端资源,剔除死链接;另一方面则是出于业务要求,比如IM系统中,用户掉线了,但是服务端没有及时剔除,会导致其他用户认为此用户在线,从而可能造成误解等。

那么就需要有一种机制来保证客户端网络掉线后,能够及时的感知并进行重连,从而保证服务的可用性。之前我们介绍了心跳包,它是专门用来保持服务端和客户端的通道连接保持的。假设当客户端因为网络原因,被服务端踢下线后,客户端是无感知的,并不知道自己已经被服务端踢下线,所以这时候如果客户端依旧向服务端发送数据,将会失败。此时这就是断线重连应该工作的地方了。具体设计如下:

75ddab25-f90a-45c7-bf11-d456fc6f1489

可以看到,我们依旧用了netty原生的IdleState类来检测空闲通道。当客户端一段时间没收到服务端的消息,将会首先尝试给服务端发送一次心跳,由于此时客户端已经被服务端踢掉了,所以三次心跳均未获得回应,此时,客户端突然想明白了:“哦,我想我已经掉线了”。于是客户端将会利用ctx对象进行服务端重连操作。

此种方式简单易行,虽然不具有实时性,但是效果很好,可以有效地避免因为网络抖动等未知原因导致的掉线问题。

以上几种特性,是设计通信框架过程中,基本上都绕不开。虽然不同的通信框架由于承载的业务不同而造成设计上的差异,但是正是因为这些特性的存在,才能保证整个通信过程中的稳定性和可靠性。

接下来我们将焦点转移到服务端和客户端的设计上来。

先说说服务端和客户端,基本上的通讯模型为,服务端bind本地端口,然后进行listen监听。客户端connect服务端套接字,然后进行通讯。用netty打造的双端,也绕不开这种通讯模型。其实如果读者有过通信框架的设计经验的话,将会对此十分熟悉。不过就通讯方式来说,也是很统一的,一般都是一端发送数据,另一端接收处理,然后看具体业务再决定需不需要返回数据回去。那么这里就涉及到一个要点,因为数据的返回有同步和异步之分,一般说来同步等待数据返回的性能要比异步获取数据的性能要差一些,但是具体能差异多少,完全由设计者自己把握。

同步等待数据返回这块,我就无需多说了,基本上就是如下示例代码:

3c1d4c35-e7cd-4897-925e-760c2eed0521

异步获取返回数据这块,则设计上要复杂一些,因为设计方式是多种多样的。有用双Queue来做异步化(任务quque和应答queue), 有用Future来做异步化,当然也有用多线程来做异步化等。TinyWhale的异步化处理,采用的是后者,在客户端讲解那块,将会做详细的解释。

再说说netty框架,由于其纯异步化模型,所以获取的各种结果对象基本上是各种Future,如果之前对这种模型接触比较少的话,将会不太习惯netty的这种设计思维。具体的使用方式,将会在接下来的设计中进行详细讲解。

服务端设计

首先说道服务端,是指提供服务的一方,一般用来处理客户端请求。由于netty这块,已经将底层封装的特别好,所以这里无需多余设计,只需要了解netty的异步模型即可。那么何为netty的异步模型呢?

既然说到了同步异步,那么不免就会提起阻塞非阻塞,我就说下个人的理解吧。同步异步的区别,个人认为,只要不是一个时间只能做一件事儿的,均可称为异步。实现异步有多种方式,而多线程只是异步的一种实现方式而已。比如我们用两个queue模拟生产消费行为,也可以称之为异步。阻塞非阻塞的区别,个人认为,主要体现在对资源的争抢等待上面,发生了资源争抢等待,则被阻塞,反之为非阻塞。比如http请求远程结果,阻塞等待等。个人意见,如有谬误,还请指教。接下来让我们进入正题。

首先要从同步阻塞模型说起。

同步阻塞 

相信大家都听说过这个模型,客户端请求到服务端,服务端里面有个Acceptor接收请求,然后针对每个请求都创建一个Thread来处理,典型的一对一通信处理方式。看下具体的模型示意图:

32c59e26-a4ef-4c46-b192-8985c8fa5142

首先,客户端请求达到Acceptor,Acceptor接收并处理,然后Acceptor为每个请求创建一个线程来处理。这样后续的请求处理工作就在各自的线程上进行处理了。此种方式最简便,代码也非常好写,但是带来的问题就是一个请求对应一个线程,无法做到高性能,而且由于线程开销较大,对服务器的稳定运行也有一定的影响,随时都有可能出现内存耗尽,创建线程失败等,最终的结果就是因为宕机等缘故造成生产问题。

由于上述问题,后来产生了伪异步处理模型,其实就是讲Acceptor里面为每个请求分配一个线程,改成了线程池这种池化方式来处理,总体上性能比之前要好很多,而且机器运行也稳定很多,相对之前的模型,有了不小的提升。但是从本质上来将,此种方式和之前方式相比,并未有质的改变,之所以称为伪异步,缘由在此吧。

非阻塞

同步阻塞模型由于性能不好,可靠性低,所以催生了非阻塞模型的产生。目前非阻塞模型有两种,一种是NIO,另一种是AIO,然而AIO虽可以称得上为真正的异步非阻塞IO模型,代码也很简便,但是并未大规模的应用,料想应该有它自身的短板,所以我们着重来讲解NIO模型。首先来看看NIO模型示意图:

938a5b17-bc55-45cb-be25-87a3b550e824

上面这幅图是网上流传比较广的一幅图,因为被大家所熟知,所以这里我就直接拿来用了,这幅图的出处在这里。具体来看一下。

首先,从图中可以看出,client为客户端请求,mainReactor主要接收客户端请求,然后调用acceptor进行处理,acceptor查到已经就绪的连接,则交由subReactor进行处理。subReactor这里会负责已连接客户端的读写网络操作,也就是如果有读写操作,会反映到subReactor中来,至于业务处理部分,则直接扔给ThreadPool进行业务处理。一般说来,subReactor的个数大概和CPU的核数是一致的。从这里还可以看出mainReactor和subReactor都有派发器的意味。

由于此NIO模型使用了事件驱动,而且以linux底层作为通讯支持,完全使用了epoll高性能的特点,所以整体表现堪称完美。这里我要推荐一座金矿,大名鼎鼎的C10k问题,诸位看官如果有兴趣,可以探索一番。

然后来具体说下服务端设计吧:

public class NettyServer {

    /**
     * 服务端带参构造
     * @param serverAddress
     * @param serviceRegistry
     * @param serverBeans
     */
    public NettyServer(String serverAddress, ServerRegistry serviceRegistry, Map<String, Object> serverBeans) {
        this.serverAddress = serverAddress;
        this.serviceRegistry = serviceRegistry;
        this.serverBeans = serverBeans;
    }

    /**
     * 日志记录
     */
    private static final Logger logger = LoggerFactory.getLogger(NettyServer.class);

    /**
     * 服务端绑定地址
     */
    private String serverAddress;

    /**
     * 服务注册
     */
    private ServerRegistry serviceRegistry;

    /**
     * 服务端加载的bean列表
     */
    private Map<String, Object> serverBeans;

    /**
     * 主事件池
     */
    private EventLoopGroup bossGroup = new NioEventLoopGroup();

    /**
     * 副事件池
     */
    private EventLoopGroup workerGroup = new NioEventLoopGroup();

    /**
     * 服务端通道
     */
    private Channel serverChannel;

    /**
     * 绑定本机监听
     *
     * @throws Exception
     */
    public void bind() throws Exception {

        //启动器
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        //为Acceptor设置事件池,为客户端接收设置事件池
        serverBootstrap.group(bossGroup, workerGroup)
                //工厂模式,创建NioServerSocketChannel类对象
                .channel(NioServerSocketChannel.class)
                //等待队列大小
                .option(ChannelOption.SO_BACKLOG, 100)
                //地址复用
                .option(ChannelOption.SO_REUSEADDR, true)
                //开启Nagle算法,
                //网络好的时候:对响应要求比较高的业务,不建议开启,比如玩游戏,键盘数据,鼠标响应等,需要实时呈现;
                //            对响应比较低的业务,建议开启,可以有效减少小数据包传输。
                //网络差的时候:不建议开启,否则会导致整体效果更差。
                .option(ChannelOption.TCP_NODELAY, true)
                //日志记录组件的level
                .handler(new LoggingHandler(LogLevel.INFO))
                //各种业务处理handler
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel channel) throws Exception {
                        //空闲检测handler,用于检测通道空闲状态
                        channel.pipeline().addLast("idleStateHandler", new IdleStateHandler(45, 45, 120));
                        //编码器
                        channel.pipeline().addLast("nettyMessageDecoder", new NettyMessageDecoder(1024, 4, 4));
                        //解码器
                        channel.pipeline().addLast("nettyMessageEncoder", new NettyMessageEncoder());
                        //心跳包业务处理,一般需要配置idleStateHandler一起使用
                        channel.pipeline().addLast("heartBeatHandler", new HeartBeatResponseHandler());
                        //服务端先进行鉴权,然后处理业务
                        channel.pipeline().addLast("loginAuthResponseHandler", new LoginAuthResponseHandler());
                        //业务处理handler
                        channel.pipeline().addLast("nettyHandler", new ServerHandler(serverBeans));
                    }
                });

        //获取ip和端口
        String[] array = serverAddress.split(":");
        String host = array[0];
        int port = Integer.parseInt(array[1]);

        //绑定端口,同步等待成功
        ChannelFuture future = serverBootstrap.bind(host, port).sync();

        //注册连接事件监听器
        future.addListener(cfl -> {
            if (cfl.isSuccess()) {
                logger.info("服务端[" + host + ":" + port + "]已上线...");
                serverChannel = future.channel();
            }
        });

        //注册关闭事件监听器
        future.channel().closeFuture().addListener(cfl -> {
            //关闭服务端
            close();
            logger.info("服务端[" + host + ":" + port + "]已下线...");
        });

        //注册服务地址
        if (serviceRegistry != null) {
            serviceRegistry.register(serverBeans.keySet(), host, port);
        }
    }

    /**
     * 关闭server
     */
    public void close() {
        //关闭套接字
        if(serverChannel!=null){
            serverChannel.close();
        }
        //关闭主线程组
        if (bossGroup != null) {
            bossGroup.shutdownGracefully();
        }
        //关闭副线程组
        if (workerGroup != null) {
            workerGroup.shutdownGracefully();
        }
    }
}
服务端源码

由于代码做了具体的注释,我这里就不针对性的进行解释了。需要注意的是,当服务启动之后,会注册两个监听器,一个绑定时间监听,一个关闭事件监听,当事件被触发的时候,会回调两个事件内部的逻辑。最后服务端正常启动,会被注册到注册中心中,以便于客户端调用。需要注意的是,一般情况下,业务Handler最好和心跳包Handler等非业务性的Handler处理分开,避免业务高峰时期,因为心跳包等Handler的处理来耗费捉襟见肘的内存资源或者CPU资源等,造成服务器性能下降。来看一下ServerHandler的具体设计:

5ca2ea97-bcac-45bb-8c22-49ca6599a2c3

从这里可以看出,我们用了一个线程池来将业务处理进行池化,这样做就不会受到心跳包等其他非业务处理Handler的影响,最大限度的保证系统的稳定性。

更多关于同步异步,阻塞非阻塞的设计,请参见Doug Lea:Scalable IO in Java

客户端设计

再来说说客户端,是指消费服务的一方,一般用来实现特定的消费业务。同样的,netty这块已经将底层封装的很好,所以直接编写业务即可。和编写服务端不同的是,这里不需要分BossGroup和WorkerGroup,因为对于客户端来说,只需要连接服务端,然后发送数据并监听即可,不存在影响性能的问题。具体的写法看看吧:

public class NettyClient {

    /**
     * 日志记录
     */
    private static final Logger logger = LoggerFactory.getLogger(NettyClient.class);

    /**
     * 客户端请求Future列表
     */
    private Map<String, TinyWhaleFuture> clientFutures = new ConcurrentHashMap<>();


    /**
     * 客户端业务处理handler
     */
    private ClientHandler clientHandler = new ClientHandler(clientFutures);

    /**
     * 事件池
     */
    private EventLoopGroup group = new NioEventLoopGroup();

    /**
     * 启动器
     */
    private Bootstrap bootstrap = new Bootstrap();

    /**
     * 客户端通道
     */
    private Channel clientChannel;

    /**
     * 客户端连接
     * @param host
     * @param port
     * @throws InterruptedException
     */
    public NettyClient(String host, int port) throws InterruptedException {
        bootstrap.group(group)
                .channel(NioSocketChannel.class)
                .option(ChannelOption.TCP_NODELAY, true)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel channel) throws Exception {
                        //通道空闲检测
                        channel.pipeline().addLast("idleStateHandler", new IdleStateHandler(45, 45, 120));
                        //解码器
                        channel.pipeline().addLast("nettyMessageDecoder", new NettyMessageDecoder(1024 * 1024, 4, 4));
                        //编码器
                        channel.pipeline().addLast("nettyMessageEncoder", new NettyMessageEncoder());
                        //心跳处理
                        channel.pipeline().addLast("heartBeatHandler", new HeartBeatRequestHandler());
                        //业务处理
                        channel.pipeline().addLast("clientHandler", clientHandler);
                        //鉴权处理
                        channel.pipeline().addLast("loginAuthHandler", new LoginAuthRequestHandler());
                    }
                });

        //发起同步连接操作
        ChannelFuture channelFuture = bootstrap.connect(host, port);

        //注册连接事件
        channelFuture.addListener((ChannelFutureListener)future -> {
            //如果连接成功
            if (future.isSuccess()) {
                logger.info("客户端[" + channelFuture.channel().localAddress().toString() + "]已连接...");
                clientChannel = channelFuture.channel();
            }
            //如果连接失败,尝试重新连接
            else{
                logger.info("客户端[" + channelFuture.channel().localAddress().toString() + "]连接失败,重新连接中...");
                future.channel().close();
                bootstrap.connect(host, port);
            }
        });

        //注册关闭事件
        channelFuture.channel().closeFuture().addListener(cfl -> {
            close();
            logger.info("客户端[" + channelFuture.channel().localAddress().toString() + "]已断开...");
        });
    }

    /**
     * 客户端关闭
     */
    private void close() {
        //关闭客户端套接字
        if(clientChannel!=null){
            clientChannel.close();
        }
        //关闭客户端线程组
        if (group != null) {
            group.shutdownGracefully();
        }
    }

    /**
     * 客户端发送消息,将获取的Future句柄保存到clientFutures列表
     * @return
     * @throws InterruptedException
     * @throws ExecutionException
     */
    public TinyWhaleFuture send(NettyMessage<NettyRequest> request) {
        TinyWhaleFuture rpcFuture = new TinyWhaleFuture(request);
        rpcFuture.addCallback(new TinyWhaleAsyncCallback() {
            @Override
            public void success(Object result) {
            }

            @Override
            public void fail(Exception e) {
                logger.error("发送失败", e);
            }
        });
        clientFutures.put(request.getBody().getRequestId(), rpcFuture);
        clientHandler.sendMessage(request);
        return rpcFuture;
    }
}
客户端源码

由于代码中,我也做了诸多的注释,所以这里不再一一解释。需要注意的是,和编写服务端类似,我这里添加了两个监听事件,监听连接成功事件,监听关闭事件,响应的业务场景如果触发了这两个事件,将会执行事件内部的逻辑。

这里需要提一下消息发送的场景。一般说来,客户端向服务端发送数据,然后服务端处理功能后返回给客户端,客户端接收到消息后再进行后续处理。这个流程一般有两种实现方式,一种是同步的实现方式,另一种是异步的实现方式,具体来呈现以下:

首先是同步实现方式,顾名思义,客户端发送数据给服务端,服务端在处理完毕并返回数据之前,客户端一直处于阻塞等待状态,send方法的代码设计如下:

e0387579-7276-4d74-bd83-31c1d974ede2

来看看clientHandler里面的sendMessage方法:

4b2d29fd-55fa-4159-873c-0a9ea9cd553a

在开始发送之前,我们先拿到当前ctx的promise句柄,然后将数据写入到缓冲区,最后将此句柄返回给send方法,send方法接收到此句柄后,将会等待promise执行完毕,如何判断promise执行完毕呢?当客户端接收到服务端返回,就可以将promise置为完成状态:

677e5ed7-63f4-4e75-a7bd-763304e1dcd4

可以看到,通过重置promise的setSuccess方法,即可将promise置为完成态,这样操作之后,send方法里面就可以正常的拿到数据并返回了。否则将会一直处于阻塞状态。

可以看到,在netty中实现阻塞的方式来接收服务端返回,处理起来还是挺麻烦的,根本原因在于netty完全异步化的模型,所以只能用如上的方式来进行同步化处理。

再来说说异步化处理吧, 这也是netty很推崇的方式。

首先来看看send方法:

9d017228-d265-4b58-b735-9618856758a5

从上面代码中可以看到,当我们将消息发送出去后,会立即获得一个TinyWhaleFuture的句柄,不会再有阻塞等待的场景。我们看看clientHandler.sendMessage的具体实现:

2974eedd-4e1c-46c0-96a7-1735a4ad1916

可以看到,只是单纯的将数据推送到缓冲区而已。

还记得我们的TinyWhaleFuture句柄吗?既然返回给我们了这个句柄,那么我们肯定是可以从此句柄中取出我们想要获取的数据的,我们看看客户端如果收到服务端的返回结果,该如何处理呢?

50149181-14be-4cce-9fdc-1393e0cd70b3

可以看到,这里利用了一个Map来保存用户每个发送请求,一旦当服务端返回数据后,就会将请求置为完成态,同时从Map中将已完成的操作删掉。这样,客户端拿到TinyWhaleFuture句柄后,通过提供的get方法即可在想获取结果的地方来获取返回结果。这样做,是不会阻塞其他业务执行的。

其实不仅仅是netty中,在设计其他框架的时候,也可以利用此思想来实现真正意义上的异步执行逻辑。当然,能够实现这种执行逻辑的方式有很多种,至于更好的实现方式,还请君细细斟酌吧,这里只起到抛砖引玉的作用。

4. 动态调用设计

服务注册和服务发现

先来上个大致的类设计图,ServerCache接口提供基础的本地缓存操作;ServerBase提供基础的连接注册中心,关闭注册中心连接操作;ServerRegistry为服务注册类;ServerDiscovery为服务发现类,下面是类UML图,我们来具体的说一说:

33f8e329-fe57-4223-8fb9-8e61865ce163

首先是注册中心,这个就不必说了,一般都是使用zookeeper或者consul等框架来实现,这里我们使用zookeeper。但是我们这里并不是用原生的zookeeper sdk来操作,而是使用curator来操作,curator是什么呢?在其介绍页面有句很经典的话:Guava is to Java what Curator is to Zookeeper,相当的简洁明了吧。来看下具体的使用方式吧。

首先定义用于加载注册中心服务套接字的共享缓存,客户端启动的时候,此共享缓存会从注册中心拉取服务器列表到本地保存:

1bbffe83-acca-4c28-962b-20c9ea286b3a

然后,定义服务治理的公共操作类:

8074c8cc-fb0c-44ca-9941-95ba65d32c19

可以看到,此基类中,open方法和close方法,用于连接zk服务器,关闭和zk服务器的连接。之后便是对接口中操作本地缓存的实现。

由于服务治理这块包含了服务注册和服务发现功能,所以这里,我们分别定义ServerRegistry类和ServerDiscovery类来进行处理。

ServerRegistry类,顾名思义,表示服务注册,也就是当我们的服务端启动之后,绑定了本机端口之后,会将承载的服务注册到zk中。

44220ee6-9d3b-44af-b06f-546ea2771065

ServerDiscovery类,顾名思义,服务发现,那么此类中的discovery方法则就是根据用户传入的接口名称来找到对应的服务器,然后将结果返回。需要注意的是,服务发现的过程,需要涉及到负载均衡,之所以涉及到这个,主要是为了让每台服务器收到的请求均匀一些,以达到均衡的目的,这样就不会因为请求打的不均匀导致有些服务器负载太大,有些服务器负载几乎没有的情况。负载均衡,我将在后面的章节讲解,先继续看看服务发现这块:

43f13e2c-9f5e-4bd5-89a9-8998f9f92923

可以看到,我用了一个watchNode方法来检测节点的改动,此方法内部设置了一个Listener,只要有节点的改动,都会推送到此Listener中,然后我就可以根据改动的类型来决定是否对本地缓存进行更新操作。

更具体的服务注册和服务发现使用方式,可以参考curator官网:Service register and Service discovery

负载均衡

前面说到了服务治理这块,由于里面涉及到负载均衡这块,这里就详细说一下。

一般说来,有三种负载均衡模型是绕不开的,分别是一致性哈希,此模型可以让带有业务标记的请求每次请求都会导向到指定的服务器上。轮询,此模型主要是对服务器列表进行顺序访问。随机,此模型主要是随机获取服务器并返回。其他的模型还有很多,可以根据具体的业务进行衍生,这里不做一一的展示。

首先来看看负载均衡基类:

6db1acf2-c315-48a9-b11a-599193ee965b

然后看看三种模型的实现:

一致性哈希实现,直接对服务端的size进行取余操作:

92cd52ab-0d2d-44ec-960a-0cbd383eb652

轮询实现,对访问过的服务器进行计数累加,然后把此计数作为下标并获取元素返回:

f3915660-1dbe-4346-9fb8-e2592e58be9c

随机实现,对服务器进行随机选取:

9fc58dcf-bcd2-4458-96b3-080c7ad5a66a

你也许会问,为什么你设计的负载均衡里面没有权重操作呢?其实如果愿意,也是可以加上权重操作的,这样就会衍生出来其他的负载均衡模型,比如服务访问不同,权重-10,服务能访问通,权重+1,这样就可以通过权重,选取一些权重较高的服务器优先返回,而对那些权重较低的服务器,可以少分一些请求,让其慢慢恢复到正常状态之后,再多分配一些请求过来等等。

总之,你可以在此基础上进行自己的设计,但是大体思想就是让服务器获得的负载越均衡越好。

容灾处理

此处整合Hystrix进行的设计,可以对请求做FailFast处理,RetryOnece处理,RetryTwice处理等, 具体细节可以翻看Hystrix设计即可。这里就不详解(哈哈哈,其实是因为写着写着,写的懒了,这块就不想讲了,毕竟基本上都是Hystrix那套)。

反射调用

最后要说的部分就是反射调用这块了。我们知道,当客户端发送待调用的方法发送给服务端,服务端接收后,需要通过反射调用方法,然后将结果返回给客户端。首先来看看服务端业务处理Handler:

c04f7664-506d-470b-b2cd-56d1d934ae6a

可以看到,此业务处理handler会读取客户端的请求,然后分析数据包内容,最后利用反射来调用相应的方法获取结果并压入缓冲区中,之后发送给客户端。

再来看看handle方法是如何进行反射调用并得到结果的:

a3e6bb50-4421-43c6-be3b-413cec9424f8

可以看到,很经典的反射调用场景,这里就不细说了。

从这里,我们可以看出,服务端的处理方式如上,非常的简单。但是客户端是怎么发送请求消息给服务端,又是如何接收服务端的返回数据的呢?

3acc13f4-28cc-4de4-965d-336074b6a6f5

从上面可以看出,我们用了javassist组件的反射(java自身的反射也是类似的使用方式)来构建完整的类对象,然后利用callback回调来发送请求给服务端获取数据,然后获取服务端返回的数据,最后将返回的数据拆解后,返回给客户端。如果用java自带的反射来实现,编码也是差不多的:

00ff2b8e-0261-4cc4-896e-ab7ea42ee7a1

这里需要注意的是,此处用了动态反射的功能来实现,性能并不是特别好,如果能用上字节码技术,性能会再提升一个台阶。具体的字节码实现方式,可以参见我后续的文章。

5. 跑起来吧!!

好了,我们终于把一切都准备好了,那么就让我们运行起来吧。

在服务端,首先可以看到如下的注册中心上线日志:

068cefb2-70c4-44ac-8176-17e7d7b48963

然后可以看到客户端登录日志:

57981f83-d3a9-429b-a956-75c7ab4a8861

在客户端,我们可以看到如下的日志:

2f6f67b2-4a52-4d78-b235-c9381fe23cdf

可以看到,客户端连接上来后,先发送鉴权请求,鉴权成功后,将会发送服务调用请求,调用完毕后,得到返回结果,整个过程耗费18ms,最后客户端退出。

当我们在客户端调用的时候,加上Thread.Sleep来触发心跳探活,可以看到如下的检测结果:

b2eca78b-c48b-4a06-b5f3-a5b7b3b81da7

可以看到,每隔5秒钟,我们都能收到客户端的心跳,然后我们模拟网络差,客户端掉线,看看服务端如何检测:

ee8a82c9-902e-4d54-9b8f-2ee0727ceabf

可以看到客户端被踢掉了,此时我们再去看看客户端日志,可以看出来,客户端确实被服务端踢掉线了:

d7498e9b-8f60-4d72-b52b-7596b126c8f6

最后,东西做完后,补一个benchmark吧,由于我的机器性能比较差,而且测试是直接开启IDEA这个IDE来测试的,所以性能并不见得很好:

57366a4e-c94a-41b7-a8dc-9224f7c71654

然后来看看benchmark结果吧:

0f003fc7-3e98-4488-8037-6bf5dc50c02a

性能并不是特别好,关键有以下几个地方是耗时大户:编解码,反射,同步等待服务端返回

编解码这个只能找性能比较好的组件来解决

反射可以通过字节码来实现,性能会再提升一个档次,但是难度也会提升不少。

同步等待服务返回,可以通过完全异步化实现来解决,那么刚刚展示的

a5b71565-ff20-4f5f-bafb-d4271f48a0c0

调用方式,会被改变成:

71bc7cea-5640-411b-a8a9-a75ac57ce63f

虽然这样速度会快很多,但是用户能否接受这种调用方式,则是另一个头疼的问题。性能和易用,本身就具有相悖性,所以只能在进退之间做平衡了。

写到这里,整体介绍差不多了,但是还有很多东西没有接入,譬如说kafka,mq,redis等。如果能把这些东西接入,则会让其整体显得更加丰满,同时功能也更丰富,应用场景也会更广阔一些。

6.总结

写到这里,利用netty打造分布式服务框架的要点就基本上完结了。通篇看来,知识点很多,但是都是我们耳熟能详的东西,能把它们串在一起,组成一个可以用的框架,则需要一定的思考。

文中所有内容基本上为原创,如需转载,请标明 转载自博客园程序诗人 字样,算是对本家付出的辛苦的一点尊重吧。

posted on 2019-06-06 11:07  程序诗人  阅读(7415)  评论(30编辑  收藏  举报