QUIC协议和HTTP3.0技术研究

一、前置知识

1.1 TCP协议

1.1.1 概述

​ TCP (Transmission Control Protocol 传输控制协议) 是一种面向连接 (连接导向) 的、可靠的、 基于IP的传输层协议。TCP在IP报文的协议号是6。TCP将用户数据打包构成报文段,它发送数据时启动一个定时器,另一端收到数据进行确认。TCP提供一种面向连接的可靠的字节流服务,面向连接意味着两个使用TCP的应用(B/S)在彼此交换数据之前,必须先建立一个TCP连接,类似于打电话过程,先拨号振铃,等待对方说 "喂",然后应答。

​ TCP主要特点有:

  1. TCP是面向连接的传输层协议。即存在建立连接和释放TCP连接两个过程。TCP将连接作为最基本的抽象
  2. 每一条TCP连接只能有两个端点,即连接是点对点的。连接的端点叫套接字(socket)或插口,稍后详述;
  3. TCP提供可靠交付的服务,即传输数据无差错、不丢失、不重复并且按序到达;
  4. TCP提供全双工通信。连接双方(套接字)随时可以发送数据;TCP连接两端舍友发送缓存和接受缓存来临时存放双向通信的数据;
  5. TCP是面向字节流的。TCP中的流(stream)指的是流入进程和从进呈流出的字节序列。应用程序(应用层)和TCP(传输层)交互的数据在TCP看来是无结构的字节序列,TCP并不明白其含义,而会将其存放在发送缓存中,等不阻塞时发送合适长度的字节序列;
1.1.2 TCP首部

TCP 消息头结构如下,具体字段解释略:

1.1.3 TCP可靠性保障

TCP可靠性来自于:
(1)连接管理:使用TCP协议进行传输之前发送方和接收方之间必须通过三次握手建立一个较为可靠的连接,连接关闭时使用四次挥手安全关闭
(2)超时重传:当TCP发送一个段之后,启动一个定时器,等待目的点确认收到报文,如果不能及时收到一个确认,将重发这个报文。
(3)确认应答和序列号机制:TCP传输的过程中,每次接收方收到数据后,都会对传输方进行确认应答。也就是发送ACK报文。这个ACK报文当中带有对应的确认序列号,告诉发送方,接收到了哪些数据,下一次的数据从哪里发。
(4)校验和:TCP将保持它首部和数据的检验和,这是一个端对端的检验和,目的在于检测数据在传输过程中是否发生变化。有错误,就不确认,发送端就会重发
(5)重组排序:TCP是以 IP 报文来传送,IP 数据是无序的,TCP收到所有数据后进行排序,再交给应用层
(6)重复丢弃:IP 数据报会重复,所以TCP会去重
(7)流量控制:TCP能提供流量控制,TCP连接的每一个地方都有固定的缓冲空间。TCP的接收端只允许另一端发送缓存区能接纳的数据。
(8)拥塞控制:使用“慢启动”+“拥塞避免”算法对全局流量进行控制

1.1.4 TCP三次握手

1.1.5 TCP四次挥手

1.2 HTTP协议

​ HTTP协议是Hyper Text Transfer Protocol(超文本传输协议)的缩写,是用于从万维网(WWW:World Wide Web )服务器传输超文本到本地浏览器的传送协议。基于TCP的应用层协议,它不关心数据传输的细节,HTTP(超文本传输协议)是一个基于请求与响应模式的、无状态的、应用层的协议,只有遵循统一的 HTTP 请求格式,服务器才能正确解析不同客户端发的请求,同样地,服务器遵循统一的响应格式,客户端才得以正确解析不同网站发过来的响应。

1.2.1 HTTP/1.x

​ HTTP/0.9 是一个过时的协议,它只接受 GET 一种请求方法,没有在网络传输中指定版本号,且不支持请求头。由于该版本不支持 POST 方法,因此客户端无法向服务器传递太多信息。随后提出的 HTTP/1.0 是第一个指定版本号的 HTTP 协议版本,早期只是使用在一些较为简单的网页和网络请求上,而今主要是在代理服务器中使用。HTTP/1.1 在 1999 年开始广泛应用于现在的各大浏览器网络请求中,同时也是当前使用最为广泛的 HTTP 协议。HTTP/1.1与 HTTP/1.0 的主要区别为 :

  1. 缓 存 处 理。在 HTTP/1.0 中 主 要 使 用 header 里 的 If-Modified-Since, Expires 作为缓存判断的标准,HTTP/1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。

  2. 带宽优化及网络连接的使用。HTTP/1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象传过来了,并且不支持断点续传功能,HTTP/1.1则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。

  3. 错误通知的管理。在 HTTP/1.1 中新增了 24 个错误状态响应码,如 409(Conflict)表示请求的资源与资源的当前状态发生冲突 ;410(Gone)表示服务器上的某个资源被永久性的删除。

  4. Host 头处理。在 HTTP/1.0 中认为每台服务器都绑定一个唯一的 IP 地址,因此请求消息中的 URL 并没有传递主机名(hostname)。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个 IP 地址。HTTP/1.1 的请求消息和响应消息都应支持Host 头域,且请求消息中如果没有 Host 头域会报告一个错误(400 Bad Request)。

  5. 长连接。HTTP/1.1 支持长连接(Persistent Connection)和请求的流水线(Pipelining)处理,在一个 TCP 连接上可以传送多个 HTTP 请求和响应,减少了建立和关闭连接的消耗和延迟,在HTTP/1.1 中默认开启 Connection : keep-alive,一定程度上弥补了 HTTP/1.0 每次请求都要创建连接的缺点。

尽管 HTTP/1.1 相比于 HTTP/1.0 有了很大的优化,但仍存在不少问题
(1)需要很多 TCP 连接来实现并发请求与响应,且在传输数据时每次都需要重新建立连接,这增加了大量的延迟时间并可能引起网络拥塞和高数据包丢失,导致更差的网络性能。
(2)在传输数据时,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份,这在一定程度上无法保证数据的安全性。
(3)头部信息(header)里携带的内容过大,在一定程度上增加了传输的成本,并且每次请求 header 基本不怎么变化,尤其在移动端环境下容易增加用户流量。
(4)虽然 HTTP/1.1 支持了 keep-alive,来弥补多次创建连接产生的延迟,但是 keep-alive 使用多了同样会给服务端带来大量的性能压力。
(5)HTTP 请求严格由客户端发起,在网页加载很多嵌入对象时会严重影响性能,因为服务器只能在客户端发出请求后才能传输数据。

1.2.2 HTTP/2.0

​ 与 HTTP/1.1 相比,它在以下几方面作了改进:

  • 新的二进制格式(Binary Format),HTTP1.x的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。基于这种考虑HTTP2.0的协议解析决定采用二进制格式,实现方便且健壮。
  • 多路复用(MultiPlexing),即连接共享,即每一个request都是是用作连接共享机制的。一个request对应一个id,这样一个连接上可以有多个request,每个连接的request可以随机的混杂在一起,接收方可以根据request的 id将request再归属到各自不同的服务端请求里面。
  • header压缩,如上文中所言,对前面提到过HTTP1.x的header带有大量信息,而且每次都要重复发送,HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自cache一份header fields表,既避免了重复header的传输,又减小了需要传输的大小。
  • 服务端推送(server push),同SPDY一样,HTTP2.0也具有server push功能。

1.3 为什么提出QUIC协议

1)更快速

​ 首先体现在低延迟连接上,相比 HTTPS,QUIC 建立连接的过程中至少少一个 RTT;与 TCP 不同,UDP 不是面向连接的,因此 QUIC 连接只需首次建立一次 QUIC 握手,后续便可在 0RTT 完成,而所谓的0RTT就是,通信双方发起通信连接时,第一个数据包便可以携带有效的业务数据,这个使用传统的TCP是完全不可能的。其次是优化了多路复用,解决了 TCP 连接下请求丢包导致的队头阻塞的问题。

2)更灵活

​ 主要体现两个方面:一是拥塞控制,QUIC 将拥塞控制放在应用层,更新拥塞控制算法不需要停机升级,使得在某些场景下可更有效地改变拥塞策略,达到更优的效果;二是连接迁移,TCP 使用四元组 (源 IP,源端口,目的 IP,目的端口) 标识连接,网络切换时会导致重连,而 QUIC 使用自定义的 Connection ID 作为链接标识,只要客户端使用的 Connection ID 不变,即使网络切换也能保证链接不中断。

3)更安全

​ TCP 协议头部未经过加密和认证,但 QUIC 所有的头部信息均经过认证,并且对所有信息进行加密,可有效避免数据传输过程中被中间设备截取并篡改。

二、QUIC协议

2.1 HTTP/2 的局限性和HTTP3.0

​ 除了 QUIC 是基于 UDP 实现,http类协议都是基于 TCP。TCP 因其面向连接、可靠传输等特点而被广泛采用,但在如今带
宽越来越大的网络环境下,TCP 的局限性也制约了 HTTP/2 的性能,主要表现为以下两点。
(1)数据传输前 TCP 先要进行“三次握手”,建立连接后才开始传输应用数据,这无疑增加了网络延时 ;在采用 TLS 协议
时需要交换密钥,这又增加了一次往返时延(Round-Trip Time,RTT)。
(2)HOL(Head-of-line)blocking,前序包阻塞。TCP 保证有序传输,所以当一个数据包丢失时,其他所有的包都要等它重
传整理后才会交给应用层,对于多路复用共享一个 TCP 连接的SPDY 和 HTTP/2 来说,这无疑影响更大。

HTTP3.0又称为HTTP Over QUIC,其弃用TCP协议,改为使用基于UDP协议的QUIC协议来实现。

2.2 QUIC 协议模型

​ QUIC协议模型如下图所示.它向下使用了操作系统提供的UDP(User Datagram Protocol)套接字,向上为应用层协议(例如 HTTP/2)提供了可靠且安全的传输通道.虽然QUIC在实现上基于传输层协议UDP,但 是 它 在 协 议 设 计 上 并 没 有 依 赖 于UDP的特性,即没有使用UDP端口来标识一条传输层连接。QUIC使用UDP的目的仅仅是为了保持和现有网络的兼容性,因为目前互联网上的某些防火墙会屏蔽TCP和 UDP之外的传输层协议.因此,尽管QUIC工作于传输层协议UDP之上,研究人员仍然普遍将它归类为传输层协议

​ 为了提供对移动性的支持,QUIC放弃了TCP/IP网络中使用五元组(源IP,源端口,目的IP,目的端口,协议标识符)来唯一标识一条连接的方式,而是使用一个全局唯一的随机生成的ID(即连接ID)来标识一条连接.这样,当通信一方的物理网络发生变化时,例如从蜂窝网络切换到 WIFI网络,在原先网络中建立的 QUIC 连接就 可以无缝迁移到新的网络下,从而保证网络服务在用户切换网络的过程中不被打断

2.3 QUIC 协议特点

​ QUIC 最主要的目标是减小网络传输延迟,所以选择了 UDP作为传输层协议,它的主要优点有以下几条:

2.3.1 低延迟连接的建立

​ 众所周知,建立一个TCP连接需要进行三次握手,这意味着每次连接都会产生额外的RTT,从而给每个连接增加了显著的延迟。另外,如果还需要TLS协商来创建一个安全的、加密的https连接,那么就需要更多的RTT,无疑会产生更大的延迟(如下图所示)。

首次,QUIC协议可以在1个RTT中启动一个连接并且获取完成握手所需的必要信息。

QUIC 1 RTT

​ 如果连接的是一个新的服务器,这时候client是没有server的任何信息的,当然也不知道用那种密钥交换算法,没有公钥信息,就不可能实现0 RTT握手,所以,对于新的QUIC连接至少需要1 RTT才能完成握手。

​ 在QUIC中,服务器的配置是完全静态的,而且配置是有过期时间的,由于服务器配置是静态的,因而不是每个连接都需要重新进行签名操作,一个签名可以适用于多个连接。

​ 另外,QUIC采用了两级密钥机制:初始密钥和会话密钥。QUIC在握手过程中使用Diffie-Hellman 算法协商初始密钥。初始密钥协商完毕后,服务器会提供一个临时随机数,会马上再协商会话密钥,这样可以保证密钥的前向安全性,之后可以在通信的过程中就实现对密钥的更新。接收方意识到有新的密钥要更新时,会尝试用新旧两种密钥对数据进行解密,直到成功才会正式更新密钥,否则会一直保留旧密钥有效。

​ 具体握手过程如图所示:

QUIC 0 RTT

​ 客户端在缓存了ServerConfig的情况下,客户端根据缓存的ServerConifg获取到密钥交换算法及公钥,同时生成一个全新的密钥,直接向服务器发送full Client hello消息,开始正式握手,消息中包括客户端选择的公开数。服务器收到full Client hello,不同意回复REJ;同意连接,则根据客户端的公开数计算出初始密钥,回复SHLO消息。

​ 客户端和服务器根据临时公开数和初始密钥,各自基于SHA-256算法推导出会话密钥。双方更换会话密钥通信,初始密钥已无用,至此,QUIC握手过程结束。

2.3.2 改进的拥塞控制

​ QUIC协议当前默认使用TCP协议的Cubic拥塞控制算法。看似QUIC协议只是吧TCP的拥塞算法重新实现了一遍,其实不然。QUIC协议在TCP拥塞算法基础上做了些改进:

  1. 可插拔
  • 应用程序层面就能实现不同的拥塞控制算法,不需要操作系统或内核支持。
  • 单个应用程序的不同连接也能支持配置不同的拥塞控制。
  • 不需要停机和升级就能实现拥塞控制的变更。
  1. 单调递增的Packet Number
  • QUIC并没有使用TCP的基于字节序号及ACK来确认消息的有序到达,QUIC使用的是Packet Number,每个Packet Number严格递增,所以如果Packet N丢失了,重传Packet N的Packet Number已不是N,而是一个大于N的值。 这样就很容易解决TCP的重传歧义问题。
  1. 更多的ACK块
  • QUIC ACK帧支持256个ACK块,相比TCP的SACK在TCP选项中实现,有长度限制,最多只支持3个ACK块
  1. 精确计算RTT时间
  • QUIC ACK包同时携带了从收到包到回复ACK的延时,这样结合递增的包序号,能够精确的计算RTT。
2.3.3 无队头阻塞的多路复用

​ HTTP2的最大特性就是多路复用,而HTTP2最大的问题就是队头阻塞。

​ 首先了解下为什么会出现队头阻塞。比如HTTP2在一个TCP连接上同时发送3个stream,其中第2个stream丢了一个Packet,TCP为了保证数据可靠性,需要发送端重传丢失的数据包,虽然这时候第3个数据包已经到达接收端,但被阻塞了。这就是所谓的队头阻塞。

​ 而QUIC多路复用可以避免这个问题,因为QUIC的丢包、流控都是基于stream的,所有stream是相互独立的,一条stream上的丢包,不会影响其他stream的数据传输。

2.3.4 前向纠错

​ QUIC使用了FEC(前向纠错码)来恢复数据,FEC采用简单异或的方式,每发送一组数据,包括若干个数据包后,并对这些数据包依次做异或运算,最后的结果作为一个FEC包再发送出去。接收方收到一组数据后,根据数据包和FEC包即可以进行校验和纠错。比如:10个包,编码后会增加2个包,接收端丢失第2和第3个包,仅靠剩下的10个包就可以解出丢失的包,不必重新发送,但这样也是有代价的,每个UDP数据包会包含比实际需要更多的有效载荷,增加了冗余和CPU编解码的消耗。

2.3.5 连接迁移

​ TCP的连接是基于4元组的,而QUIC使用64为的Connection ID进行唯一识别客户端和服务器的逻辑连接,这就意味着如果一个客户端改变IP地址或端口号,TCP连接不再有效,而QUIC层的逻辑连接维持不变,仍然采用老的Connection ID。

2.4 具体流程

2.4.1 首次连接

2.4.2 非首次连接

​ 前面提到客户端和服务端首次连接时服务端传递了config包,里面包含了服务端公钥和两个随机数,客户端会将config存储下来,后续再连接时可以直接使用,从而跳过这个1RTT,实现0RTT的业务数据交互。

PS:客户端保存config是有时间期限的,在config失效之后仍然需要进行首次连接时的密钥交换。

2.5 源码分析

2.5.1 下载源码

进入https://quiche.googlesource.com/quiche/切换到master分支代码

进入quic文件夹,即为 quic 协议的 google 实现

下载 quic 文件夹中的内容,其中重要代码在 core 目录下

2.5.2 源码分析

首先找到协议运行的入口,即toy_client和toy_server的源码

2.5.2.1 客户端quic_toy_client

路径:\quiche-refs_heads_master-quic\tools\quic_toy_client.cc

  • 解析命令行参数并进行初始化
  std::string host = GetQuicFlag(FLAGS_host);
  if (host.empty()) {
    host = url.host();
  }
  int port = GetQuicFlag(FLAGS_port);
  if (port == 0) {
    port = url.port();
  }

  quic::ParsedQuicVersionVector versions = quic::CurrentSupportedVersions();

  if (GetQuicFlag(FLAGS_quic_ietf_draft)) {
    quic::QuicVersionInitializeSupportForIetfDraft();
    versions = {};
    for (const ParsedQuicVersion& version : AllSupportedVersions()) {
      if (version.HasIetfQuicFrames() &&
          version.handshake_protocol == quic::PROTOCOL_TLS1_3) {
        versions.push_back(version);
      }
    }
  }

  std::string quic_version_string = GetQuicFlag(FLAGS_quic_version);

其中GetQuicFlag(FLAGS_host)为获取主机地址,GetQuicFlag(FLAGS_port)为获取端口号,后面参数解析类似,都是通过GetQuicFlag函数进行读取version.handshake_protocol == quic::PROTOCOL_TLS1_3为设置握手协议为TLS1.3

  • 配置客户端config文件
  QuicConfig config;
  std::string connection_options_string = GetQuicFlag(FLAGS_connection_options);
  if (!connection_options_string.empty()) {
    config.SetConnectionOptionsToSend(
        ParseQuicTagVector(connection_options_string));
  }

config对象是一个结构体,其中存储了从命令行参数中获取的连接配置

  • QuicConfig对象初始化需要的内容
QuicConfig::QuicConfig()
    : negotiated_(false),
      max_time_before_crypto_handshake_(QuicTime::Delta::Zero()),
      max_idle_time_before_crypto_handshake_(QuicTime::Delta::Zero()),
      max_undecryptable_packets_(0),
      connection_options_(kCOPT, PRESENCE_OPTIONAL),
      client_connection_options_(kCLOP, PRESENCE_OPTIONAL),
      max_idle_timeout_to_send_(QuicTime::Delta::Infinite()),
      max_bidirectional_streams_(kMIBS, PRESENCE_REQUIRED),
      max_unidirectional_streams_(kMIUS, PRESENCE_OPTIONAL),
     ......
      max_datagram_frame_size_(0, PRESENCE_OPTIONAL),
      active_connection_id_limit_(0, PRESENCE_OPTIONAL) {
  SetDefaults();
}
  • 创建client对象
  std::unique_ptr<QuicSpdyClientBase> client = client_factory_->CreateClient(
      url.host(), host, address_family_for_lookup, port, versions, config,
      std::move(proof_verifier));

其中使用client工厂方法,并调用CreateClient函数创建了一个单例对象,需要一些配置参数

  • 客户端初始化
if (!network_helper_->CreateUDPSocketAndBind(server_address_,
                                               bind_to_address_, local_port_)) {
    return false;
  }

前面进行流控制窗口参数配置,后面调用CreateUDPSocketAndBind函数创建udpsocket并进行端口绑定

  • connect过程
bool QuicClientBase::Connect() {
  // Attempt multiple connects until the maximum number of client hellos have
  // been sent.
  int num_attempts = 0;
  while (!connected() &&
         num_attempts <= QuicCryptoClientStream::kMaxClientHellos) {
    StartConnect();
    while (EncryptionBeingEstablished()) {
      WaitForEvents();
    }
    ParsedQuicVersion version = UnsupportedQuicVersion();
    if (session() != nullptr && !CanReconnectWithDifferentVersion(&version)) {
      // We've successfully created a session but we're not connected, and we
      // cannot reconnect with a different version.  Give up trying.
      break;
    }
    num_attempts++;
  }
  if (session() == nullptr) {
    QUIC_BUG << "Missing session after Connect";
    return false;
  }
  return session()->connection()->connected();
}

这里可以尝试多个连接,直到客户端hello的最大数量被发送。

进入StartConnect函数查看细节

QuicPacketWriter* writer = network_helper_->CreateQuicPacketWriter();
......
session_ = CreateQuicClientSession(
      client_supported_versions,
      new QuicConnection(GetNextConnectionId(), QuicSocketAddress(),
                         server_address(), helper(), alarm_factory(), writer,
                         /* owns_writer= */ false, Perspective::IS_CLIENT,
                         client_supported_versions));
set_writer(writer);
InitializeSession();

首先创建一个QuicPacketWriter用于写数据到quic数据包中,接着创建session,接下来调用set_writer方法获得此writer所有权,然后初始化session

  • 发送request
client->SendRequestAndWaitForResponse(header_block, body, /*fin=*/true);

首先构造包头部到header_block中,构造内容到body中,接着使用SendRequestAndWaitForResponse函数发送数据包

进入SendRequestAndWaitForResponse函数

void QuicSpdyClientBase::SendRequestAndWaitForResponse(
    const Http2HeaderBlock& headers,
    absl::string_view body,
    bool fin) {
  SendRequest(headers, body, fin);
  while (WaitForEvents()) {
  }
}

发现封装了一层SendRequest函数,进入SendRequest函数,发现内部调用的是SendRequestInternal

SendRequestInternal(std::move(sanitized_headers), body, fin);

进入SendRequestInternal函数,发现调用的是QuicSpdyClientStream类中的SendRequest方法

QuicSpdyClientStream* stream = CreateClientStream();
stream->SendRequest(std::move(sanitized_headers), body, fin);

进入QuicSpdyClientStream类中的SendRequest方法,分别使用WriteHeaders和WriteOrBufferBody写入head数据和body数据

size_t QuicSpdyClientStream::SendRequest(SpdyHeaderBlock headers,
                                         absl::string_view body,
                                         bool fin) {
  QuicConnection::ScopedPacketFlusher flusher(session_->connection());
  bool send_fin_with_headers = fin && body.empty();
  size_t bytes_sent = body.size();
  header_bytes_written_ =
      WriteHeaders(std::move(headers), send_fin_with_headers, nullptr);
  bytes_sent += header_bytes_written_;

  if (!body.empty()) {
    WriteOrBufferBody(body, fin);
  }

  return bytes_sent;
}

WriteHeaders和WriteOrBufferBody函数的内部调用了QuicStream中的WriteOrBufferData方法

WriteOrBufferData(data, fin, nullptr);

而WriteOrBufferData方法又是对WriteOrBufferDataInner函数的封装

最后进入WriteOrBufferDataInner函数

if (data.length() > 0) {
    struct iovec iov(QuicUtils::MakeIovec(data));
    QuicStreamOffset offset = send_buffer_.stream_offset();
    if (kMaxStreamLength - offset < data.length()) {
      QUIC_BUG << "Write too many data via stream " << id_;
      OnUnrecoverableError(
          QUIC_STREAM_LENGTH_OVERFLOW,
          absl::StrCat("Write too many data via stream ", id_));
      return;
    }
    send_buffer_.SaveStreamData(&iov, 1, 0, data.length());
    OnDataBuffered(offset, data.length(), ack_listener);
  }

可以看到最后调用文件描述符send_buffer_的SaveStreamData方法将输出保存到send buffer文件描述符

send_requst过程分析完毕

2.5.2.2 服务器端quic_toy_server

这里只分析与客户端不相同的部分

  • 对象创建

服务器对象的创建使用到的是服务器对象工厂,此处也使用到工厂模式

auto server = server_factory_->CreateServer(
      backend.get(), std::move(proof_source), supported_versions);
  • 创建UDPSocket并进行监听
server->CreateUDPSocketAndListen(quic::QuicSocketAddress(
          quic::QuicIpAddress::Any6(), GetQuicFlag(FLAGS_port)))

注意此处地址使用的是Ipv6的地址

QuicUdpSocketApi socket_api;
  fd_ = socket_api.Create(address.host().AddressFamilyToInt(),
                          /*receive_buffer_size =*/kDefaultSocketReceiveBuffer,
                          /*send_buffer_size =*/kDefaultSocketReceiveBuffer);

进入函数CreateUDPSocketAndListen其中调用socket_api对象的Create方法

std::unique_ptr<AsynchronousKeyExchange> Create(
      std::string /*server_config_id*/,
      bool /* is_fallback */,
      QuicTag type,
      absl::string_view private_key) override {
    if (private_key.empty()) {
      QUIC_LOG(WARNING) << "Server config contains key exchange method without "
                           "corresponding private key of type "
                        << QuicTagToString(type);
      return nullptr;
    }

    std::unique_ptr<SynchronousKeyExchange> ka =
        CreateLocalSynchronousKeyExchange(type, private_key);

它的作用是创建一个非阻塞的udp套接字,设置接收/发送缓冲区,并在读取时接受从自己ip地址进行。

std::unique_ptr<SynchronousKeyExchange> CreateLocalSynchronousKeyExchange(
    QuicTag type,
    absl::string_view private_key) {
  switch (type) {
    case kC255:
      return Curve25519KeyExchange::New(private_key);
    case kP256:
      return P256KeyExchange::New(private_key);
    default:
      QUIC_BUG << "Unknown key exchange method: " << QuicTagToString(type);
      return nullptr;
  }
}

进入CreateLocalSynchronousKeyExchange函数,其功能为根据服务器和客户端所选的密钥交换算法返回相应密码学算法类的对象,返回这个对象并将其作为一个unique_ptr即单例对象

  • 注册文件描述符并初始化分发器
epoll_server_.RegisterFD(fd_, this, kEpollFlags);
dispatcher_.reset(CreateQuicDispatcher());
dispatcher_->InitializeWithWriter(CreateWriter(fd_));

epoll_server_是组装传入的数据包并将它们交给调度程序dispatcher

dispatcher接收来自帧和多路客户端的数据到会话,使用reset刷新QuicDispatcher对象

使用InitializeWithWriter方法创建一个属于dispatcher的QuicPacketWriter对象用于写数据到quic数据包,将此对象绑定到之前的文件描述符上

至此,服务器端分析完毕

三、基于QUIC协议的通信实例

3.1 安装依赖环境

depot_tools是chromium编译必须依赖的编译环境

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git #使用git下载depot_tools
export PATH=$PATH:/quic/depot_tools #将depot_tools路径添加到环境变量

3.2 拉取chromium源码

mkdir ~/chromium && cd ~/chromium #创建存放源码目录
fetch --nohooks --no-history chromium #检出源码

如果仅仅只是想编译最新版本源码的话,可以添加--no-history能减少一些下载量,但是缺点就是无法切换到旧版本源码。

3.3 安装其他环境

cd ~/chromium/src
./build/install-build-deps.sh
gclient runhooks

3.4 编译quic-sever和quic-client工程

编译系统根据args.gn的配置自动生成ninja文件

gn args out/Default 

编译quic_server和quic_client模块

ninja -C out/Default quic_server quic_client 

3.5 添加测试数据

mkdir /tmp/quic-data
cd /tmp/quic-data
wget -p --save-headers https://www.example.org

修改index.html如下图所示:

添加:X-Original-Url: https://www.example.org/

3.6 生成并部署证书

生成证书

cd src/net/tools/quic/certs/
./generate-certs.sh

将证书添加至系统

apt install libnss3-tools
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n quic -i /quic/chromium/src/net/tools/quic/certs/out/2048-sha256-root.pem

查看证书是否添加成功

certutil -d sql:$HOME/.pki/nssdb -L

3.7 运行QUIC服务器和客户端

运行quic_server在10086端口进行监听

./out/Default/quic_server \
  --quic_response_cache_dir=/tmp/quic-data/www.example.org \
  --certificate_file=net/tools/quic/certs/out/leaf_cert.pem \
  --key_file=net/tools/quic/certs/out/leaf_cert.pkcs8 \
  --port=10086

运行quic_client连接10086端口

./out/Default/quic_client --host=127.0.0.1 --port=10086 https://www.example.org/ --disable_certificate_verification --allow_unknown_root_cert

接收到index.html header部分

接收到index.html body部分

wireshark中抓到了quic协议的udp加密数据包

服务器客户端通信成功

四、总结

​ QUIC 作为一个新的传输层协议,它在设计上针对 TCP的不足进行了很多优化。它提供的多路传输、快速握手等新特性使得它和 TCP相比在理论上可以获得更低的数据传输延时。现有测量工作表明,QUIC在大部分情况下的确能比 TCP 达到更低的传输延时,但是仍然有部分情况下 QUIC 的表现不如 TCP。这些 QUIC 性能表现较差的场景往往是拥塞算法的选择、服务器部署等外部因素造成的,而非QUIC本身的设计缺陷。因此,QUIC 的软件实现仍然有很大的进步空间。在安全性方面,基于 TLSv1.3的 QUIC 一方面牺牲了一定的计算资源为用户提供了比 TLSv1.2 更高的安全性,另一方面以牺牲前向安全性为代价换来了更低的握手延时。但总体来说,TLSv1.3 还是很好地保障了数据传输的安全性。此外,许多在 TCP上未被解决的问题在 QUIC上也依然存在,仍然有很多后续工作亟待解决。

​ 本文从quic协议的工作流程入手简单分析了协议的工作原理,并基于chromium的源代码,以客户端和服务器之间的通信为例分析了协议的交互过程。

参考资料

[1] 维基百科

https://zh.wikipedia.org/wiki/快速UDP网络连接

[2] Checking out and building Chromium on Linux

https://chromium.googlesource.com/chromium/src/+/master/docs/linux/build_instructions.md

[3] TCP协议概述一

https://blog.csdn.net/beirdu/article/details/79672613

[4] HTTP协议简介
https://www.cnblogs.com/universal/p/10415985.html

posted @ 2021-01-27 11:38  流沙蛋黄  阅读(1472)  评论(0编辑  收藏  举报