《Java 网络编程基础》1.3 linkd 项目与实验环境

把 linkd 拆成 core、http、ws、app 四个 Maven 模块,固定 JDK 25.0.5 与 9090/8080 端口约定;给出 JAVA_HOME 设置、父 POM、启动与 curl/lsof/netstat 验证步骤,并记录 macOS 没有 ss/epoll 时的替代诊断命令。

本节目标:把 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 之前,先把工程骨架搭好。这一节做四件事:

  1. 定义 linkd 的四个 Maven 模块和依赖方向;
  2. 固定 JDK 25 与 Maven 3.9.12,处理「默认 java 是 JDK 8」这个本机事实;
  3. 约定 9090/8080 端口、健康检查和只读接口;
  4. 给出启动、探测和诊断命令,并说明 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-httpHTTP 消息、解析、路由HttpRequest、HttpResponse、HttpParser、HttpRouter
linkd-wsWebSocket 帧与会话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 HTTP127.0.0.1:9090本卷所有实验的入口
libraryd HTTP127.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 统一。
  • 端口约定:linkd 9090,libraryd 8080;健康检查 /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 连接、字节流与可靠性边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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