ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

一个端口,三种协议:一次 TCP 首字节探测的 Netty 深潜

一个端口,三种协议:一次 TCP 首字节探测的 Netty 深潜 本文是 JAWS RPC 框架系列的第 8 篇。JAWS 是我从零写的 RPC 框架核心四模块 2.3 万多行目标是「可以从头读到尾」的工业级 RPC 教学样本。前面的文章讲过 HTTP/2 传输、gRPC 线格式互通、全链异步改造这一篇讲一个刚落地的特性单端口多协议服务器。一、同一个端口的四次敲门先看效果。启动 adaptive provider 之后端口 10000 上同时活着三种协议随便你怎么敲# HTTP/1.1 健康检查curl-ihttp://localhost:10000/health# HTTP/1.1 发起 RPC 调用curl-XPOST http://localhost:10000/invoke\-Hcontent-type: application/json\-d{interface:org.hongxi.jaws.sample.api.DemoService, method:hello,group:test,version:2.0,args:[lily]}# HTTP/2 h2c 健康检查curl--http2-prior-knowledge-ihttp://localhost:10000/health# HTTP/2 h2c 发起 RPC 调用curl--http2-prior-knowledge-XPOST http://localhost:10000/invoke\-Hcontent-type: application/json\-d{interface:...,method:hello,args:[lily]}再加上一个走 JAWS 二进制协议的普通 consumer 直连127.0.0.1:10000——四种客户端形态一个端口全通。这个特性叫 adaptive 传输ProtocolConfig里设一个参数transportFactoryadaptive服务端就不再是「一种传输占一个端口」而是按每条 TCP 连接的首字节自动识别协议装配对应的处理管线。核心实现只有 397 行其中真正的主角ProtocolDetectionHandler占 205 行。这篇文章就深潜这 205 行。因为它几乎踩遍了 Netty pipeline 动态装配的所有关键点探测时的字节缓冲怎么管、handler 运行中怎么把自己摘掉、摘掉的顺序为什么是生死攸关的。二、决策线一个挂了十天的提案先把时间线交代清楚因为这个特性不是拍脑袋加的而是一个老提案的第三种解法。十天前我在做端口规划时面对一个经典矛盾。JAWS 的服务端传输在持续增加nettyTCP 二进制性能最好、http2多路复用 流式后来又加了 httpJSON over HTTP/1.1调试友好wire 还自成一类。每种传输各监听一个端口最直接但端口数随传输数线性膨胀而「多协议单端口」这个方向当时只有两条已知路径Dubbo 式统一端口。Dubbo 的 Port Unification端口统一PU服务端按 magic number 探测进来的帧是 dubbo 协议还是 HTTP/2 preface再路由到对应协议栈。问题是整套探测和路由机制织进了主干——配置项、URL 映射、协议装配层层纠缠读源码的人要跨好几个抽象层才能拼出全貌。HTTP/2 家族内按 path 路由。多个协议共享 HTTP/2 端口按:path语义分发。问题是这要求所有协议先「说 HTTP/2」纯二进制协议没法进来。两条路都有硬伤当时的选择是搁置默认保持三端口独立教学清晰优先。转机出现在两件事之后。第一件HTTP/1.1 传输层落地三个传输的 pipeline 装配代码都齐了——netty、http2、http 三套管线各自独立、各自能跑。第二件我意识到三条管线的「分叉点」其实极早协议差异从 TCP 连接的前几个字节就能分辨。这意味着不需要 Dubbo 那种织进主干的常驻路由层只需要一个站在 pipeline 最前面的「一次性哨兵」看一眼首字节装配对应管线然后把自己删掉功成身退。于是有了第三种解法adaptive 不改变任何默认行为它是第五种传输工厂wire 是第四种一个 opt-in 参数。三端口独立的教学清晰性保住了单端口的运维便利性也拿到了——不用二选一。接入方式也是纯 SPI 的零侵入。一个Extension(adaptive)注解的工厂类加进META-INF/services的注册文件核心的分发逻辑一行没动Extension(adaptive)publicclassAdaptiveTransportFactoryextendsAbstractTransportFactory{OverridepublicSetStringsupportedProtocols(){returnSet.of(jaws);}OverrideprotectedServerinnerCreateServer(URLurl,MessageHandlermessageHandler){returnnewAdaptiveServer(url,messageHandler);}OverrideprotectedClientinnerCreateClient(URLurl){thrownewUnsupportedOperationException(...);}}supportedProtocols()声明它承载 jaws 协议传输解析器按既有规则显式transportFactory参数优先把它挂进装配链。对框架其余部分来说adaptive 就是一个普通的 Server 实现——它对内怎么变魔术JawsExporter一无所知。特性的复杂度被完整封在了一个包里这是判断「一个特性该不该做」时我最看重的信号。三、探测规则三个字节定生死ProtocolDetectionHandler的探测规则朴素到近乎简陋首字节特征协议装配的管线0x4A 0x57“JW”JAWS 二进制NettyDecoder NettyChannelHandlerPRI * HTTP/2.0...24 字节 prefaceHTTP/2 h2cHttp2FrameCodec Http2MultiplexHandlerG/P/D/H/O/T/C开头HTTP/1.1HttpServerCodec Aggregator HttpRequestHandler对应的核心代码privatevoiddetectAndConfigure(ChannelHandlerContextctx){if(cumulation.readableBytes()3){return;// 至少攒 3 字节才能做第一轮判别}byteb0cumulation.getByte(cumulation.readerIndex());byteb1cumulation.getByte(cumulation.readerIndex()1);// JAWS 二进制协议2 字节 magic 0x4A57if(b0(byte)0x4Ab1(byte)0x57){configureJawsBinary(ctx);return;}// HTTP/2 h2c prior-knowledge以 P 开头完整 preface 是 24 字节if(b0P){if(cumulation.readableBytes()HTTP2_PREFACE_LENGTH){return;// 等满 24 字节再判}if(matchesHttp2Preface()){configureHttp2(ctx);return;}}// HTTP/1.1首字节是 ASCII HTTP 方法起始字符if(isHttpMethodStart(b0)){configureHttp1(ctx);return;}// 未知协议快速失败thrownewIllegalStateException(AdaptiveServer: cannot detect protocol from first bytes: 0xInteger.toHexString(b00xFF) 0xInteger.toHexString(b10xFF), remotectx.channel().remoteAddress());}几个设计点值得展开为什么 3 字节起步JAWS 的 magic0x4A57需要 2 字节而 HTTP 方法判别只需要 1 字节。攒 3 字节是让所有判别规则都有足够弹药的第一档位。TCP 不保证消息边界首包可能只有 1 字节也可能一次到达整个请求——探测逻辑必须对任意切分方式都成立。HTTP/2 的判定为什么特殊HTTP/2 h2c 的 prior-knowledge 模式以固定的 24 字节连接前导PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n开头。看到P开头还不能下结论——POST 也是P开头——必须等满 24 字节做全量比对。这就是探测逻辑里唯一的「等待」分支。HTTP/1.1 的判定是最弱的。7 个方法首字符GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS/TRACE/CONNECT 的首字母去重本质上是在赌「没有其他协议会以这些 ASCII 字母开头」。对 RPC 服务器来说这个赌注是安全的但它说明了首字节探测的能力边界字节层能区分的是「协议家族的指纹」不是协议的全部语义。这个边界后面还会回来咬人见第五节。未知协议直接抛异常。探测不出来的连接不猜、不降级、不默认直接断掉并在日志里留下前两个字节的十六进制。调试协议问题时这个日志比任何模糊的「连接被重置」都有用。四、深潜探测器的生命周期4.1 为什么不用 ByteToMessageDecoderNetty 自带的ByteToMessageDecoder就是干「攒字节、够了就触发」这事的但这里故意继承的是更原始的ChannelInboundHandlerAdapter。Javadoc 里写明了原因/** * This handler extends {link ChannelInboundHandlerAdapter} (not * {link io.netty.handler.codec.ByteToMessageDecoder}) so that the cumulation * buffer is managed explicitly and can be safely forwarded to the next * pipeline stage before self-removal. */关键在后半句。ByteToMessageDecoder的 cumulation 缓冲区是它的私有领地由它的生命周期管理而探测器的需求是「把攒下的字节连同所有权一起交给刚装配好的协议管线然后自己消失」。如果借用父类的缓冲区自删时缓冲区的释放路径就会纠缠不清。手工管理一个ByteBuf cumulation字段责任边界反而清晰探测期内我拥有它交出去之后一刀两断。4.2 摘掉自己三个字节的顺序决定了生死整篇文章最值钱的代码是forwardAndCleanup一共 12 行privatevoidforwardAndCleanup(ChannelHandlerContextctx){// 转发前先保留一份数据快照ByteBufretainedcumulation.retainedSlice();cumulation.release();cumulationnull;// 必须先删除自己再 fire 数据ctx.pipeline().remove(this);// 现在数据会流经新装配的协议 handlerctx.pipeline().fireChannelRead(retained);}为什么remove必须发生在fireChannelRead之前pipeline.fireChannelRead()是从pipeline 头部开始传播的。如果 detector 还在 pipeline 里就 fire这批数据会先流回 detector 自己——触发第二轮探测往 pipeline 里再插一套协议 handler然后 Netty 抛出IllegalArgumentException: Duplicate handler name。这个坑的阴险之处在于本地测试很难撞上因为探测通常在一个 channelRead 里就完成了但一旦 TCP 分包让数据分多次到达、或者未来有人在 fire 路径上加了什么它就会以最难调试的方式出现。顺序问题的另一面是retainedSlice()。直接把cumulation引用交给下游再释放自己引用计数就会在两个 owner 之间漂移。正确姿势是先retainedSlice()抬一次引用计数新快照归下游再release()原始缓冲归自己cumulation null之后handlerRemoved里的兜底释放就不会重复释放OverridepublicvoidhandlerRemoved(ChannelHandlerContextctx){// 探测完成前 handler 被移除比如连接在探测期就断了兜底释放if(cumulation!null){cumulation.release();cumulationnull;}}这个handlerRemoved不是仪式性代码。客户端连上来只发 1 个字节然后断开是完全合法的网络行为——此时 detector 已经攒了 1 字节在 cumulation 里连接关闭触发 handler 移除没有这段兜底就是一个实打实的内存泄漏。4.3 检测之后零开销三套管线怎么装配由AdaptiveServer上的三个包级方法承担detector 只负责选择voidaddJawsBinaryPipeline(ChannelPipelinepipeline){if(heartbeat0){pipeline.addLast(idle_state,newIdleStateHandler(heartbeat*3,heartbeat,0,TimeUnit.MILLISECONDS));pipeline.addLast(heartbeat,newHeartbeatHandler(codec));}pipeline.addLast(decoder,newNettyDecoder(this,codec,maxContentLength));pipeline.addLast(handler,newNettyChannelHandler(this,codec,messageHandler,serverExecutor,inflightRequests));}voidaddHttp2Pipeline(ChannelPipelinepipeline){pipeline.addLast(http2_codec,Http2FrameCodecBuilder.forServer().build());pipeline.addLast(http2_multiplex,newHttp2MultiplexHandler(newChannelInitializer(){OverrideprotectedvoidinitChannel(io.netty.channel.ChannelstreamChannel){streamChannel.pipeline().addLast(newHttp2StreamServerHandler(messageHandler,serverExecutor,serializationName,inflightRequests,maxContentLength));}}));}voidaddHttp1Pipeline(ChannelPipelinepipeline){pipeline.addLast(http_codec,newHttpServerCodec());pipeline.addLast(aggregator,newHttpObjectAggregator(maxContentLength));pipeline.addLast(http_handler,newHttpRequestHandler(messageHandler,serverExecutor,interfaceClasses));}注意 detector 是addLast进 pipeline 的而这三个方法也用addLast——但此时 pipeline 里只有 detector 一个所以协议 handler 实际就位后紧跟在探测点的位置。装配完成、detector 自删之后这条连接的 pipeline 和「服务端一开始就配置了该协议」完全等价。探测的全部成本是建连时多持有几微秒的缓冲、多一次 handler 增删。数据面零开销。这是这个设计和 Dubbo PU 最大的区别Dubbo 的探测是常驻的协议路由层每个连接的每一帧都要过JAWS 的探测是哨兵站完一班岗就撤。4.4 顺着管线往里看三种存活策略装配代码里还藏着一处容易一眼扫过、细想很有意思的差异三套管线对「连接怎么保持活着」给出了三种不同的答案。JAWS 二进制管线的开头两行是条件装配的IdleStateHandlerHeartbeatHandler——应用层心跳空闲超过 3 倍心跳周期没读到数据就断定连接死亡。HTTP/2 管线里则完全没有这两个 handler——HTTP/2 协议自带 PING 帧连接存活依赖 TCP keepalive 与 PING由 Netty 的Http2FrameCodec按 HTTP/2 语义管理不需要应用层再操心。HTTP/1.1 管线更干脆每个请求处理完就ChannelFutureListener.CLOSE关连接短连接模式根本不存在「保活」问题。二进制协议长连接 应用层心跳IdleStateHandler 驱动 HTTP/2 长连接 协议层 PINGHTTP/2 帧语义驱动 HTTP/1.1 短连接 请求即生命周期无需保活同一个端口、同一个AdaptiveServer实例三种协议各自带着自己的存活哲学共存。这其实是「首字节探测」方案的一个副产品因为探测发生在协议装配之前每套管线可以按自己协议的最优实践完整装配不需要为了共存而抹平差异。如果走的是「所有协议先说 HTTP/2」的语义路由路线二进制协议的心跳机制就只能被硬塞进 HTTP/2 的 PING 语义里别扭地模拟。4.5 客户端明确拒绝最后一个设计决定是刻意的非对称adaptive 只做服务端。OverrideprotectedClientinnerCreateClient(URLurl){thrownewUnsupportedOperationException(Adaptive transport is server-side only; set transportFactory to a concrete transport (e.g. netty, http2) for consumers);}「自适应」在客户端是个伪需求——调用方永远知道自己想说什么协议让客户端「猜」服务端协议只会把问题从配置文件转移到报错现场。服务端宽容来什么接什么、客户端明确选好了再来这个组合在实践中远比两端都自适应省心。五、不做什么gRPC 为什么不进探测列表写到这里可能有人会问JAWS 的 wire 传输能说标准 gRPC 线格式grpcurl 都打得通为什么不把 gRPC 也塞进 adaptive 的探测列表凑一个「四协议单端口」技术上其实可行。gRPC over HTTP/2 的请求头里有content-type: application/grpc完全可以在 HTTP/2 管线建立后按头部语义再做一次分流——字节层分不清它和 jaws 的 http2 传输共享同一个 H2 preface语义层可以。但我最终放弃了理由有三层第一依赖方向不可接受。adaptive 活在 jaws-core 的 transport 包里而 wire 是独立模块。让 core 里的探测逻辑感知 wire 的 gRPC 语义等于 core 要反向依赖 wire模块边界瞬间变丑。要解也可以——引入 SPI 让 wire 注册自己的探测规则——但为一个 opt-in 特性引入跨模块的探测扩展点复杂度和收益不成比例。第二协议语义不止一个 content-type。wire 的 gRPC 语义是一整套keepalive PING 策略、too_many_pingsGOAWAY、grpc-timeout头传播、健康检查服务。探测进来的 gRPC 连接如果只享受「能解析帧格式」的待遇而丢失这些行为就是一个半残的实现——看起来兼容实际语义错漏。要么不做要做就得把 wire 的全部协议行为搬进 adaptive 的管线那就是两条路里更差的那条。第三也是最重要的一层wire 的对外身份是协议不是传输。JAWS 讲 wire 的故事时说的是「我删掉了 gRPC 依赖自己实现了 gRPC 线格式」——wire 是一种协议grpcurl 可以直接调用这一定位是它作为独立模块存在的全部意义。如果把 wire 塞进 adaptive 的传输列表里当「第四种传输」等于亲手把 wire 从协议降格回传输层和它自己的叙事自相矛盾。所以 adaptive 的探测列表到三种为止。「不做什么」背后的依赖方向、语义完整性和定位纪律比「四种协议单端口」的数字好看得多。六、用户拿到了什么按系列惯例最后说说这个特性对使用者的实际意义。运维侧端口收敛。以前一个 provider 要暴露三种传输就要占三个端口防火墙规则、K8s Service、健康检查探针都要乘三。现在一个端口全收netstat上少两行不该是卖点但部署配置里少两个containerPort是真实的清爽。调试侧curl 成了万能客户端。HTTP/1.1 的 JSON 端点让任何环境都能直连服务——不需要 SDK、不需要写 consumer、不需要了解 JAWS 序列化。线上验证一个服务的部署是否成功一条 curl 就够出问题时/invoke的 JSON 响应里直接带error和processTime字段比翻 RPC 日志快得多。迁移侧消费端可以渐进换传输。服务端开 adaptive 之后存量 consumer 继续走二进制协议零改动新的非 Java 客户端走 HTTP/2 或 HTTP/1.1 接入中间不需要任何网关层做协议转换。协议选择的自由度从部署期选好端口定死后移到了每个客户端的连接时刻。教学侧三种管线的活体对照。这个私心不藏——同一个端口上三种协议的 Netty pipeline 装配代码只隔一个探测器的距离。读源码的人可以在AdaptiveServer的三个 builder 方法里直观对比二进制协议为什么只需要 decoder、HTTP/2 为什么要Http2MultiplexHandler按流初始化、HTTP/1.1 为什么要 aggregator 攒完整请求。这比散在三个包里自己找对照清晰得多。七、结语回头看这个特性最有意思的地方是它的克制。十天的提案期里两条「正统」路径——织进主干的常驻路由、先说 HTTP/2 的语义路由——都因为复杂度或约束被放弃最终落地的方案是一个 205 行的一次性哨兵不改变任何默认行为不引入任何跨模块依赖探测完就把自己删掉。还有一件事值得单独说这个特性从想法到落地只花了一个上午而它的速度密码在特性开始之前就写好了。AdaptiveServer的全部「服务器性」——事件循环、bind 与失败清理、业务线程池、inflight 计数、优雅关闭——都继承自 261 行的AbstractNettyServer。这个基类只留了一个抽象方法initChannel()装配 pipeline即协议唯一不同的部分和三个默认 no-op 的生命周期钩子四个传输的服务端共享同一套生命周期语义。于是AdaptiveServer的 140 行里只剩参数装配、三个 pipeline builder、和一行「装上探测器」。好抽象的回报不是优雅是复利下一个特性进来时你只需要写差异。特性行为最新颖的一个接入成本反而最低——这是框架抽象层质量最硬的证据。RPC 框架的很多问题都是这样加一个功能不难难的是加一个功能的同时不污染已有的边界。adaptive 的答案是「站在 pipeline 最前面看一眼然后消失」。JAWS 系列下一篇大概率写自适应负载均衡的落地——power of two choices 那篇文章欠了很久了。本文代码均来自 JAWS 框架实际源码commit 4436e50可直接对照阅读https://github.com/javahongxi/jaws
返回列表