目标函数调用
当 Dubbo 的解码器精准地切出一个完整的业务数据包(Body)后,接下来就要进入反序列化与反射调用的阶段了。
我们可以把这个过程分为两部分来看:首先看客户端到底传了哪些信息过来(也就是 Body 里装了啥),其次看服务端拿到这些信息后是如何一步步找到并执行目标函数的。
# 一、 客户端传入的信息有哪些?(Body 的秘密)
虽然你在写代码时只是调用了一个普通的接口方法(例如 userService.getUserById(123)),但 Dubbo 的客户端(Consumer)在把请求发出去之前,会把这次调用拆解成非常详细的元数据,统统打包进 Body 里。
在 Dubbo 协议中,请求体(Body)反序列化后,主要包含以下核心信息:
- Dubbo 版本号 (Dubbo Version): 例如
2.7.x,让服务端知道客户端的框架版本,以便做兼容处理。 - 服务接口全限定名 (Service Name): 例如
com.example.UserService。这是定位服务的第一步。 - 服务版本号 (Service Version): 例如
1.0.0。Dubbo 支持同一个接口有多个版本,必须明确指定。 - 方法名 (Method Name): 字符串格式,例如
"getUserById"。 - 参数类型列表 (Parameter Types): 这是一个字符串数组或类名列表,例如
["java.lang.Long"]。这非常关键! 因为 Java 支持方法重载(方法名相同但参数不同),单靠方法名是不够的,必须加上参数类型才能唯一确定一个方法。 - 实际参数值 (Arguments): 具体的入参对象,例如
[123]。 - 隐式传参/上下文 (Attachments): 这是一个 Map(键值对)。用来传递一些不适合放在方法参数里的附加信息(扩展Map),比如:
- 链路追踪的 TraceID(用于分布式日志连通)。
- 路由标签(Tag),用于灰度发布。
- 接口的超时时间(Timeout)等。
# 二、 服务端收到后,如何调用目标函数?
服务端解码器(Netty 接收端)拿到了上述 7 样武器后,会经过一个标准的“流水线”来完成最终的函数调用。整个过程可以分为 4 个核心步骤:
# 步骤 1:线程分流(派发策略 Dispatcher)
Netty 的 IO 线程(Boss/Worker 线程)是非常宝贵的,只负责收发数据,绝对不能在网络线程里跑耗时的业务逻辑。
- Netty 收到完整的 Body 字节流后,通常会立刻把这些数据封装成一个任务,扔进 Dubbo 专门的业务线程池(ExecutorService)中。
- 接下来的反序列化和调用,都是在这个独立的业务线程池里执行的,从而保证了网络通信的高吞吐。
# 步骤 2:反序列化 (Deserialization)
业务线程拿到字节流后,根据前面 16 字节 Header 里指定的序列化类型(如 Hessian2),将字节流还原为上述的 Java 对象和字符串(接口名、方法名、参数值等)。
# 步骤 3:寻找服务实现类(Invoker 路由)
服务端在启动时,会把自己本地真正的业务实现类(比如标了 @DubboService 的 UserServiceImpl)注册到一个内部的容器中(通常是一个大 Map)。
服务端以
接口名:版本号:分组作为 Key,去这个 Map 里查找,就能精准找到对应的代理对象Invoker。三合一匹配条件,缺一不可:
接口全类名 interfaceName
服务分组 group + 版本 version(存在 attachments 里)
方法名 methodName + 参数类型数组 parameterTypes
# 步骤 4:反射调用目标方法(Reflection / Javassist)
找到了实现类,也知道了方法名和参数类型,接下来就是执行代码了。
如果你自己写,可能会想到用 Java 原生的反射:
// 伪代码概念
Method method = serviceClass.getMethod(methodName, parameterTypes);
Object result = method.invoke(targetInstance, arguments);
但在追求极致性能的 RPC 框架中,原生的 Method.invoke() 反射性能是比较慢的。Dubbo 为了更快,默认采用了 Javassist(动态字节码生成) 技术:
- Dubbo 在动态编译时,会为每个服务实现类生成一个包装类(Wrapper)。
- 这个包装类里有一个动态生成的
invokeMethod方法,里面全是一堆if-else。 - 当调用传过来时,它实际上是直接运行编译好的 Java 代码,例如
if("getUserById".equals(m)) { return ((UserServiceImpl)target).getUserById((Long)args[0]); }。 - 这种做法把“反射调用”直接变成了“本地直接调用”,速度极快。
为什么原生反射性能差?
Method.invoke()底层痛点:
- 每次调用要做权限校验、参数装箱 / 拆箱、安全检查;
- JIT 很难对反射调用做深度优化,循环调用损耗巨大;
- 重载方法需要循环匹配参数类型数组,多一层判断开销。
可以把创建对象想象成进入一栋大楼:
- 反射:你每次都要去前台(
Class对象),出示工牌(访问权限检查),询问会议室位置(方法查找),再签字登记(AccessibleObject.setAccessible())。前台每次都会认真核实你的权限,还要确认你这个人有没有进错楼层。这个过程安全,但每次都有手续开销。- Javassist:它自己就是大楼的建筑师,在开工前(编译期或类加载时)就给你发了一张门禁卡,卡里写好了你的权限和路线。当你需要进入时,直接刷卡走专用通道,和普通员工一样,没有额外的人工验证环节。
Dubbo 通过 Javassist 动态生成 UserServiceImpl$Wrapper 包装类,硬编码所有方法,不需要运行时根据字符串找 Method:
// Javassist动态生成的Wrapper简化源码
public class UserServiceImpl$Wrapper implements Wrapper {
// 缓存所有方法元数据(启动一次性反射获取)
public static final Method m0; // getUserById(Long)
static {
m0 = UserServiceImpl.class.getMethod("getUserById", Long.class);
}
// 核心方法:硬编码分发,无循环匹配、无Method.invoke
@Override
public Object invokeMethod(Object impl, String methodName, Class<?>[] types, Object[] args) throws Throwable {
UserServiceImpl target = (UserServiceImpl) impl;
// 硬编码if判断,直接调用原生方法,纯Java普通调用
if ("getUserById".equals(methodName) && types.length == 1 && types[0] == Long.class) {
return target.getUserById((Long) args[0]);
}
// 其他方法硬编码分支...
throw new NoSuchMethodException();
}
}
服务启动、暴露 @DubboService 实现类 UserServiceImpl 时:
- Dubbo 使用 Javassist 读取实现类全部方法(方法名、参数类型、重载区分);
- 动态生成字节码
UserServiceImpl$Wrapper; - Wrapper 内部把当前类所有方法写死硬编码分支,缓存到全局单例,后续所有 RPC 复用,不再重复生成。
运行时 RPC 调用,直接走硬编码普通方法调用,完全避开 Method.invoke 反射。
# 三、 如何返回
- 目标函数执行完毕,拿到了返回值(比如一个
User对象,或者抛出了一个Exception)。 - 服务端把这个返回值、以及最初请求里带过来的 Invoke ID(请求唯一标识) 一起,重新打包成一个响应报文。
- 同样加上 16 字节的 Dubbo 响应头,写好响应体的
Data Length。 - 通过 Netty 发回给客户端。
- 客户端根据 Invoke ID 找到当时正在死等(或者异步回调)的那个线程,把结果塞给它,你的客户端代码就成功拿到了远程返回的对象。
这就是一次完整的 RPC 调用从“拆包成功”到“执行完毕”的生命周期。