广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在 Spring Boot 中使用 Netty 构建自定义 RPC 通信框架?

在 Spring Boot 微服务架构中,RPC 远程调用通常被理解为带服务发现与负载均衡的完整框架,但真正的通信核心并不复杂:客户端将方法调用信息封装成二进制数据,服务端解析后反射执行并返回结果。Netty 作为高性能网络框架,天然适合做这件事。本文会从零开始实现一个自定义 RPC 框架,重点说明通信协议、编解码、请求关联和 Spring Boot 启动方式,让读者既能理解原理,也能获得一套可继续扩展的代码骨架。

如何在 Spring Boot 中使用 Netty 构建自定义 RPC 通信框架?

一、为什么用 Netty 替代传统 HTTP 调用

很多内部服务之间的调用使用 HTTP 加 JSON,优点是调试方便、跨语言友好,但每次请求通常需要重新建立连接或依赖线程池管理短连接,并且 JSON 文本序列化会产生较多字节。Netty 基于 NIO 多路复用,可以用更少的线程支持大量长连接,配合自定义二进制协议,可显著降低序列化体积和 CPU 占用。更重要的是,Netty 把连接、编解码、业务处理拆成独立的 ChannelHandler,开发者可以按需插入心跳、限流、告警等逻辑,而不用修改 HTTP 容器配置。

在 Spring Boot 项目中引入 Netty 并不需要替换 Web 容器,只需要额外监听一个 TCP 端口。RPC 服务端启动时通过 ServerBootstrap 绑定端口,客户端通过 Bootstrap 连接服务端。Netty 的 bossGroup 负责 accept 连接,workerGroup 负责后续读写,默认线程数可以为 CPU 核数的 2 倍,但内部 RPC 场景可根据 IO 密集程度调整。下面是一个典型的服务端启动代码:

@Component
public class RpcServer {
    private final EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    private final EventLoopGroup workerGroup = new NioEventLoopGroup();
    public void start(int port) throws InterruptedException {
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ch.pipeline()
                              .addLast(new LengthFieldBasedFrameDecoder(65535, 0, 4, 0, 4))
                              .addLast(new RpcDecoder())
                              .addLast(new RpcEncoder())
                              .addLast(new RpcServerHandler());
                        }
                    })
                    .option(ChannelOption.SO_BACKLOG, 128)
                    .childOption(ChannelOption.TCP_NODELAY, true);
            ChannelFuture future = bootstrap.bind(port).sync();
            future.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

二、消息协议与编解码器怎么设计

自定义 RPC 协议至少要回答两个问题:如何切分一条完整消息,以及如何把 Java 对象转成字节。TCP 是字节流,多个请求包粘在一起或一个请求被拆成多个包是常见现象。Netty 提供的 LengthFieldBasedFrameDecoder 可以根据报文头中的长度字段自动切分数据帧,代码里配置为前 4 字节表示后续长度,适合大多数自定义协议。

协议头可以设计为 16 字节固定长度:魔数、版本、消息类型、序列化类型、请求 ID、正文长度。魔数用于快速识别非法请求,版本用于后续协议升级,请求 ID 用于关联客户端与响应。正文序列化建议从 JSON 起步,后续替换为 Kryo 或 Protobuf,编解码器只需根据 serializationType 选择对应实现。

下面先定义消息结构:

public class RpcMessage {
    private short magic = 0x4E52;
    private byte version = 1;
    private byte messageType;
    private byte serializationType;
    private long requestId;
    private int length;
    private Object data;
    // getter / setter 省略
}

解码流程通常为:读取 16 字节头,校验魔数,读取长度后检查可读字节是否足够,不够则重置读索引等待更多数据,足够则读取 body 并反序列化。这个模式可以避免半包读到不完整消息导致解析错误。

public class RpcDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        if (in.readableBytes() < 16) {
            return;
        }
        in.markReaderIndex();
        short magic = in.readShort();
        if (magic != 0x4E52) {
            ctx.close();
            return;
        }
        byte version = in.readByte();
        byte messageType = in.readByte();
        byte serializationType = in.readByte();
        long requestId = in.readLong();
        int length = in.readInt();
        if (in.readableBytes() < length) {
            in.resetReaderIndex();
            return;
        }
        byte[] body = new byte[length];
        in.readBytes(body);
        Object data = Serializer.deserialize(body, serializationType);
        RpcMessage message = new RpcMessage();
        message.setMagic(magic);
        message.setVersion(version);
        message.setMessageType(messageType);
        message.setSerializationType(serializationType);
        message.setRequestId(requestId);
        message.setLength(length);
        message.setData(data);
        out.add(message);
    }
}

编码器相对简单,先写消息头,再写序列化后的 body。需要注意 writeInt(length) 和实际字节数保持一致,并且每个 write 操作后正确释放或复用 ByteBuf。

三、服务注册与客户端动态代理

服务端需要维护一个服务名到实例的映射表。通过 Spring 的 BeanPostProcessor 扫描带有 @RpcService 注解的 Bean,启动时自动注册,这样新增服务只需要在实现类上添加注解,不用手工写 Map。收到请求后,处理器根据 RpcRequest 中的 serviceName 找到实例,再通过反射调用方法。反射调用是同步的,建议将业务处理提交到独立线程池,避免阻塞 Netty 的 IO 线程。

客户端动态代理是 RPC 框架最常用的入口。JDK 的 Proxy 可以在运行时生成接口实现,当业务代码调用接口方法时,代理对象把方法名、参数类型、参数值封装成 RpcRequest,通过 Netty 通道发送给服务端。每个请求带唯一 requestId,客户端维护一个 ConcurrentHashMap 保存 requestId 到 CompletableFuture 的映射。服务端响应后,客户端 handler 根据 requestId 找到对应 Future 并 complete,这样代理方法就能返回结果。相比同步阻塞等待,这种异步关联方式可以避免线程被大量挂起。

public class RpcServerHandler extends SimpleChannelInboundHandler<RpcMessage> {
    private final Map<String, Object> serviceMap;
    public RpcServerHandler(Map<String, Object> serviceMap) {
        this.serviceMap = serviceMap;
    }
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, RpcMessage msg) throws Exception {
        RpcRequest request = (RpcRequest) msg.getData();
        Object service = serviceMap.get(request.getServiceName());
        Method method = service.getClass().getMethod(request.getMethodName(), request.getParameterTypes());
        Object result = method.invoke(service, request.getParameters());
        RpcResponse response = new RpcResponse();
        response.setRequestId(request.getRequestId());
        response.setResult(result);
        RpcMessage responseMsg = new RpcMessage();
        responseMsg.setMessageType((byte) 2);
        responseMsg.setRequestId(request.getRequestId());
        responseMsg.setData(response);
        ctx.writeAndFlush(responseMsg);
    }
}

客户端代理示例:

public class RpcClientProxy implements InvocationHandler {
    private final RpcClient client;
    public RpcClientProxy(RpcClient client) {
        this.client = client;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        RpcRequest request = new RpcRequest();
        request.setServiceName(method.getDeclaringClass().getName());
        request.setMethodName(method.getName());
        request.setParameterTypes(method.getParameterTypes());
        request.setParameters(args);
        return client.send(request).get(5, TimeUnit.SECONDS);
    }
    @SuppressWarnings("unchecked")
    public <T> T getProxy(Class<T> clazz) {
        return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz}, this);
    }
}

四、Spring Boot 集成与实际调用验证

集成到 Spring Boot 时,可以用一个配置类创建 RpcServer 和 RpcClient 的 Bean,并在启动后开启服务端监听。服务端一般放在独立的线程中启动,避免阻塞 Spring Boot 主线程;客户端则可以在首次调用时建立连接,也可以使用连接池管理多个通道。为了简化,本文的客户端使用单个连接,通过长连接连续发送请求。

验证时定义两个接口:一个返回固定字符串,另一个接收参数并返回计算结果。服务端注册好实现类,客户端注入代理对象后直接调用。观察日志可以看到请求 ID、方法名和响应时间。如果出现调用超时,优先检查 LengthFieldBasedFrameDecoder 的长度偏移是否与编码器一致;如果出现响应错乱,则要检查 requestId 是否在编解码时正确传输。这些细节看似简单,但实际排查时往往最耗时。

@Configuration
public class RpcAutoConfiguration {
    @Bean(destroyMethod = "stop")
    public RpcServer rpcServer() {
        return new RpcServer();
    }
}

五、性能优化与常见问题

Netty RPC 的性能优化主要从几方面入手。第一,启用 TCP_NODELAY,关闭 Nagle 算法,降低小包延迟。第二,合理设置 workerGroup 线程数,不要盲目调大,线程过多会导致上下文切换增加。第三,考虑使用对象池和堆外内存减少 GC 压力,Netty 的 PooledByteBufAllocator 已经提供较好默认值,需要时可以显式配置。第四,序列化格式对性能影响很大,JSON 适合调试,Protobuf 或 Kryo 更适合生产环境,但更换序列化时要保证客户端和服务端版本一致。

常见问题包括:粘包半包导致解析异常、忘记释放 ByteBuf 导致内存泄漏、回调长时间不返回导致 Future 堆积、channel 空闲没有心跳被网关断开。解决这些问题需要在 pipeline 中加入心跳 handler、设置合理的超时时间,并为 pending 请求设置清理机制。只有把这些边界情况处理好,自定义 RPC 框架才能稳定运行。

广告位 · 文章底部(后台-模块管理替换此区域)