1、Netty协议栈功能设计
1.1、协议栈功能描述
Netty协议栈承载了业务内部各模块之间的消息交互和服务调用,它的主要功能如下。
(1)基于Netty的NIO通信框架,提供高性能的异步通信能力;
(2)提供消息的编解码框架,可以实现POJO的序列化和反序列化;
(3)提供基于IP地址的白名单接入认证机制;
(4)链路的有效性校验机制;
(5)链路的断连重连机制。
1.2、通信模型
具体步骤如下。
(1)Netty协议栈客户端发送握手请求消息,携带节点ID等有效身份认证信息;
(2)Netty协议栈服务端对握手请求消息进行合法性校验,包括节点ID有效性校验,节点重复登录校验和IP地址合法性校验,校验通过后返回登录成功的握手应答消息;
(3)链路建立成功之后,客户端发送业务消息;
(4)链路成功之后,服务端发送心跳消息;
(5)链路建立成功之后,客户端发送心跳消息;
(6)链路建立成功之后,服务端发送业务消息
(7)服务端退出时,服务端关闭连接,客户端感知对方关闭连接后,被动关闭客户端连接。
备注:需要指出的是,Netty协议通信双方链路建立成功之后,双方可以进行全双工通信,无论客户端还是服务端,都可以主动发送请求消息给对方,通信方式可以是TWO WAY或者ONE WAY。双方之间的心跳采用Ping-Pong机制,当链路处于空闲状态时,客户端主动发送Ping消息給服务端,服务端接收到Ping消息后发送应答消息Pong給客户端,如果客户端连续发送N条ping消息都没有接收到服务端返回的pong消息,说明链路已经挂死或者对方处于异常状态,客户端主动关闭连接,间隔周期T后发起重连操作,直到重连成功。
1.3、消息定义
Netty协议栈消息定义包含两部分:
- 消息头
- 消息体
其具体定义分别如表12-1和表12-2所示。
名称 | 类型 | 长度 | 描述 |
header | Header | 变长 | 消息头定义 |
body | Object | 变长 | 对于请求消息,它是方法的参数(作为示例,只支持携带一个参数) 对于响应消息,它是返回值 |
名称 | 类型 | 长度 | 描述 |
crcCode | 整型int | 32 | Netty消息的校验码,它由三部分组成: 1)0XABEF:固定值,表明该消息是Netty协议消息,2个字节 2)主版本号:1~255,1个字节 3)次版本号:1~255,1个字节。 recCode=0XABEF+主版本号+次版本号 |
length | 整型int | 32 | 消息长度,整个消息,包括消息头和消息体 |
sessionID | 长整型long | 64 | 集群节点内全局唯一,由会话ID生成器生成 |
type | byte | 8 | 0:业务请求消息 1:业务响应消息 2:业务ONE WAY消息(既是请求又是响应消息) 3:握手请求消息 4:握手应答消息 5:心跳请求消息 6:心跳应答消息 |
priority | byte | 8 | 消息优先级:0~255 |
attachment | Map<String,Object> | 变长 | 可选字段,用于扩展消息头 |
1.4、Netty协议的编解码规范
1.4.1、Netty协议的编码
Netty协议 Netty Message的编码规范如下。
(1)crcCode:java.nio.ByteBuffer.putInt(int value),如果采用其他缓冲区实现,必须与其等价;
(2)length: java.nio.ByteBuffer.putInt(int value),如果采用其他缓冲区实现,必须与其等价;
(3)sessionId:java.nio.ByteBuffer.putLong(long value),如果采用其他缓冲区实现必须与其等价;
(4)type: java.nio.ByteBuffer.putByte(byte value),如果采用其他缓冲区实现,必须与其等价;
(5)priority:java.nio.ByteBuffer.putByte(byte value),如果釆用其他缓冲区实现,必须与其等价;
(6)attachment:它的编码规则为——如果 attachment长度为0,表示没有可选附件,则将长度编码设为0, java.nio.ByteBuffer.putInt(0);如果大于0,说明有附件需要编码,
具体的编码规则如下。
◎首先对附件的个数进行编码,java.nio.ByteBuffer.putInt(attachment.size());
◎然后对Key进行编码,先编码长度,再将它转换成byte数组之后编码内容。
(7)body的编码:通过 JBoss Marshalling将其序列化为byte数组,然后调用java.nio.ByteBuffer.put(byte[] src)将其写入ByteBuffer缓冲区中。
由于整个消息的长度必须等全部字段都编码完成之后才能确认,所以最后需要更新消息头中的length字段,将其重新写入ByteBuffer中。
1.4.2、Netty协议的解码
相对于 Netty Message的编码,仍旧以java.nio.ByteBuffer为例,给出Netty协议的解码规范。
(1)crcCode:通过java.nio.ByteBuffer.getInt()获取校验码字段,其他缓冲区需要与其等价;
(2)length:通过java.nio.ByteBuffer.getInt()获取 Netty消息的长度,其他缓冲区需要与其等价;
(3)sessionId:java.nio.ByteBuffer.getLong()获取会话ID,其他缓冲区需要与其等价;
(4)type:通过java.nio.ByteBuffer.getByte()获取消息类型,其他缓冲区需要与其等价;
(5)priority:通过java.nio.ByteBuffer.getByte()获取消息优先级,其他缓冲区需要与其等价
(6)attachment:它的解码规则为——首先创建一个新的 attachment对象,调用java.nio.ByteBuffer.getInt()获取附件的长度,如果为0,说明附件为空,解码结束,继续解消息体;如果非空,则根据长度通过for循环进行解码。
(7)body:通过 JBoss的 marshaller对其进行解码
1.5、链路的建立
Netty协议栈支持服务端和客户端,对于使用Netty协议栈的应用程序而言,不需要刻意区分到底是客户端还是服务端,在分布式组网环境中,一个节点可能既是服务端也是客户端,这个依据具体的用户场景而定。
Netty协议栈对客户端的说明如下:如果A节点需要调用B节点的服务,但是A和B之间还没有建立物理链路,则由调用方主动发起连接,此时,调用方为客户端,被调用方为服务端。
考虑到安全,链路建立需要通过基于IP地址或者号段的黑白名单安全认证机制,作为样例,本协议使用基于IP地址的安全认证,如果有多个IP,通过逗号进行分割。在实际商用项目中,安全认证机制会更加严格,例如通过密钥对、用户名和密码进行安全认证客户端与服务端链路建立成功之后,由客户端发送握手请求消息,握手请求消息的定义如下。
(1)消息头的type字段值为3;
(2)可选附件为个数为0;
(3)消息体为空;
(4)握手消息的长度为22个字节;
服务端接收到客户端的握手请求消息之后,如果IP校验通过,返回握手成功应答消息给客户端,应用层链路建立成功。握手应答消息定义如下。
(1)消息头的type字段值为4
(2)可选附件个数为0
(3)消息体为byte类型的结果,“0”表示认证成功:“-1”表示认证失败
链路建立成功之后,客户端和服务端就可以互相发送业务消息了。
1.6、链路的关闭
由于采用长连接通信,在正常的业务运行期间,双方通过心跳和业务消息维持链路,任何一方都不需要主动关闭连接。但是,在以下情况下,客户端和服务端需要关闭连接。
(1)当对方宕机或者重启时,会主动关闭链路,另一方读取到操作系统的通知信号,得知对方REST链路,需要关闭连接,释放自身的句柄等资源。由于采用TCP全双工通信,通信双方都需要关闭连接,释放资源;
(2)消息读写过程中,发生了IO异常,需要主动关闭连接;
(3)心跳消息读写过程中发生了IO异常,需要主动关闭连接;
(4)心跳超时,需要主动关闭连接;
(5)发生编码异常等不可恢复错误时,需要主动关闭连接。
1.7、可靠性设计
Netty协议栈可能会运行在非常恶劣的网络环境中,网络超时、闪断、对方进程僵死或者处理缓慢等情况都有可能发生。为了保证在这些极端异常场景下Netty协议栈仍能够正常工作或者自动恢复,需要对它的可靠性进行统一规划和设计。
1.7.1、心跳机制
在凌晨等业务低谷期时段,如果发生网络闪断、连接被Hang住等网络问题时,由于没有业务消息,应用进程很难发现。到了白天业务高峰期时,会发生大量的网络通信失败,严重的会导致一段时间进程内无法处理业务消息。为了解决这个问题,在网络空闲时采用心跳机制来检测链路的互通性,一旦发现网络故障,立即关闭链路,主动重连具体的设计思路如下。
(1)当网络处于空闲状态持续时间达到T(连续周期T没有读写消息)时,客户端主动发送Ping心跳消息给服务端。
(2)如果在下一个周期T到来时客户端没有收到对方发送的Pong心跳应答消息或者读取到服务端发送的其他业务消息,则心跳失败计数器加1。
(3)每当客户端接收到服务的业务消息或者Pong应答消息时,将心跳失败计数器清零:连续N次没有接收到服务端的Pong消息或者业务消息,则关闭链路,间隔 INTERVAL时间后发起重连操作。
(4)服务端网络空闲状态持续时间达到T后,服务端将心跳失败计数器加1;只要接收到客户端发送的Ping消息或者其他业务消息,计数器清零。
(5)服务端连续N次没有接收到客户端的Ping消息或者其他业务消息,则关闭链路,释放资源,等待客户端重连。
通过Ping-Pong双向心跳机制,可以保证无论通信哪一方出现网络故障,都能被及时地检测出来。为了防止由于对方短时间内繁忙没有及时返回应答造成的误判,只有连续N次心跳检测都失败才认定链路已经损害,需要关闭链路并重建链路当读或者写心跳消息发生IO异常的时候,说明链路已经中断,此时需要立即关闭链路,如果是客户端,需要重新发起连接。如果是服务端,需要清空缓存的半包信息,等待客户端重连。
1.7.2、重连机制
如果链路中断,等待 INTERVAL时间后,由客户端发起重连操作,如果重连失败,间隔周期 INTERVAL后再次发起重连,直到重连成功。为了保证服务端能够有充足的时间释放句柄资源,在首次断连时客户端需要等待INTERVAL时间之后再发起重连,而不是失败后就立即重连。
为了保证句柄资源能够及时释放,无论什么场景下的重连失败,客户端都必须保证自身的资源被及时释放,包括但不限于SocketChannel、 Socket等。重连失败后,需要打印异常堆栈信息,方便后续的问题定位。
1.7.3、重复登录保护
当客户端握手成功之后,在链路处于正常状态下,不允许客户端重复登录,以防止客户端在异常状态下反复重连导致句柄资源被耗尽。
服务端接收到客户端的握手请求消息之后,首先对IP地址进行合法性检验,如果校验成功,在缓存的地址表中査看客户端是否已经登录,如果已经登录,则拒绝重复登录,返回错误码-1,同时关闭TCP链路,并在服务端的日志中打印握手失败的原因。
客户端接收到握手失败的应答消息之后,关闭客户端的TCP连接,等待 INTERVAL时间之后,再次发起TCP连接,直到认证成功。
为了防止由服务端和客户端对链路状态理解不一致导致的客户端无法握手成功的问题,当服务端连续N次心跳超时之后需要主动关闭链路,清空该客户端的地址缓存信息,以保证后续该客户端可以重连成功,防止被重复登录保护机制拒绝掉。
1.7.4、消息缓存重发
无论客户端还是服务端,当发生链路中断之后,在链路恢复之前,缓存在消息队列中待发送的消息不能丢失,等链路恢复之后,重新发送这些消息,保证链路中断期间消息不丢失。
考虑到内存溢出的风险,建议消息缓存队列设置上限,当达到上限之后,应该拒绝继续向该队列添加新的消息。
1.8、安全性设计
为了保证整个集群环境的安全,内部长连接采用基于IP地址的安全认证机制,服务端对握手请求消息的IP地址进行合法性校验:如果在白名单之内,则校验通过:否则,拒绝对方连接。
如果将Netty协议栈放到公网中使用,需要采用更加严格的安全认证机制,例如基于密钥和AES加密的用户名+密码认证机制,也可以采用SSL/TSL安全传输。
作为示例程序,Netty协议栈采用最简单的基于IP地址的白名单安全认证机制。
1.9、可扩展性设计
Netty协议需要具备一定的扩展能力,业务可以在消息头中自定义业务域字段,例如消息流水号、业务自定义消息头等。通过Netty消息头中的可选附件 attachment字段,业务可以方便地进行自定义扩展。
Netty协议栈架构需要具备一定的扩展能力,例如统一的消息拦截、接口日志、安全、加解密等可以被方便地添加和删除,不需要修改之前的逻辑代码,类似 Servlet的 FilterChain和AOP,但考虑到性能因素,不推荐通过AOP来实现功能的扩展。
2、Netty协议栈开发
2.1、数据结构定义
public final class NettyMessage {
private Header header;
private Object body;
}
public final class Header {
private int crcCode = 0xabef0101;
private int length;// 消息长度
private long sessionID;// 会话ID
private byte type;// 消息类型
private byte priority;// 消息优先级
private Map<String, Object> attachment = new HashMap<String, Object>(); // 附件
}
2.2、消息编解码
public final class NettyMessageEncoder extends
MessageToByteEncoder<NettyMessage> {
MarshallingEncoder marshallingEncoder;
public NettyMessageEncoder() throws IOException {
this.marshallingEncoder = new MarshallingEncoder();
}
@Override
protected void encode(ChannelHandlerContext ctx, NettyMessage msg,
ByteBuf sendBuf) throws Exception {
if (msg == null || msg.getHeader() == null)
throw new Exception("The encode message is null");
sendBuf.writeInt((msg.getHeader().getCrcCode()));
sendBuf.writeInt((msg.getHeader().getLength()));
sendBuf.writeLong((msg.getHeader().getSessionID()));
sendBuf.writeByte((msg.getHeader().getType()));
sendBuf.writeByte((msg.getHeader().getPriority()));
sendBuf.writeInt((msg.getHeader().getAttachment().size()));
String key = null;
byte[] keyArray = null;
Object value = null;
for (Map.Entry<String, Object> param : msg.getHeader().getAttachment()
.entrySet()) {
key = param.getKey();
keyArray = key.getBytes("UTF-8");
sendBuf.writeInt(keyArray.length);
sendBuf.writeBytes(keyArray);
value = param.getValue();
marshallingEncoder.encode(value, sendBuf);
}
if (msg.getBody() != null) {
marshallingEncoder.encode(msg.getBody(), sendBuf);
} else {
sendBuf.writeInt(0);
}
sendBuf.setInt(4, sendBuf.readableBytes() - 8);
}
}
@Sharable
public class MarshallingEncoder {
private static final byte[] LENGTH_PLACEHOLDER = new byte[4];
Marshaller marshaller;
public MarshallingEncoder() throws IOException {
marshaller = MarshallingCodecFactory.buildMarshalling();
}
protected void encode(Object msg, ByteBuf out) throws Exception {
try {
int lengthPos = out.writerIndex();
out.writeBytes(LENGTH_PLACEHOLDER);
ChannelBufferByteOutput output = new ChannelBufferByteOutput(out);
marshaller.start(output);
marshaller.writeObject(msg);
marshaller.finish();
out.setInt(lengthPos, out.writerIndex() - lengthPos - 4);
} finally {
marshaller.close();
}
}
}
public class NettyMessageDecoder extends LengthFieldBasedFrameDecoder {
MarshallingDecoder marshallingDecoder;
public NettyMessageDecoder(int maxFrameLength, int lengthFieldOffset,
int lengthFieldLength) throws IOException {
super(maxFrameLength, lengthFieldOffset, lengthFieldLength);
marshallingDecoder = new MarshallingDecoder();
}
@Override
protected Object decode(ChannelHandlerContext ctx, ByteBuf in)
throws Exception {
ByteBuf frame = (ByteBuf) super.decode(ctx, in);
if (frame == null) {
return null;
}
NettyMessage message = new NettyMessage();
Header header = new Header();
header.setCrcCode(frame.readInt());
header.setLength(frame.readInt());
header.setSessionID(frame.readLong());
header.setType(frame.readByte());
header.setPriority(frame.readByte());
int size = frame.readInt();
if (size > 0) {
Map<String, Object> attch = new HashMap<String, Object>(size);
int keySize = 0;
byte[] keyArray = null;
String key = null;
for (int i = 0; i < size; i++) {
keySize = frame.readInt();
keyArray = new byte[keySize];
frame.readBytes(keyArray);
key = new String(keyArray, "UTF-8");
attch.put(key, marshallingDecoder.decode(frame));
}
keyArray = null;
key = null;
header.setAttachment(attch);
}
if (frame.readableBytes() > 4) {
message.setBody(marshallingDecoder.decode(frame));
}
message.setHeader(header);
return message;
}
}
public class MarshallingDecoder {
private final Unmarshaller unmarshaller;
/**
* Creates a new decoder whose maximum object size is {@code 1048576} bytes.
* If the size of the received object is greater than {@code 1048576} bytes,
* a {@link StreamCorruptedException} will be raised.
*
* @throws IOException
*/
public MarshallingDecoder() throws IOException {
unmarshaller = MarshallingCodecFactory.buildUnMarshalling();
}
protected Object decode(ByteBuf in) throws Exception {
int objectSize = in.readInt();
ByteBuf buf = in.slice(in.readerIndex(), objectSize);
ByteInput input = new ChannelBufferByteInput(buf);
try {
unmarshaller.start(input);
Object obj = unmarshaller.readObject();
unmarshaller.finish();
in.readerIndex(in.readerIndex() + objectSize);
return obj;
} finally {
unmarshaller.close();
}
}
}
在这里我们用到了Netty的 LengthFieldBasedFrameDecoder解码器,它支持自动的TCP粘包和半包处理,只需要给出标识消息长度的字段偏移量和消息长度自身所占的字节数,Netty就能自动实现对半包的处理。对于业务解码器来说,调用父类LengthFieldBasedFrameDecoder的解码方法后,返回的就是整包消息或者为空。如果为空则说明是个半包消息,直接返回继续由I/O线程读取后续的码流。
2.3、握手和安全认证
握手的发起是在客户端和服务端TCP链路建立成功通道激活时,握手消息的接入和安全认证在服务端处理。下面看下具体实现首先开发一个握手认证的客户端 Channelhandler,用于在通道激活时发起握手请求,具体代码实现如下。
public class LoginAuthReqHandler extends ChannelHandlerAdapter {
private static final Log LOG = LogFactory.getLog(LoginAuthReqHandler.class);
/**
* Calls {@link ChannelHandlerContext#fireChannelActive()} to forward to the
* next {@link ChannelHandler} in the {@link ChannelPipeline}.
* <p/>
* Sub-classes may override this method to change behavior.
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(buildLoginReq());
}
/**
* Calls {@link ChannelHandlerContext#fireChannelRead(Object)} to forward to
* the next {@link ChannelHandler} in the {@link ChannelPipeline}.
* <p/>
* Sub-classes may override this method to change behavior.
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg)
throws Exception {
NettyMessage message = (NettyMessage) msg;
// 如果是握手应答消息,需要判断是否认证成功
if (message.getHeader() != null
&& message.getHeader().getType() == MessageType.LOGIN_RESP
.value()) {
byte loginResult = (byte) message.getBody();
if (loginResult != (byte) 0) {
// 握手失败,关闭连接
ctx.close();
} else {
LOG.info("Login is ok : " + message);
ctx.fireChannelRead(msg);
}
} else
ctx.fireChannelRead(msg);
}
private NettyMessage buildLoginReq() {
NettyMessage message = new NettyMessage();
Header header = new Header();
header.setType(MessageType.LOGIN_REQ.value());
message.setHeader(header);
return message;
}
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception {
ctx.fireExceptionCaught(cause);
}
}
第10~12行,当客户端跟服务端TCP三次握手成功之后,由客户端构造握手请求消息发送给服务端,由于采用IP白名单认证机制,因此,不需要携带消息体,消息体为空消息类型为“3:握手请求消息”。握手请求发送之后,按照协议规范,服务端需要返回握手应答消息。
第21~39行对握手应答消息进行处理,首先判断消息是否是握手应答消息,如果不是,直接透传给后面的 Channelhandler进行处理;如果是握手应答消息,则对应答结果进行判断,如果非0,说明认证失败,关闭链路,重新发起连接。
接着看服务端的握手接入和安全认证代码。
public class LoginAuthRespHandler extends ChannelHandlerAdapter {
private final static Log LOG = LogFactory.getLog(LoginAuthRespHandler.class);
private Map<String, Boolean> nodeCheck = new ConcurrentHashMap<String, Boolean>();
private String[] whitekList = {"127.0.0.1", "192.168.1.104"};
/**
* Calls {@link ChannelHandlerContext#fireChannelRead(Object)} to forward to
* the next {@link ChannelHandler} in the {@link ChannelPipeline}.
* <p>
* Sub-classes may override this method to change behavior.
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg)
throws Exception {
NettyMessage message = (NettyMessage) msg;
// 如果是握手请求消息,处理,其它消息透传
if (message.getHeader() != null
&& message.getHeader().getType() == MessageType.LOGIN_REQ
.value()) {
String nodeIndex = ctx.channel().remoteAddress().toString();
NettyMessage loginResp = null;
// 重复登陆,拒绝
if (nodeCheck.containsKey(nodeIndex)) {
loginResp = buildResponse((byte) -1);
} else {
InetSocketAddress address = (InetSocketAddress) ctx.channel()
.remoteAddress();
String ip = address.getAddress().getHostAddress();
boolean isOK = false;
for (String WIP : whitekList) {
if (WIP.equals(ip)) {
isOK = true;
break;
}
}
loginResp = isOK ? buildResponse((byte) 0)
: buildResponse((byte) -1);
if (isOK)
nodeCheck.put(nodeIndex, true);
}
LOG.info("The login response is : " + loginResp
+ " body [" + loginResp.getBody() + "]");
ctx.writeAndFlush(loginResp);
} else {
ctx.fireChannelRead(msg);
}
}
private NettyMessage buildResponse(byte result) {
NettyMessage message = new NettyMessage();
Header header = new Header();
header.setType(MessageType.LOGIN_RESP.value());
message.setHeader(header);
message.setBody(result);
return message;
}
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception {
cause.printStackTrace();
nodeCheck.remove(ctx.channel().remoteAddress().toString());// 删除缓存
ctx.close();
ctx.fireExceptionCaught(cause);
}
}
第2、3行分别定义了重复登录保护和IP认证白名单列表,主要用于提升握手的可靠性。第17~47行用于接入认证,首先根据客户端的源地址(127.0.0.1:12088)进行重复登录判断,如果客户端已经登录成功,拒绝重复登录,以防止由于客户端重复登录导致的句柄泄漏。随后通过 ChannelhandlerContext的 Channel接口获取客户端的 InetSocketaddress地址,从中取得发送方的源地址信息,通过源地址进行白名单校验,校验通过握手成功,否则握手失败。最后通过 buildResponse构造握手应答消息返回给客户端。
当发生异常关闭链路的时候,需要将客户端的信息从登录注册表中去注册,以保证后续客户端可以重连成功。
2.4、心跳检测机制
握手成功之后,由客户端主动发送心跳消息,服务端接收到心跳消息之后,返回心跳应答消息。由于心跳消息的目的是为了检测链路的可用性,因此不需要携带消息体。
客户端发送心跳请求消息的代码如下。
public class HeartBeatReqHandler extends ChannelHandlerAdapter {
private static final Log LOG = LogFactory.getLog(HeartBeatReqHandler.class);
private volatile ScheduledFuture<?> heartBeat;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg)
throws Exception {
NettyMessage message = (NettyMessage) msg;
// 握手成功,主动发送心跳消息
if (message.getHeader() != null
&& message.getHeader().getType() == MessageType.LOGIN_RESP
.value()) {
heartBeat = ctx.executor().scheduleAtFixedRate(
new HeartBeatReqHandler.HeartBeatTask(ctx), 0, 5000,
TimeUnit.MILLISECONDS);
} else if (message.getHeader() != null
&& message.getHeader().getType() == MessageType.HEARTBEAT_RESP
.value()) {
LOG.info("Client receive server heart beat message : ---> "
+ message);
} else
ctx.fireChannelRead(msg);
}
private class HeartBeatTask implements Runnable {
private final ChannelHandlerContext ctx;
public HeartBeatTask(final ChannelHandlerContext ctx) {
this.ctx = ctx;
}
@Override
public void run() {
NettyMessage heatBeat = buildHeatBeat();
LOG.info("Client send heart beat messsage to server : ---> "
+ heatBeat);
ctx.writeAndFlush(heatBeat);
}
private NettyMessage buildHeatBeat() {
NettyMessage message = new NettyMessage();
Header header = new Header();
header.setType(MessageType.HEARTBEAT_REQ.value());
message.setHeader(header);
return message;
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception {
cause.printStackTrace();
if (heartBeat != null) {
heartBeat.cancel(true);
heartBeat = null;
}
ctx.fireExceptionCaught(cause);
}
}
首先看第9行,当握手成功之后,握手请求 Handler会继续将握手成功消息向下透传,HeartBeatReqHandler接收到之后对消息进行判断,如果是握手成功消息,则启动无限循环定时器用于定期发送心跳消息。由于 NioEventLoop是一个 Schedule,因此它支持定时器的执行。心跳定时器的单位是毫秒,默认为5000,即每5秒发送一条心跳消息。
为了统一在一个 Handler中处理所有的心跳消息,因此第15~20行用于接收服务端发送的心跳应答消息,并打印客户端接收和发送的心跳消息。
心跳定时器 Heart BeatTask的实现很简单,通过构造函数获取 ChannelHandlerContext,构造心跳消息并发送。
服务端的心跳应答 Handler代码如下。
public class HeartBeatRespHandler extends ChannelHandlerAdapter {
private static final Log LOG = LogFactory.getLog(HeartBeatRespHandler.class);
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg)
throws Exception {
NettyMessage message = (NettyMessage) msg;
// 返回心跳应答消息
if (message.getHeader() != null
&& message.getHeader().getType() == MessageType.HEARTBEAT_REQ
.value()) {
LOG.info("Receive client heart beat message : ---> "
+ message);
NettyMessage heartBeat = buildHeatBeat();
LOG.info("Send heart beat response message to client : ---> "
+ heartBeat);
ctx.writeAndFlush(heartBeat);
} else
ctx.fireChannelRead(msg);
}
private NettyMessage buildHeatBeat() {
NettyMessage message = new NettyMessage();
Header header = new Header();
header.setType(MessageType.HEARTBEAT_RESP.value());
message.setHeader(header);
return message;
}
}
服务端的心跳 Handler非常简单,接收到心跳请求消息之后,构造心跳应答消息返回,并打印接收和发送的心跳消息。
心跳超时的实现非常简单,直接利用Netty的 ReadTimeoutHandler机制,当一定周期内(默认值50s)没有读取到对方任何消息时,需要主动关闭链路。如果是客户端,重新发起连接;如果是服务端,释放资源,清除客户端登录缓存信息,等待服务端重连。
2.5、断连重连
当客户端感知断连事件之后,释放资源,重新发起连接,具体代码实现看下面客户端代码。
首先监听网络断连事件,如果 Channel关闭,则执行后续的重连任务,通过 Bootstrap重新发起连接,客户端挂在 closeFuture上监听链路关闭信号,一旦关闭,则创建重连定时器,5s之后重新发起连接,直到重连成功。服务端感知到断连事件之后,需要清空缓存的登录认证注册信息,以保证后续客户端能够正常重连。
2.6、客户端代码
public class NettyClient {
private static final Log LOG = LogFactory.getLog(NettyClient.class);
private ScheduledExecutorService executor = Executors
.newScheduledThreadPool(1);
EventLoopGroup group = new NioEventLoopGroup();
public void connect(int port, String host) throws Exception {
// 配置客户端NIO线程组
try {
Bootstrap b = new Bootstrap();
b.group(group).channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch)
throws Exception {
ch.pipeline().addLast(
new NettyMessageDecoder(1024 * 1024, 4, 4));
ch.pipeline().addLast("MessageEncoder",
new NettyMessageEncoder());
ch.pipeline().addLast("readTimeoutHandler",
new ReadTimeoutHandler(50));
ch.pipeline().addLast("LoginAuthHandler",
new LoginAuthReqHandler());
ch.pipeline().addLast("HeartBeatHandler",
new HeartBeatReqHandler());
}
});
// 发起异步连接操作
ChannelFuture future = b.connect(
new InetSocketAddress(host, port),
new InetSocketAddress(NettyConstant.LOCALIP,
NettyConstant.LOCAL_PORT)).sync();
// 当对应的channel关闭的时候,就会返回对应的channel。
// Returns the ChannelFuture which will be notified when this channel is closed. This method always returns the same future instance.
future.channel().closeFuture().sync();
} finally {
// 所有资源释放完成之后,清空资源,再次发起重连操作
executor.execute(new Runnable() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
try {
connect(NettyConstant.PORT, NettyConstant.REMOTEIP);// 发起重连操作
} catch (Exception e) {
e.printStackTrace();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
}
/**
* @param args
* @throws Exception
*/
public static void main(String[] args) throws Exception {
new NettyClient().connect(NettyConstant.PORT, NettyConstant.REMOTEIP);
}
}
第15和16行增加了NettyMessageDecoder用于Netty消息解码,为了防止由于单条消息过大导致的内存溢出或者畸形码流导致解码错位引起内存分配失败,我们对单条消息最大长度进行了上限限制。第17和18行新增了Nety消息编码器,用于协议消息的自动编码。随后依次増加了读超时 Handler、握手请求 Handler和心跳消息 Handler。
第28行发起TCP连接的代码与之前的不同,这次我们绑定了本地端口,主要用于服务端重复登录保护,另外,从产品管理角度看,一般情况下不允许系统随便使用随机端口。
利用Netty的 ChannelPipeline和 Channelhandler机制,可以非常方便地实现功能解耦和业务产品的定制。例如本例程中的心跳定时器、握手请求和后端的业务处理可以通过不同的 Handler来实现,类似于AOP。通过 Handler chain的机制可以方便地实现切面拦截
和定制,相比于AOP它的性能更高。
2.7、服务端代码
相对于客户端,服务端的代码更简单一些,主要的工作就是握手的接入认证等,不用关心断连重连等事件。
public class NettyServer {
private static final Log LOG = LogFactory.getLog(NettyServer.class);
public void bind() throws Exception {
// 配置服务端的NIO线程组
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 100)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch)
throws IOException {
ch.pipeline().addLast(
new NettyMessageDecoder(1024 * 1024, 4, 4));
ch.pipeline().addLast(new NettyMessageEncoder());
ch.pipeline().addLast("readTimeoutHandler",
new ReadTimeoutHandler(50));
ch.pipeline().addLast(new LoginAuthRespHandler());
ch.pipeline().addLast("HeartBeatHandler",
new HeartBeatRespHandler());
}
});
// 绑定端口,同步等待成功
b.bind(NettyConstant.REMOTEIP, NettyConstant.PORT).sync();
LOG.info("Netty server start ok : "
+ (NettyConstant.REMOTEIP + " : " + NettyConstant.PORT));
}
public static void main(String[] args) throws Exception {
new NettyServer().bind();
}
}
与客户端不同的是,服务端 ChannelPipeline中除了Netty编码器和解码器以外,还有握手和接入认证的 LoginAuthRespHandler和心跳应答 HeartBeatRespHandler。