Android | okhttp细枝篇

嗨,我是哈利迪~《看完不忘系列》之okhttp(树干篇)一文对okhttp的请求流程做了初步介绍,本文将对他的一些实现细节和相关网络知识进行补充。

本文约2000字,阅读大约5分钟。

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

推荐阅读「查缺补漏」巩固你的HTTP知识体系,常用的概念都在了,由于目前用的比较多的还是http 1.1,所以下面分析会跳过http2,以http 1.1为主。

cache

image

强缓存:Cache-Control(maxAge过期时长)、Expires(过期时间);

协商缓存:etag(唯一标识)、lastModified(最后修改时间)。

缓存优先级:Cache-Control > Expires > etag > lastModified,从树干篇中可知,在CacheInterceptor拦截器中会从磁盘取出缓存的Response(如果有),然后在CacheStrategy.Factory中,解析缓存的Response来得到缓存策略CacheStrategy

//CacheStrategy.Factory.java
CacheStrategy getCandidate() {
    //1.强缓存
    //计算Age
    long ageMillis = cacheResponseAge();
    //根据Response的Date和Age,计算新鲜度
    long freshMillis = computeFreshnessLifetime();
    //新鲜度符合要求,返回策略,走强缓存
    if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        Response.Builder builder = cacheResponse.newBuilder();
        return new CacheStrategy(null, builder.build());
    }
    //2.协商缓存
    String conditionName;
    String conditionValue;
    if (etag != null) {
        conditionName = "If-None-Match";
        //etag唯一标识
        conditionValue = etag;
    } else if (lastModified != null) {
        conditionName = "If-Modified-Since";
        //最后修改时间
        conditionValue = lastModifiedString;
    } else if (servedDate != null) {
        conditionName = "If-Modified-Since";
        //特殊处理:把Response接收时间设置为最后修改时间
        conditionValue = servedDateString;
    } else {
        //啥参数都没有,返回策略,cacheResponse为null
        return new CacheStrategy(request, null);
    }
    Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
    //header添加行
    Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);
    //Request设置该header
    Request conditionalRequest = request.newBuilder()
        .headers(conditionalRequestHeaders.build())
        .build();
    return new CacheStrategy(conditionalRequest, cacheResponse);
}

强缓存内部细节,

//CacheStrategy.Factory.java
//强缓存
long computeFreshnessLifetime() {
    CacheControl responseCaching = cacheResponse.cacheControl();
    if (responseCaching.maxAgeSeconds() != -1) {
        //返回CacheControl的maxAge,即过期时长
        return SECONDS.toMillis(responseCaching.maxAgeSeconds());
    } else if (expires != null) {
        //返回过期时间expires减接收时间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建议:文档的最长期限应默认为提供文档时的期限的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

看看抓包数据,请求可见okhttp自动帮我们加上了gzip压缩(具体支不支持还得看后端接口),

image

响应可见Cache-Control是private(不是max-age=xxx),Expires是1970年(没做支持),所以这个get请求不走强缓存;

然后etag和lastModified也没有,getCandidate方法会尝试把Response接收时间设置为最后修改时间即If-Modified-Since=servedDateString,再抓一次可见时间被带上了,

image

不过由于这个接口没做支持,带上If-Modified-Since也没用,接口直接返回200(整个Response)而不是304(缓存可用),所以协商缓存也没走,即其实每次请求都会返回完整的Response,磁盘缓存Response的data并没有被用上。

要是在面试官前吹:“我做的玩安卓App,用了okhttp,他强大的缓存机制可以为用户提速、节省流量”,是会被吊打的!

image

缓存体系需要客户端和后端共建,不然okhttp也有心无力。(当然,客户端也可以在okhttp外自行实现一层缓存,那就另说了)

connection

image

ConnectInterceptor拦截器中会获取和建立连接,

  1. 发射器创建交换器:transmitter.newExchange、
  2. 交换寻找器find连接:exchangeFinder.find、findHealthyConnection、findConnection、
    1. 有分配好的连接可用,return
    2. 从连接池里找到池化的连接,return
    3. 创建连接,进行socket连接

一个连接池有多个连接,一个连接可以同时处理多个发射器,下面看建立连接,

//RealConnection.java
void connect(...) {
    if (route.requiresTunnel()) {
        //如果此路由通过HTTP代理隧道HTTPS,忽略
        connectTunnel(connectTimeout, readTimeout, writeTimeout, call, eventListener);
        if (rawSocket == null) {
            break;
        }
    } else {
        //默认没代理,走这里
        connectSocket(connectTimeout, readTimeout, call, eventListener);
    }
    //建立协议
    establishProtocol(connectionSpecSelector, pingIntervalMillis, call, eventListener);
}

void connectSocket(...) throws IOException {
    //判断android平台或java平台,进行连接,最终调了socket.connect
    Platform.get().connectSocket(rawSocket, route.socketAddress(), connectTimeout);
}

void establishProtocol(...){
    //...忽略了一些http2相关内容
    //创建SSLSocket、进行tls握手
    connectTls(connectionSpecSelector);
}

socket连上后,会创建SSLSocket进行tls握手,

//RealConnection.java
void connectTls(...){
    SSLSocketFactory sslSocketFactory = address.sslSocketFactory();
    SSLSocket sslSocket = null;
    //创建SSLSocket
    sslSocket = (SSLSocket) sslSocketFactory.createSocket(
        rawSocket, address.url().host(), address.url().port(), true);
    //进行tls握手
    sslSocket.startHandshake();
    socket = sslSocket;
}

route和dns

ConnectInterceptor创建连接时,会用RouteSelector来选择路线,

image

连接池维护了一个RouteDatabase来记录ip黑名单,可以记录最近连接失败过的ip地址,在RouteSelector中则会优先选择不在黑名单中的ip,

//RouteSelector.java
Selection next() throws IOException {
    List<Route> routes = new ArrayList<>();
    //遍历代理,默认有一个代理是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在黑名单中,放进推迟使用的列表
                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);
}

可见,如果一个域名配了多个ip,当某个ip不稳定时(连接失败过),之后就会跳过而优先使用更稳定的ip。(不过RouteDatabase只是简单地基于内存实现,用Set记录,App重启黑名单就没了)

nextProxy中,dns把域名解析成对应ip,默认实现走的是InetAddress.getAllByName(hostname)

interface Dns {
    Dns SYSTEM = hostname -> {
        if (hostname == null) throw new UnknownHostException("hostname == null");
        //默认实现
        return Arrays.asList(InetAddress.getAllByName(hostname));
    };

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

有时有些数据对安全性要求不高(不需要https),或者我们要在内网调试,可以直接换成ip访问来省去域名解析的时间,

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) {//使用内网ip进行调试
            return getDebugIp();
        }
        if (useConfigIp(hostname)) {//使用服务端下发的ip表,跳过域名解析
            return getIpByConfig(hostname);
        }
        //走默认实现,老老实实的进行域名解析
        return Dns.SYSTEM.lookup(hostname);
    }
}

cookie

BridgeInterceptor拦截器中会自动从CookieJar里存取Cookie、默认的CookieJar是空实现,需要用OkHttpClient自行配置,

builder.cookieJar(new MyCookieJar());

//基于内存实现的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

默认支持不加密、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是航班状态监听,因为他跟踪了整个请求流程,通过他可以看到每个环节的数据和耗时,引用官方图片,

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握手的环节,

image

参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341