本节目标:把 linkd 的四个模块、JDK 25 工具链和 9090/8080 端口约定一次配好,并能用命令验证服务真的在监听。
适用版本:JDK 25(Temurin 25.0.5+8);Maven 3.9.12;macOS 26.3 (25D125)。
承接的问题
1.1 和 1.2 的代码都是单文件示例:一个 HealthHandler、一个 BlockingEchoExample、一个 NonBlockingEchoExample。它们适合解释机制,不适合继续堆功能。从第 2 章开始,linkd 要同时容纳缓冲区、帧解码、连接状态、HTTP 解析、路由和 WebSocket;如果还把所有类塞进一个目录,后面的每一节都会变成「在同一个文件里加一段」。
所以在写 TCP 之前,先把工程骨架搭好。这一节做四件事:
- 定义
linkd的四个 Maven 模块和依赖方向; - 固定 JDK 25 与 Maven 3.9.12,处理「默认
java是 JDK 8」这个本机事实; - 约定 9090/8080 端口、健康检查和只读接口;
- 给出启动、探测和诊断命令,并说明 macOS 上没有
ss、没有 epoll 时该用什么替代。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8;Maven 3.9.12。
四个模块与依赖方向
linkd 的父目录结构如下:
linkd/
├── pom.xml # 父 POM,packaging=pom
├── linkd-core/ # 缓冲区、帧、连接、事件循环
├── linkd-http/ # HTTP/1.1、HTTP/2
├── linkd-ws/ # WebSocket
└── linkd-app/ # main、路由、配置
每个模块只放一类东西:
| 模块 | 职责 | 关键类型 |
|---|---|---|
linkd-core | 与协议无关的传输层 | BufferPool、FrameDecoder、Connection、EventLoop、Handler |
linkd-http | HTTP 消息、解析、路由 | HttpRequest、HttpResponse、HttpParser、HttpRouter |
linkd-ws | WebSocket 帧与会话 | WebSocketFrame、WebSocketSession |
linkd-app | 入口、路由装配、配置 | LinkdMain、健康检查与只读接口的 Handler |
依赖方向是单向的:
linkd-app -> linkd-http -> linkd-core
linkd-app -> linkd-ws -> linkd-core
linkd-core 不依赖任何其他模块,也不依赖 HTTP 或 WebSocket 的概念;linkd-http 和 linkd-ws 只依赖 linkd-core;linkd-app 依赖三者并把它们装配起来。模块之间不循环依赖,否则 Maven 的 reactor 会报循环,类的可见性也会变得难以解释。
父 POM 负责版本和编译参数:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.plume.linkd</groupId>
<artifactId>linkd-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>linkd-core</module>
<module>linkd-http</module>
<module>linkd-ws</module>
<module>linkd-app</module>
</modules>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.14.4</junit.version>
</properties>
</project>
maven.compiler.release=25 会让 javac 按 Java 25 的 API 和 class 文件版本编译,输出 major version 69。这比单独设 source/target 更严格:source/target 只控制语法和字节码版本,不阻止你调用当前 JDK 才有的 API;release 会连 API 一起校验。
子模块只需要声明父 POM 和自己的 artifactId:
<parent>
<groupId>com.plume.linkd</groupId>
<artifactId>linkd-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>linkd-core</artifactId>
linkd-app 额外声明对另外三个模块的依赖:
<dependency>
<groupId>com.plume.linkd</groupId>
<artifactId>linkd-http</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.plume.linkd</groupId>
<artifactId>linkd-ws</artifactId>
<version>${project.version}</version>
</dependency>
linkd-app 通过 linkd-http 间接拿到 linkd-core,不需要重复声明;重复声明不会报错,但会让依赖图变乱。测试依赖只用 JUnit Jupiter 5.14.4,而且只在测试作用域;生产代码不引入 Netty、OkHttp、Apache HttpClient、Spring、Lombok、Guava、Jackson。日志用 System.getLogger,不绑定 SLF4J 实现。
JDK 25 与 JAVA_HOME
本机第一次打开终端时,java 不一定是你以为的那个 JDK。先看现状:
java -version
mvn -version
本机输出(节选,未设置 JAVA_HOME):
openjdk version "1.8.0_482"
OpenJDK Runtime Environment (Zulu 8.92.0.21-CA-macos-aarch64) (build 1.8.0_482-b08)
OpenJDK 64-Bit Server VM (Zulu 8.92.0.21-CA-macos-aarch64) (build 25.482-b08, mixed mode)
Apache Maven 3.9.12 (848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1)
Java version: 1.8.0_482, vendor: Azul Systems, Inc.
java 和 Maven 都跑在 JDK 8 上。这不是「Maven 版本旧」,而是 JAVA_HOME 没指向 JDK 25;Maven 启动时会优先用 JAVA_HOME,没有才用 PATH 里的 java。直接在这个环境里跑 mvn compile,maven.compiler.release=25 不会生效成你想要的结果:JDK 8 的 javac 不认识 record,会报「需要class, interface或enum」;如果源码只用 Java 8 语法,构建甚至会「成功」,但产物是 major version 52,后面用 JDK 25 加载时行为和你以为的不一样。
统一设置:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
java -version && javac -version && mvn -version
本机输出(节选):
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, shared mode)
javac 25.0.5
Apache Maven 3.9.12 (848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1)
Java version: 25.0.5, vendor: Eclipse Adoptium
三条命令缺一不可:java -version 确认运行时,javac -version 确认编译器,mvn -version 确认 Maven 自己用的是哪个 JDK。只检查 java 不够,因为 Maven 可能仍然从别处拿到旧的 JAVA_HOME。
把这三行写进 shell 配置(~/.zshrc 或 ~/.bash_profile)之前,先执行 ls -d "$JAVA_HOME" 确认路径存在;如果路径不存在,java -version 会直接报 No such file or directory,而不是悄悄退回 JDK 8。路径确认后再写进配置文件,避免每次开终端都要手动 export。
本卷所有命令都假设 JAVA_HOME 已设置。书中不再重复这三行,只写 java、mvn;如果你看到 release version 25 not supported 或 record 语法错误,先回到这一节检查 mvn -version。
端口、路径与协议约定
linkd 的端口和路径是跨章节契约,不能每节换一个:
| 项 | 值 | 说明 |
|---|---|---|
linkd HTTP | 127.0.0.1:9090 | 本卷所有实验的入口 |
libraryd HTTP | 127.0.0.1:8080 | 下游只读服务,可能未启动 |
| 健康检查 | GET /healthz | 返回 200 OK 与 ok |
| 只读接口 | GET /books/{id} | 代理到 libraryd 的 /books/{id} |
| WebSocket | /ws/echo | 文本帧回显,二进制帧原样回显 |
| HTTP/2 | 明文 h2c | 只做 prior knowledge,不做 TLS ALPN |
实验环境统一绑定 127.0.0.1,不绑定 0.0.0.0。这样做有两个好处:不会把未完成的服务器暴露到局域网;curl、nc、lsof 的目标地址没有歧义。等到第 15 章做压测时再讨论监听地址和连接数上限。
libraryd 是已有服务,端口 8080。本卷前几章不会假设它一定在运行:如果 8080 没有服务,linkd 的 /books/{id} 返回一个固定的假响应,并在响应头里加 X-Linkd-Stub: true。这是教学占位,不是生产代理;第 8 章实现真实代理后,这个头会去掉。不要把假响应当成 libraryd 的真实数据。
端口被占用时,启动会直接失败(节选):
Exception in thread "main" java.net.BindException: Address already in use
at java.base/sun.nio.ch.Net.bind0(Native Method)
at java.base/sun.nio.ch.Net.bind(Net.java:522)
at java.base/sun.nio.ch.ServerSocketChannelImpl.netBind(ServerSocketChannelImpl.java:335)
at java.base/sun.nio.ch.ServerSocketChannelImpl.bind(ServerSocketChannelImpl.java:297)
先找占用者,再决定是停掉旧进程还是换端口:
lsof -nP -iTCP:9090 -sTCP:LISTEN
不要看到 PID 就 kill -9。先确认它是不是上一次没退出的 linkd:ps -p <pid> -o pid,command。如果是自己的实验进程,正常 kill <pid> 让它走关闭钩子;kill -9 会跳过排空和关闭,留下 TIME_WAIT 和没写完的日志。
macOS 的诊断事实
这一节列出的命令会在第 15 章反复出现,先把「有什么、没有什么」说清楚。
没有 ss。 ss 是 Linux iproute2 的工具,macOS 不提供:
command -v ss
本机没有输出(退出码 1)。替代命令是:
netstat -anv -p tcp | grep 9090
lsof -nP -iTCP:9090
netstat 看状态和队列,lsof 看进程和文件描述符。lsof -nP -iTCP:9090 -sTCP:LISTEN 只列监听套接字;去掉 -sTCP:LISTEN 会同时列出已建立连接。
没有 epoll。 macOS 的 Selector 由 kqueue 实现,不是 epoll。1.2 的线程栈里已经看到 sun.nio.ch.KQueue.poll;写代码时不需要分支,排障时不要把 Linux 的 epoll 经验直接搬过来。本卷不写「Selector 就是 epoll」,也不给 macOS 的 epoll 实测。
tcpdump 需要 sudo,loopback 用 lo0。 抓 9090 的握手和关闭:
sudo tcpdump -i lo0 -nn port 9090
这条命令需要管理员权限,输出依赖实际流量;本节不贴「本机输出」,因为实验服务未必在跑。第 15 章会在有真实请求时抓包,并标注输出是本机还是示意。
ab 可用,wrk、hey 未安装。 压测基线用 /usr/sbin/ab,本机 ab -V 输出 ApacheBench, Version 2.3;不要写 wrk 或 hey 的实测数字,本机没有这两个命令。
启动与验证
在 linkd/ 根目录构建:
mvn -q -DskipTests package
四个模块都是本地类,启动时把各模块的 target/classes 放进 classpath 即可:
java -cp "linkd-app/target/classes:linkd-core/target/classes:linkd-http/target/classes:linkd-ws/target/classes" \
com.plume.linkd.app.LinkdMain
启动日志(示意输出,具体线程名和端口绑定信息以实现为准):
linkd 0.1.0-SNAPSHOT
event-loop-1 started
http listening on 127.0.0.1:9090
验证分三步。第一步,确认端口在监听:
lsof -nP -iTCP:9090 -sTCP:LISTEN
本机最小服务的输出形状如下(linkd 启动后 PID 和 FD 会不同):
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 6718 bingrong.yan 5u IPv6 0x9077098634c1ed59 0t0 TCP 127.0.0.1:9090 (LISTEN)
第二步,确认 TCP 能连上:
nc -vz -w 2 127.0.0.1 9090
本机最小服务的输出:
Connection to 127.0.0.1 port 9090 [tcp/websm] succeeded!
[tcp/websm] 是 /etc/services 给 9090 的服务名,不影响连接;nc 只是把端口反查成了名字。
第三步,确认 HTTP 语义:
curl -sS -D - http://127.0.0.1:9090/healthz
linkd 的响应应当包含:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 3
Connection: keep-alive
ok
上面的 HTTP 响应是示意输出:本节没有构建 linkd,只验证了最小服务能返回同样的状态行和 ok。状态码、Content-Length 和响应体必须对得上;如果 curl 卡住,先用 nc -vz 判断是 TCP 没通还是 HTTP 没回。
停止服务用 Ctrl-C。linkd 注册了关闭钩子,收到 SIGINT 后停止 accept、排空写队列、关闭连接;验证方式是再执行一次 lsof -nP -iTCP:9090 -sTCP:LISTEN,应当没有输出。优雅关闭的完整实现在 14.3,这里只要求进程能退出、端口能释放。
在 linkd 里落地
LinkdMain 的职责是装配,不是处理请求。它先创建 HttpRouter 并注册路由;accept 循环随后把每个已接受的 SocketChannel 连同对应的 Handler 注册到 EventLoop。路由注册的形状是:
HttpRouter router = new HttpRouter();
router.add("GET", "/healthz", req -> new HttpResponse(
200,
Map.of("Content-Type", "text/plain; charset=utf-8"),
"ok\n".getBytes(StandardCharsets.UTF_8)));
LinkdMain 随后读取 -Dlinkd.port、绑定 127.0.0.1:9090、安装关闭钩子并启动事件循环;accept 循环、Connection 的创建和 Handler 的挂载在 6.2 与 7.1 展开。真正启动时,它还会把 libraryd 的地址 127.0.0.1:8080 和 /books/{id} 的 Handler 一起装配进去。
linkd-core 里的共享类型在这一章只出现名字,不展开实现;BufferPool、FrameDecoder、Connection、EventLoop、Handler 的签名以模块表为准,分别在第 5、4、7、6 章实现。linkd-http 的 HttpRequest、HttpResponse、HttpParser、HttpRouter 在第 8 章实现,linkd-ws 的 WebSocketFrame、WebSocketSession 在第 11 章实现。现在只需要保证包名和类型名不漂移,后面各节才能互相引用。
常见错误与边界
错误一:只看 java -version,不看 mvn -version。 本机默认 java 是 1.8.0_482,Maven 也拿到 1.8;设置 JAVA_HOME 后两者才都变成 25.0.5。只改 PATH 不改 JAVA_HOME,Maven 可能仍然用旧 JDK。判断标准是 mvn -version 的 Java version: 行,不是 java -version。
错误二:在 JDK 8 下编译 Java 25 语法。 用 record 写一个类,父 POM 设了 maven.compiler.release=25,但 Maven 仍跑在 JDK 8,会得到(节选,路径已替换):
[ERROR] /path/src/main/java/probe/Probe.java:[2,8] 需要class, interface或enum
[ERROR] /path/src/main/java/probe/Probe.java:[3,19] 需要class, interface或enum
如果源码碰巧只用 Java 8 语法,构建可能「成功」,但 javap -v 显示的 major version 是 52(Java 8),不是 69。两种结果都不对:前者是编译失败,后者是产物版本不对。先修 JAVA_HOME,再谈代码。
错误三:端口被占用还反复重启。 BindException: Address already in use 不会告诉你占用者是谁。先 lsof -nP -iTCP:9090 -sTCP:LISTEN 找 PID,再 ps -p <pid> -o pid,command 确认是不是自己的进程。不要用 kill -9 跳过关闭钩子;正常 kill 会让 linkd 排空写队列。
错误四:在 macOS 上执行 ss。 本机 command -v ss 没有输出,执行 ss -lntp 会得到 zsh: command not found: ss。用 netstat -anv -p tcp 和 lsof -nP -iTCP:9090 替代;抓包用 sudo tcpdump -i lo0 -nn port 9090。
边界:libraryd 不在 8080 时,/books/{id} 返回假数据。 这是本卷的显式占位,响应头 X-Linkd-Stub: true 用来提醒「不是真实代理」。看到这个头不要排查 libraryd,先确认当前章节是否已经实现真实代理;第 8 章之后仍然出现这个头,才是 bug。
边界:端口约定不是「必须占用 9090」。 如果本机 9090 被别的服务占用,可以用 -Dlinkd.port=19090 启动做实验;但书中的命令、抓包过滤和压测脚本默认 9090,换端口后要同步改。端口可以变,/healthz 的语义和 9090/8080 的相对关系不要变。
小结
linkd分四个模块:linkd-core管传输层,linkd-http管 HTTP,linkd-ws管 WebSocket,linkd-app负责装配;依赖方向单向,不循环。- 父 POM 用
maven.compiler.release=25,产物是major version 69;子模块只声明父 POM 和artifactId。 - 本机默认
java和 Maven 都可能跑在 JDK 8;必须同时检查java -version、javac -version、mvn -version,并用JAVA_HOME统一。 - 端口约定:
linkd9090,libraryd8080;健康检查/healthz返回200 OK和ok,/books/{id}在libraryd不可用时返回带X-Linkd-Stub: true的假响应。 - macOS 没有
ss、没有 epoll;连接诊断用netstat、lsof,抓包用sudo tcpdump -i lo0,Selector底层是 kqueue。 - 启动后按「
lsof看监听 →nc看 TCP →curl看 HTTP」三步验证;停止用Ctrl-C,再用lsof确认端口释放。
环境就位后,下一节进入 TCP 本身:连接、字节流、可靠性边界,以及 Socket/ServerSocket 在 Java 里到底保证了什么、不保证什么。
阅读导航:上一节:1.2 BIO、NIO、AIO 与 Reactor 模型 · 下一节:2.1 TCP 连接、字节流与可靠性边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。