netty系列之:中國加油

flydean發表於2021-08-09

簡介

之前的系列文章中我們學到了netty的基本結構和工作原理,各位小夥伴一定按捺不住心中的喜悅,想要開始手寫程式碼來體驗這神奇的netty框架了,剛好最近東京奧運會,我們寫一個netty的客戶端和伺服器為中國加油可好?

場景規劃

那麼我們今天要搭建什麼樣的系統呢?

首先要搭建一個server伺服器,用來處理所有的netty客戶的連線,並對客戶端傳送到伺服器的訊息進行處理。

還要搭建一個客戶端,這個客戶端負責和server伺服器建立連線,併傳送訊息給server伺服器。在今天的例子中,客戶端在建立連線過後,會首先傳送一個“中國”訊息給伺服器,然後伺服器收到訊息之後再返回一個”加油!“ 訊息給客戶端,然後客戶端收到訊息之後再傳送一個“中國”訊息給伺服器.... 以此往後,迴圈反覆直到奧運結束!

我們知道客戶端和伺服器端進行訊息處理都是通過handler來進行的,在handler裡面,我們可以重寫channelRead方法,這樣在讀取channel中的訊息之後,就可以對訊息進行處理了,然後將客戶端和伺服器端的handler配置在Bootstrap中啟動就可以了,是不是很簡單?一起來做一下吧。

啟動Server

假設server端的handler叫做CheerUpServerHandler,我們使用ServerBootstrap構建兩個EventLoopGroup來啟動server,有看過本系列最前面文章的小夥伴可能知道,對於server端需要啟動兩個EventLoopGroup,一個bossGroup,一個workerGroup,這兩個group是父子關係,bossGroup負責處理連線的相關問題,而workerGroup負責處理channel中的具體訊息。

啟動服務的程式碼千篇一律,如下所示:

 // Server配置
        //boss loop
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        //worker loop
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        final CheerUpServerHandler serverHandler = new CheerUpServerHandler();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
              // tcp/ip協議listen函式中的backlog引數,等待連線池的大小
             .option(ChannelOption.SO_BACKLOG, 100)
              //日誌處理器
             .handler(new LoggingHandler(LogLevel.INFO))
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 //初始化channel,新增handler
                 public void initChannel(SocketChannel ch) throws Exception {
                     ChannelPipeline p = ch.pipeline();
                     //日誌處理器
                     p.addLast(new LoggingHandler(LogLevel.INFO));
                     p.addLast(serverHandler);
                 }
             });

            // 啟動伺服器
            ChannelFuture f = b.bind(PORT).sync();

            // 等待channel關閉
            f.channel().closeFuture().sync();

不同的服務,啟動伺服器的程式碼基本都是一樣的,這裡我們需要注意這幾點。

在ServerBootstrap中,我們加入了一個選項:ChannelOption.SO_BACKLOG,ChannelOption.SO_BACKLOG對應的是tcp/ip協議listen(int socketfd,int backlog)函式中的backlog引數,用來初始化服務端可連線佇列,backlog引數指定了這個佇列的大小。因為對於一個連線來說,處理客戶端連線請求是順序處理的,所以同一時間只能處理一個客戶端連線,多個客戶端來的時候,服務端將不能處理的客戶端連線請求放在佇列中等待處理,

另外我們還新增了兩個LoggingHandler,一個是給handler新增的,一個是給childHandler新增的。LoggingHandler主要監控channel中的各種事件,然後輸出對應的訊息,非常好用。

比如在伺服器啟動的時候會輸出下面的日誌:

 [nioEventLoopGroup-2-1] INFO  i.n.handler.logging.LoggingHandler - [id: 0xd9b41ea4] REGISTERED
 [nioEventLoopGroup-2-1] INFO  i.n.handler.logging.LoggingHandler - [id: 0xd9b41ea4] BIND: 0.0.0.0/0.0.0.0:8007
 [nioEventLoopGroup-2-1] INFO  i.n.handler.logging.LoggingHandler - [id: 0xd9b41ea4, L:/0:0:0:0:0:0:0:0:8007] ACTIVE

這個日誌是第一個LoggingHandler輸出的,分別代表了伺服器端的REGISTERED、BIND和ACTIVE事件。從輸出我們可以看到,伺服器本身繫結的是0.0.0.0:8007。

在客戶端啟動和伺服器端建立連線的時候會輸出下面的日誌:

[nioEventLoopGroup-2-1] INFO  i.n.handler.logging.LoggingHandler - [id: 0x37a4ba9f, L:/0:0:0:0:0:0:0:0:8007] READ: [id: 0x6dcbae9c, L:/127.0.0.1:8007 - R:/127.0.0.1:54566]
[nioEventLoopGroup-2-1] INFO  i.n.handler.logging.LoggingHandler - [id: 0x37a4ba9f, L:/0:0:0:0:0:0:0:0:8007] READ COMPLETE

上面日誌表示READ和READ COMPLETE兩個事件,其中 L:/127.0.0.1:8007 - R:/127.0.0.1:54566 代表本地伺服器的8007埠連線了客戶端的54566埠。

對於第二個LoggingHandler來說,會輸出一些具體的訊息處理相關的訊息。比如REGISTERED、ACTIVE、READ、WRITE、FLUSH、READ COMPLETE等事件,這裡面就不一一列舉了。

啟動客戶端

同樣的,假設客戶端的handler名稱叫做ChinaClientHandler,那麼可以類似啟動server一樣啟動客戶端,如下:

// 客戶端的eventLoop
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.TCP_NODELAY, true)
             .handler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 public void initChannel(SocketChannel ch) throws Exception {
                     ChannelPipeline p = ch.pipeline();
                     //新增日誌處理器
                     p.addLast(new LoggingHandler(LogLevel.INFO));
                     p.addLast(new ChinaClientHandler());
                 }
             });
            // 啟動客戶端
            ChannelFuture f = b.connect(HOST, PORT).sync();

客戶端啟動使用的是Bootstrap,我們同樣為他配置了一個LoggingHandler,並新增了自定義的ChinaClientHandler。

訊息處理

我們知道有兩種handler,一種是inboundHandler,一種是outboundHandler,這裡我們是要監控從socket讀取資料的事件,所以這裡客戶端和伺服器端的handler都繼承自ChannelInboundHandlerAdapter即可。

訊息處理的流程是客戶端和伺服器建立連線之後,會首先傳送一個”中國“的訊息給伺服器。

客戶端和伺服器建立連線之後,會觸發channelActive事件,所以在客戶端的handler中就可以傳送訊息了:

    public void channelActive(ChannelHandlerContext ctx) {
        ctx.writeAndFlush("中國");
    }

伺服器端在從channel中讀取訊息的時候會觸發channelRead事件,所以伺服器端的handler可以重寫channelRead方法:

    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        log.info("收到訊息:{}",msg);
        ctx.writeAndFlush("加油!");
    }

然後客戶端從channel中讀取到"加油!"之後,再將”中國“寫到channel中,所以客戶端也需要重寫方法channelRead:

    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ctx.writeAndFlush("中國");
    }

這樣是不是就可以迴圈往復的進行下去了呢?

訊息處理中的陷阱

事實上,當你執行上面程式碼你會發現,客戶端確實將”中國“ 訊息寫入了channel,但是伺服器端的channelRead並沒有被觸發。為什麼呢?

研究發下,如果寫入的物件是一個String,程式內部會有這樣的錯誤,但是這個錯誤是隱藏的,你並不會在執行的程式輸出中看到,所以對新手小夥伴還是很不友好的。這個錯誤就是:

DefaultChannelPromise@57f5c075(failure: java.lang.UnsupportedOperationException: unsupported message type: String (expected: ByteBuf, FileRegion))

從錯誤的資訊可以看出,目前支援的訊息型別有兩種,分別是ByteBuf和FileRegion。

好了,我們將上面的訊息型別改成ByteBuf試一試:

        message = Unpooled.buffer(ChinaClient.SIZE);
        message.writeBytes("中國".getBytes(StandardCharsets.UTF_8));
        
        public void channelActive(ChannelHandlerContext ctx) {
        log.info("可讀位元組:{},index:{}",message.readableBytes(),message.readerIndex());
        log.info("可寫位元組:{},index:{}",message.writableBytes(),message.writerIndex());
        ctx.writeAndFlush(message);
    }

上面我們定義了一個ByteBuf的全域性message物件,並將其傳送給server,然後在server端讀取到訊息之後,再傳送一個ByteBuf的全域性message物件給client,如此迴圈往復。

但是當你執行上面的程式之後會發現,伺服器端確實收到了”中國“,客戶端也確實收到了”加油!“,但是客戶端後續傳送的”中國“訊息伺服器端卻收不到了,怎麼回事呢?

我們知道ByteBuf有readableBytes、readerIndex、writableBytes、writerIndex、capacity和refCnt等屬性,我們將這些屬性在message傳送前和傳送之後進行對比:

在訊息傳送之前:

可讀位元組:6,readerIndex:0
可寫位元組:14,writerIndex:6
capacity:20,refCnt:1

在訊息傳送之後:

可讀位元組:6,readerIndex:0
可寫位元組:-6,writerIndex:6
capacity:0,refCnt:0

於是問題找到了,由於ByteBuf在處理過一次之後,refCnt變成了0,所以無法繼續再次重複寫入,怎麼解決呢?

簡單的辦法就是每次傳送的時候再重新new一個ByteBuf,這樣就沒有問題了。

但是每次都新建一個物件好像有點浪費空間,怎麼辦呢?既然refCnt變成了0,那麼我們呼叫ByteBuf中的retain()方法增加refCnt不就行了?

答案就是這樣,但是要注意,需要在傳送之前呼叫retain()方法,如果是在訊息被處理過後呼叫retain()會報異常。

總結

好了,執行上面的程式就可以一直給中國加油了,YYDS!

本文的例子可以參考:learn-netty4

本文已收錄於 http://www.flydean.com/06-netty-cheerup-china/

最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!

歡迎關注我的公眾號:「程式那些事」,懂技術,更懂你!

相關文章