《Netty 高性能网络编程》1.3 linkd-v2 工程与依赖

本节搭 linkd-v2 四模块 Maven 工程:父 POM 固定 netty.version=4.2.19.Final 与 maven.compiler.release=25,模块保持单向依赖,入口 LinkdV2Main 用 System.Logger 打印协议常量,并给出 mvn -q -DskipTests package 的构建结果与 netty-all 聚合 POM 的验证方法。

本节目标:把 1.2 的单文件 Echo 变成可重复构建的四模块工程,并让版本、编译级别和依赖方向只有一处定义。
适用版本:JDK 25(Temurin 25.0.5+8)+ Maven 3.9.12 + Netty 4.2.19.Final。

承接的问题

1.2 的 Echo 能跑,但依赖是临时拼的 classpath,类放在哪个目录、版本从哪里来、以后新增 HTTP 和 gRPC 模块往哪放,都没有约束。50 篇的代码要落在同一套工程里,第 1 章必须先把骨架定下来。

linkd-v2 按职责拆成四个模块:linkd-core 放协议帧与编解码,linkd-server 放 HTTP、WebSocket、HTTP/2 和网关,linkd-client 放客户端、重连和连接池,linkd-app 只做入口与生命周期。拆分的目的是让依赖方向可检查,而不是为了目录好看。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Maven 3.9.12,Netty 4.2.19.Final。下面的构建输出来自这台机器,时间是单次运行,不作为性能结论。

目录与依赖方向

工程的目录结构如下,包名与 BRIEF 的共享类型一致:

linkd-v2/
├── pom.xml
├── linkd-core/
│   ├── pom.xml
│   └── src/main/java/com/plume/linkd/v2/protocol/
│       ├── ProtocolConstants.java
│       ├── FrameType.java
│       └── LinkdFrame.java
├── linkd-server/
│   ├── pom.xml
│   └── src/main/java/com/plume/linkd/v2/server/
├── linkd-client/
│   ├── pom.xml
│   └── src/main/java/com/plume/linkd/v2/client/
└── linkd-app/
    ├── pom.xml
    └── src/main/java/com/plume/linkd/v2/app/LinkdV2Main.java

依赖方向只有三条边:

linkd-app ──► linkd-server ──► linkd-core
    │                             ▲
    └────────► linkd-client ──────┘

linkd-core 不依赖任何其他模块;linkd-server 和 linkd-client 都只依赖 linkd-core;linkd-app 依赖另外三个。这样 LinkdFrameDecoder 不会被 server 的 HTTP 类型污染,client 也不会被迫引入 HttpServerCodec。

linkd-client 在第 1 章还没有源码,Maven 仍会为它打一个空 jar。这是正常的:模块先占位,第 8 章再填 RpcClient 和 ReconnectHandler。空 jar 不影响 reactor 构建,但部署时不要把它当成有内容的库。

父 POM:属性与 dependencyManagement

父 POM 只做三件事:声明模块、锁定版本、提供依赖管理。下面是完整文件:

<?xml version="1.0" encoding="UTF-8"?>
<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>com.plume.linkd.v2</groupId>
  <artifactId>linkd-v2</artifactId>
  <version>0.2.0-SNAPSHOT</version>
  <packaging>pom</packaging>

  <modules>
    <module>linkd-core</module>
    <module>linkd-server</module>
    <module>linkd-client</module>
    <module>linkd-app</module>
  </modules>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>25</maven.compiler.release>
    <netty.version>4.2.19.Final</netty.version>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>${netty.version}</version>
      </dependency>
      <dependency>
        <groupId>com.plume.linkd.v2</groupId>
        <artifactId>linkd-core</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>com.plume.linkd.v2</groupId>
        <artifactId>linkd-server</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>com.plume.linkd.v2</groupId>
        <artifactId>linkd-client</artifactId>
        <version>${project.version}</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

几个点值得说明:

  • packaging 是 pom,父工程不产出 jar;modules 的顺序与阅读顺序一致,reactor 仍会按依赖关系排序。
  • maven.compiler.release=25 写在属性里,默认的 compiler 插件会读取它,等价于 javac --release 25。这样编译出的 class 文件目标版本固定,不依赖本机 JAVA_HOME 的默认值。
  • netty.version 只出现一次。四个模块的 POM 里都不写版本,升级时只改这一行。
  • 内部模块用 ${project.version} 管理版本,子模块引用时同样省略版本,避免 linkd-core 的版本和父工程漂移。

这里没有显式声明 maven-compiler-plugin 的版本,用的是 Maven 3.9.12 绑定的默认版本。团队如果要求完全可复现,应该在 CI 里锁定插件版本;本书不引入额外的 pluginManagement,保持 POM 易读。

子模块 POM:谁依赖谁

linkd-core 的 POM 最短,只引 Netty:

<parent>
  <groupId>com.plume.linkd.v2</groupId>
  <artifactId>linkd-v2</artifactId>
  <version>0.2.0-SNAPSHOT</version>
</parent>

<artifactId>linkd-core</artifactId>

<dependencies>
  <dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
  </dependency>
</dependencies>

linkd-server 和 linkd-client 把上面的 netty-all 换成 linkd-core,不直接引 Netty;它们通过传递依赖拿到 ByteBuf 和 Channel。linkd-app 则同时依赖三个模块:

<dependencies>
  <dependency>
    <groupId>com.plume.linkd.v2</groupId>
    <artifactId>linkd-core</artifactId>
  </dependency>
  <dependency>
    <groupId>com.plume.linkd.v2</groupId>
    <artifactId>linkd-server</artifactId>
  </dependency>
  <dependency>
    <groupId>com.plume.linkd.v2</groupId>
    <artifactId>linkd-client</artifactId>
  </dependency>
</dependencies>

模块与依赖的对应关系可以压成一张表:

模块依赖职责不该出现的东西
linkd-corenetty-allLinkdFrame、ProtocolConstants、帧编解码HTTP 类型、libraryd 地址
linkd-serverlinkd-core9090/9091/9092/9093 的 handler 与 pipeline客户端重连逻辑
linkd-clientlinkd-coreRpcClient、ReconnectHandler、连接池HttpServerCodec
linkd-app三个模块LinkdV2Main、配置、关闭钩子协议细节

linkd-server 不直接声明 netty-all,是为了让「Netty 版本只有一个来源」这件事在依赖树里也成立;它通过 linkd-core 传递获得。代价是 linkd-server 的 POM 看不出它用了 Netty,读代码时要知道这层传递关系。

netty-all 是聚合 POM,不是 fat jar

netty-all 这个名字容易让人以为它是一个包含所有类的超大 jar。实际不是。直接看它的内容:

jar tf ~/.m2/repository/io/netty/netty-all/4.2.19.Final/netty-all-4.2.19.Final.jar

本机输出:

META-INF/MANIFEST.MF
META-INF/
META-INF/maven/
META-INF/maven/io.netty/
META-INF/maven/io.netty/netty-all/
META-INF/io.netty.versions.properties
META-INF/maven/io.netty/netty-all/pom.xml
META-INF/maven/io.netty/netty-all/pom.properties
META-INF/INDEX.LIST

整个 jar 只有元数据,没有 io/netty/channel/Channel.class。真实的类在 netty-transport、netty-buffer、netty-codec 等模块里,由 Maven 根据 netty-all 的 POM 传递进来。所以:

  • 编译时写一个 netty-all 坐标就够了,Maven 会解析整棵依赖树。
  • 运行时不能只把 netty-all-4.2.19.Final.jar 复制到 lib/,否则加载 Channel 时会 NoClassDefFoundError。
  • 部署要么打 fat jar(把传递依赖一起打进去),要么用 target/dependency 或容器镜像把整棵依赖树带上。

netty-all 的 POM 还会带入各平台的原生传输 classifier(Linux 的 epoll/io_uring、macOS 的 kqueue 等),依赖树会比只做 HTTP 服务端时大。本书为了减少版本错配,统一用 netty-all;如果后续镜像体积成为问题,可以按需改成 netty-codec-http 等单模块依赖,但要同时锁定版本。

入口 LinkdV2Main

入口类放在 linkd-app,只依赖 core 的常量与帧类型:

package com.plume.linkd.v2.app;

import com.plume.linkd.v2.protocol.FrameType;
import com.plume.linkd.v2.protocol.LinkdFrame;
import com.plume.linkd.v2.protocol.ProtocolConstants;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;

public final class LinkdV2Main {

    private static final Logger LOG = System.getLogger(LinkdV2Main.class.getName());

    private LinkdV2Main() {
    }

    public static void main(String[] args) {
        LOG.log(Level.INFO, "linkd-v2 0.2.0-SNAPSHOT starting");
        LOG.log(Level.INFO, "protocol magic=0x%08X version=%d header=%d maxPayload=%d".formatted(
                ProtocolConstants.MAGIC,
                ProtocolConstants.VERSION,
                ProtocolConstants.HEADER_LENGTH,
                ProtocolConstants.MAX_PAYLOAD));

        LinkdFrame hello = new LinkdFrame(FrameType.HELLO, 0, 1, new byte[0]);
        LOG.log(Level.INFO, "sample frame type=%d requestId=%d payload=%d".formatted(
                hello.type(), hello.requestId(), hello.payload().length));

        LOG.log(Level.INFO, "ports http=9090 h2c=9091 grpc=9092 custom=9093");
    }
}

日志用 System.Logger,不引 SLF4J。原因有三点:JDK 自带,不需要绑定;Netty 内部日志也走 JDK 日志,二者能落到同一个后端;System.getLogger 按类名分类,后续加 Level.DEBUG 时不用改依赖。

LOG.log(Level.INFO, "...".formatted(...)) 用的是预格式化字符串,不是 {0} 占位符。System.Logger 的 {0} 走 MessageFormat,数字会被加上千位分隔符(1048576 变成 1,048,576),日志里反而不方便对照协议常量。预格式化避免了这个问题,代价是即使日志级别关闭,字符串也已经拼好;当前只有启动时几条,可以接受。

ProtocolConstants 和 LinkdFrame 来自 linkd-core:

public record LinkdFrame(int type, int flags, int requestId, byte[] payload) {
}

这个 record 与 BRIEF 的签名一致,第 7 章会在它周围补帧格式校验和编解码器,不会改字段。

构建:mvn -q -DskipTests package

在 linkd-v2/ 根目录执行:

mvn -q -DskipTests package; echo "exit=$?"

本机输出:

exit=0

-q 只输出警告和错误,构建成功时没有任何行,所以「没有输出」就是预期结果;看到 exit=0 才说明四个模块都打包成功。如果命令看起来卡住,先确认是不是在下载依赖,而不是构建失败。

产物在各自的 target/ 下:

ls -1 */target/*.jar

本机输出:

linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar
linkd-client/target/linkd-client-0.2.0-SNAPSHOT.jar
linkd-core/target/linkd-core-0.2.0-SNAPSHOT.jar
linkd-server/target/linkd-server-0.2.0-SNAPSHOT.jar

去掉 -q 可以看到 reactor 的构建顺序和耗时:

mvn -DskipTests clean package

本机输出(节选):

[INFO] linkd-v2 ........................................... SUCCESS [  0.176 s]
[INFO] linkd-core ......................................... SUCCESS [  1.384 s]
[INFO] linkd-server ....................................... SUCCESS [  0.315 s]
[INFO] linkd-client ....................................... SUCCESS [  0.060 s]
[INFO] linkd-app .......................................... SUCCESS [  0.222 s]
[INFO] BUILD SUCCESS
[INFO] Total time:  2.250 s

顺序是父工程、core、server、client、app,与依赖方向一致。2.250 秒是这台机器、这次干净构建的单次结果,不构成性能结论;首次构建还要算上下载依赖的时间。

跑入口类需要把 linkd-app 和依赖的类放进 classpath。第 15 章会做正式打包,这里先用本地仓库的 jar 验证:

CP="linkd-app/target/classes:linkd-core/target/classes:$(find ~/.m2/repository/io/netty -name '*-4.2.19.Final.jar' | tr '\n' ':')"
java -Djava.util.logging.SimpleFormatter.format='%4$s %3$s %5$s%n' \
     -cp "$CP" com.plume.linkd.v2.app.LinkdV2Main

本机输出:

信息 com.plume.linkd.v2.app.LinkdV2Main linkd-v2 0.2.0-SNAPSHOT starting
信息 com.plume.linkd.v2.app.LinkdV2Main protocol magic=0x4C4B4446 version=1 header=16 maxPayload=1048576
信息 com.plume.linkd.v2.app.LinkdV2Main sample frame type=1 requestId=1 payload=0
信息 com.plume.linkd.v2.app.LinkdV2Main ports http=9090 h2c=9091 grpc=9092 custom=9093

-Djava.util.logging.SimpleFormatter.format 只是把默认的两行日志压成一行,方便对照;不设置也能运行,只是时间戳和类名分两行显示。

常见错误与边界

错误一:在子模块里重复写 <version>4.2.19.Final</version>。 四个模块各写一遍,升级时漏掉一个,依赖树里就会出现两个 Netty 版本,运行时报 NoSuchMethodError 或链接错误。版本只放在父 POM 的 dependencyManagement 和 netty.version 属性里。

错误二:让 linkd-core 依赖 linkd-server。 只要形成环,Maven 在 reactor 阶段就会失败,提示项目之间存在循环引用。判断方法很简单:core 里出现 HttpServerCodec 或 GatewayHandler,方向就错了。

错误三:编译级别没跟上。 父 POM 写了 maven.compiler.release=25,但 IDE 或 CI 用了旧的 source/target,LinkdFrame 这个 record 会直接编译失败,提示当前 source 级别不支持 record。先检查 mvn -version 的 Java 版本和 IDE 的 compiler compliance,再查 POM。

错误四:把 netty-all 的 jar 当运行时依赖复制。 前面已经验证 jar 里没有类;单独放进 lib/ 会在启动时找不到 Channel。用 Maven 的依赖树或打包插件把传递依赖带上。

错误五:长期只用 mvn -q。 静默模式会隐藏依赖收敛警告、弃用 API 和插件提示。日常开发可以 -q,升级 Netty 或排查构建问题时要去掉,完整看一遍 reactor 输出。

边界:空模块也会打 jar。 linkd-client 现在没有源码,package 仍会产出一个不含类的 jar,构建不会报错。不要因为「构建成功」就以为模块已经实现;第 8 章之前它只是占位。

小结

  • linkd-v2 是 packaging=pom 的父工程加四个模块,依赖方向为 app → server/client → core,不允许反向或成环。
  • 父 POM 用 netty.version=4.2.19.Final 和 maven.compiler.release=25 固定版本与编译级别,子模块不重复写版本。
  • netty-all 是聚合 POM,jar 里只有元数据,类在 netty-transport、netty-buffer 等模块里,由 Maven 传递。
  • LinkdV2Main 用 System.Logger 输出启动信息与协议常量,不引入 SLF4J 绑定。
  • mvn -q -DskipTests package 成功时没有输出,exit=0 和四个 jar 才是判据;耗时数字只代表本机单次构建。

工程立住后,2.1 开始接入第一个监听端口:用 ServerBootstrap 绑定 9093,把 1.2 的 Echo 换成带 LinkdFrameDecoder 的 pipeline。

阅读导航:上一节:1.2 从 Reactor 到 Netty 组件映射 · 下一节:2.1 ServerBootstrap 与 Bootstrap 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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