QUIC协议和HTTP3.0研究
1. 什么是QUIC
QUIC(Quick UDP Internet Connections),即快速UDP网络连接,是被设计用在传输层的网络协议,最初由Google的Jim Roskind
提出,最初实现和部署在2012年,截止目前仍然是一个因特网草案,但已经被广泛应用于Google浏览器
和Google服务器
之间。目前Chorme
、Microsoft Edge
、Firefox
、Safari
均已经支持QUIC,尽管不常用。
QUIC增加了面向连接的TCP网络应用程序的性能,它通过使用UDP在两个端点之间建立一系列多路复用(multiplexing)
的连接实现这个目的,它同时被用来代替(obsolesce)TCP在网络层的作用,因此也被戏称为TCP/2
QUIC与HTTP/2
的多路复用连接紧密结合,允许多个数据流独立的到达终端,因此一个数据包与其他的数据流传输的数据包丢失
无关。与之相对的是,TCP如果有任何数据包的丢失或延迟,就会发生队头阻塞
QUIC的另一个目标是减少连接和传输时候的延迟
,以及评估每一个方向的带宽
来避免阻塞
。它还将拥塞控制算法
移动到两个端点的用户空间,而不是内核空间,根据QUIC的实现,这将会提升算法的性能。此外,当遇到预期的错误的时候,QUIC协议可以使用前向纠错(forward error correction)FEC
来提升性能。2018年10月,IETF的HTTP和QUIC工作组共同决定将QUIC上的HTTP映射称为HTTP/3
,以使其在全球范围内标准化。
为什么需要QUIC
传统的TCP
网络通信协议旨在提供一个接口,然后再两个端口之间发送数据流。TCP的传输需要保证数据报按顺序
来接收,如果发现接收顺序错误,就需要使用自动重传请求
来通知发送方重新发送数据包,同时建立连接的三次握手
在复杂的网络环境和地理限制也是一个重要的考虑内容。
此外,由于TCP设计像一个"数据管道"
,如果单个数据包有问题,后续的所有数据报的发送将会被阻塞。现代社会的应用场景对更低延迟、良好的传输性能的要求越来越高,于是提出一个新的解决方案就十分有必要了。
QUIC做了什么
QUIC的目标几乎等同于TCP连接,但是延迟却会更少。它通过两个更改来实现
- 减少连接期间的开销
- 提高网络交换事件期间的性能。例如从wifi切换到移动网络能更快的切换
QUIC大致可以通过如下公式概括:
TCP + TLS + HTTP2 = UDP + QUIC + HTTP2’s API
从公式可看出:QUIC协议虽然是基于UDP,但它不但具有TCP的可靠性、拥塞控制、流量控制等,且在TCP协议的基础上做了一些改进,比如避免了队首阻塞;另外,QUIC协议具有TLS的安全传输特性,实现了TLS的保密功能,同时又使用更少的RTT建立安全的会话。
2. 深入QUIC
QUIC解决了一些现代网站应用的传输层和应用层问题,并且只需要一点或根本不需要改变客户端应用。QUIC非常类似于TCP+TLS+HTTP2
,但是是使用UDP实现的。使用QUIC作为一个独立的协议可以实现现有协议无法实现的创新,因为现有协议往往受客户端和中间设备的妨碍。
现在存在的硬件以及软件不足:
- 路由封杀UDP 443端口( 这正是QUIC 部署的端口)
- UDP包过多,由于QS限定,会被服务商误认为是攻击,UDP包被丢弃;
- 无论是路由器还是防火墙目前对QUIC都还没有做好准备
相比于TCP+TLS+HTTP2,QUIC主要在五个方面更具有优势(特性):
- 建立连接延迟
- 改善拥塞控制
- 没有队头阻塞的多路复用
- 前向纠错
- 连接迁移
下面将一一介绍
2.1 建立连接(Connection Establishment)
建立连接的低延迟可以说是QUIC的核心特性。
发送数据之前,QUIC只需要0RTT
就能够建立连接,而传统的TCP+TLS则需要1-3RTT
才能够建立连接。具体的QUIC建立连接的方法如下:
QUIC客户端
第一次连接到服务器时,客户端必须执行1次往返握手
,以获取完成握手所需的信息。客户端发送早期(empty)客户端Hello问候(CHLO)
,服务器发送拒绝(rejection)(REJ)
,其中包含客户端前进所需的信息,包括源地址令牌和服务器的证书。客户端下次发送CHLO时,可以使用以前连接中的缓存凭据来立即将加密的请求发送到服务器。
我们再深入的了解一下QUIC的加密协议。
根据因特网草案所述,QUIC现在使用TLS1.3
版本来保证传输的安全可靠性。
阅读TLS1.3文档,我们可以发现TLS的功能其中一项是支持了零往返时间(0-RTT)
模式,节省了往返时间。
我们对比一下TLS1.2和TLS1.3协议:
- TLS1.2协议
- TLS1.3协议
从上面可以看出,为什么叫0RTT呢?
-
当客户端首次发起QUIC连接时,客户端想服务器发送一个
client hello
消息,服务器回复一个server reject
消息。该消息中有包括server config
,类似于TLS1.3中的key_share
交换。这需要产生1-RTT. 事实上,QUIC加密协议的作者也明确指出当前的QUIC加密协议是「注定要死掉的」(destined to die
), 未来将会被TLS1.3代替。只是在QUIC提出来的时候,TLS1.3还没出生?,这只是一个临时的加密方案。 -
当客户端获取到
server config
以后,就可以直接计算出密钥,发送应用数据了,可以认为是0-RTT。 -
因此,QUIC握手除去首次连接需要产生1-RTT,理论上,后续握手都是0-RTT的。
-
假设
1-RTT=100ms
, QUIC建立安全连接连接的握手开销为0ms, 功能上等价于TCP+TLS
, 但是握手开销比建立普通的TCP连接延迟都低:(正常体为首次建立连接的延迟,粗体部分为后续握手的延迟)
总结一下:首次建立连接需要一个RTT,但是后续连接只需要可以直接发送数据,故称为0RTT
前面图片是1RTT,后面是0RTT
2.2 拥塞控制(Congestion Control)
让我们回想一下TCP的拥塞控制:慢启动,拥塞避免,快重传,快恢复
QUIC的拥塞控制基于了TCP NewReno
。NewReno是基于拥塞窗口的拥塞控制。根据QUIC草案对于拥塞部分的描述
QUIC包括了一些具体的拥塞控制算法:
- 显示拥塞控制:如果路径支持ECN,QUIC会将
Congestion Experienced codepoint
(CEC)标记视为拥塞信号 - 慢启动: 拥塞窗口一直增大直到到达阈值
- 拥塞避免:如果有一个数据丢失,拥塞窗口减半然后重新设置阈值
- 恢复期Recovery Period(暂译):区别于TCP的快恢复,QUIC的恢复期是检测到丢失的一段时间内拥塞窗口变为1
- 忽略不可解密的数据包丢失:从上面的建立连接我们可以知道,TLS发送会有一个秘匙,如果某些数据包发送过快的话,而秘匙还没到,就会造成无法解析这个包的数据。
- 探测超时:发送一个探测包,如果没有收到确认,可能拥塞
- 持续性拥塞:如果收到一个ACK帧,与前一个已经收到的ACK帧相差较大,可能拥塞
从拥塞算法来看,QUIC相比于TCP没有太大不不同,那么QUIC在什么地方和TCP不同呢?
2.2.1 可拔插的拥塞控制
什么是可拔插?就是可以灵活的使用拥塞算法,一次选择一个或几个拥塞算法同时工作
- 在应用层实现拥塞算法,而以前实现对应的拥塞算法,需要部署到操作系统内核中。现在可以更快的迭代升级
- 不同的平台具有不同的底层和网络环境,现在我们能够灵活的选择拥塞控制,比如选择A选择Cubic,B则选择显示拥塞控制
- 应用程序不需要停机和升级,我们在服务端进行的修改,现在只需要简单的reload一下就能实现不同拥塞控制切换
2.2.2 单调递增的包编号
回想TCP,TCP使发送方的发送顺序与接收方的发送顺序相抵触,从而导致重传带有相同序号的相同数据,从而导致“重传歧义”
QUIC并没有使用TCP的基于字节序号及ACK来确认消息的有序到达,QUIC使用的是Packet Number,每个Packet Number严格递增,所以如果Packet N丢失了,重传Packet N的Packet Number已不是N,而是一个大于N的值。 这样就很容易解决TCP的重传歧义问题。
2.2.3 没有Reneging
什么叫 Reneging 呢?就是接收方丢弃已经接收并且上报给 SACK 选项的内容
QUIC ACK包含类似于TCP SACK的信息,但是QUIC不允许重新发送任何确认的数据包,从而极大地简化了双方的实现并减轻了发送方的内存压力。
2.2.4 更多的ACK帧
QUIC支持许多ACK范围,与TCP的3 SACK范围相反。
由于 TCP 头部最大只有 60 个字节,标准头部占用了 20 字节,所以 Tcp Option 最大长度只有 40 字节,再加上 Tcp Timestamp option 占用了 10 个字节 ,所以留给 Sack 选项的只有 30 个字节。
每一个 Sack Block 的长度是 8 个,加上 Sack Option 头部 2 个字节,也就意味着 Tcp Sack Option 最大只能提供 3 个 Block。
但是 Quic Ack Frame 可以同时提供 256 个 Ack Block,在丢包率比较高的网络下,更多的 Sack Block 可以提升网络的恢复速度,减少重传量。
2.2.5 延迟确认的显式更正
QUIC端点会测量接收到数据包与发送相应确认之间的延迟,使对等方可以保持更准确的往返时间估计
2.3 流(Stream)的多路复用(Multiplexing)
HTTP2的最大特性就是多路复用,而HTTP2最大的问题就是队头阻塞。
首先了解下为什么会出现队头阻塞。比如HTTP2在一个TCP连接上同时发送3个stream,其中第2个stream丢了一个Packet,TCP为了保证数据可靠性,需要发送端重传丢失的数据包,虽然这时候第3个数据包已经到达接收端,但被阻塞了。这就是所谓的队头阻塞。
而QUIC多路复用可以避免这个问题,因为QUIC的丢包、流控都是基于stream的,所有stream是相互独立的,一条stream上的丢包,不会影响其他stream的数据传输。
下面简要的介绍一下流:
QUIC中的流向应用程序提供了轻量级的,有序的字节流抽象。QUIC流的另一种观点是作为一种弹性的“消息”抽象。
流可以由任一端点创建流,可以同时发送与其他流交错的数据,并且可以将其取消。
2.3.1 发送流
发送部分的状态端点启动的流发送部分(type:客户端为0和2 ,服务器为1和3)由应用程序打开。在 “就绪”状态代表一个新创建的数据流,它能够从应用程序接受数据。流数据可能在此状态下被缓冲以准备发送。
2.3.2 接受流
接收流的部分的状态由对等方(type:客户端的类型1和3 ,或0和2)发起的流的接收部分(对于服务器而言),则在为该流接收到第一个STREAM,STREAM_DATA_BLOCKED或RESET_STREAM时创建。对于由对等方发起的双向流,接收到MAX_STREAM_DATA或STOP_SENDING帧作为流还创建接收部分。
你可以把流比作为发送和接受数据的数据结构
2.3.3 Connections连接
什么是连接呢?
Connection 可以类比一条 TCP 连接。多路复用意味着在一条 Connetion 上会同时存在多条 Stream。既需要对单个 Stream 进行控制,又需要针对所有 Stream 进行总体控制。
2.4 前向纠错(Forward Error Correction)
为了从丢失的数据包中恢复而无需等待重新传输,QUIC可以用FEC数据包来补充一组数据包。与RAID-4相似,FEC数据包包含FEC组中数据包的奇偶校验。如果该组中的一个数据包丢失,则可以从FEC数据包和该组中的其余数据包中恢复该数据包的内容。发送者可以决定是否发送FEC分组以优化特定场景(例如,请求的开始和结束).
在这里需要注意的是:早期QUIC中使用的FEC算法是基于XOR的简单实现,不过IETF的QUIC协议标准中已经没有FEC的踪影,猜测是FEC在QUIC协议的应用场景中难以被高效的使用。
2.5 连接迁移(Connection Migration)
QUIC一个令人激动的特性就是连接迁移了,想象一下,当你从wifi切换到数据网络的时候,客户端IP会发生变化,这时候需要重新建立TCP连接
那 QUIC 是如何做到连接迁移呢?很简单,任何一条 QUIC 连接不再以 IP 及端口四元组标识,而是以一个 64 位的随机数作为 ID 来标识,这样就算 IP 或者端口发生变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。
2.5.1 启动连接迁移
端点可以通过发送包含来自该地址的非探测帧的数据包,将连接迁移到新的本地地址。
2.5.2 响应连接迁移
从包含非探测帧的新对等方地址接收到数据包表明对等方已迁移到该地址。
2.5.3 损失检测和拥塞控制
当响应后,中间可能会有数据损失和拥塞控制问题:新路径上的可用容量可能与旧路径上的容量不同。在旧路径上发送的数据包不应有助于新路径的拥塞控制或RTT估计。端点确认对等方对其新地址的所有权后,应立即为新路径重置拥塞控制器和往返时间估计器。
2.6流量控制
有必要限制接收方可以缓冲的数据量,以防止快速发送方压倒慢速接收方,或者防止恶意发送
QUCI主要采取两种流量控制:
1.流控制:
通过限制可以在任何流上发送的数据量来防止单个流占用整个连接的接收缓冲区。
2.连接控制:
通过限制所有流上以STREAM帧发送的流数据的总字节数,来防止发送方超出连接的接收方缓冲区容量。
QUIC 实现流量控制的原理比较简单:
通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。
针对Stream:
可用窗口=最大窗口数-接收到的最大偏移数
针对Connection:
可用窗口=Stream1可用窗口+Stream2可用窗口+...+StreamN可用窗口
至此,我们关于QUIC的主要特性就讲完了
3. 一个简单的QUIC通信实现
分析的源码基于quic-go
:quic-go
前置条件:Go1.14版本以上
3.1 源码下载编译运行
使用git命令将源代码clone到本地
git clone https://github.com/lucas-clemente/quic-go.git
接着,根据官方提示,运行
go test ./...
这里我使用了go test -v ./...
来获得更详细的信息
部分测试截图
测试成功,源代码没有问题,我们开始编写服务端和客户端的通信代码
先简单的贴出服务端和客户端的核心代码:
服务端
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
客户端
session, err := quic.DialAddr(addr, tlsConf, nil)
3.2 源码分析
先来分析客户端:
tlsConf := &tls.Config{
InsecureSkipVerify: true,
NextProtos: []string{"quic-echo-example"},
}
首先在QUIC
配置TLS
来保证安全性
session, err := quic.DialAddr(addr, tlsConf, nil)
拨号,即连接指定的IP地址。session
类似于TCP/IP的套接字
stream, err := session.OpenStreamSync(context.Background())
创建流,在stream
上发送和接收信息。context.Background()
类似于管道,相当于给予QUIC一个通信的手段
// 发送数据
stream.Write([]byte(message))
// 接收数据
buf := make([]byte, len(message))
io.ReadFull(stream, buf)
发送和接收数据,至此,一个完整的client就分析完了
继续分析服务端:
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
监听addr
,generateTLSConfig()
代表TLS的配置,最后一个参数是quic.config
一般是nil
sess, err := listener.Accept(context.Background())
sess
与上面的session
类似
stream, err := sess.AcceptStream(context.Background())
创建stream
,接收信息,在server
端新建一个context
专门对这个连接进行通信
3.3 仿照用例自己实现
分析完上面主要的代码之后,我们现在自己实现一个简单的QUIC通信,实现客户端发送Hello
,服务端发送Hi
。
为了方便,我们把客户端和服务端写到一个文件夹里。
const addr = "localhost:6688"
const clientMessage = "Hello"
const serverMessage = "Hi"
首先定义一下本地监听端口号和要发送的数据
客户端:
session, err := quic.DialAddr(addr, tlsConf, nil)
为了简单,我们使用最简单的TLS
配置安全传输
stream, err := session.OpenStreamSync(context.Background())
创建个流,使用流传输数据
_, err = stream.Write([]byte(clientMessage))
客户端发送数据
_, err = stream.Read(buf)
读取服务端发来的信息到buf
,buf是一个字节数组
客户端至此完成
服务端:
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
服务端使用TLS
检测安全,generateTLSConfig()
类似于通用配置,可自定义
sess, err := listener.Accept(context.Background())
接收新的连接请求
stream, err := sess.AcceptStream(context.Background())
在刚才的连接请求上创建新的接收流
_, err = stream.Read(buf)
_, err = stream.Write([]byte(serverMessage))
发送和接收数据
完整代码在附录
3.4 QUIC通信总结
因为QUIC是在UDP的基础上实现的,所以大部分与UDP的机制相同,下面是我根据自己理解画的一张图
可以看到相较于传统的UDP协议,还是有很多类似之处的
4. QUIC实现源码剖析
4.1 客户端分析
4.1.1 DialAddr
我们从客户端开始分析:
session, err := quic.DialAddr(addr, tlsConf, nil)
进入DialAddr
函数进行查看
func DialAddr(addr string,tlsConf *tls.Config,config *Config,
) (Session, error) {
return DialAddrContext(context.Background(), addr, tlsConf, config)
}
先来看函数的参数列表:
addr
表示服务端的地址,tlsConf
表示tls
的配置,最后一个config
表示QUIC
的配置,当填入nil
的时候将使用默认配置。
我们来看看config *Config
常用的一些选项:
HandshakeIdleTimeout
:握手延迟MaxIdleTimeout
:双方没有发送消息的最大时间,超过这个时间则断开AcceptToken
:令牌接收MaxReceiveStreamFlowControlWindow
:最大的接收流控制窗口(针对Stream)MaxReceiveConnectionFlowControlWindow
:最大的针对连接的可接收的数据窗口(针对一个Connection可以有多少最大的数据窗口)MaxIncomingStreams
:一个连接最大有多少Stream
接着看函数的返回值(Session, error)
error
不必多少,是对于一系列错误的管理Session
:根据代码注释,Session是一个在两个端点之间的connection
即连接。
继续深入源代码,我们知道DialAddr
只是一个封装函数,我们继续向下追溯DialAddrContext
,
func DialAddrContext(ctx context.Context,addr string,tlsConf *tls.Config,config *Config,
) (Session, error) {
return dialAddrContext(ctx, addr, tlsConf, config, false)
}
唯一的不同在于多加了一个context.Context
类型,这是什么东西呢?
上下文context.Context
是用来设置截止日期、同步信号,传递请求相关值的结构体。
我们可以把它理解为一个同步信号,即对信号同步以减少资源浪费。
我们常用的context.Background()
返回一个预定义的类型。因为不是协议的重点,所以我们简略看一下。
我们发现DialAddrContext
依然只是一个包装函数,我们继续向下追溯DialAddrContext
func dialAddrContext( ctx context.Context,addr string,tlsConf *tls.Config,config *Config,use0RTT bool,
) (quicSession, error) {
udpAddr, err := net.ResolveUDPAddr("udp", addr)
//...
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
//...
return dialContext(ctx, udpConn, udpAddr, addr, tlsConf, config, use0RTT, true)
}
到这里就比较清晰了,因为QUIC
基于udp,先调用net.ResolveUDPAddr("udp", addr)
,接着在UDP上监听net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
,最后根据返回的结果调用dialContext
函数统一处理。
继续看dialContext
函数干了什么,在函数里我们可以找到关键的代码行
//...
packetHandlers, err := getMultiplexer().AddConn(pconn, config.ConnectionIDLength, config.StatelessResetKey, config.Tracer)
//...
c, err := newClient(pconn, remoteAddr, config, tlsConf, host, use0RTT, createdPacketConn)
getMultiplexer
建立一个多路复用,在前面我们分析原理的时候提到了多路复用(multiplexing)
。接着返回一个新的客户端结构newClient
。
至此,我们终于分析完了DialAddr
的调用路径。
4.1.2 session.OpenStreamSync
stream, err := session.OpenStreamSync(context.Background())
OpenStreamSync
:打开一个新的双向的QUIC Stream
追踪这个函数,我们找到一个函数OpenStreamSync
,这个函数里面是一些处理多线程的语句,在最后,有一个openStream
,下面列出这个函数
func (m *outgoingBidiStreamsMap) openStream() streamI {
s := m.newStream(m.nextStream)
m.streams[m.nextStream] = s
m.nextStream++
return s
}
不要忘记,我们的Session
是一个connection
,它包含多个Stream
,这个函数就是新建一个流,然后加入这个Session
,我们继续查看newStream
,它返回一个新创建的流(Stream)
,流也是一个结构体.
type stream struct {
receiveStream
sendStream
completedMutex sync.Mutex
sender streamSender
receiveStreamCompleted bool
sendStreamCompleted bool
version protocol.VersionNumber
}
流里面有接收流receiveStream
,发送流sendStream
,以及同步用的锁completedMutex
我们可以看看接受流里面有什么:
StreamID
:流IDio.Reader
:读接口CancelRead
:是否禁止接收流SetReadDeadline
:读超时设置
再看看发送流:
StreamID
:流IDio.Write
:写接口CancelWrite
:是否禁止写Context
:上面提到过的用来同步的结构体SetWriteDeadline
:设置写超时
到这里,我们就分析完了OpenStreamSync
调用路径,最后返回一个流
4.1.3 Read
我们再来看看Stream
的读(接收)操作
_, err = stream.Read(buf)
找到具体的Read
函数
func (s *receiveStream) Read(p []byte) (int, error) {
s.mutex.Lock()
completed, n, err := s.readImpl(p)
s.mutex.Unlock()
//...
}
s
就是我们基于的接收流的名字,发送具体的接收使用了readImpl
函数
看看readImpl
函数
if s.currentFrame == nil || s.readPosInFrame >= len(s.currentFrame) {
s.dequeueNextFrame()
}
看看还有没有可用来读的MaxReceiveStreamFlowControlWindow
,有的话读数据
copy(p[bytesRead:], s.currentFrame[s.readPosInFrame:])
把数据放到我们接收的地方,这里是从s
目前的窗口复制到p
里
4.1.4 Write
接着看Stream
的写(发送)操作
找到Write
函数,因为太长,下面分析一些关键的代码
if s.canBufferStreamFrame() && len(s.dataForWriting) > 0{
//...
}
先检查还能不能发送
copy(s.nextFrame.Data[l:], s.dataForWriting)
接着讲发送的数据放到Frame
里面,调用底层发送出去
4.2 服务端分析
4.2.1 ListenAddr
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
同样的,进入函数具体查看
func ListenAddr(addr string, tlsConf *tls.Config, config *Config) (Listener, error) {
return listenAddr(addr, tlsConf, config, false)
}
刚才我们在客户端分析过addr
,tls
,config
的意义,这里不再赘述
继续进入listenAddr
func listenAddr(addr string, tlsConf *tls.Config, config *Config, acceptEarly bool) (*baseServer, error) {
udpAddr, err := net.ResolveUDPAddr("udp", addr)
conn, err := net.ListenUDP("udp", udpAddr)
serv, err := listen(conn, tlsConf, config, acceptEarly)
return serv, nil
}
前两个函数与客户端差不多,我们来看看listen
函数发生了什么
这个函数返回一个baseServer
,这是一个QUIC
的listener
,它是一个数据结构
主要添加了如下的结构体成员
sessionQueue
:一个客户端的Session
队列sessionQueueLen
:客户端的Session
队列的长度
接着新建一个线程,不断地监听端口,等待一个新的客户端连接请求
4.2.2 Accept
sess, err := listener.Accept(context.Background())
看看Accept
源代码
func (s *baseServer) Accept(ctx context.Context) (Session, error) {
return s.accept(ctx)
}
继续查看accept
,关键代码为
atomic.AddInt32(&s.sessionQueueLen, -1)
当收到一个新的请求的时候添加到sessionQueue
中,返回一个客户端Session
,用这个Session
可以和客户端进行发送和接收数据
服务端的Write
和Read
不再分析,同样和客户端一样使用Stream
进行发送和接收
5. 与传统的TCP比较与应用前景
5.1 QUIC性能分析
业界应用情况:
● 腾讯QQ应用情况
● 微博移动端全面支持QUIC协议
5.2 展望
虽然QUIC相比于以前的通信协议有更大的进步,能具有更低的延迟和更好的安全性,但应用落地依然还具有一段距离
- QUIC现在仍然是草案,虽然
Chormium
和QUIC-GO
是两个已经落地使用的协议,但距离大规模应用仍然具有距离 - 由于历年的潜规则, 很多路由器对于
UDP数据包
直接丢弃 - 网络服务商对UDP持消极态度
- 硬件的更新是遥遥无期的问题
但基于QUIC的优势,期待着QUIC正式成为互联网标准,并且大规模应用落地
我只是简单的分析了一下QUIC协议,受限于个人的水平,文章依然还有很多不足之处,请多多包涵
参考资料
[1]. https://en.wikipedia.org/wiki/QUIC
[2]. https://blog.csdn.net/chenhaifeng2016/article/details/79011059
[3]. https://docs.google.com/document/d/1gY9-YNDNAB1eip-RTPbqphgySwSNSDHLq9D5Bty4FSU/edit
[4]. https://zhuanlan.zhihu.com/p/44980381
[5]. https://tools.ietf.org/html/draft-ietf-quic-transport-20#page-23
[6]. https://tools.ietf.org/html/draft-ietf-quic-recovery-20#page-4
[7]. https://zhuanlan.zhihu.com/p/32553477
[8]. https://tools.ietf.org/html/draft-ietf-quic-transport-20#ref-HTTP2
[9]. https://github.com/lucas-clemente/quic-go
[10]. https://www.zhihu.com/question/30519570/answer/1400925045
附录
实现QUIC客户端和服务端:Go语言
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"github.com/lucas-clemente/quic-go"
"math/big"
"time"
)
const addr = "localhost:4242"
const clientMessage = "Hello"
const serverMessage = "Hi"
func main() {
go func() {
err := server()
if err != nil {
panic(err)
}
}()
err := client()
if err != nil {
panic(err)
}
// 等待main和go程 执行完,防止server执行完自动结束
time.Sleep(time.Second * 5)
}
// 客户端
func client() error {
tlsConf := &tls.Config{
InsecureSkipVerify: true,
NextProtos: []string{"quic-echo-example"},
}
session, err := quic.DialAddr(addr, tlsConf, nil)
if err != nil {
return err
}
stream, err := session.OpenStreamSync(context.Background())
if err != nil {
return err
}
fmt.Printf("Client: Sending '%s'\n", clientMessage)
_, err = stream.Write([]byte(clientMessage))
if err != nil {
return err
}
buf := make([]byte, 1024)
_, err = stream.Read(buf)
if err != nil {
return err
}
fmt.Printf("Client: Got '%s'\n", buf)
return nil
}
// 服务端
func server() error {
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
if err != nil {
return err
}
sess, err := listener.Accept(context.Background())
if err != nil {
return err
}
stream, err := sess.AcceptStream(context.Background())
if err != nil {
panic(err)
}
buf := make([]byte, 1024)
_, err = stream.Read(buf)
if err != nil {
return err
}
fmt.Printf("Server: Got '%s'\n", buf)
fmt.Printf("Server: Sending '%s'\n", serverMessage)
_, err = stream.Write([]byte(serverMessage))
return err
}
func generateTLSConfig() *tls.Config {
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
panic(err)
}
template := x509.Certificate{SerialNumber: big.NewInt(1)}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
panic(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
panic(err)
}
return &tls.Config{
Certificates: []tls.Certificate{tlsCert},
NextProtos: []string{"quic-echo-example"},
}
}