本文基于 jaws 源码(commit 27d5fcd)撰写,所有代码片段均摘自 jaws-coretransport/http2 包。jaws 是一个核心不到 2 万行的轻量级 RPC 框架,目标是用可读完的代码量完整呈现工业级 RPC 的核心机制。

0. 一个四天的故事

jaws 的 HTTP/2 传输不是一次设计出来的,而是四天里五步长出来的:

日期动作关键决策
D1删掉 jaws-transport-grpc 模块借 gRPC 框架壳,只作为传输价值单一,且没有基于它继续演进的计划
D1自研 h2c 传输落进 core不借助任何 RPC 框架,用 Netty 裸写 HTTP/2,每请求独立 stream,从设计上消除队头阻塞
D2Server Streaming方法返回 Flow.Publisher 即流式,x-jaws-streaming 头标记,协议零帧格式改动
D3TLS / 多连接 / 健康检查 / 网关元数据生产四件套一次补齐,GOAWAY 优雅排空
D4gRPC 协议方言不引入 gRPC 框架,而是让 jaws HTTP/2 传输"听懂" gRPC 线格式,零 proto 依赖

最有意思的是头尾两步:第一天删掉 gRPC,第四天 gRPC 又回来了——但不是以「依赖 gRPC 框架」的方式,而是以「jaws 自己说 gRPC 协议」的方式。从借壳到拆壳再造,这个弧线值得完整讲一遍。

下面按分层架构自底向上:先看为什么原 TCP 协议有队头阻塞,再看 HTTP/2 传输的 pipeline 怎么搭,然后逐层往上讲流式、生产化、协议兼容。

1. 问题的起点:TCP 协议的队头阻塞在哪

jaws 原有的主力传输是 Netty TCP + 自研二进制协议:NettyClient 维护单条连接,所有并发请求共用它,靠 16 字节协议头里的 requestId 区分彼此——也就是经典的「单连接多路复用」:

/**
 * Netty-based {@link Client} implementation maintaining a single
 * ...
 */
public class NettyClient extends AbstractClient {
    private volatile NettyChannel channel;

这个模型本身没问题,Dubbo、Motan 都这么干,它换来的是连接数少、握手成本低、协议头紧凑。它的问题出在公平性:单条 TCP 连接上,字节流是严格有序的。如果某个大响应正在传输,排在它后面的小响应只能等;更糟的是如果这条连接抖动,所有在途请求一起受牵连。这就是队头阻塞(Head-of-Line Blocking)在应用层的形态——不是 TCP 传输层那种丢包引发的,而是单流多路复用固有的串行化代价。

举个具体的例子:consumer 用 20 个线程打同一个 provider,A 线程调了一个返回 1MB 列表的方法,B 线程调一个返回 32 字节心跳级小响应的方法。在单连接多路复用下,这两个响应的序列化字节在同一条 TCP 流里排队,NettyDecoder 按到达顺序逐帧解出,再按 requestId 分发——B 的小响应必须等 A 的大响应全部走完。极端情况下,一个慢序列化的大对象能拖住整条连接上所有人的 P99。

HTTP/2 的解法是 stream:一条连接上开多个并行流,每个流有自己的 HEADERS/DATA 帧序列,流与流之间不互相排队。每个 stream 有独立的生命周期(打开、数据、半关、关闭),独立的流控窗口,framing 层保证帧不交错损坏。理论上 TCP 层的队头阻塞仍在(丢包会阻塞整条连接的所有流),但应用层的串行化没了——对同机房回环、低丢包率的 RPC 场景,收益是实打实的。

还有一个容易被忽略的隐性收益:请求上下文的隔离。TCP 协议下,解码器吐出的是无状态的帧,「这个响应属于哪个请求」要靠 requestId 在 ConcurrentMap 里查;查表逻辑、回调注册、超时取消、连接关闭时的回调清理,全是要小心维护的共享状态。HTTP/2 下,每个 stream 天然对应一个 handler 实例,上下文就是实例字段——共享状态直接消失了一类。

所以 D1 的决策是:不要 gRPC 框架,但要 HTTP/2 的 stream 模型,自己写。

2. 地基:h2c pipeline 怎么搭

Netty 官方提供了 HTTP/2 的积木,jaws 用了三块核心:

Http2FrameCodecBuilder.forServer().build()      // 帧编解码 + 连接状态机
Http2MultiplexHandler                          // 每个新 stream 生成一个子 handler
Http2StreamChannelBootstrap                    // 客户端主动开 stream 的入口

服务端 pipeline 的骨架(Http2Server):

pipeline.addLast(sslHandler);            // 可选,TLS 时才有
pipeline.addLast(http2FrameCodec);       // Http2FrameCodec
pipeline.addLast(new Http2MultiplexHandler(
        new Http2ServerStreamHandler(...)));  // 每 stream 一个实例

Http2MultiplexHandler 的语义值得强调:它为每个新到的 stream 实例化一个 Http2ServerStreamHandler,这个 handler 只看得见自己 stream 的 HEADERS/DATA 帧。也就是说,「请求上下文」这个在 TCP 协议里要靠 requestId + callbackMap 手工维护的东西,在 HTTP/2 传输里被结构化地隔离了——一个 handler 实例就是一个请求的一生。

选型上还有一个不显眼但关键的差异:HTTP/2 有两种「入门方式」——h2c 的 prior knowledge(双方预先约定说 HTTP/2,连接建立后直接发帧,前缀是 PRI * HTTP/2.0 魔法串)和 ALPN 协商(TLS 握手时在扩展里选协议)。jaws 不配 TLS 时走前者,配了 TLS 走后者,对应到 Netty 就是:Http2FrameCodecBuilder 负责 framing 和连接状态机,加密不加密只影响 pipeline 里有没有 SslHandler,帧层完全无感。这也是后面「TLS 是可选项而不是独立传输」能成立的结构基础。

另一个值得对比的积木是 Http2MultiplexHandlerHttp2MultiplexCodec:前者基于 Http2StreamChannel(每个 stream 一个完整子 channel,handler 可以是普通 ChannelInboundHandler,能自由组合 pipeline),后者已废弃。jaws 的服务端 handler 树、客户端的 Http2StreamChannelBootstrap,全部建立在前者的模型上——stream 是 channel,这个抽象让 HTTP/2 编程的心智模型回到了 Netty 用户熟悉的样子。

客户端这边,每次调用开一个新 stream:

io.netty.channel.Channel streamChannel =
        new Http2StreamChannelBootstrap(connChannel)
                .handler(new Http2StreamResponseHandler(this, request.getRequestId()))
                .open().syncUninterruptibly().getNow();

// Register before writing so a fast failure (channelInactive) can
// always find and fail the future
registerCallback(request.getRequestId(), responseFuture);

byte[] payload = Http2PayloadCodec.encodeRequest(request, serialization);
Http2Headers headers = buildRequestHeaders(request);
streamChannel.write(new DefaultHttp2HeadersFrame(headers));
streamChannel.writeAndFlush(new DefaultHttp2DataFrame(
        Unpooled.wrappedBuffer(payload), true));

注意这里依然注册了 requestId → ResponseFuture 的回调——因为 jaws 的异步模型(DefaultResponseFuture + 超时调度)是传输无关的,上提到了 AbstractClient 里统一维护(HTTP/2 和 TCP 两个客户端共享同一套记账逻辑,后面细讲)。

保活的差异:TCP 传输用应用层心跳(IdleStateHandler 事件驱动的双向心跳帧),HTTP/2 传输则交给 Netty Http2FrameCodec 的 PING keepalive——协议层自带保活,就不必在应用层再发明一个。

3. 序列化边界:payload 仍是 byte[]——这是 HTTP/2 的固有成本

上一篇文章讲过 jaws TCP 传输的全链路 ByteBuf 零拷贝(编解码直写 ByteBufOutputStream)。HTTP/2 传输做不到那么极致,原因是边界的性质变了:Http2DataFrame 接受的是完整消息体,帧头由 Netty 管理,业务侧拿到的是「先攒完整 payload,再包成帧」的模型:

public interface Http2PayloadCodec {
    // 请求/响应整体序列化为 byte[],包进单个 DATA 帧
    static byte[] encodeRequest(Request request, Serialization serialization) { ... }
    static DefaultRequest decodeRequest(byte[] payload, Serialization serialization) { ... }
}

这是 HTTP/2 这类「帧协议」的固有成本:你不能像 TCP 自定义协议那样把序列化流直接怼进连接级的输出流,因为帧边界必须完整。gRPC 也一样,protobuf 序列化完才封帧。jaws 的取舍是:TCP 传输继续零拷贝路线(教学价值 + 极致性能),HTTP/2 传输接受 byte[] 边界,换取 stream 模型和后面的全部演进空间。

值得一提的是,item 级编解码复用的正是 jaws 流式 Serialization SPI——ObjectOutput.writeObject 写单个对象、ObjectInput.readObject 读单个对象,序列化器本身不感知「流」的存在:

public static byte[] encodeItem(Object item, Serialization serialization) throws IOException {
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    try (ObjectOutput out = serialization.serialize(bos)) {
        out.writeObject(item);
        out.flush();
    }
    return bos.toByteArray();
}

这套 SPI 最初是为 TCP 零拷贝(直写 ByteBufOutputStream)设计的,HTTP/2 传输把它接到 ByteArrayOutputStream 上就获得了 item 级复用——好接口的价值在于第二个使用者出现时零改造

另外,服务端收请求时的攒包逻辑值得一提——DATA 帧可能分片到达,Http2ServerStreamHandler 用一个带 maxContentLength 上限的缓冲区重组:

if (buffer.size() + content.readableBytes() > maxContentLength) {
    overLimit = true;
    sendError(ctx, Http2Constants.STATUS_BAD_REQUEST,
            "Request payload exceeds maxContentLength: " + maxContentLength);
    return;
}

超限直接拒绝并标记 overLimit,后续帧静默丢弃,防止恶意分片把内存撑爆。

4. Server Streaming:方法签名即协议

流式是 D2 的重头。设计上最省笔墨的一步是模式判定:StreamType 不需要用户配置,按方法签名自动识别——返回 Flow.Publisher 就是 server streaming,否则 unary:

public enum StreamType {
    /** Traditional unary invocation: one request, one response. */
    UNARY("unary"),
    /** Server streaming: client sends one request, server streams multiple responses. */
    SERVER("server"),
    ...
}

线上协议只加了一个 HTTP 头 x-jaws-streaming: server,不动帧格式。服务接口这样写:

public interface StreamService {
    /** Server-streaming: returns a {@link Flow.Publisher} that emits
     * {@code count} greeting items with the given prefix. */
    Flow.Publisher<String> greetStream(String prefix, int count);
}

java.util.concurrent.Flow 而不是 Reactor/RxJava,一是因为它是 JDK 9+ 原生 API,框架不想为流式引入响应式库依赖;二是它足够小——四个接口(Publisher/Subscriber/Subscription/Processor),教学叙事友好。

和 Dubbo 对比一下这个决策的分量:Dubbo 3 的 Triple 流式 API 绑定的是 CompletableFuture + reactor 生态的双轨制,Reactive Stream 特性依赖 Flux/Mono;而 jaws 用 JDK 自带的 Flow,消费端代码不 import 任何第三方响应式类型,接口 JAR 里也没有传递依赖。对一个「希望读者把源码读完整」的框架,少一个依赖的收益是双倍的:构建轻了,认知也轻了。

4.1 服务端:订阅 Publisher,逐 item 写帧

Http2ServerStreamHandler 把入口拆成 dispatchUnary / dispatchStream 两条路径。流式路径先发不带 END_STREAM 的响应头,再订阅业务 Publisher:

private void dispatchStream(ChannelHandlerContext ctx, Request request) {
    Flow.Publisher<Object> publisher = messageHandler.handleStream(serverChannel, request);

    // Send response headers first (without END_STREAM)
    Http2Headers respHeaders = new DefaultHttp2Headers()
            .status(Http2Constants.STATUS_OK)
            .set(Http2Constants.HEADER_CONTENT_TYPE, Http2Constants.CONTENT_TYPE)
            .set(Http2Constants.HEADER_STREAMING, StreamType.SERVER.getWireValue());
    ctx.write(new DefaultHttp2HeadersFrame(respHeaders));

    publisher.subscribe(new Flow.Subscriber<>() {
        @Override
        public void onSubscribe(Flow.Subscription s) {
            s.request(Long.MAX_VALUE);
        }
        @Override
        public void onNext(Object item) {
            byte[] itemBytes = Http2StreamCodec.encodeItem(item, serialization);
            ctx.writeAndFlush(new DefaultHttp2DataFrame(
                    Unpooled.wrappedBuffer(itemBytes), false));   // 不带 END_STREAM
        }
        ...
    });
}

两个细节体现了真实的工程思考:

其一,onSubscribe 里直接 request(Long.MAX_VALUE) 而不是 request(1),源码注释写明了原因——「避免在 onNext() 里同步递归调用 subscription.request(1)」。HTTP/2 的流控在传输层(Netty 会按 connection window 自动协调),应用层逐 item 申请反而会把 Netty event loop 拖进递归。

其二,每个 item 用 Http2StreamCodec 独立序列化,复用 jaws 流式 Serialization SPI 的 ObjectOutput/ObjectInput,不引入任何「整个流的包装结构」——流的边界由 END_STREAM 表达,item 边界由 DATA 帧表达,序列化只管 item 本身。

完成时发一个空的 END_STREAM 帧收尾:

@Override
public void onComplete() {
    // Send final empty DATA frame with END_STREAM
    ctx.writeAndFlush(new DefaultHttp2DataFrame(true));
    finishStream();   // RpcContext.destroy() + activeRequests 减一
}

背压的诚实声明:这套实现的背压是「服务端 Publisher 驱动」的——request(n) 只是 no-op 提示,真正的节流靠服务端 Publisher 自身的产生速率。如果业务 Publisher 是无界高速产出(比如轮询数据库变更的无限流),客户端消费不动时,中间靠的是 Netty HTTP/2 的 stream flow-control window:发送侧写不进去会体现在 channel.isWritable(),但 jaws 目前没有把它反馈回 Publisher 的 request 调用。对教学框架,把这条边界写清楚比硬造一个不完整的双向背压更负责任——生产级的答案是订阅时 request(1)、每消费完一个再 request 下一个,并把 isWritable() 纳入决策,这留给读者作为练习入口。

4.2 客户端:handler 即 Publisher

消费端更妙——Http2StreamClientHandler 自己就实现了 Flow.Publisher,每收到一个 DATA 帧就解一个 item、onNext 一个:

class Http2StreamClientHandler extends ChannelInboundHandlerAdapter
        implements Flow.Publisher<Object> {

    @Override
    public void subscribe(Flow.Subscriber<? super Object> subscriber) {
        this.subscriber = subscriber;
        subscriber.onSubscribe(new Flow.Subscription() {
            @Override
            public void request(long n) {
                // We always request 1 at a time from the server side; this is a no-op
                // hint. The actual back-pressure is driven by the server's Publisher.
            }
            @Override
            public void cancel() { terminate(); }
        });
    }

    private void onData(Http2DataFrame dataFrame) {
        ...
        Object item = Http2StreamCodec.decodeItem(bytes, serialization);
        deliverItem(item);   // subscriber.onNext(item)
        if (dataFrame.isEndStream()) {
            complete();      // subscriber.onComplete()
        }
    }
}

业务侧消费就是标准 Flow API:

Flow.Publisher<String> publisher = streamService.greetStream("hello", 5);
publisher.subscribe(new Flow.Subscriber<>() {
    @Override public void onSubscribe(Flow.Subscription s) { s.request(Long.MAX_VALUE); }
    @Override public void onNext(String item) { System.out.println("stream item => " + item); }
    @Override public void onError(Throwable t) { ... }
    @Override public void onComplete() { ... }
});

client/bidi streaming 也曾做到一半(Flow.Publisher 参数、Cluster 层 callStream 覆写、Flow 桥接),最终评估后废弃:server streaming 已覆盖流式下发的核心场景,双向流带来的复杂度(集群层重试语义、双向背压协调)远超一个教学框架愿意承担的份量。砍掉一半的流式,换来整个特性的可读性,这本身就是骨架框架的取舍示范。

5. 站稳脚跟:传输层基类收敛

有了两个客户端实现(TCP 的 NettyClient、HTTP/2 的 Http2Client),重复逻辑立刻显形:URL 与状态管理、异步回调记账、超时调度、优雅关闭——两边各写一份,还各有微妙差异。D3 的第一步是把它收敛进 AbstractClient:

public abstract class AbstractClient implements Client {
    /** Max number of in-flight requests per client, rejecting overflow
     *  requests to prevent OutOfMemoryError. */
    private static final int MAX_INFLIGHT_REQUESTS = 20000;

    /** Per-request timeout scheduler shared by all clients. */
    private static final HashedWheelTimer timeoutTimer = new HashedWheelTimer(...);

    /** Async requests need to register a callback future.
     *  Removal triggers: 1) response received 2) timeout task cancels it 3) close(). */
    private final ConcurrentMap<Long, ResponseFuture> callbackMap = new ConcurrentHashMap<>();
    private final ConcurrentMap<Long, Timeout> timeoutMap = new ConcurrentHashMap<>();
    ...
}

三个收益:

单一事实来源。异步超时从「netty 版、http2 版各一套略有差异的实现」变成一处实现,修 bug 只修一次。这里有个容易被低估的细节:超时调度用的是 HashedWheelTimer 而不是每个请求一个 ScheduledFuture。RPC 客户端在途请求成千上万时,ScheduledThreadPoolExecutor 的堆操作是 O(log n) 且全局锁竞争;时间轮按 tick 环形槽管理,插入 O(1),代价是精度受 tick 间隔限制——对 RPC 超时这种「百毫秒级精度足够」的场景,这笔交易稳赚。两个客户端各自实现时,这种调优容易只落在其中一个上;收敛后才成为两者的共同默认。

优雅停机模板化close(timeout) 的排空逻辑(在途请求等完 → 超时取消残余 → doClose() 交给子类释放传输专属资源)只写一遍,两个客户端只实现各自的 doClose。注释里明确写了回调移除的三种触发:「response received / timeout task cancels / close()」——三处代码路径共享同一个不变量,这是并发代码最想要的结构。

过载保护单点化registerCallback 里统一做 MAX_INFLIGHT_REQUESTS 检查,超过 2 万在途直接拒绝,防 OOM——这类保护必须在所有传输上一致生效,放在基类是最不容易漏的位置。

净效果是两个客户端合计删掉约 320 行重复代码,Http2Client 只保留连接管理与请求发送。这一步和 jaws 此前的「Cluster 装配后不可变」「SPI 单例化」是同一条脉络:把骨架打磨到每个机制只有一个权威实现

6. 生产四件套:TLS、多连接、健康检查、网关元数据

D3 下午,HTTP/2 传输一次补齐生产化能力。挑设计上最值得讲的三个:

6.1 TLS + ALPN,可选 mTLS

三个 URL 参数控制:sslCertChain / sslPrivateKey(配齐即 mTLS 双向认证)、sslTrustCert(信任 CA)。构建逻辑:

private SslContext buildSslContext() {
    ...
    if (hasMutualTls) {
        builder = SslContextBuilder.forClient()
                .keyManager(new File(certChain), new File(privateKey));
    } else {
        builder = SslContextBuilder.forClient();
    }
    if (hasTrust) {
        builder.trustManager(new File(trustCert));
    }
    return builder
            .sslProvider(SslProvider.JDK)
            .applicationProtocolConfig(new ApplicationProtocolConfig(
                    ApplicationProtocolConfig.Protocol.ALPN,
                    ...
                    ApplicationProtocolNames.HTTP_2))
            .build();
}

不配任何参数就是纯 h2c,配了走 ALPN 协商 h2——加密是传输的一个可选项,而不是一个独立的传输实现,这和被删掉的 gRPC 模块(加密和传输绑死)形成对比。

6.2 多连接 + Round-Robin

Http2Client 从单 channel 重构为连接数组:

/**
 * Multiple connections for L4 LB distribution. When connectionCount > 1,
 * requests are distributed across connections via round-robin.
 */
private volatile io.netty.channel.Channel[] channels;
private final AtomicInteger requestCounter = new AtomicInteger(0);

private io.netty.channel.Channel selectConnection() {
    if (channels.length == 1) {
        return channels[0];
    }
    int idx = Math.abs(requestCounter.getAndIncrement() % channels.length);
    return channels[idx];
}

注释把动机写得很直白:为 L4 负载均衡。单连接复用在 LB 后面会失去均衡意义——所有流量粘在一条连接、落在一台后端;多条连接 round-robin 分发,四层 LB 才有把流量摊到多台后端的机会。流内依然多路复用,连接间轮询,两者是正交的。

配套测试是多连接下 50 并发请求跨 3 条连接的全通过用例。

6.3 网关友好:health、元数据镜像、GOAWAY

这是最有「产品思维」的一组改动。健康检查零开销直达:

// Health check: GET /health returns immediately without dispatching
if ("GET".equals(method) && Http2Constants.HEALTH_PATH.equals(path)) {
    sendHealthResponse(ctx);   // 200 OK + "OK",不进业务线程池,不序列化
    return;
}

元数据镜像:客户端把接口名、方法名、参数签名、group、version 镜像进 HTTP 头:

// Mirror metadata for gateway-level routing and observability
if (request.getInterfaceName() != null) {
    headers.set(Http2Constants.HEADER_INTERFACE, request.getInterfaceName());  // x-jaws-interface
}
if (request.getMethodName() != null) {
    headers.set(Http2Constants.HEADER_METHOD, request.getMethodName());        // x-jaws-method
}

七层网关(Nginx/Envoy)从此不需要解 body 就能按接口路由、按 group 灰度、按方法做观测——RPC 流量第一次对中间层「可读」。

优雅排空:server 停机时向所有活跃连接发 GOAWAY(HTTP/2 协议原生的"请去别处"信号),客户端收到后主动迁移新连接,与 jaws 四阶段优雅停机(停止接收 → 等在途 → 注销注册中心 → 关连接)正好衔接。实现上用 Netty 的 ChannelGroup 追踪全部活跃连接:

/** Tracks all active connection channels for GOAWAY on graceful shutdown. */
private final ChannelGroup connectionChannels = new DefaultChannelGroup(
        "http2-connections", GlobalEventExecutor.INSTANCE);

GOAWAY 里会带上「最后一个允许的 streamId」,已开的 stream 允许完成、新 stream 拒绝——这是 HTTP/2 协议为优雅排空专门设计的语义,比自己发明「停机标志位 + 心跳通知」干净得多。对比一下:TCP 传输的优雅停机要在协议头里找地方塞状态、靠心跳帧捎带通知,HTTP/2 传输则直接用协议原生能力,这也是「用协议自带的表达,不发明自己的」的又一次实践。

7. gRPC 回归:不是框架,是方言

D4 的 gRPC 兼容层是整个进化史的点睛之笔。目标:标准 gRPC 客户端,零 proto 依赖,直接调 jaws 服务——包括流式。

入口识别靠 content-type:

// gRPC compatibility: detect by content-type
String contentType = headers.get(Http2Constants.HEADER_CONTENT_TYPE) != null
        ? headers.get(Http2Constants.HEADER_CONTENT_TYPE).toString() : null;
if (GrpcCodec.isGrpcContentType(contentType)) {     // application/grpc+json
    grpcRequest = true;
    String[] parsed = GrpcCodec.parsePath(path);    // /{package}.{Service}/{Method}
    grpcServiceName = parsed[0];
    grpcMethodName = parsed[1];
    ...
}

核心是 GrpcCodec,三件事。gRPC 帧编解码——5 字节长度前缀(1 字节压缩标志 + 4 字节大端长度):

public static byte[] decodeFrame(byte[] data) {
    ...
    // byte 0: compressed flag (ignored, we don't support compression yet)
    int length = ((data[1] & 0xFF) << 24)
            | ((data[2] & 0xFF) << 16)
            | ((data[3] & 0xFF) << 8)
            | (data[4] & 0xFF);
    byte[] message = new byte[length];
    System.arraycopy(data, 5, message, 0, length);
    return message;
}

path 解析:gRPC 的 :path/{package}.{Service}/{Method},jaws 拿它直接查 Provider 注册表(findProviderByInterface)。JSON → Java 参数:gRPC 官方是 protobuf,这里用 application/grpc+json 编码,消息体是 JSON,按位置映射成方法参数:

public static Object[] jsonToArguments(String json, Method method) {
    ...
    Object parsed = JSON.parse(json);
    if (parsed instanceof JSONObject jsonObj) {
        return jsonObjectToArray(jsonObj, paramTypes);   // 对象字段按序映射
    } else if (parsed instanceof com.alibaba.fastjson2.JSONArray jsonArr) {
        return jsonArrayToArray(jsonArr, paramTypes);    // 数组按位映射
    }
    if (paramTypes.length == 1) {                        // 单标量
        return new Object[]{convertValue(parsed, paramTypes[0])};
    }
    ...
}

响应按 gRPC 线格式回:HEADERS(200) + DATA(gRPC 帧) + TRAILERS(grpc-status: 0)。错误语义对齐 gRPC 状态码——UNIMPLEMENTED(方法/服务不存在)、INVALID_ARGUMENT(参数不合法)、INTERNAL(业务异常)。

流式也通:dispatchGrpc 检测到方法返回 Flow.Publisher,路由到 dispatchGrpcStream——订阅 Publisher,每个 item 包一个 gRPC 长度前缀帧写出,完成发 grpc-status: 0 的 trailers,出错发 grpc-status: 13。标准 gRPC 客户端消费 jaws 流式服务,和消费一个原生 gRPC server streaming 方法体验一致。

中途踩了一个很典型的坑,值得单独记:dispatchGrpc 按接口名找到了 Provider,但没把 group/version 回填进请求 attachment,导致后续用默认值(default_rpc/1.0)构造 serviceKey,和注册的 Provider(test/2.0)对不上,报 “no provider found”。修复就是一行语义:从解析到的 Provider URL 把 group/version 拷回 attachment。跨协议适配最容易漏的就是这种「两边都自认为对方会填」的字段

这一步和 Dubbo Triple 的思路同源——Triple 的本质也是「让标准 gRPC/HTTP 客户端能调 Dubbo 服务」,同样以 HTTP/2 为承载、以 content-type 识别、以 grpc-status 表达错误。差别在编码选择:Triple 主推 protobuf IDL,jaws 选了 application/grpc+json——不为别的,教学框架要去掉「先写 proto、再生成代码」这道前置工序,让任何一个会写 JSON 的 gRPC 客户端都能直接对话。jaws 用一个 GrpcCodec(197 行)+ 一个 dispatch 分支实现了同等的协议互操作,而这条路径在 D1 删掉 gRPC 模块时还不存在——删掉依赖换来的不是失去 gRPC,而是获得了自己定义"怎么兼容 gRPC"的自由

8. 测试策略:155 个测试怎么守住这些演进

四天五步、每步都动传输核心,测试没有红过一次,这不是运气。HTTP/2 传输的测试分层值得展开说说,因为它示范了「传输层怎么写测试」这个普遍难题的解法:

帧级测试不依赖网络Http2TransportTestEmbeddedChannel 把 handler 直接怼进内存管道,写帧、读帧、断言,不启端口、毫秒级跑完,适合覆盖协议分支(流式头、健康检查、超限拒绝、gRPC 识别)。

端到端测试走真实回环GrpcCompatibilityTest 四个用例(echo、多参数、方法不存在、服务不存在)是完整 start server → connect → invoke → assert → stop 的闭环,验证的是「gRPC 客户端视角看到的 jaws」,不是「jaws 自认为发出的字节」——协议兼容类特性必须用这种「以对方为镜子」的测法,因为兼容性的 bug 永远出现在两边的理解差异处(前面 group/version 回填那个坑就是它照出来的)。

并发拓扑测试守住重构。multiConnectionClient 用例是 50 个并发请求跨 3 条连接,全部断言响应正确——多连接 round-robin 这种改动,顺序单线程测试测不出连接错乱,必须并发压着验。

// GrpcCompatibilityTest 的验证思路(节选)
// 1. 启动 HTTP/2 server 暴露普通 jaws 服务
// 2. 用 gRPC 线格式手工构造请求: HEADERS(content-type=application/grpc+json,
//    path=/org.hongxi.jaws.test.EchoService/echo) + DATA(5字节前缀+JSON)
// 3. 断言响应: HEADERS(200) + DATA(gRPC帧+JSON) + TRAILERS(grpc-status=0)

回头看,155 个测试里 HTTP/2 相关就占了两百多行测试代码里沉淀的四组场景,而这四组场景恰好对应四次演进:基础传输(TCP 时代就有)、流式、多连接、gRPC。每次功能演进带一组对应用例进来,而不是功能堆完再补测试——这是这四天能全绿的根本原因。

9. 写在最后:传输层的「三种方言」

回头看,jaws 的 HTTP/2 传输最终形态是一个传输层说三种方言:

入口识别方式编码
jaws 自有 RPC(h2c 或 h2 over TLS)content-type 自有值任选序列化(fastjson2/hessian2/protostuff)
gRPC unary / streamingapplication/grpc+jsonJSON + gRPC 帧
探活GET /health无 body

而支撑这一切的代码,transport/http2 包 11 个类约 2400 行,加上基类 AbstractClient 不到 3000 行。三种方言共享同一套 stream 模型、同一份异步记账、同一个优雅停机模板——这就是基类收敛的意义:机制写一次,方言随便加

这四天留下的方法论,比功能本身更值钱:

其一,借壳的终点是拆壳。gRPC 模块作为「验证 SPI 可插拔」的学习模块完成了使命,就该退场;而 HTTP/2 的真正价值(stream 模型、协议互操作)要自己握着协议层代码才拿得到。

其二,协议扩展优先选带外语义。加流式没有动帧格式,只加了一个 HTTP 头;TLS 没有产生新传输,只是 pipeline 的可选段。能用协议自带的表达,就不发明自己的。

其三,砍功能也是设计。client/bidi streaming 做到一半评估后废弃、oneway 因异步已覆盖而不做——一个骨架框架的说服力,来自每行代码都有存在的理由。

jaws 的 TCP 传输讲全链路零拷贝的极致,HTTP/2 传输讲 stream 模型与协议兼容的宽度,两者在 AbstractClient 收口。下一步打算补一篇 Cluster 层的自适应负载均衡(power of two choices),把这个骨架的「上层建筑」也讲完。

jaws 源码:github.com/javahongxi/jaws(核心不到 2 万行,155 个测试全绿,欢迎 star 交流)

更多推荐