[自己做个游戏服务器二] 游戏服务器的基石-Netty全解析

2021-10-19 11:32:37 浏览数 (1)

Netty的大名我想做java 的基本都知道,因为他实在太出名了,现在很多著名的软件都是使用netty作为通讯基础,今天就聊聊Netty,希望能讲清楚,如果懒得看理论,可以直接拉到后面看Hello world。把代码抄下来,运行一下看看。

1、Netty 是什么

Netty是一个高性能、异步事件驱动的NIO框架,基于JAVA NIO提供的API实现。它提供了对TCP、UDP和文件传输的支持

作为一个异步NIO框架,Netty的所有IO操作都是异步非阻塞的,通过Future-Listener机制,用户可以方便的主动获取或者通过通知机制获得IO操作结果。

作为当前最流行的NIO框架,Netty在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,一些业界著名的开源组件也基于Netty的NIO框架构建。

Netty的官网 :https://netty.io/

2、Netty的优点

Netty的缺点就不说了,Netty的优点有很多:

  • 统一的 API,支持多种传输类型,阻塞和非阻塞的。
  • 功能强大,内置了多种解码编码器,支持多种协议,比如上图中的右侧黄色区域,通用的文本,二进制协议,google protobuf等。
  • 性能高,对比其他主流的NIO框架,Netty的性能最优。
  • 社区活跃,发现BUG会及时修复,迭代版本周期短,不断加入新的功能。
  • 简单而强大的线程模型。
  • 自带编解码器解决 TCP 粘包/拆包问题。
  • 自带各种协议栈,比如 SSL 。
  • 比直接使用 Java 核心 API 有更高的吞吐量、更低的延迟、更低的资源消耗和更少的内存复制,Zero-Copy Byte buffer。
  • 安全性不错,有完整的 SSL/TLS 以及 StartTLS 支持。
  • 成熟稳定,经历了大型项目的使用和考验,而且很多开源项目都使用到了 Netty, 比如我们经常接触的 Dubbo、RocketMQ ,Elasticsearch等等。

3、核心组件

3.1 Netty的线程模型

Netty的线程模型是比较重要的,理解了Netty的线程模型才能很好地使用Netty,Netty常见的线程模型有三种:

1.单线程模型

单线程模型,是指所有的 I/O 操作都在同一个 NIO 线程上面完成的,此时NIO线程职责包括:接收新建连接请求、读写操作等,在游戏开发中不会使用,也不合理,不展开。

2.Reactor多线程模型

第一种不合理,升级一下,一个接受连接的线程, 所有的 I/O 操作都在同一个 NIO 线程池上面完成,这种线程模型可以满足大部分情况,但是如果在连接的时候需要做一些验证,就会阻塞线程。性能会出问题,服务器

3.Reactor主从多线程模型

服务端用于接收客户端连接的不再是一个单独的 NIO 线程,而是一个独立的 NIO 线程池。Acceptor 接收到客户端 TCP连接请求并处理完成后(可能包含接入认证等),将新创建的 SocketChannel注 册 到 I/O 线 程 池(sub reactor 线 程 池)的某个I/O线程上, 由它负责SocketChannel 的读写和编解码工作。Acceptor 线程池仅仅用于客户端的登录、握手和安全认证,一旦链路建立成功,就将链路注册到后端 subReactor 线程池的 I/O 线程上,由 I/O 线程负责后续的 I/O 操作。

这也是在游戏开发中最常用的线程模型,需要掌握,下面这张图将核心技术都做了展示

3.2 EventLoopGroup

NioEventLoopGroup 核心实际上就是个线程池,是为了处理IO事件而存在的一个线程池。

一个 EventLoopGroup 包含一个或者多个 EventLoop;一个 EventLoop 在它的生命周期内只和一个 Thread 绑定;所有有 EnventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理;一个 Channel 在它的生命周期内只注册于一个 EventLoop;每一个 EventLoop 负责处理一个或多个 Channel;

我们实现服务端的时候,一般会初始化两个线程组:

  1. bossGroup :接收连接。
  2. workerGroup :负责具体的处理,交由对应的 Handler 处理

BossEventLoop 只负责处理连接,开销非常小,连接到来,马上将 SocketChannel 转发给 WorkerEventLoopGroup,WorkerEventLoopGroup 会由 next 选择其中一个 EventLoop 来将这 个SocketChannel 注册到其维护的 Selector 并对其后续的 IO 事件进行处理。

注:默认的线程数量 是当前cpu 数量 *2

代码语言:javascript复制
public abstract class MultithreadEventLoopGroup extends MultithreadEventExecutorGroup implements EventLoopGroup {
    private static final InternalLogger logger = InternalLoggerFactory.getInstance(MultithreadEventLoopGroup.class);
    private static final int DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt("io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2));

    protected MultithreadEventLoopGroup(int nThreads, Executor executor, Object... args) {
        super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, executor, args);
    }

3.3 Channel

Channel 表示一个和客户端建立的连接,相当于电话建立了连接,Channel是双向的通道。

通道(Channel)是双向的,可读可写。在 Java NIO 中,Buffer 是一个顶层接口,它的常用子类有:

  • FileChannel:用于文件读写
  • DatagramChannel:用于 UDP 数据包收发
  • ServerSocketChannel:用于服务端 TCP 数据包收发
  • SocketChannel:用于客户端 TCP 数据包收发

游戏中常用的通道类型有以下:

NioSocketChannel:异步非阻塞的客户端 TCP Socket 连接。

NioServerSocketChannel:异步非阻塞的服务器端 TCP Socket 连接。

常用的就是这两个通道类型,因为是异步非阻塞的。所以是首选。

3.4 option()与childOption()

首先说一下这两个的区别。

option()设置的是服务端用于接收进来的连接,也就是boosGroup线程。

childOption()是提供给父管道接收到的连接,也就是workerGroup线程。

搞清楚了之后,我们看一下常用的一些设置有哪些:

SocketChannel参数,也就是childOption()常用的参数:

SO_RCVBUF Socket参数,TCP数据接收缓冲区大小。TCP_NODELAY TCP参数,立即发送数据,默认值为Ture。SO_KEEPALIVE Socket参数,连接保活,默认值为False。启用该功能时,TCP会主动探测空闲连接的有效性。

ServerSocketChannel参数,也就是option()常用参数:

SO_BACKLOG Socket参数,服务端接受连接的队列长度,如果队列已满,客户端连接将被拒绝。默认值,Windows为200,其他为128。

3.5 inbound 和 outbound

inbound 表示 消息进入到服务器的路径,可以理解为输入

outBound 表示 消息输出到客户端的路径,可以理解为输出

代码语言:javascript复制
ChannelPipeline p = ...;
   p.addLast("1", new InboundHandlerA());
   p.addLast("2", new InboundHandlerB());
   p.addLast("3", new OutboundHandlerA());
   p.addLast("4", new OutboundHandlerB());
   p.addLast("5", new InboundOutboundHandlerX());

当一个输入事件来了之后,事件处理器的调用顺序为1,2,5当一个输出事件来了之后,事件处理器的处理顺序为5,4,3。(注意输出事件的处理器发挥作用的顺序与定义的顺序是相反的)

可以理解为对handler 进行压栈操作。

ChannelInboundHandlerAdapter处理器常用的事件有

  1. 注册事件 fireChannelRegistered。
  2. 连接建立事件 fireChannelActive。
  3. 读事件和读完成事件 fireChannelRead、fireChannelReadComplete。
  4. 异常通知事件 fireExceptionCaught。
  5. 用户自定义事件 fireUserEventTriggered。
  6. Channel 可写状态变化事件 fireChannelWritabilityChanged。
  7. 连接关闭事件 fireChannelInactive。

ChannelOutboundHandler处理器常用的事件有

  1. 端口绑定 bind。
  2. 连接服务端 connect。
  3. 写事件 write。
  4. 刷新时间 flush。
  5. 读事件 read。
  6. 主动断开连接 disconnect。
  7. 关闭 channel 事件 close。

还有一个类似的handler(),主要用于装配parent通道,也就是bossGroup线程。一般情况下,都用不上这个方法。

3.6 ByteBuf

ByteBuff有三种类型:

  1. 堆内存缓冲区(HeapByteBuf) 数据存储在堆中,可以认为就是我们常用的内存缓冲区
  2. 直接内存缓冲区(DirectByteBuf) 数据存储在内核中。由于数据本身就存储在内核中,因此使用网卡传输数据的时候直接可以传输,不需要多余的拷贝。因此,这也被称为零拷贝。 从硬盘中读取数据使用网卡发送出去,一般步骤如下: 数据从磁盘读取到内核的read buffer 数据从内核缓冲区拷贝到用户缓冲区 数据从用户缓冲区拷贝到内核的socket buffer 数据从内核的socket buffer拷贝到网卡接口(硬件)的缓冲区 使用内存缓冲区只需要两步: 调用transferTo,数据从文件由DMA引擎拷贝到内核read buffer接着DMA从内核read buffer将数据拷贝到网卡接口buffer
  1. 复合缓冲区(CompositeByteBuf) 复合缓冲区可以将多个ByteBuff组合

注:即内核功能模块运行在内核空间,而应用程序运行在用户空间

  • ByteBuf有读readerIndex和写writerIndex两个指针,用来标记“可读”、“可写”、“可丢弃”的字节
  • 调用write*方法写入数据后,写指针将会向后移动
  • 调用read*方法读取数据后,读指针将会向后移动
  • 写入数据或读取数据时会检查是否有足够多的空间可以写入和是否有数据可以读取
  • 写入数据之前,会进行容量检查,当剩余可写的容量小于需要写入的容量时,需要执行扩容操作
  • 扩容时有一个4MB的阈值,需要扩容的容量小于阈值或大于阈值所对应的扩容逻辑不同
  • clear等修改读写指针的方法,只会更改读写指针位置的值,并不会影响ByteBuf中已有的内容
  • setZero等修改字节值的方法,只会修改对应字节的值,不会影响读写指针的值以及字节的可读写状态

Netty又为我们提供了两个工具类:Pooled、Unpooled,分类用来分配池化的和未池化的ByteBuf,进一步简化了创建ByteBuf的步骤,只需要调用这两个工具类的静态方法即可。

3.7 使用 Netty 自带的解码器

  • LineBasedFrameDecoder : 发送端发送数据包的时候,每个数据包之间以换行符作为分隔,LineBasedFrameDecoder 的工作原理是它依次遍历 ByteBuf 中的可读字节,判断是否有换行符,然后进行相应的截取。
  • DelimiterBasedFrameDecoder : 可以自定义分隔符解码器,LineBasedFrameDecoder 实际上是一种特殊的 DelimiterBasedFrameDecoder 解码器。
  • FixedLengthFrameDecoder: 固定长度解码器,它能够按照指定的长度对消息进行相应的拆包,每个数据包的长度都是固定的。
  • LengthFieldBasedFrameDecoder:这个是后面服务器将要使用的解码器,下期会有实例

3.8 Netty 版本

  1. netty5 中使用了 ForkJoinPool,增加了代码的复杂度,但是对性能的改善却不明显
  2. 多个分支的代码同步工作量很大
  3. 作者觉得当下还不到发布一个新版本的时候
  4. 在发布版本之前,还有更多问题需要调查一下,比如是否应该废弃 exceptionCaught, 是否暴露EventExecutorChooser等等。

当前最新版本:4.1.68.Final

4、Hello World

4.1 官方的demo

官方的demo下载源码就可以在example下看到所有的demo,

gitHub 地址:https://github.com/netty/netty/tree/4.1/example/src/main/java/io/netty/example

4.2 idea 建立maven项目

我这里使用了idea,所以下面的截图也是用Idea。

4.2.1 File ->New 进入下面的界面

4.2.2 next 如下图填入自己的信息

4.2.3 等待一下,知道maven加载项目完成,如下结构

4.3 服务端代码

为了尽可能的仅仅展示Netty的代码,去掉那些花里胡哨的技术,只是简单的程序

代码语言:javascript复制
package com.xiangcai;

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;

/**
 * 服务端代码
 * @author 香菜
 */
public class GameServer {

    /**
     * 启动
     */
    public static void start() throws InterruptedException {
        EventLoopGroup boss = new NioEventLoopGroup(1);
        EventLoopGroup worker = new NioEventLoopGroup();
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.group(boss, worker)
                    .channel(NioServerSocketChannel.class)
                    //服务端可连接队列数,对应TCP/IP协议listen函数中backlog参数
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    //设置TCP长连接,一般如果两个小时内没有数据的通信时,TCP会自动发送一个活动探测数据报文
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    //将小的数据包包装成更大的帧进行传送,提高网络的负载,即TCP延迟传输
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childHandler(new NettyServerHandlerInitializer());

            ChannelFuture channelFuture = serverBootstrap.bind(8088).sync();
            System.out.println("服务器启动了");
            channelFuture.channel().closeFuture().sync();
        } finally {
            // 关闭线程
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }


    public static void main(String[] args) throws InterruptedException {
        start();
    }
}

下面看下Channel的初始化代码:

使用了2个解码器

代码语言:javascript复制
package com.xiangcai;

import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
/**
 * 服务端代码
 * @author 香菜
 */
public class NettyServerHandlerInitializer extends ChannelInitializer<Channel> {
    @Override
    protected void initChannel(Channel ch) throws Exception {
        ch.pipeline()
                //基于换行符的解码器
                .addLast(new LineBasedFrameDecoder(1024))
                //  强转字符串
                .addLast(new StringDecoder())
                //  业务处理
                .addLast(new NettyServerHandler());

    }
}

下面是业务的代码展示:

这里只是展示了简单的收发消息

代码语言:javascript复制
package com.xiangcai;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
/**
 * 服务端代码
 * @author 香菜
 */
public class NettyServerHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        super.channelActive(ctx);
    }
    /**
     * 超时处理 如果5秒没有接受客户端的心跳,就触发; 如果超过两次,则直接关闭;
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object obj) throws Exception {
        if (obj instanceof IdleStateEvent) {
            IdleStateEvent event = (IdleStateEvent) obj;
            if (IdleState.READER_IDLE.equals(event.state())) { // 如果读通道处于空闲状态,说明没有接收到心跳命令
                System.out.println("已经5秒没有接收到客户端的信息了");
            }
        } else {
            super.userEventTriggered(ctx, obj);
        }
    }

    /**
     * 业务逻辑处理
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        System.out.println("服务端收到信息:"   msg);
        String respStr = "收到了 "   System.getProperty("line.separator");
        ByteBuf resp = Unpooled.copiedBuffer(respStr.getBytes());
        ctx.writeAndFlush(resp);
    }
    /**
     * 异常处理
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        ctx.close();
    }
}

4.4 客户端代码

客户端启动代码

代码语言:javascript复制
package com.xiangcai;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import org.junit.Test;

/**
 * 服务端代码
 * @author 香菜
 */
public class TestClient {
    public static void clientStart() {
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();

            b.group(group)
                    .channel(NioSocketChannel.class)
                    .option(ChannelOption.TCP_NODELAY, true)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
                            ch.pipeline().addLast(new StringDecoder());
                            ch.pipeline().addLast(new ClientHandler());
                        };
                    });

            // 发起异步连接操作
            ChannelFuture f = b.connect("127.0.0.1", 8088).sync();
            // 等待客户端连接关闭
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 优雅退出,释放NIO线程组
            group.shutdownGracefully();
        }
    }

    public static void main(String[] args) {
        clientStart();
    }
}

客户端业务处理:

也是简单的收发消息

代码语言:javascript复制
package com.xiangcai;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;

import java.nio.charset.StandardCharsets;
/**
 * 服务端代码
 * @author 香菜
 */
public class ClientHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        System.out.println("建立连接");
        byte[] bytes = ("连接上了,开始说话"   System.getProperty("line.separator")).getBytes(StandardCharsets.UTF_8);
        ByteBuf message = Unpooled.buffer(bytes.length);
        message.writeBytes(bytes);
        ctx.writeAndFlush(message);
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        String body = (String) msg;
        System.out.println("收到信息 "   body);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        System.out.println("发生异常");
        ctx.close();
    }

}

5、总结

现在越来越多的游戏公司使用Java进行开发,Netty是绕不开的网络基础,搞懂Netty 很重要,Netty也很简单,只要记得线程模型,编解码,其他的都是细节问题,在开发的过程中进行学习也不迟,希望这篇文章能帮助你理解,如果你有疑问可以留言给我,一起学习交流。

完整项目源码下载地址:https://download.csdn.net/download/perfect2011/29665428

最后一张图结尾

写的好累,希望大佬们能点个赞,点个在看。

0 人点赞