1. 概述
在《芋道 Spring Boot WebSocket 入門》文章中,我們使用 WebSocket 實現了一個簡單的 IM 功能,支援身份認證、私聊訊息、群聊訊息。
然後就有胖友私信艿艿,希望使用純 Netty 實現一個類似的功能。良心的艿艿,當然不會給她發紅人卡,因此就有了本文。可能有胖友不知道 Netty 是什麼,這裡簡單介紹下:
Netty 是一個 Java 開源框架。Netty 提供非同步的、事件驅動的網路應用程式框架和工具,用以快速開發高效能、高可靠性的網路伺服器和客戶端程式。
也就是說,Netty 是一個基於 NIO 的客戶、伺服器端程式設計框架,使用Netty 可以確保你快速和簡單的開發出一個網路應用,例如實現了某種協議的客戶,服務端應用。
Netty 相當簡化和流線化了網路應用的程式設計開發過程,例如,TCP 和 UDP 的 Socket 服務開發。
下面,我們來新建三個專案,如下圖所示:
-
lab-67-netty-demo-server
專案:搭建 Netty 服務端。 -
lab-67-netty-demo-client
專案:搭建 Netty 客戶端。 -
lab-67-netty-demo-common
專案:提供 Netty 的基礎封裝,提供訊息的編解碼、分發的功能。
另外,我們也會提供 Netty 常用功能的示例:
- 心跳機制,實現服務端對客戶端的存活檢測。
- 斷線重連,實現客戶端對服務端的重新連線。
不嗶嗶,直接開幹。
友情提示:可能會胖友擔心,沒有 Netty 基礎是不是無法閱讀本文?!艿艿的想法,看!就硬看,按照程式碼先自己能搭建一下哈~文末,艿艿會提供一波 Netty 基礎入門的文章。
2. 構建 Netty 服務端與客戶端
本文在提供完整程式碼示例,可見 https://github.com/YunaiV/Spr... 的 lab-67 目錄。原創不易,給點個 Star 嘿,一起衝鴨!
本小節,我們先來使用 Netty 構建服務端與客戶端的核心程式碼,讓胖友對專案的程式碼有個初始的認知。
2.1 構建 Netty 服務端
建立 lab-67-netty-demo-server
專案,搭建 Netty 服務端。如下圖所示:
下面,我們只會暫時看看 server
包下的程式碼,避免資訊量過大,擊穿胖友的禿頭。
2.1.1 NettyServer
建立 NettyServer 類,Netty 服務端。程式碼如下:
@Component
public class NettyServer {
private Logger logger = LoggerFactory.getLogger(getClass());
@Value("${netty.port}")
private Integer port;
@Autowired
private NettyServerHandlerInitializer nettyServerHandlerInitializer;
/**
* boss 執行緒組,用於服務端接受客戶端的連線
*/
private EventLoopGroup bossGroup = new NioEventLoopGroup();
/**
* worker 執行緒組,用於服務端接受客戶端的資料讀寫
*/
private EventLoopGroup workerGroup = new NioEventLoopGroup();
/**
* Netty Server Channel
*/
private Channel channel;
/**
* 啟動 Netty Server
*/
@PostConstruct
public void start() throws InterruptedException {
// <2.1> 建立 ServerBootstrap 物件,用於 Netty Server 啟動
ServerBootstrap bootstrap = new ServerBootstrap();
// <2.2> 設定 ServerBootstrap 的各種屬性
bootstrap.group(bossGroup, workerGroup) // <2.2.1> 設定兩個 EventLoopGroup 物件
.channel(NioServerSocketChannel.class) // <2.2.2> 指定 Channel 為服務端 NioServerSocketChannel
.localAddress(new InetSocketAddress(port)) // <2.2.3> 設定 Netty Server 的埠
.option(ChannelOption.SO_BACKLOG, 1024) // <2.2.4> 服務端 accept 佇列的大小
.childOption(ChannelOption.SO_KEEPALIVE, true) // <2.2.5> TCP Keepalive 機制,實現 TCP 層級的心跳保活功能
.childOption(ChannelOption.TCP_NODELAY, true) // <2.2.6> 允許較小的資料包的傳送,降低延遲
.childHandler(nettyServerHandlerInitializer);
// <2> 繫結埠,並同步等待成功,即啟動服務端
ChannelFuture future = bootstrap.bind().sync();
if (future.isSuccess()) {
channel = future.channel();
logger.info("[start][Netty Server 啟動在 {} 埠]", port);
}
}
/**
* 關閉 Netty Server
*/
@PreDestroy
public void shutdown() {
// <3.1> 關閉 Netty Server
if (channel != null) {
channel.close();
}
// <3.2> 優雅關閉兩個 EventLoopGroup 物件
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
? ① 在類上,新增 @Component
註解,把 NettyServer 的建立交給 Spring 管理。
-
port
屬性,讀取application.yml
配置檔案的netty.port
配置項。 -
#start()
方法,新增@PostConstruct
註解,啟動 Netty 伺服器。 -
#shutdown()
方法,新增@PreDestroy
註解,關閉 Netty 伺服器。
? ② 我們來詳細看看 #start()
方法的程式碼,如何實現 Netty Server 的啟動。
<2.1>
處,建立 ServerBootstrap 類,Netty 提供的伺服器的啟動類,方便我們初始化 Server。
<2.2>
處,設定 ServerBootstrap 的各種屬性。
友情提示:這裡涉及較多 Netty 元件的知識,艿艿先以簡單的語言描述,後續胖友在文末的 Netty 基礎入門的文章,補充學噢。
<2.2.1>
處,呼叫 #group(EventLoopGroup parentGroup, EventLoopGroup childGroup)
方法,設定使用 bossGroup
和 workerGroup
。其中:
-
bossGroup
屬性:Boss 執行緒組,用於服務端接受客戶端的連線。 -
workerGroup
屬性:Worker 執行緒組,用於服務端接受客戶端的資料讀寫。
Netty 採用的是多 Reactor 多執行緒的模型,服務端可以接受更多客戶端的資料讀寫的能力。原因是:
- 建立專門用於接受客戶端連線的
bossGroup
執行緒組,避免因為已連線的客戶端的資料讀寫頻繁,影響新的客戶端的連線。- 建立專門用於接收客戶端讀寫的
workerGroup
執行緒組,多個執行緒進行客戶端的資料讀寫,可以支援更多客戶端。課後習題:感興趣的胖友,後續可以看看《【NIO 系列】——之 Reactor 模型》文章。
<2.2.2>
處,呼叫 #channel(Class<? extends C> channelClass)
方法,設定使用 NioServerSocketChannel 類,它是 Netty 定義的 NIO 服務端 TCP Socket 實現類。
<2.2.3>
處,呼叫 #localAddress(SocketAddress localAddress)
方法,設定服務端的埠。
<2.2.4>
處,呼叫 option#(ChannelOption<T> option, T value)
方法,設定服務端接受客戶端的連線佇列大小。因為 TCP 建立連線是三次握手,所以第一次握手完成後,會新增到服務端的連線佇列中。
課後習題:更多相關內容,後續可以看看《淺談 TCP Socket 的 backlog 引數》文章。
<2.2.5>
處,呼叫 #childOption(ChannelOption<T> childOption, T value)
方法,TCP Keepalive 機制,實現 TCP 層級的心跳保活功能。
課後習題:更多相關內容,後續可以看看《TCP Keepalive 機制刨根問底》文章。
<2.2.6>
處,呼叫 #childOption(ChannelOption<T> childOption, T value)
方法,允許較小的資料包的傳送,降低延遲。
課後習題:更多相關內容,後續可以看看《詳解 Socket 程式設計 --- TCP_NODELAY 選項》文章。
<2.2.7>
處,呼叫 #childHandler(ChannelHandler childHandler)
方法,設定客戶端連線上來的 Channel 的處理器為 NettyServerHandlerInitializer。稍後我們在「2.1.2 NettyServerHandlerInitializer」小節來看看。
<2.3>
處,呼叫 #bind()
+ #sync()
方法,繫結埠,並同步等待成功,即啟動服務端。
? ③ 我們來詳細看看 #shutdown()
方法的程式碼,如何實現 Netty Server 的關閉。
<3.1>
處,呼叫 Channel 的 #close()
方法,關閉 Netty Server,這樣客戶端就不再能連線了。
<3.2>
處,呼叫 EventLoopGroup 的 #shutdownGracefully()
方法,優雅關閉 EventLoopGroup。例如說,它們裡面的執行緒池。
2.1.2 NettyServerHandlerInitializer
在看 NettyServerHandlerInitializer 的程式碼之前,我們需要先了解下 Netty 的 ChannelHandler 元件,用來處理 Channel 的各種事件。這裡的事件很廣泛,比如可以是連線、資料讀寫、異常、資料轉換等等。
ChannelHandler 有非常多的子類,其中有個非常特殊的 ChannelInitializer,它用於 Channel 建立時,實現自定義的初始化邏輯。這裡我們建立的 NettyServerHandlerInitializer 類,就繼承了 ChannelInitializer 抽象類,程式碼如下:
@Component
public class NettyServerHandlerInitializer extends ChannelInitializer<Channel> {
/**
* 心跳超時時間
*/
private static final Integer READ_TIMEOUT_SECONDS = 3 * 60;
@Autowired
private MessageDispatcher messageDispatcher;
@Autowired
private NettyServerHandler nettyServerHandler;
@Override
protected void initChannel(Channel ch) {
// <1> 獲得 Channel 對應的 ChannelPipeline
ChannelPipeline channelPipeline = ch.pipeline();
// <2> 新增一堆 NettyServerHandler 到 ChannelPipeline 中
channelPipeline
// 空閒檢測
.addLast(new ReadTimeoutHandler(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS))
// 編碼器
.addLast(new InvocationEncoder())
// 解碼器
.addLast(new InvocationDecoder())
// 訊息分發器
.addLast(messageDispatcher)
// 服務端處理器
.addLast(nettyServerHandler)
;
}
}
在每一個客戶端與服務端建立完成連線時,服務端會建立一個 Channel 與之對應。此時,NettyServerHandlerInitializer 會進行執行 #initChannel(Channel c)
方法,進行自定義的初始化。
友情提示:建立的客戶端的 Channel,不要和「2.1.1 NettyServer」小節的 NioServerSocketChannel 混淆,不是同一個哈。在
#initChannel(Channel ch)
方法的ch
引數,就是此時建立的客戶端 Channel。
① <1>
處,呼叫 Channel 的 #pipeline()
方法,獲得客戶端 Channel 對應的 ChannelPipeline。ChannelPipeline 由一系列的 ChannelHandler 組成,又或者說是 ChannelHandler 鏈。這樣, Channel 所有上所有的事件都會經過 ChannelPipeline,被其上的 ChannelHandler 所處理。
② <2>
處,新增五個 ChannelHandler 到 ChannelPipeline 中,每一個的作用看其上的註釋。具體的,我們會在後續的小節詳細解釋。
2.1.3 NettyServerHandler
建立 NettyServerHandler 類,繼承 ChannelInboundHandlerAdapter 類,實現客戶端 Channel 建立連線、斷開連線、異常時的處理。程式碼如下:
@Component
@ChannelHandler.Sharable
public class NettyServerHandler extends ChannelInboundHandlerAdapter {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private NettyChannelManager channelManager;
@Override
public void channelActive(ChannelHandlerContext ctx) {
// 從管理器中新增
channelManager.add(ctx.channel());
}
@Override
public void channelUnregistered(ChannelHandlerContext ctx) {
// 從管理器中移除
channelManager.remove(ctx.channel());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
logger.error("[exceptionCaught][連線({}) 發生異常]", ctx.channel().id(), cause);
// 斷開連線
ctx.channel().close();
}
}
① 在類上新增 @ChannelHandler.Sharable
註解,標記這個 ChannelHandler 可以被多個 Channel 使用。
② channelManager
屬性,是我們實現的客戶端 Channel 的管理器。
-
#channelActive(ChannelHandlerContext ctx)
方法,在客戶端和服務端建立連線完成時,呼叫 NettyChannelManager 的#add(Channel channel)
方法,新增到其中。 -
#channelUnregistered(ChannelHandlerContext ctx)
方法,在客戶端和服務端斷開連線時,呼叫 NettyChannelManager 的#add(Channel channel)
方法,從其中移除。
具體的 NettyChannelManager 的原始碼,我們在「2.1.4 NettyChannelManager」 小節中來瞅瞅~
③ #exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
方法,在處理 Channel 的事件發生異常時,呼叫 Channel 的 #close()
方法,斷開和客戶端的連線。
2.1.4 NettyChannelManager
建立 NettyChannelManager 類,提供兩種功能。
? ① 客戶端 Channel 的管理。程式碼如下:
@Component
public class NettyChannelManager {
/**
* {@link Channel#attr(AttributeKey)} 屬性中,表示 Channel 對應的使用者
*/
private static final AttributeKey<String> CHANNEL_ATTR_KEY_USER = AttributeKey.newInstance("user");
private Logger logger = LoggerFactory.getLogger(getClass());
/**
* Channel 對映
*/
private ConcurrentMap<ChannelId, Channel> channels = new ConcurrentHashMap<>();
/**
* 使用者與 Channel 的對映。
*
* 通過它,可以獲取使用者對應的 Channel。這樣,我們可以向指定使用者傳送訊息。
*/
private ConcurrentMap<String, Channel> userChannels = new ConcurrentHashMap<>();
/**
* 新增 Channel 到 {@link #channels} 中
*
* @param channel Channel
*/
public void add(Channel channel) {
channels.put(channel.id(), channel);
logger.info("[add][一個連線({})加入]", channel.id());
}
/**
* 新增指定使用者到 {@link #userChannels} 中
*
* @param channel Channel
* @param user 使用者
*/
public void addUser(Channel channel, String user) {
Channel existChannel = channels.get(channel.id());
if (existChannel == null) {
logger.error("[addUser][連線({}) 不存在]", channel.id());
return;
}
// 設定屬性
channel.attr(CHANNEL_ATTR_KEY_USER).set(user);
// 新增到 userChannels
userChannels.put(user, channel);
}
/**
* 將 Channel 從 {@link #channels} 和 {@link #userChannels} 中移除
*
* @param channel Channel
*/
public void remove(Channel channel) {
// 移除 channels
channels.remove(channel.id());
// 移除 userChannels
if (channel.hasAttr(CHANNEL_ATTR_KEY_USER)) {
userChannels.remove(channel.attr(CHANNEL_ATTR_KEY_USER).get());
}
logger.info("[remove][一個連線({})離開]", channel.id());
}
}
? ② 向客戶端 Channel 傳送訊息。程式碼如下:
@Component
public class NettyChannelManager {
/**
* 向指定使用者傳送訊息
*
* @param user 使用者
* @param invocation 訊息體
*/
public void send(String user, Invocation invocation) {
// 獲得使用者對應的 Channel
Channel channel = userChannels.get(user);
if (channel == null) {
logger.error("[send][連線不存在]");
return;
}
if (!channel.isActive()) {
logger.error("[send][連線({})未啟用]", channel.id());
return;
}
// 傳送訊息
channel.writeAndFlush(invocation);
}
/**
* 向所有使用者傳送訊息
*
* @param invocation 訊息體
*/
public void sendAll(Invocation invocation) {
for (Channel channel : channels.values()) {
if (!channel.isActive()) {
logger.error("[send][連線({})未啟用]", channel.id());
return;
}
// 傳送訊息
channel.writeAndFlush(invocation);
}
}
}
2.1.5 引入依賴
建立 pom.xml
檔案,引入 Netty 依賴。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>lab-67-netty-demo</artifactId>
<groupId>cn.iocoder.springboot.labs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>lab-67-netty-demo-server</artifactId>
<properties>
<!-- 依賴相關配置 -->
<spring.boot.version>2.2.4.RELEASE</spring.boot.version>
<!-- 外掛相關配置 -->
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>1.8</maven.compiler.source>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot 基礎依賴 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Netty 依賴 -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.50.Final</version>
</dependency>
<!-- 引入 netty-demo-common 封裝 -->
<dependency>
<groupId>cn.iocoder.springboot.labs</groupId>
<artifactId>lab-67-netty-demo-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
2.1.6 NettyServerApplication
建立 NettyServerApplication 類,Netty Server 啟動類。程式碼如下:
@SpringBootApplication
public class NettyServerApplication {
public static void main(String[] args) {
SpringApplication.run(NettyServerApplication.class, args);
}
}
2.1.7 簡單測試
執行 NettyServerApplication 類,啟動 Netty Server 伺服器。日誌如下:
... // 省略其他日誌
2020-06-21 00:16:38.801 INFO 41948 --- [ main] c.i.s.l.n.server.NettyServer : [start][Netty Server 啟動在 8888 埠]
2020-06-21 00:16:38.893 INFO 41948 --- [ main] c.i.s.l.n.NettyServerApplication : Started NettyServerApplication in 0.96 seconds (JVM running for 1.4)
Netty Server 啟動在 8888 埠。
2.2 構建 Netty 客戶端
建立 lab-67-netty-demo-client
專案,搭建 Netty 客戶端。如下圖所示:
下面,我們只會暫時看看 client
包下的程式碼,避免資訊量過大,擊穿胖友的禿頭。
2.2.1 NettyClient
建立 NettyClient 類,Netty 客戶端。程式碼如下:
@Component
public class NettyClient {
/**
* 重連頻率,單位:秒
*/
private static final Integer RECONNECT_SECONDS = 20;
private Logger logger = LoggerFactory.getLogger(getClass());
@Value("${netty.server.host}")
private String serverHost;
@Value("${netty.server.port}")
private Integer serverPort;
@Autowired
private NettyClientHandlerInitializer nettyClientHandlerInitializer;
/**
* 執行緒組,用於客戶端對服務端的連線、資料讀寫
*/
private EventLoopGroup eventGroup = new NioEventLoopGroup();
/**
* Netty Client Channel
*/
private volatile Channel channel;
/**
* 啟動 Netty Server
*/
@PostConstruct
public void start() throws InterruptedException {
// <2.1> 建立 Bootstrap 物件,用於 Netty Client 啟動
Bootstrap bootstrap = new Bootstrap();
// <2.2>
bootstrap.group(eventGroup) // <2.2.1> 設定一個 EventLoopGroup 物件
.channel(NioSocketChannel.class) // <2.2.2> 指定 Channel 為客戶端 NioSocketChannel
.remoteAddress(serverHost, serverPort) // <2.2.3> 指定連線伺服器的地址
.option(ChannelOption.SO_KEEPALIVE, true) // <2.2.4> TCP Keepalive 機制,實現 TCP 層級的心跳保活功能
.option(ChannelOption.TCP_NODELAY, true) //<2.2.5> 允許較小的資料包的傳送,降低延遲
.handler(nettyClientHandlerInitializer);
// <2.3> 連線伺服器,並非同步等待成功,即啟動客戶端
bootstrap.connect().addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
// 連線失敗
if (!future.isSuccess()) {
logger.error("[start][Netty Client 連線伺服器({}:{}) 失敗]", serverHost, serverPort);
reconnect();
return;
}
// 連線成功
channel = future.channel();
logger.info("[start][Netty Client 連線伺服器({}:{}) 成功]", serverHost, serverPort);
}
});
}
public void reconnect() {
// ... 暫時省略程式碼。
}
/**
* 關閉 Netty Server
*/
@PreDestroy
public void shutdown() {
// <3.1> 關閉 Netty Client
if (channel != null) {
channel.close();
}
// <3.2> 優雅關閉一個 EventLoopGroup 物件
eventGroup.shutdownGracefully();
}
/**
* 傳送訊息
*
* @param invocation 訊息體
*/
public void send(Invocation invocation) {
if (channel == null) {
logger.error("[send][連線不存在]");
return;
}
if (!channel.isActive()) {
logger.error("[send][連線({})未啟用]", channel.id());
return;
}
// 傳送訊息
channel.writeAndFlush(invocation);
}
}
友情提示:整體程式碼,是和「2.1.1 NettyServer」對等,且基本是一致的。
? ① 在類上,新增 @Component
註解,把 NettyClient 的建立交給 Spring 管理。
-
serverHost
和serverPort
屬性,讀取application.yml
配置檔案的netty.server.host
和netty.server.port
配置項。 -
#start()
方法,新增@PostConstruct
註解,啟動 Netty 客戶端。 -
#shutdown()
方法,新增@PreDestroy
註解,關閉 Netty 客戶端。
? ② 我們來詳細看看 #start()
方法的程式碼,如何實現 Netty Client 的啟動,建立和伺服器的連線。
<2.1>
處,建立 Bootstrap 類,Netty 提供的客戶端的啟動類,方便我們初始化 Client。
<2.2>
處,設定 Bootstrap 的各種屬性。
<2.2.1>
處,呼叫 #group(EventLoopGroup group)
方法,設定使用 eventGroup
執行緒組,實現客戶端對服務端的連線、資料讀寫。
<2.2.2>
處,呼叫 #channel(Class<? extends C> channelClass)
方法,設定使用 NioSocketChannel 類,它是 Netty 定義的 NIO 服務端 TCP Client 實現類。
<2.2.3>
處,呼叫 #remoteAddress(SocketAddress localAddress)
方法,設定連線服務端的地址。
<2.2.4>
處,呼叫 #option(ChannelOption<T> childOption, T value)
方法,TCP Keepalive 機制,實現 TCP 層級的心跳保活功能。
<2.2.5>
處,呼叫 #childOption(ChannelOption<T> childOption, T value)
方法,允許較小的資料包的傳送,降低延遲。
<2.2.7>
處,呼叫 #handler(ChannelHandler childHandler)
方法,設定自己 Channel 的處理器為 NettyClientHandlerInitializer。稍後我們在「2.2.2 NettyClientHandlerInitializer」小節來看看。
<2.3>
處,呼叫 #connect()
方法,連線伺服器,並非同步等待成功,即啟動客戶端。同時,新增回撥監聽器 ChannelFutureListener,在連線服務端失敗的時候,呼叫 #reconnect()
方法,實現定時重連。? 具體 #reconnect()
方法的程式碼,我們稍後在瞅瞅哈。
③ 我們來詳細看看 #shutdown()
方法的程式碼,如何實現 Netty Client 的關閉。
<3.1>
處,呼叫 Channel 的 #close()
方法,關閉 Netty Client,這樣客戶端就斷開和服務端的連線。
<3.2>
處,呼叫 EventLoopGroup 的 #shutdownGracefully()
方法,優雅關閉 EventLoopGroup。例如說,它們裡面的執行緒池。
④ #send(Invocation invocation)
方法,實現向服務端傳送訊息。
因為 NettyClient 是客戶端,所以無需像 NettyServer 一樣使用「2.1.4 NettyChannelManager」維護 Channel 的集合。
2.2.2 NettyClientHandlerInitializer
建立的 NettyClientHandlerInitializer 類,就繼承了 ChannelInitializer 抽象類,實現和服務端建立連線後,新增相應的 ChannelHandler 處理器。程式碼如下:
@Component
public class NettyClientHandlerInitializer extends ChannelInitializer<Channel> {
/**
* 心跳超時時間
*/
private static final Integer READ_TIMEOUT_SECONDS = 60;
@Autowired
private MessageDispatcher messageDispatcher;
@Autowired
private NettyClientHandler nettyClientHandler;
@Override
protected void initChannel(Channel ch) {
ch.pipeline()
// 空閒檢測
.addLast(new IdleStateHandler(READ_TIMEOUT_SECONDS, 0, 0))
.addLast(new ReadTimeoutHandler(3 * READ_TIMEOUT_SECONDS))
// 編碼器
.addLast(new InvocationEncoder())
// 解碼器
.addLast(new InvocationDecoder())
// 訊息分發器
.addLast(messageDispatcher)
// 客戶端處理器
.addLast(nettyClientHandler)
;
}
}
和「2.1.2 NettyServerHandlerInitializer」的程式碼基本一樣,差別在於空閒檢測額外增加 IdleStateHandler,客戶端處理器換成了 NettyClientHandler。
2.2.3 NettyClientHandler
建立 NettyClientHandler 類,實現客戶端 Channel 斷開連線、異常時的處理。程式碼如下:
@Component
@ChannelHandler.Sharable
public class NettyClientHandler extends ChannelInboundHandlerAdapter {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private NettyClient nettyClient;
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
// 發起重連
nettyClient.reconnect();
// 繼續觸發事件
super.channelInactive(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
logger.error("[exceptionCaught][連線({}) 發生異常]", ctx.channel().id(), cause);
// 斷開連線
ctx.channel().close();
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception {
// 空閒時,向服務端發起一次心跳
if (event instanceof IdleStateEvent) {
logger.info("[userEventTriggered][發起一次心跳]");
HeartbeatRequest heartbeatRequest = new HeartbeatRequest();
ctx.writeAndFlush(new Invocation(HeartbeatRequest.TYPE, heartbeatRequest))
.addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
} else {
super.userEventTriggered(ctx, event);
}
}
}
① 在類上新增 @ChannelHandler.Sharable
註解,標記這個 ChannelHandler 可以被多個 Channel 使用。
② #channelInactive(ChannelHandlerContext ctx)
方法,實現在和服務端斷開連線時,呼叫 NettyClient 的 #reconnect()
方法,實現客戶端定時和服務端重連。
③ #exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
方法,在處理 Channel 的事件發生異常時,呼叫 Channel 的 #close()
方法,斷開和客戶端的連線。
④ #userEventTriggered(ChannelHandlerContext ctx, Object event)
方法,在客戶端在空閒時,向服務端傳送一次心跳,即心跳機制。這塊的內容,我們稍後詳細講講。
2.2.4 引入依賴
建立 pom.xml
檔案,引入 Netty 依賴。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>lab-67-netty-demo</artifactId>
<groupId>cn.iocoder.springboot.labs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>lab-67-netty-demo-client</artifactId>
<properties>
<!-- 依賴相關配置 -->
<spring.boot.version>2.2.4.RELEASE</spring.boot.version>
<!-- 外掛相關配置 -->
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>1.8</maven.compiler.source>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- 實現對 Spring MVC 的自動化配置 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Netty 依賴 -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.50.Final</version>
</dependency>
<!-- 引入 netty-demo-common 封裝 -->
<dependency>
<groupId>cn.iocoder.springboot.labs</groupId>
<artifactId>lab-67-netty-demo-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
2.2.5 NettyClientApplication
建立 NettyClientApplication 類,Netty Client 啟動類。程式碼如下:
@SpringBootApplication
public class NettyClientApplication {
public static void main(String[] args) {
SpringApplication.run(NettyClientApplication.class, args);
}
}
2.2.6 簡單測試
執行 NettyClientApplication 類,啟動 Netty Client 客戶端。日誌如下:
... // 省略其他日誌
2020-06-21 09:06:12.205 INFO 44029 --- [ntLoopGroup-2-1] c.i.s.l.n.client.NettyClient : [start][Netty Client 連線伺服器(127.0.0.1:8888) 成功]
同時 Netty Server 服務端發現有一個客戶端接入,列印如下日誌:
2020-06-21 09:06:12.268 INFO 41948 --- [ntLoopGroup-3-1] c.i.s.l.n.server.NettyChannelManager : [add][一個連線(db652822)加入]
2.3 小結
至此,我們已經構建 Netty 服務端和客戶端完成。因為 Netty 提供的 API 非常便利,所以我們不會像直接使用 NIO 時,需要處理大量底層且細節的程式碼。
不過,如上的內容僅僅是本文的開胃菜,正片即將開始!美滋滋,繼續往下看,奧利給!
3. 通訊協議
在「2. 構建 Netty 服務端與客戶端」小節中,我們實現了客戶端和服務端的連線功能。而本小節,我們要讓它們兩能夠說上話,即進行資料的讀寫。
在日常專案的開發中,前端和後端之間採用 HTTP 作為通訊協議,使用文字內容進行互動,資料格式一般是 JSON。但是在 TCP 的世界裡,我們需要自己基於二進位制構建,構建客戶端和服務端的通訊協議。
我們以客戶端向服務端傳送訊息來舉個例子,假設客戶端要傳送一個登入請求,對應的類如下:
public class AuthRequest {
/** 使用者名稱 **/
private String username;
/** 密碼 **/
private String password;
}
- 顯然,我們無法將一個 Java 物件直接丟到 TCP Socket 當中,而是需要將其轉換成 byte 位元組陣列,才能寫入到 TCP Socket 中去。即,需要將訊息物件通過序列化,轉換成 byte 位元組陣列。
- 同時,在服務端收到 byte 位元組陣列時,需要將其又轉換成 Java 物件,即反序列化。不然,服務端對著一串 byte 位元組處理個毛線?!
友情提示:服務端向客戶端發訊息,也是一樣的過程哈!
序列化的工具非常多,例如說 Google 提供的 Protobuf,效能高效,且序列化出來的二進位制資料較小。Netty 對 Protobuf 進行整合,提供了相應的編解碼器。如下圖所示:
但是考慮到很多胖友對 Protobuf 並不瞭解,因為它實現序列化又增加胖友的額外學習成本。因此,艿艿仔細一個捉摸,還是採用 JSON 方式進行序列化。可能胖友會疑惑,JSON 不是將物件轉換成字串嗎?嘿嘿,我們再把字串轉換成 byte 位元組陣列就可以啦~
下面,我們新建 lab-67-netty-demo-common
專案,並在 codec
包下,實現我們自定義的通訊協議。如下圖所示:
3.1 Invocation
建立 Invocation 類,通訊協議的訊息體。程式碼如下:
/**
* 通訊協議的訊息體
*/
public class Invocation {
/**
* 型別
*/
private String type;
/**
* 訊息,JSON 格式
*/
private String message;
// 空構造方法
public Invocation() {
}
public Invocation(String type, String message) {
this.type = type;
this.message = message;
}
public Invocation(String type, Message message) {
this.type = type;
this.message = JSON.toJSONString(message);
}
// ... 省略 setter、getter、toString 方法
}
① type
屬性,型別,用於匹配對應的訊息處理器。如果類比 HTTP 協議,type
屬性相當於請求地址。
② message
屬性,訊息內容,使用 JSON 格式。
另外,Message 是我們定義的訊息介面。程式碼如下:
public interface Message {
// ... 空,作為標記介面
}
3.2 粘包與拆包
在開始看 Invocation 的編解碼處理器之前,我們先了解下粘包與拆包的概念。
如果的內容,引用《Netty 解決粘包和拆包問題的四種方案》文章的內容,進行二次編輯。
3.2.1 產生原因
產生粘包和拆包問題的主要原因是,作業系統在傳送 TCP 資料的時候,底層會有一個緩衝區,例如 1024 個位元組大小。
-
如果一次請求傳送的資料量比較小,沒達到緩衝區大小,TCP 則會將多個請求合併為同一個請求進行傳送,這就形成了粘包問題。
例如說,在《詳解 Socket 程式設計 --- TCP_NODELAY 選項》文章中我們可以看到,在關閉 Nagle 演算法時,請求不會等待滿足緩衝區大小,而是儘快發出,降低延遲。
- 如果一次請求傳送的資料量比較大,超過了緩衝區大小,TCP 就會將其拆分為多次傳送,這就是拆包,也就是將一個大的包拆分為多個小包進行傳送。
如下圖展示了粘包和拆包的一個示意圖,演示了粘包和拆包的三種情況:
- A 和 B 兩個包都剛好滿足 TCP 緩衝區的大小,或者說其等待時間已經達到 TCP 等待時長,從而還是使用兩個獨立的包進行傳送。
- A 和 B 兩次請求間隔時間內較短,並且資料包較小,因而合併為同一個包傳送給服務端。
- B 包比較大,因而將其拆分為兩個包 B_1 和 B_2 進行傳送,而這裡由於拆分後的 B_2 比較小,其又與 A 包合併在一起傳送。
3.2.2 解決方案
對於粘包和拆包問題,常見的解決方案有三種:
? ① 客戶端在傳送資料包的時候,每個包都固定長度。比如 1024 個位元組大小,如果客戶端傳送的資料長度不足 1024 個位元組,則通過補充空格的方式補全到指定長度。
這種方式,艿艿暫時沒有找到採用這種方式的案例。
? ② 客戶端在每個包的末尾使用固定的分隔符。例如 \r\n
,如果一個包被拆分了,則等待下一個包傳送過來之後找到其中的 \r\n
,然後對其拆分後的頭部部分與前一個包的剩餘部分進行合併,這樣就得到了一個完整的包。
具體的案例,有 HTTP、WebSocket、Redis。
? ③ 將訊息分為頭部和訊息體,在頭部中儲存有當前整個訊息的長度,只有在讀取到足夠長度的訊息之後才算是讀到了一個完整的訊息。
友情提示:方案 ③ 是 ① 的升級版,動態長度。
本文,艿艿將採用這種方式,在每次 Invocation 序列化成位元組陣列寫入 TCP Socket 之前,先將位元組陣列的長度寫到其中。如下圖所示:
3.3 InvocationEncoder
建立 InvocationEncoder 類,實現將 Invocation 序列化,並寫入到 TCP Socket 中。程式碼如下:
public class InvocationEncoder extends MessageToByteEncoder<Invocation> {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
protected void encode(ChannelHandlerContext ctx, Invocation invocation, ByteBuf out) {
// <2.1> 將 Invocation 轉換成 byte[] 陣列
byte[] content = JSON.toJSONBytes(invocation);
// <2.2> 寫入 length
out.writeInt(content.length);
// <2.3> 寫入內容
out.writeBytes(content);
logger.info("[encode][連線({}) 編碼了一條訊息({})]", ctx.channel().id(), invocation.toString());
}
}
① MessageToByteEncoder 是 Netty 定義的編碼 ChannelHandler 抽象類,將泛型 <I>
訊息轉換成位元組陣列。
② #encode(ChannelHandlerContext ctx, Invocation invocation, ByteBuf out)
方法,進行編碼的邏輯。
<2.1>
處,呼叫 JSON 的 #toJSONBytes(Object object, SerializerFeature... features)
方法,將 Invocation 轉換成 位元組陣列。
<2.2>
處,將位元組陣列的長度,寫入到 TCP Socket 當中。這樣,後續「3.4 InvocationDecoder」可以根據該長度,解析到訊息,解決粘包和拆包的問題。
友情提示:MessageToByteEncoder 會最終將 ByteBuf out
寫到 TCP Socket 中。
<2.3>
處,將位元組陣列,寫入到 TCP Socket 當中。
3.4 InvocationDecoder
建立 InvocationDecoder 類,實現從 TCP Socket 讀取位元組陣列,反序列化成 Invocation。程式碼如下:
public class InvocationDecoder extends ByteToMessageDecoder {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// <2.1> 標記當前讀取位置
in.markReaderIndex();
// <2.2> 判斷是否能夠讀取 length 長度
if (in.readableBytes() <= 4) {
return;
}
// <2.3> 讀取長度
int length = in.readInt();
if (length < 0) {
throw new CorruptedFrameException("negative length: " + length);
}
// <3.1> 如果 message 不夠可讀,則退回到原讀取位置
if (in.readableBytes() < length) {
in.resetReaderIndex();
return;
}
// <3.2> 讀取內容
byte[] content = new byte[length];
in.readBytes(content);
// <3.3> 解析成 Invocation
Invocation invocation = JSON.parseObject(content, Invocation.class);
out.add(invocation);
logger.info("[decode][連線({}) 解析到一條訊息({})]", ctx.channel().id(), invocation.toString());
}
}
① ByteToMessageDecoder 是 Netty 定義的解碼 ChannelHandler 抽象類,在 TCP Socket 讀取到新資料時,觸發進行解碼。
② 在 <2.1>
、<2.2>
、<2.3>
處,從 TCP Socket 中讀取長度。
③ 在 <3.1>
、<3.2>
、<3.3>
處,從 TCP Socket 中讀取位元組陣列,並反序列化成 Invocation 物件。
最終,新增 List<Object> out
中,交給後續的 ChannelHandler 進行處理。稍後,我們將在「4. 訊息分發」小結中,會看到 MessageDispatcher 將 Invocation 分發到其對應的 MessageHandler 中,進行業務邏輯的執行。
3.5 引入依賴
建立 pom.xml
檔案,引入 Netty、FastJSON 等等依賴。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>lab-67-netty-demo</artifactId>
<groupId>cn.iocoder.springboot.labs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>lab-67-netty-demo-common</artifactId>
<properties>
<!-- 外掛相關配置 -->
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>1.8</maven.compiler.source>
</properties>
<dependencies>
<!-- Netty 依賴 -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.50.Final</version>
</dependency>
<!-- FastJSON 依賴 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.71</version>
</dependency>
<!-- 引入 Spring 相關依賴 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>5.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.2.5.RELEASE</version>
</dependency>
<!-- 引入 SLF4J 依賴 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.30</version>
</dependency>
</dependencies>
</project>
3.6 小結
至此,我們已經完成通訊協議的定義、編解碼的邏輯,是不是蠻有趣的?!
另外,我們在 NettyServerHandlerInitializer 和 NettyClientHandlerInitializer 的初始化程式碼中,將編解碼器新增到其中。如下圖所示:
4. 訊息分發
在 SpringMVC 中,DispatcherServlet 會根據請求地址、方法等,將請求分發到匹配的 Controller 的 Method 方法上。
在 lab-67-netty-demo-client
專案的 dispatcher
包中,我們建立了 MessageDispatcher 類,實現和 DispatcherServlet 類似的功能,將 Invocation 分發到其對應的 MessageHandler 中,進行業務邏輯的執行。
下面,我們來看看具體的程式碼實現。
4.1 Message
建立 Message 介面,定義訊息的標記介面。程式碼如下:
public interface Message {
}
下圖,是我們涉及到的 Message 實現類。如下圖所示:
4.2 MessageHandler
建立 MessageHandler 介面,訊息處理器介面。程式碼如下:
public interface MessageHandler<T extends Message> {
/**
* 執行處理訊息
*
* @param channel 通道
* @param message 訊息
*/
void execute(Channel channel, T message);
/**
* @return 訊息型別,即每個 Message 實現類上的 TYPE 靜態欄位
*/
String getType();
}
- 定義了泛型
<T>
,需要是 Message 的實現類。 - 定義的兩個介面方法,胖友自己看下注釋哈。
下圖,是我們涉及到的 MessageHandler 實現類。如下圖所示:
4.3 MessageHandlerContainer
建立 MessageHandlerContainer 類,作為 MessageHandler 的容器。程式碼如下:
public class MessageHandlerContainer implements InitializingBean {
private Logger logger = LoggerFactory.getLogger(getClass());
/**
* 訊息型別與 MessageHandler 的對映
*/
private final Map<String, MessageHandler> handlers = new HashMap<>();
@Autowired
private ApplicationContext applicationContext;
@Override
public void afterPropertiesSet() throws Exception {
// 通過 ApplicationContext 獲得所有 MessageHandler Bean
applicationContext.getBeansOfType(MessageHandler.class).values() // 獲得所有 MessageHandler Bean
.forEach(messageHandler -> handlers.put(messageHandler.getType(), messageHandler)); // 新增到 handlers 中
logger.info("[afterPropertiesSet][訊息處理器數量:{}]", handlers.size());
}
/**
* 獲得型別對應的 MessageHandler
*
* @param type 型別
* @return MessageHandler
*/
MessageHandler getMessageHandler(String type) {
MessageHandler handler = handlers.get(type);
if (handler == null) {
throw new IllegalArgumentException(String.format("型別(%s) 找不到匹配的 MessageHandler 處理器", type));
}
return handler;
}
/**
* 獲得 MessageHandler 處理的訊息類
*
* @param handler 處理器
* @return 訊息類
*/
static Class<? extends Message> getMessageClass(MessageHandler handler) {
// 獲得 Bean 對應的 Class 類名。因為有可能被 AOP 代理過。
Class<?> targetClass = AopProxyUtils.ultimateTargetClass(handler);
// 獲得介面的 Type 陣列
Type[] interfaces = targetClass.getGenericInterfaces();
Class<?> superclass = targetClass.getSuperclass();
while ((Objects.isNull(interfaces) || 0 == interfaces.length) && Objects.nonNull(superclass)) { // 此處,是以父類的介面為準
interfaces = superclass.getGenericInterfaces();
superclass = targetClass.getSuperclass();
}
if (Objects.nonNull(interfaces)) {
// 遍歷 interfaces 陣列
for (Type type : interfaces) {
// 要求 type 是泛型引數
if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
// 要求是 MessageHandler 介面
if (Objects.equals(parameterizedType.getRawType(), MessageHandler.class)) {
Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
// 取首個元素
if (Objects.nonNull(actualTypeArguments) && actualTypeArguments.length > 0) {
return (Class<Message>) actualTypeArguments[0];
} else {
throw new IllegalStateException(String.format("型別(%s) 獲得不到訊息型別", handler));
}
}
}
}
}
throw new IllegalStateException(String.format("型別(%s) 獲得不到訊息型別", handler));
}
}
① 實現 InitializingBean 介面,在 #afterPropertiesSet()
方法中,掃描所有 MessageHandler Bean ,新增到 MessageHandler 集合中。
② 在 #getMessageHandler(String type)
方法中,獲得型別對應的 MessageHandler 物件。稍後,我們會在 MessageDispatcher 呼叫該方法。
③ 在 #getMessageClass(MessageHandler handler)
方法中,通過 MessageHandler 中,通過解析其類上的泛型,獲得訊息型別對應的 Class 類。這是參考 rocketmq-spring
專案的 DefaultRocketMQListenerContainer#getMessageType()
方法,進行略微修改。
友情提示:如果胖友對 Java 的泛型機制沒有做過一點了解,可能略微有點硬核。可以先暫時跳過,知道意圖即可。
4.4 MessageDispatcher
建立 MessageDispatcher 類,將 Invocation 分發到其對應的 MessageHandler 中,進行業務邏輯的執行。程式碼如下:
@ChannelHandler.Sharable
public class MessageDispatcher extends SimpleChannelInboundHandler<Invocation> {
@Autowired
private MessageHandlerContainer messageHandlerContainer;
private final ExecutorService executor = Executors.newFixedThreadPool(200);
@Override
protected void channelRead0(ChannelHandlerContext ctx, Invocation invocation) {
// <3.1> 獲得 type 對應的 MessageHandler 處理器
MessageHandler messageHandler = messageHandlerContainer.getMessageHandler(invocation.getType());
// 獲得 MessageHandler 處理器的訊息類
Class<? extends Message> messageClass = MessageHandlerContainer.getMessageClass(messageHandler);
// <3.2> 解析訊息
Message message = JSON.parseObject(invocation.getMessage(), messageClass);
// <3.3> 執行邏輯
executor.submit(new Runnable() {
@Override
public void run() {
// noinspection unchecked
messageHandler.execute(ctx.channel(), message);
}
});
}
}
① 在類上新增 @ChannelHandler.Sharable
註解,標記這個 ChannelHandler 可以被多個 Channel 使用。
② SimpleChannelInboundHandler 是 Netty 定義的訊息處理 ChannelHandler 抽象類,處理訊息的型別是 <I>
泛型時。
③ #channelRead0(ChannelHandlerContext ctx, Invocation invocation)
方法,處理訊息,進行分發。
<3.1>
處,呼叫 MessageHandlerContainer 的 #getMessageHandler(String type)
方法,獲得 Invocation 的 type
對應的 MessageHandler 處理器。
然後,呼叫 MessageHandlerContainer 的 #getMessageClass(messageHandler)
方法,獲得 MessageHandler 處理器的訊息類。
<3.2>
處,呼叫 JSON 的 ## parseObject(String text, Class<T> clazz)
方法,將 Invocation 的 message
解析成 MessageHandler 對應的訊息物件。
<3.3>
處,丟到執行緒池中,然後呼叫 MessageHandler 的 #execute(Channel channel, T message)
方法,執行業務邏輯。
注意,為什麼要丟到 executor
執行緒池中呢?我們先來了解下 EventGroup 的執行緒模型。
友情提示:在我們啟動 Netty 服務端或者客戶端時,都會設定其 EventGroup。
EventGroup 我們可以先簡單理解成一個執行緒池,並且執行緒池的大小僅僅是 CPU 數量 * 2。每個 Channel 僅僅會被分配到其中的一個執行緒上,進行資料的讀寫。並且,多個 Channel 會共享一個執行緒,即使用同一個執行緒進行資料的讀寫。
那麼胖友試著思考下,MessageHandler 的具體邏輯視線中,往往會涉及到 IO 處理,例如說進行資料庫的讀取。這樣,就會導致一個 Channel 在執行 MessageHandler 的過程中,阻塞了共享當前執行緒的其它 Channel 的資料讀取。
因此,我們在這裡建立了 executor
執行緒池,進行 MessageHandler 的邏輯執行,避免阻塞 Channel 的資料讀取。
可能會有胖友說,我們是不是能夠把 EventGroup 的執行緒池設定大一點,例如說 200 呢?對於長連線的 Netty 服務端,往往會有 1000 ~ 100000 的 Netty 客戶端連線上來,這樣無論設定多大的執行緒池,都會出現阻塞資料讀取的情況。
友情提示:executor
執行緒池,我們一般稱之為業務執行緒池或者邏輯執行緒池,顧名思義,就是執行業務邏輯的。這樣的設計方式,目前 Dubbo 等等 RPC 框架,都採用這種方式。
後續,胖友可以認真閱讀下《【NIO 系列】——之 Reactor 模型》文章,進一步理解。
4.5 NettyServerConfig
建立 NettyServerConfig 配置類,建立 MessageDispatcher 和 MessageHandlerContainer Bean。程式碼如下:
@Configuration
public class NettyServerConfig {
@Bean
public MessageDispatcher messageDispatcher() {
return new MessageDispatcher();
}
@Bean
public MessageHandlerContainer messageHandlerContainer() {
return new MessageHandlerContainer();
}
}
4.6 NettyClientConfig
友情提示:和「4.5 NettyServerConfig」小結一致。
建立 NettyClientConfig 配置類,建立 MessageDispatcher 和 MessageHandlerContainer Bean。程式碼如下:
@Configuration
public class NettyClientConfig {
@Bean
public MessageDispatcher messageDispatcher() {
return new MessageDispatcher();
}
@Bean
public MessageHandlerContainer messageHandlerContainer() {
return new MessageHandlerContainer();
}
}
4.7 小結
後續,我們將在如下小節,具體演示訊息分發的使用:
5. 斷開重連
Netty 客戶端需要實現斷開重連機制,解決各種情況下的斷開情況。例如說:
- Netty 客戶端啟動時,Netty 服務端處於掛掉,導致無法連線上。
- 在執行過程中,Netty 服務端掛掉,導致連線被斷開。
- 任一一端網路抖動,導致連線異常斷開。
具體的程式碼實現比較簡單,只需要在兩個地方增加重連機制。
- Netty 客戶端啟動時,無法連線 Netty 服務端時,發起重連。
- Netty 客戶端執行時,和 Netty 斷開連線時,發起重連。
考慮到重連會存在失敗的情況,我們採用定時重連的方式,避免佔用過多資源。
5.1 具體程式碼
① 在 NettyClient 中,提供 #reconnect()
方法,實現定時重連的邏輯。程式碼如下:
// NettyClient.java
public void reconnect() {
eventGroup.schedule(new Runnable() {
@Override
public void run() {
logger.info("[reconnect][開始重連]");
try {
start();
} catch (InterruptedException e) {
logger.error("[reconnect][重連失敗]", e);
}
}
}, RECONNECT_SECONDS, TimeUnit.SECONDS);
logger.info("[reconnect][{} 秒後將發起重連]", RECONNECT_SECONDS);
}
通過呼叫 EventLoop 提供的 #schedule(Runnable command, long delay, TimeUnit unit)
方法,實現定時邏輯。而在內部的具體邏輯,呼叫 NettyClient 的 #start()
方法,發起連線 Netty 服務端。
又因為 NettyClient 在 #start()
方法在連線 Netty 服務端失敗時,又會呼叫 #reconnect()
方法,從而再次發起定時重連。如此迴圈反覆,知道 Netty 客戶端連線上 Netty 服務端。如下圖所示:
② 在 NettyClientHandler 中,實現 #channelInactive(ChannelHandlerContext ctx)
方法,在發現和 Netty 服務端斷開時,呼叫 Netty Client 的 #reconnect()
方法,發起重連。程式碼如下:
// NettyClientHandler.java
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
// 發起重連
nettyClient.reconnect();
// 繼續觸發事件
super.channelInactive(ctx);
}
5.2 簡單測試
① 啟動 Netty Client,不要啟動 Netty Server,控制檯列印日誌如下圖:
可以看到 Netty Client 在連線失敗時,不斷髮起定時重連。
② 啟動 Netty Server,控制檯列印如下圖:
可以看到 Netty Client 成功重連上 Netty Server。
6. 心跳機制與空閒檢測
在上文中,艿艿推薦胖友閱讀《TCP Keepalive 機制刨根問底》文章,我們可以瞭解到 TCP 自帶的空閒檢測機制,預設是 2 小時。這樣的檢測機制,從系統資源層面上來說是可以接受的。
但是在業務層面,如果 2 小時才發現客戶端與服務端的連線實際已經斷開,會導致中間非常多的訊息丟失,影響客戶的使用體驗。
因此,我們需要在業務層面,自己實現空閒檢測,保證儘快發現客戶端與服務端實際已經斷開的情況。實現邏輯如下:
- 服務端發現 180 秒未從客戶端讀取到訊息,主動斷開連線。
- 客戶端發現 180 秒未從服務端讀取到訊息,主動斷開連線。
考慮到客戶端和服務端之間並不是一直有訊息的互動,所以我們需要增加心跳機制:
- 客戶端每 60 秒向服務端發起一次心跳訊息,保證服務端可以讀取到訊息。
- 服務端在收到心跳訊息時,回覆客戶端一條確認訊息,保證客戶端可以讀取到訊息。
友情提示:
- 為什麼是 180 秒?可以加大或者減小,看自己希望多快檢測到連線異常。過短的時間,會導致心跳過於頻繁,佔用過多資源。
- 為什麼是 60 秒?三次機會,確認是否心跳超時。
雖然聽起來有點複雜,但是實現起來並不複雜哈。
6.1 服務端的空閒檢測
在 NettyServerHandlerInitializer 中,我們新增了一個 ReadTimeoutHandler 處理器,它在超過指定時間未從對端讀取到資料,會丟擲 ReadTimeoutException 異常。如下圖所示:
通過這樣的方式,實現服務端發現 180 秒未從客戶端讀取到訊息,主動斷開連線。
6.2 客戶端的空閒檢測
友情提示:和「6.1 服務端的空閒檢測」一致。
在 NettyClientHandlerInitializer 中,我們新增了一個 ReadTimeoutHandler 處理器,它在超過指定時間未從對端讀取到資料,會丟擲 ReadTimeoutException 異常。如下圖所示:
通過這樣的方式,實現客戶端發現 180 秒未從服務端讀取到訊息,主動斷開連線。
6.3 心跳機制
Netty 提供了 IdleStateHandler 處理器,提供空閒檢測的功能,在 Channel 的讀或者寫空閒時間太長時,將會觸發一個 IdleStateEvent 事件。
這樣,我們只需要在 NettyClientHandler 處理器中,在接收到 IdleStateEvent 事件時,客戶端向客戶端傳送一次心跳訊息。如下圖所示:
- 其中,HeartbeatRequest 是心跳請求。
同時,我們在服務端專案中,建立了一個 HeartbeatRequestHandler 訊息處理器,在收到客戶端的心跳請求時,回覆客戶端一條確認訊息。程式碼如下:
@Component
public class HeartbeatRequestHandler implements MessageHandler<HeartbeatRequest> {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public void execute(Channel channel, HeartbeatRequest message) {
logger.info("[execute][收到連線({}) 的心跳請求]", channel.id());
// 響應心跳
HeartbeatResponse response = new HeartbeatResponse();
channel.writeAndFlush(new Invocation(HeartbeatResponse.TYPE, response));
}
@Override
public String getType() {
return HeartbeatRequest.TYPE;
}
}
- 其中,HeartbeatResponse 是心跳確認響應
6.4 簡單測試
啟動 Netty Server 服務端,再啟動 Netty Client 客戶端,耐心等待 60 秒後,可以看到心跳日誌如下:
// ... 客戶端
2020-06-22 08:24:47.275 INFO 57005 --- [ntLoopGroup-2-1] c.i.s.l.n.c.handler.NettyClientHandler : [userEventTriggered][發起一次心跳]
2020-06-22 08:24:47.335 INFO 57005 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationEncoder : [encode][連線(44223e18) 編碼了一條訊息(Invocation{type='HEARTBEAT_REQUEST', message='{}'})]
2020-06-22 08:24:47.408 INFO 57005 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(44223e18) 解析到一條訊息(Invocation{type='HEARTBEAT_RESPONSE', message='{}'})]
2020-06-22 08:24:47.409 INFO 57005 --- [pool-1-thread-1] c.i.s.l.n.m.h.HeartbeatResponseHandler : [execute][收到連線(44223e18) 的心跳響應]
// ... 服務端
2020-06-22 08:24:47.388 INFO 56998 --- [ntLoopGroup-3-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(34778465) 解析到一條訊息(Invocation{type='HEARTBEAT_REQUEST', message='{}'})]
2020-06-22 08:24:47.390 INFO 56998 --- [pool-1-thread-1] c.i.s.l.n.m.h.HeartbeatRequestHandler : [execute][收到連線(34778465) 的心跳請求]
2020-06-22 08:24:47.399 INFO 56998 --- [ntLoopGroup-3-1] c.i.s.l.n.codec.InvocationEncoder : [encode][連線(34778465) 編碼了一條訊息(Invocation{type='HEARTBEAT_RESPONSE', message='{}'})]
7. 認證邏輯
友情提示:從本小節開始,我們就具體看看業務邏輯的處理示例。
認證的過程,如下圖所示:
7.1 AuthRequest
建立 AuthRequest 類,定義使用者認證請求。程式碼如下:
public class AuthRequest implements Message {
public static final String TYPE = "AUTH_REQUEST";
/**
* 認證 Token
*/
private String accessToken;
// ... 省略 setter、getter、toString 方法
}
這裡我們使用 accessToken
認證令牌進行認證。
因為一般情況下,我們使用 HTTP 進行登入系統,然後使用登入後的身份標識(例如說 accessToken
認證令牌),將客戶端和當前使用者進行認證繫結。
7.2 AuthResponse
建立 AuthResponse 類,定義使用者認證響應。程式碼如下:
public class AuthResponse implements Message {
public static final String TYPE = "AUTH_RESPONSE";
/**
* 響應狀態碼
*/
private Integer code;
/**
* 響應提示
*/
private String message;
// ... 省略 setter、getter、toString 方法
}
7.3 AuthRequestHandler
服務端...
建立 AuthRequestHandler 類,為服務端處理客戶端的認證請求。程式碼如下:
@Component
public class AuthRequestHandler implements MessageHandler<AuthRequest> {
@Autowired
private NettyChannelManager nettyChannelManager;
@Override
public void execute(Channel channel, AuthRequest authRequest) {
// <1> 如果未傳遞 accessToken
if (StringUtils.isEmpty(authRequest.getAccessToken())) {
AuthResponse authResponse = new AuthResponse().setCode(1).setMessage("認證 accessToken 未傳入");
channel.writeAndFlush(new Invocation(AuthResponse.TYPE, authResponse));
return;
}
// <2> ... 此處應有一段
// <3> 將使用者和 Channel 繫結
// 考慮到程式碼簡化,我們先直接使用 accessToken 作為 User
nettyChannelManager.addUser(channel, authRequest.getAccessToken());
// <4> 響應認證成功
AuthResponse authResponse = new AuthResponse().setCode(0);
channel.writeAndFlush(new Invocation(AuthResponse.TYPE, authResponse));
}
@Override
public String getType() {
return AuthRequest.TYPE;
}
}
程式碼比較簡單,胖友看看 <1>
、<2>
、<3>
、<4>
上的註釋。
7.4 AuthResponseHandler
客戶端...
建立 AuthResponseHandler 類,為客戶端處理服務端的認證響應。程式碼如下:
@Component
public class AuthResponseHandler implements MessageHandler<AuthResponse> {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public void execute(Channel channel, AuthResponse message) {
logger.info("[execute][認證結果:{}]", message);
}
@Override
public String getType() {
return AuthResponse.TYPE;
}
}
列印個認證結果,方便除錯。
7.5 TestController
客戶端...
建立 TestController 類,提供 /test/mock
介面,模擬客戶端向服務端傳送請求。程式碼如下:
@RestController
@RequestMapping("/test")
public class TestController {
@Autowired
private NettyClient nettyClient;
@PostMapping("/mock")
public String mock(String type, String message) {
// 建立 Invocation 物件
Invocation invocation = new Invocation(type, message);
// 傳送訊息
nettyClient.send(invocation);
return "success";
}
}
7.6 簡單測試
啟動 Netty Server 服務端,再啟動 Netty Client 客戶端,然後使用 Postman 模擬一次認證請求。如下圖所示:
同時,可以看到認證成功的日誌如下:
// 客戶端...
2020-06-22 08:41:12.364 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationEncoder : [encode][連線(9e086597) 編碼了一條訊息(Invocation{type='AUTH_REQUEST', message='{"accessToken": "yunai"}'})]
2020-06-22 08:41:12.390 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(9e086597) 解析到一條訊息(Invocation{type='AUTH_RESPONSE', message='{"code":0}'})]
2020-06-22 08:41:12.392 INFO 57583 --- [pool-1-thread-1] c.i.s.l.n.m.auth.AuthResponseHandler : [execute][認證結果:AuthResponse{code=0, message='null'}]
// 服務端...
2020-06-22 08:41:12.374 INFO 56998 --- [ntLoopGroup-3-2] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(791f122b) 解析到一條訊息(Invocation{type='AUTH_REQUEST', message='{"accessToken": "yunai"}'})]
2020-06-22 08:41:12.379 INFO 56998 --- [ntLoopGroup-3-2] c.i.s.l.n.codec.InvocationEncoder : [encode][連線(791f122b) 編碼了一條訊息(Invocation{type='AUTH_RESPONSE', message='{"code":0}'})]
8. 單聊邏輯
私聊的過程,如下圖所示:
服務端負責將客戶端 A 傳送的私聊訊息,轉發給客戶端 B。
8.1 ChatSendToOneRequest
建立 ChatSendToOneRequest 類,傳送給指定人的私聊訊息的請求。程式碼如下:
public class ChatSendToOneRequest implements Message {
public static final String TYPE = "CHAT_SEND_TO_ONE_REQUEST";
/**
* 傳送給的使用者
*/
private String toUser;
/**
* 訊息編號
*/
private String msgId;
/**
* 內容
*/
private String content;
// ... 省略 setter、getter、toString 方法
}
8.2 ChatSendResponse
建立 ChatSendResponse 類,聊天傳送訊息結果的響應。程式碼如下:
public class ChatSendResponse implements Message {
public static final String TYPE = "CHAT_SEND_RESPONSE";
/**
* 訊息編號
*/
private String msgId;
/**
* 響應狀態碼
*/
private Integer code;
/**
* 響應提示
*/
private String message;
// ... 省略 setter、getter、toString 方法
}
8.3 ChatRedirectToUserRequest
建立 ChatRedirectToUserRequest 類, 轉發訊息給一個使用者的請求。程式碼如下:
public class ChatRedirectToUserRequest implements Message {
public static final String TYPE = "CHAT_REDIRECT_TO_USER_REQUEST";
/**
* 訊息編號
*/
private String msgId;
/**
* 內容
*/
private String content;
// ... 省略 setter、getter、toString 方法
}
友情提示:寫完之後,艿艿突然發現少了一個 fromUser
欄位,表示來自誰的訊息。
8.4 ChatSendToOneHandler
服務端...
建立 ChatSendToOneHandler 類,為服務端處理客戶端的私聊請求。程式碼如下:
@Component
public class ChatSendToOneHandler implements MessageHandler<ChatSendToOneRequest> {
@Autowired
private NettyChannelManager nettyChannelManager;
@Override
public void execute(Channel channel, ChatSendToOneRequest message) {
// <1> 這裡,假裝直接成功
ChatSendResponse sendResponse = new ChatSendResponse().setMsgId(message.getMsgId()).setCode(0);
channel.writeAndFlush(new Invocation(ChatSendResponse.TYPE, sendResponse));
// <2> 建立轉發的訊息,傳送給指定使用者
ChatRedirectToUserRequest sendToUserRequest = new ChatRedirectToUserRequest().setMsgId(message.getMsgId())
.setContent(message.getContent());
nettyChannelManager.send(message.getToUser(), new Invocation(ChatRedirectToUserRequest.TYPE, sendToUserRequest));
}
@Override
public String getType() {
return ChatSendToOneRequest.TYPE;
}
}
程式碼比較簡單,胖友看看 <1>
、<2>
上的註釋。
8.5 ChatSendResponseHandler
客戶端...
建立 ChatSendResponseHandler 類,為客戶端處理服務端的聊天響應。程式碼如下:
@Component
public class ChatSendResponseHandler implements MessageHandler<ChatSendResponse> {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public void execute(Channel channel, ChatSendResponse message) {
logger.info("[execute][傳送結果:{}]", message);
}
@Override
public String getType() {
return ChatSendResponse.TYPE;
}
}
列印個聊天傳送結果,方便除錯。
8.6 ChatRedirectToUserRequestHandler
客戶端
建立 ChatRedirectToUserRequestHandler 類,為客戶端處理服務端的轉發訊息的請求。程式碼如下:
@Component
public class ChatRedirectToUserRequestHandler implements MessageHandler<ChatRedirectToUserRequest> {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public void execute(Channel channel, ChatRedirectToUserRequest message) {
logger.info("[execute][收到訊息:{}]", message);
}
@Override
public String getType() {
return ChatRedirectToUserRequest.TYPE;
}
}
列印個聊天接收訊息,方便除錯。
8.7 簡單測試
① 啟動 Netty Server 服務端。
② 啟動 Netty Client 客戶端 A。然後使用 Postman 模擬一次認證請求(使用者為 yunai
)。如下圖所示:
③ 啟動 Netty Client 客戶端 B。注意,需要設定 --server.port
埠為 8081,避免衝突。如下圖所示:
然後使用 Postman 模擬一次認證請求(使用者為 tutou
)。如下圖所示:
④ 最後使用 Postman 模擬一次 yunai
芋艿給 tutou
土豆傳送一次私聊訊息。如下圖所示:
同時,可以看到客戶端 A 向客戶端 B 傳送私聊訊息的日誌如下:
// 客戶端 A...(芋艿)
2020-06-22 08:48:09.505 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(9e086597) 編碼了一條訊息(Invocation{type='CHAT_SEND_TO_ONE_REQUEST', message='{toUser: "tudou", msgId: "1", content: "你猜"}'})]
2020-06-22 08:48:09.510 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(9e086597) 解析到一條訊息(Invocation{type='CHAT_SEND_RESPONSE', message='{"code":0,"msgId":"1"}'})]
2020-06-22 08:48:09.511 INFO 57583 --- [ool-1-thread-69] c.i.s.l.n.m.c.ChatSendResponseHandler : [execute][傳送結果:ChatSendResponse{msgId='1', code=0, message='null'}]
2020-06-22 08:48:35.148 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(9e086597) 編碼了一條訊息(Invocation{type='CHAT_SEND_TO_ONE_REQUEST', message='{toUser: "tutou", msgId: "1", content: "你猜"}'})]
2020-06-22 08:48:35.150 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(9e086597) 解析到一條訊息(Invocation{type='CHAT_SEND_RESPONSE', message='{"code":0,"msgId":"1"}'})]
2020-06-22 08:48:35.150 INFO 57583 --- [ool-1-thread-70] c.i.s.l.n.m.c.ChatSendResponseHandler : [execute][傳送結果:ChatSendResponse{msgId='1', code=0, message='null'}]
// 服務端 ...
2020-06-22 08:48:35.149 INFO 56998 --- [ntLoopGroup-3-2] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(791f122b) 解析到一條訊息(Invocation{type='CHAT_SEND_TO_ONE_REQUEST', message='{toUser: "tutou", msgId: "1", content: "你猜"}'})]
2020-06-22 08:48:35.149 INFO 56998 --- [ntLoopGroup-3-2] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(791f122b) 編碼了一條訊息(Invocation{type='CHAT_SEND_RESPONSE', message='{"code":0,"msgId":"1"}'})]
2020-06-22 08:48:35.149 INFO 56998 --- [ntLoopGroup-3-3] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(79cb3a1e) 編碼了一條訊息(Invocation{type='CHAT_REDIRECT_TO_USER_REQUEST', message='{"content":"你猜","msgId":"1"}'})]
// 客戶端 B...(禿頭)
2020-06-22 08:48:18.277 INFO 59613 --- [ntLoopGroup-2-1] c.i.s.l.n.c.handler.NettyClientHandler : [userEventTriggered][發起一次心跳]
2020-06-22 08:48:18.278 INFO 59613 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationEncoder : [encode][連線(24fbc3e8) 編碼了一條訊息(Invocation{type='HEARTBEAT_REQUEST', message='{}'})]
2020-06-22 08:48:18.280 INFO 59613 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(24fbc3e8) 解析到一條訊息(Invocation{type='HEARTBEAT_RESPONSE', message='{}'})]
2020-06-22 08:48:18.281 INFO 59613 --- [pool-1-thread-4] c.i.s.l.n.m.h.HeartbeatResponseHandler : [execute][收到連線(24fbc3e8) 的心跳響應]
2020-06-22 08:48:35.150 INFO 59613 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(24fbc3e8) 解析到一條訊息(Invocation{type='CHAT_REDIRECT_TO_USER_REQUEST', message='{"content":"你猜","msgId":"1"}'})]
2020-06-22 08:48:35.151 INFO 59613 --- [pool-1-thread-5] l.n.m.c.ChatRedirectToUserRequestHandler : [execute][收到訊息:ChatRedirectToUserRequest{msgId='1', content='你猜'}]
9. 群聊邏輯
群聊的過程,如下圖所示:
服務端負責將客戶端 A 傳送的群聊訊息,轉發給客戶端 A、B、C。
友情提示:考慮到邏輯簡潔,艿艿提供的本小節的示例,並不是一個一個群,而是所有人在一個大的群聊中哈~
9.1 ChatSendToAllRequest
建立 ChatSendToOneRequest 類,傳送給所有人的群聊訊息的請求。程式碼如下:
public class ChatSendToAllRequest implements Message {
public static final String TYPE = "CHAT_SEND_TO_ALL_REQUEST";
/**
* 訊息編號
*/
private String msgId;
/**
* 內容
*/
private String content;
// ... 省略 setter、getter、toString 方法
}
友情提示:如果是正經的群聊,會有一個 groupId
欄位,表示群編號。
9.2 ChatSendResponse
和「8.2 ChatSendResponse」小節一致。
9.3 ChatRedirectToUserRequest
和「8.3 ChatRedirectToUserRequest」小節一致。
9.4 ChatSendToAllHandler
服務端...
建立 ChatSendToAllHandler 類,為服務端處理客戶端的群聊請求。程式碼如下:
@Component
public class ChatSendToAllHandler implements MessageHandler<ChatSendToAllRequest> {
@Autowired
private NettyChannelManager nettyChannelManager;
@Override
public void execute(Channel channel, ChatSendToAllRequest message) {
// <1> 這裡,假裝直接成功
ChatSendResponse sendResponse = new ChatSendResponse().setMsgId(message.getMsgId()).setCode(0);
channel.writeAndFlush(new Invocation(ChatSendResponse.TYPE, sendResponse));
// <2> 建立轉發的訊息,並廣播傳送
ChatRedirectToUserRequest sendToUserRequest = new ChatRedirectToUserRequest().setMsgId(message.getMsgId())
.setContent(message.getContent());
nettyChannelManager.sendAll(new Invocation(ChatRedirectToUserRequest.TYPE, sendToUserRequest));
}
@Override
public String getType() {
return ChatSendToAllRequest.TYPE;
}
}
程式碼比較簡單,胖友看看 <1>
、<2>
上的註釋。
9.5 ChatSendResponseHandler
和「8.5 ChatSendResponseHandler」小節一致。
9.6 ChatRedirectToUserRequestHandler
和「8.6 ChatRedirectToUserRequestHandler」小節一致。
9.7 簡單測試
① 啟動 Netty Server 服務端。
② 啟動 Netty Client 客戶端 A。然後使用 Postman 模擬一次認證請求(使用者為 yunai
)。如下圖所示:
③ 啟動 Netty Client 客戶端 B。注意,需要設定 --server.port
埠為 8081,避免衝突。
④ 啟動 Netty Client 客戶端 C。注意,需要設定 --server.port
埠為 8082,避免衝突。
⑤ 最後使用 Postman 模擬一次傳送群聊訊息。如下圖所示:
同時,可以看到客戶端 A 群發給所有客戶端的日誌如下:
// 客戶端 A...
2020-06-22 08:55:44.898 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(9e086597) 編碼了一條訊息(Invocation{type='CHAT_SEND_TO_ALL_REQUEST', message='{msgId: "2", content: "廣播訊息"}'})]
2020-06-22 08:55:44.901 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(9e086597) 解析到一條訊息(Invocation{type='CHAT_SEND_RESPONSE', message='{"code":0,"msgId":"2"}'})]
2020-06-22 08:55:44.901 INFO 57583 --- [ol-1-thread-148] c.i.s.l.n.m.c.ChatSendResponseHandler : [execute][傳送結果:ChatSendResponse{msgId='2', code=0, message='null'}]
2020-06-22 08:55:44.901 INFO 57583 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(9e086597) 解析到一條訊息(Invocation{type='CHAT_REDIRECT_TO_USER_REQUEST', message='{"content":"廣播訊息","msgId":"2"}'})]
2020-06-22 08:55:44.903 INFO 57583 --- [ol-1-thread-149] l.n.m.c.ChatRedirectToUserRequestHandler : [execute][收到訊息:ChatRedirectToUserRequest{msgId='2', content='廣播訊息'}]
// 服務端...
2020-06-22 08:55:44.898 INFO 56998 --- [ntLoopGroup-3-2] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(791f122b) 解析到一條訊息(Invocation{type='CHAT_SEND_TO_ALL_REQUEST', message='{msgId: "2", content: "廣播訊息"}'})]
2020-06-22 08:55:44.901 INFO 56998 --- [ntLoopGroup-3-2] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(791f122b) 編碼了一條訊息(Invocation{type='CHAT_SEND_RESPONSE', message='{"code":0,"msgId":"2"}'})]
2020-06-22 08:55:44.901 INFO 56998 --- [ntLoopGroup-3-2] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(791f122b) 編碼了一條訊息(Invocation{type='CHAT_REDIRECT_TO_USER_REQUEST', message='{"content":"廣播訊息","msgId":"2"}'})]
2020-06-22 08:55:44.901 INFO 56998 --- [ntLoopGroup-3-3] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(79cb3a1e) 編碼了一條訊息(Invocation{type='CHAT_REDIRECT_TO_USER_REQUEST', message='{"content":"廣播訊息","msgId":"2"}'})]
2020-06-22 08:55:44.901 INFO 56998 --- [ntLoopGroup-3-4] c.i.s.l.n.codec.InvocationEncoder : [decode][連線(9dc03826) 編碼了一條訊息(Invocation{type='CHAT_REDIRECT_TO_USER_REQUEST', message='{"content":"廣播訊息","msgId":"2"}'})]
// 客戶端 B...
2020-06-22 08:55:44.902 INFO 59613 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(24fbc3e8) 解析到一條訊息(Invocation{type='CHAT_REDIRECT_TO_USER_REQUEST', message='{"content":"廣播訊息","msgId":"2"}'})]
2020-06-22 08:55:44.902 INFO 59613 --- [ool-1-thread-83] l.n.m.c.ChatRedirectToUserRequestHandler : [execute][收到訊息:ChatRedirectToUserRequest{msgId='2', content='廣播訊息'}]
// 客戶端 C...
2020-06-22 08:55:44.901 INFO 61597 --- [ntLoopGroup-2-1] c.i.s.l.n.codec.InvocationDecoder : [decode][連線(9128c71c) 解析到一條訊息(Invocation{type='CHAT_REDIRECT_TO_USER_REQUEST', message='{"content":"廣播訊息","msgId":"2"}'})]
2020-06-22 08:55:44.903 INFO 61597 --- [ool-1-thread-16] l.n.m.c.ChatRedirectToUserRequestHandler : [execute][收到訊息:ChatRedirectToUserRequest{msgId='2', content='廣播訊息'}]
666. 彩蛋
至此,我們已經通過 Netty 實現了一個簡單的 IM 功能,是不是收穫蠻大的,嘿嘿。
下面,良心的艿艿,再來推薦一波文章,嘿嘿。
- 想要了解 Netty 原始碼的,可以閱讀《Netty 實現原理與原始碼解析系統 —— 精品合集》文章。
- 想要入門 Netty 基礎的,可以閱讀《Netty Bootstrap(圖解)》文章。
等後續,艿艿會在 https://github.com/YunaiV/one... 開源專案中,實現一個相對完整的客服功能,哈哈哈~