OKHttp 网络框架的原理在面试过程中经常被问道,笔者希望通过总结文字+流程图的方式来归纳OKHttp的原理。
1. OKhttp是什么?
OKHttp 是由Square公司开源的网络请求框架。Google在Android4.4以后开始将源码中的HttpURLConnection底层实现替换为OKHttp。
2. 为什么要使用OKHttp网络请求框架,或者该网络请求框架有什么优点?
1)支持Http1、Http2、WebSocket
这里顺便复习一下http1.0, http1.1和http2.0的区别:
http1.0:每次请求都建立短连接,连接不可复用。
http1.1:引入了长连接,但是连接里的请求是排队等待的,其中一个请求超时,后续等待的请求就被阻塞。传输数据格式是文本。
http2.0: 多路复用。多个请求不是像1.1那样排队等待而是并行的,根据请求id来区分不同的请求;而且http2.0使用二进制格式进行传输;http2.0支持头部压缩;支持服务端主动push消息。
2)具有重试和重定向机制
3)支持GZIP压缩数据,减少数据流量
4)可以缓存响应的数据,减少短时间内重复的网络请求
5)底层TCP socket 连接复用,可以减少请求延时
等等,这些优点其实和OKHttp的五大拦截器(重试重定向拦截器、桥接拦截器、缓存拦截器、连接拦截器、请求服务拦截器)密切相关。
3. 如何使用OKHttp? 以同步Get和异步Get请求为例
//同步Get 请求
String url = "http://wwww.baidu.com";
OkHttpClient okHttpClient = new OkHttpClient();
final Request request = new Request.Builder()
.url(url)
.build();
final Call call = okHttpClient.newCall(request);
new Thread(new Runnable() {
@Override
public void run() {
try {
Response response = call.execute();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
//异步Get请求
String url = "http://wwww.baidu.com";
OkHttpClient okHttpClient = new OkHttpClient();
final Request request = new Request.Builder()
.url(url)
.get()
.build();
Call call = okHttpClient.newCall(request);
call.enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.d(TAG, "okhttp onFailure: ");
}
@Override
public void onResponse(Call call, Response response) throws IOException {
Log.d(TAG, "okhttp onResponse: " + response.body().string());
}
});
使用OKHttp发起请求时,使用到三个关键的类OKHttpClient、Call、Request。
这里涉及到的设计模式:Request的创建是 Builder建造者设计模式、OKHttpClient的使用是门面设计模式
调用Call的同步接口execute或者异步接口enqueue后,统一交给分发器Dispatcher进行处理。
分发器把请求交给了拦截器链(责任链设计模式),整个流程如下图
在拦截器链中,传递的是Chain,可以通过Chain获取到Request,返回的是Response,一个U字型的请求响应链。
4. 什么是分发器Dispatcher,有什么作用?
分发器作用是调配请求任务,内部有一个线程池、维护了三个队列:
//异步请求使用的线程池
private ExecutorService executorService;
//异步请求准备队列
private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque();
//异步请求正在执行队列
private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque();
//同步请求正在执行队列
private final Deque<RealCall> runningSyncCalls = new ArrayDeque();
分发器内部工作原理流程图(以异步请求为例):实际上就是一个生产——消费者模式, Dispatcher是生产者,线程池是消费者。
1)判断Call是放入ready队列还是Running队列的依据
如果当前正在执行队列的请求数大于64,则放入ready队列;
如果小于64,但是已经存在同一域名主机的请求5个,也还是放入 ready 队列;
2)从ready队列 把请求Call移动到执行队列的条件是什么?
每个请求执行完成就会调用client.dispatcher().finished(call)把当前的call从running队列移除,然后调用Dispatcher的promoteCalls()和1) 相同逻辑的判断,决定是否移动。
3)分发器中的线程池有什么特点?
核心线程数0; 最大线程数:Interger.MAXVALUE;keepAliveTime : 60
可以做到无等待、最大并发。
4)线程池的原理:
当把请求交给线程池时
a. 线程数量小于核心线程数量,新建核心线程处理新来的请求任务。
b. 线程数量大于等于核心线程数量,如果存在空闲线程,使用空闲线程处理新来的请求任务。
c.线程数量大于等于核心线程数量,并且不存在空闲线程,新的请求任务会被添加到等待队列
添加请求任务到等待队列成功:等待空闲线程。
添加请求任务到等待队列失败:
如果线程数量小于最大线程池数量,则新建线程执行新的请求任务
如果线程数量等于最大线程池数量,拒绝该任务
OKhttp的线程池采用的是SyncChronousQueue队列——没有容量的等待队列,添加任务的时候会添加失败,因此会走到上述的步骤c 添加失败,又因为最大线程数是Interger.MAXVALUE,所以就会新建线程处理新的请求任务,这样就能做到无等待,最大并发。
public synchronized ExecutorService executorService() {
if (this.executorService == null) {
this.executorService = new ThreadPoolExecutor(0,
Interger.MAXVALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue(),
Util.threadFactory("OkHttp Dispatcher", false));
}
return this.executorService;
}
5.每一个拦截器的作用是什么?
1)重试重定向拦截器
请求超时可以重试;请求返回根据返回码如果是30X,可以进行重定向
2)桥接拦截器
对Request添加一些请求头,进行GZip压缩和解压缩
3)缓存拦截器
判断当前请求是否存在缓存以及是否可用利用缓存
4)连接拦截器
复用或者新建一个socket连接
5)请求服务拦截器
真正与服务器进行通信,向服务端发送数据和解析服务端的响应数据
6. 连接池复用原理
1)连接池的类位于okhttp3.ConnectionPool:
private static final Executor executor = new ThreadPoolExecutor(0 /* corePoolSize */,
Integer.MAX_VALUE /* maximumPoolSize */, 60L /* keepAliveTime */, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp ConnectionPool", true));
private final int maxIdleConnections;
private final Deque<RealConnection> connections = new ArrayDeque<>();
final RouteDatabase routeDatabase = new RouteDatabase();
boolean cleanupRunning;
线程池 : 使用的是 SyncChronousQueue队列
Deque<RealConnection>:双向队列
RouteDatabase: 记录连接失败的Route黑名单,当连接失败的时候就会把失败的线路加进去
RealConnection: 对Socket的物理连接的包装, 内部维护了List<Reference<StreamAllocation>>
的引用。StreamAllocation的数量是socket被引用的计数,为0代表RealConnection空闲,不为0代表上层有引用,不关闭RealConnection。
2)ConnectionPool提供对Deque<RealConnection>进行操作的方法分别为put放入连接、get获取连接、connectionBecameIdle移除连接、evictAll移除所有连接。
Put方法核心:每次放入新的连接之前,先执行清理空闲连接的线程。
void put(RealConnection connection) {
assert (Thread.holdsLock(this));
if (!cleanupRunning) {
cleanupRunning = true;
executor.execute(cleanupRunnable);
}
connections.add(connection);
}
Get方法核心:遍历连接缓存列表connections,当Connection中socket的引用计数的次数小于限制大小并且请求的地址和此连接的地址完全匹配。则直接复用该Connection作为request的连接。
RealConnection get(Address address, StreamAllocation streamAllocation) {
assert (Thread.holdsLock(this));
for (RealConnection connection : connections) {
if (connection.allocations.size() < connection.allocationLimit
&& address.equals(connection.route().address)
&& !connection.noNewStreams) {
streamAllocation.acquire(connection);
return connection;
}
}
return null;
}
清理线程的核心:不停调用Cleanup 清理并返回下次清理的间隔时间。继而进入wait 等待,时间到后释放锁,继续执行下一次Cleanup 清理。
private final Runnable cleanupRunnable = new Runnable() {
@Override public void run() {
while (true) {
long waitNanos = cleanup(System.nanoTime());
if (waitNanos == -1) return;
if (waitNanos > 0) {
long waitMillis = waitNanos / 1000000L;
waitNanos -= (waitMillis * 1000000L);
synchronized (ConnectionPool.this) {
try {
ConnectionPool.this.wait(waitMillis, (int) waitNanos);
} catch (InterruptedException ignored) {
}
}
}
}
}
};
cleanup核心:
1)首先pruneAndGetAllocationCount()会根据连接中的引用计数来计算空闲连接数idleConnectionCount和活跃连接数inUseConnection。
2)如果空闲连接闲置时间超过keepAliveDurationNs,或者空闲连接数超过maxIdleConnections,则从Deque中移除此连接。
3)如果空闲连接个数大于0则返回此连接即将到期的时间,如果都是活跃连接并且大于0则返回默认的keepAlive时间5分钟,如果没有任何连接则跳出循环并返回-1。
long cleanup(long now) {
int inUseConnectionCount = 0;
int idleConnectionCount = 0;
RealConnection longestIdleConnection = null;
long longestIdleDurationNs = Long.MIN_VALUE;
// Find either a connection to evict, or the time that the next eviction is due.
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++;
//寻找闲置时间越大的连接
long idleDurationNs = now - connection.idleAtNanos;
if (idleDurationNs > longestIdleDurationNs) {
longestIdleDurationNs = idleDurationNs;
longestIdleConnection = connection;
}
}
//如果空闲连接闲置时间超过keepAliveDurationNs,或者空闲连接数超过maxIdleConnections
//则从Deque中移除此 连接。
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) {
//如果空闲连接个数大于0则返回此连接即将到期的时间
// A connection will be ready to evict soon.
return keepAliveDurationNs - longestIdleDurationNs;
} else if (inUseConnectionCount > 0) {
//如果都是活跃连接并且大于0则返回默认的keepAlive时间5分钟
// All connections are in use. It'll be at least the keep alive duration 'til we run again.
return keepAliveDurationNs;
} else {
//如果没有任何连接则跳出循环并返回-1
// No connections, idle or in use.
cleanupRunning = false;
return -1;
}
}
closeQuietly(longestIdleConnection.socket());
// Cleanup again immediately.
return 0;
}
总结连接池复用的原理:
使用Deque<RealConnection>双向队列
来存储连接,通过put、get、connectionBecameIdle和evictAll几个操作来对Deque进行操作。
通过连接Connection中socket的引用计数对象StreamAllocation来判断是否是空闲连接还是活跃连接。
根据算法(空闲连接闲置时间超过keepAliveDurationNs,或者空闲连接数超过maxIdleConnections,则从Deque中移除此连接)进行回收。
复用时,遍历连接缓存列表connections,当Connection中socket的引用计数的次数小于限制大小并且请求的地址和此连接的地址完全匹配。则直接复用该Connection作为request的连接。
7. 面试问题
7.1 OKHttp网络请求做了什么优化?
1)连接池复用,减少了网络请求延时。连接池复用原理如上。
2)无缝支持GZip压缩来减少数据流量。可以在Request的请求头添加("Accept-Encoding", "gzip")告诉服务端数据是经过gzip压缩的。
3)缓存数据减少重复的网络请求。
OKHttp里有一个CacheStrategy对象用于判断是使用缓存还是发起网络请求,该对象内部有两个成员变量networkRequest和cacheResponse。当networkRequest不为空,则发起网络请求;如果networkRequest为空,cacheRespose不为空,则使用缓存;两者都不存在则返回504,请求失败。
4)支持重试和重定向。注意:并不是失败都重试,一般是发生 路由异常或者IO异常并且满足OKHttpClient的重试配置才能重试。
7.2 OKHttp使用了哪些设计模式
Builder(创建Request)、责任链(拦截器)、工厂(如创建Call对象时);门面设计模式(Dispatcher)等。
7.3 回答一下OKHttp是如何进行异步网络请求的
1)Builder设计模式创建OkhttpClient对象和Request对象
2)通过OkhttpClient对象和Request对象创建Call,内部使用了工厂方法设计模式。
3)Call.enqueue把事情交给Dispatcher,Dispatcher相当于生产者,消费者就是线程池。
4)Call根据条件被放入ready队列还是执行队列(正在执行的任务不大于64并且同一个域名请求的连接不超过5个就被放入执行队列)
5)Call执行时经过五大拦截器(责任链设计模式)把请求发送到服务端。服务端返回后需要手动切换到主线程进行Ui更新(如何没有使用Retrofit)
7.4 个人对网络这块做过的优化
1)HttpDns ip直连。Okhttp提供了dns接口设置自己的HttpDns,可以解决Local DNS域名解析耗时长、域名劫持、跨网访问以及域名缓存等问题。
2)使用webP格式的图片代替jpeg和png格式的图片,节省流量。
3)网络请求合并请求,如业务埋点累计一定的数目后再进行统一上报,而不是每条埋点每次触发就上报。
4)大文件采用分块上传下载,断点续传,降低传输失败率。
5)业务逻辑需要有缓存机制,优先使用本地的内存缓存和磁盘缓存进行UI显示。
6)大量数据按需加载,使用增量更新机制。如需要分页的模块。