Netty4.1.1实现群聊功能的代码详细解析

学习Netty已经有一段时间了,其实过程也很坎坷。一开始上手就看文档学习,发现根本看不懂,毕竟中间件类别的东西比之前学习WEB框架更具有挑战性。那怎么办呢?当然还是需要先熟悉Java NIO,如果通读(不要求深入理解)相关API文档,即当对NIO存在一个较为清晰的认识后,回过头来再次学习Netty就会发现容易理解很多,这可能就是事半功倍吧。

虽然Netty的第一个上手项目本来就是要实现一个客户端服务端通信业务,但是当我们可以靠自己敲出一个有群聊功能的小程序加强前面学习过的知识点的话还是相当不错的。

参考文章:https://waylau.com/netty-chat/

代码当然是要上的,我的大部门代码都存在注释,当然需要深入讲解的地方我会展开来讲以加深记忆。

虽然最新的Netty版本是5.x,但是考虑到Netty4的普及性以及可参考资料的丰富性,此处演示代码依赖环境为netty4.1.1

程序结构如下:

服务端业务逻辑处理程序:SimpleChatServerHandler

服务端通道管道初始化:SimpleChatServerInitial

服务端启动:SimpleChatServer

客户端业务逻辑处理程序:SimpleChatClientHandler

客户端通道管道初始化:SimpleChatClientInitial

客户端启动类:SimpleChatClient

SimpleChatServerHandler

package netty.in.action.chatRoom;

import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.util.concurrent.GlobalEventExecutor;

public class SimpleChatServerHandler extends SimpleChannelInboundHandler<String> {

    // 静态channel组用来存放客户端连接的channel
    public static ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

    // 处理客户端发来的消息
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, String s) throws Exception {
        Channel incoming = channelHandlerContext.channel();
        for (Channel channel : channels) {
            if (channel != incoming) {
                channel.writeAndFlush("[" + incoming.remoteAddress() + "]:" + s + "\n");
            } else {
                channel.writeAndFlush("you:" + s + "\n");
            }
        }
    }

    // 处理用户连接的方法
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        // 获取客户端连接的channel
        Channel incoming = ctx.channel();
        // 通知目前已经连接的所有用户新用户的连接
        for (Channel channel : channels) {
            channel.writeAndFlush("[USER]-" + incoming.remoteAddress() + "加入\n");
        }
        // 将channel加入channelGroup
        channels.add(incoming);
    }

    // 处理用户断开连接的方法
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        Channel leaving = ctx.channel();
        for (Channel channel : channels) {
            channel.writeAndFlush("[USER]-" + leaving.remoteAddress() + "离开\n");
        }
        channels.remove(leaving);
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        Channel channel = ctx.channel();
        System.out.println("[USER]-" + channel.remoteAddress() + "在线\n");
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        Channel channel = ctx.channel();
        System.out.println("[USER]-" + channel.remoteAddress() + "掉线\n");
    }

    // 异常处理
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        Channel channel = ctx.channel();
        System.out.println("[USER]-" + channel.remoteAddress() + "异常\n");
        cause.printStackTrace();
        // 直接关闭连接
        ctx.close();
    }
}

上面的代码中业务逻辑代码都很好理解,如果不是很懂建议看看文章开头的参考文章。

需要去理解的是ChannelGroup,这是什么?业务逻辑中将客户端的channel存在其中,并且依赖其完成广播行为。看看DefaultChannelGroup的源码,关注**add()**方法:

public boolean add(Channel channel) {
        ConcurrentMap<ChannelId, Channel> map = channel instanceof ServerChannel ? this.serverChannels : this.nonServerChannels;
        boolean added = map.putIfAbsent(channel.id(), channel) == null;
        if (added) {
            channel.closeFuture().addListener(this.remover);
        }
        if (this.stayClosed && this.closed) {
            channel.close();
        }
        return added;
    }

其实一目了然,底层是一个ConcurrentMap,这也就解释了为什么DefaultChannelGroup能做到线程安全。

判断是否是ServerChannel的子类,然后分开存放。

Map的put与putIfAbsent区别:

put在放入数据时,如果放入数据的key已经存在与Map中,最后放入的数据会覆盖之前存在的数据,

而putIfAbsent在放入数据时,如果存在重复的key,那么putIfAbsent不会放入值( 如果传入key对应的value已经存在,就返回存在的value,不进行替换。如果不存在,就添加key和value,返回null )。

接下来关注GlobalEventExecutor,初始化DefaultChannelGroup为什么要传入这样的实例?

GlobalEventExecutor是一种单线程单例执行器,它会自动启动,并且当任务队列中没有任务时挂起1秒钟后停止,该执行者无法调度大量任务 。

public static final GlobalEventExecutor INSTANCE;
static {
        SCHEDULE_QUIET_PERIOD_INTERVAL = TimeUnit.SECONDS.toNanos(1L);
        INSTANCE = new GlobalEventExecutor();
    }

执行器线程在netty是普遍存在的,主要用来处理task。这些task是多种多样的,比如write或者close。其实观察netty的源码就可以发现,许多方法走到最后总是绕不开一个executor。本来在netty中所有的channel都有一个executor,它是什么呢?

public EventExecutor executor() {
        return (EventExecutor)(this.executor == null ? this.channel().eventLoop() : this.executor);
    }

回到了起点嘿嘿。每个Channel绑定一个EventLoop不会被改变,很多Channel会共享同一个EventLoop。

回过头来看GlobalEventExecutor,字面意思是全局事件执行器。其实举一个例子就很好理解了,在广播时我们使用了如下代码:

for (Channel channel : channels) {
    if (channel != incoming) {
        channel.writeAndFlush("[" + incoming.remoteAddress() + "]:" + s + "\n");
    } else {
        channel.writeAndFlush("you:" + s + "\n");
    }
}

其实一行代码就可以搞定:

channels.writeAndFlush(s + "\n"); // 注意末尾的\n不要落了,后面会讲为什么

之所以要向上面那样写的复杂,主要是为了区分开发给别人和发给自己的消息。下面这一行代码的writeAndFlush这个task最终就是交给GlobalEventExecutor来执行的。

SimpleChatServerInitial

package netty.in.action.chatRoom;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.DelimiterBasedFrameDecoder;
import io.netty.handler.codec.Delimiters;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;


public class SimpleChatServerInitial extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel channel) throws Exception {
        channel.pipeline()
                .addLast("framer", new DelimiterBasedFrameDecoder(8192, Delimiters.lineDelimiter()))
                .addLast("encoder", new StringEncoder())
                .addLast("decoder", new StringDecoder())
                .addLast("handler", new SimpleChatServerHandler());

        System.out.println("[USER]-" + channel.remoteAddress() + "连接上");
    }
}

关于编码器解码器大家可以积极参考相关文档,其实它们就是一类特殊的handlers。

发送或接收消息后,Netty必须将消息数据从一种形式转化为另一种。接收消息后,需要将消息从字节码转成Java对象(由某种解码器解码);发送消息前,需要将Java对象转成字节(由某些类型的编码器进行编码)。这种转换一般发生在网络程序中,因为网络上只能传输字节数据。
有多种基础类型的编码器和解码器,要使用哪种取决于想实现的功能。要弄清楚某种类型的编解码器,从类名就可以看出,如“ByteToMessageDecoder”、“MessageToByteEncoder”,还有Google的协议“ProtobufEncoder”和“ProtobufDecoder”。
严格的说其他handlers可以做编码器和适配器,使用不同的Adapter classes取决你想要做什么。如果是解码器则有一个ChannelInboundHandlerAdapter或ChannelInboundHandler,所有的解码器都继承或实现它们。“channelRead”方法/事件被覆盖,这个方法从入站(inbound)通道读取每个消息。重写的channelRead方法将调用每个解码器的“decode”方法并通过ChannelHandlerContext.fireChannelRead(Object msg)
传递给ChannelPipeline中的下一个ChannelInboundHandler。
类似入站消息,当你发送一个消息出去(出站)时,除编码器将消息转成字节码外还会转发到下一个ChannelOutboundHandler。

真正需要注意的负责处理TCP通信中的粘包拆包问题的特殊的Decoder。

什么是粘包拆包?

假设客户端分别发送两个数据包D1,D2个服务端,但是发送过程中数据是何种形式进行传播这个并不清楚,分别有下列4种情况

  • 服务端一次接受到了D1和D2两个数据包,两个包粘在一起,称为粘包;
  • 服务端分两次读取到数据包D1和D2,没有发生粘包和拆包;
  • 服务端分两次读到了数据包,第一次读到了D1和D2的部分内容,第二次读到了D2的剩下部分,这个称为拆包;
  • 服务器分三次读到了数据部分,第一次读到了D1包,第二次读到了D2包的部分内容,第三次读到了D2包的剩下内容。

更新详细的解释请前往:https://blog.csdn.net/a953713428/article/details/67100345 (Netty学习(四)-TCP粘包和拆包)。

明白了粘包拆包,总是要找到解决方式:

上层应用协议为了对消息进行区分,一般采用如下4种方式

  • 消息长度固定,累计读取到消息长度总和为定长Len的报文之后即认为是读取到了一个完整的消息。计数器归位,重新读取。
  • 将回车换行符作为消息结束符。
  • 将特殊的分隔符作为消息分隔符,回车换行符是他的一种。
  • 通过在消息头定义长度字段来标识消息总长度。

这里使用的是DelimiterBasedFrameDecoder,它对应的是第三种解决方式。这也就是为什么前文每条消息的后面我们都要加上特殊字符**\n**的原因。如果想要指定其他特殊字符作为消息分割符可以如下:

ByteBuf delimiter = Unpooled.copiedBuffer("\t".getBytes());
pipeline.addLast("framer", new DelimiterBasedFrameDecoder(8192,delimiter)); 

其他解决方式请参考:https://blog.csdn.net/a953713428/article/details/68231119 (Netty学习(五)-DelimiterBasedFrameDecoder)

SimpleChatServer

package netty.in.action.chatRoom;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class SimpleChatServer {

    private int port;

    public SimpleChatServer(int port) {
        this.port = port;
    }

    public void run() {
        // 创建两个EventLoopGroup,一个用来处理客户端连接,一个用来处理消息
        EventLoopGroup connectGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            // 服务端引导
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(connectGroup, workerGroup)
                    // 指定IO方式
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new SimpleChatServerInitial())
                    // 指定最大连接数
                    .option(ChannelOption.SO_BACKLOG, 128)
                    // 会向两个小时没有发送过过消息的客户端发送一个活动探测客户端状态
                    .childOption(ChannelOption.SO_KEEPALIVE, true);

            System.out.println("服务端已经启动");
            ChannelFuture future = bootstrap.bind(port).sync();
            // 因为服务端启动后一直会阻塞,实际上这一句代码是不会执行到的
            future.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            connectGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
            System.out.println("服务端已经关闭");
        }
    }

    public static void main(String[] args) {
        new SimpleChatServer(8889).run();
    }
}

上面的代码很常规,需要解释的就是ChannelOption各个参数:

1、ChannelOption.SO_BACKLOG

​ ChannelOption.SO_BACKLOG对应的是tcp/ip协议listen函数中的backlog参数,函数listen(int socketfd,int backlog)用来初始化服务端可连接队列,服务端处理客户端连接请求是顺序处理的,所以同一时间只能处理一个客户端连接,多个客户端来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理,backlog参数指定了队列的大小

2、ChannelOption.SO_REUSEADDR

​ ChanneOption.SO_REUSEADDR对应于套接字选项中的SO_REUSEADDR,这个参数表示允许重复使用本地地址和端口,

​ 比如,某个服务器进程占用了TCP的80端口进行监听,此时再次监听该端口就会返回错误,使用该参数就可以解决问题,该参数允许共用该端口,这个在服务器程序中比较常使用,

​ 比如某个进程非正常退出,该程序占用的端口可能要被占用一段时间才能允许其他进程使用,而且程序死掉以后,内核一需要一定的时间才能够释放此端口,不设置SO_REUSEADDR就无法正常使用该端口。

3、ChannelOption.SO_KEEPALIVE

​ Channeloption.SO_KEEPALIVE参数对应于套接字选项中的SO_KEEPALIVE,该参数用于设置TCP连接,当设置该选项以后,连接会测试链接的状态,这个选项用于可能长时间没有数据交流的连接。当设置该选项以后,如果在两小时内没有数据的通信时,TCP会自动发送一个活动探测数据报文。

4、ChannelOption.SO_SNDBUF和ChannelOption.SO_RCVBUF

​ ChannelOption.SO_SNDBUF参数对应于套接字选项中的SO_SNDBUF,ChannelOption.SO_RCVBUF参数对应于套接字选项中的SO_RCVBUF这两个参数用于操作接收缓冲区和发送缓冲区的大小,接收缓冲区用于保存网络协议站内收到的数据,直到应用程序读取成功,发送缓冲区用于保存发送数据,直到发送成功。

5、ChannelOption.SO_LINGER

​ ChannelOption.SO_LINGER参数对应于套接字选项中的SO_LINGER,Linux内核默认的处理方式是当用户调用close()方法的时候,函数返回,在可能的情况下,尽量发送数据,不一定保证会发生剩余的数据,造成了数据的不确定性,使用SO_LINGER可以阻塞close()的调用时间,直到数据完全发送

6、ChannelOption.TCP_NODELAY

​ ChannelOption.TCP_NODELAY参数对应于套接字选项中的TCP_NODELAY,该参数的使用与Nagle算法有关,Nagle算法是将小的数据包组装为更大的帧然后进行发送,而不是输入一次发送一次,因此在数据包不足的时候会等待其他数据的到了,组装成大的数据包进行发送,虽然该方式有效提高网络的有效负载,但是却造成了延时,而该参数的作用就是禁止使用Nagle算法,使用于小数据即时传输,于TCP_NODELAY相对应的是TCP_CORK,该选项是需要等到发送的数据量最大的时候,一次性发送数据,适用于文件传输。

7、IP_TOS

IP参数,设置IP头部的Type-of-Service字段,用于描述IP包的优先级和QoS选项。

8、ALLOW_HALF_CLOSURE

Netty参数,一个连接的远端关闭时本地端是否关闭,默认值为False。值为False时,连接自动关闭;为True时,触发ChannelInboundHandler的userEventTriggered()方法,事件为ChannelInputShutdownEvent。

以上复制于:https://www.jianshu.com/p/975b30171352 (Netty ChannelOption参数详解)客户端业务逻辑

SimpleChatClientHandler

package netty.in.action.chatRoom;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

public class SimpleChatClientHandler extends SimpleChannelInboundHandler<String> {
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, String s) throws Exception {
        System.out.println(s);
    }
}

SimpleChatClientInitial

package netty.in.action.chatRoom;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.DelimiterBasedFrameDecoder;
import io.netty.handler.codec.Delimiters;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;


public class SimpleChatClientInitial extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel channel) throws Exception {
        channel.pipeline()
                .addLast("framer", new DelimiterBasedFrameDecoder(8192, Delimiters.lineDelimiter()))
                .addLast("encoder", new StringEncoder())
                .addLast("decoder", new StringDecoder())
                .addLast("handler", new SimpleChatClientHandler());
    }
}

SimpleChatClient

package netty.in.action.chatRoom;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class SimpleChatClient {

    private String host;
    private int port;

    public SimpleChatClient(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public void run() {

        EventLoopGroup group = new NioEventLoopGroup();
        try {
            // 客户端引导
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group)
                    .channel(NioSocketChannel.class)
                    .handler(new SimpleChatClientInitial());


            Channel channel = bootstrap.connect(host, port).sync().channel();
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
            while (true) {
                channel.writeAndFlush(bufferedReader.readLine() + "\r\n");
            }

        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            group.shutdownGracefully();
        }
    }

    public static void main(String[] args) {
        new SimpleChatClient("localhost", 8889).run();
    }
}

运行结果:

SERVER:

服务端已经启动
[USER]-/127.0.0.1:1445连接上
[USER]-/127.0.0.1:1445在线

[USER]-/127.0.0.1:2246连接上
[USER]-/127.0.0.1:2246在线

CLENT ONE:

[USER]-/127.0.0.1:2246加入
你好啊
you:你好啊

CLIENT TWO:

我是新人,多多关照
you:我是新人,多多关照

Netty4.1.1实现群聊功能的代码详细解析_第1张图片

你可能感兴趣的:(架构之道)