《Java 服务端通信工程》1.1 从 RPC 语义到线格式

把「调用一个远程方法」拆成方法标识、参数、关联、时间和错误五个语义要素,再逐一映射到 HTTP/2 的 HEADERS、DATA 与 trailers,给出 gRPC 五字节消息前缀的编解码代码,并用 MethodDescriptor 在 9093 上跑通一次 unary 调用。

承接的问题

卷一和卷二解决的是「一条连接里怎么把字节变成消息」。到了服务之间,问题多了一层:调用方写的是 libraryClient.getBook(isbn),网络上跑的是 HTTP/2 的 HEADERS、DATA 和 trailers。中间这层映射如果说不清,后面所有治理手段都会失去落点——不知道 deadline 放在哪,就不知道超时该在哪一层算;不知道错误放在哪,就只能从响应体里猜。

这一节先把 RPC 的语义拆成五个要素,再逐一看它们在线格式里的位置,最后用 MethodDescriptor 在 9093 上跑通一次调用。读完之后,你应该能回答:一次 GetBook 从方法调用到字节流,中间经过了哪些结构,每个结构负责哪部分语义。

RPC 语义的五个要素

把「远程调用」和「本地调用」放在一起对比,差异集中在五件事上:

要素本地调用远程调用需要额外回答的问题
方法标识类名 + 方法名 + 签名对端怎么知道调的是哪个方法、哪个版本
参数与返回值直接传对象引用怎么序列化、字段怎么编号、类型怎么对齐
关联同步栈帧天然关联响应怎么找回对应的请求,并发时怎么不串
时间调用会一直等到返回等多久、超时后谁负责取消、剩余时间怎么传
错误抛异常,栈信息完整错误怎么编码、哪些能重试、原因怎么表达

这五件事缺一不可。很多「RPC 用起来别扭」的问题,本质是只解决了前两件:方法能调、参数能传,但关联靠一个自增 ID,时间靠 socket.setSoTimeout,错误靠返回体里的 code 字段。单机测试都能过,一上并发和故障就暴露。

gRPC 的选择是把这五件事分别放到协议的不同层,而不是塞进一个「请求对象」里:

要素gRPC 中的落点
方法标识HTTP/2 的 :path,格式 /package.Service/Method
参数与返回值DATA 帧里的 Protobuf 消息,前面带 5 字节前缀
关联HTTP/2 的 stream ID,一个流一次调用
时间grpc-timeout 头,服务端还原成 Context 里的 deadline
错误trailers 里的 grpc-status 与 grpc-message,可附 grpc-status-details-bin

这张表是本节的主线。后面每一节讲治理,实际上都是在某一层上加规则:1.2 细化错误层,3.2 细化时间层,4.2 在 metadata 层加请求 ID 和租户。

从方法调用到 HTTP/2 流

一次 unary 调用在 HTTP/2 上是一条完整的流。客户端先发 HEADERS,再发一个 DATA,最后发 END_STREAM;服务端回 HEADERS、DATA、trailers。把中间的结构按顺序列出来:

客户端                                  服务端
  | -- HEADERS (:method POST, :path /plume.linkd.v3.Library/GetBook,
  |             content-type application/grpc, te trailers,
  |             grpc-timeout 800m, x-request-id ...) -->|
  | -- DATA (00 00 00 00 04 0a 02 34 32) ------------->|
  | -- END_STREAM ------------------------------------->|
  | <-- HEADERS (:status 200, content-type application/grpc) --|
  | <-- DATA (00 00 00 00 0f 0a 02 34 32 12 07 ...) ----------|
  | <-- trailers (grpc-status 0) -----------------------------|

几个容易忽略的点:

第一,HTTP 状态码几乎总是 200。业务成功失败看 trailers 里的 grpc-status,不看 :status。:status 非 200 通常意味着「请求根本没到 gRPC 层」——代理拒绝、路径写错、协议不匹配。把 200 当成成功、非 200 当成失败,会把「书不存在」误判成「服务挂了」。

第二,te: trailers 是 HTTP/1.1 时代的遗留要求,HTTP/2 里仍然要带。它声明的不是「我会发 trailers」,而是「我理解 trailers 这种帧」;有些代理看到它才愿意透传 trailers。

第三,grpc-timeout 是相对时间,不是绝对时间戳。它解决的是「两端时钟不一致」的问题:客户端说「还剩 800 毫秒」,服务端收到后按自己的时钟减,不需要同步时钟。代价是网络传输时间会被算进服务端的剩余时间里,这通常是想要的行为。

第四,一个 stream 只承载一次调用。HTTP/2 的多路复用让多条调用共享一条 TCP 连接,但流与流之间互不阻塞(除了连接级流控)。这一点在 6.1 展开。

消息帧:五字节前缀

HTTP/2 的 DATA 帧本身不保证消息边界。一次 DATA 可能包含半条消息,也可能包含三条。gRPC 在消息前面加了固定 5 字节:

偏移长度字段说明
01compressed0 表示未压缩,1 表示按 grpc-encoding 压缩
14message length大端无符号,只算消息体,不含这 5 字节
5NmessageProtobuf 序列化后的字节

长度字段只算消息体,这一点和卷二自定义帧的口径一致:解码器用 5 + length 算整帧,分配缓冲区时直接用 length。上限 4 MiB,超过就返回 RESOURCE_EXHAUSTED,而不是把连接撑爆。

下面这段代码只依赖 JDK,把「加前缀」和「拆前缀」写成两个方法,方便在没有 gRPC 依赖的测试里验证边界:

package com.plume.linkd.v3.wire;

import java.util.Arrays;

public final class GrpcMessageFraming {

    public static final int PREFIX_LENGTH = 5;
    public static final int MAX_MESSAGE_SIZE = 4 * 1024 * 1024;

    public static byte[] frame(byte[] message) {
        if (message.length > MAX_MESSAGE_SIZE) {
            throw new IllegalArgumentException("message too large: " + message.length);
        }
        byte[] framed = new byte[PREFIX_LENGTH + message.length];
        framed[0] = 0;
        framed[1] = (byte) (message.length >>> 24);
        framed[2] = (byte) (message.length >>> 16);
        framed[3] = (byte) (message.length >>> 8);
        framed[4] = (byte) message.length;
        System.arraycopy(message, 0, framed, PREFIX_LENGTH, message.length);
        return framed;
    }

    public static byte[] unframe(byte[] framed) {
        if (framed.length < PREFIX_LENGTH) {
            throw new IllegalArgumentException("incomplete prefix: " + framed.length);
        }
        int compressed = framed[0] & 0xFF;
        if (compressed != 0 && compressed != 1) {
            throw new IllegalArgumentException("bad compressed flag: " + compressed);
        }
        int length = ((framed[1] & 0xFF) << 24)
                | ((framed[2] & 0xFF) << 16)
                | ((framed[3] & 0xFF) << 8)
                | (framed[4] & 0xFF);
        if (length > MAX_MESSAGE_SIZE) {
            throw new IllegalArgumentException("message too large: " + length);
        }
        if (framed.length != PREFIX_LENGTH + length) {
            throw new IllegalArgumentException("length mismatch: " + length + " vs " + framed.length);
        }
        return Arrays.copyOfRange(framed, PREFIX_LENGTH, framed.length);
    }

    public static void main(String[] args) {
        byte[] request = {0x0a, 0x02, 0x34, 0x32};
        byte[] framed = frame(request);
        System.out.println("framed=" + toHex(framed));
        System.out.println("roundtrip=" + Arrays.equals(request, unframe(framed)));
    }

    private static String toHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            if (!sb.isEmpty()) {
                sb.append(' ');
            }
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

预期输出(请求是 GetBookRequest{isbn="42"} 的序列化结果,4 字节):

framed=00 00 00 00 04 0a 02 34 32
roundtrip=true

0a 是字段 1、wire type 2(length-delimited),02 是长度,34 32 是 ASCII 的 42。这个字节序列在 2.1 会再拆一次,现在只需要确认前缀和长度口径。

一个常见反例是把 length 当成「整帧长度」。如果编码器写 5 + message.length,解码器按自己的口径读 length 字节,就会多读 5 字节,把下一条消息的前缀当成当前消息的尾巴。错误不会立刻报,通常要等到后面某条消息解析失败才暴露。

在 linkd-v3 里落地:一个完整的 MethodDescriptor

MethodDescriptor 是客户端与服务端共享的「方法身份证」。它包含四类信息:方法类型、完整方法名、请求/响应编组器,以及可选的幂等/安全标记。

package com.plume.linkd.v3.api;

import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import io.grpc.MethodDescriptor;
import io.grpc.protobuf.ProtoUtils;

public final class LibraryMethods {

    public static final MethodDescriptor<GetBookRequest, Book> GET_BOOK =
            MethodDescriptor.<GetBookRequest, Book>newBuilder()
                    .setType(MethodDescriptor.MethodType.UNARY)
                    .setFullMethodName(MethodDescriptor.generateFullMethodName(
                            "plume.linkd.v3.Library", "GetBook"))
                    .setRequestMarshaller(ProtoUtils.marshaller(GetBookRequest.getDefaultInstance()))
                    .setResponseMarshaller(ProtoUtils.marshaller(Book.getDefaultInstance()))
                    .setIdempotent(true)
                    .build();

    private LibraryMethods() {
    }
}

setFullMethodName 的格式是 包.服务/方法,斜杠前是服务名,斜杠后是方法名。写成 plume.linkd.v3.Library.GetBook 或 /GetBook,客户端能发出去,服务端会返回 UNIMPLEMENTED,因为路由按完整名字精确匹配。

setIdempotent(true) 不改变线格式,它只是告诉框架「这个方法重复调用是安全的」,供负载均衡、重试和追踪使用。GetBook 是只读查询,标 true 合理;CreateOrder 这类方法不能标,是否重试要靠 3.3 的幂等键判断。

服务端用 ServerServiceDefinition 把方法名和实现绑起来,客户端用 ClientCalls.blockingUnaryCall 调用:

package com.plume.linkd.v3.demo;

import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryMethods;
import io.grpc.CallOptions;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.ServerServiceDefinition;
import io.grpc.stub.ClientCalls;
import io.grpc.stub.ServerCalls;

public final class WireRoundTrip {

    public static void main(String[] args) throws Exception {
        ServerServiceDefinition service = ServerServiceDefinition.builder("plume.linkd.v3.Library")
                .addMethod(LibraryMethods.GET_BOOK, ServerCalls.asyncUnaryCall((request, observer) -> {
                    observer.onNext(Book.newBuilder()
                            .setIsbn(request.getIsbn())
                            .setTitle("book:" + request.getIsbn())
                            .setStock(1)
                            .build());
                    observer.onCompleted();
                }))
                .build();

        Server server = ServerBuilder.forPort(0).addService(service).build().start();
        ManagedChannel channel = ManagedChannelBuilder
                .forAddress("127.0.0.1", server.getPort())
                .usePlaintext()
                .build();
        try {
            Book book = ClientCalls.blockingUnaryCall(
                    channel, LibraryMethods.GET_BOOK, CallOptions.DEFAULT,
                    GetBookRequest.newBuilder().setIsbn("42").build());
            System.out.println("unary=" + book.getTitle());
        } finally {
            channel.shutdownNow();
            server.shutdownNow();
        }
    }
}

ServerBuilder 与 ManagedChannelBuilder 会按 classpath 选择传输 provider;本机只引入 grpc-netty-shaded,选中它内部的 Netty 实现,端口传 0 时由系统分配空闲端口,避免与 9093 冲突。

本机验证时用的是 shaded 的 NettyServerBuilder.forPort(0);上面的代码用 ServerBuilder,两者在只引入 shaded 的 classpath 上选中同一个 provider。本机(Temurin 25.0.5+8,grpc-netty-shaded 1.76.0)输出:

unary=book:42

这段代码只用了 ServerServiceDefinition 和 ClientCalls,没有生成 stub。真实项目里 comm-api 会由 protoc 生成 LibraryGrpc,用法等价:LibraryGrpc.newBlockingStub(channel).getBook(request)。手工写一遍的价值在于看清 stub 只是 MethodDescriptor + Channel + CallOptions 的封装,不是黑盒。

语义层与线格式层的边界

分清两层之后,很多设计问题就有了判断依据:

问题归属层判断依据
新增一个可选字段线格式 + 契约只要字段号新、类型兼容,旧端能忽略
把 int32 改成 string契约线格式类型不兼容,必须新字段
超时设多少语义取决于调用链预算,不是协议常量
错误怎么表达语义用 Status + ErrorInfo,不要塞进响应消息
请求 ID 怎么传语义(metadata)用 x-request-id,不要新增消息字段
消息太大怎么办线格式4 MiB 上限,超出走分页或对象存储

最容易犯的错是把语义塞进消息体。比如在 Book 里加 bool success 和 string error_message,看起来自洽,实际上有三个代价:第一,错误无法被拦截器统一处理;第二,重试逻辑拿不到「可重试」标记;第三,HTTP 网关无法把错误映射成状态码。1.2 会给出正确的错误模型。

反过来,把线格式问题当语义问题也会出错。比如为了「让错误更明确」把 grpc-status 改成自定义数字,代理和客户端都不认识,最后只能自己实现一套协议栈。

常见错误与边界

错误一:把 :status 200 当成功。 前面说过,gRPC 的业务状态在 trailers。只看 HTTP 状态码,会把 NOT_FOUND 当成成功,把响应里的默认 Book 当成真实数据。

错误二:长度字段含前缀。 五字节前缀里,长度只算消息体。含前缀会让两端各差 5 字节,错误延迟暴露。

错误三:忽略 compressed 标志。 客户端可能按 grpc-encoding: gzip 压缩,服务端必须先看 compressed 位再决定要不要解压。直接按未压缩解析,得到的是乱码而不是明确的错误。

错误四:方法名大小写或包名不一致。 plume.linkd.v3.Library/GetBook 与 plume.linkd.v3.library/GetBook 在字符串层面不同,路由不会匹配。生成代码能避免这类问题,手工写 MethodDescriptor 时最容易踩。

错误五:把大对象塞进 metadata。 metadata 是 HTTP/2 头,ASGI 代理和网关对头大小有限制,而且头不参与流控。超过几 KB 的内容应该放消息体,metadata 只放标识和少量上下文。

边界:4 MiB 是单条消息上限,不是单次调用上限。 流式调用可以发很多条消息,每条都受 4 MiB 限制;总大小由 deadline、流控和业务分页共同约束。服务端配置的上限如果小于客户端,客户端会在发送时收到 RESOURCE_EXHAUSTED,而不是等到业务处理。

边界:grpc-timeout 的单位是纳秒到小时。 常见写法是 800m(毫秒)、1S(秒)、100u(微秒)。解析时不要假设只有毫秒一种单位;跨语言调用时,单位写错会导致超时被放大或缩小三个数量级。

小结

  • RPC 语义的五个要素:方法标识、参数与返回值、关联、时间、错误,分别落在 :path、DATA、stream ID、grpc-timeout 和 trailers。
  • gRPC 的 HTTP 状态码几乎总是 200,业务结果看 grpc-status;非 200 通常意味着请求没到 gRPC 层。
  • 消息前缀是 5 字节:1 字节压缩标志 + 4 字节大端长度,长度只算消息体,上限 4 MiB。
  • MethodDescriptor 是方法身份证,包含类型、完整方法名、编组器和幂等标记;stub 只是它的封装。
  • 语义问题不要塞进消息体,线格式问题不要自定义协议字段;两层分开,后面的治理才有落点。
  • 本机最小闭环的输出是 unary=book:42,它证明的是调用链闭合,不包括超时、重试和错误处理。

调用能跑通之后,下一个问题是「失败时对端到底收到了什么」。1.2 从 Status 的十六个状态码讲到 ErrorInfo 的原因码,把「书不存在」和「下游挂了」分开。

阅读导航:上一节 目录与学习路径 | 下一节 1.2 错误模型与状态码

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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