自定义 Netty 编解码器


1. 介绍

Netty 是 Java 中一个高性能的网络框架,本文将实现一个自定义的编解码器来加深对 Netty 工作流程的理解。

客户端与服务器的分工是这样的:

  • 客户端:接收服务器发送的消息,并且给服务器返回接收成功的消息。
  • 服务器:
    • 当客户端连接建立后,打印客户端已上线,并且开启一个定时任务,每隔 3s 给客户端发送服务端当前的时间。
    • 当接收到客户端的消息时,打印到控制台上。
    • 当客户端连接断开后,打印客户端已离线。

2. 关注点

2.1 粘包/拆包 问题的解决方案

TCP 传输可能会出现 粘包/拆包 的问题,在 Netty 中可以使用 LengthFieldBasedFrameDecoder 来解决。对 粘包/拆包 问题不熟悉的可以看这篇文章——Netty——TCP 粘包/拆包问题

2.2 自定义消息协议

本次自定义编解码器需要 自定义消息的协议,一般来说,业务消息包含四部分:

  • magic:魔数,用于验证消息的有效性,是一个固定的整数,和 .class 文件中的魔数类似。
  • code:业务码,表示消息的类型,不同的业务消息对应不同的业务处理。
  • len :消息的长度。
  • data:消息的内容。

为了简化消息协议,本次自定义的消息协议只包含 lendata 两个字段。

2.3 @Sharable

2.3.1 是什么

在 Netty 中,ChannelHandler 是处理 I/O 事件的核心组件。一般而言,每个 Channel 都会有自己独立的 ChannelPipelineChannelPipeline 里包含了一系列的 ChannelHandler对 Netty 的核心组件不熟悉的可以看这篇文章——Netty——核心组件

若一个 ChannelHandler 被标记为 @Sharable,那就意味着这个 ChannelHandler 实例能够被多个 ChannelChannelPipeline 同时使用,而不用为每个 Channel 都创建新的 ChannelHandler 实例,这样可以节省系统资源

2.3.2 使用场景

  • 无状态处理:当 ChannelHandler 不维护任何与特定 Channel 相关的状态时,就能使用 @Sharable 注解。例如,对所有 Channel 都应用相同的 日志记录数据转换 等操作。
  • 资源共享:要是 ChannelHandler 需要共享某些资源(如 数据库连接池、本地缓存 等),使用 @Sharable 注解可以保证这些资源在多个 Channel 间有效共享。

2.3.3 注意事项

  • 状态管理被标记为 @SharableChannelHandler 必须是无状态的,或者其状态可以安全地在多个 Channel 间共享。若 ChannelHandler 维护了与特定 Channel 相关的状态,就可能会出现数据混乱和线程安全问题。
  • 线程安全:由于 @SharableChannelHandler 可能会被多个线程同时访问,所以 要保证其所有方法都是线程安全的
  • 特殊情况:Netty 要求 ByteToMessageDecoder (将字节流转化成业务消息的解码器) 的实现类不能标注 @Sharable,因为 ByteToMessageDecoder 往往会维护一些内部状态,在解码过程中,它可能需要追踪已经读取的字节数、当前解析的位置或者中间的解析结果等,以便继续处理后续的数据。

3. 代码实现

3.1 消息协议

3.1.1 消息类 Msg

/**
 * 自定义消息协议
 */
public class Msg {

    /**
     * 消息的长度
     */
    private int len;

    /**
     * 消息的内容
     */
    private byte[] data;

    public Msg() {}

    public Msg(int len, byte[] data) {
        this.len = len;
        this.data = data;
    }

    public int getLen() {
        return len;
    }

    public void setLen(int len) {
        this.len = len;
    }

    public byte[] getData() {
        return data;
    }

    public void setData(byte[] data) {
        this.data = data;
    }
}

3.1.2 消息编码器 MsgEncoder

/**
 * 消息的编码器,将消息从 Msg 类型 编码成 字节流
 * 消息编码是无状态的,可共享
 */
@ChannelHandler.Sharable
public class MsgEncoder extends MessageToByteEncoder<Msg> {

    @Override
    protected void encode(ChannelHandlerContext ctx, Msg msg, ByteBuf out) throws Exception {
        // 先写入消息的长度
        out.writeInt(msg.getLen());
        // 然后再写入消息内容
        out.writeBytes(msg.getData());
    }
}

3.1.3 消息解码器 MsgDecoder

/**
 * 消息的解码器,将消息从 字节流 解码成 Msg 类型
 * 消息解码器不能共享
 */
public class MsgDecoder extends ByteToMessageDecoder {

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        // 检查可读字节数是否足够读取 消息的长度,如果不够,则直接返回,等待更多数据
        if (in.readableBytes() < 4) {
            return;
        }

        // 标记读指针,便于回滚
        in.markReaderIndex();

        // 读取消息的长度
        int len = in.readInt();

        // 检查可读字节数是否足够读取 消息的内容,如果不够,则重置读指针,等待更多数据
        if (in.readableBytes() < len) {
            in.resetReaderIndex();
            return;
        }

        // 读取消息的内容
        byte[] data = new byte[len];
        in.readBytes(data);

        // 将消息封装成 Msg 类型,交给 Pipeline 中的下一个 Handler 处理
        out.add(new Msg(len, data));
    }
}

3.2 服务器

3.2.1 服务器类 Server

/**
 * 支持收发 Msg 类型的消息的服务器
 */
public class Server {

    public static void main(String[] args) {
        final EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        final EventLoopGroup workerGroup = new NioEventLoopGroup();

        final MsgEncoder encoder = new MsgEncoder();

        final ServerBootstrap bootstrap = new ServerBootstrap()
                .group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline()
                                .addLast(new LengthFieldBasedFrameDecoder(4096, 0, 4))
                                .addLast(new MsgDecoder())
                                .addLast(encoder)
                                .addLast(new ServerHandler());
                    }
                });

        // 当 JVM 关闭时关闭 BossGroup 和 WorkerGroup
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }));

        bootstrap.bind(8888);
    }
}

3.2.2 服务器的处理器 ServerHandler

/**
 * 服务器的处理器
 * 每个 Channel (客户端) 对应一个处理器,无法共享
 */
public class ServerHandler extends SimpleChannelInboundHandler<Msg> {

    /**
     * 时间的格式化器
     */
    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");

    /**
     * 用于固定时间间隔发送消息的单线程池
     */
    private final ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1);

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Msg msg) throws Exception {
        System.out.println("客户端[" + ctx.channel().remoteAddress() + "]说:" + new String(msg.getData()));
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("客户端[" + ctx.channel().remoteAddress() + "]已上线");
        // 建立连接 1s 后,每隔 3s 给客户端发送一次消息
        executor.scheduleAtFixedRate(() -> {
            String content = "当前时间是 " + FORMATTER.format(LocalDateTime.now());
            byte[] data = content.getBytes();
            int len = data.length;
            ctx.writeAndFlush(new Msg(len, data));
        }, 1, 3, TimeUnit.SECONDS);
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("客户端[" + ctx.channel().remoteAddress() + "]已离线");
    }
}

3.3 客户端

3.3.1 客户端类 Client

/**
 * 支持收发 Msg 类型的消息的客户端
 */
public class Client {

    public static void main(String[] args) {
        final EventLoopGroup group = new NioEventLoopGroup();

        final MsgEncoder encoder = new MsgEncoder();
        final ClientHandler clientHandler = new ClientHandler();

        final Bootstrap bootstrap = new Bootstrap()
                .group(group)
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline()
                                .addLast(new LengthFieldBasedFrameDecoder(4096, 0, 4))
                                .addLast(new MsgDecoder())
                                .addLast(encoder)
                                .addLast(clientHandler);
                    }
                });

        // 当 JVM 关闭时关闭 Group
        Runtime.getRuntime().addShutdownHook(new Thread(group::shutdownGracefully));

        bootstrap.connect("127.0.0.1", 8888);
    }
}

3.3.2 客户端的处理器 ClientHandler

/**
 * 客户端的处理器
 * 消息处理是无状态的,可共享
 */
@ChannelHandler.Sharable
public class ClientHandler extends SimpleChannelInboundHandler<Msg> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Msg msg) throws Exception {
        System.out.println("服务器说:" + new String(msg.getData()));
        String content = "接收成功";
        byte[] data = content.getBytes();
        int len = data.length;
        ctx.writeAndFlush(new Msg(len, data));
    }
}

3.4 测试

3.4.1 服务器

客户端[/127.0.0.1:49514]已上线
客户端[/127.0.0.1:49518]已上线
客户端[/127.0.0.1:49514]说:接收成功
客户端[/127.0.0.1:49518]说:接收成功
客户端[/127.0.0.1:49514]说:接收成功
客户端[/127.0.0.1:49518]说:接收成功
客户端[/127.0.0.1:49514]说:接收成功
客户端[/127.0.0.1:49518]说:接收成功
客户端[/127.0.0.1:49514]已离线
客户端[/127.0.0.1:49518]已离线

3.4.2 客户端 [/127.0.0.1:49514]

服务器说:当前时间是 11:00:49
服务器说:当前时间是 11:00:52
服务器说:当前时间是 11:00:55

3.4.3 客户端 [/127.0.0.1:49518]

服务器说:当前时间是 11:00:49
服务器说:当前时间是 11:00:52
服务器说:当前时间是 11:00:55

4. 总结

使用 Netty 框架,可以简单地开发出一个高性能的网络服务器。本次实现主要有三个重点:

  • 使用 LengthFieldBasedFrameDecoder 处理 粘包/拆包 问题。
  • 自定义消息协议。
  • 使用 @Sharable 注解标注可共享的 Handler。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值