使用Netty实现通信协议


通信协议从广义上区分,可以分为公有协议和私有协议。由于私有协议的灵活性,它往往会在某个公司或者组织内部使用,按需定制,也因为如此,升级起来会非常方便,灵活性好。绝大多数的私有协议传输层都基于TCP/IP,所以利用Netty的NIO TCP协议栈可以非常方便地进行私有协议的定制和开发。


其实其中所谓的实现也是比较的简单,说白了还是服务端和客户端之间进行的通信,这次我们实现一个自定义的通信协议,也是对我们之前学过的Netty知识做一个总结及梳理,我们一开始就是简单的使用Netty来实现服务端、客户端之间的简单文本通信,后来我们在慢慢地解决其粘包半包问题、以及序列化问题,再接着我们又利用Netty实现了Http服务器以及静态网页服务器,以及WebSocket在Netty中的使用。

在学习的过程中,我们渐渐发现其实我们上述一系列的demo的代码实现其实基本类似,就是完成服务端和客户端的通信,对其中的一些请求等进行不同的处理,是的,因为我们的Netty其实就是一个高性能网络通信框架呀,它就是用于解决这方面的问题的。


接来下我们借助于Netty来实现自己的通信协议,其实也就是将我们之前的代码进行相关的修改,也是在服务端和客户端之间进行通信,只不过我们这里会进行一些业务处理,以及添加一些功能,如我们之前介绍过的提供消息的编解码框架,可以实现POJO的序列化和反序列化;另外这里我们还可能做一些必要的控制,如客户端不允许重复登录,还有提供基于IP地址的白名单接入认证机制;以及链路的异常断连重连机制等等。
在这里插入图片描述

上图就是我们实现的简单示意图,我们在客户端和服务端连接建立后,就自动请求登录,登录成功后,每个5秒中客户端会向服务器发送一次心跳报文,然后我们的业务可以通过客户端向服务器发起请求,这里我们就简单在服务端将其请求打印出来,然后我们业务可调用客户端的关闭连接的api进行关闭,若用户未关闭,服务端由于网络问题或异常问题关闭了连接,客户端会进行不断的重新尝试连接。




项目结构及消息定义

明确了其要求后,我们首先来看一下项目的结构,以及其中需要用到的Maven依赖
在这里插入图片描述

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.38.Final</version>
</dependency>
<dependency>
    <groupId>com.esotericsoftware</groupId>
    <artifactId>kryo-shaded</artifactId>
    <version>4.0.2</version>
</dependency>
<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.13</version>
</dependency>

首先我们看一下我们在项目中消息的编解码框架使用到的Kryo,有关Netty中的序列化/反序列化的问题我们之前已经详细介绍过了,还介绍了ProtoBuf、MessagePack 以及Java本身自带的序列化,至于用哪一种就看个人的习惯啦,这里我们使用了之前在Netty中没有介绍过的Kryo,有不了解Kryo的小伙伴可查看 Kryo的基础使用


其中 KryoUtil 工具类我们以及介绍过了,其内容在之前的博客中也有,然后我们主要看一下其编码器——KryoEncoder 和解码器—— KryoDecoder

public class KryoEncoder extends MessageToByteEncoder<Message> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) throws Exception {
        byte[] bytes = KryoUtil.writeToByteArray(msg);
        out.writeBytes(bytes);
        ctx.flush();
    }
}
public class KryoDecoder extends MessageToMessageDecoder<ByteBuf> {

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
        if(msg == null) return;

        Input input = new Input(new ByteBufInputStream(msg));
        Kryo kryo = KryoUtil.getInstance();
        Object object = kryo.readClassAndObject(input);
        out.add(object);
    }
}

工具类介绍完成后,我们既然是实现一个自定义的通信协议,那么我们肯定要自定义消息头及消息体,消息头中我们可以定义一些我们需要用到的消息类型,消息体中就直接是Object类型

名称 类型 长度 描述
header Header 变长 消息头定义
body Object 变长 消息体定义
public final class Message {

    private Header header;
    private Object body;

	//省略Getter、Setter以及toString方法
	...
}

然后我们来看一下详细的消息头定义,其中包括以下几种类型,有些这里我们没有用到,只是写了一些基础的,还额外定义了一个attachment属性,用于以后的扩展,其中我们主要看一下type消息类型,我们还为此定义了一个枚举类型,列举了我们这次基础的消息类型

public final class Header {

    private int crcCode = 0xabef0101;	//Netty消息校验码
    private int length;         //消息长度
    private long sessionId;     //会话Id
    private byte type;          //消息类型
    private byte priority;      //消息优先级
    private Map<String, Object> attachment;     //附件
    
	//省略Getter、Setter以及toString方法
	...
}
public enum MessageType {

    SERVICE_REQUEST((byte) 0),      //业务请求消息
    SERVICE_RESPONSE((byte) 1),     //业务响应消息
    NO_RESPONSE((byte) 2),          //无需回复消息
    LOGIN_REQUEST((byte) 3),        //登录请求消息
    LOGIN_RESPONSE((byte) 4),       //登录响应消息
    HEARTBEAT_REQUEST((byte) 5),    //心跳请求消息
    HEARTBEAT_RESPONSE((byte) 6);   //心跳应答消息

    private byte value;

    MessageType(byte value) {
        this.value = value;
    }

    public byte value() {
        return value;
    }
}



登录请求及应答

项目结构、工具类,以及基础的消息定义都介绍完了,这里我们就从客户端来介绍一下该通信协议,首先其中的connet()方法就是我们经常见到的标准的Netty客户端启动代码,至于我们这里为什么需要实现Runnable接口,以及定义的连接、关闭属性和周期性执行的线程池这里我们后续会介绍
在这里插入图片描述


看到标准的Netty客户端启动代码,我们肯定就需要看看其添加了哪些ChannelHandler,其中的作用都在代码中有相关的注释,并且大部分我们都在前面介绍过了,如下:

public class ClientInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();

        //剥离接受到的消息长度,获取实际的消息数据
        pipeline.addLast("lengthFieldBasedFrameDecoder",
                new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2));
        //给发送的消息添加消息长度
        pipeline.addLast("lengthFieldPrepender", new LengthFieldPrepender(2));

        //反序列化,将字节数组转换为消息
        pipeline.addLast(new KryoDecoder());
        //序列化,将消息转化为字节数组
        pipeline.addLast(new KryoEncoder());

        //超时检测
        pipeline.addLast("readTimeoutHandler", new ReadTimeoutHandler(60, TimeUnit.SECONDS));

        //发起登录请求
        pipeline.addLast("loginRequestHandler", new LoginRequestHandler());

        //心跳请求
        pipeline.addLast("heartBeatRequestHandler", new HeartBeatRequestHandler());
    }
}

上述几个ChannelHandler首先我们来看一个发起登录请求的Handler,在客户端连接到服务器后,就会发起一次登录请求消息,就是很简单的发送一个我们自定义的消息类型过去,如下
在这里插入图片描述


然后我们再看看其客户端收到服务器的登录请求,返回一个消息给客户端,客户端的处理如下,我们在接收到消息响应后,首先会通过消息头中的消息类型判断一下,是否属于登录响应的消息,如果不是则进行向下一个Handler传递,若是我们就判断是否登录成功,若失败则关闭连接,若成功也是向下传递,为什么呢?因为成功了我们接下来会发起心跳检测的。

这里我们一定不要忘记了ctx.fireChannelRead(msg),为什么之前我们有的地方没有写呢?这个我们之前也有提及,因为我们如果继承的是SimpleChannelInboundHandler,就无需在特意写了,因为它已经帮我实现好了。
在这里插入图片描述


这里我们看完了客户端的对登录请求的发起及响应,我们再看看服务端对登录的处理,首先肯定是标准的Netty服务端的启动代码,以及添加的相关的ChannelHandler,如下:

public class NettyServer {

    public static void main(String[] args) throws InterruptedException {
        NettyServer server = new NettyServer();
        server.start(8888);
    }

    private void start(int port) throws InterruptedException {
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup work = new NioEventLoopGroup();
        ServerBootstrap serverBootstrap = new ServerBootstrap();

        try {
            serverBootstrap.group(boss, work)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .localAddress(new InetSocketAddress(port))
                    .childHandler(new ServerInitializer());

            ChannelFuture channelFuture = serverBootstrap.bind().sync();
            channelFuture.channel().closeFuture().sync();
        } finally {
            boss.shutdownGracefully();
            work.shutdownGracefully();
        }
    }
}
public class ServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();

        //日志打印,可打印发送/接受的数据,可自行开启
        //pipeline.addLast(new LoggingHandler(LogLevel.INFO));

        //剥离接受到的消息长度,获取实际的消息数据
        pipeline.addLast("lengthFieldBasedFrameDecoder",
                new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2));
        //给发送的消息添加消息长度
        pipeline.addLast("lengthFieldPrepender", new LengthFieldPrepender(2));

        //反序列化,将字节数组转换为消息
        pipeline.addLast(new KryoDecoder());
        //系列话,将消息转化为字节数组
        pipeline.addLast(new KryoEncoder());

        //超时检测
        pipeline.addLast("readTimeoutHandler", new ReadTimeoutHandler(60, TimeUnit.SECONDS));

        //响应登录请求
        pipeline.addLast("loginResponse", new LoginResponseHandler());

        //心跳应答
        pipeline.addLast("heartBeatResponseHandler", new HeartBeatResponseHandler());

        pipeline.addLast(new BusinessHandler());
    }
}

是不是非常的简单,然后查看其负责响应登录请求的 LoginResponseHandler ,其中简单定义了一个Map,用于判断用户是否重复登录的,还有一个数组,用以模拟用户的白名单。

然后我们介绍到消息后判断是否为登录请求消息,不是则向下传递,若是的话我们先看其是否已经登录过了,登录过了就返回一个消息(0登录失败,1登录成功)
在这里插入图片描述
在这里插入图片描述


如果该客户端还没有登录,我们就判断其是否在白名单内,若在则允许登录,并且将其添加到已登录的Map缓存之中,返回登录成功的消息,否则则返回登录失败的消息
在这里插入图片描述




心跳检测

客户端收到登录成功的消息后,也会将消息传递下去,然后就会进行我们的心跳检测机制了,如下客户端在登录成功后就会启动一个定时指定的任务,这里每个10秒钟就会发送一个心跳消息给服务端,其发送心跳的任务是一个内部类进行实现的
在这里插入图片描述
在这里插入图片描述

至于这里为什么可以进行周期执行任务,我们可以看到Netty的EventLoop、EventLoopGroup是实现了ScheduleExecutorService接口的,这个接口我们在并发编程中的线程池中进行介绍过
在这里插入图片描述


至于服务器接收到了心跳报文,其处理也是超级简单,就是返回一个心跳报文的响应回去就可以了

public class HeartBeatResponseHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        Message message = (Message) msg;

        if (message.getHeader() != null && message.getHeader().getType() == MessageType.HEARTBEAT_REQUEST.value()) {
            Header header = new Header();
            header.setType(MessageType.HEARTBEAT_RESPONSE.value());

            Message response = new Message();
            response.setHeader(header);

            System.out.println("服务端收到客户端的心跳报文,发送心跳应答报文...");
            ctx.writeAndFlush(response);
            ReferenceCountUtil.release(msg);
        } else {
            ctx.fireChannelRead(msg);
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.fireExceptionCaught(cause);
    }
}



超时检测

那么要是我们客户端和服务端之间的连接有问题,心跳报文发送有问题,那么会怎么样呢?这里我们在客户端和服务端的实现上都添加了超时检测的ReadTimeoutHandler,该Handler是由Netty为我们提供的,如果在设置时间段内都没有数据读取了,那么就引发超时,然后关闭当前的Channel。

与之对应的还有个WriteTimeoutHandler,用于控制数据输出的时候的超时,如果在设置时间段内都没有数据写了,那么就超时。它们都是IdleStateHandler的子类。
在这里插入图片描述


这里我们可以来看一下IdleStateHandler,首先看下这个IdleStateHandler的参数,如下第一个参数设置未读时间,第二个参数设置为未写时间,第三个为都未进行操作的时间
在这里插入图片描述

  • readerIdleTime: 读空闲超时时间设定,如果channelRead()方法超过readerIdleTime时间未被调用则会触发超时事件调用userEventTrigger()方法
  • writerIdleTime: 写空闲超时时间设定,如果write()方法超过writerIdleTime时间未被调用则会触发超时事件调用userEventTrigger()方法
  • allIdleTime: 所有类型的空闲超时时间设定,包括读空闲和写空闲
  • unit: 时间单位,包括时分秒等

其实IdleStateHandler也是一种Handler,也可以在启动时添加到ChannelPipeline管道中,当有读写操作时消息在其中传递进行检查

pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));

其中的channelActive()方法在socket通道建立时被触发,如下:
在这里插入图片描述

channelActive()方法调用Initialize()方法,根据配置的readerIdleTime,WriteIdleTIme等超时事件参数往任务队列taskQueue中添加定时任务task
在这里插入图片描述
在这里插入图片描述

上述实现的三种不同的Task任务都是差不多的,这里我们查看其中的一种,定时任务添加到对应线程EventLoopExecutor对应的任务队列taskQueue中,在对应线程的run()方法中循环执行,

主要步骤是用当前时间减去最后一次channelRead方法调用的时间判断是否空闲超时,如果空闲超时则创建空闲超时事件并传递到channelPipeline中;
在这里插入图片描述


IdleStateHandler心跳检测主要是通过向线程任务队列中添加定时任务,判断channelRead()方法或write()方法是否调用空闲超时,如果超时则触发超时事件执行自定义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);
    }
}

但是我们需要注意的就是在实际的运用中Netty通过IdleStateHandler实现最常见的心跳机制并不是一种双向心跳的PING-PONG模式,而是客户端发送心跳数据包,服务端接收心跳但不回复,因为如果服务端同时有上千个连接,心跳的回复需要消耗大量网络资源。

这样的话,如果服务端一段时间内内有收到客户端的心跳数据包则认为客户端已经下线,将通道关闭避免资源的浪费;在这种心跳模式下服务端可以感知客户端的存活情况,无论是宕机的正常下线还是网络问题的非正常下线,服务端都能感知到,而客户端不能感知到服务端的非正常下线。

要想实现客户端感知服务端的存活情况,需要进行双向的心跳;Netty中的channelInactive()方法是通过Socket连接关闭时挥手数据包触发的,因此可以通过channelInactive()方法感知正常的下线情况,但是因为网络异常等非正常下线则无法感知。




断连重连

在上述的超时检测中提到,我们在客户端和服务端在一定在时间内未能读取到数据的话,就会关闭当前的Channel,关闭连接后,我们在客户端就会进行关闭动作,但是由于其是发送的异常导致关闭,客户端会根据其之前介绍的normalClose标志属性判断这是一个异常关闭,这里就会不断的进行重连,直到成功连接。
在这里插入图片描述




业务处理

至于一开始说的为什么我们的Client客户端实现了Runnable接口,因为在其run方法中,我们会进行连接服务器,这样业务在调用我们的时候,这可以将其放置到一个线程中去执行,然后直接开始其业务逻辑
在这里插入图片描述

然后再客户端中,我们还提供了几个API方法,功能业务进行调用,如下
在这里插入图片描述


在业务实现的时候,还有一点需要特别注意,因为客户端连接服务端是需要时间的,我们在进行业务处理的时候,需要等待其连接成功,这就是通过了客户的的connected标记属性,也提供了相关的方法。

在业务调用时,这里我们使用了并发编程中提到的等待通知机制,所以我们在客户端的相关方法上也加上一些处理
在这里插入图片描述

完成后,我们就可以通过客户端给服务的发送消息了,这里我们在业务处理Handler就是简单的将其打印出来的
在这里插入图片描述



接来下我们可以来进行相关的测试,如下:
在这里插入图片描述
在这里插入图片描述


然后我们还可以进行断连重连机制,如我们可以在连接成功后,将服务端进行重启,或者我们将心跳报文发送时间延长,使其超时,测试结果如下
在这里插入图片描述


然后我们测试在正常的情况下进行发送消息,和关闭连接,如下:
在这里插入图片描述
在这里插入图片描述


在这里插入图片描述
在这里插入图片描述

发布了286 篇原创文章 · 获赞 12 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/newbie0107/article/details/104645726