学完Netty之后,很多人跟我一样会有个困惑:东西都看懂了,但总感觉是零散的——Channel、Pipeline、EventLoop、ByteBuf各自都认识,凑在一起就不知道能干什么。写Demo又太简单,写生产级项目又够不着。我自己就是在这种状态下开始尝试手写RPC框架的,初衷很简单:把一个真实场景的网络通信问题从头到尾自己实现一遍,看看Netty到底能在里面承担什么角色。
这个项目我前后断断续续做了几周,踩了不少坑,也把很多之前一知半解的知识点真正串起来了。这篇文章就把完整的设计思路、核心实现、避坑经验一次性讲透,适合已经学过Netty基础、想通过一个实战项目巩固知识,或者对RPC原理感兴趣、打算自己动手实现一遍的开发者参考。我会尽量把每个关键决策背后的原因也说清楚,因为很多时候我们差的不是代码,而是为什么这么做。
1. RPC框架核心问题与设计思路
1.1 5分钟搞懂一次RPC调用要经过哪些环节
在写代码之前,必须先把RPC的本质看透。RPC全称是Remote Procedure Call,翻译过来就是远程过程调用——让调用方像调用本地方法一样调用远程服务。这句话听起来简单,但背后藏着三个难啃的骨头:代理、通信、序列化。
假设我现在要调用一个远程方法,这中间发生了什么?我总结下来是七个环节:客户端拿到接口代理对象,通过代理把方法名、参数类型、参数值等信息包装成请求,序列化成二进制字节流,通过网络传输到服务端,服务端反序列化恢复请求,找到对应实现类,反射调用真实方法,再把返回值按同样路径回传。整个过程的骨架就是这样,手写RPC本质上就是把这七个环节自己实现一遍。
这里有一步非常关键:客户端实际从来没有真正拿到过服务端的实现类,它拿到的只是一个代理对象。这个代理对象长着跟接口一样的方法签名,但内部做的事情完全不一样——他不是执行方法逻辑,而是发起网络请求并等待结果。JDK动态代理就是实现这一步的天然工具,后面我会专门讲。
那么这个项目跟单纯学Netty有什么差别?我个人的体会是:单纯写Netty聊天室或者HTTP转发,你关注的是网络层本身;但写RPC框架,你还得同时考虑接口设计、序列化选型、服务发现、异常处理、超时控制这些问题。Netty反而是其中已经被封装得比较好的那部分,这也是为什么拿RPC做Netty实战项目特别合适——它不会让你陷入单纯的API调用,而是逼你想清楚每个环节怎么配合。
1.2 为什么最终选Netty而不是Socket或HTTP
很多人在设计RPC通信层时会纠结:用Java原生Socket行不行?用HttpClient行不行?用Netty是不是杀鸡用牛刀?我的建议是,如果你的目标就是学Netty,那就用Netty,但心里要明白备选方案的优劣对比。
原生Socket最大的问题是线程模型。每来一个连接就开一个线程,或者用线程池兜底,这个模式在几百个连接下还能撑住,一旦上了几千个连接,线程数量失控,上下文切换开销直接把性能拖垮。HTTP方案则面临另一个问题:HTTP协议本身太重了,每个请求都有完整的头部、状态行,大量字段对内部RPC调用来说是冗余的,而且HTTP多半走短连接或半长连接,性能天然比自定义二进制协议低一截。
Netty的主动优势在于几点:事件驱动模型配合EventLoop,天然支持高并发连接;自带编解码器体系,可以方便地实现自定义协议;有完善的粘包半包处理方案;零拷贝特性降低GC压力;最让我满意的是ChannelHandler的Pipeline链式调用设计,把业务逻辑切成一个个独立处理器,后续往里面加功能非常方便。
注意:这里说的“选Netty”是指通信层选Netty,RPC框架的其他部分仍然自己写。整个过程更像是“用Netty实现RPC框架的通信底座”,而不是“把Netty嵌进某个现成框架”。
1.3 手写RPC框架的功能边界怎么定
写代码前必须明确:这个项目做到什么程度算完成?我的定义是四个核心能力:支持接口代理调用、支持自定义协议、支持服务注册与发现、支持基本的负载均衡。这四个能力对应的正好是上面提到的七个环节。
有些东西我建议第一版先不要碰,包括:集群容错(失败重试、故障转移)、流量控制、全链路追踪、跨语言支持。这些如果加上,工作量会翻倍,而且核心思路容易被淹没。先把主链路跑通,后面扩展起来会更清楚该在哪里下手。
我也给自己定了一个原则:不要过度设计。比如注册中心,网上很多人一上来就引入ZooKeeper或者Nacos,但我第一版就写了一个基于Map的内存注册中心——它解决的是“服务端地址怎么被客户端知道”这个问题,跟用什么存储是两码事。后面想换ZooKeeper,只需要改一个实现类,接口不用动。先跑通,再替换,这才是正确节奏。
2. 协议设计与模块划分实操
2.1 自定义RPC协议:每个字段都要有存在的理由
RPC通信不是把序列化后的字节直接往管道里塞那么简单,前提是双方得有一个共同遵守的“对话格式”,这就是协议。我参考了主流RPC框架的做法,设计了一个精简的协议头,总共包含五个固定字段加一个变长消息体:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 魔数 | 4 | 固定为0xCACA,用于快速校验数据有效性 |
| 消息类型 | 1 | 0x01请求,0x02响应,0x03心跳 |
| 序列化类型 | 1 | 0x01JDK,0x02JSON,0x03自定义二进制 |
| 请求ID | 8 | 全局唯一,关联请求与响应 |
| 消息体长度 | 4 | 表示消息体字节数 |
| 消息体 | 不定长 | 实际的序列化数据 |
魔数的存在很多人不理解,觉得是多余的。其实它的作用是防止网络上其他数据包被误解析——比如TCP连接上先到了一些乱七八糟的数据,接收方一看魔数不对就直接丢弃,不用继续解后面的字段。这个设计很像身份验证的第一道门。
请求ID这8个字节是整个协议里最容易被忽略但实际最重要的字段。RPC请求和响应是异步配对的:客户端发出去一个请求,服务器处理完返回时,客户端怎么知道这是哪个请求的响应?没错,靠请求ID。这就好比你在餐厅点了三个菜,如果厨师上菜时不告诉你这是哪道菜,你根本不知道手里这盘是鱼香肉丝还是宫保鸡丁。后面讲请求编排时这个ID会起到关键作用。
2.2 粘包半包问题怎么处理:刻在骨子里的长度字段
第一次跑通客户端和服务端通信后,拿着小数据测试一切正常,我一度觉得粘包半包问题被夸大了。直到我写了一个循环发送几千次的测试用例,才看到解析结果乱成一团——这就是典型的“小数据蜜月期”陷阱。
粘包是指多个消息被TCP合并成一个包发送,半包则是一个消息被拆成多个TCP包。解决思路说起来很简单:既然TCP是字节流,没有消息边界,那就自己定义边界。实现方式有固定长度、分隔符、长度字段三种,RPC场景最常用的是长度字段方案。
Netty里有一个现成的解码器LengthFieldBasedFrameDecoder,专门解决这个问题。核心配置就三个参数:maxFrameLength(最大帧长度)、lengthFieldOffset(长度字段偏移量)、lengthFieldLength(长度字段长度)。我的协议里,魔数占4字节、消息类型占1字节、序列化类型占1字节、请求ID占8字节,一共14字节,然后才是4字节的消息体长度字段,所以偏移量就是14。
new LengthFieldBasedFrameDecoder(1024 * 1024, 14, 4, 0, 0)配置好之后,这个解码器会从头开始读数据,读完长度字段后,就知道后面要截取多少字节作为完整的帧,自动处理粘包和半包。这一步做完,后续Handler接收到的每个对象都是一个完整消息,不用再关心底层拆包逻辑。
一个重要提醒:LengthFieldBasedFrameDecoder负责按长度切帧,但它切出来的仍然是一堆二进制字节,对应的
ByteBuf,后面的Handler还要做反序列化操作。很多初学者会把“拆帧”和“反序列化”搞混,其实它们是两步:先拿到完整帧,再把帧里的字节变成Java对象。
2.3 模块划分:让代码结构跟着职责走
项目虽然不大,但模块划分一定要清晰。我分了五个模块,依赖关系是单向的,不允许循环依赖:
rpc-common:存放协议定义、序列化接口、常量、工具类,被其他所有模块依赖rpc-transport:基于Netty的通信层,负责连接管理和编解码rpc-registry:服务注册与发现rpc-consumer:客户端代理与调用编排rpc-provider:服务端接收请求并处理
这个划分的好处很具体:替换序列化方案只需要动rpc-common里的序列化接口实现;把内存注册中心换成ZooKeeper只需要改rpc-registry;通信层从Netty换成别的框架只影响rpc-transport。模块之间用接口通信,每次改动都在局部,不用推倒重来。
我特别想强调接口设计在这里的价值。以注册中心为例,我先定义了一个ServiceRegistry接口,只有三个方法:register、discover、unregister。内存实现就是一个ConcurrentHashMap<String, List<String>>,key是服务名,value是该服务对应的地址列表。后面如果要接Nacos,就写一个新的实现类,把这个Map的操作替换成Nacos客户端调用,上层代码一行都不用改。
3. 基于Netty的通信层落地
3.1 服务端启动流程:从BootStrap看Netty的骨架
服务端写起来其实有固定套路,核心是创建两个EventLoopGroup:bossGroup负责接收新连接,workerGroup负责处理IO读写。这个分工很多初学者容易忘记一个朴素的事实:接收连接和处理请求是两种不同性质的活儿,分开才能互不拖累。
我的服务端启动核心代码如下:
EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.TCP_NODELAY, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline() .addLast(new RpcFrameDecoder()) .addLast(new RpcMessageDecoder()) .addLast(new RpcMessageEncoder()) .addLast(new RpcRequestHandler()); } }); ChannelFuture future = bootstrap.bind(port).sync(); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }SO_BACKLOG表示等待连接的上限,类似于银行排队区能容纳的人数,超出上限的请求会被拒绝。TCP_NODELAY则是关闭Nagle算法,小数据包不用等待合并直接发送,对RPC这种强调低延迟的场景很关键。这两个参数看起来不起眼,但对实际表现有实质影响。
Pipeline的Handler顺序是另一个容易被忽视的细节。我把帧解码放在最前面,然后是消息解码(把字节转成消息对象)、编码器,最后是业务Handler。这种顺序意味着入站数据先进帧解码器,切出完整帧;再进消息解码器,变成Java对象;最后进业务处理器,执行真正的反射调用。出站则相反,业务处理器把响应发出后,先经编码器变成字节,再经帧编码器(其实帧的逻辑已经包含在消息编码器里)写出去。
3.2 客户端启动思路:一连接一通道还是用池
客户端的启动跟服务端类似,但有一个核心问题要提前想清楚:是不是每次RPC调用都要重新建立连接?答案绝对是否定的。TCP建连需要三次握手,频繁建立销毁连接不仅慢,还会给服务端带来大量TIME_WAIT状态连接,最终把端口资源耗尽。
我采用的方案是连接池式管理:客户端启动时先创建一批连接到目标服务端的Channel,放进一个队列或者列表里,每次调用时从池中借出一条Channel来写数据,用完归还。实现上其实很简单:
public class ChannelPool { private final List<Channel> channels = new CopyOnWriteArrayList<>(); public Channel getChannel(LoadBalanceStrategy strategy) { // 根据负载均衡策略取出一个可用Channel } }这里有个细节值得注意:多个请求同时使用同一个Channel是安全的,因为Netty的Channel写操作是线程安全的,消息会进入发送队列按顺序发出。也就是说我只需要为每个服务端建池,不需要为每次调用建连接。
3.3 EventLoop被阻塞是最容易犯的错
我必须严肃地提醒一件事:绝对不要在Netty的EventLoop线程里执行阻塞操作。如果你在业务Handler的channelRead0方法里直接做了数据库查询、远程调用、文件读写,这些阻塞方法会把这个EventLoop绑定的所有连接都拖住——因为一个EventLoop要服务多个Channel,你堵住了一个,等于堵住了一串。
我第一版代码就踩了这个坑:在RpcRequestHandler里直接执行了反射调用,反射调用的方法恰好又去查了数据库,结果导致同一线程上的其他连接全部出现诡异超时。排查了很久才反应过来是线程阻塞问题。
正确做法是把耗时操作丢到一个独立的业务线程池里执行。Netty官方也没规定处理逻辑必须在线程内跑完,业务逻辑完全可以异步化:
private static final ExecutorService BIZ_EXECUTOR = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2); // 在channelRead0中: BIZ_EXECUTOR.submit(() -> { Object result = invokeTargetMethod(request); ctx.writeAndFlush(response); });这里还要再强调一点:ctx.writeAndFlush不能在Handler方法返回之后随便用。如果你想在Handler外面(比如业务线程里)写数据,不要用ChannelHandlerContext,因为Handler结束后你的回调引用是不安全的。正确做法是先记下channel,然后在业务线程中用channel.writeAndFlush,这个方法是线程安全的。
4. 动态代理与请求编排实现
4.1 JDK动态代理:让接口调用变成网络请求
动态代理是RPC入口的魔法所在。客户端引入服务接口后,实际上拿到的对象是JDK动态代理生成的代理类实例。这个代理类实现了目标接口,但每个方法内部执行的不是业务逻辑,而是构造一个RPC请求然后发出去。
我实现的代理工厂逻辑大致如下:
public class RpcProxyFactory { @SuppressWarnings("unchecked") public static <T> T create(Class<T> interfaceClass, RpcClient rpcClient) { return (T) Proxy.newProxyInstance( interfaceClass.getClassLoader(), new Class[]{interfaceClass}, (proxy, method, args) -> { RpcRequest request = new RpcRequest(); request.setRequestId(UUID.randomUUID().toString()); request.setInterfaceName(interfaceClass.getName()); request.setMethodName(method.getName()); request.setParameterTypes(method.getParameterTypes()); request.setParameters(args); // 发送请求,等待响应 RpcResponse response = rpcClient.invoke(request); if (response.getError() != null) { throw new RuntimeException(response.getError()); } return response.getResult(); } ); } }这里有一个所有初学者都容易忽略的问题:method.getParameterTypes()跟args是两码事。反射调用时不仅需要参数值,还需要参数类型,没有参数类型的话服务端没法用getMethod定位到正确的方法——尤其重载场景下,同名方法参数不同,缺了类型信息就完全没办法区分。
另外一个问题是方法签名的类型在分布式环境下要保持一致。客户端接口方法的参数类型必须和服务端实现类的方法参数类型完全一致,否则服务端反射时找不到方法。这个问题的本质是类的全限定名和签名都必须对得上,任何一处不一致都会在运行时以NoSuchMethodException的形式炸给你看。
4.2 请求ID与CompletableFuture组成的“结果等待室”
动态代理发出请求后,调用线程需要等待结果返回。这里有两个选择:同步阻塞等待,或者异步回调。我第一版选择了同步等待,因为它最容易写——用synchronized加锁,响应回来时notifyAll唤醒。但这样有个问题:多个线程同时发起不同请求时,每个线程都要单独关联自己的响应,用全局锁效率极低。
后来我改成了CompletableFuture方案,这也是现在的主流做法。原理很简单:每个请求发出前,先在内存里放一个ConcurrentHashMap<Long, CompletableFuture<RpcResponse>>,key是请求ID,value是保存响应结果的Future对象。请求发出后,调用线程执行future.get()等待;响应到达时,处理Handler根据请求ID找到对应的Future,调用complete方法把结果填充进去,future.get()立刻返回。
public class PendingRequestManager { private final ConcurrentHashMap<Long, CompletableFuture<RpcResponse>> pendingMap = new ConcurrentHashMap<>(); public CompletableFuture<RpcResponse> createPending(Long requestId) { CompletableFuture<RpcResponse> future = new CompletableFuture<>(); pendingMap.put(requestId, future); return future; } public void complete(Long requestId, RpcResponse response) { CompletableFuture<RpcResponse> future = pendingMap.remove(requestId); if (future != null) { future.complete(response); } } }还有个没解决的问题是超时。如果服务端处理时间很长或者直接崩了,调用线程会永远卡在future.get()上。我给Future设置了超时时间,这样主线程不会无限等待;同时用定时任务定期扫描pendingMap,把超时未完成的Future手动完成一个异常结果。
这里有个我自己调试很久才发现的坑:如果你给
future.get()设置了3秒超时,但之后服务端迟到的响应回来了,你必须把那个corresponding entry从map里删掉,否则map里会堆积大量永远不会被消费的Future对象,形成内存泄漏。所以超时处理不是简单抛个异常就完事,还要做清理工作。
4.3 心跳与断线重连:长连接的生命线
RPC的长连接不代表连接永远活着。物理链路断开、对端进程崩溃、网络超时,都可能让连接变成僵尸。这个时候如果不做心跳检测,客户端发出去的请求会一直得不到响应,只能靠超时兜底,体验很差。
Netty对心跳的支持非常完善。服务端通过IdleStateHandler这个核心组件来做空闲检测,配置一下参数就行:
ch.pipeline().addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));三个参数分别是读空闲、写空闲、全部空闲的时间上限。上面这行配置表示60秒内如果读不到任何数据,就会触发一个IdleStateEvent事件。我写了一个HeartbeatHandler,收到这个事件时主动向客户端发送一个心跳包,客户端收到心跳包后回复一个心跳响应,这样就证明链路是通的。
客户端这边还有个任务:如果一段时间没读到服务端数据,说明连接可能已经断了,这时候要做的是主动重连。很多Netty初学者做的重连方案是在客户端里加一个定时任务,定时检查Channel状态,如果处于非活跃状态就重新connect。这个方案能用,但我更推荐借助Netty的ChannelFutureListener监听连接关闭事件,在连接断开时触发重连逻辑,这样更实时,也不会浪费定时任务资源。
5. 服务注册发现与负载均衡
5.1 写一个最简内存版注册中心:先解决有无问题
刚开始不用引入复杂中间件,用一个ConcurrentHashMap就能实现最简注册中心。这个做法不是偷懒,而是让你专注理解“注册中心到底解决什么问题”这一核心。
我的内存注册中心包含两个核心数据结构:一个服务列表Map,存储服务名到地址列表的映射;一个服务端实例信息Map,存储服务端实例的唯一标识和它的详细元信息。服务端启动时调用注册方法把自己的IP和端口登记进来;客户端发起调用前,通过服务名查询可用的地址列表。
public class InMemoryServiceRegistry implements ServiceRegistry { private final ConcurrentHashMap<String, List<ServiceInstance>> serviceMap = new ConcurrentHashMap<>(); @Override public void register(ServiceInstance instance) { serviceMap.computeIfAbsent( instance.getServiceName(), k -> new CopyOnWriteArrayList<>() ).add(instance); } @Override public List<ServiceInstance> discover(String serviceName) { return serviceMap.getOrDefault(serviceName, Collections.emptyList()); } }注册中心叫“中心”,但真正的作用是扮演一个“目录”或者“通信录”。消费者请求服务时,不是直接把请求发到一个固定的IP,而是先从注册中心拿到当前可用的服务端地址列表,然后从中选一个发起调用。这个模式带来的好处是服务端IP变了、实例多了少了,客户端不需要感知,注册中心负责动态同步。
5.2 服务端掉线了怎么办:心跳续约与剔除机制
内存注册中心最大的缺陷是没有自动感知服务端上线和下线的能力。服务端正常启动时能注册,但它崩溃的那一刻,注册中心是毫不知情的。如果客户端继续请求一个已死实例的地址,会一直超时。
解决思路借鉴了主流框架的做法:租约机制。服务端启动时向注册中心注册一个租约,租约包含过期时间;服务端需要周期性续约(比如每10秒续约一次);注册中心在后台每30秒扫一次租约列表,把超过最多容忍时间没有续约的实例标记为不健康并剔除。这套思路后来我用在内存实现里,虽然功能简单,但机制跟大型注册中心是一脉相承的:
class Lease { ServiceInstance instance; long lastRenewTime; boolean isExpired(long currentTime) { return currentTime - lastRenewTime > MAX_TIMEOUT; } }5.3 负载均衡:从最简单开始,但不能跳过
每次从注册中心拿回来的是一组可用地址,客户端需要选出一个来发请求。如果不做选择,永远只用第一个,那么实例扩容就没有意义了。
最常用的策略有轮询、随机、一致性哈希、最小活跃数、加权轮询几种。作为个人项目,我先实现了轮询和随机两种,然后稍微深入了最小连接数。前两种是一行代码能搞定的策略,我用接口把负载均衡策略抽象出来,未来想加策略直接实现接口就行。
public interface LoadBalancer { ServiceInstance select(List<ServiceInstance> instances); }例如轮询只需要维护一个原子计数器,对实例数量取模即可:
public class RoundRobinLoadBalancer implements LoadBalancer { private final AtomicInteger counter = new AtomicInteger(); @Override public ServiceInstance select(List<ServiceInstance> instances) { if (instances.isEmpty()) return null; int idx = Math.abs(counter.getAndIncrement()) % instances.size(); return instances.get(idx); } }这里有个小坑:取模运算在计数器为负数时结果可能为负,所以一定先取绝对值。这个看似不起眼的错误我真实遇到过——计数器自增到超过整型上限后变负,结果直接数组越界。
6. 上手即遇的坑与排查实录
6.1 一个异常排查了三天:终于找到是序列化字节序问题
第一次完整跑通服务调用后,我信心满满地开始压测。结果发现大概每几十次调用就会莫名其妙地成功返回一个错误的对象——比如请求一个用户对象,拿回来的性别字段变到了名称字段上。最开始我怀疑是缓存的脏数据,后来一步步打印日志才发现,是JDK序列化和自定义缓冲字节序不一致导致的。
JDK原生序列化是Java语言层面统一的,正常不会出现这种问题。但当我尝试切换到一个更高效的序列化方案时,为了压缩数据大小,手动调整了字段写入顺序。一不留神,写入和读取的字段顺序不一致,字段错位,数据自然就乱了。排查过程很难受,因为不是必现,而是随机偶发,跟具体数据内容有关。
这个坑给我最大的教训就是:序列化和反序列化永远是一对,写序列化代码时就必须放着一个反序列化测试在旁边,每改一个字段立即测一次。字节序问题、字段顺序问题、类型不匹配问题,都是靠这个习惯提前暴露出来的。吃序列化的亏,越早吃越好。
6.2 Channel复用:远程调用线程安全常识
我在早期实现中,每来一次RPC调用就新建一个Channel,用完就关闭。这个思路在并发量低的时候看起来没毛病,一旦并发上来,系统就开始疯狂报连接拒绝错误,服务器端的句柄数也快速上涨。
后来我改用连接复用的方式,但马上又遇到另一个问题:同一个Channel里多个请求同时写出时,响应数据怎么对应正确的请求?还好我提前设计了请求ID,通过PendingRequestManager的映射就能关联。如果一个Channel同时承载了100个请求,服务端返回时靠请求ID精确分发到各自的Future上。
所以这里有个重要结论:Channel可以用并发写,但响应分发必须依赖请求ID,不能依赖“先后顺序”来配对。这在异步场景下是完全不可靠的,可能前一个请求还没返回,后一个的响应先到了。
6.3 本地测试服务端只配一个端口,注册中心怎么知道
写本地测试时有一个很容易忽略但影响很大的问题:服务端启动时注册到注册中心的是什么地址?很多人直接写了localhost或127.0.0.1,代码在本机跑没有问题,一旦把服务端部署到另一台机器,注册的IP就错了,客户端根本连不上。
正确的做法是在服务端启动时获取本机在局域网中的真实IP,源码实现上可以通过InetAddress.getLocalHost()来拿,但多网卡环境下这个结果未必准确。更可靠的方式是用NetworkInterface遍历所有非回路网卡,选取第一个正常的IPv4地址:
Enumeration<NetworkInterface> nis = NetworkInterface.getNetworkInterfaces(); while (nis.hasMoreElements()) { NetworkInterface ni = nis.nextElement(); Enumeration<InetAddress> ias = ni.getInetAddresses(); while (ias.hasMoreElements()) { InetAddress ia = ias.nextElement(); if (ia instanceof Inet4Address && ia.isSiteLocalAddress()) { return ia.getHostAddress(); } } }我当时没有做这一步,结果在虚拟机里测试时注册了127.0.0.1,宿主机上的客户端怎么都连不上。这种环境相关的坑,跟代码逻辑没关系,但一样能折磨你一整个下午。提前把获取真实IP的逻辑写对,长期省心。
6.4 测试RPC框架必须做的三件事
写完框架后,验证它能不能扛住基本压力非常关键。我总结了三类测试是必须做的:功能测试、并发测试、断连测试。
功能测试就不多说了,写一个简单的计算器服务或者用户查询服务,验证基本调用链路通不通。并发测试才是真正的试金石:开启100个线程,每线程循环调用1000次,统计成功率和耗时分布。这个测试能一次性暴露序列化并发问题、EventLoop阻塞问题、Channel复用问题。断连测试则是把服务端强制关闭,观察客户端是卡死还是快速报错,心跳检测和超时是否按预期工作。
我第一次做并发测试时成功率只有70%多,大量请求超时,当时一度觉得框架问题严重到没法修。后来一份一份排查才发现,有些是因为序列化对象没加serialVersionUID导致反序列化失败,有些是因为连接池的写入方法没有做并发保护。每个问题排除后,再跑测试的成就感是实实在在的。
7. 写在最后的一点体会
从萌生“手写RPC”的念头到整个项目跑稳,我最大的感受是:Netty的API学起来不难,但“会用的距离”比想象中长。这个项目给了我一个完全不依赖框架源码的机会,逼着我在没有任何现成答案的情况下去设计协议、设计线程模型、设计异常处理路径。
如果你也正打算通过这个项目巩固Netty知识,我建议你别急着在网上找代码抄。先自己画一遍RPC调用链路的图,把每个环节需要的组件列出来,然后逐个实现。抄代码也许半天就能跑通,但自己独立设计到跑通,对Netty和分布式基础知识的理解深度完全不一样。
后续如果还有精力,可以考虑引入这些问题:如何支持异步调用、如何做全链路超时传播、如何用一致性哈希让带有状态的请求始终落到同一台机器、如何用SPI机制做序列化插拔。这些扩展方向每一个都能让项目更完整,也都能加深对Netty某一层面的理解。
第一次完整跑通RPC调用的时候,我看到控制台上打印出服务端返回的普通字符串,整个人是真实地松了一口气——那一瞬间的感觉,比看十篇Netty教程都来得实在。