这本书怎么读
全书 50 篇,正文 15 章 45 节。三种读法对应三种目标,选一种为主,不要同时推进:
| 目标 | 主线 | 建议顺序 | 可以后置 |
|---|---|---|---|
| 把 RPC 用对 | 契约、编码、调用模型 | 第 1–3 章 → 第 4 章 → 第 12 章 | 第 13–14 章 |
| 把服务治理做起来 | 发现、负载均衡、重试熔断、限流 | 第 4–9 章,回看第 3 章 | 第 10 章的 mTLS 细节 |
| 把系统跑稳 | 观测、灰度、混沌、容量、验收 | 第 10–15 章,边做边补第 3、7 章 | 第 2 章的编码细节 |
每条路径的入口都在目录页。正文每节末尾有一行阅读导航,按白名单顺序指向前后节;跳读时用它确认自己跳过了什么。
需要的前置知识
本书假设你具备以下基础,不重复讲解:
- 能用 Java 写多线程程序,知道
ExecutorService、CompletableFuture、ConcurrentHashMap的基本用法。 - 用过 Maven 多模块工程,知道
dependencyManagement和mvn -pl的作用。 - 理解 TCP 的连接、半关闭和超时,知道 HTTP/1.1 的请求响应模型。
- 写过一个能启动、能收请求、能打日志的服务。
不要求你用过 gRPC、Protobuf、注册中心或服务网格。这些概念会在第一次出现时给定义,但不会从「什么是 RPC」讲起。
如果卷一的 Selector 循环、卷二的 ByteBuf 引用计数完全没有概念,建议至少补读卷一第 6–8 章和卷二第 5–6 章。本卷不会再用它们写代码,但会用它们解释「为什么这里不能阻塞」「为什么消息边界不能靠一次读」。
环境与版本
版本在全书锁定,正文不漂移:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | Temurin 25.0.5+8 | javac --release 25 |
| Maven | 3.9.12 | 构建与依赖管理 |
| gRPC-Java | 1.76.0 | grpc-netty-shaded、grpc-protobuf、grpc-stub、grpc-services |
| Protobuf | 4.33.2 | protobuf-java、protobuf-java-util |
| JUnit Jupiter | 5.14.4 | 单元测试与契约测试 |
| libraryd | HTTP 8080 + PostgreSQL 17 | 已有服务,不重写 |
本机默认 java 可能指向 JDK 8。每个终端会话先设置 JAVA_HOME,再执行构建:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
java -version
mvn -v
本机实际输出版本行如下(java -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, mixed mode, sharing)
comm-api 的依赖如下。gRPC 的四个构件版本必须一致,Protobuf 的运行时版本必须与 protoc 生成的代码匹配:
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.76.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.76.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.76.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-services</artifactId>
<version>1.76.0</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>4.33.2</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>4.33.2</version>
</dependency>
</dependencies>
不要在同一个 classpath 里再引入非 shaded 的 gRPC 传输实现,也不要直接依赖 Netty 4.2。shaded 构件内部已经打包了一份 Netty,两套 Netty 同时出现时,类加载顺序决定谁生效,表现为「本机好的、换台机器就 NoSuchMethodError」。
项目结构与端口
linkd-v3 是四模块工程,groupId com.plume.linkd.v3:
| 模块 | 包前缀 | 职责 |
|---|---|---|
comm-api | com.plume.linkd.v3.api | .proto、生成的 stub、错误码常量 |
comm-core | com.plume.linkd.v3.core | 服务发现、负载均衡、重试、熔断、限流、指标、追踪 |
comm-server | com.plume.linkd.v3.server | gRPC 服务实现、拦截器、健康检查、反射 |
comm-gateway | com.plume.linkd.v3.gateway | HTTP/BFF 网关、协议转换、下游调用 |
依赖方向是单向的:comm-gateway 和 comm-server 依赖 comm-core,comm-core 依赖 comm-api;comm-api 不依赖任何业务模块。这样 .proto 和生成的类型可以被所有模块共享,又不会把服务实现拖进客户端。
端口固定,排障时按端口定位第一现场:
| 端口 | 归属 | 协议 | 用途 |
|---|---|---|---|
| 8080 | libraryd | HTTP | 既有服务,本卷不重写 |
| 9093 | linkd-v3 | gRPC | 服务端通信主端口 |
| 9094 | comm-gateway | HTTP | 对外网关、BFF |
| 9095 | comm-server | HTTP | 指标、健康检查、管理 |
端口和两档 deadline 不是散落在各处的字面量,代码里用同一个记录承载,避免文档、配置和实现各说一套:
package com.plume.linkd.v3.core;
import java.time.Duration;
public record LinkdDefaults(
int grpcPort,
int gatewayPort,
int adminPort,
Duration clientDeadline,
Duration hopDeadline) {
public static final LinkdDefaults PRODUCTION =
new LinkdDefaults(9093, 9094, 9095,
Duration.ofMillis(800), Duration.ofMillis(300));
public static void main(String[] args) {
System.out.println("ports=" + PRODUCTION.grpcPort()
+ "/" + PRODUCTION.gatewayPort()
+ "/" + PRODUCTION.adminPort());
System.out.println("budget=" + PRODUCTION.clientDeadline().toMillis()
+ "/" + PRODUCTION.hopDeadline().toMillis());
}
}
预期输出(手算,未在本机单独编译运行):
ports=9093/9094/9095
budget=800/300
gRPC 服务名是 plume.linkd.v3.Library,四个方法固定:
syntax = "proto3";
package plume.linkd.v3;
option java_package = "com.plume.linkd.v3.api";
option java_multiple_files = true;
option java_outer_classname = "LibraryProto";
service Library {
rpc GetBook(GetBookRequest) returns (Book);
rpc SearchBooks(SearchBooksRequest) returns (stream Book);
rpc WatchStock(WatchStockRequest) returns (stream StockUpdate);
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
字段号一旦发布就不再改。第 2.3 节会给出完整的兼容矩阵,这里只需要记住一条:新增字段用新编号,删除字段用 reserved,永远不要复用旧编号。
代码约定
全书的代码遵循同一组约定,目的是让片段之间可以互相引用:
- 包名按模块划分,不从
com.plume.linkd.v3之外引入业务类。 - 共享类型(
RequestContext、RpcError、RetryPolicy等)的签名在前言定死,后续章节只增方法、不改构造参数。 - 不引入 Lombok、日志门面绑定或响应式框架。日志用
System.Logger,测试用 JUnit Jupiter。 - 时间统一用
java.time.Duration和java.time.Instant,不传裸long毫秒;协议里的int64时间戳在边界处转换。 - 异常只在边界处转换。业务层抛
RpcError,gRPC 边界把它转成StatusRuntimeException,网关再转成 HTTP 响应。 - 代码片段尽量完整到能编译;省略 import 时,会在正文里说明依赖的包。
- 「本机输出」只贴真实执行过的结果;没有执行过的写成「预期输出」,并说明是手算还是示例。
一个最小的环境自检程序如下,它不需要 gRPC 依赖,只用 JDK:
package com.plume.linkd.v3.demo;
import java.time.Duration;
public final class EnvironmentProbe {
public static final Duration CLIENT_DEADLINE = Duration.ofMillis(800);
public static final Duration LIBRARYD_DEADLINE = Duration.ofMillis(300);
public static void main(String[] args) {
System.out.println("java.version=" + Runtime.version());
System.out.println("grpc.version=1.76.0");
System.out.println("protobuf.version=4.33.2");
System.out.println("client.deadline.ms=" + CLIENT_DEADLINE.toMillis());
System.out.println("libraryd.deadline.ms=" + LIBRARYD_DEADLINE.toMillis());
System.out.println("budget.ok=" + (LIBRARYD_DEADLINE.multipliedBy(3).compareTo(CLIENT_DEADLINE) > 0));
}
}
预期输出(JDK 25 的 Runtime.version() 格式):
java.version=25.0.5+8-LTS
grpc.version=1.76.0
protobuf.version=4.33.2
client.deadline.ms=800
libraryd.deadline.ms=300
budget.ok=true
最后一行 budget.ok=true 不是在说预算够用,而是在说「3 次单跳尝试已经超过总预算」这个判断成立。第 3.2 节会把这个问题展开:重试不能简单地把单跳 deadline 乘以次数。
测试与验证
每节代码至少能通过编译;涉及网络行为的示例,用 JUnit Jupiter 或一个 main 方法验证。构建与测试命令统一:
mvn -q -DskipTests package
mvn -q -pl comm-api,comm-core -am test
mvn -q -pl comm-server -am -Dtest=LibraryServiceTest test
写文章时不做「看起来对」的验证。涉及以下三类内容时,必须在正文里给出可复现的判据:
| 类型 | 判据 | 写法 |
|---|---|---|
| 编码/解码 | 输入、字节序列、解析结果三者一致 | 贴十六进制和字段表 |
| 调用行为 | 客户端观察到的状态码与消息 | 贴 StatusRuntimeException 的 code 和 description |
| 性能/容量 | 机器口径、并发数、持续时间、指标 | 标明「本机实测」或「估算」 |
没有机器口径的性能数字不写。没有执行过的命令输出,不写成「本机输出」。
本机能力边界
本机口径固定如下,正文中的「本机」都指这台机器:
Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8
gRPC-Java 1.76.0(grpc-netty-shaded)
三个边界需要提前知道:
第一,JDK 25 上运行 shaded Netty 会打印 sun.misc.Unsafe::allocateMemory 的终结弃用警告。这是依赖访问 JDK 内部内存 API 的真实行为,不影响示例结果,也不应该被伪造成「无警告」。
第二,grpcurl 本机未安装。涉及反射服务的验证,正文用 Java 代码或 ServerServiceDefinition 的反射输出来完成;只有明确写「若已安装 grpcurl」的地方,才给出命令行。
第三,macOS 没有 ss,也没有 epoll。连接诊断用 netstat、lsof -i、tcpdump -i lo0(需要 sudo);原生传输的讨论会区分「本机可验证」和「Linux 部署侧配置」,不把两者混在一起。
遇到问题时先查哪里
初学时最容易卡在四类问题上,先按下面的顺序排查,能省掉大量搜索时间:
| 现象 | 先查 | 常见原因 |
|---|---|---|
| 编译时报找不到生成的类 | mvn -pl comm-api clean install 是否执行过 | 生成目录没进源码路径,或 IDE 没刷新 |
运行时报 NoSuchMethodError | gRPC / Protobuf 版本是否一致 | 混入了另一套传输实现或旧版运行时 |
调用返回 UNIMPLEMENTED | 完整方法名是否匹配 | 包名、大小写、服务名写错 |
| 调用一直不返回 | deadline 是否设置 | 客户端没设预算,服务端在等下游 |
这些问题在附录 A 有更完整的签名和命令;正文里遇到时,会直接指向对应章节。
小结
- 三种读法:把 RPC 用对、把治理做起来、把系统跑稳。选一条,按目录页的路径走。
- 版本锁定 JDK 25.0.5+8、gRPC 1.76.0、Protobuf 4.33.2;只引入
grpc-netty-shaded,不混用非 shaded 传输。 - 四模块单向依赖,端口 8080/9093/9094/9095 各司其职,服务名与四个方法固定。
- 共享类型签名在前言定死;时间用
Duration,异常只在边界转换。 - 「本机输出」只写真实执行过的结果,其余写「预期输出」并说明口径。
- 本机没有
grpcurl,macOS 没有ss和 epoll;这些边界会如实写进正文。 - 环境自检程序只依赖 JDK,可以在引入 gRPC 依赖之前先确认版本。
- 四模块的单向依赖和四个端口是排障的第一现场,不要按「看起来相关」跳着查。
约定交代完,下一页是目录与学习路径:十五章怎么分组、每章的验收信号是什么、三条路径分别跳过哪些内容。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。