2、Netty环境搭建与第一个程序:Maven依赖配置,编写一个简单的Echo服务器与客户端

好,咱们正式开始动手了。这一章的目标很简单——把Netty跑起来。我见过太多人一上来就啃源码,结果连个Hello World都跑不通,信心直接崩了。所以咱们先搭环境,写一个Echo程序,让数据在客户端和服务器之间打个来回。你亲自跑通一次,后面学什么都踏实。

2.1 Maven依赖配置

我个人习惯用Maven管理项目。Netty的依赖其实不复杂,但版本选择有讲究。我早期踩过一个坑——用了4.0.x的旧版本,结果跟JDK8的新特性冲突,调试了一下午。所以这里我直接给你推荐稳定版本。

pom.xml 里加入以下依赖:

<dependencies>
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.100.Final</version>
    </dependency>
</dependencies>

为什么用 netty-all?说白了就是省事。它把Netty的核心模块、编解码器、传输层实现都打包在一起了。你刚开始学,没必要纠结哪个模块该引哪个不该引,一个 netty-all 搞定所有。

小提示: 如果你用的是JDK11以上,建议选4.1.80.Final之后的版本。这些版本对模块化系统支持更好,不会报什么模块访问警告。

2.2 编写Echo服务器

Echo服务器,说白了就是客户端发什么,服务器原样返回什么。听起来简单,但它能帮你理解Netty最核心的「事件驱动」模型。

先看服务器端的启动代码:

public class EchoServer {
    private final int port;

    public EchoServer(int port) {
        this.port = port;
    }

    public void start() throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 public void initChannel(SocketChannel ch) {
                     ch.pipeline().addLast(new EchoServerHandler());
                 }
             });

            ChannelFuture f = b.bind(port).sync();
            System.out.println("服务器启动,端口:" + port);
            f.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws Exception {
        new EchoServer(8080).start();
    }
}

这里有两个 EventLoopGroup,你可能会问:为什么需要两个?我刚开始学的时候也纳闷。其实 bossGroup 只负责接收新连接,workerGroup 负责处理已连接的读写操作。你想想看,如果只有一个线程组,既要接客又要干活,高并发下肯定忙不过来。

接下来是处理器:

public class EchoServerHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf in = (ByteBuf) msg;
        System.out.println("收到客户端消息: " + in.toString(CharsetUtil.UTF_8));
        ctx.write(in); // 把消息原样写回去
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush(); // 刷新缓冲区,把数据发出去
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}
重点: channelRead 里我直接调用了 ctx.write(in),注意这里没有释放 ByteBuf。为什么?因为Netty会在写入完成后自动帮你释放。如果你手动调了 ReferenceCountUtil.release(),反而会出问题。我曾经在这个细节上栽过跟头,排查了半天才发现是重复释放导致的。

2.3 编写Echo客户端

客户端跟服务器结构类似,但启动方式不同。客户端不需要 ServerBootstrap,而是用 Bootstrap

public class EchoClient {
    private final String host;
    private final int port;

    public EchoClient(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public void start() throws Exception {
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .handler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 public void initChannel(SocketChannel ch) {
                     ch.pipeline().addLast(new EchoClientHandler());
                 }
             });

            ChannelFuture f = b.connect(host, port).sync();
            System.out.println("已连接到服务器");
            f.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws Exception {
        new EchoClient("127.0.0.1", 8080).start();
    }
}

客户端的处理器稍微复杂一点,因为它要主动发消息:

public class EchoClientHandler extends ChannelInboundHandlerAdapter {
    private final ByteBuf message;

    public EchoClientHandler() {
        message = Unpooled.copiedBuffer("你好,Netty!", CharsetUtil.UTF_8);
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        System.out.println("连接建立,发送消息...");
        ctx.writeAndFlush(message);
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf in = (ByteBuf) msg;
        System.out.println("收到服务器回显: " + in.toString(CharsetUtil.UTF_8));
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

注意 channelActive 这个方法。它会在TCP连接建立成功后立即被调用。我习惯在这里发送第一条消息,这样能最快验证整个链路是否通畅。

2.4 运行与验证

先启动服务器,再启动客户端。你会看到控制台输出类似这样的内容:

服务器端:

服务器启动,端口:8080
收到客户端消息: 你好,Netty!

客户端:

已连接到服务器
连接建立,发送消息...
收到服务器回显: 你好,Netty!
避坑指南: 如果你运行时报 java.net.BindException: Address already in use,说明端口被占用了。检查一下是不是之前启动的服务器没关掉。我遇到过最离谱的一次,是IDE里同时跑了两个实例,端口冲突了半小时才反应过来。

2.5 代码结构总结

咱们把整个流程梳理一下,方便你对照:

组件 作用 我的建议
EventLoopGroup 管理线程池,处理I/O事件 服务器用两个Group,客户端用一个
ServerBootstrap / Bootstrap 启动辅助类,配置参数 注意区分服务端和客户端的启动方式
ChannelInitializer 初始化管道,添加处理器 每个新连接都会执行一次
ChannelHandler 处理业务逻辑 注意ByteBuf的释放时机

嗯,到这里你的第一个Netty程序就跑通了。别看它简单,这个Echo模型是所有RPC框架的基础。后面咱们讲编解码、粘包拆包,都是在这个结构上做扩展。你先把这段代码敲一遍,跑通了再往下走。

下一章,咱们聊聊Netty的线程模型——为什么它能支撑百万级并发?这里面的门道,可比你想象的有意思多了。