Android | okhttp细枝篇
嗨,我是哈利迪~《看完不忘系列》之okhttp(树干篇)一文对okhttp的请求流程做了初步介绍,本文将对他的一些实现细节和相关网络知识进行补充。
本文约2000字,阅读大约5分钟。
源码基于3.14.9,即java版本的最新版
推荐阅读「查缺补漏」巩固你的HTTP知识体系,常用的概念都在了,由于目前用的比较多的还是http 1.1,所以下面分析会跳过http2,以http 1.1为主。
cache
强缓存: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一用~),
看看抓包数据,请求可见okhttp自动帮我们加上了gzip压缩(具体支不支持还得看后端接口),
响应可见Cache-Control是private(不是max-age=xxx),Expires是1970年(没做支持),所以这个get请求不走强缓存;
然后etag和lastModified也没有,getCandidate方***尝试把Response接收时间设置为最后修改时间
即If-Modified-Since=servedDateString,再抓一次可见时间被带上了,
不过由于这个接口没做支持,带上If-Modified-Since也没用,接口直接返回200(整个Response)而不是304(缓存可用),所以协商缓存也没走,即其实每次请求都会返回完整的Response,磁盘缓存Response的data并没有被用上。
要是在面试官前吹:“我做的玩安卓App,用了okhttp,他强大的缓存机制可以为用户提速、节省流量”,是会被吊打的!
缓存体系需要客户端和后端共建,不然okhttp也有心无力。(当然,客户端也可以在okhttp外自行实现一层缓存,那就另说了)
connection
ConnectInterceptor
拦截器中会获取和建立连接,
- 发射器创建交换器:transmitter.newExchange、
- 交换寻找器find连接:exchangeFinder.find、findHealthyConnection、findConnection、
- 有分配好的连接可用,return
- 从连接池里找到池化的连接,return
- 创建连接,进行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
来选择路线,
连接池维护了一个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
是航班状态监听,因为他跟踪了整个请求流程,通过他可以看到每个环节的数据和耗时,引用官方图片,
打印日志,
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握手的环节,
参考资料
- 官网 & GitHub & 3.x文档
- 掘金 - 「查缺补漏」巩固你的HTTP知识体系
- 掘金 - Okhttp如何开启的Http2.0 & 掘金 - HTTP 2.0与OkHttp
- 简书 - SSL/TLS 握手协议Handshake Protocol全过程解析