前言
這一篇文章主要介紹如何用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
的語法,proto3
比proto2
支援更多的語言但更簡潔。如果首次使用 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
類,目的是方便呼叫 NettyClient
的start()
方法重新連線服務端
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…