Lazy loaded image
Dubbo-go丨讲讲Triple协议
字数 7803阅读时长≈ 20 分钟
2026-6-5
type
Post
status
Published
date
Jun 5, 2026
slug
dubbogo1
summary
tags
技术探索
category
icon
password

Triple 不只是"兼容 gRPC":从一条 HTTP 请求追到Dubbo-go Handler

主线:用一条 curl 请求串起 HTTP Path、Content-Type、Triple Runtime、生成代码与业务 Handler

前言:一个 RPC 服务,为什么 curl 也能直接调?

第一次看到 Dubbo-go 官方示例时,有一件事让我觉得很有意思。
服务端明明注册的是一个 RPC 服务:
但客户端甚至不需要先写一个 Dubbo-go Consumer。
直接:
就能得到:
这就是当前 Dubbo-go Quick Start 给出的最小调用方式。
问题也就来了:
如果只回答一句"因为用了 Triple 协议",其实什么都没解释。
我真正想搞清楚的是:
所以这篇我不准备讲 Triple 协议规范,我想先抓住这一条 curl,把它一路追到 Greet()。我觉得只要这条链能追通,Triple 很多概念自然就能解释了。

1.先把结论画出来:curl 最后并不是直接调用业务方法

先建立一个简化模型。
这里我要先强调一点:
这张图是理解模型,不是说当前源码里一定存在八个完全同名、完全线性的函数。
文章后面真正读源码时,要再去找:
哪一层注册服务? 哪一层识别请求? 哪一层解码? 哪一层调用 generated binding?
这篇的主线:
我觉得先画模型、再拿源码验证箭头,比一上来扎进 protocol/triple/目录舒服多了。至少我始终知道自己现在到底在找哪一步。

2.一切先从 .proto 开始:这个 HTTP Path 不是随便写的

官方 Quick Start 使用的服务定义大致是:
当前官方文档就是以这个 GreetService.Greet 为例介绍 Dubbo-go Triple。
然后 curl 使用的地址是:
拆开看:
也就是说:
本质上在表达:
Triple 协议规范同样把请求路径定义为:
并且 Path 是服务定位信息的一部分。
所以这不是普通 Web 项目里开发者随手写的:
至少在这个 Protobuf+Triple 的开发模型中,服务契约本身已经决定了服务名和方法名,后面的生成代码再把这种契约映射到运行时。
我最后把这部分记成一句话:这个 URL 看起来很 HTTP,但它表达的其实还是 RPC 的"服务+方法"。HTTPPath 只是把 RPC 寻址信息放进了一个大家都认识的载体里。

3.proto 为什么还不够?因为 Go 运行时不能直接执行 IDL

有了:
还不能直接运行。
Dubbo-go 当前 Quick Start 会使用两个代码生成插件:
最终产生:
其中 greet.pb.go 主要负责 Protobuf Message,例如 GreetRequest 、GreetResponse 及相关编码结构;而 greet.triple.go 则包含 Triple 服务注册、客户端代理与 RPC 调用相关的生成代码,例如RegisterGreetServiceHandler、NewGreetService。
这个边界非常重要。
我会把它画成:
换句话说,生成代码实际上承担了一座桥:
这也是这篇最值得讲透的地方之一。
如果完全跳过 greet.triple.go ,直接从 HTTP Runtime 跳到:
中间就少了最关键的一层。
所以我现在看 generated code,不会再把它当成"反正 protoc 自动生成,没必要看"。对于 RPC 框架来说,很多"协议怎么找到我的业务方法"的答案,恰恰就藏在这层胶水代码里。

4.generated binding 和业务代码,到底谁负责什么?

官方示例中,业务实现非常简单:
然后注册:
当前官方 Quick Start 就是通过生成的 RegisterGreetServiceHandler 把用户实现挂到 Server 上。
这里可以非常清楚地看到两类代码。
用户负责:业务逻辑
生成代码负责:RPC 契约和运行时之间的适配
所以更准确的模型应该是:
这就把一个很容易混掉的边界说明白了:
Triple Runtime 并不需要知道 GreetTripleServer 里面有什么业务逻辑、业务代码
也不需要知道 HTTP 请求到底怎么被解码
中间依靠 generated binding 把两边接起来。
我觉得这就是生成代码真正厉害的地方:它不是帮我少写几个 struct,而是把"协议世界"和"业务方法世界"隔开了。

5.Server 启动时,Triple 到底是怎么被选中的?

再回到服务端初始化。
当前官方示例:
然后:
这是当前 Dubbo-go 官方 Quick Start 和 gRPC 互操作示例都在使用的方式。
所以我们至少可以先建立这样一个生命周期:
这里同样不要把:NewServer 和:Serve 混成一个时间点。
前者主要在构造服务器和注入配置,后者才进入真正的服务运行阶段。
而:RegisterGreetServiceHandler则解决了另一个问题:
请求到了以后,这个 Service/Method 最终应该交给谁处理?
所以我自己会把启动阶段记成三件事:先告诉 Server"我要跑 Triple",再告诉它"我有哪些服务",最后Serve()真正把门打开。

6.现在真正开始追这条 curl

请求是:
这是当前官方 Quick Start 可以直接工作的 HTTP/1.1 调用形式。
连续问三个直接的问题:
1. 这是什么服务、什么方法? 2. Body 应该按什么格式解释? 3. 解出来以后该交给哪个 Handler?
这三个问题分别对应了:
Path Content-Type Generated Service Binding
于是整个请求解析的核心,其实已经露出来了。
复杂源码如果拆成这三个问题,我觉得一下子就没那么吓人了:先找谁,再搞清楚数据是什么,最后把数据交给谁。

7.第一关:Path 解决"调用谁"

请求:
最先携带的是服务定位信息。它是在说:
调用 RPC Service:greet.GreetService
调用 Method:Greet
Triple 的协议规范把 Path 作为 Call Specification 的一部分,并规定基本形态是: /Service-Name/Method-Name
因此,如果我故意改成: /greet.GreetService/NotExist
那么请求甚至还没有资格进入:
Greet(...)
因为服务方法定位本身就已经失败。
这也是为什么后面的故障实验里,我一定会故意打一个错误 Path。
我自己的理解就是:RPC 的"方法分派"没有消失,它只是被编码到了 HTTP Path 里。看着像 URL,实际上干的还是 RPC 寻址。

8.第二关:Content-Type 解决"Body 应该怎么看"

再看:Content-Type:application/json
为什么这行这么重要?
因为网络传过来的 Body 最终只是一串字节。
例如:7b 22 6e 61 6d 65 ...
Runtime 必须知道:
我应该拿 JSON decoder 解? 还是按 Protobuf binary 解? 还是按 gRPC message framing 处理?
Triple 的 HTTP RPC 子协议支持 application/json 和 application/proto;当前官方 Quick Start 则直接使用 application/json 调用 Unary 服务。
所以这里的关键关系是:
这里的图同样是在表达协议层的判断逻辑,不应该拿它当成具体源码里一定存在一个三分支 switch。
我觉得 Content—Type 在这里根本不是一个"顺手带上的 HTTP Header",它其实在告诉协议栈:同样这堆字节,你应该用哪套语言去读。

9.最神奇的一步:JSON 是怎么变成*GreetRequest的?

现在我们终于走到我最开始的问题。 curl 发的是:
业务方法收到的是:
也就是:
中间这一段,本质就是协议运行时根据服务描述和 generated binding 已经掌握的请求类型信息完成解码,再把一个强类型 Request 交给服务 Handler。
因为 .proto 已经声明:
而 protoc-gen-go 又已经生成:
所以 Runtime 并不是拿到 JSON 以后随便猜一个 Go struct。
这份"该解成什么类型"的知识,从服务定义一路进入了生成代码和运行时注册关系。当前官方文档也明确区分了 greet.pb.go 的 Message 职责与 greet.triple.go 的服务绑定职责。
可以把这一步画得更清楚:
所以这里让我觉得 RPC 框架有意思的地方是:HTTP 层看到的是字节,业务层看到的是类型,中间那条"字节 → 类型"的桥就是协议运行时和生成代码一起搭起来的。

10.为什么业务方法完全看不到 HTTP?

进入:
以后,你会发现业务代码里甚至没有:
业务代码只关心:
这其实特别能说明 RPC 抽象的价值。
底下进来的可能是:
也可能走兼容 gRPC 的协议形式。
但是当请求真正落到业务层:
业务实现不应该为每种网络入口写一遍逻辑。
官方 Triple 文档也把目标之一描述为:不同访问形式最终可以进入同一套业务逻辑。
我觉得这里才是真正把"HTTP"和"RPC"区别开了:HTTP 是请求怎么进来的,RPC Service 才是业务在调用什么。传输入口可以变,业务方法没必要跟着一起变。

11.返回的时候,再把这条链倒着走一次

业务方法返回:
之后当然不能直接把 Go 对象扔到 TCP 连接里。
还要反过来:
在当前 Quick Start 的 JSON 调用中,最后客户端看到:
官方文档同样给出了这个返回结果。
所以完整闭环应该是:
这张图基本就是我认为这篇文章最重要的一张图。
一句话总结就是:进来的时候是"字节变对象",回去的时候是"对象再变字节"。业务代码正好被夹在中间最干净的那一层。

12.Triple 到底是不是gRPC?

我看到过一句错的话:
这句话太粗糙了。
当前 Apache Dubbo 协议文档把 Triple 描述为基于 HTTP 的 RPC 协议:它兼容 gRPC,同时还设计了更 HTTP-friendly 的 Unary RPC 形式,可以运行在 HTTP/1 和 HTTP/2 上。
更容易理解的画法是:
Apache Dubbo 当前协议规范把这两部分区分得很明确:
-精简 HTTP RPC 子协议可以基于 HTTP/1 或 HTTP/2,主要解决 Unary 请求; —gRPC—compatible 部分基于 HTTP/2,并覆盖 Unary 与 Streaming。
所以:Triple 把 gRPC 兼容性放进了自己的协议设计里,但它的目标范围比"实现一个 gRPC Server"更宽。

13.为什么 curl 能调,其实就是 HTTP-friendly 这半边在发挥作用

回头再看这条:
它根本没有:
也没有要求调用者必须先建立一套Dubbo-go Consumer API。
一个普通 HTTP 工具就能完成 Unary 调用。
当前 Triple 规范明确支持基于 HTTP/1 的 Unary 请求以及 application/json/application/proto 形式;官方 Dubbo-go Quick Start 就把 HTTP/1.1+JSON+curl 当成最简单的服务访问方式。
从调试体验看,这个意义很直接。
如果线上某个服务有问题,我至少可以先用 curl 确认:
端口通不通? Path 对不对? JSON 能不能解? 服务是否存在? 业务实现有没有返回?
而不用一开始就准备完整 RPC Client。
对我来说,"HTTP-friendly"最直观的价值是:这个 RPC 服务真出问题的时候,我能拿 curl 直接敲它。

14.那"兼容 gRPC"又具体体现在哪里?

这一点最好不要只说概念。
Apache Dubbo 当前专门提供了 Dubbo-go 与标准 gRPC 的互操作示例。
同一个通过:protocol.WithTriple() 启动的 Dubbo-go Server,可以被标准 grpc-go Client 调用;官方示例反过来也展示了 Dubbo-go Client 调标准 gRPC Server。
官方实验最终验证的是双向互操作:
于是我们就能把前面的"HTTP-friendly"和"gRPC-compatible"放在同一张图上:
这张图我觉得比一句:Triple 100%兼容 gRPC 更有解释力。
说白了就是:前门不止一个,但后厨还是同一个。HTTP 客户端和 gRPC 客户端可以走不同协议形态进入Dubbo-go Triple Server,最后目标都是那份服务实现。

15.Unary 和 Streaming 为什么一定要分开讲?

当前 Triple 规范中,HTTP/1 场景只支持 Unary;Streaming 则要求 HTTP/2,并按照与 gRPC 兼容的协议方式处理。
也就是说:
Unary:
Request ↓ Response
非常适合我们这篇用 curl 跟踪。
而 Streaming:
还会引入:
复杂度一下就上来了。
当前 Dubbo-go 官方 Streaming 文档也同时提供了 Unary、客户端流、服务端流以及双向流形式。
所以正文我宁愿先彻底讲透:
最后再告诉读者:
我觉得源码文章很怕一开始就贪大。Unary 这条链都没走明白就研究 Streaming,最后大概率只会记住一堆frame 名字。

16.generated client 又处在什么位置?

前面一直站在 Server 侧。
其实 greet.triple.go 不只有 Server binding。
当前官方 Quick Start 明确说明,生成文件中还包括:NewGreetService 这样的客户端代理入口。
于是 Dubbo-go 客户端可以写成类似:
官方 Quick Start 与 gRPC interoperability 示例都使用了这种 generated client 方式。
站在客户端看,逻辑刚好反过来:
于是生成代码其实同时把两端都"类型化"了:
这时候我才真正理解*.triple.go 为什么重要:它是一份把 RPC Service 契约真正落到 Go 调用模型上的 binding。

17.所以 generated code 和 runtime 的边界到底在哪?

这一节是我认为这篇最能体现源码理解深度的地方。
简单说:
例如 generated binding 更接近:
而协议 Runtime 更接近:
这两部分结合以后,才有:
这也是为什么协议框架不可能只靠 generated code,也不可能只靠一个通用 HTTP Server。
只有 Runtime:
只有 generated code:
知道 UserService.Greet, 但自己没必要重写一整套网络协议栈。
我觉得把这条边界看懂以后,再进 protocol/triple/目录就舒服多了:我要看的不再是"这里好多 HTTP 代码",而是 Runtime 到底在哪一步把控制权交给 generated binding。

18.真正读源码,我建议不要从最底层网络代码开始

源码范围是:
如果让我实际读,我会换成这样的顺序。
先看官方最小 Example:
然后看:
先搞清:
服务怎么注册 Method 怎么描述 Handler 怎么连接用户实现 Client proxy 怎么生成
之后再进入:
去找:
最后才根据需要继续往:
钻。
我会把源码阅读顺序写成:
我现在越来越觉得,源码阅读顺序不应该等于调用栈最底层到最上层。先从自己能观察到的 API 往里追,通常更容易建立心智模型。

19.这篇最应该做的四个故障实验

正常 curl 成功其实只能证明:
真正能暴露协议边界的,是把请求故意弄坏。
研究报告给这篇安排了四个实验:
并要求同时记录:
我很建议保留这个设计。
因为这四次破坏刚好攻击四个不同位置。
这四个实验我觉得特别值钱,因为每弄坏一次,其实就是在问:请求现在到底已经走到协议栈哪一层了?

20.实验一:把 Method Path 写错

正常:
改成:
然后记录:
Triple 协议规范中,服务不存在类问题具有对应的 HTTP/RPC 错误映射,但实际文章最好以你所绑定 commit的运行结果为准,而不是提前写死某个版本一定返回什么。
这一轮真正要证明的是:
请求连业务 Handler 都还没找到。
所以如果 Greet()里埋了日志:
理论上这一轮应该重点观察它是否出现。
这个实验是在验证:Path 是 RPC dispatch 的一部分,不是一个无关紧要的 URL 字符串。

21.实验二:把 Content-Type 写错

例如:
记录:
Triple HTTP RPC 规范定义了支持的内容编码,并明确存在 Unsupported Media Type 的处理语义。
这一轮真正验证的是:
也就是说:"找到方法" 和:"能解请求" 根本不是同一个阶段。
我觉得这种实验比直接看一段 Content-Type 判断源码清楚得多:改一个 Header,请求直接卡在 decode 之前,模块边界一下就出来了。

22.实验三:Content-Type 正确,但 JSON 故意写坏

例如:
这次:
Path 是对的 Content-Type 也是对的
但 Body 不是合法 JSON。
仍然记录:
协议规范包含请求格式、序列化/反序列化相关错误语义;具体运行结果仍建议在文章绑定的源码版本上实际测量。
这一轮真正想验证:
只有 decode 真正完成:
业务 Handler 才应该拿到合法参数。

23.实验四:让请求已经进 Handler,再故意返回

error

最后一轮才动业务代码。
例如测试时让:
然后:
这一次和前面三次最大的区别是:
业务执行 ×
所以我们要观察的是:
Triple 协议规范本身定义了 HTTP 状态与 RPC 错误之间的映射,但博客最终仍应该记录你实际版本的 status、body 和日志。
前面三个实验都在问"为什么还没进业务",这个实验才是在问"业务已经失败了,协议层怎么把失败送回去"。四个放在一起,整个错误边界就特别清楚。

24.再做一个互操作实验,比单纯写"兼容 gRPC"有说服力得多

研究报告建议最后再用 gRPC 入口调用同一个服务,比较:
同一份服务契约 不同客户端入口
我觉得这一步不能删。
官方 Dubbo-go 当前也提供完整互操作样例,标准 grpc-go Client 可以连接通过 protocol.WithTriple()创建的 Dubbo-go Server。
实验可以分成:
然后只在业务方法里加一条日志:
如果两个入口最后都到同一份:
那"协议入口"和"业务实现"之间的分层就不是我们画图猜出来的,而是跑出来的。
我觉得与其在文章里写十遍"Triple兼容 gRPC",不如真的拿标准 gRPC Client 调一次。能互通,这四个字一下就有了证据。

25.如果我线上遇到 Triple 调不通,现在会怎么定位?

有了前面的链以后,排障顺序其实很自然。
这个排障图其实就是全文调用图的反向应用。
比如:
404 /service not found
第一反应应该偏向:
Path/Service/Method registration
如果: invalid request body
则应该优先检查:
Content-Type Body Request 类型
如果业务日志已经明确打印:
ENTER Greet
那至少说明:
传输 协议识别 服务定位 request decoding handler dispatch
已经走过了。
源码真正读明白以后,最大的变化就是排错不会再从整个 protocol/triple 目录乱搜。我先判断请求死在哪一层,再往那一层钻。

26.回头看,为什么 Triple 特别适合 Dubbo 这种微服务框架?

现在我们已经完全不靠一句:
Triple=新 RPC 协议
来理解它了。
从刚才这条请求链,可以看到 Triple 做的是:
另一方面,它又可以继续接进 Dubbo 自己的:
当前官方文档也强调,Triple 服务可以继续融入 Dubbo 的服务发现与路由等框架能力,而不是一套完全游离在治理体系之外的 HTTP Server。
于是前几篇文章其实在这里全部接上了。
前几篇解决:
调用哪台 Provider?
这一篇终于解决:
到了那台 Provider 以后, 网络上的请求又怎么进入真正的业务方法?
我觉得把这几篇连起来以后,Dubbo-go 才真正从"注册中心+负载均衡的一堆功能"变成了一套完整 RPC Runtime。

27.我最后给自己留下的源码阅读地图

如果以后再回来看 Triple,我会从这几个区域进入:
研究报告建议重点阅读:
并且明确建议只用 Unary JSON/PB 做正文主线,把 Streaming 当作后续分叉。
我觉得这个取舍非常重要。
看 Triple 最容易迷路的原因不是代码特别玄学,而是一下同时面对 HTTP、HTTP/2、gRPC、Protobuf、生成代码和 RPC Runtime。只追一条 Unary 请求,反而最容易把这些东西放回正确位置。

29.最后再回答开头的问题:一条 curl 到底经历了什

么?
最开始我们只有:
现在可以把它完整展开:
① curl 建立 HTTP 请求 ↓ (2) Path 指向 greet.GreetService/Greet ↓ (3) Content-Type 告诉协议运行时 Body 按 JSON 处理 ↓ (4) Triple Runtime 识别服务和方法 ↓ ⑤ 请求 Body 被转换成 *greet.GreetRequest ↓ (6) generated Triple binding 把请求交给注册的 Handler ↓ (7) GreetTripleServer.Greet( ctx, req, ) ↓ (8) 返回*greet.GreetResponse ↓ (9) Triple Runtime 编码响应 ↓
再把它压缩成一句:
HTTP 请求 → RPC 寻址 → 协议解码 → 类型化请求 -Generated Binding → 业务 Handler → 类型化响应 → 协议编码 → HTTP Response
当前 Dubbo-go 官方 Quick Start、Triple 协议规范以及 gRPC interoperability 示例,分别给这条链提供了最小服务注册、HTTP JSON 调用、协议语义与 gRPC 互操作的验证入口。

总结

Triple 最值得理解的地方是它把HTTP 世界里的请求,经过协议 Runtime 和 generated binding,稳定地还原成了一次普通的强类型 Go 方法调用。
 
回到首页
目录