从零手写一个简化版 Dubbo:一次 RPC 调用背后的完整链路

项目地址:https://gitee.com/sadboylushuowen/learn_dubbo

这段时间用AI做了一个 Java RPC 学习项目,目标、学习 Dubbo,而是按调用链一点点拆开:一次远程调用到底经历了什么,Dubbo 那些核心模块为什么要这么分层。

项目最后跑通了这样一条链路:

Consumer
    -> @DubboReference
    -> 动态代理
    -> RpcInvocation
    -> SPI 加载 LoadBalance
    -> ClusterInvoker
    -> RegistryDirectory
    -> RegistryServer
    -> SocketClient
    -> SocketServer
    -> LocalInvoker
    -> Provider
    -> RpcResult

换句话说,业务侧看起来只是调用一个本地接口:

demoService.sayHello("spring");

但底层已经经过了调用模型、序列化、网络通信、服务注册发现、动态代理、负载均衡、失败转移、SPI 扩展和注解集成。

这篇文章把整个过程复盘一遍,重点放在知识点和设计思路上。

1. RPC 最核心的问题

RPC 的目标很简单:

让 Consumer 像调用本地接口一样调用远程 Provider。

但真正实现时,会马上遇到几个问题:

调用哪个接口?
调用哪个方法?
参数是什么?
结果怎么返回?
对象怎么变成字节?
字节怎么通过网络传过去?
Consumer 怎么知道 Provider 地址?
多个 Provider 怎么选?
调用失败怎么办?
框架能力怎么扩展?
业务方怎么少写框架代码?

这个项目就是围绕这些问题一步一步展开。

2. 第一步:先把一次调用表达出来

最开始没有网络,也没有序列化,只做 RPC 调用模型。

一次远程调用至少要包含:

接口名
方法名
参数类型
参数值
请求 ID
附加参数

所以定义了 InvocationRpcInvocation

调用结果也要有统一表达:

返回值
异常
请求 ID
错误信息

所以定义了 ResultRpcResult

接着用 Invoker 表示“一个可执行的服务”:

Invocation -> Invoker -> Result

这里的关键点是:RPC 不应该一开始就关心网络。先把“调用”这件事在 Java 里抽象清楚,后面网络层只是负责把这个调用对象传过去。

3. 第二步:对象变字节,字节变对象

远程调用不能直接传 Java 对象,最终传输的一定是字节。

所以抽出 Serialization 接口:

byte[] serialize(Object object);
<T> T deserialize(byte[] bytes, Class<T> type);

第一版用 JDK 序列化实现,也就是 ObjectOutputStreamObjectInputStream

这一步跑通后,请求和响应就可以这样转换:

RpcInvocation -> byte[] -> RpcInvocation
RpcResult     -> byte[] -> RpcResult

这一阶段的重点不是 JDK 序列化有多好,而是明白:RPC 框架必须有统一的序列化抽象。以后换 JSON、Hessian、Kryo,本质上都是替换这个接口实现。

4. 第三步:Socket 网络通信和帧边界

有了字节之后,下一步才是网络。

第一版使用 JDK SocketServerSocket

Consumer SocketClient 发送请求字节
Provider SocketServer 接收请求字节
Provider 执行本地 Invoker
Provider 返回响应字节
Consumer 反序列化得到 Result

这里一个很重要的细节是 SocketCodec

TCP 是流协议,不天然知道一条消息从哪里开始、到哪里结束。如果直接读字节,可能出现半包、粘包问题。

所以用最简单的帧格式:

int length + byte[] body

先读 4 字节长度,再按长度读取完整 body。

这一步跑通后,最小远程调用链路就成立了:

Consumer -> Socket -> Provider -> Socket -> Consumer

5. 第四步:用 config 层封装过程

前三步跑通后,问题变成:业务方要写太多框架内部代码。

Provider 侧原来要手写:

LocalInvoker
RpcRequestHandler
SocketServer
server.start()

Consumer 侧原来要手写:

RpcInvocation
Serialization
SocketClient
deserialize Result

这些都不是业务代码。

所以新增 dubbo-config

ServiceConfig
    封装服务暴露

ReferenceConfig
    封装服务引用

Provider 只需要表达:

serviceConfig.setInterfaceClass(DemoService.class);
serviceConfig.setRef(new DefaultDemoService());
serviceConfig.export();

Consumer 只需要表达:

referenceConfig.setInterfaceClass(DemoService.class);

这一步的核心思想是:

对外暴露意图
对内封装过程

这也是 Dubbo config 层存在的原因。

6. 动态代理:真正像调用本地接口

有了 ReferenceConfig 之后,Consumer 仍然需要手动写方法名和参数类型。

这还不像真正的 RPC。

所以引入 JDK 动态代理,让 Consumer 写成:

DemoService demoService = referenceConfig.get();
demoService.sayHello("shawn");

代理对象拦截接口方法调用,然后内部转换成:

Method -> RpcInvocation -> 远程调用 -> Result -> 返回值

动态代理解决的是“使用体验”问题。

业务方看到的是本地接口,框架看到的是一次标准化的远程调用。

7. 第五步:注册中心

前面是直连调用,Consumer 需要知道 Provider 地址。

这不适合真实场景。

所以新增 dubbo-registry

Provider 启动时注册服务
Consumer 调用时按接口名查服务地址

第一版注册中心不是 ZooKeeper,也不是 Nacos,而是一个独立进程版的内存注册中心:

RegistryServer
    持有内存 Map

Provider
    register(interfaceName -> host:port)

Consumer
    lookup(interfaceName)

这里有个容易踩的点:不能只用普通 static Map

Provider 和 Consumer 是两个 JVM 进程,静态变量不共享。所以注册中心必须作为独立进程存在,Provider 和 Consumer 都通过 Socket 访问它。

这一步之后,Consumer 不再感知 Provider 地址。

它只知道:

我要调用 DemoService

地址由注册中心负责发现。

8. 第六步:多个 Provider 和负载均衡

真实 RPC 场景里,一个服务通常有多个 Provider:

DemoService
    -> 127.0.0.1:20880
    -> 127.0.0.1:20881

所以注册中心从单实例查询增强为:

lookupAll(interfaceName) -> List<ServiceInstance>

接着新增 dubbo-cluster

Directory
    负责拿到服务实例列表

LoadBalance
    负责从多个实例里选一个

ClusterInvoker
    负责串起 list -> select -> invoke

第一版负载均衡使用轮询:

provider-20880
provider-20881
provider-20880
provider-20881

这里开始进入 Dubbo 的服务治理思想。

注册发现解决的是:

有哪些 Provider?

负载均衡解决的是:

这次选哪个 Provider?

9. Failover:选中的 Provider 失败了怎么办

负载均衡只能解决“选谁”,不能解决“选中的挂了怎么办”。

所以继续增强 ClusterInvoker,实现最小 Failover:

调用当前 Provider
如果 Socket 异常,或者 Result.hasException()
从候选列表移除当前 Provider
重新选择下一个 Provider
如果成功就返回
如果全部失败再抛异常或返回失败 Result

这一步之后,调用链开始具备容错能力。

负载均衡和容错要分开理解:

LoadBalance:这次选谁
Failover:选的人失败了怎么办

Dubbo 的 cluster 能力,本质上就是围绕这两个问题继续扩展:路由、权重、失败转移、失败重试、失败广播、快速失败等。

10. SPI:让框架能力可插拔

前面实现了 RoundRobinLoadBalance,但如果代码里一直写:

new RoundRobinLoadBalance()

那框架能力还是写死的。

所以新增 dubbo-common 的 SPI 机制:

@SPI
@Extension
ExtensionLoader
META-INF/dubbo/{接口全名}

LoadBalance 被标记成扩展点:

@SPI("roundRobin")
public interface LoadBalance

RoundRobinLoadBalance 被标记成扩展实现:

@Extension("roundRobin")
public class RoundRobinLoadBalance implements LoadBalance

配置文件内容:

roundRobin=com.study.dubbo.cluster.loadbalance.RoundRobinLoadBalance

这样 ReferenceConfig 不再依赖具体实现类,而是通过扩展名加载:

ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension("roundRobin")

SPI 的价值是插件化。

以后新增随机负载均衡,不需要改主流程,只需要新增实现类和配置:

random=com.xxx.RandomLoadBalance

Dubbo 里很多核心能力都是类似思路:

Protocol
Registry
Serialization
LoadBalance
Cluster
Filter
ThreadPool

主链路只依赖接口,具体实现通过 SPI 插进去。

11. 最后一步:Spring 风格注解集成

框架内部能力都有了之后,最后要解决使用体验。

业务方不应该总是手写:

new ServiceConfig()
new ReferenceConfig()

所以新增 dubbo-spring

@DubboService
@DubboReference
DubboAnnotationBootstrap

Provider 实现类可以写:

@DubboService(interfaceClass = DemoService.class, port = 20880)
public class DefaultDemoService implements DemoService

Consumer 可以写:

@DubboReference
private DemoService demoService;

第一版没有直接引入真实 Spring,而是用 DubboAnnotationBootstrap 模拟 Spring Bean 生命周期:

扫描对象上的 @DubboService
创建 ServiceConfig 并 export

扫描字段上的 @DubboReference
创建 ReferenceConfig 并 get 代理
反射注入字段

最终业务侧变成:

Provider 声明服务
Consumer 声明引用
框架负责中间过程

这一步和前面的 dubbo-config 思想是一致的:

对外暴露意图
对内封装过程

12. 最终模块关系

整个项目最后的分层大概是:

dubbo-demo
    示例 API / Provider / Consumer

dubbo-spring
    注解入口,隐藏 ServiceConfig / ReferenceConfig

dubbo-config
    服务暴露和服务引用配置入口

dubbo-cluster
    Directory / LoadBalance / ClusterInvoker / Failover

dubbo-registry
    服务注册与服务发现

dubbo-remoting
    Socket 通信、编解码、请求处理

dubbo-serialization
    对象与字节转换

dubbo-rpc
    Invocation / Result / Invoker 等核心调用模型

dubbo-common
    SPI 扩展机制

依赖关系不是随便排的。

越底层越关注“基础能力”,越上层越关注“使用体验”。

13. 这个项目里最有价值的几个知识点

第一,RPC 的本质不是网络,而是调用模型。

网络只是传输手段。真正要先抽象的是:

Invocation
Result
Invoker

第二,TCP 传输必须处理帧边界。

int length + body 是最简单的协议格式,但足够说明问题。

第三,动态代理是 RPC 使用体验的关键。

没有代理,用户调用的是框架 API;有了代理,用户调用的是业务接口。

第四,注册中心解决的是地址发现,不是调用本身。

Provider 注册,Consumer 查询。注册中心只是让 Consumer 不再写死 Provider 地址。

第五,cluster 层解决的是服务治理。

多个 Provider 怎么选,失败后怎么办,都不应该放在 ReferenceConfig 里。

第六,SPI 解决的是扩展能力。

框架只依赖接口,具体实现通过扩展名加载。

第七,Spring 集成解决的是使用成本。

业务方只声明注解,框架把注解转换成服务暴露和服务引用。

14. 还可以继续补什么

这个项目已经能覆盖一个 RPC 框架的核心骨架,但离真实 Dubbo 还有很多工程化能力:

Netty 替代 JDK Socket
ZooKeeper / Nacos 注册中心
心跳和服务下线摘除
异步调用和 Future
请求超时控制
过滤器链 Filter
更多负载均衡策略
更多容错策略
真实 Spring BeanPostProcessor
@EnableDubbo 和包扫描
URL 配置模型
协议扩展
配置中心
监控和指标

但这些都属于在主链路之上的增强。

如果主链路没理解,直接看这些会很容易散。

15. 总结

这个项目最重要的收获不是“写了多少代码”,而是把 RPC 框架拆成了一条清晰的链:

本地接口调用
    -> 动态代理
    -> Invocation
    -> 序列化
    -> 网络传输
    -> Provider 执行
    -> Result
    -> 注册发现
    -> 负载均衡
    -> 容错
    -> SPI 扩展
    -> 注解集成

从这个角度再去看 Dubbo 源码,会轻松很多。

因为你看到的不再是一堆类名,而是一条调用链上的不同角色:

谁表达调用?
谁负责传输?
谁负责发现地址?
谁负责选择 Provider?
谁负责失败重试?
谁负责加载扩展?
谁负责把框架封装成注解?

理解了这些问题,再看真正的 Dubbo,只是把学习版里的每个点做得更完整、更健壮、更工程化。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐