初識Netty原理(一)—— 基本使用

jie12366發表於2019-12-17

第一個Netty實踐DiscardServer

建立Netty專案

建立一個快速開始的Maven專案,匯入Netty4.0版本的依賴(我的JDK是1.8,官方建議1.6以上)。Netty依賴如下:

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.6.Final</version>
</dependency>
複製程式碼

這裡使用了門面日誌框架Slf4j,所以需要引入slf4j和日誌實現框架logback。

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.25</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
</dependency>
複製程式碼

服務端程式

先上程式碼,程式碼裡已有詳細註釋,具體的介紹後面再說。

public class NettyDiscardServer {
	private static final Logger logger = LoggerFactory.getLogger(NettyDiscardServer.class);

	final int port;
    // 建立一個伺服器端的啟動器
	ServerBootstrap sb = new ServerBootstrap();

	NettyDiscardServer(int port) {
		this.port = port;
	}

	public void runServer() {
		// 建立反應器執行緒組
		// 開啟一個執行緒(其實就是建立一個反應器類的物件)用於父通道,負責新連線的監聽
		EventLoopGroup boss = new NioEventLoopGroup(1);
		// 使用不帶引數的建構函式,預設執行緒數是CPU核數 * 2
		// 開啟一個執行緒池用於子通道,負責通道的IO事件的處理
		EventLoopGroup workers = new NioEventLoopGroup();

		try {
			// 設定反應器的執行緒組,一個父執行緒組,一個子執行緒組
			sb.group(boss, workers)
					// 設定NIO型別的通道
					.channel(NioServerSocketChannel.class)
					// 設定服務端監聽的埠
					.localAddress("127.0.0.1", port)
					// 設定通道的引數
                    // SO_KEEPALIVE指開啟TCP心跳檢測
					.option(ChannelOption.SO_KEEPALIVE, true)
                    // Netty的記憶體在堆外直接記憶體上分配,可避免位元組緩衝區的二次拷貝
					.option(ChannelOption.ALLOCATOR,PooledByteBufAllocator.DEFAULT)
                    // 裝配子通道流水線
					.childHandler(new ChannelInitializer<SocketChannel>() {
                                // 當有新連線到達時會建立一個通道
								protected void initChannel(SocketChannel ch) {
									// 向子通道流水線新增第一個Handler處理器
									ch.pipeline().addLast(new NettyDiscardHandler());
                                    // 向子通道流水線新增第二個Handler處理器
									ch.pipeline().addLast(new NettyDiscardHandler1());
								}
							});
			// 開始繫結伺服器
			// 呼叫sync同步方法阻塞直到繫結成功
			ChannelFuture channel = sb.bind().sync();
			logger.info("伺服器啟動成功,監聽埠:" + channel.channel().localAddress());
			// 伺服器監聽通道會一直等待 通道關閉的非同步任務結束
			ChannelFuture close = channel.channel().closeFuture();
			// 同步關閉
			close.sync();
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			// 釋放所有使用的執行緒,釋放所有資源(包括關閉通道和選擇器)
			boss.shutdownGracefully();
			workers.shutdownGracefully();
		}
	}

	public static void main(String[] args) {
		new NettyDiscardServer(8888).runServer();
	}
}
複製程式碼

業務處理器

在Netty的反應器模式中,所有的業務處理都在Handler處理器中完成。這裡寫一個簡單的入站處理(讀取訊息不回覆),以及演示簡單的通道流水線處理。這裡兩個類我都是做為上面伺服器類的內部類實現的。

    // 繼承netty預設實現的入站處理器,在內部實現自己的業務邏輯
    static class NettyDiscardHandler extends ChannelInboundHandlerAdapter {
        // 讀取緩衝區中的資料
		@Override
		public void channelRead(ChannelHandlerContext ctx, Object msg)
				throws Exception {
            // 將資料轉為ByteBuf(類似Java NIO中的ByteBuffer)類來處理
			ByteBuf buf = (ByteBuf) msg;
			try {
				logger.info("收到訊息 : ");
                // 迴圈讀取緩衝區中的字元
				while (buf.isReadable()) {
					System.out.print((char) buf.readByte());
				}
				System.out.println();
			} finally {
				// 通過 通道處理器上下文 將訊息傳播到下一個處理器節點
				ctx.fireChannelRead(msg);
			}
		}

		@Override
		public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
				throws Exception {
			// 遇到異常時關閉連線
			cause.printStackTrace();
			ctx.close();
		}
	}

	static class NettyDiscardHandler1 extends ChannelInboundHandlerAdapter {
		@Override
		public void channelRead(ChannelHandlerContext ctx, Object msg)
				throws Exception {
			logger.info("我是第二個處理器");
            // 拋棄訊息
            ReferenceCountUtil.release(msg);
		}
	}
複製程式碼

簡單的客戶端程式

實現一個簡單的想伺服器傳送訊息的客戶端。

public class NettyDiscardClient {
	public static void main(String[] args) throws IOException {
		SocketChannel channel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 8888));
		channel.configureBlocking(false);
		// 自旋,等待連線完成
		while (!channel.finishConnect());
        // 向服務端傳送一個訊息
		ByteBuffer buf = ByteBuffer.allocate(1024);
		buf.put("hello world!".getBytes());
		buf.flip();
		channel.write(buf);
		channel.close();
	}
}
複製程式碼

執行伺服器和客戶端。其中可以看到訊息被兩個處理器處理了,這就是通道的流水線處理。

[main] INFO discardServer.NettyDiscardServer - 伺服器啟動成功,監聽埠:/127.0.0.1:8888
[nioEventLoopGroup-3-1] INFO discardServer.NettyDiscardServer - 收到訊息 : hello world!
[nioEventLoopGroup-3-1] INFO discardServer.NettyDiscardServer - 我是第二個處理器
複製程式碼

伺服器啟動程式碼分析

Netty提供了一個非常便利的工廠類Bootstrap,可以用它來方便的完成netty元件的組裝和配置。

父子通道

在Netty中,將有接收關係的NioServerSocketChannel和NioSocketChannel,叫做父子通道。

  • NioServerSocketChannel負責伺服器端新連線的監聽和接收,叫做父通道。
  • NioSocketChannel負責通道間資料的傳輸,叫做子通道。

EventLoopGroup執行緒組

在Netty中,一個EventLoop就是一個反應器。由於Netty是多執行緒版本的反應器模式,所以使用EventLoopGroup來實現多執行緒版本的反應器。
EventLoopGroup一般常用兩個建構函式,一個是帶引數的(用於指定執行緒數),一個是不帶引數的。下面來看一下內部原始碼。

// 帶引數,直接指定執行緒數
public NioEventLoopGroup(int nThreads) {
    this(nThreads, (Executor) null);
}
// 不帶引數,內部呼叫帶引數的建構函式,傳入的引數為0
public NioEventLoopGroup() {
    this(0);
}
複製程式碼

對於無參的建構函式,傳入的執行緒數居然是0,帶著疑問繼續往原始碼裡鑽。經過層層呼叫,最後找到了,是呼叫了父類MultithreadEventLoopGroup的建構函式。

protected MultithreadEventLoopGroup(int nThreads, ThreadFactory threadFactory, Object... args) {
    super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, threadFactory, args);
}
複製程式碼

可以發現,如果傳入的執行緒數為0,則把預設的DEFAULT_EVENT_LOOP_THREADS賦值給執行緒數nThreads。這個DEFAULT_EVENT_LOOP_THREADS是多少呢,在父類中找找看。

static {
    // 將CPU核數 * 2 作為預設執行緒數
    DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt("io.netty.eventLoopThreads", Runtime.getRuntime().availableProcessors() * 2));
    // 如果日誌開啟了debug模式,則列印出開啟的執行緒數
    if (logger.isDebugEnabled()) {
        logger.debug("-Dio.netty.eventLoopThreads: {}", DEFAULT_EVENT_LOOP_THREADS);
    }
}
複製程式碼

通過原始碼可以發現,如果傳入一個無參的建構函式,則預設開始CPU核數 * 2個EventLoop執行緒。
另外,開篇的伺服器端程式碼中,建立了兩個執行緒組例項。其中一個用於負責新連線的監聽和連線,查詢父通道的IO事件(這裡用帶引數的建構函式建立了一個執行緒)。還有一個執行緒組用於負責查詢所有子通道的IO事件,並執行Handler處理器中的業務處理。

Bootstrap的啟動流程

Bootstrap可以方便的組裝和配置Netty的元件。下面介紹一下流程。

  1. 建立一個伺服器端的啟動器ServerBootstrap。
ServerBootstrap sb = new ServerBootstrap();
複製程式碼
  1. 建立反應器執行緒組,並賦值給ServerBootstrap。
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup workers = new NioEventLoopGroup();
sb.group(boss, workers);  // 對應的方法原型 ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup)
複製程式碼
  1. 設定通道的IO型別,這裡伺服器端一般使用NioServerSocketChannel,也就是Java NIO型別。
sb.channel(NioServerSocketChannel.class) // 對應的方法原型 B channel(Class<? extends C> channelClass)
複製程式碼
  1. 設定監聽的地址和埠。
sb.localAddress("127.0.0.1", port) // 對應的方法原型 B localAddress(String inetHost, int inetPort)
複製程式碼
  1. 設定傳輸通道的配置選項,這裡是給父通道接收連線通道設定一些選項。對於ChannelOption常量的分析。參考Netty支援的ChannelOption分析
sb.option(ChannelOption.SO_KEEPALIVE, true)
sb.option(ChannelOption.ALLOCATOR,PooledByteBufAllocator.DEFAULT) // 對應的方法原型 B option(ChannelOption<T> option, T value)
複製程式碼
  1. 裝配子通道的Pipeline流水線,通過呼叫childHandler方法,傳入一個ChannelInitializer通道初始化類的例項(這個例項的泛型需要與前面的啟動器中設定的通道型別對應)。在父通道成功接收一個連線,並建立成功一個子通道後,就會初始化子通道,這個例項就會被呼叫。在這個例項中有一個initChannel初始化方法,用於向子通道流水線新增業務處理器。
sb.childHandler(new ChannelInitializer<SocketChannel>() {
	protected void initChannel(SocketChannel ch) {
	    ch.pipeline().addLast(new NettyDiscardHandler());
		ch.pipeline().addLast(new NettyDiscardHandler1());
	}
});
複製程式碼
  1. 繫結伺服器新連線的監聽埠。內部通過呼叫一個doBind(final SocketAddress localAddress)建立一個之前設定的新連線通道型別例項,並將這個通道例項和地址埠繫結。這裡呼叫了同步阻塞方法,阻塞到地址埠繫結成功為止,伺服器正式啟動啦。
ChannelFuture channel = sb.bind().sync();
複製程式碼
  1. 自我阻塞,直到通道關閉。當通道關閉時,closeFuture例項的sync方法會返回。
// 獲取一個closeFuture例項
ChannelFuture close = channel.channel().closeFuture();
// 自我阻塞,直到通道關閉,就返回。
close.sync();
複製程式碼
  1. 關閉EventLoopGroup。同時會關閉內部的反應器執行緒以及選擇器和所有子通道。並釋放掉底層的資源。
boss.shutdownGracefully();
workers.shutdownGracefully();
複製程式碼

Netty中的反應器模式

反應器模式中的IO處理流程

在Reactor模式中的IO處理流程圖如下: [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-XWq76zbM-1575900038358)(i.loli.net/2019/12/09/…)]

  1. 通道註冊:首先需要將通道註冊到選擇器中,通道中就緒的IO事件才能被選擇器查詢到。 2.查詢選擇:一個反應器對應一個執行緒,不斷的輪詢查詢選擇器中的IO就緒事件。
  2. 事件分發:如果查詢到就緒的IO事件,就分發給繫結在對應通道上的處理器去處理。
  3. 業務處理:完成真正的IO操作和業務處理。

Netty中的Channel元件

Netty中沒有使用Java NIO的Channel,而是實現了自己的Channel通道元件。由於Netty還能處理OIO,所以也封裝了對應OIO通道元件。
但是在Netty中使用最多的還是NioSocketChannel(非同步非阻塞TCP Socket傳輸通道),NioServerSocketChannel(非同步非阻塞TCP Socket伺服器端監聽通道)。
其中有個好玩的通道就是EmbeddedChannel(嵌入式通道),這個通道主要是用於單元測試的(它可以讓開發人員在開發中方便的模擬入站與出站的操作,而不進行底層實際的傳輸)。因為Netty實際開發中主要就是開發業務處理器,但是對於業務處理器的測試需要用到客戶端和伺服器的操作,每次測試都要開啟兩個程式測試很麻煩,所以就有了EmbeddedChannel。除了不進行傳輸之外,其它處理流程和真實的傳輸通道的是一樣的。
其中主要的API如下:

名稱 說明
writeInbound (Object... msgs) 向通道中寫入inbound入站資料,模擬通道收到客戶端的資料
readInbound() 從EmbeddedChannel通道中讀取入站資料,也就是返回入站處理器處理完的資料,如果沒有資料,返回NULL
writeOutbound (Object... msgs) 向通道中寫入outbound出站資料,模擬通道傳送資料到客戶端。
readOutbound() 從EmbeddedChannel通道中讀取出站資料,類似readInbound。
flush() 結束EmbeddedChannel,它會呼叫通道的close方法,並釋放所有資料的底層資源。

Netty中的Reactor反應器

Netty中的反應器有多個實現類,對應於Channel通道。NioSocketChannel對應的反應器類是NioEventLoop。
NioEventLoop有兩個重要成員,一個是Thread執行緒類的成員,一個是Java NIO選擇器的成員。
一個NioEventLoop擁有一個Thread執行緒,負責一個Java NIO選擇器的IO事件輪詢。但是一個NioEventLoop反應器可以對應多個NioSocketChannel通道。

Netty中的Handler處理器

Netty中有兩大類的Handler處理器。他們都繼承與ChannelHandler介面。

  • ChannelInboundHandler入站處理器,最常用的channelRead用於從通道中讀取資料並處理。對應的Netty預設實現為ChannelInboundHandlerAdapter入站處理介面卡。
  • 是ChannelIOutboundHandler出站處理器,最常用的channelWrite用於把資料寫入通道中。對應的Netty預設實現為ChannelOutboundHandlerAdapter出站處理介面卡。

Handler處理器的生命週期:新增處理器到流水線 -> 註冊到流水線中 -> 通道啟用後回撥 -> 入站方法回撥 -> 底層連線關閉 -> 移除註冊 -> 從流水線中刪除。

Netty的Pipeline流水線

在Netty中通道和處理器例項的關係為一對多。這裡就用到了通道流水線(ChannelPipeline)。一個通道(有一個pipeline成員)擁有一個通道流水線。
通道流水線ChannelPipeline基於責任鏈模式設計的,內部是一個雙向連結串列結構,每一個節點對應一個ChannelHandlerContext處理器上下文物件(處理器新增到流水線時,會建立一個通道處理器上下文,裡面包裹了一個ChannelHandler處理器物件,並關聯著ChannelPipeline流水線。用於控制流水線上處理器的傳播),可以支援動態的增加和刪除處理器上下文物件。
在通道流水線中,IO事件按照既定的順序處理。入站處理器的執行次序為從前往後,而出站處理器的次序為從後往前。

相關文章