《Java 网络编程基础》4.3 编解码器设计与错误处理

帧切出来只是 byte[],还不能说明协议是否正确。本节把 linkd 的 4 字节信封拆成 magic、version、type,再解析后续 payload,用 ProtocolException 区分可恢复与致命错误,给出错误帧、关闭策略和编码解码分离的 FrameEncoder,并完成每连接一份 carry 的 LinkdFrameHandler。

本节目标:把「切帧」和「解释帧」分成两层,能判断哪类错误必须关连接、哪类可以回错误帧后继续。
适用版本:JDK 25(Temurin 25.0.5+8);FrameDecoder、FrameEncoder、MessageCodec、Handler。

承接的问题

4.2 的 FrameDecoder 只保证「这 N 个字节是一帧」,它不保证这 N 个字节是 linkd 的消息。帧里可能有错的 magic、不支持的版本、未知的 type,或者短到连信封头都放不下。这些错误发生在边界之内,解码器没有资格判断,必须由上一层处理。

更麻烦的是「处理完之后怎么办」。4.2 的 LengthFieldFrameDecoder 在长度超限时抛 FrameTooLargeException,此时缓冲区已经失步:你不知道下一帧从哪里开始。继续读只会把后面的字节当成新帧头,产生一连串假帧。相反,如果只是某个 type 不认识,帧边界仍然完好,完全可以回一个错误帧然后继续服务。

还有一个具体的旧账。2.1 的 FramedEchoHandler 每次 onReadable 都从池里借一个新 buffer,decode 之后在 finally 里 release。那段代码只处理完整帧,一旦出现半包,未消费的字节会跟着 buffer 一起被释放,下一帧永远拼不回来。根因不是 FrameDecoder,而是「解码状态没有归属」:状态应该属于连接,不属于某一次 read。

本节先把状态归属和错误分类讲清楚,再给出 linkd 的 MessageCodec、FrameEncoder 和 LinkdFrameHandler,把 4.1、4.2 的字节与帧接进连接层。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

核心机制一:解码器可以无状态,处理链必须有状态

FixedLengthFrameDecoder、DelimiterFrameDecoder、LengthFieldFrameDecoder 都只有 final 配置字段,decode 不写任何成员变量。它们是无状态的,可以被多个连接共享——但 linkd 仍然每个连接创建一个实例,原因是以后可能加入「最大帧计数」「协议版本」这类每连接状态,提前把归属定下来比事后拆共享对象便宜。

真正有状态的是「还没成帧的字节」和「解码到一半的字符」。这两份状态都必须满足三个条件:

  1. 每连接一份。 不能放在 static 字段,也不能让 Handler 单例持有。两个客户端各发半个帧,共享 carry 会把它们的字节拼成一条假消息。
  2. 只在一个线程里访问。 linkd 的连接在 EventLoop 上注册,同一个连接的 onReadable 不会并发执行;carry 和 Utf8StreamDecoder 因此不需要加锁。跨线程传递只能通过 EventLoop.execute 投递任务,不能直接摸 carry。
  3. 生命周期跟连接一致。 连接关闭时丢弃,不要放回池里给下一个连接复用;池化的是 ByteBuffer 的内存,不是里面的字节。

各组件在这三条上的表现:

组件是否有状态归属能否共享
FrameDecoder 的三种实现无(只有配置)连接或全局可以
FrameEncoder无全局可以
MessageCodec无全局可以
carry 缓冲区有(未成帧字节)连接不可以
Utf8StreamDecoder有(半个字符)连接不可以
LinkdFrameHandler有(持有上面两者)连接不可以

carry 的读写循环固定为「读 → flip → decode → compact」:

初始              read 追加            flip               decode 交出 1 帧      compact
[pos=0 lim=cap]   [data      ][    ]   [data      ][    ]  [frame][left][    ]   [left      ][    ]
                  pos=n      lim=cap   pos=0  lim=n       pos=k  lim=n          pos=n-k lim=cap

compact 是状态能跨 read 保持的关键:它把 left 搬到数组开头,position 设为 left 的长度,下一次 read 直接往后追加。如果忘了 compact,下一次 read 会写在不该写的位置,或者因为 position == limit 一直读到 0 字节。

核心机制二:错误分三层,关闭策略跟着分层

编解码链路上的错误可以按「边界是否完好」分成三层:

层例子边界能否继续读处理
成帧长度超限、分隔符超长已丢失不能记录后关闭,不回错误帧
信封magic 错、版本不支持完好能关闭;对端说的不是本协议
信封type 未知、信封短于 4 字节完好能回错误帧,继续
业务书号不存在、参数非法完好能回错误帧,继续

判断标准不是「错误严重不严重」,而是「下一帧的起始位置还确不确定」。长度字段读到 2 GB,你既没有分配内存,也无法知道对端到底发了多少字节;此时缓冲区里的下一个字节可能是长度字段的一部分,也可能是 payload,继续解析只会产生假帧。相反,magic 错了但帧边界是 4.2 算出来的,边界可信,只是内容不是本协议——这种情况可以关闭,但没必要回错误帧,因为对端可能根本解析不了 linkd 的错误格式。

ProtocolException 把信封层的错误码固定下来:

package com.plume.linkd.core;

public final class ProtocolException extends RuntimeException {

    public enum Kind {
        BAD_MAGIC, BAD_VERSION, BAD_TYPE, SHORT_PAYLOAD
    }

    private final Kind kind;

    public ProtocolException(Kind kind, String detail) {
        super(kind + ": " + detail);
        this.kind = kind;
    }

    public Kind kind() {
        return kind;
    }
}

用枚举而不是字符串前缀,是为了让 catch 分支可以按类别判断,不依赖异常消息的文案。BAD_MAGIC 和 BAD_VERSION 属于「对端不是 linkd」,直接关闭;BAD_TYPE 和 SHORT_PAYLOAD 属于「帧是 linkd 的,但内容有问题」,回错误帧后继续。

错误帧沿用同一套信封:type = 0xFF,payload 是 UTF-8 文本。客户端收到后按正常帧解析,看到 0xFF 就知道是错误。生产环境通常把 payload 换成「数字错误码 + 请求 id + 简短描述」,而不是直接放异常消息;异常消息可能包含内部类名、路径或对端传入的原文,回显给对方既泄露信息,也容易被用来放大日志。

错误帧的内容要有限制:只回错误码和一句固定描述,不回显请求 payload,不附堆栈。否则一个 16 KiB 的坏请求可以换回一个 16 KiB 的错误响应,放大倍数虽然不大,但足以把日志和带宽拖垮。

关闭策略要和 3.3 的语义对齐。Connection.close() 的约定是「停止接收新数据、等待写队列排空、关闭通道」,它是优雅关闭,不是 SO_LINGER(0)。因此:

  • 致命错误(成帧失败、magic/version 错)不 enqueue 任何东西,直接 close();写队列本来就是空的,关闭很快。
  • 可恢复错误先 enqueue 错误帧,再继续读;不要立刻 close(),否则错误帧可能还没发出去。
  • 业务错误同上,但业务层要保证错误响应本身不会再抛异常。
  • 对端已关闭(read 返回 -1)直接 close(),不要再写。

核心机制三:编码与解码分离

解码面对的是不可信输入,错误要分类、要决定连接命运;编码面对的是本地数据,出错基本是程序 bug,直接抛运行时异常即可。两者的失败模式不同,接口也应该分开:

package com.plume.linkd.core;

import java.nio.ByteBuffer;

public interface FrameEncoder {

    ByteBuffer encode(byte[] payload);
}
package com.plume.linkd.core;

import java.nio.ByteBuffer;

public final class LengthFieldEncoder implements FrameEncoder {

    @Override
    public ByteBuffer encode(byte[] payload) {
        ByteBuffer out = ByteBuffer.allocate(Integer.BYTES + payload.length);
        out.putInt(payload.length);
        out.put(payload);
        out.flip();
        return out;
    }
}

三个约定必须写进接口文档,否则调用方一定踩:

  • 返回的 buffer 处于读模式。 encode 内部 put 完必须 flip(),否则 position == limit,enqueue 写出 0 字节,对端只看到连接空闲。这个 bug 在压测时表现为「请求有日志、响应永远超时」。
  • 所有权转移给调用方。 返回的 ByteBuffer 不再被编码器引用,调用方可以安全地交给写队列。编码器不能把它放进成员变量复用,否则下一个请求会覆盖还在队列里的上一个响应。
  • 长度字段和 FrameDecoder 必须对称。 编码器写 4 字节大端,解码器读 4 字节大端,两边都用 Integer.BYTES,不要一边 4 字节一边 2 字节。协议升级时两个类要一起改。

信封在代码里就是一个不可变的记录:

package com.plume.linkd.core;

public record Message(int type, byte[] payload) {
}

type 是 0–255 的无符号值,payload 是帧体。record 只提供构造器和访问器,不做防御性拷贝;payload 的所有权约定在调用方,谁解码谁负责不要再改它。

MessageCodec 负责信封的 encode/decode,不碰 socket、不碰缓冲区状态:

package com.plume.linkd.core;

import java.nio.ByteBuffer;

public final class MessageCodec {

    public static final short MAGIC = 0x4C4B;
    public static final byte VERSION = 1;
    public static final int HEADER = 4;

    public Message decode(byte[] frame) {
        if (frame.length < HEADER) {
            throw new ProtocolException(ProtocolException.Kind.SHORT_PAYLOAD,
                    "frame " + frame.length + " < " + HEADER);
        }
        ByteBuffer buf = ByteBuffer.wrap(frame);
        short magic = buf.getShort();
        if (magic != MAGIC) {
            throw new ProtocolException(ProtocolException.Kind.BAD_MAGIC,
                    "0x" + Integer.toHexString(magic & 0xffff));
        }
        byte version = buf.get();
        if (version != VERSION) {
            throw new ProtocolException(ProtocolException.Kind.BAD_VERSION,
                    "v" + version);
        }
        int type = buf.get() & 0xff;
        byte[] payload = new byte[buf.remaining()];
        buf.get(payload);
        return new Message(type, payload);
    }

    public byte[] encode(Message message) {
        byte[] payload = message.payload();
        ByteBuffer out = ByteBuffer.allocate(HEADER + payload.length);
        out.putShort(MAGIC).put(VERSION).put((byte) message.type()).put(payload);
        return out.array();
    }
}

信封是 4 字节:magic 2 字节(4C 4B,ASCII 的 LK)、version 1 字节、type 1 字节,后面直接跟 payload。帧的总长由 4.2 的 4 字节长度域给出,信封里不再放长度字段——同一件事只在一个地方表达,少一个可能对不上的数字。ByteBuffer.wrap(frame) 默认大端,所以 getShort 直接按 4.1 的结论读,不需要再设 order()。

MessageCodec 不检查帧长,因为帧边界已经由 LengthFieldFrameDecoder 保证。它只校验信封的最小长度、magic 和 version;payload 的长度就是 frame.length - 4。这也是分层的价值:长度域负责「到哪里结束」,信封负责「这帧是不是 linkd 的、type 是什么」,两层各管一件事。如果将来把 MessageCodec 复用到没有长度域的传输上,才需要在它内部加回长度校验。

跑一次编码再解码(CodecDemo):

本机输出:

frame=17B hex=4c4b0101474554202f626f6f6b732f3432
type=1 payload=GET /books/42
SHORT_PAYLOAD: frame 2 < 4
BAD_MAGIC: 0x4b

逐段拆第一行:4c4b 是 magic,01 是版本,01 是 type,后面 13 字节是 GET /books/42。总长 4 + 13 = 17 字节,和输出一致。第三行是一个只有 2 字节的帧,连信封头都不够,被 SHORT_PAYLOAD 拒绝;第四行把 magic 的首字节改成 0x00,剩下 00 4b,被 BAD_MAGIC 拒绝。

在 linkd 里落地:每连接一份 carry 的 Handler

把上面几块接起来,就是 LinkdFrameHandler:

package com.plume.linkd.core;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;

public final class LinkdFrameHandler implements Handler {

    private static final int INITIAL = 1024;
    private static final int MAX_BUFFER = 64 * 1024;
    private static final int TYPE_ECHO = 1;

    private final FrameDecoder decoder;
    private final FrameEncoder encoder;
    private final MessageCodec codec;
    private ByteBuffer carry = ByteBuffer.allocate(INITIAL);

    public LinkdFrameHandler(FrameDecoder decoder, FrameEncoder encoder, MessageCodec codec) {
        this.decoder = decoder;
        this.encoder = encoder;
        this.codec = codec;
    }

    @Override
    public void onReadable(Connection c) {
        try {
            if (!carry.hasRemaining()) {
                if (!grow()) {
                    c.close();
                    return;
                }
            }
            int n = c.channel().read(carry);
            if (n == -1) {
                c.close();
                return;
            }
            carry.flip();
            List<byte[]> frames = decoder.decode(carry);
            for (byte[] frame : frames) {
                dispatch(c, frame);
            }
            carry.compact();
        } catch (FrameTooLargeException e) {
            System.getLogger("linkd").log(System.Logger.Level.WARNING,
                    "closing connection: " + e.getMessage());
            c.close();
        } catch (IOException e) {
            c.close();
        }
    }

    private void dispatch(Connection c, byte[] frame) {
        try {
            Message request = codec.decode(frame);
            if (request.type() != TYPE_ECHO) {
                throw new ProtocolException(ProtocolException.Kind.BAD_TYPE,
                        "type " + request.type());
            }
            c.enqueue(encoder.encode(codec.encode(
                    new Message(request.type(), request.payload()))));
        } catch (ProtocolException e) {
            if (e.kind() == ProtocolException.Kind.BAD_MAGIC
                    || e.kind() == ProtocolException.Kind.BAD_VERSION) {
                c.close();
                return;
            }
            c.enqueue(encoder.encode(codec.encode(
                    new Message(0xFF, e.getMessage().getBytes(StandardCharsets.UTF_8)))));
        }
    }

    private boolean grow() {
        if (carry.capacity() >= MAX_BUFFER) {
            return false;
        }
        int capacity = Math.min(carry.capacity() * 2, MAX_BUFFER);
        ByteBuffer bigger = ByteBuffer.allocate(capacity);
        carry.flip();
        bigger.put(carry);
        carry = bigger;
        return true;
    }

    @Override
    public void onWritable(Connection c) {
    }

    @Override
    public void onClosed(Connection c) {
    }
}

这段代码逐块对应前面的契约:

  • carry 是实例字段,LinkdFrameHandler 每个连接创建一个,所以半包字节不会串连接。它和 2.1 的 FramedEchoHandler 最大的区别就在这里:后者每次 onReadable 借新 buffer,finally 里释放,半包字节跟着内存一起没了。
  • carry.hasRemaining() 为 false 表示缓冲区写满了还没有成帧。grow() 翻倍扩容,上限 MAX_BUFFER;到上限仍满就关闭连接。这里有一个必须成立的不变量:MAX_BUFFER 要不小于 maxFrameLength + 4,否则一个合法的最大帧永远装不下,grow 会提前放弃。linkd 取 64 KiB 对 16 KiB 的单帧上限,留了 4 倍余量。
  • read 返回 -1 表示对端发了 FIN。此时 carry 里可能还有半个帧,但连接已经结束,没有「下一次」了,直接关闭。半关闭的语义在 3.3 已经讲过,这里不重复。
  • decode 可能返回多帧,for 循环逐帧 dispatch,不能只处理第一帧。
  • dispatch 把「解信封」和「检查 type」放在同一个 try 里。BAD_MAGIC/BAD_VERSION 直接 close(),不回错误帧;SHORT_PAYLOAD/BAD_TYPE 回 type=0xFF 的错误帧后继续。type 检查也抛 ProtocolException,是为了让两类错误走同一条 catch,不用在 handler 里写两份错误响应。
  • FrameTooLargeException 在 onReadable 外层捕获,只记日志并关闭,不 enqueue。原因是边界已经失步,回错误帧也没有意义,而且对端可能正处在「发一个超大帧」的状态,继续读只会浪费内存。
  • catch (IOException) 覆盖 read 的失败。Connection.close() 本身不抛受检异常,关闭失败只影响资源回收,不影响控制流。

dispatch 末尾的「业务」在本节是回显:把请求的 type 和 payload 原样编码回去,方便用 nc 或测试客户端验证整条链路。真实的 linkd 会按 type 分派到健康检查或 /books/{id} 的处理器,payload 的解析在 8.3 的路由层展开。回显版本的价值在于:它把「编解码是否正确」和「业务是否正确」分开,客户端收到原样字节,就说明帧、信封、错误处理都对齐了。

LinkdMain 的装配在 4.2 已经出现,这里补上生命周期:Handler 在 accept 之后、注册读事件之前创建,一个 SocketChannel 对应一个实例;连接关闭时 EventLoop 调用 onClosed,之后不再调用 onReadable。onClosed 目前是空实现,如果以后在 handler 里加了定时器或会话状态,必须在这里取消和清理,否则连接对象会被回调引用住,GC 收不掉。

onWritable 也是空实现,因为 linkd 的写队列由 Connection 自己管理,Handler 不需要在可写事件里做额外动作。14.1 讲背压时,这里会加上「队列高于高水位就停止读」的逻辑;本节只保证读路径正确。

常见错误与边界

错误一:把 LinkdFrameHandler 做成单例。 单例意味着所有连接共享一个 carry。客户端 A 的半个帧和客户端 B 的半个帧会被拼成一条消息,MessageCodec 可能解析成功,但内容是错的,日志里完全看不出来。症状是「偶发串包,压测时更明显」。Handler 必须每连接一个实例。

错误二:解码失败后继续读。 长度超限时缓冲区已经失步,继续调用 decode 会把 payload 当成长度字段,产生一连串假帧,最后可能是 OOM 或大量 BAD_MAGIC。致命错误必须关闭连接,不能「跳过这一帧再试」。

错误三:编码器返回写模式的 buffer。 encode 里 put 完忘记 flip(),返回的 buffer position == limit,enqueue 写出 0 字节。对端看到连接一直空闲,服务端日志里却有完整的请求记录。所有 FrameEncoder 实现都要在返回前 flip()。

错误四:业务异常冒泡出 onReadable。 dispatch 只捕获了 ProtocolException,业务代码如果抛 RuntimeException,会一路冒到 EventLoop,carry.compact() 不会执行。下一次 onReadable 时 carry 还停留在读模式,read 写入位置错误。业务异常要在 dispatch 内单独 catch,回错误帧,不要让它在缓冲区状态未收尾时逃出去。

错误五:错误响应回显请求原文或堆栈。 对端发一个 16 KiB 的坏请求,你回一个 16 KiB 的异常消息,里面还带类名和路径。这既是信息泄露,也是放大攻击。错误帧只放错误码和固定描述,原文只进本地日志。

边界:Message.payload() 是可变数组。 record Message(int type, byte[] payload) 不会防御性拷贝,两个 Message 可以共享同一个数组。如果解码后把 payload 直接存进会话,又在下一帧复用了同一块缓冲区,前一帧的内容会被改写。需要长期保存时自己 clone()。

边界:close() 是优雅关闭,不是立即复位。 对端已经失步时,close() 仍会尝试把写队列里已有的数据发完。致命错误路径不 enqueue,所以队列是空的;如果之前已经排了响应,close() 会先把它们发完,这段时间对端可能继续发垃圾数据,但 linkd 不再读,内核缓冲区满了以后对端的写会阻塞。这是可接受的收尾方式,不要为了「立刻断」去调 SO_LINGER。

边界:协议版本升级要留兼容窗口。 VERSION = 1 直接拒绝其他版本,升级时所有旧客户端会同时断线。生产协议通常保留「支持 N 和 N-1」的逻辑,把版本检查从 BAD_VERSION 改成「按版本选择解码器」。本节为了保证错误分类清晰,只做单版本。

小结

  • FrameDecoder 和 FrameEncoder 可以是无状态的共享对象;carry 和 Utf8StreamDecoder 有状态,必须每连接一份,并且只在 EventLoop 线程里访问。
  • 错误的分类标准是「帧边界是否完好」:成帧失败必须关闭,magic/version 错关闭,type 未知/信封太短回错误帧后继续,业务错误回错误帧后继续。
  • ProtocolException.Kind 用枚举固定错误码,catch 按类别分支,不依赖异常消息文案。
  • 编码器返回的 ByteBuffer 必须是读模式、所有权转移给调用方;长度字段要和解码器对称,统一用 4 字节大端。
  • MessageCodec 的信封是 4 字节:magic 4C4B、version、type,后面跟 payload;长度只由 4.2 的长度域表达,本机 17 字节的帧可以逐段对上。
  • LinkdFrameHandler 每连接持有 carry,decode 后 compact;MAX_BUFFER 必须大于等于单帧上限加头部,否则合法帧永远装不下。

到这一章结束,字节、字符、帧、信封都有了,但所有讨论都默认「缓冲区就在手边」。下一节拆开 ByteBuffer 的 position、limit、capacity,解释 flip() 到底做了什么,以及为什么 compact() 是读循环能继续的前提。

阅读导航:上一节:4.2 定长、分隔符与长度域帧 · 下一节:5.1 Buffer 的 position、limit 与 flip 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发