RPC 如何从代码变成框架?
前言
经过前几篇文章,我们已经让一次远程调用跑了起来:
动态代理
↓
封装请求
↓
序列化
↓
协议编码
↓
Netty 发送
↓
服务端解码
↓
反射执行
↓
返回响应
↓
requestId 匹配 Future
代码可能集中在一个类中:
public Object invoke(
Class<?> serviceInterface,
Method method,
Object[] arguments
) throws Exception {
Object localService =
services.get(serviceInterface.getName());
if (localService != null) {
return method.invoke(
localService,
arguments
);
}
RpcRequest request =
buildRequest(
serviceInterface,
method,
arguments
);
byte[] body = serialize(request);
RpcHeader header =
buildHeader(body.length);
Channel channel =
getChannel(providerAddress);
CompletableFuture<RpcResponse> future =
registerFuture(header.requestId());
channel.writeAndFlush(
encode(header, body)
);
RpcResponse response =
future.get(
3,
TimeUnit.SECONDS
);
return response.result();
}
它确实能工作。
但所有逻辑都挤在一起:
本地服务判断
动态代理
请求构造
协议头生成
序列化
服务发现
连接获取
负载均衡
网络发送
Future 注册
超时等待
异常转换
于是新的问题出现了:
如果序列化从 Java 原生序列化换成 JSON,需要改多少地方?
如果自定义协议换成 HTTP,代理层是否也要重写?
本地调用和远程调用应该在什么位置分支?
Dispatcher 是服务注册表、调用路由器,还是业务执行器?
服务节点负载均衡和连接池负载均衡是一回事吗?
为什么请求 ID 不应该由代理层生成协议头?
一个方法返回普通对象和返回 CompletableFuture,调用链应该怎样兼容?
本地调用也走动态代理,是否意味着必须使用反射?
HTTP 是无状态协议,为什么还可以复用连接?
加了 requestId,协议就变成有状态了吗?
把代码拆成十几个类,就算完成分层了吗?
这些问题已经不再属于“如何写出 RPC”。
它们属于另一个阶段:
怎样把一个能运行的 RPC Demo,重构成职责清楚、组件可替换、依赖方向稳定的框架?
一、能跑还不够
一个 Demo 追求的是:
先把调用链跑通
一个框架追求的是:
变化发生时
尽量只修改对应层
假设所有逻辑都在一个类里。
今天加入:
服务版本
明天加入:
注册中心
后天加入:
重试
熔断
链路追踪
认证
压缩
最终很容易变成:
if (local) {
...
} else if (http) {
...
} else if (netty) {
...
}
if (retryEnabled) {
...
}
if (compressionEnabled) {
...
}
if (asyncReturnType) {
...
}
这类代码的问题不是行数多,而是:
一次修改
会同时影响多个职责
例如换一种协议,理论上只应该影响:
协议编解码
却可能同时修改:
动态代理
连接池
服务路由
回调管理
服务端分发
这说明代码虽然被拆成了多个方法,却没有形成真正的边界。
二、分层是什么?
分层不等于:
一个大类
拆成十个小类
真正的分层至少需要满足三个条件。
1. 职责明确
每层只回答一类问题。
例如:
代理层:
用户调用了什么方法?
路由层:
应该本地执行还是远程执行?
发现层:
哪些服务节点可以调用?
负载层:
本次选择哪个节点?
传输层:
如何把消息送到目标节点?
协议层:
字节应该怎样组织?
调用层:
服务端如何执行目标方法?
2. 依赖单向
上层可以依赖下层抽象:
代理层
↓
调用层
↓
传输层
但传输层不应该反过来知道:
当前调用的是 UserService
协议编码器也不应该知道:
服务应该去注册中心查询
3. 组件可替换
理想情况下:
NettyTransport
可以换成:
HttpTransport
而代理层无需重写。
同样:
JavaSerializer
可以换成:
JsonSerializer
而服务发现和负载均衡无需变化。
三、完整架构
重构后的消费者链路可以设计为:
业务代码
↓
服务代理
↓
调用路由
├── 本地调用器
│ ↓
│ 本地服务注册表
│
└── 远程调用器
↓
服务发现
↓
节点负载均衡
↓
连接管理
↓
传输层
↓
协议编解码
↓
Netty
服务端链路:
Netty
↓
协议解码
↓
RpcRequest
↓
请求分发
↓
本地服务注册表
↓
方法调用器
↓
RpcResponse
↓
协议编码
↓
Netty
展开以后:
Consumer
┌─────────────────────────────────────────────┐
│ UserService Proxy │
│ ↓ │
│ RpcInvocation │
│ ↓ │
│ InvocationRouter │
│ ┌─────┴────────┐ │
│ ↓ ↓ │
│ LocalInvoker RemoteInvoker │
│ ↓ ↓ │
│ ServiceRegistry ServiceDiscovery │
│ ↓ │
│ LoadBalancer │
│ ↓ │
│ ChannelManager │
│ ↓ │
│ RpcTransport │
└─────────────────────────────────────────────┘
↓
TCP
↓
Provider
┌─────────────────────────────────────────────┐
│ FrameDecoder │
│ ↓ │
│ MessageDecoder │
│ ↓ │
│ RequestDispatcher │
│ ↓ │
│ ServiceRegistry │
│ ↓ │
│ MethodInvoker │
│ ↓ │
│ MessageEncoder │
└─────────────────────────────────────────────┘
四、调用才是核心
很多初版 RPC 会把核心对象设计成:
class MyContent {
String serviceName;
String methodName;
Class<?>[] parameterTypes;
Object[] arguments;
}
这个方向是正确的。
但它不应该直接与某种网络协议绑定。
更适合的名称是:
public record RpcInvocation(
ServiceKey serviceKey,
String methodName,
String[] parameterTypeNames,
Object[] arguments,
Map<String, String> attachments
) {
}
服务标识:
public record ServiceKey(
String interfaceName,
String group,
String version
) {
}
调用对象表达的是:
要调用什么服务
要调用什么方法
参数是什么
附加上下文是什么
它不关心:
是否使用 TCP
是否使用 HTTP
协议头有多少字节
使用哪个 Netty Channel
这正是分层后的第一个稳定核心。
五、代理只做翻译
代理层的职责是:
把 Java 方法调用翻译成 RpcInvocation。
JDK 动态代理会把代理对象上的方法调用分派到对应的 InvocationHandler.invoke(),并传入 Method 和参数数组,这正好适合作为 RPC 调用入口。
代理工厂:
public final class RpcProxyFactory {
private final InvocationRouter router;
private final Duration timeout;
public RpcProxyFactory(
InvocationRouter router,
Duration timeout
) {
this.router = router;
this.timeout = timeout;
}
public <T> T create(
Class<T> serviceInterface,
String group,
String version
) {
Object proxy = Proxy.newProxyInstance(
serviceInterface.getClassLoader(),
new Class<?>[]{serviceInterface},
(proxyObject, method, arguments) -> {
if (method.getDeclaringClass()
== Object.class) {
return invokeObjectMethod(
proxyObject,
method,
arguments
);
}
RpcInvocation invocation =
createInvocation(
serviceInterface,
group,
version,
method,
arguments
);
CompletionStage<Object> result =
router.invoke(invocation);
if (CompletionStage.class
.isAssignableFrom(
method.getReturnType()
)) {
return result;
}
return result.toCompletableFuture()
.get(
timeout.toMillis(),
TimeUnit.MILLISECONDS
);
}
);
return serviceInterface.cast(proxy);
}
}
代理层不应该负责:
拼协议头
创建 Socket
挑选 Channel
序列化请求
操作 PendingMap
否则一旦协议或传输方式改变,代理也必须改变。
代理应该只知道:
调用信息
+
调用入口
六、谁决定本地还是远程?
资料中使用 Dispatcher 判断服务是否已经注册在当前 JVM:
存在本地服务
↓
本地调用
不存在本地服务
↓
远程调用
这个思路有价值,但不建议把所有职责都放入一个名为 Dispatcher 的类。
可以拆成:
InvocationRouter
决定本地还是远程
LocalServiceRegistry
保存本地服务
LocalInvoker
执行本地方法
RemoteInvoker
发起远程请求
接口:
public interface RpcInvoker {
CompletionStage<Object> invoke(
RpcInvocation invocation
);
}
路由器:
public final class InvocationRouter
implements RpcInvoker {
private final LocalServiceRegistry registry;
private final RpcInvoker localInvoker;
private final RpcInvoker remoteInvoker;
public InvocationRouter(
LocalServiceRegistry registry,
RpcInvoker localInvoker,
RpcInvoker remoteInvoker
) {
this.registry = registry;
this.localInvoker = localInvoker;
this.remoteInvoker = remoteInvoker;
}
@Override
public CompletionStage<Object> invoke(
RpcInvocation invocation
) {
if (registry.contains(
invocation.serviceKey()
)) {
return localInvoker.invoke(invocation);
}
return remoteInvoker.invoke(invocation);
}
}
这样,路由器只回答:
走哪条调用路径?
而不亲自完成所有工作。
七、本地也走代理吗?
本地服务已经存在:
UserService implementation =
new UserServiceImpl();
最直接的方式是:
implementation.findById(1001L);
为什么还要经过代理?
因为代理层可以统一加入:
监控
日志
链路追踪
权限校验
超时策略
调用统计
上下文传播
于是调用方始终面向:
UserService proxy;
至于代理后面走:
本地调用
还是:
远程调用
由路由器决定。
但要注意:
统一调用入口,不等于本地调用必须模拟一遍网络流程。
本地调用不应该执行:
序列化
协议编码
Netty 发送
协议解码
它可以直接查找服务对象并执行方法:
public final class LocalInvoker
implements RpcInvoker {
private final LocalServiceRegistry registry;
private final MethodResolver methodResolver;
@Override
public CompletionStage<Object> invoke(
RpcInvocation invocation
) {
try {
Object service =
registry.get(
invocation.serviceKey()
);
Method method =
methodResolver.resolve(
service.getClass(),
invocation.methodName(),
invocation.parameterTypeNames()
);
Object result =
method.invoke(
service,
invocation.arguments()
);
return CompletableFuture.completedFuture(
result
);
} catch (InvocationTargetException exception) {
return CompletableFuture.failedFuture(
exception.getTargetException()
);
} catch (Throwable throwable) {
return CompletableFuture.failedFuture(
throwable
);
}
}
}
Method.invoke() 会调用底层目标方法,并返回执行结果;底层方法抛出的异常会通过 InvocationTargetException 暴露。
反射是否成为性能瓶颈,应该通过真实压测判断。
不要仅因为使用反射,就提前把本地调用设计得极其复杂。
八、注册表不是注册中心
这两个概念很容易混淆。
1. 本地服务注册表
保存当前 JVM 中已经存在的服务实例:
ServiceKey
↓
Service Object
例如:
public final class LocalServiceRegistry {
private final ConcurrentMap<
ServiceKey,
Object
> services = new ConcurrentHashMap<>();
public void register(
ServiceKey key,
Object service
) {
Object previous =
services.putIfAbsent(
key,
service
);
if (previous != null) {
throw new IllegalStateException(
"Service already registered: "
+ key
);
}
}
public boolean contains(ServiceKey key) {
return services.containsKey(key);
}
public Object get(ServiceKey key) {
Object service = services.get(key);
if (service == null) {
throw new ServiceNotFoundException(key);
}
return service;
}
}
它解决的是:
当前进程中
哪个对象实现了这个服务?
2. 服务注册中心
保存某个服务当前有哪些网络节点:
ServiceKey
↓
Endpoint List
例如:
UserService:1.0
├── 10.0.0.10:8080
├── 10.0.0.11:8080
└── 10.0.0.12:8080
它解决的是:
远程服务部署在哪里?
因此应当分成:
public interface ServiceDiscovery {
List<Endpoint> discover(
ServiceKey serviceKey
);
}
而不是让一个 Dispatcher 同时保存:
本地服务对象
远程地址
连接池
回调 Future
九、负载均衡有两层
资料中提到了连接池随机选择。
这里必须区分两种负载均衡。
1. 节点负载均衡
从多个 Provider 中选择一个:
UserService
├── Provider A
├── Provider B
└── Provider C
接口:
public interface LoadBalancer {
Endpoint select(
List<Endpoint> endpoints,
RpcInvocation invocation
);
}
策略可能是:
轮询
随机
一致性哈希
最少活跃调用
带权选择
2. 连接负载均衡
选定 Provider A 后,它可能有多条连接:
Provider A
├── Channel 0
├── Channel 1
├── Channel 2
└── Channel 3
此时还要选择:
本次请求使用哪条 Channel?
这是连接池内部策略:
public interface ChannelSelector {
Channel select(
ChannelPool pool
);
}
所以完整过程是:
服务发现
↓
得到多个 Provider
节点负载均衡
↓
选中 Provider B
连接池
↓
取得 Provider B 的连接集合
连接选择
↓
选中 Channel 2
把这两层都叫“负载均衡”,很容易让代码边界变得混乱。
十、协议不要泄漏
初版代码中,代理层可能直接构造:
MyContent
MyHeader
然后序列化并发送。
重构后应该区分:
调用消息
和:
传输帧
1. 调用消息
RpcInvocation
表示业务调用。
2. 协议消息
public record RpcRequest(
long requestId,
RpcInvocation invocation
) {
}
表示一次远程请求。
3. 传输帧
public record RpcFrame(
int magic,
byte version,
byte flags,
byte serializerId,
long requestId,
byte[] body
) {
}
表示网络协议中的一帧数据。
转换过程:
RpcInvocation
↓
RpcRequest
↓
序列化
↓
RpcFrame
↓
ByteBuf
这样代理层只接触:
RpcInvocation
传输层接触:
RpcRequest
协议层接触:
RpcFrame
ByteBuf
如果未来把自定义 TCP 协议换成 HTTP:
代理层
调用路由
服务发现
负载均衡
理论上都不需要改变。
十一、Transport 做什么?
可以定义统一传输接口:
public interface RpcTransport {
CompletionStage<RpcResponse> request(
Endpoint endpoint,
RpcRequest request
);
}
Netty 实现:
public final class NettyRpcTransport
implements RpcTransport {
private final ChannelManager channelManager;
private final PendingRequestRegistry pendingRequests;
@Override
public CompletionStage<RpcResponse> request(
Endpoint endpoint,
RpcRequest request
) {
CompletableFuture<RpcResponse> response =
new CompletableFuture<>();
pendingRequests.register(
request.requestId(),
response
);
return channelManager.getChannel(endpoint)
.thenCompose(channel ->
write(channel, request)
)
.thenCompose(ignored -> response)
.whenComplete(
(result, throwable) -> {
if (throwable != null) {
pendingRequests.remove(
request.requestId()
);
}
}
);
}
private CompletionStage<Void> write(
Channel channel,
RpcRequest request
) {
CompletableFuture<Void> result =
new CompletableFuture<>();
channel.writeAndFlush(request)
.addListener(future -> {
if (future.isSuccess()) {
result.complete(null);
} else {
result.completeExceptionally(
future.cause()
);
}
});
return result;
}
}
Netty 的 Channel I/O 是异步操作,调用会先返回 ChannelFuture,由它表示本次连接、写入、绑定或关闭操作的最终状态。
这里仍要区分:
ChannelFuture
表示网络 I/O 是否完成
CompletableFuture<RpcResponse>
表示远程调用是否得到响应
十二、连接怎样创建?
资料中使用了双重检查锁:
先检查连接
↓
加锁
↓
再次检查
↓
创建连接
它可以避免多个线程同时创建同一个槽位,但容易让:
连接池初始化
连接创建
异步连接结果
锁对象数组
失败重试
缠在一起。
更清晰的方式是缓存:
正在创建或已经创建的 Future
例如:
public final class ChannelManager {
private final ConcurrentMap<
Endpoint,
CompletableFuture<ChannelPool>
> pools = new ConcurrentHashMap<>();
public CompletionStage<Channel> getChannel(
Endpoint endpoint
) {
return pools.computeIfAbsent(
endpoint,
this::createPool
)
.thenApply(ChannelPool::select);
}
private CompletableFuture<ChannelPool> createPool(
Endpoint endpoint
) {
CompletableFuture<ChannelPool> future =
new CompletableFuture<>();
connectAll(endpoint)
.whenComplete(
(channels, throwable) -> {
if (throwable != null) {
pools.remove(
endpoint,
future
);
future.completeExceptionally(
throwable
);
return;
}
future.complete(
new ChannelPool(channels)
);
}
);
return future;
}
}
ConcurrentHashMap.computeIfAbsent() 会原子地完成指定键的缺失检查和映射建立,但映射函数本身应保持短小,避免在计算期间执行长时间阻塞操作。
因此示例中放入 Map 的是:
CompletableFuture<ChannelPool>
而不是在 computeIfAbsent() 中同步等待多个 TCP 连接建立。
CompletableFuture 可以由网络回调线程显式调用 complete() 或 completeExceptionally() 完成,并同时表达成功结果与异常。
十三、有状态是什么?
资料中把协议分成:
有状态协议:
通过 requestId 共享连接
无状态协议:
请求期间独占连接
这种划分不准确。
“状态”至少有三个维度。
1. 应用会话状态
例如:
用户是否登录
购物车内容
事务上下文
订阅关系
2. 连接状态
例如:
TCP 序号
TLS 会话
流量控制窗口
协议协商结果
3. 请求关联状态
例如:
requestId → Future
streamId → Request
RPC 加入 requestId,只能说明:
客户端保存了未完成请求的关联状态
它并不能直接把整个协议定义为“有状态协议”。
十四、无状态不等于短连接
HTTP 被规范描述为无状态的请求—响应应用层协议,但“无状态”描述的是请求语义,并不代表每个请求都必须新建 TCP 连接。
HTTP/1.1 默认支持持久连接,一条连接可以承载多个请求和响应。
HTTP/2 更进一步,通过 Stream ID 在同一连接中并发承载多个请求—响应交换。
因此:
协议无状态
不能推出:
一条连接只能发送一个请求
是否可以共享连接,取决于协议是否具备:
消息边界
请求关联
并发语义
顺序约束
流量控制
而不是只看“有状态”或“无状态”这个标签。
十五、连接何时独占?
一条连接是否需要独占,应根据协议能力判断。
1. 无法关联响应
假设协议没有 requestId,也没有 Stream ID:
发送 Request A
发送 Request B
收到:
Response X
Response Y
客户端无法可靠判断:
X 属于 A 还是 B
这时可以采取:
一次只允许一个未完成请求
也就是在请求—响应周期内串行使用连接。
2. 支持请求关联
如果协议具有:
requestId
消息边界
并发写入安全
响应匹配
就可以在一条连接上同时存在多个未完成请求:
Request 101
Request 102
Request 103
Response 103
Response 101
Response 102
所以更准确的分类是:
串行请求协议
和:
多路复用协议
而不是简单地说:
无状态必须独占
有状态可以共享
十六、同步只是外观
用户接口可能是:
User findById(long id);
也可能是:
CompletionStage<User> findById(long id);
底层都可以统一返回:
CompletionStage<Object>
同步接口由代理层等待:
远程 Future
↓
代理线程阻塞等待
↓
返回普通对象
异步接口直接返回:
远程 Future
↓
调用者注册后续动作
因此,框架内部更适合使用异步模型:
public interface RpcInvoker {
CompletionStage<Object> invoke(
RpcInvocation invocation
);
}
同步与异步的差异留在最外层代理处理。
这能避免底层每层都同时维护:
invoke()
invokeAsync()
两套几乎相同的代码。
十七、局部异步有用吗?
资料中提到:
全链路不异步
局部异步就没有意义
这个说法过于绝对。
局部异步仍然可以带来价值。
例如:
Netty I/O 线程
↓
异步等待远程响应
↓
不阻塞 EventLoop
即使最外层业务线程最终调用:
future.get();
底层 I/O 线程仍然没有被阻塞。
但也要承认:
最外层同步等待
意味着该业务线程仍会占用等待资源。
因此需要区分:
底层事件驱动
调用接口异步
整个业务链异步
它们不是同一件事。
十八、别堵住 EventLoop
Netty 中,一条 Channel 注册后,其 I/O 操作由对应 EventLoop 处理;一个 EventLoop 通常会管理多条 Channel。
所以服务端不能在 I/O Handler 中长期执行:
数据库查询
复杂计算
远程 HTTP 调用
文件读写
Thread.sleep()
否则:
请求 A 的业务阻塞
↓
同一 EventLoop 上
请求 B、C、D 的 I/O 一起延迟
推荐结构:
NioEventLoop
↓
拆帧
↓
协议解码
↓
业务执行器
↓
服务调用
↓
写回响应
Netty 的 ChannelPipeline 允许在添加 Handler 时指定一个 EventExecutorGroup,由该执行器组运行这个 Handler 的方法。
例如:
EventExecutorGroup businessGroup =
new DefaultEventExecutorGroup(16);
pipeline.addLast(
new RpcFrameDecoder(),
new RpcMessageDecoder()
);
pipeline.addLast(
businessGroup,
new RpcRequestHandler(
requestDispatcher
)
);
pipeline.addLast(
new RpcMessageEncoder()
);
这样:
协议解码
保留在 I/O 线程,而:
业务方法执行
进入业务线程。
十九、Provider 怎么分层?
服务端请求处理器不应该亲自承担:
查找服务
解析参数类型
反射方法
处理异常
构造响应
可以继续拆分。
1. RequestHandler
只接收请求和发送响应:
public final class RpcRequestHandler
extends SimpleChannelInboundHandler<RpcRequest> {
private final RequestDispatcher dispatcher;
public RpcRequestHandler(
RequestDispatcher dispatcher
) {
this.dispatcher = dispatcher;
}
@Override
protected void channelRead0(
ChannelHandlerContext context,
RpcRequest request
) {
dispatcher.dispatch(request)
.whenComplete(
(response, throwable) -> {
if (throwable != null) {
context.writeAndFlush(
RpcResponse.failure(
request.requestId(),
throwable
)
);
return;
}
context.writeAndFlush(response);
}
);
}
}
2. RequestDispatcher
负责把请求交给调用器:
public final class RequestDispatcher {
private final LocalServiceRegistry registry;
private final MethodResolver methodResolver;
public CompletionStage<RpcResponse> dispatch(
RpcRequest request
) {
try {
RpcInvocation invocation =
request.invocation();
Object service =
registry.get(
invocation.serviceKey()
);
Method method =
methodResolver.resolve(
service.getClass(),
invocation.methodName(),
invocation.parameterTypeNames()
);
Object result =
method.invoke(
service,
invocation.arguments()
);
return CompletableFuture.completedFuture(
RpcResponse.success(
request.requestId(),
result
)
);
} catch (InvocationTargetException exception) {
return CompletableFuture.completedFuture(
RpcResponse.failure(
request.requestId(),
exception.getTargetException()
)
);
} catch (Throwable throwable) {
return CompletableFuture.completedFuture(
RpcResponse.failure(
request.requestId(),
throwable
)
);
}
}
}
后续还可以把它继续替换为:
MethodInvoker
InterceptorChain
ServiceFilter
但在第一轮重构中,不必一次设计出所有扩展点。
二十、包该怎么拆?
一个学习版 RPC 可以先拆成下面这些包:
rpc
├── api
│ ├── RpcInvocation
│ ├── RpcRequest
│ ├── RpcResponse
│ └── ServiceKey
│
├── proxy
│ └── RpcProxyFactory
│
├── invoke
│ ├── RpcInvoker
│ ├── InvocationRouter
│ ├── LocalInvoker
│ └── RemoteInvoker
│
├── registry
│ ├── LocalServiceRegistry
│ └── ServiceDiscovery
│
├── cluster
│ ├── LoadBalancer
│ └── Endpoint
│
├── transport
│ ├── RpcTransport
│ ├── ChannelManager
│ └── ChannelPool
│
├── protocol
│ ├── RpcFrame
│ ├── RpcFrameDecoder
│ ├── RpcMessageDecoder
│ └── RpcMessageEncoder
│
├── serialize
│ ├── RpcSerializer
│ └── SerializerRegistry
│
└── provider
├── RequestDispatcher
├── MethodResolver
└── RpcRequestHandler
依赖方向:
proxy
↓
invoke
↓
transport
↓
protocol
而不是:
protocol
↓
proxy
基础协议层不应该反向依赖代理实现。
二十一、什么时候别走本地?
同一个 JVM 中存在服务实现,不代表每次都应该自动短路为本地调用。
以下场景可能仍然需要远程路径:
测试真实网络链路
验证序列化兼容
执行流量隔离
遵守服务治理策略
验证超时和重试
模拟跨版本调用
因此,路由策略不应该硬编码成:
if (localServiceExists) {
alwaysLocal();
}
更适合抽象为:
public interface InvocationPolicy {
InvocationMode choose(
RpcInvocation invocation,
InvocationContext context
);
}
可能的模式:
LOCAL_FIRST
REMOTE_ONLY
LOCAL_ONLY
REMOTE_FIRST
这也是框架化之后才能自然加入的能力。
二十二、哪些内容不该过度设计?
重构很容易走向另一个极端:
一个实现类
对应十个接口
一行代码
经过八层转发
学习版 RPC 不需要一开始就加入:
复杂 SPI
插件类加载器
脚本化路由
动态字节码生成
十种负载均衡
完整 Service Mesh
第一轮重构只需要解决最明显的变化点:
本地与远程
服务发现
节点选择
连接管理
协议编解码
服务调用
判断是否需要抽象,可以问:
这里是否真的存在两个以上合理实现?
例如:
Serializer
Java / JSON / Protobuf
适合抽象。
Transport
Netty / HTTP
适合抽象。
如果当前只有一个简单赋值操作,未来也没有明确变化,就不必为了“架构感”强行创建接口。
二十三、常见误区
1. 拆成多个类就是分层
错误。
如果类之间仍然互相读取内部状态,只是把代码搬了位置,并没有形成稳定边界。
2. Dispatcher 应该管理全部 RPC 逻辑
错误。
路由、本地注册、远程发现、方法调用和连接管理应该分别建模。
3. 代理层应该构造协议头
错误。
代理层负责方法调用转换,协议头属于协议层。
4. 本地调用也要序列化
错误。
统一的是调用入口,不是执行成本。
5. 本地调用必须使用反射
不一定。
反射是一种通用实现,也可以缓存 Method、使用 MethodHandle,或在生成代理时建立更直接的调用器。
6. 注册表就是注册中心
错误。
本地注册表保存对象,注册中心保存远程服务节点。
7. 随机选 Channel 就是服务负载均衡
错误。
服务节点选择和节点内部连接选择是两个层次。
8. requestId 让协议变成有状态
不准确。
requestId 建立请求—响应关联状态,但不能单独决定整个协议是否“有状态”。
9. 无状态协议必须一请求一连接
错误。
HTTP 是无状态应用协议,但 HTTP/1.1 支持持久连接,HTTP/2 还能在一条连接中并发多路复用请求。
10. 有 requestId 就能共享连接
不完整。
还需要消息边界、并发写入安全、响应匹配、流量控制和错误处理。
11. 每个连接都创建 EventLoopGroup
错误。
EventLoopGroup 应管理多条 Channel;Channel 注册后由某个 EventLoop 负责 I/O,而一个 EventLoop 通常管理多条 Channel。
12. ChannelFuture 就是 RPC Future
错误。
ChannelFuture 表示 Netty I/O 操作结果,RPC Future 表示远程方法调用结果。
13. 把业务扔给另一个 I/O EventLoop 就行
不推荐。
那会阻塞另一个 EventLoop 管理的 Channel。阻塞业务更适合独立业务执行器。
14. 使用线程池就是异步 RPC
错误。
线程切换只是执行方式变化。真正的异步接口应把未来结果暴露为 Future 或 CompletionStage。
15. 局部异步完全没意义
错误。
即使外层最终同步等待,底层 I/O 线程不被阻塞仍然有价值。
16. 本地服务存在就必须本地调用
错误。
路由策略可能要求走真实远程链路。
17. 反射一定是 RPC 最大瓶颈
不一定。
网络、序列化、业务执行、队列等待和下游服务通常也会产生显著成本,应通过压测确定瓶颈。
18. 重构后层次越多越好
错误。
抽象应该对应真实变化,而不是为了展示设计模式。
总结
RPC Demo 的目标是:
把一次远程调用跑通
RPC 框架的目标是:
让变化被限制在对应层
重构前:
代理
协议
序列化
连接池
服务发现
Netty
回调
反射
全部挤在一条线性流程中。
重构后,可以形成:
代理层
把方法调用转换成 RpcInvocation
路由层
决定本地调用还是远程调用
本地调用层
从本地注册表找到对象并执行
远程调用层
构造请求并等待远程响应
服务发现层
找到可用 Provider
节点负载层
选择一个 Provider
连接管理层
管理 Provider 的 Channel
传输层
发送请求并返回 Future
协议层
完成消息编码、拆帧和解码
服务端分发层
找到实现对象并执行方法
本地调用与远程调用可以共享:
接口
代理
调用对象
拦截器
但不应该共享:
序列化
网络传输
协议编解码
服务注册表和注册中心也必须区分:
本地注册表
ServiceKey → Object
远程注册中心
ServiceKey → Endpoint List
负载均衡同样分为:
服务节点选择
和:
节点连接选择
协议是否能复用连接,不取决于简单的“有状态”或“无状态”标签,而取决于:
消息边界
请求关联
并发语义
流量控制
错误处理
requestId 负责关联请求与响应,但它不负责消息拆包,也不能单独决定协议状态模型。
Netty EventLoop 应专注于:
连接事件
协议编解码
快速状态转换
阻塞业务应该进入独立执行器。
最后,可以用一句话概括这次重构:
RPC 重构不是把一个大类拆成很多小类,而是把方法调用、服务路由、节点发现、连接管理、协议传输和业务执行分别放进稳定边界,让每一种变化只影响它应该影响的那一层。
评论区