Zookeeper 源碼解析——客戶端網絡通信

一、概述

  在前面的博文中我們已經分析了 Zookeeper 中的 Zab 選舉部分的代碼實現,在這篇博文中我們將再通過源碼分析一下 Zookeeper 客戶端的網絡通信實現。

  博客內所有文章均爲 原創,所有示意圖均爲 原創,若轉載請附原文鏈接。


二、 Zookeeper 中的 RPC 網絡數據結構

2.1 協議數據結構

  Zookeeper 中的 RPC 網絡協議數據結構概述即包括三個部分:

  1. 起始的 4 byte(int)用於記錄實際數據長度(後接實際數據);
  2. RequestHeader 請求頭 / ResponseHeader 響應頭
  3. Request 請求體 / Response 響應體

  且 RequestHeader 主要包括 xidtype 兩部分,其中 xid 代表請求的順序號,用於保證請求的順序發送和接收,而 type 代表請求的類型;而 ResponseHeader 主要包括 xidzxid 以及 err ,其中 xid 的作用與 RequestHeader 中的 xid 作用相同,zxid 表示分佈事務 id ,而 err 爲記錄相關的錯誤信息的錯誤碼。


2.2 核心數據結構 Packet

static class Packet {
	RequestHeader requestHeader;	// 請求頭信息
	ReplyHeader replyHeader;		// 響應頭信息

	Record request;		// 請求數據
	Record response;	// 響應數據

	AsyncCallback cb;	// 異步回調
    Object ctx;			// 異步回調所需使用的 context

	String clientPath;	// 客戶端路徑視圖
    String serverPath;	// 服務器的路徑視圖
	boolean finished;	// 是否已經處理完成
    
    ByteBuffer bb;		
    public boolean readOnly;
    WatchRegistration watchRegistration;
    WatchDeregistration watchDeregistration;
	
	// 省略方法邏輯..
}

三、核心源碼解析

3.1 建立 Netty 網絡連接

// Zookeeper.java
public ZooKeeper(String connectString, int sessionTimeout, Watcher watcher, boolean canBeReadOnly, 
					HostProvider aHostProvider, ZKClientConfig clientConfig) throws IOException {
        
    // 創建連接管理器
	cnxn = createConnection(connectStringParser.getChrootPath(), hostProvider, sessionTimeout, 
				this, watchManager, getClientCnxnSocket(), canBeReadOnly);
	cnxn.start();
}

  首先當我們建立一個 Zookeeper 客戶端時需要創建一個 Zookeeper 對象,且在這個 Zookeeper 對象創建的過程中會創建一個客戶端連接管理器(ClientCnxn),接着在創建 ClientCnxn 的過程中又需要創建一個 ClientCnxnSocket 用於實現客戶端間的通信,所以我們跟進這個 getClientCnxnSocket 方法。

// Zookeeper.java
private ClientCnxnSocket getClientCnxnSocket() throws IOException {
	// 從配置文件中獲取 ClientCnxnSocket 配置信息
	String clientCnxnSocketName = getClientConfig().getProperty(ZKClientConfig.ZOOKEEPER_CLIENT_CNXN_SOCKET);
	// 如果配置文件中沒有提供 ClientCnxnSocket 配置信息則默認使用 NIO
	if (clientCnxnSocketName == null) {
		clientCnxnSocketName = ClientCnxnSocketNIO.class.getName();
	}
	try {
		// 通過反射獲取 ClientCnxnSocket 的構造方法
		Constructor<?> clientCxnConstructor = Class.forName(clientCnxnSocketName).getDeclaredConstructor(ZKClientConfig.class);
		// 通過以客戶端配置爲入參調用構造方法來創建一個 ClientCnxnSocket 實例
		ClientCnxnSocket clientCxnSocket = (ClientCnxnSocket) clientCxnConstructor.newInstance(getClientConfig());
		// 將創建完成的 ClientCnxnSocket 實例返回
		return clientCxnSocket;
	}
}

  在這個 getClientCnxnSocket 方法中會選擇 ClientCnxnSocket 的實現版本,目前的 Zookeeper 中存在兩個實現版本,一個是使用 Java JDK 中的 NIO 實現的 ClientCnxnSocketNIO ,另一個是使用 Netty 實現的 ClientCnxnSocketNetty ,而選擇的方式優先根據配置文件中的配置進行選擇,如果沒有進行配置則默認選擇 ClientCnxnSocketNIO 實現版本,之後再通過 反射 的方式創建其實例對象。

// ClientCnxnSocketNetty.java
ClientCnxnSocketNetty(ZKClientConfig clientConfig) throws IOException {
	this.clientConfig = clientConfig;
	
	// 創建一個 eventLoopGroup 用於後面對異步請求的處理
	// 且因爲客戶端只有一個 outgoing Socket 因此只需要一個 eventLoopGroup 即可
	eventLoopGroup = NettyUtils.newNioOrEpollEventLoopGroup(1 /* nThreads */);
	initProperties();
}

public static EventLoopGroup newNioOrEpollEventLoopGroup(int nThreads) {
	// 如果 Epoll 可用( Linux )則優先使用 EpollEventLoopGroup 否則使用 NioEventLoopGroup
	if (Epoll.isAvailable()) {
		return new EpollEventLoopGroup(nThreads);
	} else {
		return new NioEventLoopGroup(nThreads);
	}
}

  我們這裏的分析以 Netty 實現爲準,所以選擇 ClientCnxnSocketNetty 實現版本,在 ClientCnxnSocketNetty 的構造方法中會選擇具體的 EventLoopGroup 的實現,如果是在 Linux 優先選擇使用性能更高的 EpollEventLoopGroup 實現,且這裏配置的線程數目爲一,因此這是典型的 單線程 Reactor 實現。

// Zookeeper.java
protected ClientCnxn createConnection(String chrootPath, HostProvider hostProvider, int sessionTimeout, ZooKeeper zooKeeper, ClientWatchManager watcher, ClientCnxnSocket clientCnxnSocket, boolean canBeReadOnly) throws IOException {
	// 將剛剛創建的 clientCnxnSocket 實例作爲入參創建一個 ClientCnxn 對象
	return new ClientCnxn(chrootPath, hostProvider, sessionTimeout, this, watchManager, clientCnxnSocket, canBeReadOnly);
}

// ClientCnxn.java
public ClientCnxn(String chrootPath, HostProvider hostProvider, int sessionTimeout, ZooKeeper zooKeeper, ClientWatchManager watcher, ClientCnxnSocket clientCnxnSocket, long sessionId, byte[] sessionPasswd, boolean canBeReadOnly) {
	// 省略屬性初始化...

	// 將剛剛創建的 clientCnxnSocket 實例作爲入參創建一個 SendThread 實例
	// 這裏的 SendThread 和 EventThread 均爲 ClientCnxn 的內部類且本質均爲一個線程
	sendThread = new SendThread(clientCnxnSocket);
	eventThread = new EventThread();
	this.clientConfig = zooKeeper.getClientConfig();
	initRequestTimeout();
}

  看完 getClientCnxnSocket 方法後我們再回頭去看 Zookeeper 構造方法中的 createConnection 方法,可以看到該方法的實質就是創建了一個 ClientCnxn 對象,並在 ClientCnxn 的構造方法中創建了 SendThread 發送線程和 EventThread 事件處理線程。

// ClientCnxn.java
// 該方法在 Zookeeper 構造方法中被調用
public void start() {
	// 啓動 sendThread 和 eventThread 既調用 SendThread 和 EventThread 線程的 run 方法
	sendThread.start();
    eventThread.start();
}

  當完成 SendThread 和 EventThread 這兩個線程的創建和初始化後,在 Zookeeper 的構造方法中最後會通過 cnxn.start() 方法啓動這兩個線程。

// ClientCnxn.SendThread.java
public void run() {
	while (state.isAlive()) {
		try {
			// 當前尚未與服務端建立連接
			if (!clientCnxnSocket.isConnected()) {
         		// 如果正在關閉則不嘗試重連
				if (closing) {
					break;
				}
				// 如果存在之前通過 pingRwServer 方法搜索到的可用服務器地址 rwServerAddress 則優先使用它嘗試重連
				if (rwServerAddress != null) {
					serverAddress = rwServerAddress;
					rwServerAddress = null;
				} else {
					// 如果不存在 rwServerAddress 則直接更換服務器地址後嘗試重連
					serverAddress = hostProvider.next(1000);
				}
				// 傳入服務器地址建立連接
				startConnect(serverAddress);
				clientCnxnSocket.updateLastSendAndHeard();
			}
			// 省略已連接邏輯...
		}
	}
}

// ClientCnxn.SendThread.java
private void startConnect(InetSocketAddress addr) throws IOException {
    if(!isFirstConnect){
	    try {
	    	// 如果不是第一次連接則先讓線程睡眠 1000ms 以內的隨機時間,防止短時間內過快的不斷重連
			Thread.sleep(r.nextInt(1000));
		} 
	}
	// 設置狀態爲 CONNECTING
	state = States.CONNECTING;
	// 調用 ClientCnxnSocket 的 connect 方法嘗試連接
	clientCnxnSocket.connect(addr);
}

  在 SendThread 的 run 方法中會啓動初始化連接的流程,並且最終會調用到 ClientCnxnSocketNetty 的 connect 方法來建立客戶端網絡通信的連接,而 connect 方法中的代碼邏輯註釋已經描述的比較清楚,所以不做贅述。

// ClientCnxnSocketNetty.java
void connect(InetSocketAddress addr) throws IOException {
	firstConnect = new CountDownLatch(1);

	// 初始化 Netty 邏輯
	Bootstrap bootstrap = new Bootstrap()
			.group(eventLoopGroup)	// 設置 eventLoopGroup
            .channel(NettyUtils.nioOrEpollSocketChannel()) // 選擇合適的 SocketChannel
            .option(ChannelOption.SO_LINGER, -1) // 對應套接字選項SO_LINGER 
            .option(ChannelOption.TCP_NODELAY, true) // 對應套接字選項 TCP_NODELAY
            .handler(new ZKClientPipelineFactory(addr.getHostString(), addr.getPort())); // 設置處理器
	bootstrap = configureBootstrapAllocator(bootstrap);
    bootstrap.validate();

    connectLock.lock();
    try {
    	// Netty 異步調用
    	connectFuture = bootstrap.connect(addr);
    	// 監聽並處理返回結果
        connectFuture.addListener(new ChannelFutureListener() {
        	@Override
            public void operationComplete(ChannelFuture channelFuture) throws Exception {
                boolean connected = false;
                connectLock.lock();
                try {
                	if (!channelFuture.isSuccess()) {
                		// 連接失敗則直接返回
                    	return;
                    } else if (connectFuture == null) {
                        // 如果 connectFuture 爲空則證明嘗試連接被取消
                        // 但是因爲可能已經連接成功了,所以應當確保 channel 被正常關閉
                        channelFuture.channel().close();
						return;
                    }
                    
                    // lenBuffer 僅用於讀取傳入消息的長度(該 Buffer 長度爲 4 byte),具體描述見 RPC 網絡數據結構
                    lenBuffer.clear();
                    incomingBuffer = lenBuffer;
                        
                    // 設置 Session、之前的觀察者和身份驗證
                    sendThread.primeConnection();
                    
                    connected = true;
                } finally {
                    connectFuture = null;
                    connectLock.unlock();
                    // 喚醒發送線程中的發送邏輯(向 outgoingQueue 中添加一個 WakeupPacket 空包)
                    wakeupCnxn();
                    // 避免 ClientCnxn 中的 SendThread 在 doTransport() 中等待第一次連接而被阻塞並最終超時
                    firstConnect.countDown();
                }
            }
        });
    } finally {
        connectLock.unlock();
    }
}

  最後,在 connect 方法中需要注意下面的幾點:

  • 設置 Bootstrap 的處理器爲 ZKClientPipelineFactory,準確來說是 ZKClientHandler(如下圖);
  • 初始化設置 incomingBuffer = lenBuffer,保證第一次讀入的是數據包中真實數據的長度(首部 4byte ByteBuffer);
  • 完成連接後需要通過 wakeupCnxn() 方法來喚醒發送線程的發送邏輯(實質是發送一個空的 WakeupPacket);
// ZKClientPipelineFactory.java
protected void initChannel(SocketChannel ch) throws Exception {
	ChannelPipeline pipeline = ch.pipeline();
    if (clientConfig.getBoolean(ZKClientConfig.SECURE_CLIENT)) {
    	initSSL(pipeline);
    }
    // 配置 Pipeline
    pipeline.addLast("handler", new ZKClientHandler());
}

3.2 SendThread 從 outgoingQueue 獲取併發送 Packet

// ClientCnxn.SendThread.java
public void run() {
	while (state.isAlive()) {
    	try {
			if (!clientCnxnSocket.isConnected()) {
            	// 連接服務端邏輯..
			}
			// 省略部分代碼..

			// 發送 outgoingQueue 中的數據包並將已發送的數據包轉移到 PendingQueue 中
        	clientCnxnSocket.doTransport(to, pendingQueue, ClientCnxn.this);
        	
        	// 省略部分代碼..
		} 
	}

  當完成了客戶端連接後即可進入到請求發送的邏輯中,客戶端發送請求的邏輯主要位於 ClientCnxn 的 SendThread 線程中,在初始化時我們已經啓動了該線程,所以當連接建立完成後會通過調用 clientCnxnSocket ( ClientCnxnSocketNetty )的 doTransport 方法發送位於 outgoingQueue 中的 Packet 請求。

// ClientCnxnSocketNetty.java
void doTransport(int waitTimeOut, List<Packet> pendingQueue, ClientCnxn cnxn) throws IOException, InterruptedException {
	try {
		// 該線程方法會等待連接的建立且超時即返回
		if (!firstConnect.await(waitTimeOut, TimeUnit.MILLISECONDS)) {
			return;
		}
		Packet head = null;
		if (needSasl.get()) {
			if (!waitSasl.tryAcquire(waitTimeOut, TimeUnit.MILLISECONDS)) {
				return;
			}
		} else {
			// 從 outgoingQueue 隊列中獲取要發送的 Packet
			head = outgoingQueue.poll(waitTimeOut, TimeUnit.MILLISECONDS);
		}
		// 檢查當前是否正處於關閉流程中
		if (!sendThread.getZkState().isAlive()) {
			// adding back the packet to notify of failure in conLossPacket().添加回數據包以通知conLossPacket()中的失敗
			addBack(head);
			return;
		}
		// 當通道斷開時
		if (disconnected.get()) {
			addBack(head);
			throw new EndOfStreamException("channel for sessionid 0x" + Long.toHexString(sessionId) + " is lost");
		}
		if (head != null) {
			// 調用 doWrite 方法執行實際的發送數據操作
			doWrite(pendingQueue, head, cnxn);
		}
	} finally {
		updateNow();
	}
}

  在 doTransport 方法中首先會 await 等待連接建立,並且在超時後會立即返回(因此在連接建立後需要第一時間喚醒該線程以避免其超時返回),之後會從 outgoingQueue 中取出待發送的 Packet ,並在進行一系列驗證後通過 doWrite 方法來實際發送該 Packet 。

private void doWrite(List<Packet> pendingQueue, Packet p, ClientCnxn cnxn) {
	updateNow();
    boolean anyPacketsSent = false;
    while (true) {
    	// 跳過處理 WakeupPacket 數據包
    	if (p != WakeupPacket.getInstance()) {
        	if ((p.requestHeader != null) &&
            		(p.requestHeader.getType() != ZooDefs.OpCode.ping) &&
                	(p.requestHeader.getType() != ZooDefs.OpCode.auth)) {
				p.requestHeader.setXid(cnxn.getXid());
				
                synchronized (pendingQueue) {
                    // 將該 Packet 添加到 pendingQueue 隊列中
                	pendingQueue.add(p);
                }
            }
            // 只發送數據包到通道,而不刷新通道
            sendPktOnly(p);
            // 記錄當前迭代存在需要被髮送的數據
            anyPacketsSent = true;
        }
        if (outgoingQueue.isEmpty()) {
            break;
        }
        // 將該 Packet 從 outgoingQueue 隊列中出隊
        p = outgoingQueue.remove();
    }

    if (anyPacketsSent) {
    	// 如果本次迭代存在需要被髮送的數據,則調用 flush 刷新 Netty 通道
    	channel.flush();
    }
}

  在 doWrite 方法中會在驗證該 Packet 非 WakeupPacket 後爲其設置請求頭中的 xid ,並將其添加到 pendingQueue 中,最後通過 sendPktOnly 方法將其發送到通道中(暫不刷新通道,方法代碼如下),然後如果 outgoingQueue 中仍存在待發送的 Packet 則繼續重複執行添加 pendingQueue 併發送的邏輯,當 outgoingQueue 中的 Packet 全部處理完成後調用 channel.flush() 刷新通道,將本輪數據一起發送。


// ClientCnxnSocketNetty.java
private ChannelFuture sendPktOnly(Packet p) {
	// 僅發送數據包到通道,而不調用 flush() 方法刷新通道
    return sendPkt(p, false);
}

// ClientCnxnSocketNetty.java
private ChannelFuture sendPkt(Packet p, boolean doFlush) {
	// 創建 ByteBuffer
    p.createBB();
    updateLastSend();
    // 將 ByteBuffer 轉化爲 Netty 的 ByteBuf
    final ByteBuf writeBuffer = Unpooled.wrappedBuffer(p.bb);
    final ChannelFuture result = doFlush
            ? channel.writeAndFlush(writeBuffer)
            : channel.write(writeBuffer);
    result.addListener(onSendPktDoneListener);
    return result;
}

3.3 同步版 RPC 調用流程(Create API)

3.3.1 創建 Packet 併入隊 outgoing (且 Packet.Wait)

// Zookeeper.java
public String create(final String path, byte data[], List<ACL> acl, CreateMode createMode) throws KeeperException, InterruptedException {
	final String clientPath = path;
	// 相關信息驗證
    PathUtils.validatePath(clientPath, createMode.isSequential());
    EphemeralType.validateTTL(createMode, -1);

    final String serverPath = prependChroot(clientPath);

	// 創建 Request 請求包和 Response 響應包
    RequestHeader h = new RequestHeader();
    h.setType(createMode.isContainer() ? ZooDefs.OpCode.createContainer : ZooDefs.OpCode.create);
    CreateRequest request = new CreateRequest();
    CreateResponse response = new CreateResponse();
    request.setData(data);
    request.setFlags(createMode.toFlag());
    request.setPath(serverPath);
    if (acl != null && acl.size() == 0) {
    	throw new KeeperException.InvalidACLException();
    }
    request.setAcl(acl);

	// 提交請求並接收返回頭
    ReplyHeader r = cnxn.submitRequest(h, request, response, null);
    // 處理異常
    if (r.getErr() != 0) {
    	throw KeeperException.create(KeeperException.Code.get(r.getErr()), clientPath);
    }
    // 返回結果
    if (cnxn.chrootPath == null) {
        return response.getPath();
    } else {
        return response.getPath().substring(cnxn.chrootPath.length());
    }
}

  當我們在客戶端使用 Create 命令創建節點時,實際會調用到 Zookeeper 的 Create 方法,而 Create 方法存在兩個版本的實現,首先就是同步版本的實現,在同步版本中 Create 方法的邏輯可以概括爲以下五步:

  1. 驗證相關信息的有效性(包括驗證客戶端的路徑以及創建模式的選擇);
  2. 創建 Request 請求包和 Response 響應包,併爲 Request 請求包填充數據;
  3. 通過調用 ClientCnxnsubmitRequest 方法提交請求並接收請求結果;
  4. 處理請求結果中的異常;
  5. 將請求結果處理後返回;
// ClientCnxn.java
public ReplyHeader submitRequest(RequestHeader h, Record request, Record response, WatchRegistration watchRegistration, WatchDeregistration watchDeregistration) throws InterruptedException {

	ReplyHeader r = new ReplyHeader();
	// 根據 Request 數據和 Response 數據打包創建 Packet
    Packet packet = queuePacket(h, r, request, response, null, null, null, null, watchRegistration, watchDeregistration);
    
    synchronized (packet) {
        if (requestTimeout > 0) {
            // 等待請求完成超時
            waitForPacketFinish(r, packet);
        } else {
            // 無限等待請求完成
            while (!packet.finished) {
                packet.wait();
            }
        }
    }
    if (r.getErr() == Code.REQUESTTIMEOUT.intValue()) {
        // 如果請求超時則清空 outgoingQueue 和 pendingQueue
        sendThread.cleanAndNotifyState();
    }
    return r;
}

  submitRequest 方法中的邏輯相對比較清晰,該方法首先會調用 queuePacket 方法創建 Packet 並將其入隊 outgoing ,之後同步該 Packet,最後根據是否設置了超時時間來選擇是否使用超時邏輯,如果設置了超時時間,當請求在超時時間內未完成即返回並清空相關隊列(outgoingQueue 和 pendingQueue),而如果未設置超時時間則該線程無限期的 wait 在該 Packet 直至接收到該請求的響應(在接收到請求的響應後該線程會被 notify)。

// ClientCnxn.java
public Packet queuePacket(RequestHeader h, ReplyHeader r, Record request, Record response, AsyncCallback cb, String clientPath, String serverPath, Object ctx, WatchRegistration watchRegistration, WatchDeregistration watchDeregistration) {
        Packet packet = null;

		// 創建一個 Packet 實例並將相關數據填入
        packet = new Packet(h, r, request, response, watchRegistration);
        packet.cb = cb;
        packet.ctx = ctx;
        packet.clientPath = clientPath;
        packet.serverPath = serverPath;
        packet.watchDeregistration = watchDeregistration;
        
        // 同步狀態(下面文字中描述原因)
        synchronized (state) {
            if (!state.isAlive() || closing) {
            	// 如果當前連接已斷開或者正在關閉則返回相應的錯誤信息
                conLossPacket(packet);
            } else {
                // 如果客戶端要求關閉會話,則將其狀態標記爲正在關閉(Closing)
                if (h.getType() == OpCode.closeSession) {
                    closing = true;
                }
                // 將 Packet 添加到 outgoing 隊列中等待發送
                outgoingQueue.add(packet);
            }
        }
        
        // 喚醒發送線程發送 outgoing 隊列中的 Packet(Netty 中爲空實現)
        sendThread.getClientCnxnSocket().packetAdded();
        return packet;
    }

  在 queuePacket 方法中會創建一個 Packet 實例,並將入參中的 request 請求包和 response 響應包等數據填充到該 Packet 中。但需要注意的是,到目前爲止還沒有爲包生成 Xid ,它是在稍後發送時由 ClientCnxnSocket::doIO() 實現生成的,因爲 Packet 實際上是在那裏被髮送的。然後會同步在狀態值 state 上,這裏之所以要同步在該狀態上的原因有兩點:

  1. 同步 SendThread.run() 中的 cleanup() 操作以避免競爭;
  2. 通過對每個包進行同步,如果一個 closeSession 包被添加,後面的包都會被通知;

  在同步後會首先判斷當前連接的狀態,如果當前連接已斷開或者正在關閉則直接返回相應的錯誤信息,而當連接狀態正常時,首先會判斷該客戶端請求的類型是否爲 closeSession ,如果爲該類型則意味着客戶端需要關閉該連接,所以設置當前的狀態爲正在關閉(closing),然後將該 Packet 入隊 outgoingQueue 等待發送,最後喚醒發送邏輯(在 Netty 實現版本中添加一個 Packet 將會喚醒一個網絡連接,所以我們不需要向隊列添加一個虛擬包來觸發喚醒,因此 NettyClientCnxnSocket 中的 packetAdded 方法爲空實現)。

  到這裏爲止( Packet 入隊 outgoingQueue 且線程 wait )同步版本的 Create API 實現第一部分已經分析完成,下面我們來大概總結一下主要的流程:

  1. 首先在 Zookeeper 的 Create() 方法中根據入參創建 Request 請求包和 Response 響應包;
  2. 調用 ClientCnxn 的 submitRequest() 方法提交該請求,在 submitRequest() 方法中會調用 queuePacket() 方法;
  3. 在 queuePacket() 方法中將入參中的 Request 請求包和 Response 響應包以及其它信息封裝爲 Packet ,然後將其入隊 outgoingQueue 並喚醒發送邏輯;
  4. 返回到 ClientCnxn 的 submitRequest() 方法中,根據是否設置超時時間選擇不同的邏輯來將該線程 wait ;

3.3.2 處理響應並出隊 pendingQueue(且 Packet.notifyAll)

// ClientCnxnSocketNetty.ZKClientHandler.java
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {
	updateNow();
    while (buf.isReadable()) {
    
    	if (incomingBuffer.remaining() > buf.readableBytes()) {
    		// 如果 incomingBuffer 中剩餘的空間大於 ByteBuf 中可讀數據長度
    		// 重置 incomingBuffer 中的 limit 爲 incomingBuffer 當前的 position 加上 ByteBuf 中可讀的數據長度
        	int newLimit = incomingBuffer.position() + buf.readableBytes();
			incomingBuffer.limit(newLimit);
        }

		// 將 ByteBuf 中的數據讀入到 incomingBuffer( ByteBuffer)中
		buf.readBytes(incomingBuffer);
        incomingBuffer.limit(incomingBuffer.capacity());

        if (!incomingBuffer.hasRemaining()) {
        	incomingBuffer.flip();
        	if (incomingBuffer == lenBuffer) {
        		recvCount.getAndIncrement();
        		// 當 incomingBuffer 等於 lenBuffer 時首先讀取數據包中數據的長度 4byte
            	readLength();
        	} else if (!initialized) {
        		// 如果未進行初始化則首先讀取連接結果
        		readConnectResult();
        		// 重置 lenBuffer 並重新初始化 incomingBuffer 爲 lenBuffer 來讀取數據包中真正數據的長度
            	lenBuffer.clear();
            	incomingBuffer = lenBuffer;
            	initialized = true;
            	updateLastHeard();
        	} else {
        		// 讀取數據包中真正的數據
        		sendThread.readResponse(incomingBuffer);
        		// 重置 lenBuffer 並重新初始化 incomingBuffer 爲 lenBuffer 來讀取數據包中真正數據的長度
            	lenBuffer.clear();
            	incomingBuffer = lenBuffer;
            	updateLastHeard();
        	}
    	}
	}
	
	// 喚醒發送邏輯
    wakeupCnxn();
}

// ClientCnxnSocket.java
void readLength() throws IOException {
	// 讀取數據包中數據的長度 len
	int len = incomingBuffer.getInt();
    if (len < 0 || len >= packetLen) {
    	throw new IOException("Packet len" + len + " is out of range!");
    }
    // 重新申請長度爲 len 的 ByteBuffer 來讀取真正的數據
    incomingBuffer = ByteBuffer.allocate(len);
}

  因爲我們整體的分析以 Netty 實現爲準,因此當接收到響應後會調用我們在創建 Netty Bootstrap 時所設置的 ZKClientHandler 中的 channelRead0 方法來處理數據。因爲在 channelRead0 方法中是將 Netty 的 ByteBuf 轉換爲了 NIO 的 ByteBuffer 來進行處理,所以我們先大概明確幾個比較重要的 NIO ByteBuffer 方法的作用:

  1. ByteBuffer.remaining() :limit - position;
  2. ByteBuffer.hasRemaining() :position < limit;
  3. ByteBuffer.clear() :position = 0; limit = capacity; mark = -1;
  4. ByteBuffer.flip() :position = 0; limit = position; mark = -1;

  首先在初始化連接的過程中(connect 方法中)我們就已將 incomingBuffer 設置爲 lenBuffer(4 byte ByteBuffer),因此當數據到達時 incomingBuffer 會先將 ByteBuf 中的前 4byte 數據讀入,然後調用 readLength 方法來獲取這 4byte 所代表的 int 數值(真實數據的長度),之後再創建一個新的長度爲真實數據長度的 ByteBuffer 賦值給 incomingBuffer,這樣在下一輪的讀取過程中 incomingBuffer 就可以從 ByteBuf 中一次性完整的讀出所有的真實數據,最後調用 readResponse 方法來處理讀取到的真實數據。

// ClientCnxn.SendThread.java
void readResponse(ByteBuffer incomingBuffer) throws IOException {
	ByteBufferInputStream bbis = new ByteBufferInputStream(incomingBuffer);
	BinaryInputArchive bbia = BinaryInputArchive.getArchive(bbis);
	//創建臨時響應頭
	ReplyHeader replyHdr = new ReplyHeader();

	// 解析數據包中的數據填充臨時響應頭,然後根據臨時響應頭中的 xid 進行分類處理
	replyHdr.deserialize(bbia, "header");
	if (replyHdr.getXid() == -2) {
		// xid == -2 爲心跳包
		// 心跳包不做處理直接返回
		return;
	}
	if (replyHdr.getXid() == -4) {
    	// xid == -4 爲認證包               
        // 省略處理錯誤邏輯...
        return;
	}
    if (replyHdr.getXid() == -1) {
    	// xid == -1 爲通知包
        // 省略通知處理邏輯...
        return;
    }

	Packet packet;
    synchronized (pendingQueue) {
    	// 如果 pendingQueue 爲空則直接拋異常,否則出隊一個 Packet
    	if (pendingQueue.size() == 0) {
        	throw new IOException("Nothing in the queue, but got " + replyHdr.getXid());
        }
        packet = pendingQueue.remove();
    }

	// 由於請求是按順序處理的,所以獲得對第一個請求的響應
	try {
		// 如果 pendingQueue 剛剛出隊的 Packet 不是當前響應所對應的 Packet 則證明出現異常
		if (packet.requestHeader.getXid() != replyHdr.getXid()) {
			packet.replyHeader.setErr( KeeperException.Code.CONNECTIONLOSS.intValue());
		}

		// 填充 Packet 響應頭數據
        packet.replyHeader.setXid(replyHdr.getXid());
        packet.replyHeader.setErr(replyHdr.getErr());
        packet.replyHeader.setZxid(replyHdr.getZxid());
        // 更新最後處理的 zxid
		if (replyHdr.getZxid() > 0) {
			lastZxid = replyHdr.getZxid();
		}
		// 如果 Packet 中存在 Response 響應包(數據爲空)且響應頭解析未出現錯誤則繼續解析響應體數據
		if (packet.response != null && replyHdr.getErr() == 0) {
			packet.response.deserialize(bbia, "response");
		}
    } finally {
    	// 完整響應數據解析後調用
    	finishPacket(packet);
	}
}

  readResponse 方法首先從剛剛讀入數據的 ByteBuffer 中解析出一個臨時響應頭,然後根據這個臨時響應頭中的 xid 來進行分類處理, 當處理完成後會從 pendingQueue 中出隊一個 Packet,這個 Packet 正常來說應當是我們之前發送最後一個請求後入隊的那個 Packet (請求順序性),因此判斷這個出隊的 Packet 的 xid 是否等於當前正在處理的這個請求中的 Packet 的 xid ,如果不是則證明出現了丟包或斷連等問題,所以向臨時響應頭中添加一個錯誤信息,然後將臨時響應頭中的數據填充到剛剛出隊的那個 Packet 的 ReplyHeader 響應頭中並更新最後處理的 zxid 屬性值( lastZxid ),最終如果確認該 Packet 中存在 Response(需要返回響應信息)並且在解析響應頭的過程中未發現錯誤,則開始從 ByteBuffer 中解析出響應體並賦給 Packet 的 Response 屬性,當全部處理完成時,最終調用 finishPacket 方法完成 ByteBuffer 的 Response 解析。

// ClientCnxn.java
    protected void finishPacket(Packet p) {
        // 省略 watch 事件處理邏輯...
        if (p.cb == null) {
        	// 如果 Packet 中不存在方法回調(同步 API)
            synchronized (p) {
            	// 設置 Packet 處理完成
                p.finished = true;
                // 喚醒所有 wait 在該 Packet 上的線程
                p.notifyAll();
            }
        } else {
        	// 如果 Packet 中不存在方法回調(異步 API),先設置 Packet 處理完成
            p.finished = true;
            // 進入異步 Packet 的處理邏輯
            eventThread.queuePacket(p);
        }
    }

  finishPacket 方法在響應處理完成後就會被調用,在這個方法中首先會對 Watch 事件進行處理,然後判斷當前 Packet 中是否存在回調方法(本次調用是同步還是異步),如果不存在回調方法則證明本次調用爲同步調用,因此更新 Packet 的 finished 狀態後通過 Packet 的 notifyAll 方法喚醒所有 wait 在該 Packet 上的線程(wait 邏輯位於 ClientCnxn 的 submitRequest 方法),而如果存在回調方法,則應通過調用 EventThread 的 queuePacket 方法進入對於異步回調的處理邏輯中。


3.4 異步版 RPC 調用流程(Create API)

3.4.1 創建 Packet 併入隊 outgoing

public void create(final String path, byte data[], List<ACL> acl, CreateMode createMode, StringCallback cb, Object ctx){

        final String clientPath = path;
        // 相關信息驗證(與同步版相同)
        PathUtils.validatePath(clientPath, createMode.isSequential());
        EphemeralType.validateTTL(createMode, -1);

        final String serverPath = prependChroot(clientPath);

		// 創建 Request 請求包和 Response 響應包(與同步版相同)
        RequestHeader h = new RequestHeader();
        h.setType(createMode.isContainer() ? ZooDefs.OpCode.createContainer : ZooDefs.OpCode.create);
        CreateRequest request = new CreateRequest();
        CreateResponse response = new CreateResponse();
        ReplyHeader r = new ReplyHeader();
        request.setData(data);
        request.setFlags(createMode.toFlag());
        request.setPath(serverPath);
        request.setAcl(acl);

		// 直接調用 queuePacket 方法創建 Packet 併入隊 outgoingQueue
        cnxn.queuePacket(h, r, request, response, cb, clientPath, serverPath, ctx, null);
    }

  其實異步版的 API 和同步版 API 是大體相同的,只不過在異步版本的 API 中僅需要調用 queuePacket 方法創建 Packet 併入隊 outgoingQueue ,然後直接返回即可,而不需要再在 submitRequest 方法中 wait 等待請求響應的返回。


3.4.2 入隊 waitingEvents 並在出隊時進行方法回調

  在接收並處理 Response 的過程中同步版和異步版的 API 前面的處理邏輯都是完全相同的,差異之處在於在 finishPacket 方法中同步版調用會直接喚醒所有 wait 在該 Packet 上面的線程然後返回,而對於異步版本則會調用到 queuePacket 方法來對響應做進一步的處理。

// ClientCnxn.EventThread.java
public void queuePacket(Packet packet) {
	if (wasKilled) {
		// EventThread 在接收到 eventOfDeath 後 wasKilled 將被設爲 true
        synchronized (waitingEvents) {
        	// 如果 EventThread 仍在運行(isRunning == true)則將 Packet 入隊 waitingEvents
            if (isRunning) waitingEvents.add(packet);
            // 否則直接調用 processEvent 方法處理該 Packet
        	else processEvent(packet);
    	}
	} else {
		// 如果 EventThread 線程正常運行則直接將 Packet 入隊 waitingEvents
    	waitingEvents.add(packet);
	}
}

  queuePacket 方法主要執行的就是將 Packet 入隊 waitingEvents 的邏輯,但是需要注意的是對於 EventThread 存在兩個標誌量(下文會細講),且當 wasKilled 爲 true 時並不是意味着 EventThread 已經完全不能處理 Packet 了,還需要再次判斷 isRunning 來確定當前 EventThread 是否真的已經停止運行了,如果當前 wasKilled 爲 true 且 isRunning 爲 false 則證明 EventThread 已經真正的結束了,所以該方法會自己調用 processEvent 方法來處理該 Packet 。
  

// ClientCnxn.EventThread.java
public void run() {
	try {
    	isRunning = true;
        while (true) {
        // 從 waitingEvents 隊列中獲取事件
        Object event = waitingEvents.take();
        if (event == eventOfDeath) {
        	// 如果事件類型爲 eventOfDeath 則修改 wasKilled 標誌值
        	wasKilled = true;
        } else {
        	// 否則通過調用 processEvent 處理該事件
        	processEvent(event);
        }
        if (wasKilled)
        synchronized (waitingEvents) {
            // 如果當前狀態爲 wasKilled == true 則判斷當前 waitingEvents 是否已空
        	if (waitingEvents.isEmpty()) {
        		// 如果 waitingEvents 已空則設置 isRunning 爲 false 來終止當前線程的運行
            	isRunning = false;
                break;
            }
        }
    }
}

  當我們在 queuePacket 方法中將 Packet 入隊 waitingEvents 後,在 ClientCnxn 的內部類(線程)EventThread 中會通過 run 方法不斷取出隊列中的 Packet ,然後調用 processEvent 方法進行處理。

  這裏設計很巧妙的就是對於關閉該線程時的操作,在該線程中使用了兩個標誌量 wasKilledisRunning ,當外部將要關閉它時會通過發送類型爲 eventOfDeath 的 Packet 先設置 wasKilled 爲 true,此時進入關閉的第一階段。EventThread 得到該關閉消息後開始進行掃尾工作,在每次處理完一個 Packet 後就會判斷 waitingEvents 中是否還存在未處理的 Packet ,如果存在就繼續處理,如果不存在就將 isRunning 設置爲 false 並跳出循環,此時標誌着 EventThread 已經完成了掃尾工作,可以正常關閉了。因此,EventThread 可以安全的進入到最後一個線程的關閉階段。

  這樣的三階段關閉流程保證了數據的安全性,保證了 EventThread 不會在 waitingEvents 還存在數據時就關閉而導致數據丟失,同時也正是因爲這樣,當通過 queuePacket 方法向 waitingEvents 中添加元素時,就算 wasKilled 已經爲 true 了,但只要 isRunning 還爲 true 就證明 waitingEvents 中還存在數據既 EventThread 還可以處理數據,所以仍然可以放心的將 Packet 入隊 waitingEvents 來交給 EventThread 處理,且不會發生數據丟失的情況。

  

// ClientCnxn.EventThread.java
private void processEvent(Object event) {
	try {
		// 重構後的代碼,省略巨多各種事件類型的判斷和處理邏輯...
		// 當進行異步 Create 時,事件類型爲 CreateResponse 
    	if (p.response instanceof CreateResponse) {
    		// 獲取 Packet 中的回調信息
        	StringCallback cb = (StringCallback) p.cb;
        	// 獲取 Packet 中的響應體
            CreateResponse rsp = (CreateResponse) p.response;
            if (rc == 0) {
            	cb.processResult(rc, clientPath, p.ctx,
                	(chrootPath == null ? rsp.getPath() : rsp.getPath().substring(chrootPath.length())));
			} else {
				// 進行方法回調
				cb.processResult(rc, clientPath, p.ctx, null);
			}
		}
	}
}

  最後,在 processEvent 方法中會根據傳入 Packet 的類型來選擇不同的處理邏輯對 Packet 進行處理,因爲我們這裏分析的是 Create API ,而其對應的響應類型爲 CreateResponse ,所以會進入到如上圖代碼的邏輯中,具體的處理方式也就是獲取到 Packet 中所保存的回調方法,然後對其進行回調即可,至此也就完成了整個異步版本的方法調用。

四、同步版 API 和異步版 API 總結與比較

4.1 版本總結

  首先經過上面的源碼分析我們先總結一下幾個比較重要的數據結構和屬性:

  • outgoingQueue :保存待發送的 Packet 的隊列;
  • pendingQueue :保存已發送但還未接收到響應的 Packet 的隊列;
  • waitingEvents :保存已接收到響應待回調的 Packet 的隊列;
  • EventThread.wasKilled :外部發送信號終止 EventThread ,但此時可能尚未真正停止;
  • EventThread.isRunning :標誌着 EventThread 尚在運行(waitingEvents 中還存在未處理的 Packet),當該屬性爲 false 時證明線程進入終止狀態;

  下面總結 同步版 API 主流程:

  1. 創建 Packet 併入隊 outgoingQueue ,然後線程 wait 在該 Packet 進行等待;
  2. SendThread 從 outgoingQueue 中取出 Packet 後進行發送,併入隊 pendingQueue ;
  3. 接收響應後從 pendingQueue 中出隊 Packet ,然後將響應數據解析到 Packet中;
  4. 解析完成後調用 Packet.notifyAll 方法喚醒所有阻塞在該 Packet 上的線程;

  下面總結 異步版 API 主流程:

  1. 創建 Packet 併入隊 outgoingQueue ,然後方法直接返回;
  2. SendThread 從 outgoingQueue 中取出 Packet 後進行發送,併入隊 pendingQueue ;
  3. 接收響應後從 pendingQueue 中出隊 Packet ,然後將響應數據解析到 Packet中;
  4. 解析完成後將該 Packet 入隊 waitingEvents ,然後 EventThread 會從 waitingEvents 中取出 Packet 並調用其回調方法;

4.2 版本比較

  最後總結一下,其實異步版本的 API 與同步版的 API 大體上都是一致的,差別之處在於同步版本中在將 Packet 入隊 outgoingQueue 後會 wait 等待,而異步版本則會在入隊後直接返回。其次在接收到請求的響應後,同步版本會喚醒所有 wait 在該 Packet 上的線程然後返回,而異步版本則會繼續將 Packet 入隊 waitingEvents ,然後在 EventThread 中對其進行出隊後再對其 Packet 中的回調方法進行調用。


五、內容總結

  這篇博文從 Zookeeper 源碼的角度分析了客戶端中網絡連接建立的流程,並且分析了同步版和異步版的 Create API 的具體代碼實現,並通過對兩種實現的方式的梳理和總結得到了 Zookeeper 客戶端網絡通信的普適邏輯。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章