OkHttp 原始碼剖析系列(六)——連線複用機制及連線的建立

N0tExpectErr0r發表於2020-01-03

你好,我是 N0tExpectErr0r,一名熱愛技術的 Android 開發

我的個人部落格:blog.N0tExpectErr0r.cn

OkHttp 原始碼剖析系列文章目錄:

OkHttp 原始碼剖析系列(一)——請求的發起及攔截器機制概述

OkHttp 原始碼剖析系列(二)——攔截器整體流程分析

OkHttp 原始碼剖析系列(三)——快取機制

OkHttp 原始碼剖析系列(四)——連線建立概述

OkHttp 原始碼剖析系列(五)——代理路由選擇

OkHttp 原始碼剖析系列(六)——連線複用機制及連線的建立

OkHttp 原始碼剖析系列(七)——請求的發起及響應的讀取

findConnection 的過程中無法從 transmitter 中取得 Connection 時,會呼叫 connectionPool.transmitterAcquirePooledConnection 方法來嘗試從連線池中獲取連線,讓我們從這篇文章開始研究一下 OkHttp 中連線池的實現。

HTTP 中的 TCP 連線管理

HTTP/1.0

在 HTTP/1.0 中,由於 HTTP 協議是一種無連線的網路協議,進行一次 HTTP 請求是這樣的一條流程:

image-20190803100926274

這樣設計可以保證每條 HTTP 請求都是獨立的,互不干擾。但這樣的設計有一個致命的缺點——如果我們向同一個伺服器發起數十個 HTTP 請求,則我們的每條 HTTP 請求都需要與這個伺服器建立一條 TCP 連線。而我們知道,建立 TCP 連線需要經過三次握手,而關閉 TCP 連線則需要四次揮手,可想而知這樣頻繁地建立與關閉 TCP 連線對網路資源的消耗是十分嚴重的,極大地降低了網路的效率,並且提高了伺服器的壓力。

在 HTTP/1.0 中存在一個名為 Connection:Keep-Alive 的 Header,但沒有官方的標準規定其工作機制,它預設是關閉的,可以通過在 Header 中加入從而開啟。當客戶端及服務端都對 Keep-Alive 機制支援時,就可以維持該 TCP 連線從而使得下一次可以進行復用。

HTTP/1.1

而在 HTTP/1.1 中,真正引入了 Keep-Alive 機制,它預設是開啟的,可以通過 Connection:close 進行關閉。在 HTTP 請求結束時,若啟動了 Keep-Alive 機制,則該連線並不會立即關閉,此時如果有新的請求到來,且 host 相同,則會複用這條 TCP 連線進行請求,減少了 TCP 連線的頻繁建立與關閉的資源消耗。

image-20190803103322539

通過這樣的連線複用的做法,可以大幅地減少對資源的消耗,如下圖所示:

OkHttp 原始碼剖析系列(六)——連線複用機制及連線的建立

同時,在 HTTP/1.1 中還引入了 Keep-Alive 請求頭,在其中可以設定兩個值:timeoutmax ,從而設定這個連線何時被關閉。

  • timeout:指定了一個空閒連線需要保持開啟狀態的最小時長(以秒為單位)
  • max:在連線關閉之前,在此連線可以傳送的請求的最大值

但這樣就存在了一個問題,在原來不採用 Keep-Alive 的時候,客戶端可以通過 TCP 連線是否關閉來判斷資料是否接收完成,但在採用了 Keep-Alive 的情況下,客戶端如何才能得知自己需要的資料已經接收完畢了呢?

Content-Length

看過我之前的多執行緒下載的實現博文的讀者,應該知道在服務端的 ResponseHeader 中,會包含 Content-Length 這一欄位,它表示了實體內容的長度(比如檔案 / 圖片的大小),通過該欄位客戶端就可以確定自己需要接受的位元組數。從而確認資料已接收完成。

Transfer-Encoding:chunked

前面的 Content-Length 看上去完美解決了無法判斷資料接收完畢的問題。但對於一些動態的場景,比如一些動態頁面,服務端是無法預先知道該頁面的大小的,在該頁面建立完成前,其長度是不可知的,服務端也就無法返回一個確切的 Content-Length 欄位給客戶端了,只能開啟一個足夠大的 buffer。

此時,就可以採用 Transfer-Encoding:chunked 來實現,它表示一種分塊編碼的意思,它只在 HTTP/1.1 中提供,允許服務端將傳送給客戶端的資料分成多個部分。

如果使用了分塊編碼,則請求及響應有以下的特點:

  1. 在 Header 中加入 Transfer-Encoding:chunked,表示使用分塊編碼
  2. 每一個分塊有兩行,每一行都以 \r\n 結尾,第一行表示這個分塊的資料長度,是一個十六進位制的數(不包括資料結尾的 \r\n,第二行則是這個分塊的具體資料。
  3. 最後一個分塊長度為0,且資料沒有內容,表示整個實體的結束。

HTTP/2

在前面的 HTTP/1.1 中,雖然實現了 TCP 連線的複用,但仍有如下幾個缺陷:

  1. 如果客戶端想要發起並行的請求,則必須建立多個 TCP 連線,這對網路資源的消耗也是十分嚴重的。
  2. 不會讀對請求及響應的 Header 進行壓縮,造成了網路流量的浪費。
  3. 不支援資源優先順序導致 TCP 連線利用率低下。

多路複用

為了解決上面幾個問題,HTTP/2 引入了多路複用機制,同時引入了幾個新的概念:

  • 資料流:基於 TCP 連線上的一個雙向的位元組流,每發起一個請求,就會建立一個資料流,後續的請求過程的資料傳遞都通過該流進行
  • 資料幀:HTTP/2 中的資料最小切片單位,其中又分為了 Header FrameData Frame 等等。
  • 訊息:一個請求或響應對應的一系列資料幀。

引入了這些概念之後,在 HTTP 請求的過程中,服務端/客戶端首先會將我們的請求/響應切分為不同的資料幀,當另一方接收到後再將其組裝從而形成完整的請求/響應,如下所示

image-20190803114234258

這樣,就實現了對 TCP 連線的多路複用,將一個請求或響應分為了一個個的資料幀,使得多個請求可以並行地進行。

多路複用與 Keep-Alive 的區別

  1. Keep-Alive 機制雖然解決了複用 TCP 連線問題,但沒有解決請求阻塞的問題,需要等到上一個請求結束後,才能複用該 TCP 連線進行下一個請求。
  2. HTTP/1.x 對資料的傳遞仍然是以一個整體進行傳遞,而在 HTTP/2 中引入了資料幀的概念,使得多個請求可以同時在流中進行傳遞。
  3. HTTP/2 採用了 HPACK 壓縮演算法對 Header 進行壓縮,降低了請求的流量消耗。

img

OkHttp 中的複用機制

前面提了 HTTP 中的複用機制,通過對 TCP 連線的複用,大幅提高了網路請求的效率。無論是 HTTP/1.1 中的 Keep-Alive 還是 HTTP/2 中的多路複用,都需要連線池來維護 TCP 連線,讓我們看看 OkHttp 中連線池的實現。

我們知道,在 findConnection 過程中,若無法從 transimitter 中獲取到連線,則會嘗試從連線池中獲取連線。

我們可以看到 RealConnectionPool.connections,它是一個 Deque,儲存了所有的連線:

private final Deque<RealConnection> connections = new ArrayDeque<>();
複製程式碼

連線清理機制

同時會發現,在這個類中還存在著一個 executor,它的設定與 OkHttp 用於非同步請求的執行緒池的設定幾乎一樣,它是用來做什麼的呢?

/**
 * Background threads are used to cleanup expired connections. There will be at most a single
 * thread running per connection pool. The thread pool executor permits the pool itself to be
 * garbage collected.
 */
private static final Executor executor = new ThreadPoolExecutor(0 /* corePoolSize */,
        Integer.MAX_VALUE /* maximumPoolSize */, 60L /* keepAliveTime */, TimeUnit.SECONDS,
        new SynchronousQueue<>(), Util.threadFactory("OkHttp ConnectionPool", true));
複製程式碼

通過上面的註釋可以看出,它是用來執行清理過期連線的任務的,並且最多每個連線池只會有一個執行緒在執行清理任務。這個清理的任務就是下面的 cleanupRunnable

private final Runnable cleanupRunnable = () -> {
    while (true) {
        long waitNanos = cleanup(System.nanoTime());
        if (waitNanos == -1) return;
        if (waitNanos > 0) {
            long waitMillis = waitNanos / 1000000L;
            waitNanos -= (waitMillis * 1000000L);
            synchronized (RealConnectionPool.this) {
                try {
                    RealConnectionPool.this.wait(waitMillis, (int) waitNanos);
                } catch (InterruptedException ignored) {
                }
            }
        }
    }
};
複製程式碼

可以看到它是採用一個迴圈的方式呼叫 cleanup 方法進行清理,並從返回值中獲取了需要 wait 的秒數,呼叫 wait 方法進入阻塞,也就是說每次清理的間隔由 cleanup 的返回值進行決定

我們看到 cleanup 方法:

/**
 * Performs maintenance on this pool, evicting the connection that has been idle the longest if
 * either it has exceeded the keep alive limit or the idle connections limit.
 *
 * <p>Returns the duration in nanos to sleep until the next scheduled call to this method. Returns
 * -1 if no further cleanups are required.
 */
long cleanup(long now) {
    int inUseConnectionCount = 0;
    int idleConnectionCount = 0;
    RealConnection longestIdleConnection = null;
    long longestIdleDurationNs = Long.MIN_VALUE;
    synchronized (this) {
        for (Iterator<RealConnection> i = connections.iterator(); i.hasNext(); ) {
            RealConnection connection = i.next();
			// 統計連線被引用的transimitter的個數,若大於0則說明是正在使用的連線
			if (pruneAndGetAllocationCount(connection, now) > 0) {
                inUseConnectionCount++;
                continue;
            }
            // 否則是空閒連線
            idleConnectionCount++;
            // 找出空閒連線中空閒時間最長的連線
            long idleDurationNs = now - connection.idleAtNanos;
            if (idleDurationNs > longestIdleDurationNs) {
                longestIdleDurationNs = idleDurationNs;
                longestIdleConnection = connection;
            }
        }
        if (longestIdleDurationNs >= this.keepAliveDurationNs
                || idleConnectionCount > this.maxIdleConnections) {
            // 如果發現空閒時間最久的連線所空閒時間超過了Keep-Alive設定的時間,或者是空閒連線數超過了最大空閒連線數
            // 將前面的其從佇列中刪除,並且在之後對其socket進行關閉
            connections.remove(longestIdleConnection);
        } else if (idleConnectionCount > 0) {
            // 返回離達到keep-alive設定的時間的距離,將在達到時執行進行清理
            return keepAliveDurationNs - longestIdleDurationNs;
        } else if (inUseConnectionCount > 0) {
            // 如果當前連線都是正在使用的,返回keep-alive所設定的時間
            return keepAliveDurationNs;
        } else {
            // 沒有連線了,停止執行cleanup
            cleanupRunning = false;
            return -1;
        }
    }
    // 關閉空閒最久的連線,繼續嘗試清理
    closeQuietly(longestIdleConnection.socket());
    return 0;
}
複製程式碼

可以看到,主要是下面幾步:

  1. 呼叫 pruneAndGetAllocationCount 方法統計連線被引用的數量,大於 0 說明連線正在被使用
  2. 通過上面的方法統計空閒連線數及正在使用的連線數,並從中找出空閒最久的連線
  3. 若空閒最久的連線空閒的時間超過了所設定的 keepAliveDurationNs(這裡不是指的 Keep-Alive 所設定時間),或者空閒連線數超過了所設定的 maxIdleConnections,清理該連線(移除並關閉socket),並返回 0 表示立即繼續清理。
  4. 若還未超過,則返回下一次超過外部設定的 keepAliveDurationNs,表示等到下次超時的時候再進行清理
  5. 若當前連線都正處於使用中,返回所設定的 keepAliveDurationNs
  6. 若當前沒有連線,則將 cleanupRunning 置為 false 停止清理

在 OkHttp 中,將空閒連線的最長存活時間設定為了 5 分鐘,並且將最大空閒連線數設定為了 5

我們看看 pruneAndGetAllocationCount 是如何對連線被引用的數量進行統計的:

/**
 * Prunes any leaked transmitters and then returns the number of remaining live transmitters on
 * {@code connection}. Transmitters are leaked if the connection is tracking them but the
 * application code has abandoned them. Leak detection is imprecise and relies on garbage
 * collection.
 */
private int pruneAndGetAllocationCount(RealConnection connection, long now) {
    List<Reference<Transmitter>> references = connection.transmitters;
    for (int i = 0; i < references.size(); ) {
        Reference<Transmitter> reference = references.get(i);
        if (reference.get() != null) {
            i++;
            continue;
        }
        // We ve discovered a leaked transmitter. This is an application bug.
        TransmitterReference transmitterRef = (TransmitterReference) reference;
        String message = "A connection to " + connection.route().address().url()
                + " was leaked. Did you forget to close a response body?";
        Platform.get().logCloseableLeak(message, transmitterRef.callStackTrace);
        references.remove(i);
        connection.noNewExchanges = true;
        // If this was the last allocation, the connection is eligible for immediate eviction.
        if (references.isEmpty()) {
            connection.idleAtNanos = now - keepAliveDurationNs;
            return 0;
        }
    }
    return references.size();
}
複製程式碼

可以看到,connection 中是有維護一個引用它的 TransmitterReference 佇列的,通過遍歷並判斷該 Transimitter 是否為 null 即可進行統計。這裡的 Reference 所存的實際是一個繼承自 WeakReferenceTransimitterReference 類:

static final class TransmitterReference extends WeakReference<Transmitter> {
   // ...
}

複製程式碼

可以發現,這種設計有點像 JVM 中的引用計數法 + 標記清除,實際上就是 OkHttp 仿照 JVM 的垃圾回收設計了這樣一種類似引用計數法的方式來統計一個連線是否是空閒連線,同時採用標記清除法對空閒且不滿足設定的規則的連線進行清除

獲取連線

我們看到 connectionPool.transmitterAcquirePooledConnection 方法,瞭解一下連線池獲取連線的過程:

/**
 * Attempts to acquire a recycled connection to {@code address} for {@code transmitter}. Returns
 * true if a connection was acquired.
 *
 * <p>If {@code routes} is non-null these are the resolved routes (ie. IP addresses) for the
 * connection. This is used to coalesce related domains to the same HTTP/2 connection, such as
 * {@code square.com} and {@code square.ca}.
 */
boolean transmitterAcquirePooledConnection(Address address, Transmitter transmitter,
                                           @Nullable List<Route> routes, boolean requireMultiplexed) {
    assert (Thread.holdsLock(this));
    for (RealConnection connection : connections) {
        if (requireMultiplexed && !connection.isMultiplexed()) continue;
        if (!connection.isEligible(address, routes)) continue;
        transmitter.acquireConnectionNoEvents(connection);
        return true;
    }
    return false;
}
複製程式碼

可以看到,首先註釋中對我們傳入不同的 routes 引數進行了解釋,若 routes 不為 null 說明這是已解析過的路由,可以將其合併到同一個 HTTP/2 連線。

而在 connection.isMultiplexed 的註釋中說到,若該連線為 HTTP/2 連線,則會返回 true。

connection.isEligible 註釋中則說到,若該連線可以給對應的 address 分配 stream,則返回 true。

在程式碼中,對 connections 進行了遍歷:

  1. 當需要進行多路費用且當前的連線不是 HTTP/2 連線時,則放棄當前連線

  2. 噹噹前連線不能用於為 address 分配 stream,則放棄當前連線。

  3. 前兩者都不滿足,則獲取該連線,並設定到 transimitter 中。

三次獲取連線的區別

我們回顧一下 findConnection 中三次嘗試從連線池獲取連線的過程:

  • 第一次嘗試:connectionPool.transmitterAcquirePooledConnection(address, transmitter, null, false)

  • 第二次嘗試(需要在進行了路由選擇的情況下):connectionPool.transmitterAcquirePooledConnection(address, transmitter, routes, false)

  • 第三次嘗試:connectionPool.transmitterAcquirePooledConnection(address, transmitter, routes, true)

可以發現,其傳入的引數是不同的。第一次由於是嘗試從已經解析過的路由的連線池中獲取連線,因此 route 設定為 null。

第二次由於是在無法找到對應的連線,在進行了路由選擇的條件下進行的,因此將 route 設定為了 null。

而最後一次嘗試從連線池獲取連線之所以需要將 requireMultiplexed 設定為 true,因為這次只有可能是在多個請求並行進行的情況下才有可能發生,這種情況只有 HTTP/2 的連線才有可能發生。

加入連線

通過 RealConnectionPool.put 方法可以向連線池中加入連線:

void put(RealConnection connection) {
    assert (Thread.holdsLock(this));
    if (!cleanupRunning) {
        cleanupRunning = true;
        executor.execute(cleanupRunnable);
    }
    connections.add(connection);
}
複製程式碼

由於之前判斷了如果連線池中沒有連線,就會暫停連線清理執行緒,所以這裡如果放入了新的連線,就會判斷連線清理執行緒是否正在執行,若已停止執行則將其繼續執行。之後將該連線放入了 Deque 中。

通知連線空閒

每當外部呼叫了 Transimitter.releaseConnectionNoEvents 方法時,最後都會呼叫到 RealConnection.connectionBecameIdle 方法來通知連線池連線進入了空閒狀態:

/**
 * Notify this pool that {@code connection} has become idle. Returns true if the connection has
 * been removed from the pool and should be closed.
 */
boolean connectionBecameIdle(RealConnection connection) {
    assert (Thread.holdsLock(this));
    if (connection.noNewExchanges || maxIdleConnections == 0) {
        connections.remove(connection);
        return true;
    } else {
        notifyAll(); // Awake the cleanup thread: we may have exceeded the idle connection limit.
        return false;
    }
}
複製程式碼

此時如果該連線不支援用於建立新 Exchange,或不允許有空閒連線,則會直接將該連線移除,否則會通過 notifyAll 方法喚醒阻塞的清理執行緒,嘗試對空閒連線進行清理,這樣能保證每當有空閒連線時最及時地對連線池進行清理。

連線的建立

我們知道,在尋找連線的過程中,若從 Transimitter 及連線池中都無法獲取到連線時,就會建立一個新的連線,讓我們看看這個建立連線的過程是怎樣的:

在尋找連線的程式碼中,建立連線的核心程式碼如下:

// ...
result = new RealConnection(connectionPool, selectedRoute);
result.connect(connectTimeout, readTimeout, writeTimeout, pingIntervalMillis,
                connectionRetryEnabled, call, eventListener);
複製程式碼

我們先看到 RealConnection 的建構函式:

public RealConnection(RealConnectionPool connectionPool, Route route) {
    this.connectionPool = connectionPool;
    this.route = route;
}
複製程式碼

只是進行了簡單的賦值,我們接著看到 RealConnection.connect 方法:

public void connect(int connectTimeout, int readTimeout, int writeTimeout,
                    int pingIntervalMillis, boolean connectionRetryEnabled, Call call,
                    EventListener eventListener) {
    if (protocol != null) throw new IllegalStateException("already connected");
    RouteException routeException = null;
    List<ConnectionSpec> connectionSpecs = route.address().connectionSpecs();
    ConnectionSpecSelector connectionSpecSelector = new ConnectionSpecSelector(connectionSpecs);
    // ...一些錯誤處理
    while (true) {
        try {
            if (route.requiresTunnel()) {
				// 如果使用了隧道技術,呼叫connectTunnel方法
				connectTunnel(connectTimeout, readTimeout, writeTimeout, call, eventListener);
                if (rawSocket == null) {
                    // We were unable to connect the tunnel but properly closed down our resources.
                    break;
                }
            } else {
            	// 未使用隧道技術,呼叫connectSocket方法
                connectSocket(connectTimeout, readTimeout, call, eventListener);
            }
            // 建立協議
            establishProtocol(connectionSpecSelector, pingIntervalMillis, call, eventListener);
            eventListener.connectEnd(call, route.socketAddress(), route.proxy(), protocol);
            break;
        } catch (IOException e) {
            //... 異常下的資源釋放
        }
    }
    // ... 一些錯誤處理
}
複製程式碼

可以看到,這裡是一個迴圈,不斷嘗試建立連線,其中核心步驟如下:

  1. 若使用了隧道技術,呼叫 connectTunnel 方法
  2. 若未使用隧道技術,呼叫 connectSocket 方法
  3. 呼叫 establishProtocol 方法建立協議

讓我們看看三個方法分別是如何實現的。

直接連線

我們先看看直接連線是如何實現的,我們看到 connectSocket 方法:

/**
 * Does all the work necessary to build a full HTTP or HTTPS connection on a raw socket.
 */
private void connectSocket(int connectTimeout, int readTimeout, Call call,
                           EventListener eventListener) throws IOException {
    Proxy proxy = route.proxy();
    Address address = route.address();
    // 初始化rawSocket,其中對SOCKS代理採用了SOCKS代理伺服器
    rawSocket = proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.HTTP
            ? address.socketFactory().createSocket()
            : new Socket(proxy);
    eventListener.connectStart(call, route.socketAddress(), proxy);
    rawSocket.setSoTimeout(readTimeout);
    try {
    	// 呼叫connectSocket方法對Socket進行連線,這裡預置了不同平臺的實現
        Platform.get().connectSocket(rawSocket, route.socketAddress(), connectTimeout);
    } catch (ConnectException e) {
        ConnectException ce = new ConnectException("Failed to connect to " + route.socketAddress());
        ce.initCause(e);
        throw ce;
    }
    // 獲取source及sink,用於讀取及寫入
    try {
        source = Okio.buffer(Okio.source(rawSocket));
        sink = Okio.buffer(Okio.sink(rawSocket));
    } catch (NullPointerException npe) {
        if (NPE_THROW_WITH_NULL.equals(npe.getMessage())) {
            throw new IOException(npe);
        }
    }
}
複製程式碼

可以看到,這裡主要是進行 Socket 的連線,首先根據代理型別建立了 Socket,之後呼叫了 connectSocket 方法進行連線(裡面呼叫的其實仍然是 socket.connect 方法)。最後呼叫 Okio 的方法獲取 sourcesink

這個過程還是比較簡單的,和正常使用 Socket 的流程大致相同:建立Socket=>連線=>獲取 stream,其中在 connectSocket 時根據不同平臺做了不同的處理。

通過隧道連線

首先我們要理解一下什麼是隧道。之前在 《計算機網路——自頂向下方法》中看到過相關內容,不過書中沒有詳細介紹。

隧道技術的出現主要是為了適配 IPv4 到 IPv6 的轉變。通過隧道技術可以通過一種網路協議來傳輸另外一種網路協議的資料,比如 A 主機與 B 主機都是採用 IPv6,而連線 A 與 B 的是 IPv4 的網路,為了實現 A 與 B 的通訊,可以使用隧道技術,資料包經過 IPv4 的多協議路由時將 IPv6 的資料包放入 IPv4 的資料包中,傳遞給 B。當到達 B 的路由器時,資料又被剝離之後傳遞給 B。這樣在 A 與 B 看來,它們使用的都是 IPv6 與對方通訊。如下圖所示:

image-20190803160002636

而在 HTTP 協議中,主要是為了建立 SSL 隧道從而對 HTTPS 進行支援,通過 SSL 隧道傳送 HTTP 資料。

那麼怎麼開啟隧道呢?

HTTP 提供了一個特殊的 method—— CONNECT,它是 HTTP/1.1 協議中預留的方法,可以通過它將連線改為隧道的代理伺服器。客戶端傳送一個 CONNECT 請求給隧道閘道器請求開啟一條 TCP 連線,當隧道打通之後,客戶端通過 HTTP 隧道傳送的所有資料會轉發給 TCP 連線,伺服器響應的所有資料會通過隧道發給客戶端。

而在 OkHttp 中,對隧道的支援主要是為了支援 SSL 隧道——SSL 隧道的初衷是為了通過防火牆來傳輸加密的 SSL 資料,此時隧道的作用就是將非 HTTP 的流量(SSL 的流量)傳過防火牆到達指定的伺服器(比如 HTTPS)。

接著我們看到 connectTunnel 方法的實現:

/**
 * Does all the work to build an HTTPS connection over a proxy tunnel. The catch here is that a
 * proxy server can issue an auth challenge and then close the connection.
 */
private void connectTunnel(int connectTimeout, int readTimeout, int writeTimeout, Call call,
                           EventListener eventListener) throws IOException {
	// 建立隧道Request
    Request tunnelRequest = createTunnelRequest();
    HttpUrl url = tunnelRequest.url();
    for (int i = 0; i < MAX_TUNNEL_ATTEMPTS; i++) {
        // 通過connectSocket建立Socket
        connectSocket(connectTimeout, readTimeout, call, eventListener);
        // 建立隧道
        tunnelRequest = createTunnel(readTimeout, writeTimeout, tunnelRequest, url);
        // 當建立的隧道為null時,說明隧道成功建立,break
        if (tunnelRequest == null) break; 
        // 回收資源
        closeQuietly(rawSocket);
        rawSocket = null;
        sink = null;
        source = null;
        eventListener.connectEnd(call, route.socketAddress(), route.proxy(), null);
    }
}
複製程式碼

這裡首先構建了一個隧道的 tunnelRequest。之後進行了迴圈,不斷嘗試建立隧道,不過 OkHttp 限制了其最大嘗試次數為 21 次。

建立隧道的過程首先通過 connectSocket 方法建立了 Socket 連線,然後通過 createTunnel 方法建立隧道。

我們看看 createTunnelRequest 方法做了什麼:

private Request createTunnelRequest() throws IOException {
    Request proxyConnectRequest = new Request.Builder()
            .url(route.address().url())
            .method("CONNECT", null)
            .header("Host", Util.hostHeader(route.address().url(), true))
            .header("Proxy-Connection", "Keep-Alive") // For HTTP/1.0 proxies like Squid.
            .header("User-Agent", Version.userAgent())
            .build();
    Response fakeAuthChallengeResponse = new Response.Builder()
            .request(proxyConnectRequest)
            .protocol(Protocol.HTTP_1_1)
            .code(HttpURLConnection.HTTP_PROXY_AUTH)
            .message("Preemptive Authenticate")
            .body(Util.EMPTY_RESPONSE)
            .sentRequestAtMillis(-1L)
            .receivedResponseAtMillis(-1L)
            .header("Proxy-Authenticate", "OkHttp-Preemptive")
            .build();
    Request authenticatedRequest = route.address().proxyAuthenticator()
            .authenticate(route, fakeAuthChallengeResponse);
    return authenticatedRequest != null
            ? authenticatedRequest
            : proxyConnectRequest;
}
複製程式碼

可以看到,這裡構建了一個 method 為 CONENCT 的請求。

我們接著看看 createTunnel 方法又做了什麼事情:

/**
 * To make an HTTPS connection over an HTTP proxy, send an unencrypted CONNECT request to create
 * the proxy connection. This may need to be retried if the proxy requires authorization.
 */
private Request createTunnel(int readTimeout, int writeTimeout, Request tunnelRequest,
                             HttpUrl url) throws IOException {
    // 構造HTTP/1.1請求
    String requestLine = "CONNECT " + Util.hostHeader(url, true) + " HTTP/1.1";
    while (true) {
        Http1ExchangeCodec tunnelCodec = new Http1ExchangeCodec(null, null, source, sink);
        source.timeout().timeout(readTimeout, MILLISECONDS);
        sink.timeout().timeout(writeTimeout, MILLISECONDS);
        tunnelCodec.writeRequest(tunnelRequest.headers(), requestLine);
        tunnelCodec.finishRequest();
        // 發出隧道請求
        Response response = tunnelCodec.readResponseHeaders(false)
                .request(tunnelRequest)
                .build();
        tunnelCodec.skipConnectBody(response);
        switch (response.code()) {
            case HTTP_OK:
                // 返回200說明成功建立隧道,返回null
                if (!source.getBuffer().exhausted() || !sink.buffer().exhausted()) {
                    throw new IOException("TLS tunnel buffered too many bytes!");
                }
                return null;
            case HTTP_PROXY_AUTH:	// 表示服務端要進行代理認證
            	// 進行代理認證
                tunnelRequest = route.address().proxyAuthenticator().authenticate(route, response);
                // 代理認證不通過
                if (tunnelRequest == null) throw new IOException("Failed to authenticate with proxy");
                // 代理認證通過,但需要關閉TCP連線
                if ("close".equalsIgnoreCase(response.header("Connection"))) {
                    return tunnelRequest;
                }
                break;
            default:
                throw new IOException(
                        "Unexpected response code for CONNECT: " + response.code());
        }
    }
}
複製程式碼

可以看到,這裡主要進行如下的工作:

  1. 拼接 HTTP/1.1 請求
  2. 發出隧道請求,讀取響應
  3. 若隧道請求返回 200,說明隧道建立成功,返回 null
  4. 若隧道返回 407,說明伺服器需要進行代理認證,呼叫對應方法進行代理認證

隧道打通之後,就可以通過隧道進行網路請求了。

釋出協議

經過前面的步驟,我們建立了一條與服務端的 Socket 通道,我們接著看到 establishProtocol 方法:

private void establishProtocol(ConnectionSpecSelector connectionSpecSelector,
                               int pingIntervalMillis, Call call, EventListener eventListener) throws IOException {
	// 如果不是https地址
    if (route.address().sslSocketFactory() == null) {
        // 如果協議中包含了 http2 with prior knowledge
        if (route.address().protocols().contains(Protocol.H2_PRIOR_KNOWLEDGE)) {
            socket = rawSocket;
            protocol = Protocol.H2_PRIOR_KNOWLEDGE;
            startHttp2(pingIntervalMillis);
            return;
        }
        // 協議為 HTTP/1.1
        socket = rawSocket;
        protocol = Protocol.HTTP_1_1;
        return;
    }
    eventListener.secureConnectStart(call);
    // TLS握手
    connectTls(connectionSpecSelector);
    eventListener.secureConnectEnd(call, handshake);
    if (protocol == Protocol.HTTP_2) {
        // 如果是HTTP2協議,呼叫 startHttp2 方法
        startHttp2(pingIntervalMillis);
    }
}
複製程式碼

可以看到,這個方法主要是在建立了 Socket 連線的基礎上,對各個協議進行支援。

首先判斷了當前地址是否是 HTTPS 地址。

不是 HTTPS 的情況下,若協議中包含了 H2_PRIOR_KNOWLEDGE 則採用 HTTP/2 進行請求,呼叫 startHttp2 方法,否則採用 HTTP/1.1。

是 HTTPS 的情況下,首先呼叫了 connectTls 方法進行 TLS 握手,之後若是 HTTP/2 協議,則呼叫 startHttp2 方法。

啟動 HTTP/2 連線

讓我們先看看 startHttp2 方法究竟是做了什麼:

private void startHttp2(int pingIntervalMillis) throws IOException {
    socket.setSoTimeout(0); // HTTP/2 connection timeouts are set per-stream.
    http2Connection = new Http2Connection.Builder(true)
            .socket(socket, route.address().url().host(), source, sink)
            .listener(this)
            .pingIntervalMillis(pingIntervalMillis)
            .build();
    http2Connection.start();
}
複製程式碼

這裡主要是構建了一個 HTTP/2 的 Http2Connection,並且將 listener 設定為了該 RealConnection,之後通過 http2Connection.start 方法啟動了 HTTP/2 連線。

/**
 * @param sendConnectionPreface true to send connection preface frames. This should always be true
 *                              except for in tests that don t check for a connection preface.
 */
void start(boolean sendConnectionPreface) throws
        IOException {
    if (sendConnectionPreface) {
        writer.connectionPreface();
        writer.settings(okHttpSettings);
        int windowSize = okHttpSettings.getInitialWindowSize();
        if (windowSize != Settings.DEFAULT_INITIAL_WINDOW_SIZE) {
            writer.windowUpdate(0, windowSize - Settings.DEFAULT_INITIAL_WINDOW_SIZE);
        }
    }
    new Thread(readerRunnable).start(); // Not a daemon thread.
}
複製程式碼

這裡 sendConnectionPreface 預設為 true,它首先呼叫了 writer.connectionPreface 方法,之後呼叫了 writer.settings 方法。最後,啟用了一個 readerRunnable 的讀取執行緒。

在 HTTP/2 中,每個終端都需要傳送一個連線 preface 作為在使用的協議的一個最終的確認,併為 HTTP/2 連線建立初始的設定。客戶端和伺服器相互傳送一個不同的連線 preface。

連線 preface 以字串 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 開始,這個序列後面必須跟著一個 SETTINGS 幀。因此,在之後又呼叫了 writer.settings 方法,寫入 SETTINGS 幀。

我們先看到 connectionPreface 方法:

public synchronized void connectionPreface() throws IOException {
    if (closed) throw new IOException("closed");
    if (!client) return; // Nothing to write; servers don t send connection headers!
    if (logger.isLoggable(FINE)) {
        logger.fine(format(">> CONNECTION %s", CONNECTION_PREFACE.hex()));
    }
    sink.write(CONNECTION_PREFACE.toByteArray());
    sink.flush();
}
複製程式碼

這裡實際上是向 HTTP/2 連線的 Socket 中寫入了 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 這一字串。之後我們看到 writer.settings 方法:

/**
 * Write okhttp settings to the peer.
 */
public synchronized void settings(Settings settings) throws IOException {
    if (closed) throw new IOException("closed");
    int length = settings.size() * 6;
    byte type = TYPE_SETTINGS;
    byte flags = FLAG_NONE;
    int streamId = 0;
    frameHeader(streamId, length, type, flags);
    for (int i = 0; i < Settings.COUNT; i++) {
        if (!settings.isSet(i)) continue;
        int id = i;
        if (id == 4) {
            id = 3; // SETTINGS_MAX_CONCURRENT_STREAMS renumbered.
        } else if (id == 7) {
            id = 4; // SETTINGS_INITIAL_WINDOW_SIZE renumbered.
        }
        sink.writeShort(id);
        sink.writeInt(settings.get(i));
    }
    sink.flush();
}
複製程式碼

這裡主要是寫入了一些配置的資料,其中呼叫了 frameHeader 寫入了幀頭。

最後我們看到 readerRunnable.execute

@Override
protected void execute() {
    ErrorCode connectionErrorCode = ErrorCode.INTERNAL_ERROR;
    ErrorCode streamErrorCode = ErrorCode.INTERNAL_ERROR;
    IOException errorException = null;
    try {
        reader.readConnectionPreface(this);
        while (reader.nextFrame(false, this)) {
        }
        connectionErrorCode = ErrorCode.NO_ERROR;
        streamErrorCode = ErrorCode.CANCEL;
    } catch (IOException e) {
        errorException = e;
        connectionErrorCode = ErrorCode.PROTOCOL_ERROR;
        streamErrorCode = ErrorCode.PROTOCOL_ERROR;
    } finally {
        close(connectionErrorCode, streamErrorCode, errorException);
        Util.closeQuietly(reader);
    }
}
複製程式碼

可以看到,這裡主要是呼叫了 reader.readConnectionPreface 方法讀取服務端傳送來的 preface,並判斷是否為對應字串,從而完成 HTTP/2 連線的啟動。

TLS 握手

接著我們看到 TLS 握手的過程,讓我們看看 connectTls 方法:

private void connectTls(ConnectionSpecSelector connectionSpecSelector) throws IOException {
    Address address = route.address();
    SSLSocketFactory sslSocketFactory = address.sslSocketFactory();
    boolean success = false;
    SSLSocket sslSocket = null;
    try {
		// 基於之前建立的Socket建立一個包裝物件SSLSocket
		sslSocket = (SSLSocket) sslSocketFactory.createSocket(
                rawSocket, address.url().host(), address.url().port(), true /* autoClose */);
        // 對TLS相關資訊進行配置
        ConnectionSpec connectionSpec = connectionSpecSelector.configureSecureSocket(sslSocket);
        if (connectionSpec.supportsTlsExtensions()) {
            Platform.get().configureTlsExtensions(
                    sslSocket, address.url().host(), address.protocols());
        }
        // 進行握手
        sslSocket.startHandshake();
        // 獲取SSLSession
        SSLSession sslSocketSession = sslSocket.getSession();
        Handshake unverifiedHandshake = Handshake.get(sslSocketSession);
        // 驗證證書對該主機是否有效
        if (!address.hostnameVerifier().verify(address.url().host(), sslSocketSession)) {
            List<Certificate> peerCertificates = unverifiedHandshake.peerCertificates();
            if (!peerCertificates.isEmpty()) {
                X509Certificate cert = (X509Certificate) peerCertificates.get(0);
                throw new SSLPeerUnverifiedException(
                        "Hostname " + address.url().host() + " not verified:"
                                + "\n    certificate: " + CertificatePinner.pin(cert)
                                + "\n    DN: " + cert.getSubjectDN().getName()
                                + "\n    subjectAltNames: " + OkHostnameVerifier.allSubjectAltNames(cert));
            } else {
                throw new SSLPeerUnverifiedException(
                        "Hostname " + address.url().host() + " not verified (no certificates)");
            }
        }
        address.certificatePinner().check(address.url().host(),
                unverifiedHandshake.peerCertificates());
        String maybeProtocol = connectionSpec.supportsTlsExtensions()
                ? Platform.get().getSelectedProtocol(sslSocket)
                : null;
        socket = sslSocket;
        // 獲取source及sink
        source = Okio.buffer(Okio.source(socket));
        sink = Okio.buffer(Okio.sink(socket));
        handshake = unverifiedHandshake;
        protocol = maybeProtocol != null
                ? Protocol.get(maybeProtocol)
                : Protocol.HTTP_1_1;
        success = true;
    } catch (AssertionError e) {
        if (Util.isAndroidGetsocknameError(e)) throw new IOException(e);
        throw e;
    } finally {
        if (sslSocket != null) {
            Platform.get().afterHandshake(sslSocket);
        }
        if (!success) {
            closeQuietly(sslSocket);
        }
    }
}
複製程式碼

可以看到,這裡的步驟主要是下列步驟:

  1. 基於之前建立的 Socket 建立包裝類 SSLSocket
  2. 對 TLS 相關資訊進行配置
  3. 通過 SSLSocket 進行握手
  4. 驗證一些證書相關資訊
  5. 獲取 sourcesink

總結

OkHttp 中採用了連線池機制實現了連線的複用,避免了每次都建立新的連線從而導致資源的浪費。獲取連線的過程主要如下:

  1. 嘗試在 transimitter 中尋找已經分配的連線
  2. transimitter 中獲取不到,嘗試從連線池中獲取連線
  3. 連線池中仍然獲取不到,嘗試進行一次路由選擇,再次從連線池中獲取連線
  4. 連線池中仍然找不到需要的連線,則建立一個新的連線
  5. 由於 HTTP/2 下采用了連線的多路複用機制,所以連線可以並行進行,因此再次嘗試從連線池中獲取連線,獲取到則丟棄建立的連線
  6. 若連線池中仍獲取不到連線,則將剛剛建立的連線放入連線池

其中,在連線池中採用了一個清理執行緒對超過了設定引數的空閒連線進行清理,每次清理後會計算下一次需要清理的時間並進入阻塞,每當有新連線進入或連線進入空閒時會重新喚醒該清理執行緒。

對於每個連線,都採用了一種類似 GC 中的引用計數法的形式,每個 RealConnection 都持有了使用它的 Transimitter 的弱引用,通過判斷持有的弱引用個數從而判斷該連線是否空閒。

OkHttp 預設將最大存活空閒連線個數設定為了 5,且每個連線空閒時間不能超過 5 分鐘,否則將被清理執行緒所回收

而在連線建立過程中,首先會判斷該連線是否需要 SSL 隧道,若不需要則直接建立了 Socket 並獲取了其 sourcesink,若需要則會先嚐試建立 SSL 隧道,最後再進行 Socket 連線。

Socket 連線建立成功後,會通過 establishProtocol 方法對每個協議進行不同的處理,從而對各個協議進行支援(如對 HTTPS 的支援)

參考資料

Keep-Alive

【HTTP】keep-alive

HTTP Keep-Alive模式

okhttp連線池複用機制

Okhttp對http2的支援簡單分析

相關文章