2021SC@SDUSC
今天的这一篇博客主要对我上次留下的部分,也就是可靠性设计,进行详细解释。
这里主要涉及下面这几个方面:
- 握手和安全认证
- 心跳机制
- 重连机制
这几个方面其实还是比较大的,其中包括心跳机制其实是由一位组员专门进行源码分析的。这一部分的内容我还是主要参考老师推荐的《Netty权威指南》这本书,来进行学习分析。
目录
握手和安全认证
首先来看握手和安全认证。
握手的发起是在客户端和服务端TCP链路建立成功通道激活时,握手消息的接入和安全认证在服务端处理。
这里书中提供了两段实现的源码,我们先来分别看一下:
首先开发一个握手认证的客户端ChannelHandler,用于在通道激活时发起握手请求,具体代码实现如下:
public class LoginAuthReqHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(LoginAuthReqHandler.class);
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
LOGGER.info("通道激活,握手请求认证..................");
ctx.writeAndFlush(buildLoginReq());
}
@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 != ResultType.SUCCESS.value()) {
ctx.close();
} else {
System.out.println("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;
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.fireExceptionCaught(cause);
}
}
我们先关注channelActive:
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
LOGGER.info("通道激活,握手请求认证..................");
ctx.writeAndFlush(buildLoginReq());
}
当客户端跟服务端TCP三次握手成功之后,由客户端构造握手请求消息发送给服务端,由于采用IP白名单认证机制,因此,不需要携带消息体,消息体为空,消息类型为3即握手请求消息。握手请求发送之后,按照协议规范,服务端需要返回握手应答消息。
再来看第二个方法channelRead:
@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 != ResultType.SUCCESS.value()) {
ctx.close();
} else {
System.out.println("Login is OK : " + message);
ctx.fireChannelRead(msg);
}
} else {
ctx.fireChannelRead(msg);
}
}
这里对握手应答消息进行处理,首先判断消息是否是握手应答消息,如果不是,直接透传给后面的ChannelHandler进行处理,和我们本次要处理的消息没有关系。如果是握手应答消息,则对应答结果进行判断,如果非0,说明认证失败,那么就关闭链路,重新发起连接。
下面看服务端的握手接入和安全认证代码 LoginAuthRespHandler:
public class LoginAuthRespHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(LoginAuthRespHandler.class);
/**
* 考虑到安全,链路的建立需要通过基于IP地址或者号段的黑白名单安全认证机制,本例中,多个IP通过逗号隔开
*/
private Map<String, Boolean> nodeCheck = new ConcurrentHashMap<String, Boolean>();
private String[] whitekList = { "127.0.0.1", "192.168.56.1" };
@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)) {
LOGGER.error("重复登录,拒绝请求!");
loginResp = buildResponse(ResultType.FAIL);
} 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(ResultType.SUCCESS) : buildResponse(ResultType.FAIL);
if (isOK)
nodeCheck.put(nodeIndex, true);
}
LOGGER.info("The login response is : {} body [{}]",loginResp,loginResp.getBody());
ctx.writeAndFlush(loginResp);
} else {
ctx.fireChannelRead(msg);
}
}
/**
* 服务端接到客户端的握手请求消息后,如果IP校验通过,返回握手成功应答消息给客户端,应用层成功建立链路,否则返回验证失败信息。消息格式如下:
* 1.消息头的type为4
* 2.可选附件个数为0
* 3.消息体为byte类型的结果,0表示认证成功,1表示认证失败
*/
private NettyMessage buildResponse(ResultType result) {
NettyMessage message = new NettyMessage();
Header header = new Header();
header.setType(MessageType.LOGIN_RESP.value());
message.setHeader(header);
message.setBody(result.value());
return message;
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
nodeCheck.remove(ctx.channel().remoteAddress().toString());// 删除缓存
ctx.close();
ctx.fireExceptionCaught(cause); }
}
首先我们会看到两个数据结构:
private Map<String, Boolean> nodeCheck = new ConcurrentHashMap<String, Boolean>();
private String[] whitekList = { "127.0.0.1", "192.168.56.1" };
这两行是分别定义了重复登录保护和IP认证白名单列表,主要用于提升握手的可靠性。
再来看服务器端的 channelRead的实现:
首先根据客户端的源地址进行重复登录判断,如果客户端已经登录成功,拒绝重复登录,以防止由于客户端重复登录导致的其他问题。
随后通过ChannelHandlerContext 的 Channel接口获取客户端的InetSocketAddress地址,从中取得发送方的源地址信息,通过源地址进行白名单校验,校验通过握手成功,否则握手失败。
最后通过buildResponse构造握手应答消息返回给客户端。
当发生异常关闭链路的时候,会进入最后的异常处理代码,将客户端的信息从登录注册表中去注册,以保证后续客户端可以重连成功。
这里我在查阅资料的时候,在别的博客中发现了几个很好的图示表示:
心跳机制
什么是心跳消息呢?
心跳消息的目的是为了检测链路的可用性。
客户端收到登录成功的消息后,也会将消息传递下去,然后就会进行我们的心跳检测机制了。
握手成功之后,客户端主动发送心跳消息,服务端接收到心跳消息之后,返回心跳应答消息。由于心跳消息的目的是为了检测链路的可用性,因此不需要携带消息体。
下面我们先根据这个流程图,来阅读源代码:
客户端发送:
public class HeartBeatReqHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(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()) {
LOGGER.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();
LOGGER.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); }
}
如下客户端在登录成功后就会启动一个定时指定的任务,这里每个10秒钟就会发送一个心跳消息给服务端,其发送心跳的任务是一个内部类HeartBeatTask进行实现的:
private volatile ScheduledFuture<?> heartBeat;
heartBeat = ctx.executor().scheduleAtFixedRate(
new HeartBeatReqHandler.HeartBeatTask(ctx), 0, 5000,
TimeUnit.MILLISECONDS);
当握手成功之后,握手请求Handler会继续将握手成功消息向下透传,HeartBeatReqHandler接收到之后对消息进行判断,如果是握手成功消息,则启动无限循环定时器用于定期发送心跳消息,这也就是我们上面解释的那一部分。由于 NioEventLoop是一个schedule,这个我们组的另一位同学专门进行过任务队列的分析,因此它支持定时器的执行。心跳定时器的单位是毫秒,默认为5000,那么就是每5秒发送一条心跳消息。心跳定时器HeartBeatTask的实现很简单,通过构造函数获取ChannelHandlerContext,构造心跳消息并发送。
服务端处理:
public class HeartBeatRespHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(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()) {
LOGGER.info("Receive client heart beat message : ---> {} " ,message);
NettyMessage heartBeat = buildHeatBeat();
LOGGER.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 接收到心跳请求消息之后,构造心跳应答消息返回,并打印接收和发送的心跳消息。
这里我们涉及到一个心跳超时问题。
那么要是我们客户端和服务端之间的连接有问题,心跳报文发送有问题,那会怎么处理?
这里我们在客户端和服务端的实现上都添加了超时检测的ReadTimeoutHandler,该Handler是由Netty为我们提供的,如果在设置时间段内都没有数据读取了,那么就引发超时,然后关闭当前的Channel。
如果是客户端,重新发起连接。
如果是服务端,释放资源,清除客户端登录缓存信息,等待客户端重连。这里清楚缓存信息是很必要的,否则会由于登陆保护,而使重连失败。
我们先来看一下ReadTimeoutHandler,它都是IdleStateHandler的子类。
我们继续看一下IdleStateHandler。
首先看一下这个IdleStateHandler的参数:第一个参数设置未读时间,第二个参数设置为未写时间,第三个为都未进行操作的时间:
IdleStateHandler也是一种Handler,所以也是在启动时添加到ChannelPipeline管道中的,当有读写操作时消息在其中传递进行检查 。
其中的channelActive()方法在socket通道建立时被触发,channelActive()方法调用Initialize()方法,根据配置的readerIdleTime超时事件参数往任务队列taskQueue中添加定时任务task:
任务添加到对应线程EventLoopExecutor对应的任务队列taskQueue后,在对应线程的run()方法中循环执行。
主要步骤是用当前时间减去最后一次channelRead方法调用的时间判断是否空闲超时,如果空闲超时则创建空闲超时事件并传递到channelPipeline中,并且触发超时事件执行自定义userEventTrigger()方法,关闭连接:
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent){
IdleStateEvent event = (IdleStateEvent)evt;
if (event.state() == IdleState.WRITER_IDLE){
ctx.close();
}
} else {
super.userEventTriggered(ctx, evt);
}
}
重连机制
我们在客户端和服务端在一定在时间内未能读取到数据的话,就会关闭当前的Channel,关闭连接后。
当出现断连事件之后:
客户端:释放资源,重新发起连接。
首先监听网络断连事件,如果Channel关闭,则执行后续的重连任务,通过 Bootstrap重新发起连接,客户端挂在 closeFuture 上监听链路关闭信号,一旦关闭,则创建重连定时器,5s 之后重新发起连接,直到重连成功。
这部分我在源代码中没有翻到,因此直接讲书上的代码截图过来:
服务端:因为我们上面也解释过服务端是由登陆保护,不允许重复登陆的,因此服务端感知到断连事件之后,需要清空缓存的登录认证注册信息,以保证后续客户端能够正常重连。
小结
这一部分的内容分析到这里就先结束了,这一块主要分析了握手和安全认证。其中安全认证分析的比较少,只提到了那两个数据结构,对于心跳消息和心跳超时的代码分析的比较多,这块也和我最开始分析的EventLoop关联比较大。我在分析这部分的时候,感觉因为前面组件分析的比较仔细,因此后面的调用的时机部分,我还有一点原来是这样的感觉,在前面分析的那些比较原理,通过后面的应用才知道怎么用,优点在哪里。
最后感谢我们的指导老师,经常询问我们需不需要帮助,在我向老师求助的时候,老师也总能提醒我,让我知道应该分析的方向。最开始我打算先分析Netty的线程的框架,感觉这部分比较的关键,但是老师指出了这部分虽然比较关键,但是我们现在比较难掌握,因此还是建议我从小组件入手。我也慢慢找到了分析的感觉和方法。最后再次感谢戴老师和我们组的指导老师们对我们的帮助,让我们对这个源码从逐渐上手,到可以越来越熟练的分析。