资讯详情

自定义RPC框架核心:公共模块rpc-common的边界、协议与实现细节

📅 2026/10/15 17:20:43 | 华诺云谱 👁 阅读
自定义RPC框架核心:公共模块rpc-common的边界、协议与实现细节
1. 为什么是rpc-common这个模块的边界到底有多大上一篇我们把整体骨架搭了起来从接口定义到Netty通信层都有了大致轮廓。今天这篇专门聊公共模块rpc-common因为很多人在做自定义RPC时最容易翻车的地方其实不在Netty的线程模型也不在传输层的拆包粘包而是公共模块的职责没划清楚写着写着就把Provider端的东西塞进API模块或者把API模块的类塞进Common模块最后依赖关系乱成一锅粥。先说我踩过的坑。最开始做这套框架时我把RpcRequest、RpcResponse、序列化接口、协议常量全部放在一个rpc-core模块里想着反正都是内部用的没必要拆分。结果是Consumer端写代理的时候要引rpc-core引了rpc-core就相当于把Netty的依赖也带进来了把服务注册的代码也带进来了。每次改一个注解Consumer端都要跟着重新编译不仅工程上别扭还会出现依赖环路。所以现在我把公共模块单独抽出来遵守一条铁律rpc-common只放两边都要用的东西放进去的每一行代码都必须同时被Provider和Consumer引用只被一端用的代码坚决不放进来。1.1 公共模块该放什么不该放什么要判断一个类该不该放进rpc-common不需要记复杂规则就看两个问题第一Consumer端构建代理对象时会不会用到它第二Provider端发布服务时会不会用到它如果答案是两端都会用放进rpc-common如果只有一端会用放回各自模块。按这个标准rpc-common里最核心的内容就四类消息结构RpcRequest、RpcResponse、消息头Header这是通信双方唯一约定的数据格式。协议常量与扩展点魔数、版本号、序列化类型枚举、Serializer接口及内置实现。公共注解Provider端标注服务实现类的RpcService、Consumer端标注注入点的RpcReference这两个注解天然是两端各自用的但注解定义本身必须在公共模块否则两端无法拿到同一个类型。工具与异常服务名解析工具、RpcException异常体系以及后续要讲的动态代理辅助类。这里面最容易产生争议的是工具类。我当时差点把服务端独有的ServiceHolder放进去后来冷静想了下它只存在于Provider端Consumer端根本不需要知道服务实例是怎么存进Map的放进Common只会让Consumer端白白背一段永远不会被调用的代码。宁可前期多做几次小重构也绝不让模块边界因为懒而模糊掉。1.2 从依赖方向看谁依赖它它绝不能依赖谁rpc-common的依赖方向在工程里必须是单向的rpc-provider依赖它rpc-consumer依赖它而它自己不依赖任何业务模块除了JDK标准库和必要的第三方库比如序列化依赖、SLF4J。一旦Common反过来依赖core或provider整个项目就出现循环依赖Maven编译会直接报错这种错存在一天团队协作的效率就低一天。我用的检查方法很简单在rpc-common的pom.xml里时不时的看一眼依赖列表凡是看到Netty、Zookeeper、Spring相关的那就要警惕了。Common模块只保留三件事消息结构、序列化抽象、注解定义。保持这个纯度后面做Consumer端代理时就能轻装上阵不用拖着一大堆用不上的依赖到处跑。2. 动手前必须定死的协议设计先画清边界再写代码自定义RPC最难的不是Netty的ChannelHandler怎么写而是协议定义。协议没定好后面写编解码器、写心跳、写超时重试全都要跟着返工。我在做公共模块时最先写的就是协议常量类和消息头因为它们决定了ByteBuf里每一个字节怎么排布。2.1 报文格式的取舍为什么不用纯String看到这里你可能想问为什么自定义RPC一定要自定义协议直接用JSON字符串不行吗答案是可以但如果想控制长度、想做二进制级别的高效传输、想穿透心跳包和业务包还是得有一个固定的二进制格式。我采用的报文结构是这样的魔数4字节固定为某个十六进制值用来快速判断数据包是否是我们协议的内容不是就直接丢弃。版本号1字节标明协议版本方便以后升级协议时做兼容判断。消息类型1字节标明是请求、响应、心跳请求还是心跳响应。序列化类型1字节标明消息体用的是JSON还是其他序列化方式。状态码1字节响应时用来标记成功还是失败。请求ID8字节用Long类型用来关联请求和响应。数据长度4字节标明消息体数据的字节长度供解码器判断完整包。一共是固定20字节的Header后面跟着body字节数组。当初我把消息类型和序列化类型分开就是因为这两者变化的频率完全不同消息类型是协议层面的语义序列化类型是传输格式的策略混在一起会限制扩展性。2.2 请求ID的设计逻辑简单的long却藏着并发上限请求ID我直接用AtomicLong生成从0开始往上累加。为什么用8字节的long而不是int因为在高并发场景下int的21亿上限听上去很大但如果做压测时每秒产生百万级请求一旦系统持续运行或长连接复用ID很快会到达瓶颈。long可以让你在很长一段时间内不需要考虑ID回绕问题。请求ID必须放在Header里不能放在Body里。原因是解码器在判断业务回调时需要在不反序列化整个body的前提下快速拿到关联ID这样心跳包、超时重试、批量回调都能直接基于Header处理。我在最初设计时把requestId放在body里结果做响应匹配时要先做反序列化才拿到ID性能损耗很大而且如果反序列化失败就彻底拿不到ID了后来果断挪到了Header。2.3 序列化策略的SPI抽象别被JSON绑死序列化是RPC里开销非常大的一个环节直接写死用ObjectMapper显然不够灵活。我在公共模块里定义了一个Serializer接口只留两个方法serialize和deserialize。接口定义要尽量薄不要塞入平台特定逻辑方便将来替换实现。内置一个JSON实现作为默认序列化方式。对自定义RPC来说JSON的好处是调试方便、平台无关但它有两个硬伤一是性能相对不如二进制协议二是对复杂泛型类型的反序列化不太友好。所以我在接口设计上留了扩展位以后可以接Hessian或Protostuff风格的实现。字段上用一个serializerType标记当前请求用的是哪种解码端拿到字节后根据标记分发到对应实现。3. 核心类逐个拆解照着抄就能跑的骨架这一节是rpc-common的主体代码部分我按依赖层次从小到大逐个说明从消息对象到序列化接口再到注解和工具类你可以直接照这个结构搭建自己的公共模块。3.1 消息对象RpcRequest与RpcResponse先看RpcRequest它需要携带一次远程调用所需的全部信息public class RpcRequest implements Serializable { private static final long serialVersionUID 1L; /** * 请求ID与Header中的requestId保持一致 */ private long requestId; /** * 接口全限定名比如 com.example.demo.UserService */ private String interfaceName; /** * 方法名 */ private String methodName; /** * 参数类型列表反序列化时用 */ private String[] parameterTypes; /** * 实际参数值列表 */ private Object[] parameters; /** * 附加信息比如traceId、超时时间等 */ private MapString, Object attachments; // getter / setter / toString 省略 }两个细节值得说明。第一parameterTypes我用的是String[]而不是Class[]。一开始我用Class[]在Consumer端可以拿到类对象但序列化之后传到Provider端再反序列化时Class对象还原经常出问题尤其在自定义类加载器场景下会抛ClassNotFoundException。后来改成String数组Provider端通过Class.forName加载稳得多也避免了两端ClassLoader不一致的隐患。第二attachments这个Map是临时加上的因为后来发现做链路追踪、传递超时时间、传递token时总是需要往请求里塞一些与业务无关的上下文。有了attachments就不用每次改协议头通用的KV传递就有一个兜底方案。再看RpcResponse它是远程调用结果的统一包装public class RpcResponse implements Serializable { private static final long serialVersionUID 1L; /** * 响应ID必须与请求的requestId一致 */ private long requestId; /** * 状态码见ResponseCode常量 */ private byte statusCode; /** * 如果发生异常这里存放异常信息 */ private String errorMessage; /** * 方法返回结果 */ private Object result; /** * 泛型返回类型的实际类型用于返回ListUser这类复杂对象时反序列化 */ private Class? resultType; // getter / setter / toString 省略 }statusCode我单独抽了一个常量类比如SUCCESS0、SERVER_ERROR1、SERVICE_NOT_FOUND2、UNKNOWN_ERROR3。这些状态码不只是给人类看的Consumer端要靠它们决定是正常返回、还是抛远程异常、还是走重试逻辑所以定义要尽量稳定不要随便改数字。有一个关于柔性反序列化的心得把result和resultType分开存而不是把参数类型编码进result中。原因很简单Consumer端拿到的结果一般都是经过代理对象转换后的POJO需要由框架层把字节数据转换成对应的泛型类型。如果只存一个Object默认会被还原成LinkedHashMap取字段时就会出现类型不匹配的问题所以必须借助resultType做一次二次转换。3.2 序列化接口与默认实现接口定义保持极简public interface Serializer { /** * 将对象序列化为字节数组 */ T byte[] serialize(T obj) throws IOException; /** * 将字节数组反序列化为指定类型的对象 */ T T deserialize(byte[] data, ClassT clazz) throws IOException; }我刻意没有加serializerType到接口里因为序列化类型是协议层的概念由Header统一标记实现类不需要关心自己的编号。默认实现用Jacksonpublic class JsonSerializer implements Serializer { private final ObjectMapper objectMapper new ObjectMapper(); Override public T byte[] serialize(T obj) throws IOException { return objectMapper.writeValueAsBytes(obj); } Override public T T deserialize(byte[] data, ClassT clazz) throws IOException { return objectMapper.readValue(data, clazz); } }这里有个小坑如果直接照抄这套代码反序列化ListUser这类带泛型的返回值时会抛出MismatchedInputException。原因很好理解ClassT表示的是字面量的字节码对象泛型里的具体参数类型全部丢失了。所以我在RpcResponse中留了resultType字段在真正的Provider端实现中会通过TypeReference配合resultType构造出一个完整泛型类型再交给Jackson处理——这部分代码放在Provider端的序列化流程里而不是Common模块因为需要知道更完整的业务上下文。3.3 注解与配置类定义在Common消费在各端RpcService注解是Provider端发布服务时的入口标记Documented Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface RpcService { /** * 服务接口的Class对象 */ Class? interfaceClass() default void.class; /** * 版本号不同版本可以同时存在解决接口升级时新旧兼容问题 */ String version() default ; }interfaceClass的默认值用了void.class这是一个常见的占位写法表示没填。之所以不直接把Class属性设成必填项是因为部分场景下实现类可以直接推断出接口类型——比如类实现了一个唯一接口框架可以通过反射拿到它。但如果实现类同时实现了多个接口就必须显式指定要暴露哪一个否则框架无法决定该注册谁。RpcReference是Consumer端的注入标记Documented Retention(RetentionPolicy.RUNTIME) Target(ElementType.FIELD) public interface RpcReference { /** * 服务版本与RpcService的version对应 */ String version() default ; /** * 调用超时时间单位毫秒 */ long timeout() default 3000; /** * 是否启用失败重试 */ boolean retry() default false; /** * 重试次数 */ int retries() default 3; }把timeout、retry、retries这些参数直接放注解上是为了支持同一个接口的不同引用点配置不同策略。比如A服务上的RpcReference超时时间5秒、失败不重试B服务上超时时间1秒、失败重试2次这种细粒度控制在架构上很有必要。后来我还加过loadBalance字段指定负载均衡策略比如random、roundRobin这些配置项不会改变协议结构只影响Consumer端的代理行为。配置类我没有直接命名为RpcProperties而是做得很薄只记录框架启动需要的必要参数。以独立的配置类而不是散落一堆常量为主是因为面向Spring的场景可以通过ConfigurationProperties绑定到配置文件用YAML集中管理这比在代码里到处写魔法数字好维护得多。3.4 工具类与异常体系看似不起眼的细节ServiceUtil是公共模块里我认为最被低估的一个类。它可以负责生成服务唯一标识并将唯一标识与接口类做双向解析public final class ServiceUtil { /** * 组装服务唯一标识接口全限定名 version */ public static String buildServiceKey(String interfaceName, String version) { if (StringUtils.isEmpty(version)) { return interfaceName; } return interfaceName version; } }这个看似简单的键值规则是整个服务注册与发现的核心契约。Provider端把服务实例存进Map时用这个key作为键Consumer端发起远程调用时也根据同一个key去注册中心寻找可用服务。如果两边的拼接规则不一致会出现永远找不到服务的诡异现象排查起来极为痛苦。我第一次做的时候就坑在这里Provider端用interfaceName # version拼接Consumer端用interfaceName - version拼接结果注册中心明明有服务请求就是过不去这种低级错误全是因为没有在公共模块里统一约定。另外我加了一个RpcException继承自RuntimeExceptionpublic class RpcException extends RuntimeException { private byte statusCode; public RpcException(byte statusCode, String message) { super(message); this.statusCode statusCode; } public byte getStatusCode() { return statusCode; } }自定义RPC中异常必须自定义原因很现实默认的Exception没有状态码Consumer端无法区分是超时异常、服务未找到异常还是业务本身抛出的异常。有了statusCode代理层就可以在拿到异常时做精确的策略分发比如超时走重试、业务异常直接抛给上层。这个设计借鉴了HTTP状态码的思路但又不完全是照抄因为这里的粒度更细对应的是RPC内部的业务语义。4. 踩过的坑和设计修正这些坑在单测阶段就暴露了这一节我想分享几个在写rpc-common过程中实际掉进去过的坑。它们不会让你项目崩掉但会浪费大量的排查时间而且是那种你翻遍文档也找不到答案的隐性Bug。如果你已经搭过一套类似的框架看完可能会心一笑原来不是我一个人栽在同一个地方。4.1 参数类型用Class带来的序列化噩梦这个坑在前面的代码里已经提过RpcRequest.parameterTypes一开始是Class?[]。当时我觉得这样最直观Consumer端直接method.getParameterTypes()拿原生类丢进RequestProvider端拿到后直接对比方法签名天然一致。到了单测阶段我用同一台机器上的Provider和Consumer做测试时一切正常但换到两个不同进程后问题就出来了——Consumer发送com.example.demo.entity.User这个Class对象的字节流Provider端反序列化时报ClassNotFoundException。原因是因为Class对象在被序列化后接收端要还原它需要发送端和接收端对同一个类加载器具有相同的可见性一旦类的加载源不同就难以还原。把parameterTypes改成String[]后问题迎刃而解Consumer端把User.class.getName()写入字符串数组Provider端再根据字符串做Class.forName加载。这样彻底绕开了ClassLoader环境差异代码变动很小但稳定性提升了一整截。4.2 心跳与回调消息耦合导致响应错位框架内部要区分心跳包和业务包当时我在RpcRequest里加了一个isHeartbeat字段来标记心跳结果单测时就出了大问题心跳请求没有interfaceName和methodNameProvider端不解业务直接回一个心跳响应但Consumer端在回调的时候却把这个心跳响应也塞进了requestId对应的Future里导致活生生的调用超时。后来我认真复盘把消息分类从字段“升格”到了协议Header的消息类型一切才清晰起来。编解码器先根据Header的messageType判断该不该走完整的业务流程心跳包直接透传业务调用按requestId去匹配Future互不干扰。这个设计告诉我分类信息放的地方越靠前越不容易被遗漏。4.3 注解默认值导致服务扫描误判RpcService的interfaceClass默认值是void.class但我在扫描的时候写的是如果interfaceClass不是void.class就用这个值作为服务接口。最初的判断条件写成了if (annotation.interfaceClass() ! void.class)结果在一个实现类没有显式指定接口的场景下void.class被当成了合法接口注册一个诡异的服务条目就这么诞生了。排查时花了不少时间最后才意识到应该判断的是interfaceClass.getName()是否等于void.class.getName()然后再根据接口数量去推断。这个坑在真实项目中极易出现因为注解默认值在反射API中会被返回为初始化值而没有设置和设置为void.class在反射层面是同一个东西只能靠约定避开。5. 公共模块的验证姿势在不写Netty之前先把手脚绑起来我们做公共模块最大的可悲之处在于模块本身不能独立跑起来——它没有端口没有连接没有线程池确实很难验证。我在这部分的策略是先写单测把还没有任何网络传输的纯Java对象行为锁死。5.1 靠单测锁定协议的稳定性第一步先验证序列化把RpcResponse从JsonSerializer的序列化结果中还原出来字段值必须全部一致尤其是resultType这种反射相关字段。第二步验证ServiceUtil的解析build和parse是一对互逆操作正反两次结果都要完全等同。第三步是验证注解的反射行为在单元测试里定义几个模拟类确认RpcService和RpcReference在不同设置下的扫描结果。这些单测虽然看起来简单但价值在于为后续所有模块设定了稳定的地基。等Provider端写起来以后凡是调用公共模块的地方都可以先跑这份测试一旦有类改动出现问题最先发现的不是埋在最深处的网络层而是这一层最薄的单元测试。5.2 公共模块其实是一份契约写到这里我越发觉得rpc-common最核心的价值不是代码本身而是它扮演的契约角色。它规定了Provider和Consumer之间必须共同遵守的协议格式规定了服务唯一标识的计算规则规定了注解的语义也规定了序列化扩展点。任何一方偏离这个契约哪怕只是一行代码整个RPC流程都会出问题。但反过来只要rpc-common稳定了Provider和Consumer其实可以各自演进——不断调整内部的线程模型、负载均衡策略、连接管理方案却不需要通知对方。这一点在团队协作中尤其重要。两个人同时开发Provider端和Consumer端只要接口基于同一份rpc-common去写就没有那么多你等我改协议的阻塞两张独立的开发节奏可以并行推进。5.3 下一篇文章的衔接点公共模块的代码到这里已经铺完了下一篇我会带着这套骨架进入Consumer端讲动态代理的实现也就是从RpcReference注解到远程调用的完整通路。这部分会涉及代理生成策略、Future与回调的管理方式以及把requestId匹配机制真正跑通的过程。如果你正在做自己的自定义RPC建议先把这一篇的公共模块搭建好不要急于去写编解码器和线程池。地基稳了上层怎么盖都不会太歪。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑