OKHttp源码分析(八)Http2.0版本处理

2,936 阅读13分钟

OkHttp也对Http2.0进行了支持,2.0版本对1.1性能有了很大的提升,前几章分析的基本流程没有变化,只是传输的过程有了变化。我们先介绍下Http2.0的特征,再在OKHttp源码的基础上看看是怎么实现的。

Http各版本区别

4A34A584-DD53-4BD5-8039-1CD5EFD9BC35.png

  1. Http从0.9版本开始,只支持Get请求,没有头部的设定
  2. 1.0版本确认了基本的框架,包括报文的格式,从状态行/Header/body等。缓存和长连接等规范,但是都是初级的设定
  3. 1.1版本默认了长连接的配置,提供了管道化,进一步提高了传输的性能。在缓存部分,增加了更多的灵活性,包括Cache-Control和ETag等属性,前面讲CacheInterceptor时,都分析了相应的缓存字段。
  4. 2.0版本在性能上更近了一步,主要升级在性能上,并且增加了服务器推送等功能。下面具体看下

Http 2.0特点

既然出台了更新的方案,肯定是之前的方案有问题。基本都是性能的问题

Http1.1存在的问题

  1. Header数据冗余,每次发送都要带着儿好多一致的数据,比如Cookie和agent等完全相同的字段,是非常浪费带宽的。
  2. 头部阻塞,对于多次的请求,只能提供一问一答的方式,也就是第一次请求完成,才能发送第二次请求。虽然提供了pipeline机制,但是请求和相应必须按照顺序进行传输的,浏览器很难实现,多数浏览器关闭了这个功能。
  3. 半双工,虽然TCP是全双工的,浪费了TCP的能力,pipeline也是单向串行。也是线头阻塞的根本原因。
  4. 客户端需要主动请求 为了解决上面的问题,Http2.0出台了一些方式优化

二进制分帧层

这个是一个基础的功能,在上一篇分析传输时,我们看到Http 1.1往流中写入的多是文本编码的Utf-8格式的,但是Http2.0默认把数据使用二进制进行编码。并抽象成帧这种数据结构。又分了很多不同类型的帧。帧就是在传输中的基本单位了。因为Http2不改变之前的语意,所以分帧层是作用在应用层的下一层的,对用户是感知不到的。

HTTP1.1产生线头阻塞(Head-of-Line Blocking,HOL阻塞)的根本原因在于其半双工通信模式和流水线技术的局限性。 首先,HTTP1.1虽然引入了持久连接和流水线技术,使得多个HTTP请求可以通过单个TCP连接发送,而无需等待相应的响应。然而,这种流水线操作仍然要求客户端和服务器按顺序处理请求和响应。具体来说,客户端只有在成功发送之前的请求时,才开始发送后续请求,而这些请求虽然可以在不等待响应的情况下发送,但服务器必须按照接收请求的顺序发送响应。客户端收到的响应也需要按顺序进行处理。 这种按顺序处理请求和响应的方式,就导致了线头阻塞问题的出现。如果其中一个请求或响应的处理时间比其他请求或响应长,或者由于网络延迟、重传等原因而延迟,那么后续的所有请求和响应都将被迫等待,直到这个被阻塞的请求或响应被处理完毕。这就形成了线头阻塞,即队列头部的请求或响应被阻塞时,后续的所有请求或响应都会被阻塞。 此外,HTTP1.1在资源块(如js、css等)之间不使用分隔符,也不会进一步区分单个资源与其他资源。这导致服务器在解析资源时,必须按照接收到的顺序进行,无法对资源进行分块解析。当服务器处理一个大资源块(如js)时,后续的小资源块(如css)必须等待大资源块处理完毕后才能被解析,这也加剧了线头阻塞问题。 为了解决这个问题,HTTP/2引入了帧、流的概念,并允许通过多路复用在同一连接上交织请求和响应消息。这样,即使其中一个请求或响应被阻塞,也不会影响其他请求或响应的发送和接收。然而,需要注意的是,虽然HTTP/2在一定程度上解决了HTTP层面的线头阻塞问题,但传输层(TCP)仍然存在线头阻塞问题。这是因为TCP协议在传输数据时,需要保证数据的顺序性和完整性,如果其中一个数据包丢失或延迟,那么后续的所有数据包都必须等待这个丢失或延迟的数据包被重传或到达后才能被处理。 综上所述,HTTP1.1产生线头阻塞的根本原因在于其半双工通信模式和流水线技术的局限性,以及资源块之间缺乏分隔符和区分机制。这些问题导致了请求和响应必须按顺序处理,从而引发了线头阻塞问题。

多路复用

上一篇分析传输时,在Http1.1中,一个连接只能建立一个流,但是在Http2.0中,一个连接上可以创建多个流,这多个流还可以同时的传递帧,并且因为是二进制分帧层,所以就解决了刚才提到的线头阻塞的问题,多个流和二进制是实现复用的两个基础。但是因为TCP的缓存有限,线头阻塞还是会发生的。Http1.1如果要实现同时发送,只能另外建立TCP连接,但是因为慢启动等特性,性能是非常差的。对于乱序发送的帧,可以使用ID和序号来重新进行排列,组成最终的数据。

压缩Header

Header没有进行压缩,并且每次都要发送很多一样很长的数据。Http2.0使用了HPACK压缩格式来压缩首部。分为是哪个步骤,先通过静态词典压缩、再通过动态词典压缩、最后Huffman编码对传输的首部字段进行编码。三个步骤大大的减少了需要传输的数据量了。这些在下面的OkHttp代码中都有涉及,可以说非常高效。

服务端推送

服务端可以主动向客户端推送数据。实现原理就是客户端发出页面请求时,服务器端能够分析这个页面所依赖的其他资源,主动推送到客户端的缓存,当客户端收到原始网页的请求时,它需要的资源已经位于缓存。并发送一个帧到客户端。相对于WebSocket,功能还是差了点。

OKHttp怎么实现Http 2.0特点的

OKHttp为了实现Http 2.0。顶层的类主要使用了Http2CodecHttp2Connection。分析也是通过他们作为切入点。Http2Codec我们比较应该表示熟悉,上一篇讲了Http1Codec两个类都是实现了HttpCodec接口,都有相同的功能,包括写入Header、body、进行请求等。Http2Codec使用了Http2.0的方式进行了工作。

如何判断使用Http2.0

在OKHttp中有两种Http2.0协议,分别是Http2.0明文,简称H2c,使用了明文协议,另一种是普通的Http2.0.简称H2,可以使用SSL协议进行加密。
这些在我们配置OKHttpClient时,可以通过protocols()进行设置,默认的协议是1.1和2.0。
在建立完成连接后,首先检查是否支持Https,如果支持那么就会进行TSL握手,如果协商了Http的版本,就会用配置的版本,如果没有配置,默认1.1。如果没有支持Https,那么如果在OkHttpClient配置了H2 c的协议,就使用2.0协议。没有就使用1.1。
startHttp2(pingIntervalMillis);开始对2.0的使用。

private void startHttp2(int pingIntervalMillis) throws IOException {
  socket.setSoTimeout(0); // HTTP/2 connection timeouts are set per-stream.
  http2Connection = new Http2Connection.Builder(true)
      .socket(socket, route.address().url().host(), source, sink)
      .listener(this)
      .pingIntervalMillis(pingIntervalMillis)
      .build();
  http2Connection.start();
}

这里取消了socket的超时时间,因为超时时间需要对每个流进行设置,生效的单位变了。并且创建了一个Http2Connection。奇怪的是调用了start方法,开启了一个线程。这里线程的作用是开启读取网络响应。从readerRunnable这个变量也可以看出。

void start(boolean sendConnectionPreface) throws IOException {
  。。。
  new Thread(readerRunnable).start();
}

Http2Codec的创建也是通过http2Connection的情况进行判断,如果是http2Connection不为空,那么就创建一个Http2.0下的流Http2Codec。

public HttpCodec newCodec(OkHttpClient client, Interceptor.Chain chain,
    StreamAllocation streamAllocation) throws SocketException {
  if (http2Connection != null) {
    return new Http2Codec(client, chain, streamAllocation, http2Connection);
  } else {
    //http1.1
  }
}

经过上面的两部,就完成了顶层类的创建。

如何实现多路复用

对于多路复用,有两个前提条件,分别是多个流和二进制分帧。在一个连接上创建更多的流。这个最大值是在RealConnection#allocationLimit进行设置的,默认为1,也就是在Http1.1的默认配置,在开启Http2后,又对去进行了赋值。

allocationLimit = http2Connection.maxConcurrentStreams();

多个流已经实现了,对于二进制分帧,我们在分析传输的时候就会看到了。
下面的分析流程,还是按照上一篇的结构来,也就是按照HttpCodec接口,分为写入Header、写入Body、请求、读取Header、读取Body

写入Header

写入Header,主要通过writeRequestHeaders方法,在Http1Codec中的实现,主要是通过写入UTF-8格式的状态行和Header部分。在Http2Codec的实现,会比较复杂。

@Override public void writeRequestHeaders(Request request) throws IOException {
  if (stream != null) return;
  boolean hasRequestBody = request.body() != null;
  List<Header> requestHeaders = http2HeadersList(request);
  stream = connection.newStream(requestHeaders, hasRequestBody);
  stream.readTimeout().timeout(chain.readTimeoutMillis(), TimeUnit.MILLISECONDS);
  stream.writeTimeout().timeout(chain.writeTimeoutMillis(), TimeUnit.MILLISECONDS);
}

首先通过http2HeadersList对Header各个字段进行了处理。然后调用了newStream新建里一个stream。Header的压缩和写入逻辑也在里面,之后对单独的Stream设置了读和写的超时时间。整体逻辑是这样,我们细看下每一块。

Stream是什么

Http2Stream是逻辑双向流。Http2.0的传输功能和1.x的类结构还有很大区别。Http2Codec还是传送的入口,但是不像Http1Codec直接封装了输入输出流,传输Header交给了Http2Connection,以后的传输和接收工作交给了Http2Stream
在上面写入Header数据,看到了创建了一个stream,也就是Http2Stream。以后的写入Body,读取请求都是调用她来实现的。
Http2.0的流就是Http2Stream,存储的一个连接上多个流的数据结构是Http2Connection#streams。是一个Map,在数据返回时,会通过Http2Stream的id进行匹配,拿到对应的Http2Stream进行写入。这样外部在通过Http2Codec读取数据时,就从Http2Stream读取即可。
Http1Codec的职责由Http2Connection来完成,作为实际封装底层输入输出流的控件。在输出数据时,输出到Http2Stream中,通过StreamId进行不同的写入。大体结构如此。

stateDiagram-v2
[*] --> 写入Header数据
写入Header数据 --> Http2Connection
Http2Connection --> Http2Stream
Http2Connection --> 读取写入响应Header
读取写入响应Header --> Http2Stream

Http2Codec --> 读取响应 
读取响应 --> Http2Stream

header的转换

List<Header> requestHeaders = http2HeadersList(request);

通过http2HeadersList对原始的Header数据进行了转换。

public static List<Header> http2HeadersList(Request request) {
  Headers headers = request.headers();
  List<Header> result = new ArrayList<>(headers.size() + 4);
  result.add(new Header(TARGET_METHOD, request.method()));
  result.add(new Header(TARGET_PATH, RequestLine.requestPath(request.url())));
  String host = request.header("Host");
  if (host != null) {
    result.add(new Header(TARGET_AUTHORITY, host)); // Optional.
  }
  result.add(new Header(TARGET_SCHEME, request.url().scheme()));

  for (int i = 0, size = headers.size(); i < size; i++) {
    ByteString name = ByteString.encodeUtf8(headers.name(i).toLowerCase(Locale.US));
    if (!HTTP_2_SKIPPED_REQUEST_HEADERS.contains(name.utf8())) {
      result.add(new Header(name, headers.value(i)));
    }
  }
  return result;
}

这里没有看到对状态行的处理,Http2.0把状态行也放到了header帧里。比如通过TARGET_METHOD,放置了请求的方法,TARGET_PATH放置了路径,这些都是在请求的状态行里的。并放置了TARGET_SCHEME,当前时http还是https。header的name和value同样经过了utf-8编码。

header的压缩写入

header的写入是通过writer.synStream进行的。

writer.synStream(outFinished, streamId, associatedStreamId, requestHeaders);

writer是Http2Writer类型,它是负责写入的,封住了底层Socket的输入流。在这里做分帧的操作。响应的数据主要通过Http2Reader类。封装了底层Socket的输出流。Http2Connection代替了Http2Codec的功能。

graph TD
Http2Connection --> 输入Http2Writer
Http2Connection --> 输出Http2Reader

synStream又调用了Http2Writer#headers()进行最终的写入。

void headers(boolean outFinished, int streamId, List<Header> headerBlock) throws IOException {
  if (closed) throw new IOException("closed");
  hpackWriter.writeHeaders(headerBlock);

  long byteCount = hpackBuffer.size();
  int length = (int) Math.min(maxFrameSize, byteCount);
  byte type = TYPE_HEADERS;
  byte flags = byteCount == length ? FLAG_END_HEADERS : 0;
  if (outFinished) flags |= FLAG_END_STREAM;
  frameHeader(streamId, length, type, flags);
  sink.write(hpackBuffer, length);

  if (byteCount > length) writeContinuationFrames(streamId, byteCount - length);
}

2.0对Header的压缩是通过hpack,那么就是通过hpackWriter.writeHeaders进行压缩,并写入hpackBuffer。并通过下面的sink.write(hpackBuffer, length)进行写入的。两部就完成了最终的写入。
header压缩的逻辑主要在hpackWriter.writeHeaders中。上面也说了,分为静态词典,动态词典, huffman编码等。

  1. 静态表

HTTP-2协议之头部压缩【原理笔记】 - 程序员大本营 Google Chrome, 今天 at 14.50.24.png 静态表就是匹配name和value,如果相同,就使用index进行传输,服务器和客户端都有一份相同的表,这样传递相应的id就可以完成数据的传输。

  1. 动态表
    动态表传输已经传输过的字段,比如cookie,这样在传输以后,就不需要重新传输了,只要传输动态表里的idnex即可。通过dynamicTable进行存储。
  2. huffman编码
    写入的时候通过Huffman.get().encode(data, huffmanBuffer);进行压缩

写入Body

@Override public Sink createRequestBody(Request request, long contentLength) {
  return stream.getSink();
}

写入body通过createRequestBody进行,逻辑和Http1Codec一致。通过返回的sink,调用 RequestBody#writeTo进行写入。

请求

@Override public void flushRequest() throws IOException {
  connection.flush();
}

直接调用了Http2Connection的flush。进而调用Http2Writer#flush。最终调用 sink.flush();完成写入缓存区提交。完成请求。

读取Header

不管是Header的读取还是body的读取。都是在Http2Connection中完成的。前面说到Http2ConnectionReaderRunnable是一个另一个线程运行的。他不断在读取输出流,拿到传输过来的帧。并交给Http2Stream
主要的获取逻辑都在ReaderRunnable中。直接看最主要的run方法。

@Override protected void execute() {
  ErrorCode connectionErrorCode = ErrorCode.INTERNAL_ERROR;
  ErrorCode streamErrorCode = ErrorCode.INTERNAL_ERROR;
  try {
    reader.readConnectionPreface(this);
    while (reader.nextFrame(false, this)) {
    }
  } 
  。。。
}

可以看出循环里面的reader.nextFrame,一致在取输出流的帧。

public boolean nextFrame(boolean requireSettings, Handler handler) throws IOException {
  try {
    source.require(9); // Frame header size
  } catch (IOException e) {
    return false; // This might be a normal socket close.
  }

  int length = readMedium(source);
  if (length < 0 || length > INITIAL_MAX_FRAME_SIZE) {
    throw ioException("FRAME_SIZE_ERROR: %s", length);
  }
  byte type = (byte) (source.readByte() & 0xff);
  if (requireSettings && type != TYPE_SETTINGS) {
    throw ioException("Expected a SETTINGS frame but was %s", type);
  }
  byte flags = (byte) (source.readByte() & 0xff);
  int streamId = (source.readInt() & 0x7fffffff); // Ignore reserved bit.
  if (logger.isLoggable(FINE)) logger.fine(frameLog(true, streamId, length, type, flags));

  switch (type) {
    case TYPE_DATA:
      readData(handler, length, flags, streamId);
      break;

    case TYPE_HEADERS:
      readHeaders(handler, length, flags, streamId);
      break;

    case TYPE_PRIORITY:
      readPriority(handler, length, flags, streamId);
      break;

    case TYPE_RST_STREAM:
      readRstStream(handler, length, flags, streamId);
      break;

    case TYPE_SETTINGS:
      readSettings(handler, length, flags, streamId);
      break;

    case TYPE_PUSH_PROMISE:
      readPushPromise(handler, length, flags, streamId);
      break;

    case TYPE_PING:
      readPing(handler, length, flags, streamId);
      break;

    case TYPE_GOAWAY:
      readGoAway(handler, length, flags, streamId);
      break;

    case TYPE_WINDOW_UPDATE:
      readWindowUpdate(handler, length, flags, streamId);
      break;

    default:
      // Implementations MUST discard frames that have unknown or unsupported types.
      source.skip(length);
  }
  return true;
}

首先调用了require方法,这是一个阻塞的方法,知道输出流中又了9个字节的数据,才会进行返回。拿到了9个字节的数据。就开始处理这些数据。
为什么是9个字节呢?Headers Frame: 帧头固定的9个字节。配置了这个帧的属性。
比如type和streamId。根据不同的帧的类型,调用不同的方法进行读取。比如Header,type就是TYPE_HEADERS。并调用readHeaders(handler, length, flags, streamId)进行读取。其他类型的逻辑也类似。

读取Body

读取body的逻辑和上面类似,只是类型不同。这里不细讲了。

代码里很多流和位运算还是很难懂。