Android | okhttp細(xì)枝篇

嗨,我是哈利迪~《看完不忘系列》之okhttp(樹干篇)一文對okhttp的請求流程做了初步介紹,本文將對他的一些實(shí)現(xiàn)細(xì)節(jié)和相關(guān)網(wǎng)絡(luò)知識進(jìn)行補(bǔ)充。

本文約2000字,閱讀大約5分鐘酝掩。

源碼基于3.14.9,即java版本的最新版

推薦閱讀「查缺補(bǔ)漏」鞏固你的HTTP知識體系眷柔,常用的概念都在了期虾,由于目前用的比較多的還是http 1.1原朝,所以下面分析會跳過http2,以http 1.1為主镶苞。

cache

image

強(qiáng)緩存:Cache-Control(maxAge過期時(shí)長)喳坠、Expires(過期時(shí)間);

協(xié)商緩存:etag(唯一標(biāo)識)宾尚、lastModified(最后修改時(shí)間)丙笋。

緩存優(yōu)先級:Cache-Control > Expires > etag > lastModified,從樹干篇中可知煌贴,在CacheInterceptor攔截器中會從磁盤取出緩存的Response(如果有)御板,然后在CacheStrategy.Factory中,解析緩存的Response來得到緩存策略CacheStrategy牛郑,

//CacheStrategy.Factory.java
CacheStrategy getCandidate() {
    //1.強(qiáng)緩存
    //計(jì)算Age
    long ageMillis = cacheResponseAge();
    //根據(jù)Response的Date和Age怠肋,計(jì)算新鮮度
    long freshMillis = computeFreshnessLifetime();
    //新鮮度符合要求,返回策略淹朋,走強(qiáng)緩存
    if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        Response.Builder builder = cacheResponse.newBuilder();
        return new CacheStrategy(null, builder.build());
    }
    //2.協(xié)商緩存
    String conditionName;
    String conditionValue;
    if (etag != null) {
        conditionName = "If-None-Match";
        //etag唯一標(biāo)識
        conditionValue = etag;
    } else if (lastModified != null) {
        conditionName = "If-Modified-Since";
        //最后修改時(shí)間
        conditionValue = lastModifiedString;
    } else if (servedDate != null) {
        conditionName = "If-Modified-Since";
        //特殊處理:把Response接收時(shí)間設(shè)置為最后修改時(shí)間
        conditionValue = servedDateString;
    } else {
        //啥參數(shù)都沒有笙各,返回策略,cacheResponse為null
        return new CacheStrategy(request, null);
    }
    Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
    //header添加行
    Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);
    //Request設(shè)置該header
    Request conditionalRequest = request.newBuilder()
        .headers(conditionalRequestHeaders.build())
        .build();
    return new CacheStrategy(conditionalRequest, cacheResponse);
}

強(qiáng)緩存內(nèi)部細(xì)節(jié)础芍,

//CacheStrategy.Factory.java
//強(qiáng)緩存
long computeFreshnessLifetime() {
    CacheControl responseCaching = cacheResponse.cacheControl();
    if (responseCaching.maxAgeSeconds() != -1) {
        //返回CacheControl的maxAge杈抢,即過期時(shí)長
        return SECONDS.toMillis(responseCaching.maxAgeSeconds());
    } else if (expires != null) {
        //返回過期時(shí)間expires減接收時(shí)間served的差值
        long servedMillis = servedDate != null
            ? servedDate.getTime()
            : receivedResponseMillis;
        long delta = expires.getTime() - servedMillis;
        return delta > 0 ? delta : 0;
    } else if (lastModified != null
               && cacheResponse.request().url().query() == null) {
        //特殊處理:RFC建議:文檔的最長期限應(yīng)默認(rèn)為提供文檔時(shí)的期限的10%
        long servedMillis = servedDate != null
            ? servedDate.getTime()
            : sentRequestMillis;
        long delta = servedMillis - lastModified.getTime();
        return delta > 0 ? (delta / 10) : 0;
    }
    return 0;
}

本地磁盤緩存了Response的頭信息文件和data文件,頭信息如下(借玩安卓API一用~)仑性,

image

看看抓包數(shù)據(jù)惶楼,請求可見okhttp自動幫我們加上了gzip壓縮(具體支不支持還得看后端接口),

image

響應(yīng)可見Cache-Control是private(不是max-age=xxx)诊杆,Expires是1970年(沒做支持)歼捐,所以這個(gè)get請求不走強(qiáng)緩存;

然后etag和lastModified也沒有晨汹,getCandidate方法會嘗試把Response接收時(shí)間設(shè)置為最后修改時(shí)間即If-Modified-Since=servedDateString豹储,再抓一次可見時(shí)間被帶上了,

image

不過由于這個(gè)接口沒做支持淘这,帶上If-Modified-Since也沒用剥扣,接口直接返回200(整個(gè)Response)而不是304(緩存可用),所以協(xié)商緩存也沒走铝穷,即其實(shí)每次請求都會返回完整的Response朦乏,磁盤緩存Response的data并沒有被用上。

要是在面試官前吹:“我做的玩安卓App,用了okhttp吃引,他強(qiáng)大的緩存機(jī)制可以為用戶提速筹陵、節(jié)省流量”刽锤,是會被吊打的!

image

緩存體系需要客戶端和后端共建朦佩,不然okhttp也有心無力并思。(當(dāng)然,客戶端也可以在okhttp外自行實(shí)現(xiàn)一層緩存语稠,那就另說了)

connection

image

ConnectInterceptor攔截器中會獲取和建立連接宋彼,

  1. 發(fā)射器創(chuàng)建交換器:transmitter.newExchange、
  2. 交換尋找器find連接:exchangeFinder.find仙畦、findHealthyConnection输涕、findConnection、
    1. 有分配好的連接可用慨畸,return
    2. 從連接池里找到池化的連接莱坎,return
    3. 創(chuàng)建連接,進(jìn)行socket連接

一個(gè)連接池有多個(gè)連接寸士,一個(gè)連接可以同時(shí)處理多個(gè)發(fā)射器檐什,下面看建立連接,

//RealConnection.java
void connect(...) {
    if (route.requiresTunnel()) {
        //如果此路由通過HTTP代理隧道HTTPS弱卡,忽略
        connectTunnel(connectTimeout, readTimeout, writeTimeout, call, eventListener);
        if (rawSocket == null) {
            break;
        }
    } else {
        //默認(rèn)沒代理乃正,走這里
        connectSocket(connectTimeout, readTimeout, call, eventListener);
    }
    //建立協(xié)議
    establishProtocol(connectionSpecSelector, pingIntervalMillis, call, eventListener);
}

void connectSocket(...) throws IOException {
    //判斷android平臺或java平臺,進(jìn)行連接婶博,最終調(diào)了socket.connect
    Platform.get().connectSocket(rawSocket, route.socketAddress(), connectTimeout);
}

void establishProtocol(...){
    //...忽略了一些http2相關(guān)內(nèi)容
    //創(chuàng)建SSLSocket瓮具、進(jìn)行tls握手
    connectTls(connectionSpecSelector);
}

socket連上后,會創(chuàng)建SSLSocket進(jìn)行tls握手凡蜻,

//RealConnection.java
void connectTls(...){
    SSLSocketFactory sslSocketFactory = address.sslSocketFactory();
    SSLSocket sslSocket = null;
    //創(chuàng)建SSLSocket
    sslSocket = (SSLSocket) sslSocketFactory.createSocket(
        rawSocket, address.url().host(), address.url().port(), true);
    //進(jìn)行tls握手
    sslSocket.startHandshake();
    socket = sslSocket;
}

route和dns

ConnectInterceptor創(chuàng)建連接時(shí)搭综,會用RouteSelector來選擇路線,

image

連接池維護(hù)了一個(gè)RouteDatabase來記錄ip黑名單划栓,可以記錄最近連接失敗過的ip地址兑巾,在RouteSelector中則會優(yōu)先選擇不在黑名單中的ip,

//RouteSelector.java
Selection next() throws IOException {
    List<Route> routes = new ArrayList<>();
    //遍歷代理忠荞,默認(rèn)有一個(gè)代理是DIRECT蒋歌,即不代理
    while (hasNextProxy()) {
        Proxy proxy = nextProxy();
        //遍歷ip
        for (int i = 0, size = inetSocketAddresses.size(); i < size; i++) {
            Route route = new Route(address, proxy, inetSocketAddresses.get(i));
            if (routeDatabase.shouldPostpone(route)) {
                //如果該ip在黑名單中,放進(jìn)推遲使用的列表
                postponedRoutes.add(route);
            } else {
                //不在黑名單的ip
                routes.add(route);
            }
        }
        if (!routes.isEmpty()) {
            //找到可用的ip就跳出
            break;
        }
    }
    if (routes.isEmpty()) {
        //沒找到可用ip委煤,才把黑名單的ip拿來用
        routes.addAll(postponedRoutes);
        postponedRoutes.clear();
    }
    return new Selection(routes);
}

可見堂油,如果一個(gè)域名配了多個(gè)ip,當(dāng)某個(gè)ip不穩(wěn)定時(shí)(連接失敗過)碧绞,之后就會跳過而優(yōu)先使用更穩(wěn)定的ip府框。(不過RouteDatabase只是簡單地基于內(nèi)存實(shí)現(xiàn),用Set記錄讥邻,App重啟黑名單就沒了)

nextProxy中迫靖,dns把域名解析成對應(yīng)ip院峡,默認(rèn)實(shí)現(xiàn)走的是InetAddress.getAllByName(hostname)

interface Dns {
    Dns SYSTEM = hostname -> {
        if (hostname == null) throw new UnknownHostException("hostname == null");
        //默認(rèn)實(shí)現(xiàn)
        return Arrays.asList(InetAddress.getAllByName(hostname));
    };

    List<InetAddress> lookup(String hostname) throws UnknownHostException;
}

有時(shí)有些數(shù)據(jù)對安全性要求不高(不需要https)系宜,或者我們要在內(nèi)網(wǎng)調(diào)試照激,可以直接換成ip訪問來省去域名解析的時(shí)間,

builder.dns(new MyDns());

class MyDns implements Dns {
    @Override
    public List<InetAddress> lookup(String hostname) throws UnknownHostException {
        if (hostname == null) throw new UnknownHostException("hostname == null");
        if (mUseDebugIp) {//使用內(nèi)網(wǎng)ip進(jìn)行調(diào)試
            return getDebugIp();
        }
        if (useConfigIp(hostname)) {//使用服務(wù)端下發(fā)的ip表盹牧,跳過域名解析
            return getIpByConfig(hostname);
        }
        //走默認(rèn)實(shí)現(xiàn)俩垃,老老實(shí)實(shí)的進(jìn)行域名解析
        return Dns.SYSTEM.lookup(hostname);
    }
}

cookie

BridgeInterceptor攔截器中會自動從CookieJar里存取Cookie、默認(rèn)的CookieJar是空實(shí)現(xiàn)汰寓,需要用OkHttpClient自行配置口柳,

builder.cookieJar(new MyCookieJar());

//基于內(nèi)存實(shí)現(xiàn)的cookieJar(通常是基于磁盤)
class MyCookieJar implements CookieJar {
    private Map<String, List<Cookie>> mCookieMap = new HashMap<>();

    @Override
    public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
        mCookieMap.put(url.host(), cookies);
    }

    @Override
    public List<Cookie> loadForRequest(HttpUrl url) {
        List<Cookie> cookies = mCookieMap.get(url.host());
        return null == cookies ? Collections.emptyList() : cookies;
    }
}

tls

默認(rèn)支持不加密、tls 1.2踩寇、tls 1.3啄清,

//OkHttpClient.java
final List<ConnectionSpec> DEFAULT_CONNECTION_SPECS = Util.immutableList(
    ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT);//tls、不加密

//ConnectionSpec.java
final ConnectionSpec MODERN_TLS = new Builder(true)
    .cipherSuites(APPROVED_CIPHER_SUITES)
    //1.2和1.3
    .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2)
    .supportsTlsExtensions(true)
    .build();

eventListener

在樹干篇提到俺孙,EventListener是航班狀態(tài)監(jiān)聽辣卒,因?yàn)樗櫫苏麄€(gè)請求流程,通過他可以看到每個(gè)環(huán)節(jié)的數(shù)據(jù)和耗時(shí)睛榄,引用官方圖片荣茫,

image

打印日志,

class PrintingEventListener extends EventListener {
    private long callStartNanos;
    private static final String TAG = "PrintingEventListener";

    private void printEvent(String name) {
        long nowNanos = System.nanoTime();
        if (name.contains("callStart")) {
            callStartNanos = nowNanos;
        }
        long elapsedNanos = nowNanos - callStartNanos;
        Log.e(TAG, String.format("%.3f %s%n", elapsedNanos / 1000000000d, name));
    }

    public void callStart(Call call) {
        printEvent("callStart url = " + call.request().url());
    }

    public void callEnd(Call call) {
        printEvent("callEnd");
    }

    public void dnsStart(Call call, String domainName) {
        printEvent("dnsStart domainName = " + domainName);
    }

    public void dnsEnd(Call call, String domainName, List<InetAddress> inetAddressList) {
        printEvent("dnsEnd");
    }
    //...
}

可見第二次請求省去了域名解析场靴、建立連接啡莉、tls握手的環(huán)節(jié),

image

參考資料

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末旨剥,一起剝皮案震驚了整個(gè)濱河市咧欣,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌轨帜,老刑警劉巖魄咕,帶你破解...
    沈念sama閱讀 217,657評論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異蚌父,居然都是意外死亡哮兰,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,889評論 3 394
  • 文/潘曉璐 我一進(jìn)店門苟弛,熙熙樓的掌柜王于貴愁眉苦臉地迎上來喝滞,“玉大人,你說我怎么就攤上這事膏秫∮以猓” “怎么了?”我有些...
    開封第一講書人閱讀 164,057評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長狸演。 經(jīng)常有香客問我言蛇,道長,這世上最難降的妖魔是什么宵距? 我笑而不...
    開封第一講書人閱讀 58,509評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮吨拗,結(jié)果婚禮上满哪,老公的妹妹穿的比我還像新娘。我一直安慰自己劝篷,他們只是感情好哨鸭,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,562評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著娇妓,像睡著了一般像鸡。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上哈恰,一...
    開封第一講書人閱讀 51,443評論 1 302
  • 那天只估,我揣著相機(jī)與錄音,去河邊找鬼着绷。 笑死蛔钙,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的荠医。 我是一名探鬼主播吁脱,決...
    沈念sama閱讀 40,251評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼彬向!你這毒婦竟也來了兼贡?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,129評論 0 276
  • 序言:老撾萬榮一對情侶失蹤娃胆,失蹤者是張志新(化名)和其女友劉穎遍希,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體缕棵,經(jīng)...
    沈念sama閱讀 45,561評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡孵班,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,779評論 3 335
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了招驴。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片篙程。...
    茶點(diǎn)故事閱讀 39,902評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖别厘,靈堂內(nèi)的尸體忽然破棺而出虱饿,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 35,621評論 5 345
  • 正文 年R本政府宣布氮发,位于F島的核電站渴肉,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏爽冕。R本人自食惡果不足惜仇祭,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,220評論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望颈畸。 院中可真熱鬧乌奇,春花似錦、人聲如沸眯娱。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,838評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽徙缴。三九已至试伙,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間于样,已是汗流浹背疏叨。 一陣腳步聲響...
    開封第一講書人閱讀 32,971評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留百宇,地道東北人考廉。 一個(gè)月前我還...
    沈念sama閱讀 48,025評論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像携御,于是被迫代替她去往敵國和親昌粤。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,843評論 2 354