本文希望你在讀完之后能夠清楚的事情:一次http請求的經(jīng)歷蹲姐,這期間會遇到什么問題,okhttp怎么解決的,在它的責(zé)任鏈中的那一部分解決的這個(gè)問題腐晾,怎樣監(jiān)控我們自己的網(wǎng)絡(luò)請求,怎樣監(jiān)控網(wǎng)絡(luò)狀況丐一。
一次http網(wǎng)絡(luò)請求的歷程
網(wǎng)絡(luò)請求要依次經(jīng)歷 DNS解析藻糖、創(chuàng)建連接、收發(fā)數(shù)據(jù)库车、關(guān)閉連接幾個(gè)過程巨柒。下圖是其他教程里的一張圖,畫的非常清晰:這期間需要應(yīng)對的問題有:
1柠衍、DNS劫持洋满。即使我們現(xiàn)在幾萬用戶的小體量app,每月也能碰到幾起DNS劫持珍坊。除了DNS劫持牺勾,這部分還需要考慮IP選取策略、DNS緩存阵漏、容災(zāi)等問題禽最,如果必要的話腺怯,可以對其進(jìn)行優(yōu)化,參考百度的DNS優(yōu)化,以及美圖案例(考慮了非okhttp的情況)川无。
2呛占、連接復(fù)用。http基于TCP懦趋,所以連接要經(jīng)歷三次握手晾虑,關(guān)閉連接要經(jīng)歷4次握手,TCP連接在發(fā)送數(shù)據(jù)的時(shí)候仅叫,在起初會限制連接速度帜篇,隨著傳輸?shù)某晒蜁r(shí)間的推移逐漸提高速度(防擁塞),再加上TLS密匙協(xié)商诫咱,如果每次網(wǎng)絡(luò)請求都要經(jīng)歷創(chuàng)建連接的過程笙隙,帶來的開銷是非常大的。
3坎缭、I/O問題竟痰。客戶端會等待服務(wù)器的返回?cái)?shù)據(jù)掏呼,數(shù)據(jù)收到后還要把數(shù)據(jù)從內(nèi)核copy到用戶空間坏快,期間根據(jù)網(wǎng)絡(luò)的阻塞模型(基本有五種,常見的有阻塞I/O憎夷、非阻塞I/O莽鸿、多路復(fù)用I/O),會遇到不同程度的阻塞拾给。
4祥得、數(shù)據(jù)壓縮和加密。減少數(shù)據(jù)體積蒋得,對數(shù)據(jù)進(jìn)行加密级及。
對于上述問題的方案:
1、okhttp提供了自定義DNS解析的接口窄锅。
2、持久連接缰雇。http1.1支持事務(wù)結(jié)束之后將TCP保持在打開狀態(tài)(http1.1默認(rèn)將Keep-Alive首部開啟入偷,用于客戶端和服務(wù)器通信連接的保存時(shí)間,TCP中有Keep-Alive報(bào)文械哟,來定時(shí)探測通信雙方是否存活疏之,但是這一部分內(nèi)容用于長連接時(shí)會存在問題),對http1.1進(jìn)行連接復(fù)用暇咆,將連接放入連接池锋爪。支持http2.0丙曙,http2.0使用多路復(fù)用,支持一條連接同時(shí)處理多個(gè)請求其骄,請求可以并發(fā)進(jìn)行亏镰,一個(gè)域名會保留一條連接(一條連接即一個(gè)TCP連接,收發(fā)只有一根管道拯爽,并不是真正意義上的并發(fā)索抓,而是利用TCP把數(shù)據(jù)拆分裝包加標(biāo)簽的特性實(shí)現(xiàn)的復(fù)用),能有效降低延時(shí)(也有特殊情況毯炮,比如一個(gè)域名的數(shù)據(jù)請求特別多逼肯,或者服務(wù)端對單個(gè)連接有速度限制,如視頻流)桃煎。長連接(推薦讀下這篇文章篮幢,如果沒有從事過長連接開發(fā)的話)也是一種方案,在某些場景下非常有效为迈,但是okhttp不支持三椿。
3、OKhttp使用非阻塞I/O模型OKio(算是nio吧曲尸,和我們理解的nio不太一致赋续,理解的nio定時(shí)去檢查是否有數(shù)據(jù)到來,有的話就讀另患,沒有就返回纽乱,但是okio的實(shí)現(xiàn)是定時(shí)去檢查是否已經(jīng)讀寫完成,沒完成就認(rèn)為超時(shí)昆箕,close掉該socket)鸦列,該I/O框架的內(nèi)存表現(xiàn)也很好(mars使用epoll)。
4鹏倘、http2.0協(xié)議本身對頭部有壓縮薯嗤。對于body的壓縮okhttp提供了Gzip壓縮的支持。
OKhttp實(shí)現(xiàn)分析
網(wǎng)上找到的一個(gè)okhttp整體調(diào)用圖纤泵,由于okhttp的分析已經(jīng)很多骆姐,盡量以少代碼多總結(jié)的方式來闡述這部分內(nèi)容。主要講解okhttp應(yīng)對上述問題的具體實(shí)現(xiàn)捏题。
設(shè)計(jì)結(jié)構(gòu)十分清晰玻褪,通過責(zé)任鏈將請求發(fā)送任務(wù)進(jìn)行拆解。
1公荧、okhttp中自定義DNS解析
OkHttpClient client = new OkHttpClient.Builder()
.dns(new Dns(){
@Override
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
return Arrays.asList(InetAddress.getAllByName(DNSHelper.getIpByHost(hostname)));
}
})
.build();
DNSHelper可以通過ip直連的方式訪問自己設(shè)置的DNS服務(wù)器带射。也可以通過這種方式接入一些第三方對外提供的DNS服務(wù)。
在RetryAndFollowUpInterceptor這個(gè)攔截器中循狰,將會創(chuàng)建Address對象窟社,該對象收集網(wǎng)絡(luò)請求需要的配置信息券勺,包括DNS、host灿里、port关炼、proxy等。在ConnectIntercepter這一層創(chuàng)建了RouteSelecter钠四,用于路由選擇盗扒,持有Address對象,調(diào)用其next函數(shù)選擇路由時(shí)會調(diào)用DNS對象的lookup函數(shù)返回host的ip缀去。
2侣灶、連接復(fù)用
這部分需要操心的事情一個(gè)是連接的管理,一個(gè)是對網(wǎng)絡(luò)請求的流的管理缕碎。連接的管理交由ConnectionPool褥影,內(nèi)部含有一個(gè)保存了RealConnection對象的隊(duì)列。http2.0一個(gè)連接對應(yīng)多個(gè)流咏雌,在RealConnection內(nèi)保存了一個(gè)代表流的StreamAllocation對象的list凡怎。
在ConnectIntercepter這一層調(diào)用StreamAllocation的newStrem,嘗試在連接池里找到一個(gè)RealConnection赊抖,沒找到則創(chuàng)建一個(gè)统倒,并調(diào)用acquire添加一個(gè)自身的弱引用到RealConnection的流引用List中。newStrem最終返回一個(gè)httpcodec接口的實(shí)現(xiàn)氛雪,代表了具體的http協(xié)議內(nèi)容創(chuàng)建規(guī)則房匆,有兩種實(shí)現(xiàn),對應(yīng)了okhttp適配的兩個(gè)http版本报亩,然后傳遞給下一級浴鸿。當(dāng)然流在讀寫完成后也是需要被清理的,清理函數(shù)deallocate弦追,一個(gè)連接的流都被清理掉之后岳链,通知ConnectionPool判斷連接的kepp-alive時(shí)間,以及空閑連接數(shù)量劲件,移除超時(shí)或者超出數(shù)量限制后空閑時(shí)間最久的連接掸哑。
如下是調(diào)用流和連接的綁定過程(省略了路由選擇過程),新創(chuàng)建的連接會執(zhí)行socket的connect零远,connect的時(shí)候會判斷http協(xié)議是哪個(gè)版本苗分,然后新創(chuàng)建的RealConnection會添加到連接池里。
// Attempt to use an already-allocated connection.
RealConnection allocatedConnection = this.connection;
if (allocatedConnection != null && !allocatedConnection.noNewStreams) {
return allocatedConnection;
}
// Attempt to get a connection from the pool.
Internal.instance.get(connectionPool, address, this, null);
if (connection != null) {
return connection;
}
RealConnection result;
synchronized (connectionPool) {
if (canceled) throw new IOException("Canceled");
// Create a connection and assign it to this allocation immediately. This makes it possible
// for an asynchronous cancel() to interrupt the handshake we're about to do.
result = new RealConnection(connectionPool, selectedRoute);
acquire(result);
}
// Do TCP + TLS handshakes. This is a blocking operation.
result.connect(connectTimeout, readTimeout, writeTimeout, connectionRetryEnabled);
routeDatabase().connected(result.route());
Socket socket = null;
synchronized (connectionPool) {
// Pool the connection.
Internal.instance.put(connectionPool, result);
// If another multiplexed connection to the same address was created concurrently, then
// release this connection and acquire that one.
if (result.isMultiplexed()) {
socket = Internal.instance.deduplicate(connectionPool, address, this);
result = connection;
}
}
closeQuietly(socket);
return result;
如下可以看出連接池對空閑時(shí)間和空閑連接數(shù)量的限制(順帶一提遍烦,okhttp的線程池也是有數(shù)量限制的俭嘁,大約在60個(gè)左右躺枕,如果項(xiàng)目網(wǎng)絡(luò)庫比較亂服猪,使用線程也不太注意供填,線程過多,超過500個(gè)罢猪,在一些華為手機(jī)上會因?yàn)樯暾埐坏骄€程而崩潰)近她。
private final Deque<RealConnection> connections = new ArrayDeque<>();
final RouteDatabase routeDatabase = new RouteDatabase();
boolean cleanupRunning;
/**
* Create a new connection pool with tuning parameters appropriate for a single-user application.
* The tuning parameters in this pool are subject to change in future OkHttp releases. Currently
* this pool holds up to 5 idle connections which will be evicted after 5 minutes of inactivity.
*/
public ConnectionPool() {
this(5, 5, TimeUnit.MINUTES);
}
public ConnectionPool(int maxIdleConnections, long keepAliveDuration, TimeUnit timeUnit) {
this.maxIdleConnections = maxIdleConnections;
this.keepAliveDurationNs = timeUnit.toNanos(keepAliveDuration);
// Put a floor on the keep alive duration, otherwise cleanup will spin loop.
if (keepAliveDuration <= 0) {
throw new IllegalArgumentException("keepAliveDuration <= 0: " + keepAliveDuration);
}
}
如下是具體的清理邏輯:
synchronized (this) {
for (Iterator<RealConnection> i = connections.iterator(); i.hasNext(); ) {
RealConnection connection = i.next();
// If the connection is in use, keep searching.
if (pruneAndGetAllocationCount(connection, now) > 0) {
inUseConnectionCount++;
continue;
}
idleConnectionCount++;
// If the connection is ready to be evicted, we're done.
long idleDurationNs = now - connection.idleAtNanos;
if (idleDurationNs > longestIdleDurationNs) {
longestIdleDurationNs = idleDurationNs;
longestIdleConnection = connection;
}
}
if (longestIdleDurationNs >= this.keepAliveDurationNs
|| idleConnectionCount > this.maxIdleConnections) {
// We've found a connection to evict. Remove it from the list, then close it below (outside
// of the synchronized block).
connections.remove(longestIdleConnection);
} else if (idleConnectionCount > 0) {
// A connection will be ready to evict soon.
return keepAliveDurationNs - longestIdleDurationNs;
} else if (inUseConnectionCount > 0) {
// All connections are in use. It'll be at least the keep alive duration 'til we run again.
return keepAliveDurationNs;
} else {
// No connections, idle or in use.
cleanupRunning = false;
return -1;
}
}
3、I/O優(yōu)化
a膳帕、okio的使用:
Okio.buffer(Okio.sink(socket)) .writeUtf8("write string by utf-8.\n") .writeInt(1234).close();
b粘捎、okio沒有使用java提供的select(多路復(fù)用),而是自定義了nio實(shí)現(xiàn)危彩。個(gè)人猜測這樣實(shí)現(xiàn)的原因是多路復(fù)用實(shí)際在網(wǎng)絡(luò)連接非常多的時(shí)候表現(xiàn)更好攒磨,對于客戶端來講不一定適用,反倒會增加大量的select/epoll系統(tǒng)調(diào)用汤徽,更多用于服務(wù)器娩缰。
設(shè)置一個(gè)watchdog,將一次事件(讀谒府、寫)封裝到AsyncTimeout中拼坎,AsyncTimeout持有一個(gè)static鏈表,Watchdog定期檢測鏈表完疫。
private static final class Watchdog extends Thread {
Watchdog() {
super("Okio Watchdog");
setDaemon(true);
}
public void run() {
while (true) {
try {
AsyncTimeout timedOut;
synchronized (AsyncTimeout.class) {
timedOut = awaitTimeout();
// Didn't find a node to interrupt. Try again.
if (timedOut == null) continue;
// The queue is completely empty. Let this thread exit and let another watchdog thread
// get created on the next call to scheduleTimeout().
if (timedOut == head) {
head = null;
return;
}
}
// Close the timed out node.
timedOut.timedOut();
} catch (InterruptedException ignored) {
}
}
}
}
awaitTimeout()函數(shù)讀取鏈表泰鸡,設(shè)置等待事件。到時(shí)間后壳鹤,返回鏈表中的一個(gè)AsyncTimeout 對象盛龄,并調(diào)用該對象的timedOut()函數(shù)。
static @Nullable AsyncTimeout awaitTimeout() throws InterruptedException {
// Get the next eligible node.
AsyncTimeout node = head.next;
// The queue is empty. Wait until either something is enqueued or the idle timeout elapses.
if (node == null) {
long startNanos = System.nanoTime();
AsyncTimeout.class.wait(IDLE_TIMEOUT_MILLIS);
return head.next == null && (System.nanoTime() - startNanos) >= IDLE_TIMEOUT_NANOS
? head // The idle timeout elapsed.
: null; // The situation has changed.
}
long waitNanos = node.remainingNanos(System.nanoTime());
// The head of the queue hasn't timed out yet. Await that.
if (waitNanos > 0) {
// Waiting is made complicated by the fact that we work in nanoseconds,
// but the API wants (millis, nanos) in two arguments.
long waitMillis = waitNanos / 1000000L;
waitNanos -= (waitMillis * 1000000L);
AsyncTimeout.class.wait(waitMillis, (int) waitNanos);
return null;
}
// The head of the queue has timed out. Remove it.
head.next = node.next;
node.next = null;
return node;
}
而若這個(gè)事件已經(jīng)完成則會調(diào)用exit()函數(shù),將該事件在隊(duì)列中移除器虾。
/** Returns true if the timeout occurred. */
public final boolean exit() {
if (!inQueue) return false;
inQueue = false;
return cancelScheduledTimeout(this);
}
/** Returns true if the timeout occurred. */
private static synchronized boolean cancelScheduledTimeout(AsyncTimeout node) {
// Remove the node from the linked list.
for (AsyncTimeout prev = head; prev != null; prev = prev.next) {
if (prev.next == node) {
prev.next = node.next;
node.next = null;
return false;
}
}
// The node wasn't found in the linked list: it must have timed out!
return true;
}
超時(shí)后認(rèn)為連接不可用讯嫂,調(diào)用Sockect對象的close函數(shù)關(guān)閉該連接。
c兆沙、緩存
是okio對java I/O 做的最重要的優(yōu)化欧芽。主要思想是buffer復(fù)用,而不是創(chuàng)建大量的朝生夕死的buffer對象葛圃,防止頻繁GC千扔。這部分內(nèi)容可以對比BufferedInputStream的實(shí)現(xiàn)(BufferedInputStream內(nèi)部結(jié)構(gòu)和Segment類似,當(dāng)其設(shè)置的初始緩存byte數(shù)組大小不夠時(shí)库正,新申請一個(gè)更大容量的數(shù)組曲楚,并將原緩存數(shù)組的內(nèi)容copy過來,舍棄原數(shù)組)褥符。
代碼思路:Segment對象為byte數(shù)組的封裝龙誊,是數(shù)據(jù)的容器,是一個(gè)雙向鏈表中的節(jié)點(diǎn)喷楣,可以有插入趟大、刪除鹤树、拆分、合并逊朽、復(fù)制幾個(gè)操作罕伯。SegmentPool緩存了不用的segment,是一個(gè)靜態(tài)的單鏈表叽讳,需要時(shí)調(diào)用take獲取Segment追他,不需要時(shí)調(diào)用recycle回收岛蚤。Buffer對象封裝了這兩者的使用邑狸,例如使用okio調(diào)用writeString函數(shù)時(shí)的實(shí)現(xiàn)如下:
@Override
public Buffer writeString(String string, int beginIndex, int endIndex, Charset charset) {
if (string == null) throw new IllegalArgumentException("string == null");
if (beginIndex < 0) throw new IllegalAccessError("beginIndex < 0: " + beginIndex);
if (endIndex < beginIndex) {
throw new IllegalArgumentException("endIndex < beginIndex: " + endIndex + " < " + beginIndex);
}
if (endIndex > string.length()) {
throw new IllegalArgumentException(
"endIndex > string.length: " + endIndex + " > " + string.length());
}
if (charset == null) throw new IllegalArgumentException("charset == null");
if (charset.equals(Util.UTF_8)) return writeUtf8(string, beginIndex, endIndex);
byte[] data = string.substring(beginIndex, endIndex).getBytes(charset);
return write(data, 0, data.length);
}
@Override public Buffer write(byte[] source, int offset, int byteCount) {
if (source == null) throw new IllegalArgumentException("source == null");
checkOffsetAndCount(source.length, offset, byteCount);
int limit = offset + byteCount;
while (offset < limit) {
Segment tail = writableSegment(1);
int toCopy = Math.min(limit - offset, Segment.SIZE - tail.limit);
System.arraycopy(source, offset, tail.data, tail.limit, toCopy);
offset += toCopy;
tail.limit += toCopy;
}
size += byteCount;
return this;
}
Segment writableSegment(int minimumCapacity) {
if (minimumCapacity < 1 || minimumCapacity > Segment.SIZE) throw new IllegalArgumentException();
if (head == null) {
head = SegmentPool.take(); // Acquire a first segment.
return head.next = head.prev = head;
}
Segment tail = head.prev;
if (tail.limit + minimumCapacity > Segment.SIZE || !tail.owner) {
tail = tail.push(SegmentPool.take()); // Append a new empty segment to fill up.
}
return tail;
}
核心邏輯是緩存大小的管理推溃,然后調(diào)用System.arraycopy將數(shù)據(jù)復(fù)制到容器中硬萍。
4畜普、數(shù)據(jù)的壓縮加密
對這一部分的處理主要在BridgeInterceptor中街立。會在頭部自動添加Accept-Encoding: gzip,并自動對response的進(jìn)行解壓縮,若手動添加了狠鸳,則不處理response的數(shù)據(jù)揣苏。
對于發(fā)送數(shù)據(jù)的body,官方推薦自定義攔截器實(shí)現(xiàn)件舵。攔截器內(nèi)選用Gzip或者其他的壓縮算法對數(shù)據(jù)進(jìn)行壓縮卸察。
除了單純的壓縮,使用protobuffer代替json也是一種選擇铅祸,除了壓縮率和速度坑质,protobuffer對數(shù)據(jù)是一種天然的混淆,更安全一些临梗,但是使用起來比json要麻煩涡扼。
同樣的手段,也可以插入一個(gè)自定義的攔截器來對數(shù)據(jù)進(jìn)行加密盟庞。
網(wǎng)絡(luò)請求的監(jiān)控
okhttp在3.11版本開始提供了一個(gè)網(wǎng)絡(luò)時(shí)間監(jiān)控的回調(diào)接口HttpEventListener吃沪,能進(jìn)行一些耗時(shí)和事件統(tǒng)計(jì)。
360的方案加入攔截器統(tǒng)計(jì)響應(yīng)時(shí)間和上下行流量什猖。
網(wǎng)絡(luò)質(zhì)量的監(jiān)控
okhttp沒有這部分內(nèi)容巷波,但是有一些工具可以用,可以執(zhí)行l(wèi)inux的ping命令卸伞,在socket連接前后加入計(jì)時(shí)抹镊,使用tracerout(利用ICMP協(xié)議來查看到目標(biāo)機(jī)器鏈路中的節(jié)點(diǎn)的可達(dá)性,其報(bào)文內(nèi)會含有目標(biāo)網(wǎng)絡(luò)荤傲、主機(jī)垮耳、端口可達(dá)性等一系列信息,再加上ip協(xié)議的TTL來遍歷當(dāng)前節(jié)點(diǎn)到目標(biāo)節(jié)點(diǎn)的鏈路信息),程序?qū)崿F(xiàn)參考《traceroute程序-c語言實(shí)現(xiàn)》
弱網(wǎng)優(yōu)化和失敗處理
這部分就留坑吧终佛。okhttp對網(wǎng)絡(luò)失敗做了處理俊嗽,但是說到針對弱網(wǎng)的優(yōu)化,還是要去翻看mars铃彰。