掘金 后端 ( ) • 2024-04-23 11:07

项目源码

为什么需要自定义协议这一点的理由其实很容易想到。 比如对于我们比较熟知的Dubbo,其内部的协议就是自定义的。 之所以需要自定义协议,无非是因为:没有一种标准化协议来满足不同差异化需 求。 因此很多的中间件都会自定义协议,并且自定义协议也可以解决我们上文中讲到的拆包粘包的问题。 同时,再编写自己的产品的时候,比如IOT相关,当我们需要使用上位机来与嵌入式设备进行交互的时候,此时自己定义的协议就不可避免了。 这里我将简化一下我当初学习嵌入式时与朋友一起开发的一个上位机协议,来描述自定义自己的通信协议的一个过程。 我们当初的场景其实就是一个使用上位机发送消息到我们的嵌入式平台,然后控制嵌入式平台上各种外设的行为的一种通信协议。 嗯,所以这里建议大家学完嵌入式知识比如ESP8266的通信之后再回来继续学习本章知识(just a kidding) 好的,那么接下来我简单的描述一下接下来我们要开发的协议的格式: reqId:用来表示唯一的请求ID reqType:请求类型,类似于GET、POST、PUT、DELETE Length:数据字段长度 Data:实际数据 好的,让我们开始准备编程。

协议相关类

我们首先先按照我们协议的格式定义一下各种类型。 比如我们的Header请求头包含reqid,reqtype以及Length。 而我们的Body请求体就是这里的Data实际数据。

@Data
public class Header {
    //消息id
    private Long reqId;

    //消息类型 类似于POST\GET\DELETE\PUT
    private Byte reqType;

    //消息长度  其实大部分消息长度不会超过short
    //但是使用int更加方便
    private Integer length;
}

@Data
public class Message {
    //消息请求头
    private Header header;
    //消息体
    private Object body;

}

编解码器

再Netty中,如果我们想要自定义一些编解码器,也是很容易的。 当我们的应用程序需要根据自定义的协议格式来解析接收到的字节流时。我们可以使用ByteToMessageDecoder这个抽象类。 他用于处理从字节到消息的解码过程。因为它可以帮助帮助处理从网络接收的原始字节流,并将它们转换成应用程序可以处理的高级消息格式。 如下是我对这个类的方法的重写。

@Slf4j
public class MessageDecode extends ByteToMessageDecoder {

    /**
     * 这里的ByteBuf就是接收到的消息报文
     * @param ctx           the {@link ChannelHandlerContext} which this {@link ByteToMessageDecoder} belongs to
     * @param in            the {@link ByteBuf} from which to read data
     * @param out           the {@link List} to which decoded messages should be added
     * @throws Exception
     */
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        //首先我们需要先处理我们的消息头Header部分
        //其中Header包含 reqId reqType Len
        if (Objects.isNull(in)){
            log.info("the ByteBuf of In is null!!!");
            return;
        }
        Header header = new Header();
        header.setReqId(in.readLong());
        header.setReqType(in.readByte());
        header.setLength(in.readInt());
        //这里由于我们定义的协议的格式,所以不能吧读取length操作提前哦
        if (header.getLength()<=0){
            log.info("the Length of Message is Zero!!!");
            return;
        }
        byte[] body = new byte[header.getLength()];
        in.readBytes(body);
        ByteArrayInputStream bais = new ByteArrayInputStream(body);
        ObjectInputStream ois = new ObjectInputStream(bais);
        //进行反序列化
        Object bodyData = ois.readObject();
        if (Objects.isNull(body)){
            log.warn("the Body of Message is Null!!!");
        }
        Message message = new Message();
        message.setHeader(header);
        message.setBody(bodyData);
        //添加数据对象给下一个Handler处理
        out.add(message);
        log.info("the final Message is: {} ",message);
    }
}

你可能好奇这个类要怎么来使用? 还记得我们前面学习到的ChannelPipeline吗?放进去就行。 解码器的工作是解析接收到的 ByteBuf 数据,并将解析后的消息传递给管道(ChannelPipeline)中的下一个处理器。这是通过向 out 列表添加解析后的对象来实现的。 解码器的使用遵循如下步骤即可:

  1. 编写解码器:
    • 首先编写解码器,覆盖 decode 方法来处理接收到的原始字节流。在 decode 方法中,分析 ByteBuf,并根据我们的协议逻辑将解析后的消息对象添加到 out 列表。
  2. 添加解码器到 ChannelPipeline:
    • 在设置 Netty 服务器或客户端时,需要在初始化 Channel 时,将这个自定义解码器添加到 ChannelPipeline 中。
  3. 消息流经 ChannelPipeline:
    • 当数据到达时,它首先通过解码器。解码器处理原始的 ByteBuf,并生成高级消息对象,然后将这些对象传递给 ChannelPipeline 中的下一个处理器。
  4. 处理解码后的消息:
    • 通常我们会有一个或多个 ChannelHandler 来处理这些解码后的消息,执行业务逻辑。

这里,可以看到代码中的List out这个参数。 我认为有必要介绍一下它的作用:其主要作用是作为解码后的消息的容器,让我们可以将解码出来的消息传递给 ChannelPipeline 中的下一个 ChannelHandler。 在 decode 方法中,当我们从接收到的 ByteBuf 解析出一个或多个消息时,我们应该将这些消息添加到 out 列表中。这可以是任何类型的对象,比如一个字符串、一个自定义的 Java 对象、或者是更复杂的数据结构。 添加到 out 列表中的所有对象都会自动地被传递到 ChannelPipeline 中的下一个 ChannelHandler。这意味着我们不需要自己手动处理数据的传递,Netty 会自动为我们完成。 这意味着在一次 decode 调用中,我们可以解码并生成多个消息。 这里我简单写了一个Demo,没有啥实际意义,只是为了演示一下大概的用法:

@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    // 假设我们的协议是每个消息是一个以换行符结尾的字符串
    while (in.isReadable()) {
        ByteBuf lineBuf = readLine(in); // 假设 readLine 方法能从 ByteBuf 中读取一行
        if (lineBuf != null) {
            String line = lineBuf.toString(Charset.defaultCharset());
            out.add(line); // 将解码的字符串添加到 out 列表中
        } else {
            break; // 如果没有完整的行可读,退出循环
        }
    }
}

同理的,学习完毕解码之后,我们继续学习编码。 在 Netty 中,编码(即将应用程序的高级消息转换成字节流以便发送)通常是通过继承 MessageToByteEncoder 类来实现的。MessageToByteEncoder 是 Netty 提供的一个抽象类,用于将消息从一种高级格式编码为字节流。 它的作用是:

  1. 将消息转换为字节流:
    • 编码器的主要职责是将发送的数据(如字符串、自定义对象等)转换成字节流,以便它们可以通过网络传输。
  2. 自动处理:
    • 当消息通过 Netty 的 ChannelPipeline 发送时,如果 ChannelPipeline 中有添加 MessageToByteEncoder 的实例,Netty 会自动调用它来对消息进行编码。
  3. 类型安全:
    • MessageToByteEncoder 提供了类型参数,你可以指定它只处理特定类型的消息,这使得编码过程类型安全。
@Slf4j
public class MessageEncode extends MessageToByteEncoder<Message> {

    /**
     *
     * @param ctx           the {@link ChannelHandlerContext} which this {@link MessageToByteEncoder} belongs to
     * @param msg           the message to encode
     * @param out           the {@link ByteBuf} into which the encoded message will be written
     * @throws Exception
     */
    @Override
    public void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) throws Exception {
        if (Objects.isNull(msg)) {
            log.info("the Message is Null!!!");
            return;
        }
        Header header = msg.getHeader();
        out.writeLong(header.getReqId());
        out.writeByte(header.getReqType());
        Object body = msg.getBody();
        if (Objects.nonNull(body)) {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(baos);
            oos.writeObject(body);
            byte[] bytes = baos.toByteArray();
            //设定消息长度
            out.writeInt(bytes.length);
            //编写消息实际内容---请求体
            out.writeBytes(bytes);
            log.info("the final Message is: {} ",out);
        } else {
            log.info("the Length of Message is Zero!!!");
            out.writeInt(0);
        }
    }
}

好的,现在我们已经知道了编码器和解码器的编写方法,接下来我们来编写一个启动类来测试一下我们的代码的效果。 这里可以用到Netty提供的**EmbeddedChannel。**它可以用来在隔离的环境中测试我们的编码器和解码器,而无需启动实际的网络连接。 具体代码如下,这里你可以自己进入debug看看效果。

package blossom.project.netty;

import blossom.project.netty.codec.MessageDecode;
import blossom.project.netty.codec.MessageEncode;
import blossom.project.netty.enums.ReqTypeEnum;
import blossom.project.netty.eneity.Header;
import blossom.project.netty.eneity.Message;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.logging.LoggingHandler;
import lombok.extern.slf4j.Slf4j;

/**
 * @author: ZhangBlossom
 * @date: 2023/12/14 21:07
 * @contact: QQ:4602197553
 * @contact: WX:qczjhczs0114
 * @blog: https://blog.csdn.net/Zhangsama1
 * @github: https://github.com/ZhangBlossom
 * Bootstrap类
 */
@Slf4j
public class Bootstrap {
    public static void main(String[] args) throws Exception {
        EmbeddedChannel channel = new EmbeddedChannel(
            	//添加长度域,解决拆包粘包问题 
            	//如果消息不完整会等待完整消息到达
                new LengthFieldBasedFrameDecoder
                        (1024 * 1024,
                                9,
                                4,
                                0,
                                0),
                new LoggingHandler(),
                new MessageEncode(),
                new MessageDecode()
        );

        Header header = new Header();
        header.setReqId(5201314L);
        header.setReqType(ReqTypeEnum.GET.getCode());
        //这里不需要设定消息的长度,因为消息的长度我们在Encode里面设置了
        Message message = new Message();
        message.setHeader(header);
        message.setBody("I'll use Netty to write a RPC framework");

        //输出结果 这里会执行编码(因为我们是输出)
        //channel.writeOutbound(message);

        //申请空间
        ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
        //进行手动的编码
        new MessageEncode().encode(null, message, buf);

        //对编码的内容进行解码
        //这里就会执行我们的Decode方法
        channel.writeInbound(buf);
    }
}

到此为止,我们的协议的定义和编码以及解码都完成了。

之后,按照我们上面的流程,我们得开始准备定义服务端和客户端。

服务端

服务端这边我们只需要一个启动类以及一个Handler。 这里我直接贴出一个简单的代码。

@Slf4j
public class ServerMessageHandler extends ChannelInboundHandlerAdapter {

    /**
     * @param ctx
     * @param msg  这里就是我在解码器中out中设定的对象,可以是多个
     * @throws Exception
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        Message message = (Message) msg;
        if (Objects.isNull(message)){
            log.info("the Message is Null!!!");
            return ;
        }
        log.info("Server Receive Message : {}" , message);
        message.setBody("This is Server' response Message");
        message.getHeader().setReqType(ReqTypeEnum.ON.getCode());
        //将消息写回客户端
        ctx.writeAndFlush(message);
    }
}
@Slf4j
public class _ServerBootstrap {
    public static void main(String[] args) {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workGroup = new NioEventLoopGroup();
        //TODO ServerBootstrap的创建可以考虑用工厂或者策略
        //因为这里可以用Epoll/Nio两种Channel
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workGroup).channel(NioServerSocketChannel.class).option(ChannelOption.SO_BACKLOG,
                        1024)            // TCP连接的最大队列长度
                .option(ChannelOption.SO_REUSEADDR, true)          // 允许端口重用
                .option(ChannelOption.SO_KEEPALIVE, true)          // 保持连接检测
                .childOption(ChannelOption.TCP_NODELAY, true)      // 禁用Nagle算法,适用于小数据即时传输
                .childOption(ChannelOption.SO_SNDBUF, 65535)       // 设置发送缓冲区大小
                .childOption(ChannelOption.SO_RCVBUF, 65535)       // 设置接收缓冲区大小
                .localAddress(new InetSocketAddress(8080)) // 绑定监听端口
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    //构建处理客户端连接的ChannelPipeline
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(64 * 1024, 9, 4, 0, 0))
                                //添加我们自己的编解码器以及处理器
                                .addLast(new MessageEncode()).addLast(new MessageDecode()).addLast(new ServerMessageHandler());
                    }
                });

        try {
            ChannelFuture channelFuture = bootstrap.bind().sync();
            log.info("server startup on port {}", 8080);
            channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
            throw new RuntimeException("There are some exceptions occurring " + "during the startup of the service, " +
                    "exceptions are : {} ", e);
        } finally {
            log.info("shutdown gracefully!!!");
            workGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }


}

代码比较简单好理解,在我们运行项目的时候,先启动Server服务端然后等待接收客户端的代码即可。

客户端

@Slf4j
public class ClientMessageHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        Message message = (Message) msg;
        log.info("Client Receive Message is: {}", message);
        //这里直接调用父类的read方法
        super.channelRead(ctx, msg);
    }
}
public class _ClientBootstrap {
    public static void main(String[] args) {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap clientBootstrap = new Bootstrap();
        clientBootstrap.group(worker).channel(NioSocketChannel.class).handler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024 * 1024, 9, 4, 0, 0)).addLast(new MessageEncode()).addLast(new MessageDecode()).addLast(new ClientMessageHandler());
            }
        });
        try {
            ChannelFuture future = clientBootstrap.connect(new InetSocketAddress("localhost", 8080)).sync();
            Channel channel = future.channel();
            for (int i = 0; i < 10; i++) {
                Message record = new Message();
                Header header = new Header();
                header.setReqId(System.currentTimeMillis());
                header.setReqType(ReqTypeEnum.ON.getCode());
                record.setHeader(header);
                String body = "this is the Client Message, which id is :" + i;
                record.setBody(body);
                channel.writeAndFlush(record);
            }
            future.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //经典的优雅停机
            worker.shutdownGracefully();
        }
    }
}

将代码编写完毕之后,我们先运行Server服务端,然后再运行Client客户端,注意,这里为了方便你自己能看到效果,请在Encode和Decode处添加断点进行debug。

流程分析

消息的批量发送

代码编写完毕之后骂我们开始分析我们自定义协议的处理流程,如果这里学完能对流程有一个清晰的了解,之后再自己开发RPC协议的时候就能减少很多的阻力了。 这里我们首先在ClientBoostrap这里打上断点。我们监控如下这一行代码。 image.png 然后再Encode处的任意位置打上断点。 image.png 之后,不断执行断点,我相信你应该看到了一个现象。 就是如下的代码行连续执行了10次,也就是我们for循环的次数。

  channel.writeAndFlush(record);

并没有说我们跳过当前断点的时候,就跑到了Encode去执行代码。 只有当我们这10次for循环都执行完毕之后,才会进入Encode方法。 然后会发现,再Encode处我们也连续进入了10次。 image.png 并且,直到我们将这10次的消息都处理完毕,然后才会一次性的发送给服务端去处理。 同理,我们也可以再服务端的Decode这里看到,Decode也连续执行了10次。 为什么会这样子? 主要是因为Netty 使用异步和事件驱动的模型。因此,在客户端,尽管消息是在循环中连续发送的,但实际上的网络传输和编码操作可能是异步进行的。这意味着,所有消息可能都在缓冲区中等待发送,然后一起或分批传输。 在服务器端,解码器会在接收到数据时被触发。如果数据是一次性接收的(如我们所观察到的),解码器可能会连续执行多次,每次处理一条消息。 不过,其实按理来说这个情况也应该不太多的出现,为什么? 因为在Netty中,writeAndFlush 方法可以用来确保每次写入消息后都立即刷新到网络。在我们的代码中,我已经使用了 writeAndFlush,这应该已经在一定程度上减少了消息的批量处理。但是,由于底层网络和操作系统的缓冲机制,这仍然不能保证消息是逐一发送的。 所以,最终的解决方法,也就是我们真的希望看到一条一条的数据出去而不是一次性的话,我们可以考虑增加一个延迟。

for (int i = 0; i < 100; i++) {
    // 发送消息
    channel.writeAndFlush(record);
    // 添加一些延迟
    Thread.sleep(10); // 10毫秒的延迟
}

在客户端发送消息的地方添加一个短暂的延迟,就可以解决上面的问题了。 当然,这种方法肯定不太妙,毕竟我们就只使用了一个线程来发送这里面所有的消息,那么这条线程完成任务的时间就会大大增加了。 我们可以考虑用多线程的方式来发送这些消息。也可以跳过设置TCP参数以及Netty的写入缓冲区的大小来解决。

  1. 使用不同的线程或事件监听

为了使消息发送更加分散,我们可以在客户端使用一个单独的线程或者基于事件的回调来发送每条消息。这里是一个简单的线程实现的例子:

for (int i = 0; i < 100; i++) {
    final int index = i;
    new Thread(() -> {
        Message record = new Message();
        Header header = new Header();
        header.setReqId(System.currentTimeMillis());
        header.setReqType(ReqTypeEnum.ON.getCode());
        record.setHeader(header);
        String body = "this is the Client Message, which id is :" + index;
        record.setBody(body);

        channel.writeAndFlush(record);
    }).start();
}

这段代码对于每条消息都创建一个新的线程来发送,这可能不是最高效的做法,特别是对于大量消息发送。考虑使用线程池或者异步框架来优化这种实现。

  1. 调整TCP参数

调整TCP参数,例如禁用Nagle算法,可以通过设置Netty的 ChannelOption 实现。这可以在客户端或服务器的引导配置中完成:

bootstrap.option(ChannelOption.TCP_NODELAY, true); // 客户端或服务器

这个设置会影响TCP套接字的行为,使得每次写操作都立即发送,而不是等待更多的数据准备好一起发送。

  1. 调整Netty的写入缓冲区大小

调整写入缓冲区大小可以通过设置Netty的 ChannelOption.WRITE_BUFFER_WATER_MARK 选项来实现。例如:

int lowWaterMark = 32 * 1024; // 32KB
int highWaterMark = 64 * 1024; // 64KB
bootstrap.option(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(lowWaterMark, highWaterMark));

这些设置定义了写入缓冲区的低和高水位标记。当缓冲区大小超过高水位标记时,Netty会停止读取更多的数据,直到缓冲区大小下降到低水位标记以下。 注意,所有这些改动都需要在Netty的引导类(例如 BootstrapServerBootstrap)中进行配置。

Encode/Decode的执行时机

我们继续debug会发现,客户端只执行encode,服务端只执行decode。 这又是为什么?按照我们添加在ChannelPipeline中的顺序,我们的消息应该都要执行呀? 其实出现的这种行为是由Netty管道中处理器的配置决定的。

  1. 客户端执行encode: 当客户端发送数据时,MessageEncode 编码器会被调用,将消息对象转换为字节数据。由于客户端主要负责发送数据,所以编码器是必需的。如果客户端不接收来自服务器的响应数据,或者响应数据的格式不需要解码,那么解码器可能不会被触发。
  2. 服务端执行decode: 服务器接收来自客户端的字节数据,然后通过 MessageDecode 解码器将其转换回消息对象。在您的案例中,服务器似乎只处理来自客户端的请求,不发送响应,或者发送的响应不需要编码,因此编码器可能不会被触发。

所以,其实并不是说客户端只执行encode,服务端只执行decode。 是发送数据的哪一方会执行encode,接收数据的那一份会执行decode。 可以看到我们的Handler处理器中有服务端对消息的应答。 当我们的服务端对客户端的消息进行应答的时候,服务端就会执行encode方法将需要响应的消息进行编码,而客户端此时就会需要使用decode解码器进行解码。 (这里就不贴出代码和具体debug的流程了,有机会录制一个视频debug给大家看看) 直接启动Server和Client的代码即可,然后将断点打在Decode和Encode的代码处即可看到效果。 image.png