Springboot 整合 Netty 實戰

pjmike_pj發表於2018-10-28

前言

這一篇文章主要介紹如何用Springboot 整合 Netty,由於本人尚處於學習Netty的過程中,並沒有將Netty 運用到實際生產專案的經驗,這裡也是在網上搜尋了一些Netty例子學習後總結來的,借鑑了他人的寫法和經驗。如有重複部分,還請見諒。

關於SpringBoot 如何整合使用 Netty ,我將分為以下幾步進行分析與討論:

  • 構建Netty 服務端
  • 構建Netty 客戶端
  • 利用protobuf定義訊息格式
  • 服務端空閒檢測
  • 客戶端傳送心跳包與斷線重連

PS: 我這裡為了簡單起見(主要是懶),將 Netty 服務端與客戶端放在了同一個SpringBoot工程裡,當然也可以將客戶端和服務端分開。

構建 Netty 服務端

Netty 服務端的程式碼其實比較簡單,程式碼如下:

@Component
@Slf4j
public class NettyServer {
    /**
     * boss 執行緒組用於處理連線工作
     */
    private EventLoopGroup boss = new NioEventLoopGroup();
    /**
     * work 執行緒組用於資料處理
     */
    private EventLoopGroup work = new NioEventLoopGroup();
    @Value("${netty.port}")
    private Integer port;
    /**
     * 啟動Netty Server
     *
     * @throws InterruptedException
     */
    @PostConstruct
    public void start() throws InterruptedException {
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(boss, work)
                // 指定Channel
                .channel(NioServerSocketChannel.class)
                //使用指定的埠設定套接字地址
                .localAddress(new InetSocketAddress(port))

                //服務端可連線佇列數,對應TCP/IP協議listen函式中backlog引數
                .option(ChannelOption.SO_BACKLOG, 1024)

                //設定TCP長連線,一般如果兩個小時內沒有資料的通訊時,TCP會自動傳送一個活動探測資料包文
                .childOption(ChannelOption.SO_KEEPALIVE, true)

                //將小的資料包包裝成更大的幀進行傳送,提高網路的負載,即TCP延遲傳輸
                .childOption(ChannelOption.TCP_NODELAY, true)

                .childHandler(new NettyServerHandlerInitializer());
        ChannelFuture future = bootstrap.bind().sync();
        if (future.isSuccess()) {
            log.info("啟動 Netty Server");
        }
    }

    @PreDestroy
    public void destory() throws InterruptedException {
        boss.shutdownGracefully().sync();
        work.shutdownGracefully().sync();
        log.info("關閉Netty");
    }
}

複製程式碼

因為我們在springboot 專案中使用 Netty ,所以我們將Netty 伺服器的啟動封裝在一個 start()方法,並使用 @PostConstruct註解,在指定的方法上加上 @PostConstruct註解來表示該方法在 Spring 初始化 NettyServer類後呼叫。

考慮到使用心跳機制等操作,關於ChannelHandler邏輯處理鏈的部分將在後面進行闡述。

構建 Netty 客戶端

Netty 客戶端程式碼與服務端類似,程式碼如下:

@Component
@Slf4j
public class NettyClient  {
    private EventLoopGroup group = new NioEventLoopGroup();
    @Value("${netty.port}")
    private int port;
    @Value("${netty.host}")
    private String host;
    private SocketChannel socketChannel;

    public void sendMsg(MessageBase.Message message) {
        socketChannel.writeAndFlush(message);
    }

    @PostConstruct
    public void start()  {
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(group)
                .channel(NioSocketChannel.class)
                .remoteAddress(host, port)
                .option(ChannelOption.SO_KEEPALIVE, true)
                .option(ChannelOption.TCP_NODELAY, true)
                .handler(new ClientHandlerInitilizer());
        ChannelFuture future = bootstrap.connect();
        //客戶端斷線重連邏輯
        future.addListener((ChannelFutureListener) future1 -> {
            if (future1.isSuccess()) {
                log.info("連線Netty服務端成功");
            } else {
                log.info("連線失敗,進行斷線重連");
                future1.channel().eventLoop().schedule(() -> start(), 20, TimeUnit.SECONDS);
            }
        });
        socketChannel = (SocketChannel) future.channel();
    }
}
複製程式碼

上面還包含了客戶端斷線重連的邏輯,更多細節問題,將在下面進行闡述。

使用 protobuf 構建通訊協議

在整合使用 Netty 的過程中,我們使用 Google 的protobuf定義訊息格式,下面來簡單介紹下 protobuf

protobuf簡介

Google 官方給 protobuf的定義如下:

Protocol Buffers 是一種輕便高效的結構化資料儲存格式,可以用於結構化資料序列化,很適合做資料儲存或 RPC 資料交換格式。它可用於通訊協議、資料儲存等領域的語言無關、平臺無關、可擴充套件的序列化結構資料格式。

在 Netty 中常用 protobuf 來做序列化方案,當然也可以用 protobuf來構建 客戶端與服務端之間的通訊協議

為什麼要用protobuf

我們這裡是用 protobuf 做為我們的序列化手段,那我們為什麼要使用 protobuf,而不使用其他序列化方案呢,比如 jdk 自帶的序列化,Thrift,fastjson等。

首先 jdk 自帶序列化手段有很多缺點,比如:

  • 序列化後的碼流太大
  • 效能太低
  • 無法跨語言

而 Google Protobuf 跨語言,支援C++、java和python。然後利用protobuf 編碼後的訊息更小,有利於儲存和傳輸,並且其效能也非常高,相比其他序列化框架,它也是非常有優勢的,具體的關於Java 各種序列化框架比較此處就不多說了。總之,目前Google Protobuf 廣泛的被使用到各種專案,它的諸多優點讓我們選擇使用它。

怎麼使用protobuf

對於 Java 而言,使用 protobuf 主要有以下幾步:

  • .proto 檔案中定義訊息格式
  • 使用 protobuf 編譯器編譯 .proto檔案 成 Java 類
  • 使用 Java 對應的 protobuf API來寫或讀訊息

定義 protobuf 協議格式

這裡為我Demo裡的 message.proto檔案為例,如下:

//protobuf語法有 proto2和proto3兩種,這裡指定 proto3
syntax = "proto3"; 
// 檔案選項
option java_package = "com.pjmike.server.protocol.protobuf";
option java_outer_classname = "MessageBase";
// 訊息模型定義
message Message {
    string requestId = 1;
    CommandType cmd = 2;
    string content = 3;
    enum CommandType {
        NORMAL = 0; //常規業務訊息
        HEARTBEAT_REQUEST = 1; //客戶端心跳訊息
        HEARTBEAT_RESPONSE = 2; //服務端心跳訊息
    }
}
複製程式碼

檔案解讀:

  • 文中的第一行指定正在使用 proto3語法,如果沒有指定,編譯器預設使用 proto2的語法。現在新專案中可能一般多用 proto3的語法,proto3proto2支援更多的語言但更簡潔。如果首次使用 protobuf,可以選擇使用 proto3
  • 定義 .proto檔案時,可以標註一系列的選項,一些選項是檔案級別的,比如上面的第二行和第三行,java_package檔案選項表明protocol編譯器編譯 .proto檔案生成的 Java 類所在的包,java_outer_classname選項表明想要生成的 Java 類的名稱
  • Message中定義了具體的訊息格式,我這裡定義了三個欄位,每個欄位都有唯一的一個數字識別符號,這些識別符號用來在訊息的二進位制格式中識別各個欄位的
  • Message中還新增了一個列舉型別,該列舉中含有型別 CommandType中所有的值,每個列舉型別必須將其第一個型別對映為 0,該0值為預設值。

訊息模型定義

關於訊息格式,此處我只是非常非常簡單的定義了幾個欄位,requestId代表訊息Id,CommandType表示訊息的型別,這裡簡單分為心跳訊息型別和業務訊息型別,然後content就是具體的訊息內容。這裡的訊息格式定義是十分簡陋,真正的專案實戰中,關於自定義訊息格式的要求是非常多的,是比較複雜的。

上面簡單的介紹了 protobuf的一些語法規則,關於 protobuf語法的更多介紹參考官方文件:developers.google.com/protocol-bu…

使用 .proto編譯器編譯

第一步已經定義好了 protobuf的訊息格式,然後我們用 .proto檔案的編譯器將我們定義的 訊息格式編譯生成對應的 Java類,以便於我們在專案中使用該訊息類。

關於protobuf編譯器的安裝這裡我就不細說,詳情見官方文件: developers.google.com/protocol-bu…

安裝好編譯器以後,使用以下命令編譯.proto檔案:

protoc -I = ./ --java_out=./ ./Message.proto
複製程式碼
  • -I 選項用於指定待編譯的 .proto訊息定義檔案所在的目錄,該選項也可以寫作為 --proto_path
  • --java_out選項表示生成 Java程式碼後存放位置,對於不同語言,我們的選項可能不同,比如生成C++程式碼為 --cpp_out
  • 在前兩個選項後再加上 待編譯的訊息定義檔案

使用 Java 對應 的 protobuf API來讀寫訊息

前面已經根據 .proto訊息定義檔案生成的Java類,我們這裡程式碼根據 Message.proto生成了MessageBase類,但是要正常的使用生成的 Java 類,我們還需要引入 protobuf-java的依賴:

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.5.1</version>
</dependency>
複製程式碼

使用 protobuf 生成的每一個 Java類中,都會包含兩種內部類:Msg 和 Msg 包含的 Builder(這裡的Msg就是實際訊息傳輸類)。具體是.proto中定義的每一個message 都會生成一個 Msg,每一個Msg對應一個 Builder:

  • Buidler提供了構建類,查詢類的API
  • Msg提供了查詢,序列化,反序列化的API

比如我們使用 Builder來構建 Msg,例子如下:

public class MessageBaseTest {
    public static void main(String[] args) {
        MessageBase.Message message = MessageBase.Message.newBuilder()
                .setRequestId(UUID.randomUUID().toString())
                .setContent("hello world").build();
        System.out.println("message: "+message.toString());
    }
}
複製程式碼

這裡就不多介紹protobuf-java API的相關用法了,更多詳情還是參考官方文件:developers.google.com/protocol-bu…

protobuf的編解碼器

上面說了這麼多,訊息傳輸格式已經定義好了,但是在客戶端和服務端傳輸過程中我們還需要對這種 protobuf格式進行編解碼,當然我們可以自定義訊息的編解碼,protobuf-java 的API中提供了相關的序列化和反序列化方法。好訊息是,Netty 為了支援 protobuf提供了針對 protobuf的編解碼器,如下表所示(摘自《Netty實戰》) :

名稱 描述
ProtobufDecoder 使用 protobuf 對訊息進行解碼
ProtobufEncoder 使用 protobuf 對訊息進行編碼
ProtobufVarint32FrameDecoder 根據訊息中的 Google Protocol Buffers 的 “Base 128 Varint" 整型長度欄位值動態地分割所接收到的 ByteBuf
ProtobufVarint32LengthFieldPrepender 向 ByteBuf 前追加一個Google Protocol Buffers 的 “Base 128 Varint" 整型長度欄位值

有了這些編解碼器,將其加入客戶端和服務端的 ChannelPipeline中以用於對訊息進行編解碼,如下:

public class NettyServerHandlerInitializer extends ChannelInitializer<Channel> {

    @Override
    protected void initChannel(Channel ch) throws Exception {
        ch.pipeline()
                //空閒檢測
                .addLast(new ServerIdleStateHandler())
                .addLast(new ProtobufVarint32FrameDecoder())
                .addLast(new ProtobufDecoder(MessageBase.Message.getDefaultInstance()))
                .addLast(new ProtobufVarint32LengthFieldPrepender())
                .addLast(new ProtobufEncoder())
                .addLast(new NettyServerHandler());
    }
}
複製程式碼

客戶端心跳機制

心跳機制簡介

心跳是在TCP長連線中,客戶端與服務端之間定期傳送的一種特殊的資料包,通知對方線上以確保TCP連線的有效性。

如何實現心跳機制

有兩種方式實現心跳機制:

  • 使用TCP協議層面的 keepalive 機制
  • 在應用層上自定義的心跳機制

TCP層面的 keepalive 機制我們在之前構建 Netty服務端和客戶端啟動過程中也有定義,我們需要手動開啟,示例如下:

// 設定TCP的長連線,預設的 keepalive的心跳時間是兩個小時
childOption(ChannelOption.SO_KEEPALIVE, true)
複製程式碼

除了開啟 TCP協議的 keepalive 之外,在我研究了github的一些開源Demo發現,人們往往也會自定義自己的心跳機制,定義心跳資料包。而Netty也提供了 IdleStateHandler 來實現心跳機制

Netty 實現心跳機制

下面來看看客戶端如何實現心跳機制:

@Slf4j
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent idleStateEvent = (IdleStateEvent) evt;
            if (idleStateEvent.state() == IdleState.WRITER_IDLE) {
                log.info("已經10s沒有傳送訊息給服務端");
                //向服務端送心跳包
                //這裡使用 protobuf定義的訊息格式
                MessageBase.Message heartbeat = new MessageBase.Message().toBuilder().setCmd(MessageBase.Message.CommandType.HEARTBEAT_REQUEST)
                        .setRequestId(UUID.randomUUID().toString())
                        .setContent("heartbeat").build();
                //傳送心跳訊息,並在傳送失敗時關閉該連線
                ctx.writeAndFlush(heartbeat).addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
            }
        } else {
            super.userEventTriggered(ctx, evt);
        }
    }
}
複製程式碼

我們這裡建立了一個ChannelHandler類並重寫了userEventTriggered方法,在該方法裡實現傳送心跳資料包的邏輯,同時將 IdleStateEvent類加入邏輯處理鏈上。

實際上是當連線空閒時間太長時,將會觸發一個 IdleStateEvent事件,然後我們呼叫 userEventTriggered來處理該 IdleStateEvent事件。

當啟動客戶端和服務端之後,控制檯列印心跳訊息如下:

2018-10-28 16:30:46.825  INFO 42648 --- [ntLoopGroup-2-1] c.pjmike.server.client.HeartbeatHandler  : 已經10s沒有傳送訊息給服務端
2018-10-28 16:30:47.176  INFO 42648 --- [ntLoopGroup-4-1] c.p.server.server.NettyServerHandler     : 收到客戶端發來的心跳訊息:requestId: "80723780-2ce0-4b43-ad3a-53060a6e81ab"
cmd: HEARTBEAT_REQUEST
content: "heartbeat"
複製程式碼

上面我們只討論了客戶端傳送心跳訊息給服務端,那麼服務端還需要發心跳訊息給客戶端嗎?

一般情況是,對於長連線而言,一種方案是兩邊都傳送心跳訊息,另一種是服務端作為被動接收一方,如果一段時間內服務端沒有收到心跳包那麼就直接斷開連線。

我們這裡採用第二種方案,只需要客戶端傳送心跳訊息,然後服務端被動接收,然後設定一段時間,在這段時間內如果服務端沒有收到任何訊息,那麼就主動斷開連線,這也就是後面要說的 空閒檢測

Netty 客戶端斷線重連

一般有以下兩種情況,Netty 客戶端需要重連服務端:

  • Netty 客戶端啟動時,服務端掛掉,連不上服務端
  • 在程式執行過程中,服務端突然掛掉

第一種情況實現 ChannelFutureListener用來監測連線是否成功,不成功就進行斷連重試機制,程式碼如下:

@Component
@Slf4j
public class NettyClient  {
    private EventLoopGroup group = new NioEventLoopGroup();
    @Value("${netty.port}")
    private int port;
    @Value("${netty.host}")
    private String host;
    private SocketChannel socketChannel;

    public void sendMsg(MessageBase.Message message) {
        socketChannel.writeAndFlush(message);
    }

    @PostConstruct
    public void start()  {
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(group)
                .channel(NioSocketChannel.class)
                .remoteAddress(host, port)
                .handler(new ClientHandlerInitilizer());
        ChannelFuture future = bootstrap.connect();
        //客戶端斷線重連邏輯
        future.addListener((ChannelFutureListener) future1 -> {
            if (future1.isSuccess()) {
                log.info("連線Netty服務端成功");
            } else {
                log.info("連線失敗,進行斷線重連");
                future1.channel().eventLoop().schedule(() -> start(), 20, TimeUnit.SECONDS);
            }
        });
        socketChannel = (SocketChannel) future.channel();
    }
}
複製程式碼

ChannelFuture新增一個監聽器,如果客戶端連線服務端失敗,呼叫 channel().eventLoop().schedule()方法執行重試邏輯。

第二種情況是執行過程中 服務端突然掛掉了,這種情況我們在處理資料讀寫的Handler中實現,程式碼如下:

@Slf4j
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
    @Autowired
    private NettyClient nettyClient;
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent idleStateEvent = (IdleStateEvent) evt;
            if (idleStateEvent.state() == IdleState.WRITER_IDLE) {
                log.info("已經10s沒有傳送訊息給服務端");
                //向服務端送心跳包
                MessageBase.Message heartbeat = new MessageBase.Message().toBuilder().setCmd(MessageBase.Message.CommandType.HEARTBEAT_REQUEST)
                        .setRequestId(UUID.randomUUID().toString())
                        .setContent("heartbeat").build();
                //傳送心跳訊息,並在傳送失敗時關閉該連線
                ctx.writeAndFlush(heartbeat).addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
            }
        } else {
            super.userEventTriggered(ctx, evt);
        }
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        //如果執行過程中服務端掛了,執行重連機制
        EventLoop eventLoop = ctx.channel().eventLoop();
        eventLoop.schedule(() -> nettyClient.start(), 10L, TimeUnit.SECONDS);
        super.channelInactive(ctx);
    }
}
複製程式碼

我們這裡直接在實現心跳機制的 Handler中重寫channelInactive方法,然後在該方法中執行重試邏輯,這裡注入了 NettyClient類,目的是方便呼叫 NettyClientstart()方法重新連線服務端

channelInactive()方法是指如果當前Channel沒有連線到遠端節點,那麼該方法將會被呼叫。

服務端空閒檢測

空閒檢測是什麼?實際上空閒檢測是每隔一段時間,檢測這段時間內是否有資料讀寫。比如,服務端檢測一段時間內,是否收到客戶端傳送來的資料,如果沒有,就及時釋放資源,關閉連線。

對於空閒檢測,Netty 特地提供了 IdleStateHandler 來實現這個功能。下面的程式碼參考自《Netty 入門與實戰:仿寫微信 IM 即時通訊系統》中空閒檢測部分的實現:

@Slf4j
public class ServerIdleStateHandler extends IdleStateHandler {
    /**
     * 設定空閒檢測時間為 30s
     */
    private static final int READER_IDLE_TIME = 30;
    public ServerIdleStateHandler() {
        super(READER_IDLE_TIME, 0, 0, TimeUnit.SECONDS);
    }

    @Override
    protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception {
        log.info("{} 秒內沒有讀取到資料,關閉連線", READER_IDLE_TIME);
        ctx.channel().close();
複製程式碼

Controller方法測試

因為這是 SpringBoot 整合 Netty 的一個Demo,我們建立一個Controller方法對Netty 服務端與客戶端之間的通訊進行測試,controller程式碼如下,非常簡單:

@RestController
public class ConsumerController {
    @Autowired
    private NettyClient nettyClient;

    @GetMapping("/send")
    public String send() {
        MessageBase.Message message = new MessageBase.Message()
                .toBuilder().setCmd(MessageBase.Message.CommandType.NORMAL)
                .setContent("hello server")
                .setRequestId(UUID.randomUUID().toString()).build();
        nettyClient.sendMsg(message);
        return "send ok";
    }
}
複製程式碼

注入 NettyClient,呼叫其 sendMsg方法傳送訊息,結果如下:

c.p.server.server.NettyServerHandler     : 收到客戶端的業務訊息:requestId: "aba74c28-1b6e-42b3-9f27-889e7044dcbf"
content: "hello server"
複製程式碼

小結

上面詳細闡述了 如何用 SpringBoot 整合 Netty ,其中借鑑很多前輩大佬的例子與文章,算是初步瞭解瞭如何使用 Netty。上文中如有錯誤之處,歡迎指出。github地址: github.com/pjmike/spri…

參考資料 & 鳴謝

相關文章