深入OKHttp之网络连接

2022-05-10 20:36:08 浏览数 (1)

从 HTTP 连接说起

我们在进行http请求的时候,会有大致如下几个流程:DNS -> 建立Socket连接 -> 应用层进行 http 请求。

那么 OKHttp 是怎么进行每一步的处理呢,今天我们就来一探究竟。

ConnectInterceptor

ConnectInterceptor 中,我们可以看到如下几行代码

代码语言:javascript复制
StreamAllocation streamAllocation = realChain.streamAllocation();
HttpCodec httpCodec = streamAllocation.newStream(client, chain, doExtensiveHealthChecks);
RealConnection connection = streamAllocation.connection();

可以看到这里初始化了一个 StreamAllocation ,开启了一次新的 newStream ,最终返回了一个 RealConnection 来表示连接的对象。

我们一步一步具体分析

newStream 中,会调用 findHealthyConnection :

代码语言:javascript复制
while (true) {
    RealConnection candidate = findConnection(connectTimeout, readTimeout, writeTimeout,
    pingIntervalMillis, connectionRetryEnabled);

    // If this is a brand new connection, we can skip the extensive health checks.
    synchronized (connectionPool) {
        if (candidate.successCount == 0) {
          return candidate;
        }
    }

    // Do a (potentially slow) check to confirm that the pooled connection is still good. If it
    // isn't, take it out of the pool and start again.
    if (!candidate.isHealthy(doExtensiveHealthChecks)) {
        noNewStreams();
        continue;
    }

    return candidate;
}

这里,会有一个循环,一直在寻找一个 "healthy" 的连接,如果不是全新的连接,则会释放掉,继续去建立连接。

查看 findConnection ,我留下了部分关键代码进行分析:

代码语言:javascript复制
if (this.connection != null) {
    // We had an already-allocated connection and it's good.
    result = this.connection;
    releasedConnection = null;
}

通过注释我们了解到,我们已经有了一个可用的连接,直接复用。

代码语言:javascript复制
if (result == null) {
    // Attempt to get a connection from the pool.
    Internal.instance.get(connectionPool, address, this, null);
    if (connection != null) {
        foundPooledConnection = true;
        result = connection;
    } else {
        selectedRoute = route;
    }
}

如果不存在连接,去一个叫 connectionPool 的对象中尝试去取。

代码语言:javascript复制
if (result != null) {
    // If we found an already-allocated or pooled connection, we're done.
    return result;
}

如果这里已经找到了连接,就会直接返回。

我们继续看下面的代码,当需要我们自己创建一个连接的时候,OKHttp 是怎么处理的:

代码语言:javascript复制
boolean newRouteSelection = false;
if (selectedRoute == null && (routeSelection == null || !routeSelection.hasNext())) {
    newRouteSelection = true;
    routeSelection = routeSelector.next();
}

如果这时候没有 selectedRoute , 我们就从 routeSelector.next() 中选出一个 "路由选择"。其中包含了一套路由,每个路由有自己的地址和代理。

在拥有这组 ip 地址后,会再次尝试从 Pool 中获取连接对象。如果仍然获取不到,就自己创建一个。并调用一下 acquire(RealConnection connection, boolean reportedAcquired) 方法。

这时候如果使用的是全新的 Connect, 那么,我们就要调用 connect 方法:

代码语言:javascript复制
// Do TCP   TLS handshakes. This is a blocking operation.
result.connect(connectTimeout, readTimeout, writeTimeout, pingIntervalMillis,
connectionRetryEnabled, call, eventListener);
routeDatabase().connected(result.route());

并且,会把这个连接也 put 到 pool 里面:

代码语言:javascript复制
 // Pool the connection.
Internal.instance.put(connectionPool, result);

连接池

从上面的代码中,我们可以一直看到 ConnectionPool 这个对象。这个对象代表的是一个 TCP 连接池。Http 协议需要先建立每个 TCP 连接。如果 TCP 连接在满足条件的时候进行复用,无疑会节省很多系统资源。并且加快 Http 的整个过程,也可以理解成,缩短了 Http 请求回来的时间。

ConnectionPool 内部维护了:

•线程池 executor•clean 任务的 cleanuoRunnable•维护了 RealConnection 的队列•RouteDatabase

我们关注一下连接池的存取:

代码语言:javascript复制
@Nullable RealConnection get(Address address, StreamAllocation streamAllocation, Route route) {
    assert (Thread.holdsLock(this));
    for (RealConnection connection : connections) {
      if (connection.isEligible(address, route)) {
        streamAllocation.acquire(connection, true);
        return connection;
      }
    }
    return null;
  }

这里会在满足条件的时候,返回已经存在队列里面的 Connection 对象。那么什么时候是满足条件的呢?我们直接看 isEligible 方法里面的注释:

1.接受新的 stream, 并且 address 的 host 字段都相同,满足2.如果 hostname 不相同,我们仍然可以继续判断,这时候满足的条件就必须是 http2 了。具体 http2 的满足条件,我们后面再继续探究。

我们还可以发现:每次我们使用连接的时候,都会调用 StreamAllocationacquire 方法。我们瞥一眼这个方法:

代码语言:javascript复制
connection.allocations.add(new StreamAllocationReference(this, callStackTrace));

原来在每个 Connection 中,维护了一个 StreamAllocation 的弱引用的数组,来表示这个连接被谁引用。这个是一个很典型的引用计数方式。如果连接没有被引用,则可以认为这个连接是可以被清理的。

取出连接看完了,我们再看看连接建立的时候,是怎么扔到连接池的:

代码语言:javascript复制
void put(RealConnection connection) {
    assert (Thread.holdsLock(this));
    if (!cleanupRunning) {
      cleanupRunning = true;
      executor.execute(cleanupRunnable);
    }
    connections.add(connection);
  }

这里可以看到,每次连接放进连接池的时候,会触发一次清理操作:

代码语言:javascript复制
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 会返回纳秒为单位的下次清理时间的间隔。在时间到之前就阻塞进入冻结的状态。等待下一次清理。 cleanup 的具体逻辑不赘述。当连接的空闲时间比较长的时候,就会被清理释放。

路由选择

在获取连接的过程中,我们会调用 routeSelectornext 方法,来获取我们的路由。那么这个路由选择内部做了什么事情呢?

代码语言:javascript复制
public Selection next() {
    List<Route> routes = new ArrayList<>();
    while (hasNextProxy()) {
        Proxy proxy = nextProxy();
        for (int i = 0, size = inetSocketAddresses.size(); i < size; i  ) {
            Route route = new Route(address, proxy, inetSocketAddresses.get(i));
            if (routeDatabase.shouldPostpone(route)) {
                postponedRoutes.add(route);
            } else {
                 routes.add(route);
            }
        }
        if (!routes.isEmpty()) {
            break;
        }
    }
    return new Selection(routes);
}

这里也有一个循环,会不断的获取 Proxy ,然后根据每一个 InetSocketAddress 创建 Route 对象。如果路由是通的,那么就直接返回。如果这些地址的路由在之前都存在 routeDatabase 中,说明都不是可用的,则继续下一个 Proxy

再看下 StreamAllocation 初始化 RouteSelector 的逻辑,会调用 resetNextProxy 方法:

代码语言:javascript复制
List<Proxy> proxiesOrNull = address.proxySelector().select(url.uri());
      proxies = proxiesOrNull != null && !proxiesOrNull.isEmpty()
          ? Util.immutableList(proxiesOrNull)
          : Util.immutableList(Proxy.NO_PROXY);

addressProxySelector , 则是在构造 OKHttpClient 的时候创建的:

代码语言:javascript复制
proxySelector = ProxySelector.getDefault();

它的实现类会去读取系统的代理。当然,我们也可以自己提供自定义的 Proxy 策略。绕过系统的代理。这就是为什么有些时候我们给手机设置了 proxy,但是有些 APP 仍然不会走代理。

代理

现在我们来看看,获取 Proxy 的时候,OKHttp 究竟做了哪些事情:

代码语言:javascript复制
Proxy result = proxies.get(nextProxyIndex  );
resetNextInetSocketAddress(result);
return result;
代码语言:javascript复制
private void resetNextInetSocketAddress(Proxy proxy) {
    String socketHost;
    int socketPort;
    if (proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.SOCKS) {
        socketHost = address.url().host();
        socketPort = address.url().port();
    } else {
        SocketAddress proxyAddress = proxy.address();
        InetSocketAddress proxySocketAddress = (InetSocketAddress) proxyAddress;
        socketHost = getHostString(proxySocketAddress);
        socketPort = proxySocketAddress.getPort();
    }

    if (proxy.type() == Proxy.Type.SOCKS) {
         inetSocketAddresses.add(InetSocketAddress.createUnresolved(socketHost, socketPort));
    } else {
         List<InetAddress> addresses = address.dns().lookup(socketHost);
         for (int i = 0, size = addresses.size(); i < size; i  ) {
             InetAddress inetAddress = addresses.get(i);
             inetSocketAddresses.add(new InetSocketAddress(inetAddress, socketPort));
         }
    }
}

在代码中,Proxy 有三种模式:

•http 代理•socks 代理•DIRECT 或者 没有代理

当直接连接或者是 socks 代理的时候,socket 的host 和 port 从 address 中获取, 当是http代理的时候,则从 proxy 的代理中获取 host 和 port。如果是http代理,后续会继续走 DNS 去解析代理服务器的host。最终,这些host和port都会封装成 InetSocketAddress 对象放到 ip 列表中。

连接

介绍完连接池、路由和代理,我们来看发起 connect 这个操作的地方,即 RealConnectionconnect 方法:(这里我删除了不关键的错误处理代码)

代码语言:javascript复制
public void connect(int connectTimeout, int readTimeout, int writeTimeout,
      int pingIntervalMillis, boolean connectionRetryEnabled, Call call,
      EventListener eventListener) {

     while (true) {
         if (route.requiresTunnel()) {
            //1. 隧道连接 
            connectTunnel(connectTimeout, readTimeout, writeTimeout, call, eventListener);
            if (rawSocket == null) {
                break;
            }
         } else {
             // 2. 直接socket连接
              connectSocket(connectTimeout, readTimeout, call, eventListener);
         }
         // 3. 建立连接协议
         establishProtocol(connectionSpecSelector, pingIntervalMillis, call, eventListener);
     }

}

套接字连接

我们先来看socket连接:

代码语言:javascript复制
private void connectSocket(int connectTimeout, int readTimeout, Call call,
      EventListener eventListener) throws IOException {
    Proxy proxy = route.proxy();
    Address address = route.address();

    rawSocket = proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.HTTP
        ? address.socketFactory().createSocket()
        : new Socket(proxy);

    rawSocket.setSoTimeout(readTimeout);

    Platform.get().connectSocket(rawSocket, route.socketAddress(), connectTimeout);

    source = Okio.buffer(Okio.source(rawSocket));
    sink = Okio.buffer(Okio.sink(rawSocket));
}

具体连接操作在不同的平台上不一样,在 Android 中是在 AndroidPlatformconnectSocket 中进行的:

代码语言:javascript复制
socket.connect(address, connectTimeout);

这时候, RealConnection 中的 sourcesink 就分别代表了 socket 网络流的读入和写入。

隧道连接

隧道连接的逻辑在 connectTunnel 中:

代码语言:javascript复制
 private void connectTunnel(int connectTimeout, int readTimeout, int writeTimeout, Call call,
      EventListener eventListener) throws IOException {
    Request tunnelRequest = createTunnelRequest();
    HttpUrl url = tunnelRequest.url();
    for (int i = 0; i < MAX_TUNNEL_ATTEMPTS; i  ) {
        connectSocket(connectTimeout, readTimeout, call, eventListener);
        tunnelRequest = createTunnel(readTimeout, writeTimeout, tunnelRequest, url);
        if (tunnelRequest == null) break; // Tunnel successfully created.
    }
 }

这里我们可以看到,隧道连接会先进行socket连接,然后创建隧道。如果创建不成功,会连续尝试 21 次。

代码语言:javascript复制
private Request createTunnel(int readTimeout, int writeTimeout, Request tunnelRequest,
      HttpUrl url) throws IOException {
    String requestLine = "CONNECT "   Util.hostHeader(url, true)   " HTTP/1.1";
    while (true) {
        Http1Codec tunnelConnection = new Http1Codec(null, null, source, sink);
        tunnelConnection.writeRequest(tunnelRequest.headers(), requestLine);
        tunnelConnection.finishRequest();

        Response response = tunnelConnection.readResponseHeaders(false)
          .request(tunnelRequest)
          .build();

        Source body = tunnelConnection.newFixedLengthSource(contentLength);
        Util.skipAll(body, Integer.MAX_VALUE, TimeUnit.MILLISECONDS);
        body.close();

        switch (response.code()) {
            case HTTP_OK:
                if (!source.buffer().exhausted() || !sink.buffer().exhausted()) {
                    throw new IOException("TLS tunnel buffered too many bytes!");
                  }
                  return null;
            case HTTP_PROXY_AUTH:
                tunnelRequest = route.address().proxyAuthenticator().authenticate(route, response);
                  if (tunnelRequest == null) throw new IOException("Failed to authenticate with proxy");

                  if ("close".equalsIgnoreCase(response.header("Connection"))) {
                    return tunnelRequest;
                  }
                  break;
            default:
                  throw new IOException("Unexpected response code for CONNECT: "   response.code());
        }
    }
}

确认协议

在隧道或者socket连接建立完成后,会进行应用层的协议选择。查看 establishProtocol :

代码语言:javascript复制
if (route.address().sslSocketFactory() == null) {
    // 不是 ssl 连接,确认为 http 1.1
    protocol = Protocol.HTTP_1_1;
    return;
}
// ssl 连接
connectTls(connectionSpecSelector);
// http 2
if (protocol == Protocol.HTTP_2) {
    http2Connection = new Http2Connection.Builder(true)
          .socket(socket, route.address().url().host(), source, sink)
          .listener(this)
          .pingIntervalMillis(pingIntervalMillis)
          .build();
    http2Connection.start();
}

这里可以看到,如果 http 连接不支持 ssl 的话,就认为他是 http 1.1, 虽然理论上 http2 也可以是非 ssl 的,但是一般在使用中,http2 是必须支持 https 的。

如果设置了 SSLSocketFactory , 那么先进行 SSL 的连接。

查看 connectTls

代码语言:javascript复制
Address address = route.address();
SSLSocketFactory sslSocketFactory = address.sslSocketFactory();
SSLSocket sslSocket = null;

// ssl socket
sslSocket = (SSLSocket) sslSocketFactory.createSocket(rawSocket, address.url().host(), address.url().port(), true /* autoClose */);
// configure the socket's clphers, TLS versions, adn extensions
ConnectionSpec connectionSpec = connectionSpecSelector.configureSecureSocket(sslSocket);

if (connectionSpec.supportsTlsExtensions()) {
    // 配置 TLS 扩展
    Platform.get().configureTlsExtensions(sslSocket, address.url().host(), address.protocols());
}

// ssl 握手
sslSocket.startHandshake();
// 校验证书
Handshake unverifiedHandshake = Handshake.get(sslSocketSession);
if (!address.hostnameVerifier().verify(address.url().host(), sslSocketSession)) {
    X509Certificate cert = (X509Certificate) unverifiedHandshake.peerCertificates().get(0);
    throw new SSLPeerUnverifiedException("Hostname "   address.url().host()   " not verified:"
              "n    certificate: "   CertificatePinner.pin(cert)
              "n    DN: "   cert.getSubjectDN().getName()
              "n    subjectAltNames: "   OkHostnameVerifier.allSubjectAltNames(cert));
}
address.certificatePinner().check(address.url().host(),unverifiedHandshake.peerCertificates());

// 校验成功,判断具体的协议
String maybeProtocol = connectionSpec.supportsTlsExtensions()
          ? Platform.get().getSelectedProtocol(sslSocket)
          : null;
protocol = maybeProtocol != null
          ? Protocol.get(maybeProtocol)
          : Protocol.HTTP_1_1;
success = true;

查看 Platform.get().getSelectedProtocol(sslSocket)

代码语言:javascript复制
byte[] alpnResult = (byte[]) getAlpnSelectedProtocol.invokeWithoutCheckedException(socket);
return alpnResult != null ? new String(alpnResult, Util.UTF_8) : null;

这里会通过反射调用 OpenSSLSocketImplgetAlpnSelectedProtocol 方法,最终通过 jni 层调用 NativeCrypto.cpp 去获取确定的应用层协议。可能获取到的值目前有

•http/1.0•http/1.1•spdy/3.1•h2•quic

HTTP2

如果这时候支持的是 HTTP2 协议,那么我们关注点就要放到 Http2Connection 这个类上来。查看它的 start 方法:

代码语言:javascript复制
void start(boolean sendConnectionPreface) throws IOException {
    if (sendConnectionPreface) {
      // 连接引导
      writer.connectionPreface();
      // 写 settings
      writer.settings(okHttpSettings);
      // 获取窗口大小
      int windowSize = okHttpSettings.getInitialWindowSize();
      if (windowSize != Settings.DEFAULT_INITIAL_WINDOW_SIZE) {
        writer.windowUpdate(0, windowSize - Settings.DEFAULT_INITIAL_WINDOW_SIZE);
      }
    }
    // 读取服务端的响应数据
    new Thread(readerRunnable).start(); // Not a daemon thread.
  }

首先,在 sendConnectionPreface 中,客户端会发送 "PRI * HTTP/2.0rnrnSMrnrn" 到服务端。发送完 Connection Preface 之后,会继续发送一个 setting 帧。

Http2Connection`` 中通过 readerRunnable 来执行网络流的读取,参考ReaderRunnableexecute` 方法:

代码语言:javascript复制
reader.readConnectionPreface(this);
while (reader.nextFrame(false, this)) {}

首先,会读取 connection preface 的内容,即服务端返回的 settings 帧。如果顺利,后面会在循环中不断的读取下一帧,查看 nextFrame

这里对 HTTP2 不同类型的帧进行了处理。我们挑一个 data 帧查看,会继续走到 data 方法:

代码语言:javascript复制
// 去掉了不关键代码
Http2Stream dataStream = getStream(streamId); // 获取抽象的流对象
dataStream.receiveData(source, length);  // 把 datastream 读取到 source
if (inFinished) {
    dataStream.receiveFin(); // 读取结束
}

继续查看 receiveData :

代码语言:javascript复制
 void receiveData(BufferedSource in, int length) {
  this.source.receive(in, length);
 }

这里调用的是一个类型为 FramingSource 的 Source 对象。最终会调用 long read = in.read(receiveBuffer, byteCount); 方法。会把网络的 source 内容写到 receiveBuffer 中。然后把 receiveBuffer 的内容写到 readBuffer 中。这里的读写全部都是使用的 OKIO 框架。

那么 FramingSource 里面的的 readBuffer 在什么时候用到呢?在 OKHttpCallServerInteceptor 里构造 ResonseBody 的时候,如果是 HTTP2 的请求,会从这个 buffer 里面读取数据。

从这里对 HTTP2 的帧处理,我们可以看到 HTTP2 的特性和 HTTP1.1 有很大的不一样,HTTP2 把数据分割成了很多的二进制帧。配合多路复用的特性,每个连接可以发送很多这样的内容较小的帧,整体上提升了 HTTP 的传输性能。每个 frame 的格式如下:

具体 HTTP2 二进制分帧的原理,我们以后再做单独探究。

HTTP2 连接复用

现在回头看看连接池内对 HTTP2 的连接复用:

代码语言:javascript复制
if (route == null) return false;
if (route.proxy().type() != Proxy.Type.DIRECT) return false;
if (this.route.proxy().type() != Proxy.Type.DIRECT) return false;
if (!this.route.socketAddress().equals(route.socketAddress())) return false;

if (route.address().hostnameVerifier() != OkHostnameVerifier.INSTANCE) return false;
if (!supportsUrl(address.url())) return false;

address.certificatePinner().check(address.url().host(), handshake().peerCertificates());

可以看到 HTTP2 需要满足这些条件可以进行连接复用:

•路由共享 ip 地址,这要求我们为两个 host 都有一个dns地址,代理除外。•此连接的证书必须覆盖在新的 host 之上•证书的 pinning 必须和 host 匹配

思考

通过源码分析,我们也可以得到如下结论:

•一个APP中应该尽可能使用一个 OKHttpClient ,因为连接池不是多个 client 共享•我们可以自定义 ProxySelector 来自定义我们在代理下的行为,例如:有代理也不走•我们可以自定义 DNS ,在里面做我们自己的 DNS 解析逻辑

总结

现在,我们了解了 OKHTTP 对 HTTP 请求进行的连接, UML 图可以清晰的展示每个类的关系:我们也可以对 隧道代理,SSL,HTTP2具体的帧格式等特性,进行进一步的网络知识的深入学习和分析。来寻找一些网络优化的突破点和思路。

0 人点赞