《Java 服务端通信工程》15.2 联调、验收清单与故障注入

验收不是再跑一次健康检查。本节把 linkd-v3 接入 libraryd 的验收拆成编译单测、契约测试、连通性与超时、限流熔断、TLS、指标日志追踪、灰度回滚七组,每条给出命令或断言,并附五条故障注入与两段可编译的 JUnit 测试代码。

本节目标:把「联调通过了」变成一份别人能照着复跑、能签字的验收记录,失败时能定位到具体章节。
原则:每条检查项都有命令或断言、有预期、有证据;未执行的写 SKIP 并注明原因,不能算 PASS。

承接的问题

15.1 定义了链路和边界,但边界写得再清楚,也需要证据证明它真的生效。常见的「验收」是这样的:

  • 启动服务,curl /healthz 返回 200,宣布联调完成。
  • 手工点几个页面,看到数据,宣布功能正常。
  • 压测跑出 200 rps,没报错,宣布容量达标。

这三条都只覆盖了最短路径。真正会出问题的是:deadline 没有传递、限流没有接上、熔断打开后错误码不对、TLS 证书过期、指标维度缺失、回滚脚本跑不通。这些问题在「点几个页面」时都不会暴露。

本节按七组给出可执行清单。所有命令都是执行模板,不是本机输出;执行人填 PASS / FAIL / SKIP 和证据位置。

第一组:编译、单测与静态检查

检查项命令预期证据
JDK 版本java -version25.0.5,Temurin版本输出
编译mvn -q -DskipTests package退出码 0,四个模块产物齐全构建日志
单测mvn -q test全部通过,无跳过surefire 报告
依赖检查mvn -q dependency:tree -Dincludes=io.grpc:grpc-netty只有 shaded 版本,无 grpc-netty 混入依赖树
禁用 importgrep -rnE '^import +io[.]netty[.]' comm-*/src/main/java无匹配grep 输出

依赖检查这条经常被忽略。grpc-netty-shaded 和 grpc-netty 同时出现在 classpath 上,编译能过,运行期可能出现类冲突或 NoSuchMethodError。依赖树是唯一可靠的确认方式。

java -version 之前先 export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home,否则默认 java 可能指向 JDK 8,mvn 会用错误的编译器。

第二组:契约测试

契约测试验的是「服务端返回的东西,调用方能不能正确理解」,不是「方法有没有被调用」。至少要覆盖:正常返回、字段完整性、错误状态码、错误原因码、metadata 回显。

下面是一个可运行的 JUnit 5 测试骨架,启动真实 gRPC 服务并走网络:

package com.plume.linkd.v3.contract;

import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import com.plume.linkd.v3.server.BookRepository;
import com.plume.linkd.v3.server.LibraryService;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class LibraryContractTest {

    private Server server;
    private ManagedChannel channel;
    private LibraryGrpc.LibraryBlockingStub stub;

    @BeforeEach
    void setUp() throws IOException {
        server = ServerBuilder.forPort(0)
                .addService(new LibraryService(new FakeBookRepository()))
                .build()
                .start();
        channel = ManagedChannelBuilder.forAddress("127.0.0.1", server.getPort())
                .usePlaintext()
                .build();
        stub = LibraryGrpc.newBlockingStub(channel);
    }

    @AfterEach
    void tearDown() throws InterruptedException {
        channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
        server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
    }

    @Test
    void getBookReturnsAllContractFields() {
        Book book = stub.getBook(
                GetBookRequest.newBuilder().setIsbn("978-7-111-00001-1").build());
        assertEquals("978-7-111-00001-1", book.getIsbn());
        assertEquals("通信工程", book.getTitle());
        assertEquals("Leeting", book.getAuthor());
        assertTrue(book.getStock() >= 0);
    }

    @Test
    void expiredDeadlineFailsFast() {
        StatusRuntimeException error = assertThrows(StatusRuntimeException.class, () ->
                stub.withDeadlineAfter(1, TimeUnit.NANOSECONDS)
                        .getBook(GetBookRequest.newBuilder().setIsbn("missing").build()));
        assertEquals(Status.Code.DEADLINE_EXCEEDED, error.getStatus().getCode());
    }

    @Test
    void unknownIsbnMapsToNotFound() {
        StatusRuntimeException error = assertThrows(StatusRuntimeException.class, () ->
                stub.getBook(GetBookRequest.newBuilder().setIsbn("unknown").build()));
        assertEquals(Status.Code.NOT_FOUND, error.getStatus().getCode());
    }
}

final class FakeBookRepository implements BookRepository {

    @Override
    public CompletableFuture<Book> find(String isbn, long budgetMs) {
        if ("unknown".equals(isbn)) {
            return CompletableFuture.failedFuture(
                    Status.NOT_FOUND.withDescription("unknown isbn").asRuntimeException());
        }
        return CompletableFuture.completedFuture(Book.newBuilder()
                .setIsbn(isbn)
                .setTitle("通信工程")
                .setAuthor("Leeting")
                .setStock(3)
                .build());
    }
}

FakeBookRepository 是测试替身,按 isbn 返回固定数据或 NOT_FOUND。测试走真实端口而不是进程内传输,是为了把序列化、HTTP/2 帧、deadline 传播都覆盖进去;代价是启动稍慢,适合放在集成测试阶段。

契约测试还要断言错误详情里的原因码。完整实现用 StatusProto.fromThrowable(error) 取 com.google.rpc.Status,再解析 ErrorInfo.reason,与 15.1 的映射表逐项比对。只断言 NOT_FOUND 不够,因为 NOT_FOUND 可能来自「书不存在」,也可能来自「路由写错」。

第三组:连通性、超时与错误映射

检查项命令或断言预期证据
网关健康curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:9094/healthz200状态码
gRPC 端口lsof -nP -iTCP:9093 -sTCP:LISTEN有 LISTEN 行输出
正常查询curl -sS http://127.0.0.1:9094/books/978-7-111-00001-1200,字段完整响应体
未知图书curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:9094/books/unknown404,原因码 BOOK_NOT_FOUND状态码、响应体
未知路由curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:9094/nope404,不是 500状态码
总超时注入 700 ms 延迟后请求504,原因码 DEADLINE_EXCEEDED,耗时 < 1 s状态码、time
单跳超时停掉 libraryd 后请求503,原因码 UPSTREAM_UNAVAILABLE状态码、响应体
metadata 回显curl -H 'x-request-id: req-123' -D - ...响应头带 x-request-id: req-123响应头

超时这条要用 time 或 curl -w '%{time_total}' 记录实际耗时。只看返回 504 不够:如果耗时 5 秒才返回,说明超时没有生效,可能是某一层用了固定 5 秒。

错误映射要连原因码一起验。第 9.3 节定义了映射表,15.1 给出了链路级规则;验收时逐条对照,不能只看 HTTP 状态码。

第四组:限流、熔断与隔离

检查项命令预期证据
限流触发ab -k -n 3000 -c 100 http://127.0.0.1:9094/books/978-7-111-00001-1出现 429,带 Retry-Afterab 报告、响应头
限流恢复停止压测 5 秒后再请求200,令牌桶回填状态码序列
熔断打开停 libraryd,连续请求 20 次以上先 503,达到阈值后熔断打开,后续快速失败状态码序列、熔断指标
熔断半开等 5 秒后发 3 个请求3 个探针,成功则关闭,失败则重新打开熔断指标
并发隔离注入慢请求占满 64 并发新请求排队或 429,健康检查仍 200队列指标、健康检查
队列上限慢请求持续超过 128 队列拒绝而不是无限堆积拒绝计数

限流测试要注意租户维度。如果限流按租户配置,压测用的 tenant-loadtest 要有独立配额,否则测出来的是其他租户的余量。

熔断测试要记录状态迁移时间点,而不是只看最终状态。breaker_state 从 CLOSED 到 OPEN 到 HALF_OPEN 再到 CLOSED,每一步都要有指标或日志。只看到「后来恢复了」,无法判断半开探针是否按 3 个执行。

第五组:TLS 与证书

检查项命令预期证据
服务端证书openssl s_client -connect 127.0.0.1:9093 -alpn h2 -servername localhost握手成功,ALPN 协商为 h2输出
证书有效期openssl x509 -in server.crt -noout -enddate未过期,剩余 > 30 天日期
证书链openssl verify -CAfile ca.crt server.crtOK输出
私钥匹配比较证书与私钥的公钥指纹一致指纹
客户端信任用错误 CA 连接握手失败,不是明文降级异常
主机名校验用错误 servername 连接校验失败异常

最后两条是负向测试,很多验收只做正向握手。没有负向测试,就无法证明「证书错误时连接会被拒绝」,而不是悄悄降级。

证书过期是典型的「配置故障」:代码没变、流量没变,到某个日期突然全线失败。15.3 的运行手册会给出到期检查和轮换流程;验收阶段至少要确认告警已经配置。

第六组:指标、日志与追踪

检查项命令预期证据
指标端点curl -fsS http://127.0.0.1:9095/metrics返回 Prometheus 文本抓取片段
请求计数发 10 个请求后查 http_requests_total增加 10,维度含 route/status指标差值
延迟直方图查 rpc_duration_seconds_bucket有 le 桶,能算 p95指标片段
限流指标触发 429 后查 ratelimit_rejected_total增加,scope 正确指标差值
熔断指标停 libraryd 后查 breaker_state从 CLOSED 变 OPEN指标序列
结构化日志grep 'req-123' app.log一行包含 requestId、route、status、duration日志行
traceparent请求带 traceparent,查日志同一 traceId 贯穿网关与服务端两条日志
敏感信息grep -i 'authorization|cookie' app.log无明文令牌grep 输出

指标验收要看「维度是否足够定位」。只有 http_requests_total 一个总数,出问题时无法区分是哪条路由、哪个上游。至少要有 route、status、upstream 三个维度。

追踪验收不要求接入完整后端,但要求 traceparent 能解析、能透传、能写进日志。11.3 讲过采样与 exemplar;验收阶段先保证「同一条请求在两个服务里的 traceId 一致」。

第七组:灰度与回滚

检查项命令预期证据
灰度路由curl -H 'x-canary: v2' -D - ...命中 v2,响应头带 x-served-by: v2响应头
默认路由不带 x-canary 请求命中 v1响应头
比例切流发 100 个带随机 canary 的请求比例接近配置值计数
回滚执行回滚脚本,再请求全部命中 v1,无 5xx状态码序列
回滚耗时time ./rollback.sh< 60 s命令输出
数据兼容v2 写入后由 v1 读取字段兼容,不报错响应体

回滚脚本必须真的执行一次,不能只检查文件存在。执行后要验证三件事:流量切回、旧版本健康、没有残留的新版本连接。第 12.3 节讲过契约测试与回滚,这里是链路级的复验。

故障注入:至少五条

验收不注入故障,只能证明「正常路径能跑」。下面五条覆盖依赖、延迟、过载、连接和证书:

#注入命令或开关预期
1libraryd 退出kill -TERM "$(pgrep -f 'libraryd.*jar')"503 + UPSTREAM_UNAVAILABLE,9095 健康
2libraryd 变慢x-fault: delay:500,测试租户 100%504 + DEADLINE_EXCEEDED,耗时 < 1 s
3服务端过载x-fault: abort:RESOURCE_EXHAUSTED429 + RATE_LIMITED + Retry-After
4连接半开13.2 的 HalfOpenProbe,shutdownOutput()连接在超时后被回收,fd 不增长
5证书过期测试环境换一张已过期证书握手失败,进程不就绪,告警触发

第 2、3 条用 13.2 的 FaultInjectingServerInterceptor,只对 tenant-drill 生效,比例 100%。演练结束立即关闭 -Dlinkd.fault.enabled,并在审计日志里确认没有残留命中。

故障注入的测试代码可以复用 13.2 的拦截器:

package com.plume.linkd.v3.contract;

import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import com.plume.linkd.v3.chaos.FaultInjectingServerInterceptor;
import com.plume.linkd.v3.server.LibraryService;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.ForwardingClientCall;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.junit.jupiter.api.Test;

import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class FaultAcceptanceTest {

    @Test
    void injectedUnavailableIsMappedTo503() throws Exception {
        Server server = ServerBuilder.forPort(0)
                .addService(new LibraryService(new FakeBookRepository()))
                .intercept(new FaultInjectingServerInterceptor(true, 1L))
                .build()
                .start();
        ManagedChannel channel = ManagedChannelBuilder
                .forAddress("127.0.0.1", server.getPort())
                .usePlaintext()
                .build();
        try {
            LibraryGrpc.LibraryBlockingStub stub = LibraryGrpc.newBlockingStub(channel)
                    .withInterceptors(new DrillHeaders("abort:UNAVAILABLE"));
            StatusRuntimeException error = assertThrows(StatusRuntimeException.class, () ->
                    stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
                            .getBook(GetBookRequest.newBuilder().setIsbn("x").build()));
            assertEquals(Status.Code.UNAVAILABLE, error.getStatus().getCode());
        } finally {
            channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
            server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
        }
    }

    private static final class DrillHeaders implements ClientInterceptor {
        private final String spec;

        private DrillHeaders(String spec) {
            this.spec = spec;
        }

        @Override
        public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
                MethodDescriptor<ReqT, RespT> method, CallOptions options, Channel next) {
            Metadata.Key<String> tenant =
                    Metadata.Key.of("x-tenant-id", Metadata.ASCII_STRING_MARSHALLER);
            Metadata.Key<String> fault =
                    Metadata.Key.of("x-fault", Metadata.ASCII_STRING_MARSHALLER);
            Metadata.Key<String> ratio =
                    Metadata.Key.of("x-fault-ratio", Metadata.ASCII_STRING_MARSHALLER);
            return new ForwardingClientCall.SimpleForwardingClientCall<>(
                    next.newCall(method, options)) {
                @Override
                public void start(Listener<RespT> listener, Metadata headers) {
                    headers.put(tenant, "tenant-drill");
                    headers.put(fault, spec);
                    headers.put(ratio, "100");
                    super.start(listener, headers);
                }
            };
        }
    }
}

这个测试只验证「注入生效且状态码正确」,不验证网关映射到 503 的那一步。完整验收要在网关层再发一次 HTTP 请求,断言 503 和 reason。单元测试和链路测试各管一段,不能互相替代。

常见错误与边界

错误一:把 SKIP 当 PASS。 没执行就写 SKIP,注明原因和补测日期;把未覆盖项算成通过,整份验收失去意义。

错误二:只测正向路径。 TLS 握手成功不代表错误证书会被拒绝;正常查询成功不代表未知图书返回 404。负向断言和正向断言一样重要。

错误三:超时只看状态码。 返回 504 但耗时 5 秒,说明超时没有在该生效的层生效。必须同时记录耗时。

错误四:限流和熔断混在一起测。 两者触发的条件不同,混测时无法判断 429 是限流还是熔断后的拒绝。分开注入、分开看指标。

错误五:回滚脚本没跑过。 文件存在不等于能执行。回滚要真的切一次流量,并确认旧版本健康、无残留连接。

错误六:故障注入忘记关。 验收结束检查 linkd.fault.enabled、审计日志和 x-fault 命中数,确认没有残留。

边界:验收通过不等于没有 bug。 它只证明「在给定命令、环境和阈值下,这些路径符合预期」。未覆盖的路径、未注入的故障、未压到的流量,仍然未知。

边界:libraryd 是外部服务。 它的版本、数据、超时行为不由本书控制;验收记录要写清 libraryd 的版本和部署形态,否则结果无法复现。

小结

  • 验收分七组:编译单测、契约、连通与超时、限流熔断、TLS、指标日志追踪、灰度回滚。
  • 每条检查项都要有命令或断言、预期和证据;未执行写 SKIP,不算通过。
  • 契约测试走真实端口,覆盖正常返回、字段完整性、deadline 和错误状态码。
  • 超时验收要记录耗时,不能只看 504;错误验收要连原因码一起看。
  • 故障注入至少五条:依赖退出、延迟、过载、半开连接、证书过期。
  • 回滚脚本必须真的执行一次,验证流量切回、旧版本健康和残留连接。

阅读导航:上一节 15.1 链路架构与边界 | 下一节 15.3 运行手册与复盘

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收