网络基础、可运行示例与协议实战
2026-10-21
如果你还分不清服务器、端口和线程,可以从这里开始。本书先解释程序为什么需要网络通信,再带你运行一个小服务,最后理解 Netty 在背后做了什么。你不需要一开始就掌握高并发,也不用背下所有类名。
Netty 是 Java 的异步、事件驱动网络应用框架。你可以用它搭建 TCP 服务、HTTP 服务、WebSocket 服务,以及自定义协议的客户端和服务器。它提供网络基础设施;业务规则仍然需要你设计。
本书的终点很具体:你能够运行一个支持中文的行协议服务,解释消息如何经过 Pipeline,知道如何处理半包、内存和连接关闭,并能用测试证明协议在分片到达时仍然正确。
先读第 1~4 章并运行示例;第 5~10 章解释机制;第 11~14 章讲测试、工程化和排错;最后是学习计划、答案、速查表与资料来源。第一次遇到不熟悉的术语,可以先看例子,再回头看定义。
每章都有一个小练习。先自己回答,再到附录对照。你也可以只做一件事:每次改动代码之前,预测输出;运行之后,解释差异。
本书使用 Java 17 语法、Maven 和 Netty 4.1.139.Final。实际验证使用 JDK 21。4.2 是另一条正在发展的版本线,不应把两条版本线的配置片段随意混用。版本信息依据 2026 年 10 月 20 日取得的官方页面与 Maven Central 元数据,后续可能变化。[1][7]
所有主线代码均附在本书末尾,也随书提供完整 Maven
工程。电子书中输入代码比较麻烦时,可以直接使用工程目录。代码按教学目的设计,服务默认只监听本机
127.0.0.1。
把服务器理解为“等待请求并处理请求的程序”,客户端是“主动发起请求的程序”。这两种身份取决于它们在一次通信中的角色,同一个程序也可以同时充当二者。
IP
地址帮助定位网络中的主机,端口帮助操作系统把数据交给某个监听程序。127.0.0.1:8080
的含义是:当前这台机器的 8080 端口。localhost
通常指向本机,但可能解析为 IPv4 或 IPv6;本书明确使用 IPv4
地址,减少初学时的变量。
一个 TCP 连接通常用源 IP、源端口、目标 IP、目标端口这四个量区分。客户端也有端口,只是通常由操作系统临时分配。服务器的监听端口不能无限重复占用;启动两份相同服务时常会看到地址已被使用的错误。
TCP 提供可靠、有序的字节流。正常建立连接后,如果对端应用持续读取,你发送的字节会按顺序交给它;发生无法恢复的网络故障时,应用需要处理失败或超时。
这里最容易误解的是“字节流”。假设客户端连续发送:
hello\n
world\n
服务器可能一次读到 hello\nworld\n,也可能分三次读到
he、llo\nwo、rld\n。一次发送和一次接收没有一一对应关系。TCP
不知道哪个字节代表“一条业务消息”。
所以协议必须约定边界。本书前半部分规定:一条消息以换行符结束,内容采用
UTF-8,正文最长 1024 字节。换行符在 Java 字符串中写成
\n。在实际网络中发送的是换行字节,而不是反斜线与字母 n
两个字符。
UDP 保留数据报边界,但不保证送达和有序,适合能自行容忍或补偿丢包的场景。HTTP 是应用层协议;常见的 HTTP/1.1 和 HTTP/2 通常运行在 TCP 之上,HTTP/3 则基于 QUIC。不能把“HTTP”和“TCP”当成同层替代品。
Netty 可以处理多种传输与协议。学 Netty 时先从 TCP 开始,是因为更容易直接看到连接、字节和消息边界之间的关系。
动手想一想: 客户端连续调用两次发送,为什么服务器不一定收到两条完整消息?
你需要认识类、对象、方法、接口、异常和泛型。不要求熟练,但至少看懂下面的意思:
String message = "你好";
System.out.println(message);String 是类型,message
是变量,println 把内容输出到终端。new
创建对象;@Override
表示这里实现或覆盖了父类型的方法。try/finally
表示无论正常结束还是发生异常,都执行清理代码。
SimpleChannelInboundHandler<String> 中的
<String>
是泛型参数,表示该处理器接收字符串类型的入站消息。不要被类名长度吓到:本书会逐段解释它负责的工作。
安装 JDK 17 或 21,以及 Maven 3.9.x。Windows 可以使用安装程序或包管理器;macOS 和 Linux 可以使用对应的包管理器。确保安装的是 JDK,而不只是 JRE。
打开终端,执行:
java -version
javac -version
mvn -version三条命令都应该显示版本。mvn -version 还会列出 Maven
实际使用的 Java。若它与 java -version 不同,优先检查
JAVA_HOME 与 PATH。JAVA_HOME
应指向 JDK 根目录,不能指向 bin/java 文件。
本书命令在 macOS/Linux shell 中展示。Windows PowerShell 中的
mvn
命令相同;路径分隔符可调整为反斜线。遇到路径中有空格,应加引号。
Maven 会根据 pom.xml
下载依赖、编译源码和运行测试。项目主要目录如下:
examples/
pom.xml
src/main/java/book/LineServer.java
src/main/java/book/LineClient.java
src/test/java/book/ProtocolTest.java
src/main/java 放正式代码,src/test/java
放测试;book 是 Java
包名,也对应目录层级。target
是构建结果目录,可重新生成。
Netty 由多个模块组成。入门工程使用 netty-all
聚合依赖,减少手工挑选模块的负担。现代版本的 netty-all
不应被理解为“一个包含全部类的大 jar”;Maven
会解析它依赖的模块。正式项目可按需要选择
netty-transport、netty-codec
等模块,并保持版本一致。
小练习: 为什么不能只把下载到的一个很小的
netty-all jar 放进项目,就认定所有依赖都齐了?
进入随书工程的 examples 目录。执行:
mvn test
mvn compile exec:java -Dexec.mainClass=book.LineServer第一次下载依赖需要联网。测试完成后启动服务器,看到以下文字说明监听已成功:
LISTENING 8080
保留这个终端,再打开第二个终端,进入同一个 examples
目录:
mvn compile exec:java -Dexec.mainClass=book.LineClient客户端应依次输出:
收到:你好,Netty
再见
客户端先发送一条普通消息,再发送
quit。服务器写出“再见”,待这次写操作完成后关闭连接。关闭的是这条客户端连接,服务器仍然继续监听。
在服务器终端按 Ctrl+C 可以停止进程。示例中的 finally
负责程序正常退出该代码路径时清理资源;它不是完整的进程信号停机方案,第
13 章会讨论生产服务的停机顺序。
如果 8080 已被占用,两个终端都改用 9090:
mvn compile exec:java -Dexec.mainClass=book.LineServer -Dexec.args=9090
mvn compile exec:java -Dexec.mainClass=book.LineClient -Dexec.args=9090这两条命令分别在两个终端执行,先运行服务器。客户端参数也必须改,否则它仍会寻找 8080。
主线结构如下,完整可复制源码在附录 A:
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
try {
Channel server = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(/* 为每条新连接安装处理器 */)
.bind("127.0.0.1", 8080).sync().channel();
server.closeFuture().sync();
} finally {
boss.shutdownGracefully().sync();
worker.shutdownGracefully().sync();
}这段是结构说明,含有占位注释,运行时请用附录完整版本。boss
接收新连接,worker 处理已建立连接的
I/O。ServerBootstrap 负责组装启动配置;bind
发起监听;sync 等待异步操作完成;closeFuture
在监听 Channel 关闭时完成。
NioServerSocketChannel 表示基于 Java NIO
的监听通道。childHandler 配置的是接入后的连接。不要与
handler 混淆:服务器上的 handler
面向监听通道,childHandler 面向接受到的子通道。[1]
客户端发送字节
↓
操作系统接收 → worker 的 EventLoop
↓
按行切分 → UTF-8 解码 → 业务处理
↓
客户端接收 ← 网络发送 ← UTF-8 编码
如果你现在只记住“Netty 把网络事件交给我写的处理器”,就已经足够继续。
小练习: 客户端运行结束后,服务器为什么没有跟着退出?
在本书 TCP 示例中,客户端 Channel 可以理解为连接的操作入口,包含远端地址、是否活动、读写操作和 Pipeline。服务器监听 Channel 的职责不同:它接受新连接。
你可以调用 channel.writeAndFlush(message)
请求发送消息,但该方法返回时,不代表对方的业务已经处理完成。它返回一个
ChannelFuture,用来观察该次异步操作何时成功或失败。
EventLoop 负责处理注册到它上面的 Channel 事件和任务。通常一个 Channel 的 I/O 事件由同一个 EventLoop 线程处理,一个 EventLoop 可以管理多条连接。
因此,“有一万条连接就必须有一万个线程”并不成立。Netty 可以让有限的线程处理许多连接;但如果你在这个线程里执行耗时工作,同线程管理的其他连接也会被拖慢。
把 EventLoop 想成负责一排窗口的工作人员:每个窗口有事就处理一小步。若这个人突然花十秒钟搬货,其他窗口都得等。异步模型的价值来自快速处理事件,并让等待交给合适的机制。
启动线程中等待 bind().sync()
是为了确认监听结果。事件回调中则应避免对尚未完成的 Future
调用阻塞等待;这样可能阻塞 EventLoop,甚至形成死锁。
观察发送结果可以写成:
ctx.writeAndFlush("hello\n").addListener(future -> {
if (!future.isSuccess()) {
future.cause().printStackTrace();
ctx.close();
}
});Future 成功通常说明这次出站操作在本地传输层完成,不能证明对方程序读到、保存到数据库或提交了事务。业务上需要“确认处理成功”,必须设计应答消息、请求编号和超时。
直接 Thread.sleep、同步访问数据库、调用慢 HTTP
接口、读取大文件,都会占用当前处理器所在线程。如果它是
EventLoop,就会影响其他连接。第 12
章给出把业务处理移到独立线程池的方式。
小练习: 一个写操作 Future 成功,能否证明订单已经入库?需要什么额外约定?
每条连接建立时,我们安装:
ch.pipeline().addLast(
new LineBasedFrameDecoder(1024),
new StringDecoder(CharsetUtil.UTF_8),
new StringEncoder(CharsetUtil.UTF_8),
new ReplyHandler());LineBasedFrameDecoder
从字节流中找出以换行结束的完整帧,默认移除行分隔符。StringDecoder
把完整帧的 UTF-8 字节转换为 Java 字符串。ReplyHandler
根据字符串生成回答。StringEncoder
在发送时把字符串转换为字节。
入站事件大体沿加入顺序前进,出站事件大体沿反方向前进;只实现了相应方向接口的处理器才处理该方向的事件。[2]
加入顺序: 行解码器 → 字符串解码器 → 字符串编码器 → 业务处理器
入站方向: ──────────────────────────────────→
出站方向: ←──────────────────────────────────
把编码器放在业务处理器之前,是为了让业务处理器通过
ctx.writeAndFlush
发出的字符串,在出站传播时能遇到编码器。
ctx 是当前处理器的上下文。ctx.write
从当前上下文向前寻找出站处理器;channel.write 从 Pipeline
尾部发起出站传播。处理器位置改变时,两种写法可能走过不同的处理器集合。
同样,入站处理器要把事件交给后面的处理器,可以调用
ctx.fireChannelRead(msg)。如果既不转发也不处理消息,消息就在这里停止。涉及
ByteBuf 时,还必须考虑谁释放它。
本书业务处理器继承
SimpleChannelInboundHandler<String>,实现
channelRead0。它默认会在回调结束后自动释放匹配类型的引用计数消息。这里接收的是
String,没有 ByteBuf 的释放负担。
如果改成
SimpleChannelInboundHandler<ByteBuf>,回调结束后默认自动释放收到的
ByteBuf。把这个对象存起来稍后使用,或原样写出去时,必须先明确引用计数的所有权,不能照搬字符串写法。
带连接状态的处理器通常应每连接创建一个实例。ChannelInitializer
中 new ReplyHandler()
正是这样做的。只有确认处理器可安全共享,并符合 Netty
的共享约定时,才使用
@Sharable。一个处理器可能在多条连接的不同线程上被调用,普通成员变量不自动变成线程安全。
小练习: 若把 StringEncoder
放到业务处理器之后,业务里仍使用
ctx.writeAndFlush,会有什么风险?
“粘包”通常指一次读取包含多条消息,“半包”指一次读取只有消息的一部分。它们是字节流读取的正常现象。不能靠延时、一次发送后睡眠、关闭 Nagle 算法来建立可靠的业务边界。
常见方案如下:
| 方案 | 如何找边界 | 适用例子 | 主要限制 |
|---|---|---|---|
| 固定长度 | 每 N 字节一条 | 简单设备协议 | 变长内容浪费空间 |
| 分隔符 | 找到约定结束符 | 文本命令 | 正文须转义或禁止分隔符 |
| 长度字段 | 先读头部,再读指定长度 | 二进制协议 | 长度验证必须严格 |
| 更高层协议 | 使用已有标准编解码器 | HTTP、WebSocket | 须遵守标准的完整语义 |
UTF-8 中一个中文字符常占 3 个字节,但 TCP 可能在任意字节位置分开。如果把每次收到的 ByteBuf 独立转成字符串,可能把一个字符的前半截当成错误序列。
本书先由行解码器积累完整帧,再由字符串解码器一次转换。测试专门把“你”的第一个字节与其余字节拆开送入,验证结果仍为“你”。这说明测试要模拟网络允许的行为,不能只验证最理想的整包输入。
假设一条帧由 4 字节长度和 N 字节正文组成。长度字段使用大端序,表示正文长度,不包含自己的 4 字节。
00 00 00 03 | 61 62 63
正文长度 3 | a b c
可以使用:
new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)五个参数分别表示:最大帧总长度 1024;长度字段偏移量 0;字段宽度 4;额外调整值 0;解码后跳过前 4 字节。因为长度值已经表示正文长度,解码器会结合长度字段末端位置算出整帧长度。这里允许的正文最大值是 1020,而不是 1024。[3]
发送方向常搭配
LengthFieldPrepender(4),它默认将正文长度作为 4
字节头加到前面。若业务发字符串,Pipeline 可按以下加入顺序组装:
LengthFieldBasedFrameDecoder
StringDecoder
LengthFieldPrepender
StringEncoder
业务处理器
从业务处理器通过 ctx.writeAndFlush
发出字符串后,出站先把字符串变为字节,再在字节前添加长度。不要再添加换行符,除非新协议明确要求它。
如果允许客户端发送无限长、永远没有结束符的内容,服务端可能持续积累内存。必须限定帧长,并定义超限后的行为。示例的异常处理器会记录异常并关闭连接。
行解码器的默认超限报告时机与显式启用 failFast
时不同。测试采用
new LineBasedFrameDecoder(8, true, true),明确要求发现超限时尽早报错,避免把实现细节当成普遍保证。
小练习: 长度字段值为 12、头部为 4 字节时,线上整帧有多少字节?若最大帧长为 1024,正文最多多长?
ByteBuf 是 Netty 的字节容器。可以先把它理解为带读写位置的字节数组:[4]
0 readerIndex writerIndex capacity
| 已读区域 | 可读区域 | 可写区域 |
readableBytes()
表示当前可读字节数。readInt() 读取 4
字节并移动读下标;getInt(index)
读取指定位置而不移动读下标。writeInt 写入并推进写下标。
读取前必须确认有足够字节。如果自己实现
ByteToMessageDecoder,当数据不足时应直接返回,并且不要提前消耗无法恢复的头部。必要时使用绝对读取检查长度,完整后才移动下标。
部分 ByteBuf 使用直接内存或内存池,生命周期不能只依赖 Java
垃圾回收。引用计数用于表示还有多少使用者持有它。retain()
增加计数,release() 减少计数;到 0 后对象不可再用。[5]
最实用的原则是:接收并消费消息的一方负责释放;把消息转交给下一环节时,通常同时转交释放责任。 具体仍要服从所用处理器和 API 的契约。
在普通 ChannelInboundHandlerAdapter 中消费
ByteBuf,可以这样写:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
try {
ByteBuf buf = (ByteBuf) msg;
System.out.println(buf.readableBytes());
} finally {
ReferenceCountUtil.release(msg);
}
}这是额外示意代码,需要导入对应类型。如果调用
ctx.fireChannelRead(msg)
转发原对象,通常不应在同一路径再释放一次。若使用默认自动释放的
SimpleChannelInboundHandler<ByteBuf>,则不要在回调结束时额外手动释放同一份所有权。
slice() 与 duplicate()
通常共享底层内容,也不自动增加引用计数。源缓冲区释放后,派生视图不能凭空继续使用。需要独立延长生命周期时,可按契约使用
retainedSlice()、retainedDuplicate(),并在使用结束时释放。
copy()
复制内容,得到独立的缓冲区,代价是分配与复制。初学时先追求清楚的所有权,再考虑减少复制。
EmbeddedChannel.readOutbound() 取出的 ByteBuf
已经交给测试代码。测试读取后应在 finally 中
release()。测试结束调用
finishAndReleaseAll(),清理仍留在通道里的消息;已经被取走的对象仍由取走者负责。
遇到
IllegalReferenceCountException,检查是否重复释放或释放后使用。遇到泄漏告警,检查异常分支、消息丢弃分支和跨线程转交分支。开发排查时可打开更严格的泄漏检测,但它会增加开销。
小练习: 在
SimpleChannelInboundHandler<ByteBuf>
中,为什么不能直接把收到的 buf 存到列表里,等一分钟后再读?
| 事件 | 可以先这样理解 |
|---|---|
channelRegistered |
Channel 注册到 EventLoop |
channelActive |
连接进入活动状态 |
channelRead / channelRead0 |
收到一条进入当前处理器的消息 |
channelReadComplete |
当前一轮读事件结束,不代表业务消息结束 |
channelInactive |
连接变为非活动状态 |
exceptionCaught |
处理传播过来的异常 |
握手、鉴权等上层状态不一定与 channelActive 相同。例如
TCP 已连接,不代表 TLS 握手成功,更不代表用户已登录。
示例对 quit 的处理为:
ctx.writeAndFlush("再见\n")
.addListener(ChannelFutureListener.CLOSE);它表达“这次写操作完成后关闭”。如果先调 writeAndFlush
再立即调
close,就可能过早关闭,影响待发送的数据。即使监听器触发,也不能据此证明对方业务已消费这条消息。
write 是把消息交给出站处理过程,flush
请求把待写内容推进到底层。高吞吐场景可以批量 write 后再
flush;初学示例每次直接使用 writeAndFlush,便于观察。
拔网线、设备休眠、对端崩溃等情况下,本地不一定马上收到明确的关闭通知。应用要按业务要求设计空闲检测、请求超时、心跳和重连。
重连不是简单死循环:应设置退避、随机抖动、最大频率与退出条件;断线时已发送但未获应答的请求处于不确定状态。若要重试修改数据的操作,需要幂等键或去重机制。
小练习: channelReadComplete
能否当作“收到一条完整订单”的判断条件?
IdleStateHandler 按读、写或读写活动检测空闲,并向后传播
IdleStateEvent。它本身不会替你规定 PING/PONG
协议,也不会自动完成鉴权或判定业务健康。[6]
例如:
pipeline.addLast(new IdleStateHandler(30, 0, 0));这个构造形式的时间单位为秒,表示一段时间未读到数据时触发读空闲事件。若它放在解码器前,读活动主要体现网络字节到达;放在某些解码器后,观察到的可能是解码后的消息活动。应根据目标定义它的位置。
可以约定客户端每隔一定时间发送 PING\n,服务端回答
PONG\n。客户端如果连续超时未得到响应,关闭连接并进入重连流程。服务端若长时间无入站数据,也可关闭空闲连接。
注意:本书默认的 ReplyHandler 尚未实现 PING/PONG,它会把 PING 当作普通文本回复。实现心跳需要增加对应分支,并写测试。
当处理空闲事件时,可以在 userEventTriggered 中检查
evt 是否是
IdleStateEvent。若不是本处理器负责的事件,应调用
ctx.fireUserEventTriggered(evt) 继续传播。
连接超时控制“建立连接等多久”;读空闲控制“一段时间没有读到活动”;请求超时控制“某个请求等应答多久”。它们解决的不是同一个问题。
一个连接可能一直有心跳,却有某个业务请求永远没有完成。因此请求超时需要按请求 ID 单独管理,并在完成或失败后清除定时任务和待处理表。
小练习: 每秒都收到心跳,是否可以取消所有业务请求超时?
在写编解码器前,定义字段与约束。例如:
| 字段 | 大小 | 约定 |
|---|---|---|
| magic | 2 字节 | 固定值,帮助拒绝明显错误输入 |
| version | 1 字节 | 协议版本 |
| type | 1 字节 | 请求、应答、心跳等 |
| requestId | 8 字节 | 关联请求与应答 |
| bodyLength | 4 字节 | 正文字节数,大端序 |
| body | 可变 | UTF-8 JSON 或约定的二进制格式 |
以上是设计练习,主线工程没有实现这个二进制协议。不要把这张表误认为可以直接与行协议客户端通信。
头部总长为 16 字节,bodyLength 位于偏移 12,宽度 4,表示正文长度。若希望保留整个头部供后续解码器读取,可以使用:
new LengthFieldBasedFrameDecoder(1048576, 12, 4, 0, 0)长度字段末端正好是头部末端,因此调整值为 0;最后一个 0 表示不剥离头部。最大整帧为 1 MiB,正文最多为 1 MiB 减 16 字节。
之后的业务消息解码器应校验 magic、版本、type 和长度,再解析正文。字段值不可信,不能因为它来自已建立的 TCP 连接就跳过校验。
客户端为请求分配 requestId,保存待完成结果;服务端应答携带相同编号。应答到达后找到对应请求并完成它;超时后删除记录,避免内存一直增长。
请求编号必须在尚未完成的请求范围内保持唯一。断线时要统一失败化尚未完成的请求,否则调用方可能永远等待。若发生重连,还要考虑旧连接的应答与新连接请求之间的隔离。
JSON 便于初学和调试,但仍须限制正文大小、嵌套深度和字段范围。不要为了省事,把不可信网络输入交给 Java 原生对象反序列化。
公网传输敏感信息时,应使用 TLS,并在客户端验证证书和目标主机身份。加密、认证、授权是不同的问题:建立加密连接,不等于请求者有操作数据的权限。
小练习: 为什么只有 requestId,还不足以保证断线重试不会重复扣款?
手工看到“收到:你好”只能证明某次输入成功。它没有证明分片、合并、超长输入和关闭时机都正确。网络相关程序尤其需要构造边界条件。
Netty 的 EmbeddedChannel 允许在内存中运行
Pipeline,不需要占用真实端口。向 writeInbound 喂字节,检查
readOutbound
得到的响应,就能测试编解码器和业务处理器的组合。
ch.writeInbound(Unpooled.copiedBuffer("he", CharsetUtil.UTF_8));
assertNull(ch.readOutbound());
ch.writeInbound(Unpooled.copiedBuffer("llo\nworld\n", CharsetUtil.UTF_8));第一步不应产生回复,因为还没遇到换行。第二步应产生两条响应,分别对应 hello 和 world。这样一次同时覆盖了消息分片与消息合并。
注意:writeInbound
返回值表示是否有消息留在入站队列供读取,不是“网络读取是否成功”。本例业务处理器消费了入站消息并生成出站消息,所以判断业务结果应读取出站队列。
工程包含五项测试:普通文本的分片与合并;UTF-8 字符内部的分片;quit 的响应与关闭;超长行的拒绝;长度字段帧的分片重组。测试源码见附录 A。
这组测试支持教学示例的主要行为,但没有验证大规模并发、TLS、网络抖动下的长期运行或生产吞吐能力。测试结论必须限定在实际覆盖的范围内。
你可以增加:空行、CRLF、刚好等于长度上限、断线时残缺帧、连续多条消息、错误 UTF-8、连接失败。若业务要求严格拒绝非法 UTF-8,需要显式设计严格解码策略;普通字符串转换不应被当作输入合法性校验。
真实端到端测试则要启动服务器,建立 Socket 连接,发送字节并验证响应。它验证启动配置与网络路径,而 EmbeddedChannel 更适合精确测试 Pipeline 的边界行为。两者相互补充。
小练习: 为什么“整条中文一次输入能通过”还不足以证明中文处理正确?
假设收到一行后要同步访问数据库。可以给业务处理器指定单独的执行器组,例如:
DefaultEventExecutorGroup business = new DefaultEventExecutorGroup(4);
// 初始化每条连接时:
pipeline.addLast(business, "business", new MyBusinessHandler());这段是线程模型示意,MyBusinessHandler 需要你实现,并导入
io.netty.util.concurrent.DefaultEventExecutorGroup。执行器组应在服务级创建并复用,不能每条连接创建一组,停机时也要关闭。
把整个处理器交给这种执行器组,与在回调里临时向普通线程池提交每个请求,顺序语义不同。若自己把请求并行提交,后发请求可能先完成;需要按协议决定是否允许乱序,以及怎样关联应答。
业务线程池、待处理任务、数据库连接池都有限。若接收速度长期大于处理速度,队列会增长,内存和延迟都会恶化。
应明确容量和拒绝策略:队列满时返回繁忙、限制读入、拒绝新请求或关闭连接。对于会修改数据的请求,还要明确“拒绝”发生在执行前还是执行后,避免客户端误判。
当客户端读取很慢时,服务端出站数据可能积压。channel.isWritable()
是基于写缓冲水位等状态的信号,不是对方能否处理业务的证明,也不是硬性的内存上限。
可以监听
channelWritabilityChanged,在不可写时暂停产生更多消息,恢复可写后继续。仅仅忽略
isWritable
并持续发送,仍可能造成内存压力。设置水位并不会自动替业务拒绝所有后续写入。
autoRead
可以影响是否自动继续读入。更复杂的服务会结合高低水位暂停与恢复读取。但暂停读取时必须有可靠的恢复路径,否则连接可能一直卡住。初学阶段先把帧长限制、业务队列容量与超时做好,再逐步增加流控。
小练习: 为什么把数据库查询放进线程池后,仍然可能出现内存不断增长?
处理 HTTP/1.x 通常从 HttpServerCodec 开始。若希望把分片
HTTP 内容聚合成完整请求,可以再使用有明确大小上限的
HttpObjectAggregator。聚合方便开发,但意味着要在内存中保存整个消息,上传大文件时更应考虑流式处理。
WebSocket 通常先经 HTTP 握手,再升级协议;可研究
WebSocketServerProtocolHandler。WebSocket
帧有边界与控制帧语义,不能把 TCP 行协议的逻辑原样套过去。
本章给出方向,不包含完整 HTTP/WebSocket 工程。主线教学工程始终使用 TCP 行协议,避免在一个入门项目里同时引入太多状态。
TLS 处理通常放在 Pipeline
靠前位置,使后续协议处理器接触解密后的应用数据,出站则先编码再加密。可从
SslContextBuilder 与 SslHandler
的官方说明继续学习。
本机练习可以先明文运行。部署时要根据实际访问范围配置证书、密钥保护、身份验证和权限控制;不能把用于测试的信任所有证书配置复制到正式客户端。
一个生产服务常见的停机顺序是:停止接受新连接;停止接收新业务请求;等待进行中的业务在限定时间内结束;关闭现有连接;关闭业务线程池;关闭 EventLoopGroup。
本书主线代码便于阅读,只展示了 EventLoopGroup 的清理。若扩展为常驻服务,应跟踪监听通道、子连接和业务任务,并接入应用生命周期或 shutdown hook。关闭要有期限,不能因为一条慢请求让停机无限等待。
日志至少要能关联连接、请求编号和异常原因。不要直接把口令、令牌与完整敏感正文写入日志。网络日志处理器能帮助观察字节,但在高流量下会产生大量日志和额外开销。
值得观察的指标包括:活动连接数、连接建立失败数、请求延迟、业务队列长度、超时次数、出站积压和直接内存使用情况。先通过指标确认瓶颈,再考虑调整线程数、TCP 参数或内存分配器。
小练习: 为什么服务端把消息写成功的次数,不能直接当作业务成功次数?
遵循从外到内的顺序:进程是否启动 → 是否监听正确地址和端口 → 客户端是否连通 → 字节是否到达 → 帧是否完整 → 解码是否正确 → 业务是否执行 → 出站编码与发送是否完成。
每一步都要收集证据。比如“客户端没有输出”可能是没连上,也可能是忘记换行、没有 flush,或响应被错误的 Pipeline 顺序挡住。直接增加线程数通常解决不了这些问题。
| 现象 | 优先检查 | 操作方向 |
|---|---|---|
Address already in use |
端口被占用 | 停止旧进程或双方改端口 |
Connection refused |
服务未启动或端口不对 | 看 LISTENING 与客户端参数 |
| 连接一直超时 | 地址、路由、防火墙 | 先在本机测试,再逐步跨机器 |
| 连上但不回复 | 行分隔符、处理器顺序 | 确认发送实际换行字节 |
| 中文乱码 | 双方字符集、切帧顺序 | 统一 UTF-8,先切帧后转字符串 |
TooLongFrameException |
超过协议限制 | 检查发送内容,合理设置上限 |
IllegalReferenceCountException |
重复释放、释放后使用 | 逐条跟踪所有权 |
| 内存持续上升 | 泄漏、队列积压、大消息 | 区分 ByteBuf 泄漏与活跃对象堆积 |
| 少量请求很慢 | EventLoop 阻塞 | 检查数据库、sleep、文件 I/O |
| 客户端迟迟不结束 | 等不到服务器关闭 | 确认发送 quit 且带换行 |
假设服务器显示已经监听,客户端也连接成功,但输入 hello
后没有回复。先确认发送的是 hello\n
对应的实际换行,而不是仅仅
hello。行解码器会等待边界,因此没有收到完整帧时不回复是正确行为。
若换行已发送,再检查是否把 StringEncoder
放到了业务处理器之后;若业务使用
ctx.writeAndFlush,该编码器可能没有经过。再检查异常日志,确认不是不支持的出站消息类型。
最后检查对端是否持续读取。写成功、读取成功和业务处理成功要分别观察,不能用某一个日志替代所有层面的证据。
小练习: 当所有连接一起变慢,而 CPU 很低时,为什么仍然要检查 EventLoop 中的阻塞调用?
| 天数 | 任务 | 完成标准 |
|---|---|---|
| 第 1 天 | 阅读第 1~2 章 | 用自己的话解释端口与字节流 |
| 第 2 天 | 运行服务端和客户端 | 看见两条预期响应 |
| 第 3 天 | 手抄启动代码 | 说清 boss、worker 与 bind |
| 第 4 天 | 阅读 Pipeline | 画出双向处理顺序 |
| 第 5 天 | 修改业务回复 | 预测并验证输出 |
| 第 6 天 | 学习帧边界 | 解释半包与粘包 |
| 第 7 天 | 阅读与运行测试 | 看懂每个断言的目标 |
| 第 8 天 | 学习 ByteBuf | 区分读写下标与引用计数 |
| 第 9 天 | 练习长度字段 | 从协议表推出解码器参数 |
| 第 10 天 | 设计心跳 | 写清超时、应答与关闭规则 |
| 第 11 天 | 理解线程与队列 | 找出哪些操作会阻塞 |
| 第 12 天 | 按排错表演练 | 故意去掉换行并定位原因 |
| 第 13 天 | 实现一个扩展命令 | 添加代码和行为测试 |
| 第 14 天 | 复述完整请求路径 | 从字节到业务再到响应讲一遍 |
如果 Java 基础薄弱,可以把每一天扩展为两到三天。进度的标准是能够解释和复现,不是翻过多少页。
在行协议上增加三个命令:ECHO 内容
原样返回内容,TIME 返回当前时间,QUIT
回复后关闭。先写命令大小写、空参数、未知命令和空行的处理规则,再实现。
验收时至少覆盖:一条命令分两次到达;一次输入多条命令;中文内容;未知命令;超长输入;退出应答。不要只测试新命令的正常路径。
约定 请求编号|命令|参数
的文本格式,响应保留请求编号。明确参数中是否允许
|,若允许,就设计转义或换用成熟序列化格式。
随后让客户端同时发送多个请求,维护待处理表与超时。观察关闭连接时是否清理全部待处理项。这一步能够把“TCP 连接”与“业务请求”真正区分开来。
为每条连接增加昵称与登录状态,把消息广播给其他连接。要考虑昵称冲突、断线清理、慢客户端、广播消息所有权与房间容量。
先用不可变字符串做广播,可以减少 ByteBuf 所有权问题。共享房间表需要线程安全设计,因为不同连接可能由不同 EventLoop 管理。不要为了复用,把所有连接状态随意塞入一个未同步的 HashMap。
到这里,你已经具备继续阅读官方示例和更复杂协议实现的基础。下一步可以按兴趣学习 HTTP、WebSocket、TLS 或协议设计,不必立即钻进所有底层源码。
本附录内容由随书工程源文件直接生成,避免电子书与工程发生手工抄写差异。请按标出的路径保存文件,并保留
package book;。项目根目录是 examples,所有
Maven 命令都在该目录执行。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion><groupId>book</groupId><artifactId>netty-beginner</artifactId><version>1.0.0</version>
<properties><maven.compiler.release>17</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><netty.version>4.1.139.Final</netty.version></properties>
<dependencies><dependency><groupId>io.netty</groupId><artifactId>netty-all</artifactId><version>${netty.version}</version></dependency><dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.11.4</version><scope>test</scope></dependency></dependencies>
<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.2</version></plugin><plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version></plugin></plugins></build></project>package book;
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.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.util.CharsetUtil;
public final class LineServer {
public static void main(String[] args) throws InterruptedException {
int port = args.length == 0 ? 8080 : Integer.parseInt(args[0]);
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
try {
Channel server = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(
new LineBasedFrameDecoder(1024),
new StringDecoder(CharsetUtil.UTF_8),
new StringEncoder(CharsetUtil.UTF_8),
new ReplyHandler());
}
}).bind("127.0.0.1", port).sync().channel();
System.out.println("LISTENING " + port);
server.closeFuture().sync();
} finally {
boss.shutdownGracefully().sync();
worker.shutdownGracefully().sync();
}
}
static final class ReplyHandler extends SimpleChannelInboundHandler<String> {
@Override protected void channelRead0(ChannelHandlerContext ctx, String msg) {
if ("quit".equals(msg)) {
ctx.writeAndFlush("再见\n").addListener(ChannelFutureListener.CLOSE);
} else {
ctx.writeAndFlush("收到:" + msg + "\n");
}
}
@Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
}package book;
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.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.util.CharsetUtil;
public final class LineClient {
public static void main(String[] args) throws InterruptedException {
int port = args.length == 0 ? 8080 : Integer.parseInt(args[0]);
EventLoopGroup group = new NioEventLoopGroup(1);
try {
Channel ch = new Bootstrap().group(group).channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override protected void initChannel(SocketChannel c) {
c.pipeline().addLast(new LineBasedFrameDecoder(2048),
new StringDecoder(CharsetUtil.UTF_8),
new StringEncoder(CharsetUtil.UTF_8),
new SimpleChannelInboundHandler<String>() {
@Override protected void channelRead0(ChannelHandlerContext ctx, String msg) {
System.out.println(msg);
}
@Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace(); ctx.close();
}
});
}
}).connect("127.0.0.1", port).sync().channel();
ch.writeAndFlush("你好,Netty\nquit\n").sync();
ch.closeFuture().sync();
} finally {
group.shutdownGracefully().sync();
}
}
}package book;
import io.netty.buffer.*;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.*;
import io.netty.handler.codec.string.*;
import io.netty.util.CharsetUtil;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ProtocolTest {
private EmbeddedChannel channel() {
return new EmbeddedChannel(new LineBasedFrameDecoder(1024),
new StringDecoder(CharsetUtil.UTF_8), new StringEncoder(CharsetUtil.UTF_8),
new LineServer.ReplyHandler());
}
private String take(EmbeddedChannel ch) {
ByteBuf b = ch.readOutbound();
assertNotNull(b);
try { return b.toString(CharsetUtil.UTF_8); } finally { b.release(); }
}
@Test void splitAndCombinedMessages() {
EmbeddedChannel ch = channel();
try {
ch.writeInbound(Unpooled.copiedBuffer("he", CharsetUtil.UTF_8));
assertNull(ch.readOutbound());
ch.writeInbound(Unpooled.copiedBuffer("llo\nworld\n", CharsetUtil.UTF_8));
assertEquals("收到:hello\n", take(ch));
assertEquals("收到:world\n", take(ch));
} finally { ch.finishAndReleaseAll(); }
}
@Test void utf8MaySplitInsideCharacter() {
EmbeddedChannel ch = channel();
byte[] bytes = "你\n".getBytes(CharsetUtil.UTF_8);
try {
ch.writeInbound(Unpooled.wrappedBuffer(new byte[]{bytes[0]}));
assertNull(ch.readOutbound());
ch.writeInbound(Unpooled.wrappedBuffer(java.util.Arrays.copyOfRange(bytes,1,bytes.length)));
assertEquals("收到:你\n",take(ch));
} finally { ch.finishAndReleaseAll(); }
}
@Test void quitFlushesThenCloses() {
EmbeddedChannel ch = channel();
try {
ch.writeInbound(Unpooled.copiedBuffer("quit\n", CharsetUtil.UTF_8));
assertEquals("再见\n",take(ch));
assertFalse(ch.isOpen());
} finally { ch.finishAndReleaseAll(); }
}
@Test void oversizedLineRejected() {
EmbeddedChannel ch = new EmbeddedChannel(new LineBasedFrameDecoder(8, true, true));
try {
assertThrows(TooLongFrameException.class, () -> ch.writeInbound(
Unpooled.copiedBuffer("123456789",CharsetUtil.UTF_8)));
} finally { ch.finishAndReleaseAll(); }
}
@Test void lengthFieldSurvivesFragmentation() {
EmbeddedChannel ch = new EmbeddedChannel(new LengthFieldBasedFrameDecoder(1024,0,4,0,4));
try {
ByteBuf first = Unpooled.buffer().writeInt(3).writeByte(1);
assertFalse(ch.writeInbound(first));
assertTrue(ch.writeInbound(Unpooled.wrappedBuffer(new byte[]{2,3})));
ByteBuf result = ch.readInbound();
try { assertEquals(3,result.readableBytes());assertEquals(1,result.readByte());assertEquals(2,result.readByte());assertEquals(3,result.readByte()); }
finally { result.release(); }
} finally { ch.finishAndReleaseAll(); }
}
}两次发送不一定对应两次接收: TCP 提供字节流,不保留应用调用 send/write 的边界。接收缓冲区、网络传输与读取时机都会影响每次读到的字节数。需要协议定界。
netty-all 不等于单个全量 jar: 现代版本通过 Maven 传递依赖带入多个模块。应使用构建工具解析依赖,而不是只复制聚合 jar。
客户端结束而服务端继续: 客户端连接 Channel 与监听 Channel 不同。关闭一条连接不会自动关闭监听通道,服务器仍可接受其他连接。
写 Future 与入库: 写操作成功不能证明业务处理成功。应设计带请求编号的业务应答,明确应答是在事务提交前还是提交后发出,并配置请求超时。
编码器位置: ctx.writeAndFlush
从当前上下文向前走出站路线。业务处理器之后的编码器可能被跳过,导致字符串无法正确变成网络字节。
长度字段: 正文 12 字节加 4 字节头,共 16 字节。整帧上限 1024 时,正文最多 1020 字节。前提是长度字段不包含自身、没有其他头尾字段。
延后使用 ByteBuf: SimpleChannelInboundHandler 默认在回调结束后释放匹配消息。延后使用需要明确 retain 与 release 配对,或复制所需内容;共享视图不自动延长生命。
readComplete 与完整订单: 前者表示一轮读取事件结束,不是业务边界。应由协议解码器产出完整订单消息。
心跳不能替代请求超时: 心跳只能反映某种连接或协议活动,具体业务仍可能卡死。每个请求需要自己的期限与清理逻辑。
requestId 与重复扣款: requestId 仅用于关联应答,不自动提供幂等。服务端需要稳定的业务幂等键、去重记录或事务约束,并定义重试语义。
中文测试: 整包输入没覆盖 UTF-8 字符跨读取边界的情况。应在一个多字节字符内部切分输入,再检查最终结果。
线程池仍可能积压: 进入队列的速度长期高于消费速度时,待处理任务会增长。线程池还可能受数据库连接池和外部服务吞吐限制,需要队列上限与背压。
写成功计数不等于业务成功: 本地发送、对端读取和业务提交属于不同阶段。指标必须对应真正要衡量的阶段。
CPU 低也可能阻塞: 等待数据库、锁或网络时线程不消耗很多 CPU,却占着 EventLoop,导致其他连接的事件无法及时处理。
| 术语 | 本书中的含义 |
|---|---|
| Channel | 网络通道的操作入口,监听通道与连接通道职责不同 |
| EventLoop | 执行通道事件与任务的循环及其线程机制 |
| EventLoopGroup | 管理一组 EventLoop |
| Bootstrap | 客户端启动配置器 |
| ServerBootstrap | 服务端启动配置器 |
| Pipeline | 每个 Channel 的处理器链 |
| Handler | 处理某类事件或消息的对象 |
| Context | 处理器在 Pipeline 中的上下文 |
| Future | 可观察异步操作完成状态的结果对象 |
| Frame | 根据协议切出的完整消息单元 |
| ByteBuf | Netty 字节缓冲容器 |
| Backpressure | 消费跟不上时限制继续生产或接收的机制 |
| Idempotency | 相同业务操作重复提交仍保持约定结果的性质 |
常用命令,均在 examples 目录执行:
mvn test
mvn compile exec:java -Dexec.mainClass=book.LineServer
mvn compile exec:java -Dexec.mainClass=book.LineClient
mvn dependency:tree前三条分别用于测试、启动服务端、运行客户端。服务端需要单独终端持续运行。最后一条用于查看实际解析的依赖版本;当出现模块版本不一致时尤其有用。
本书是面向初学者的原创组织与讲解,参考下列官方资料核对概念和 API,不是官方文档的逐段翻译。检索与访问日期:2026 年 10 月 20 日。在线文档会更新;本书示例依赖固定为 4.1.139.Final。
Netty User guide for 4.x:https://netty.io/wiki/user-guide-for-4.x.html。用于核对启动器、EventLoopGroup、Channel、异步结果和基础 TCP 示例。适合完成第一个程序后对照阅读。
ChannelPipeline API:https://netty.io/4.1/api/io/netty/channel/ChannelPipeline.html。用于核对入站与出站方向,以及 Channel、Context 发起事件的起点差异。对应第 5 章。
LengthFieldBasedFrameDecoder API:https://netty.io/4.1/api/io/netty/handler/codec/LengthFieldBasedFrameDecoder.html。用于核对长度字段偏移、字段宽度、调整值、剥离长度和整帧长度的关系。对应第 6、10 章。
ByteBuf API:https://netty.io/4.1/api/io/netty/buffer/ByteBuf.html。用于核对读写下标、派生缓冲区、copy 与 retained 方法。对应第 7 章。
Reference counted objects:https://netty.io/wiki/reference-counted-objects.html。用于核对引用计数和消息所有权。建议在开始直接操作 ByteBuf 前完整阅读。
IdleStateHandler API:https://netty.io/4.1/api/io/netty/handler/timeout/IdleStateHandler.html。用于核对空闲事件、时间配置和处理方式。对应第 9 章。
Maven Central Netty 版本元数据:https://repo.maven.apache.org/maven2/io/netty/netty-all/maven-metadata.xml。用于检查实际发布版本。取得的元数据显示 4.1.139.Final 与 4.2.19.Final;它不是安全更新公告,生产项目升级前仍需查看发布说明并回归验证。
Netty 官方源码与示例:https://github.com/netty/netty。继续阅读时优先选择与依赖版本相同的标签,例如
netty-4.1.139.Final,再查找 example 模块。
Netty 安全公告:https://github.com/netty/netty/security/advisories。用于后续维护时跟踪受影响版本与修复版本。固定版本有助于复现,不代表可以长期忽略安全更新。
阅读源码建议从你已经用过的类出发:先看 LineBasedFrameDecoder,再看 ByteToMessageDecoder,最后研究 EventLoop 与传输实现。带着具体问题读,例如“没有换行时字节存在哪里”,通常比从第一行一路读到底更有效。