网络编程进阶-Netty从入门到精通

一、Netty基本概念

Netty 是一个基于 Java 的高性能、异步事件驱动的网络应用框架,主要用于构建高并发的网络服务和客户端。它屏蔽了复杂的底层网络通信细节(比如我们前面在网络编程基础所提到的各种IO模型的实现),使得我们可以更加专注于业务的开发。

Netty核心架构

先介绍Netty 的几个核心组件:

Channel(通道):代表与客户端或服务器的连接,负责数据的读写操作,是 Netty 网络通信的核心。

EventLoop(事件循环):Netty 的 I/O 线程模型,管理 Channel 的 I/O 操作。每个 EventLoop 都有自己的线程,负责处理多个 Channel 的事件。

ChannelPipeline(通道管道):Channel 的处理链,用于管理数据在入站和出站的流动。它会依次调用链上的 Handler 进行数据的处理和拦截。

Handler(处理器):分为入站和出站两类,处理 Channel 中的数据操作逻辑。开发者可以自定义 Handler 来编写具体的业务逻辑。

ByteBuf(数据缓冲区):Netty 的数据存储和处理类,优化了传统 ByteBuffer,更高效灵活,支持自动扩容、零拷贝等特性。

Netty中的Reactor实现

Netty是对Reactor模型的实现,其实现思路如下:

像这幅图就是Netty中对主动Reactor模式的实现,其BoosGroup就充当主Reactor,专门负责接受客户端连接;Worker充当从Reactor,专门负责处理读写 I/O 事件。

具体实现参考下图

Netty是基于事件驱动的,专业一手超清课程 比如:连接注册,连接激活;数据读取;异常事件等等,有了事件,就需要一个组件去监控事件的产生和事件的协调处理,这个组件就是EventLoop(事件循环/EventExecutor),在Netty 中每个Channel 都会被分配到一个 EventLoop。一个 EventLoop 可以服务于多个 Channel。每个EventLoop 会占用一个 Thread,同时这个 Thread 会处理 EventLoop 上面发生的所有 IO 操作和事件。

 Netty的工作流程

1)Netty抽象出两组线程池:BossGroup和WorkerGroup,每个线程池中都有EventLoop线程(可以是BIO NIO AIO)。BossGroup中的线程专门负责和客户端建立连接,WorkerGroup中的线程专门负责处理连接上的读写,EventLoopGroup相当于一个事件循环组,这个组中含有多少个事件循环

2)EventLoop表示一个不断循环的执行事件处理的线程,每个EventLoop都包含一个Selector,用于监听注册其上的Socket网络连接(Channel)

3)每个Boss EventLoop中循环执行以下三个步骤:

        ①select:轮询注册在其上的ServerSocketChannel的accept事件(OP_ACCEPT事件)

        ②processSelectedKeys:处理accept事件,与客户端建立连接,生成一个SocketChannel,并将其注册到某个Worker EventLoop上的Selector上

        ③runAllTasks:再去以此循环处理任务队列中的其他任务

4)每个Worker EventLoop中循环执行以下三个步骤:

        ①select:轮询注册在其上的SocketChannel的read/write事件(OP_READ/OP_WRITE事件)

        ②processSelectedKeys:在对应的SocketChannel上处理read/write事件

        ③runAllTasks:再去以此循环处理任务队列中的其他任务

5)在以上两个processSelectedkeys步骤中,会使用Pipeline(管道),Pipeline中引用了Channel,即通过Pipeline可以获取到对应的Channel,Pipeline中维护了很多的处理器(拦截处理器、过滤处理器、自定义处理器)。

二、HelloWorld

在这一部分,给大家演示一段用Netty编写一个最简单的客户端/服务器程序

服务端代码

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class NettyServer {
    private final int port;

    // 构造函数,传入端口号
    public NettyServer(int port) {
        this.port = port;
    }

    // 启动服务器的方法
    public void start() throws InterruptedException {
        // 用于接收客户端连接的主线程组
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        // 用于处理连接后的I/O操作的从线程组
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            // 创建并配置服务器启动辅助类
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(bossGroup, workerGroup) // 设置主从线程组
                    .channel(NioServerSocketChannel.class) // 指定NIO传输通道
                    .childHandler(new ChannelInitializer<SocketChannel>() { // 设置子处理器
                        @Override
                        public void initChannel(SocketChannel ch) {
                            // 获取通道的pipeline,并添加处理器
                            ChannelPipeline pipeline = ch.pipeline();
                            pipeline.addLast(new ServerHandler()); // 添加自定义处理器
                        }
                    });

            // 绑定端口并启动服务器
            ChannelFuture f = bootstrap.bind(port).sync(); 
            System.out.println("Server started on port " + port);
            // 等待关闭事件
            f.channel().closeFuture().sync();
        } finally {
            // 优雅关闭线程组
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new NettyServer(8080).start(); // 启动服务器
    }
}

// 服务器自定义处理器
class ServerHandler extends ChannelInboundHandlerAdapter {
    /**
     * 有数据时的回调
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        byte[] bytes = new byte[buf.readableBytes()];
        buf.readBytes(bytes);
        String content = new String(bytes, Charset.defaultCharset());
        System.out.println("收到的数据:"+content);
        super.channelRead(ctx, msg);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        //向客户端写回数据
        Channel channel = ctx.channel();
        ByteBuf buffer = ctx.alloc().buffer();
        buffer.writeBytes("hello,nettyclient".getBytes(StandardCharsets.UTF_8));
        channel.writeAndFlush(buffer);
        super.channelReadComplete(ctx);
    }

    // 捕获异常时触发
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close(); // 关闭通道
    }
}

客户端代码

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class NettyClient {
    private final String host;
    private final int port;

    // 构造函数,指定服务器的地址和端口号
    public NettyClient(String host, int port) {
        this.host = host;
        this.port = port;
    }

    // 启动客户端的方法
    public void start() throws InterruptedException {
        // 创建并配置客户端线程组
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            // 创建并配置客户端启动辅助类
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group) // 设置线程组
                    .channel(NioSocketChannel.class) // 指定NIO传输通道
                    .handler(new ChannelInitializer<SocketChannel>() { // 设置通道初始化
                        @Override
                        public void initChannel(SocketChannel ch) {
                            // 获取通道的pipeline,并添加处理器
                            ChannelPipeline pipeline = ch.pipeline();
                            pipeline.addLast(new ClientHandler()); // 添加自定义处理器
                        }
                    });

            // 连接服务器并等待连接完成
            ChannelFuture f = bootstrap.connect(host, port).sync();
            // 发送消息给服务器
            f.channel().writeAndFlush("Hello from Client");
            // 等待关闭事件
            f.channel().closeFuture().sync();
        } finally {
            // 优雅关闭线程组
            group.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new NettyClient("localhost", 8080).start(); // 启动客户端
    }
}

// 客户端自定义处理器
class ClientHandler extends ChannelInboundHandlerAdapter {
    // 读取服务器消息时触发
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf buffer = (ByteBuf) msg;
        byte[] bytes = new byte[buffer.readableBytes()];
        buffer.readBytes(bytes);
        String content = new String(bytes, StandardCharsets.UTF_8);
        System.out.println("客户端接收到数据:"+content);
        super.channelRead(ctx, msg);
    }

    // 捕获异常时触发
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close(); // 关闭通道
    }
}

三、Netty组件大全

Bootstrap(引导程序)

  • Bootstrap 是 Netty 用于简化 Channel 和 EventLoop 配置的启动引导类。
  • 主要分为 Bootstrap(用于客户端)和 ServerBootstrap(用于服务器端),通过 Bootstrap 配置连接参数、Handler、Channel 等,从而更方便地初始化 Channel 并启动。
  • 通过链式调用的方式配置 EventLoopGroup、Channel 类型、Pipeline、Handler 等,简化网络应用的创建过程。

在bootstrap里面就可以指定你需要绑定的PipeLine、Handler(代码里要专门编写好Handler等)。 

Channel(通道)

  • Channel 是 Netty 网络通信的核心抽象,用于表示一个连接,既可以是客户端到服务器的连接,也可以是服务器到客户端的连接。
  • 负责数据的读写操作,比如bind() connect() read() write(),封装了底层 Socket,并提供了异步 I/O 操作。
  • Netty 提供了多种 Channel 实现,例如 NioSocketChannelNioServerSocketChannelEpollSocketChannel 等,分别适用于不同的操作系统和通信模式。

EventLoop(事件循环)

  • EventLoop 是一个核心的线程模型,用于处理 Channel 上的 I/O 操作,是 Netty 的 I/O 线程抽象。
  • 每个 EventLoop 绑定到一个或多个 Channel,负责处理 Channel 的注册、读写和事件调度等操作。
  • EventLoopGroup 是一组 EventLoop,通常是线程池,负责管理多个 EventLoop 的生命周期。客户端和服务器端通常分别会有两个 EventLoopGroup:一个用于接收连接请求,另一个用于处理 I/O 操作。

EventLoopGroup核心线程数有多大?其源代码默认值是cpu核数*2,当然我们可以自己设定,在构造函数传入即可

PipeLine&Handler

ChannelPipeline(通道管道)

  • ChannelPipeline 是 Netty 中 Channel 的责任链,用于管理 Channel 上的数据流动。
  • 负责 Channel 的入站和出站事件的拦截和处理。所有的数据事件都会经过 ChannelPipeline 中的各个 Handler 处理。
  • 每个 Channel 都会有一个独立的 ChannelPipeline,并会维护一个 Handler 链表,通过链式调用的方式实现入站和出站事件的拦截和处理。

ChannelHandler(通道处理器)

  • ChannelHandler 是 Netty 中用于处理 Channel 中 I/O 事件的核心接口,分为 ChannelInboundHandlerChannelOutboundHandler,分别处理入站和出站事件。
  • 入站处理器(InboundHandler):处理来自客户端的读、连接等事件,如 channelReadchannelActive 等。
  • 出站处理器(OutboundHandler):处理写、连接等事件,并向客户端发送数据,如 writeflush 等。
  • 开发者可以继承 ChannelHandlerAdapter 来实现自定义的业务逻辑,并添加到 ChannelPipeline 中。 

ChannelPipeline 提供了 ChannelHandler 链的容器。以服务端程序为例,客户端发送过来的数据要接收,读取处理,我们称数据是入站的,需要经过一系列Handler处理后;如果服务器想向客户端写回数据,也需要经过一系列Handler处理,我们称数据是出站的。

在processSelectedkeys步骤中,会使用Pipeline(管道),Pipeline中引用了Channel,即通过Pipeline可以获取到对应的Channel,Pipeline中维护了很多的处理器(拦截处理器、过滤处理器、自定义处理器)这里串联了上面提到的

还有一个常用到的叫做ChannelHandlerContext,那他和ChannelHandler有什么关系和区别呢

ChannelHandlerContext 是与 ChannelHandler 关联的上下文对象,用于在处理过程中传递状态和操作。

它提供了对当前 Channel 的引用,并允许访问其上下游的处理器。通过ChannelHandlerContext,可以调用方法向下一个处理器发送事件,或者从上一个处理器接收事件。

每个 ChannelHandler 都会有一个对应的 ChannelHandlerContext,后者用于管理 Channel 的生命周期和事件流动。ChannelHandlerContext 是在 ChannelPipeline 中维护的,表示每个处理器的上下文。

ChannelHandler的分类

inbound入站事件处理顺序(方向)是由链表的头到链表尾,outbound事件的处理顺序是由链表尾到链表头。
inbound入站事件由netty内部触发,最终由netty外部的代码消费。
outbound事件由netty外部的代码触发,最终由netty内部消费。

一个问题,对于编写Netty数据入站处理器,可以选择继承ChannelInboundHandlerAdapter,也可以选择继承SimpleChannelInboundHandler<I>,区别是什么?

继承SimpleChannelInboundHandler需要重写channelRead0方法,且可以通过泛型指定msg类型

SimpleChannelInboundHandler在接收到数据后会自动release掉数据占用的Bytebuffer资源

服务端异步处理数据,服务端想把客户端发送来的数据 再写回等等场景下最好不要继承SimpleChannelInboundHandler

客户端推荐使用SimpleChannelInboundHandler,服务端看场景

ByteBuf(数据缓冲区)

  • ByteBuf 是 Netty 的字节缓冲区,用于数据的存储和操作,Java NIO 提供了ByteBuffer 作为它的字节容器,但是这个类使用起来过于复杂,而且也有些繁琐。Netty使用ByteBuf来替代ByteBuffer
  • 从结构上来说,ByteBuf 由一串字节数组构成。数组中每个字节用来存放信息,ByteBuf提供了两个索引,一个用于读取数据(readerIndex ),一个用于写入数据(writerIndex)。这两个索引通过在字节数组中移动,来定位需要读或者写信息的位置。而JDK的ByteBuffer只有一个索引,因此需要使用flip方法进行读写切换
  • 提供了自动扩容、读写指针等机制,避免了 JDK NIO 中 ByteBuffer 手动管理容量、切换读写模式的复杂操作。
  • 支持池化(池化 ByteBuf 可以重用内存,减少垃圾回收)和零拷贝(CompositeByteBuf 支持多个 ByteBuf 组合为一个,避免数据复制)。

ByteBuf的三个指针

readerIndex:指示读取的起始位置, 每读取一个字节, readerIndex自增累加1。 如果readerIndex 与writerIndex 相等,ByteBuf不可读。

writerIndex:指示写入的起始位置, 每写入一个字节, writeIndex自增累加1。如果增加到 writerIndex 与capacity()容量相等,表示 ByteBuf 已经不可写,但是这个时候,并不代表不能往 ByteBuf 中写数据了, 如果发现往ByteBuf 中写数据写不进去的话,Netty 会自动扩容 ByteBuf,直到扩容到底层的内存大小为 maxCapacity

maxCapacity:指示ByteBuf可以扩容的最大容量,如果向ByteBuf写入数据时,容量不足,可以进行扩容的最大容量

ByteBuf常用API

容量相关的API

capacity():表示 ByteBuf 底层占用了多少字节的内存(包括丢弃的字节、可读字节、可写字节),不同的底层实现机制有不同的计算方式。

maxCapacity(): ByteBuf 底层最大能够占用多少字节的内存,当向 ByteBuf 中写数据的时候,如果发现容量不足,则进行扩容,直到扩容到 maxCapacity,超过这个数,就抛异常。

readableBytes() 与 isReadable():readableBytes() 表示 ByteBuf 当前可读的字节数,它的值等于
writerIndex-readerIndex,如果两者相等,则不可读,isReadable() 方法返回 false

writableBytes()、 isWritable() 、maxWritableBytes():writableBytes() 表示 ByteBuf 当前可写的字节数,它的值等于 capacity()-writerIndex,如果两者相等,则表示不可写,isWritable() 返回 false,但是这个时候,并不代表不能往 ByteBuf 中写数据了, 如果发现往ByteBuf 中写数据写不进去的话,Netty 会自动扩容 ByteBuf,直到扩容到底层的内存大小为 maxCapacity,而 maxWritableBytes() 就表示可写的最大字节数,它的值等于maxCapacity-writerIndex

读写指针相关API

readerIndex() 与 readerIndex(int readerIndex):前者表示返回当前的读指针 readerIndex, 后者表示设置读指针

writeIndex() 与 writeIndex(int writerIndex):前者表示返回当前的写指针 writerIndex, 后者表示设置写指针

markReaderIndex() 与markWriterIndex():表示把当前的读指针/写指针保存起来,操作形式为:
markedReaderIndex = readerIndex         markedWriterIndex = writerIndex

读写操作相关API

writeBytes(byte[] src):表示把字节数组 src 里面的数据全部写到 ByteBuf,src字节数组大小的长度通常小于等于writableBytes()

readBytes(byte[] dst):把 ByteBuf 里面的数据全部读取到 dst,dst 字节数组的大小通常等于 readableBytes()

writeByte(int value)、readByte():writeByte() 表示往 ByteBuf 中写一个字节,而 readByte() 表示从 ByteBuf 中读取一个字节,类似的 API 还有 writeBoolean()、writeChar()、writeShort()、writeInt()、writeLong()、writeFloat()、writeDouble() 与 readBoolean()、readChar()、readShort()、readInt()、readLong()、readFloat()、readDouble() 等等

丢弃、清理,释放API

discardReadBytes():丢弃已读取的字节空间,可写空间变多

clear():重置readerIndex 、 writerIndex 为0,需要注意的是,重置并没有删除真正的内容

release():真正去释放bytebuf中的数据

ReferenceCountUtil.release(buf):工具方法,内部还是调用release()

三类不同的ByteBuf

HeapByteBuf(堆缓冲区):内存分配在jvm堆,分配和回收速度比较快,可以被JVM自动回收,缺点是,如果进行socket的IO读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到内核Channel中,性能会有一定程度的下降。由于在堆上被 JVM 管理,在不被使用时可以快速释放。可以通过 ByteBuf.array() 来获取 byte[] 数据。

DirectByteBuf(直接缓冲区) :内存分配的是堆外内存(系统内存),相比堆内存,它的分配和回收速度会慢一些, 但是将它写入或从Socket Channel中读取时,由于减少了一次内存拷贝,速度比堆内存块。
CompositeByteBuf(复合缓冲区):顾名思义就是将两个不同的缓冲区从逻辑上合并,让使用更加方便。
Netty默认使用的是DirectByteBuf,如果需要使用HeapByteBuf模式,则需要进行系统参数的设置 

关于这里的JVM堆内堆外内存的理解

堆内存:由 JVM 自动管理,使用垃圾回收机制进行内存的分配和回收。但是访问速度较慢,受垃圾回收影响,可能导致性能波动。

堆外内存:不由 JVM 管理,开发者需要手动管理其分配和释放。但是相比起来减少了内存拷贝,特别是在 I/O 操作时性能更佳,适合高性能需求场景。

ByteBuf的分配器

通常在实际开发中,我们一般都通过ChannelHandlerContext(当然也可以通过Channel)来获取ByteBuf的分配器

再通过分配器来获取ByteBuf实例

BufAllocator allocator = channelHandlerContext.alloc();
ByteBuf buf = channelHandlerContext.alloc().buffer();

Netty 提供了两种 ByteBufAllocator 的实现,分别是:

PooledByteBufAllocator :实现了 ByteBuf 的对象的池化,提高性能减少并最大限度地减少内存碎片,池化思想 通过预先申请一块专用内存地址作为 内存池 进行管理,从而不需要每次都进行分配和释放
UnpooledByteBufAllocator :没有实现对象的池化,每次会生成新的对象实例

Netty默认使用了PooledByteBufAllocator,但可以通过引导类设置非池化模式

ByteBuf的释放

引用计数:

        ByteBuf如果采用的是堆缓冲区模式的话,可以由GC回收,但是如果采用的是直接缓冲区,就不受GC的管理,就得手动释放,否则会发生内存泄露,Netty自身引入了引用计数,提供了ReferenceCounted接口,当对象的引用计数>0时要保证对象不被释放,当为0时需要被释放

那对于直接缓冲区的ByteBuf如何释放呢?

①手动释放,就是在使用完成后,调用ReferenceCountUtil.release(byteBuf); 进行释放,这种方式的弊端就是一旦忘记释放就可能会造成内存泄露
②自动释放有三种方式,分别是:

入站的TailHandler(TailContext)、继承SimpleChannelInboundHandler、HeadHandler(HeadContext)的出站释放
        TailContext:Inbound流水线的末端,如果前面的handler都把消息向后传递最终由TailContext释放该消息,需要注意的是,如果没有进行向下传递,是不会进行释放操作的
        SimpleChannelInboundHandler:自定义的InboundHandler继承自SimpleChannelInboundHandler,在SimpleChannelInboundHandler中自动释放
        HeadContext:outbound流水线的末端,出站消息一般是由应用所申请,到达最后一站时,经过一轮复杂的调用,在flush完成后终将被release掉

总结下来,到底什么时候采取什么情况呢?

对于入站的消息:

1)对原消息不做处理的话,将依次调用 ctx.fireChannelRead(msg)把原消息往下传,如果能到TailContext,那不用做什么释放,它会自动释放

2)要将原消息转化为新的消息并调用 ctx.fireChannelRead(newMsg)往下传的话,那需要将原消息release掉

3)如果已经不再调用ctx.fireChannelRead(msg)传递任何消息,需要把原消息release掉

对于出站的消息:

则无需用户关心,消息最终都会走到HeadContext,flush之后会自动释放。

Future&Promise

Future

Netty 采用非阻塞的 I/O 模型,许多操作(如发送数据、连接等)都是异步的。Future 允许你在不阻塞主线程的情况下处理这些操作的结果。

每个异步操作都可以返回一个Future。他里面提供了一系列的API,比如检查这个任务是否执行完成等

同时,还可以为这个Future添加监听器,当Future执行到某个状态时(比如已完成)就回调某段程序

ChannelFuture future = channel.writeAndFlush(message);
future.addListener(new ChannelFutureListener() {
    @Override
    public void operationComplete(ChannelFuture f) throws Exception {
        if (f.isSuccess()) {
            System.out.println("执行成功");
        } else {
            System.err.println("执行失败,原因:" + f.cause());
        }
    }
});

Promise

Netty的Future,只是增加了监听器。整个异步的状态,是不能进行设置和修改的,于是Netty的 Promise接口扩展了Netty的Future接口,可以设置异步执行的结果。在IO操作过程,如果顺利完成、或者发生异常,都可以设置Promise的结果,并且通知Promise的Listener们。

public void asyncOperationWithPromise(Channel channel) {
    Promise<String> promise = channel.eventLoop().newPromise(); // 创建可写的Promise

    // 模拟异步操作
    channel.eventLoop().execute(() -> {
        try {
            // 模拟执行异步操作
            Thread.sleep(1000);
            // 主动设置Promise的结果,相当于“写入”成功状态
            promise.setSuccess("操作成功");  
        } catch (Exception e) {
            // 主动设置Promise的异常,相当于“写入”失败状态
            promise.setFailure(e);  
        }
    });

    // 添加监听器,处理Promise的结果(相当于读取操作)
    promise.addListener((Future<String> future) -> {
        if (future.isSuccess()) {
            System.out.println("异步操作结果:" + future.get());  // 读取成功结果
        } else {
            System.err.println("异步操作失败:" + future.cause());  // 读取失败原因
        }
    });
}

在listener中拿到的future结果就是promise setSuccess后的值

编码器和解码器(Encoder/Decoder)

  • EncoderDecoder 是 Netty 的编码解码工具,分别用于将 POJO 对象转为二进制数据,以及将二进制数据解析为 POJO 对象。
  • 常见的编码器和解码器包括 StringDecoderStringEncoderProtobufDecoderHttpObjectDecoder 等,可用于多种协议的数据传输。
  • 编解码器通常被添加到 ChannelPipeline 中的特定位置,在处理入站和出站事件时自动进行编码和解码。

 在Netty中编解码器有两类,分别是:一次编解码器、二次编解码器,接下来分别介绍

一次编解码器

一次编解码器主要就是解决粘包拆包问题,那在讨论粘包拆包问题之前,我们要先来聊一聊什么是TCP的粘包拆包。

TCP 粘包,拆包

什么是粘包?

        ①当发送方每次写入到TCP发送端缓冲区的数据小于缓冲区大小,那么多个数据会合并成一条数据发送出去,导致粘包产生

        ②当接收方读取套接字缓冲区数据不够及时,那么此时接收端缓冲区就会累积了多个数据包,导致粘包产生

什么是拆包?

        ①当发送方 写入到TCP发送端缓冲区的数据大于缓冲区大小,那么这一个数据包就会被拆成多个再分别发送出去,导致拆包产生

为什么会产生粘包拆包?

因为TCP协议是面向字节流的,它对于每一条消息只把它看成都一个一个字节为单位的,只对每个字节做一个序号编排,所以是看不到消息的边界,当有缓冲区足够的字节了,那就该发出去了,当缓冲区没有足够的字节,可以等后续的一些字节来组成一个最小发送的数据包大小

还有一点就是TCP采用了Nagle算法,这个确实会导致粘包,其核心思想就是:当网络中有未被确认的数据包时,新的小数据包不会立即发送,而是先缓存在发送端,等待前一个数据包的确认。只有当收到前一个数据包的确认(ACK)或者缓冲区内的数据量达到 MSS(最大报文长度)时,才会将数据发送出去。

如何解决粘包拆包?

解决TCP粘包,拆包问题的根本:找出消息的边界

常见方案:

1、使用固定长度的报文:即每个数据包的长度固定,接收端可以根据长度将报文内容准确拆分开。但这种方法浪费了数据包中多余的空间,且不适合变长数据包。

2、使用分隔符:在每个数据包后添加特定的分隔符(如 \n),接收端通过分隔符来判断每个数据包的边界。这适用于文本协议,但对二进制协议不适用,且需要避免分隔符出现在数据内容中。

3、在消息头中定义长度字段:发送时在数据包头部加入一个字段,表示数据包的总长度,接收端通过读取该字段来判断数据包的边界。这种方式较为通用,也是 Netty 中常用的解决方案。

Netty的三种一次解码器

 固定长度FixedLengthFrameDecoder

// FixedLengthFrameDecoder 会将每 64 字节的数据作为一条消息进行处理。
pipeline.addLast(new FixedLengthFrameDecoder(64));

 分隔符DelimiterBasedFrameDecoder

// DelimiterBasedFrameDecoder 会以 # 将数据流进行拆分
ByteBuf delimiter = Unpooled.copiedBuffer("#".getBytes());
pipeline.addLast(new DelimiterBasedFrameDecoder(1024, delimiter));
pipeline.addLast(new StringDecoder());

 首部字段存消息长度大小LengthFieldBasedFrameDecoder

其构造参数:

  • maxFrameLength:单条消息的最大长度。
  • lengthFieldOffset:长度字段的偏移量,即在消息头中从哪里开始读取长度信息。
  • lengthFieldLength:长度字段的字节数。
  • lengthAdjustment:长度字段的补偿值(一般用于调整头长度和实际数据包长度的差异)。
  • initialBytesToStrip:表示接收端需要跳过的字节数,用于剥离消息头。
/*
    这里的配置表示:
    消息最大长度为 1024 字节,
    长度字段偏移量为 0 字节(表示长度字段在消息的最开始位置),
    长度字段长度为 4 字节,
    且跳过头部 4 字节(即长度字段本身)后,直接读取消息内容。
*/
pipeline.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4));

二次编解码器

        我们把解决半包粘包问题的常用三种解码器叫一次解码器,其作用是将原始数据流(可能会出现粘包和拆包的数据流)转换为用户数据(ByteBuf中存储),但仍然是字节数据,所以我们需要二次解码器将字节数组转换为java对象,或者将将一种格式转化为另一种格式,方便上层应用程序使用。

        一次解码器继承自:ByteToMessageDecoder;二次解码器继承自:MessageToMessageDecoder;但他们的本质都是继承ChannelInboundHandlerAdapter

所以说到这里,可以把二次编解码器理解为序列化与反序列化的机制

常用的二次编解码我们在这里介绍四种

StringDecoder、StringEncoder     ProtobufDecoder、ProtobufEecoder

StringDecoder 是一个将字节数据解码为字符串的解码器。

StringEncoder 是将字符串数据编码为字节的编码器。

ProtobufDecoder 是将 Protocol Buffers 格式的字节数据解码为 Java 对象的解码器。

ProtobufEncoder 是将 Protocol Buffers Java 对象编码为字节的编码器。

ProtobufDecoder 和 ProtobufEncoder 适用于结构化二进制数据协议,适合跨语言、跨平台通信,且数据结构紧凑。

Http编解码

在 Netty 中,HTTP 协议的编解码主要依赖于 HttpRequestDecoder 和 HttpResponseEncoder 这两个类,此外还可以结合 HttpObjectAggregator、HttpServerCodec 等编解码器进行优化和简化。

四、Netty高级特性

参数设置

我们对于一些参数的设置都是通过BootStrap中的.option和.childOption方法实现的,其中

1、针对ServerSocketChannel:通过.option设置

serverBootstrap.option(ChannelOption.SO_BACKLOG, 1024);
2、针对SocketChannel:通过.childOption设置
serverBootstrap.childOption(ChannelOption.SO_KEEPALIVE, true);

针对ScoketChannel,7个,通过.childOption设置,常用的两个如下:

1、SO_KEEPALIVE,tcp层keepalvie,默认关闭,一般选择关闭tcp keepalive 而使用应用keepalive
2、TCP_NODELAY:设置是否启用nagle算法,该算法是tcp在发送数据时将小的、碎片化的数据拼接成一个大的报文一起发送,以此来提高效率,默认是false(启用),如果启用可能会导致有些数据有延时,如果业务不能忍受,小报文也需要立即发送则可以禁用该算法
针对ServerScoketChannel,3个,通过.Option设置,常用的一个如下:
1、SO_BACKLOG:最大等待连接数量

零拷贝

我们先来聊一聊操作系统中的零拷贝是什么?

在 OS 层面上的零拷贝通常指避免在 用户态 与 内核态 之间来回拷贝数据. 例如 Linux 提供的 mmap 系统调用, 它可以将一段用户空间内存映射到内核空间,。

当映射成功后, 用户对这段内存区域的修改可以直接反映到内核空间; 同样地, 内核空间对这段区域的修改也直接反映用户空间。正因为有这样的映射关系, 我们就不需要在 用户态 与 内核态 之间拷贝数据, 提高了数据传输的效率

但是,Netty中的零拷贝概念与os的零拷贝不太一样,Netty的 零拷贝 的更多的是偏向于 优化数据操作 这样的概念

DirectBuffer:

        直接堆外内存区域分配空间而不是在堆内存中分配, 如果使用传统的堆内存分配,当我们需要将数据通过socket发送的时候,需要将数据从堆内存拷贝到堆外直接内存,然后再由直接内存拷贝到网卡接口层,通过Netty提供的DirectBuffer直接将数据分配到堆外内存,避免多余的数据拷贝

CompositeBufer:

        传统的ByteBuffer,如果需要将两个ByteBuffer中的数据组合到一起,我们需要首先创建一个
size=size1+size2大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用Netty提供的组合ByteBuf,就可以避免这样的操作,因为CompositeByteBuf并没有真正将多个Buffer组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝;同时也支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf, 避免了内存的拷贝。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值