网络

419 阅读50分钟

所谓『协议』就是通讯双方所必须遵守的规则,在这种规则下,不同的数据报可能被解析为不同的响应动作。

简而言之,『协议』就是指如果发送和接收方按照这个规则进行数据报文的发送,即可在基本的数据传输之上得到某些特殊的功能或服务,否则你的数据别人是不认识的。例如:遵循 TCP 协议的两端,可以在不可靠的网络传输中得到可靠的数据传输能力。

整个计算机网络是分层的,有七层模型,也有五层模型,个人觉得五层模型更利于理解。我们从上至下的介绍这五个层,它们分别是,应用层,运输层,网络层,数据链路层和物理层。

应用层

『应用层』算是距离用户最近的一层了,主机上的一个个的进程就构成了『应用层』。比如你在你的浏览器地址栏输入了 「www.baidu.com」,你的浏览器在应用层会做哪些事情呢?

首先浏览器会使用 DNS 协议返回域名「www.baidu.com」所对应的 IP 地址。

接着,应用层决定创建一个『TCP 套接字』,然后将这个请求动作封装成一个 Http 数据报并推入套接字中。 套接字分为两种类型,『TCP 套接字』和『UDP 套接字』,应用层同时可能会有几十个数据报的发出,而运输层也会收到所有的响应报文,那么它该如何区分这些报文到底是谁的响应报文呢? 而套接字就是用于区分各个应用层应用的,往往由端口号和 IP 地址进行标识,运输层只要查看响应报文的源端口号和 IP 地址就能够知道该将报文推送给哪个套接字了。 当一个应用层数据报被推动进套接字之后,应用层的所有工作也算是全部完成了,关于后续报文的去向,它已经不用管了。 这里还要说明一点的是,『TCP 套接字』和『UDP 套接字』两者本质上的区别在于,前者保证数据报可靠地到达目的地,但是必然耗时,而后者不保证数据报一定能到达目的地,但是速度快,这也是应用层协议在选择运输层协议的时候需要考虑的一点。

域名解析协议 DNS 是如何运作的,它是如何将一个域名解析返回它的 IP 地址的?

DNS 原理

首先明确一点的是,DNS 是一个应用层协议,并且它选择的运输层协议是 UDP,所以你的域名解析过程一般会很快,但也会经常出现解析失败的情况,然而刷新一下又好了。

在 DNS 服务器上,域名和它所对应的 IP 地址存储为一条记录,而所有的记录都不可能只存储在一台服务器上,我相信无论多么强大的服务器都扛不住全球上亿次的并发量吧。 大致来说,有三种类型的 DNS 服务器,根 DNS 服务器,顶级域 DNS 服务器和权威 DNS 服务器。 其中,顶级域 DNS 服务器主要负责诸如 com、org、net、edu、gov 等顶级域名。 根 DNS 服务器存储了所有顶级域 DNS 服务器的 IP 地址,也就是说你可以通过根服务器找到顶级域服务器。例如:「www.baidu.com」,根服务器会返回所有维护 com 这个顶级域服务器的 IP 地址。 然后你任意选择其中一个顶级域服务器,请求该顶级域服务器,该顶级域服务器拿到域名后应当能够做出判断并给出负责当前域的权威服务器地址,以百度为例的话,顶级域服务器将返回所有负责 baidu 这个域的权威服务器地址。 于是你可以任意选择其中一个权威服务器地址,向它继续查询 「www.baidu.com」 的具体 IP 地址,最终权威服务器会返回给你具体的 IP 地址。 至此,我们简单描述了一个域名解析的大致过程,还有一些细节之处并未提及,我们等会会通过一个实例来完整的看一下,下面描述一个非常重要的概念。 整个 DNS 解析过程中,有一个非常核心的人物我们一直没介绍它,它就像主机的『助理』一样,帮助主机查询域名的 IP 地址。它叫做『本地 DNS 服务器』。

大家每次通过 DHCP 动态获取 IP 地址的时候,这一点后文会说。其实路由器不仅给你返回了 IP 地址,还会告诉你一个 DNS 服务器地址,这个就是你的本地 DNS 服务器地址,也就是说,你的所有域名解析请求只要告诉它就行了,它会帮你查并返回结果给你的。 除此之外,本地 DNS 服务器往往是具有缓存功能的,通常两天内的记录都会被缓存,所以大部分时候你是感觉不到域名解析过程的,因为往往就是从缓存里拿的,非常快。 下面我们看一个简单的案例:现在假设请求 「www.xx.com」 。

①:主机向负责自己的本地 DNS 发送查询报文,如果本地服务器缓存中有,将直接返回结果 ②:本地服务器发现缓存中没有,于是从内置在内部的根服务器列表中选一个发送查询报文 ③:根服务器解析一下后缀名,告诉本地服务器负责 .com 的所有顶级服务器列表 ④:本地服务器选择一个顶级域服务器继续查询,.com 域服务器拿到域名后继续解析,返回负责 .xx 域的所有权威服务器列表 ⑥:本地服务器从返回的权威服务器之一再次发送查询报文,最终会从某一个权威服务器上得到具体的 IP 地址 ⑧:向主机返回结果

其实整个 DNS 报文的发送与响应过程都是要走我们的五层协议的,只是这里重点在于理解 DNS 协议本身,所以并未提及其他层的具体细节,这里的强调是提醒你 DNS 只是一个应用层协议。

运输层

运输层的任务就是将应用层推出套接字的所有数据报收集起来,并且按照应用层指定的运输层协议,TCP 或 UDP,重新封装应用层数据报,并推给网络层等待发送。 TCP 和 UDP 是运输层的两个协议,前者是基于连接的可靠传输协议,后者是无连接的不可靠传输协议,所以前者更适合于一些对数据完整性要求高的场合,后者则适合于那种可以允许数据丢失但对传输速率要求特别高的场景,例如:语音电话,视频等,丢一两个包最多卡顿一下,无伤大雅。

UDP

UDP 不同于 TCP 那样复杂,它既不保证数据可靠的传输到目的地,也不保证数据按序到达目的地,仅仅提供了简单的差错检验。报文格式如下:

其中,数据就是应用层推出来的数据,源端口号用于响应报文的交付,目的端口号用于向目的进程交付数据,校验和用于检查传输过程中数据是否受损,如果受损,UDP 将直接丢弃该报文。

TCP

TCP 要稍微复杂些,它是面向连接的,并且基于连接提供了可靠的数据传输服务,它的数据报文格式如下:

TCP 的三次握手就是为了确保通讯双方能够稳定的建立连接并完成数据报文的请求与响应动作。

第一步: 客户端向服务端发送一份特殊的 TCP 报文,该报文并不包含应用层的数据,是一份特殊的报文,它的 TCP 首部中 SYN 字段值为 1 (参见上述报文格式)。 除此之外,客户端还会随机生成一个初始序号,填在报文的「序号」字段,代表当前报文的序号是这个,并且我后续的分组会基于这个序号递增。 然后该报文将会经网络层、链路层、物理层发送到服务端。

第二步: 如果分组丢失了,那么客户端会经过某个时间间隔再次尝试发送。 而如果分组准确的到达服务端了,服务端拆开 TCP 首部会看到,这是一个特殊的 SYN 握手报文,于是为此次连接分配缓存等资源。 接着服务端开始构建响应报文,SYN 是一个用于同步需要的字段,响应报文中依然会被置为 1,并且服务端也将随机生成一个初始序号放置的响应报文的序号字段中。 最后,服务端还会为响应报文中的确认字段赋值,这个值就是客户端发过来的那个序号值加一。 整体上的意思就是说,「我同意你的连接请求,我的初始序号为 xxx,你的初始序号我收到了,我等着你的下一个分组到来」

第三步:

客户端收到服务端的响应报文,于是分配客户端 TCP 连接所必须的缓存等资源,于是连接已经建立。

实际上从第三步开始,客户端就可以携带应用层数据向服务端交换报文了,以后的每份报文中,SYN 都为 0,因为它只是用于同步初始序号的,这一点需要明确。

下面我们看看拆除一条 TCP 连接的『四次挥手』是怎样的过程。 因为一条 TCP 连接会消耗大量的主机资源,不仅仅服务端需要分配各种缓存资源,客户端也同样需要分配相应资源。因为 TCP 是『全双工通信』,服务端和客户端两方其实是一样的,谁是客户谁是服务器是相对的。 强调这一点是为了说明,一条 TCP 连接不是只有客户端才能断开,服务端也同样可以主动断开连接,这一点需要清楚。 我们这里假设客户端主动发起断开连接的请求为例:

第一步: 客户端构建一份特殊的 TCP 报文,该报文首部字段 FIN 被置为 1,然后发送该报文。

第二步: 服务端收到该特殊的 FIN 报文,于是响应客户端一个 ACK 报文,告诉客户端,请求关闭的报文已经收到,我正在处理。

第三步: 服务端发送一个 FIN 报文,告诉客户端,我将要关闭连接了。

第四步: 客户端返回一个 ACK 响应报文,告诉服务端,我收到你刚才发的报文了,我已经确认,你可以关闭连接了。

当服务端收到客户端发送的 ACK 响应报文时,将释放服务端用于该 TCP 连接的所有资源,与此同时,客户端也会定时等待一定时间后完全释放自己用于该连接的相关资源。

首先,客户端发送一个特殊分组,该分组的序号为 u。发送完成之后,客户端进入 FIN-WAIT-1 这个状态,这个状态下,该 TCP 连接的客户端不再能发送数据报,但是是可以接受数据报的,它等待着服务端的响应报文。 接着,服务端收到客户端发送的终止连接报文请求,服务端构建响应报文,告诉客户端「序号 u+1 以前的分组我都收到了」,并且进入 CLOSE-WAIT 状态,这个状态持续时间很短。 服务端会紧接着发送它的 FIN 数据报,通知客户端我服务端即将关闭连接,并随即进入 LAST_ACK 状态等待客户端响应报文。 一旦客户端收到这个 FIN 报文,将返回确认报文并进入 TIME-WAIT 状态,等待 2MSL 时间间隔后完全释放客户端 TCP 连接所占用资源。 与此同时,当服务端收到客户端最后的确认报文,就将直接断开服务端连接并释放相关资源。

至于为什么最后客户端需要等 2MSL 时间长度再完全释放 TCP 相关资源呢? 那是因为 2MSL 是一份报文存在于网络中最长的时间,超过该时间到达的报文都将被丢弃,而如果客户端最后的确认报文于网络中丢失的话,服务端必将发起超时请求,重新发送第三次挥手动作,此时等待中的客户端就可随即重新发送一份确认请求。 这是为什么客户端等待一个最长报文传输时间的原因。有人可能好奇为什么前面的各次请求都没有做超时等待而只最后一次数据发送做了超时等待? 其实原因很简单,相信你也能想到,就是 TCP 自带计时能力,超过一定时间没有收到某个报文的确认报文,会自动重新发送,而这里如果不做等待而直接关闭连接,那么我如何知道服务端到底收到没我的确认报文呢。 通过等待一个最长周期,如果这个周期内没有收到服务端的报文请求,那么我们的确认报文必然是到达了服务端了的,否则重复发送一次即可。

首先我们来看 TCP 是如何实现可靠传输的,即如何解决网络传输中丢包的问题。TCP 使用『回退 N 步』协议实现的可靠传输,准确来说,TCP 是在它的基础上进行了一部分优化。

『回退 N 步』协议也被称作『滑动窗口』协议,即最多允许发送方有 N 个「已发送但未被确认」的数据报文,如图所示,p1 到 p3 长度即为 N,这里的窗口指的就是 p1 到 p3 这个区间。 只有当发送端收到 p1 的确认报文后,整个窗口才能向前滑动,而实际上在没有收到 p1 的确认报文前,即便它后面的报文已经被接收,服务端也仅仅会缓存这些『非预期的报文』 直到服务端收到最小预期的那个报文后,从缓存中取出已经到达的后续报文,合并并向上交付,然后向发送端返回一个确认报文。 当发送端窗口从左往右已经连续多个报文被确认后,整个窗口将向前滑动多个单位长度。

这是一个发送方的窗口,灰色表示已经被确认的报文,黄色表示已发送但未被确认的报文,绿色表示下一个待发送的报文,白色表示不可用的报文。 这是我们假设服务端已经收到 6、7 两份报文,但是它上一次向上交付给应用层的是 4 号报文,也就是说它在等 5 号报文,所以它暂时会将 6、7 两个报文缓存起来,等到 5 号报文来了一并交付给应用层。 现在 5 号报文由于超时被重传了,终于到达目的地了,如愿以偿,服务端向上交付 5、6、7 三份报文,并返回一份确认报文,ACK = 8,表示序号 8 以前的所有报文都收到了。 当发送端收到这份确认报文后,5、6、7 变成灰色,窗口向前移动三个单位长度。 此外,我还想强调一个细节,TCP 是没有否定确认的,所以如果服务端连续响应的多份报文是对同一序号的确认,那很有可能该序号以后的某个报文丢失。 例如:如果服务端发送多个对分组 5 的 ACK 确认,那说明什么?说明目前我服务端完整的向上交付的序号是 5 号,后续的报文我没收到,你最好重新发一下别等待超时了。 这也是『快速重传』的核心原理。

如果网络拥塞的时候,TCP 是如何控制发送流量的呢?

TCP 认为:丢包即拥塞,需要降低发送效率,而每一次收到确认数据报即认为网络通畅,会增加发送效率。 TCP 的拥塞控制算法包含三个部分,慢启动、拥塞避免和快速恢复。 慢启动的思想是,刚开始缓慢的发送,比如某个时间段内只发送一次数据报,当收到确认报文后,下一次同样的时间间隔内,将发送两倍速率的两份数据报,并以此类推。 所以,短时间内,一个 TCP 连接的发送方将以指数级增长,但一旦出现丢包,即收到冗余的 ACK 确认,或者对于一个包的确认 ACK 始终没收到而不得不启动一次超时重传,那么发送方认为「网络是拥塞的」。 于是将速率直接调成一,即一个往返时间段,只发送一个分组,并且设置一个变量 ssthresh 表述一个阈值的概念,这个值是上次丢包时发送方发送速率的一半。 之后的发送方的发送效率一样会以指数级增长,但是不同于第一次,这次一旦达到这个阈值,TCP 将进入『拥塞避免』模式,该模式下的发送效率将不再指数级增长,会谨慎的增长。 拥塞避免的思想是,每个往返时间段发送的所有数据报全部得到确认后,下一次就增加一个分组的发送,这样缓慢的增长效率是谨慎的。 那么一旦出现发送端超时丢包,注意这里是超时,将发送速率置为一并重新进入慢启动状态,阈值就是当前发送效率的一半。 而如果是服务端返回多个冗余 ACK 以明确你丢包,TCP 认为这不是严重的,对于这种情况,TCP 减半当前发送效率并进入快速恢复阶段。 快速恢复的基本思想是,收到几个冗余的 ACK 就增加几个分组的发送效率,就是说,你服务端不是没收到我的几个报文吗,这两次发送我提升速率迅速发给你。 当这期间出现了由发送端超时导致的丢包,同样的处理方式,初始化发送速率为一并减半当前发送效率作为阈值,进入慢启动阶段。 当然,如果这期间收到了对丢失报文的确认,那么将适当降低发送效率并进入拥塞避免状态。

运输层的任务就是从应用层的各个进程的套接字那取回来所有需要发送的数据,然后选择 TCP 或者 UDP 将数据封装并推给下面的网络层待发送。

网络层

『网络层』其实解决的就是一个「转发」的问题,通过传说中的『IP 协议』划分了网络范围,即我没有直接用网线和你连在一起,我也能通过你的 IP 分析出该怎么样找到负责你的网关路由器,并通过你的网关路由给你传输数据报。 这就是『网络层』做的事情,它本质上解决了两台不存在于同一子网络下的主机相互通信的问题。而『IP 协议』以及「如何解析 IP 的算法」算是两个最核心的内容,我们首先看看这个『IP 协议』的相关概念。 以 IPv4 为例,使用 32 个比特位描述一个 IP 地址,所以理论上,整个 IPv4 可以提供 40 几个亿的 IP 地址,我们一般使用『点分十进制』来表示。 例如:11000001 00100000 11011000 00001001 的 IP 地址一般记为 193.32.216.9。 由此,我们解决了 IP 编址的问题,但是如何通过 IP 地址判断出它所属的子网络呢? 引入一个名词『子网掩码』,它在形式上和 IP 地址一样,使用 32 位比特位进行表述。其中,描述网络部分的比特位全为 1,子网络中的该主机编号部分全为 0 。 例如:子网掩码 11111111.11111111.11111111.00000000,写成十进制就是255.255.255.0。它明确了某个使用该子网掩码的 IP 的前 24 位是它的子网络部分,而后 8 位是该 IP 对应的主机在子网络下的一个编号。 举个例子:

IP 地址 172.16.254.1 所对应的子网掩码为 255.255.255.0,那么我们只需要做『AND』运算这两者即可得到该 IP 地址的网络部分。 所以,这个 IP 地址的网络号为 172.16.254 。 下面我们探讨一个十分重要的协议,它解决了一个刚加入子网络的主机如何获取属于它的 IP 地址的问题,这个协议叫,动态主机配置协议(DHCP)。

DHCP

一般来说,我们有两种方式来配置主机的 IP 地址,一种是管理员手动的指定一个 IP 地址,当然,这样的成本是非常高的,你不能配置了一个已经被分配出去的 IP地址,即管理员需要记录所有已分配的 IP 地址。 另外一种呢,就是我们的 DHCP 协议,它允许新加入的主机自动获取一个 IP 地址以及相关的子网掩码和网关地址等。 默认情况下,路由器隔离广播包,不会将收到的广播包从一个子网发送到另一个子网。当 DHCP 服务器和客户端不在同一个子网时,充当客户端默认网关的路由器将广播包发送到DHCP服务器所在的子网,这一功能就称为 DHCP 中继(DHCP Relay)。 也就是说,一个子网络中应当有一台 DHCP 服务器,用于整个子网中 IP 地址的分配。但为每个子网都单独配置一个 DHCP 服务器也有点「愚蠢」。 所以另一种解决办法就是,某个网络中的网关会知道负责该网络的 DHCP 服务器在什么位置,IP 地址是什么,网关路由会负责转发 DHCP 报文请求并返回响应的报文,这就叫 DHCP 中继。 当然了,实际上现在的路由器本身就可以充当一个 DHCP 服务器,为其所在的子网提供动态地址获取服务,所以往往也不需要转发那样麻烦。 而完整的 DHCP 请求与响应的过程则是这样的:

第一步: DHCP 服务器发现。 这个阶段的首要任务是,找到当前网络中 DHCP 服务器的位置,并且整个 DHCP 报文的交换是基于 UDP/IP 协议的,向目的端口 67 发送。 本机由于没有 IP 地址,所以 IP 数据报中的『源地址』为「0.0.0.0」,『目的地址』为「255.255.255.255」。 这样在链路层广播该数据报的时候,同一子网络下的所有主机都会接受该数据报,但只有 DHCP 服务器会响应这个请求。 于是如果路由器本身就是一个 DHCP 服务器的话,那将进入第二步,否则路由器将分组转发到 DHCP 服务器所在的网络内。

第二步:

DHCP 服务器提供。 DHCP 服务器,无论是位于外网或是网关路由本身,在收到一个『发现报文』后,将响应一个『提供报文』。

该报文中将包含,推荐客户使用的 IP 地址、子网掩码、IP 地址租用周期等信息。

第三步:

DHCP 请求。这其实是一个选择阶段,客户端主机确认服务器推荐的参数,决定使用,于是依然以广播的形式发送请求向服务器确认。

第四步:

DHCP ACK。收到客户端主机发来的确认请求后,服务器将实际从 IP 池中分配出一块 IP 地址出来,并返回客户端确认信息的 ACK。

从此之后,该主机算是获得了一块可用的 IP 地址了,终于加入了网络。 除此之外,还有一个细节不知道大家日常有没有留心,就是我们对于同一个子网络,IP 地址基本总是一样的,并没有因为每次开机后连入网络而被分配不同的 IP。 这一点算是 DHCP 协议的一个约定了,当某台主机第一次加入某个子网络,它将从 DHCP 服务器获取一个全新的 IP 地址。 而以后该主机重新加入到该网络时,将直接进入 DHCP 请求的第三步,将主机上次使用的参数发给服务器,确认是否可用。而一般情况下服务器会同意并按照你的要求分配出去一块 IP 地址,这也是为什么你每次使用的几乎是同一 IP。 讲完了 DHCP 动态获取 IP 地址,接着我们简单看看 IP 数据报的基本格式,并在最后讨论一下路由器的选择算法,看看一个 IP 数据报是如何被路由器给转发出去的。

路由器

路由器是网络层的一个核心设备,它完成了从「目的 IP 地址」到「目的 IP 所在的子网络」的完整路径转发过程。它的内部结构如下:

每个端口都直接连接了一台设备,而其中的『路由选择处理器』则负责解析一个输入端口进来的数据应该被推出到哪个输出端口中去。 所以,你应该也发现了,整个路由器的核心应该是这个『路由选择处理器』,也就是驱动这个『路由选择处理器』工作的算法,我们称之为『路由选择算法』。算法本质上就是解决,一个数据报输入进路由器内存,该从哪个输出口转发出去的问题。 一个好的 『路由选择算法』不仅仅应该解决如何到达目的地的问题,还应该考虑如何最快的到达目的地,即能够判断并选择性的绕过拥塞的网络路径。 整个路由选择算法分为两大类,全局式路由选择算法和分散式路由选择算法。前者的一个最典型的实现就是『链路状态路由选择算法』,后者的一个最典型的实现就是『距离向量算法』。 这两者算法的理论原理这里不再和大家一起探讨了,我们着重看看因特网中是如何基于这两种算法实现的路由选择。 首先,整个因特网是一个很庞大且复杂的系统,所以整体上被划分为一个一个的自治系统(AS),在每一个 AS 中都运行着同样的路由算法,自治系统之间使用 BGP 协议交换信息。

整个因特网大致就是这样的一个个自治系统互联构成的,而自治系统内部的所有路由器都运行着同样的路由选择算法,基于距离向量的『RIP 协议』或基于链路状态的『OSPF 协议』。 至于为什么要拆分自治系统,等我们介绍完这个 RIP 或者 OSPF,你就明白了。 RIP 协议的算法是这样的:

简单的一个自治系统,我们以此为例看看整个 RIP 协议是如何工作的。

首先最开始,路由器 A 的转发表肯定是这样的:

----------------------------
目的子网   下一跳路由   跳数
x           B           1
q           E           1
----------------------------

其他路由器也是类似的,第一步都建立起与自己直接相连邻居的连接。

第二步是一个不断进行的过程,相邻的路由器之间每隔 30 秒就相互交换信息,告知对方自己的转发表内容。

所以经过一次交换之后,路由器 A 将收到来自 B 和 E 的转发表信息,于是路由转发表更新如下:

----------------------------
目的子网   下一跳路由   跳数
x           B           1
q           E           1
y           B           2
p           E           2
----------------------------

但是这里有一个细节,子网络 y 是可以通过 A - B - y 到达的,但同时也可以通过 A - E - C - y 到达。你也许已经猜到了,路由器当然会选择最短路径的一条来更新自己的转发表。 所以,这个距离向量的算法本质上就是通过相互之间不断的交换信息以保证某个自治系统内,所有的路由器都知道某个目的子网的最短路径。 OSPF 的实现是这样的: 我们同样以上面的例子进行解释:

OSPF 是基于链路状态路由选择算法进行实现的,所以它也是一个全局性路由选择算法,算法运行一次即可完成全网的路由信息更新。 而 OSPF 本质上就是一个迪杰斯特拉求最短路径问题,它通过不断的迭代与计算更新整个路由转发表。假设现在我们的路由器 A 运行 OSPF 协议: 第一次迭代完成后,它得到与 B、E 两台路由器相关的子网络的路径计算。 第二次以 B 或者 E 为起点重新运行算法,这里我们假设以 B 为起点运行了算法,那么与 C 相关的子网络的路径也被更新进 A 的路由转发表。 第三次以 C 为起点同样的运行算法,得到和 D 相关的子网络路径更新。 由于 D 作为末端路由,并没有直接相连的其他路由,所以算法不再继续,回到 E。 第四次,以 E 作为原点,运行算法,得到了 C 相关子网络的路径,如果有更短的路径,将更新 A 的转发表以最优路径。 那么,待整个算法运行结束,一个自治系统中的所有路由器几乎全部遍历,但是却不同于 RIP,OSPF 相对而言收敛快,可以迅速完成任务,而 RIP 则需要不断的交换信息以达到需求,往往会陷入一个长周期。 当然了,OSPF 需要较强的 CPU 计算能力和更多的内存存储空间。所以总的而言,他们都广泛应用于整个因特网之中,RIP 应用在较为底层的 ISP 上,而 OSPF 则运行在较为高级的 ISP 中。

至此,整个网络层的基本内容也介绍完了,总结一下,网络层的核心任务就是负责转发分组,而如何将分组转发到目的主机的网络中呢,牵扯出 IP 协议,通过 IP 地址与子网掩码划分子网络,而路由器执行路由选择算法得知目的子网络的完整路由路径并进行分发。

链路层

网络层解决的是,分组转发的目的网络,也就是转发给目的网络的网关路由,而链路层解决的是,将分组广播给个人,也即目的主机。

网络层的 IP 数据包会在链路层被封装成『以太网帧』,它的基本结构是这样的:

前导码用于同步时钟,按照我的理解就是区分一个一个的帧,源和目的地址指的是『Mac 地址』,也称作物理地址。

『Mac 地址』是硬件级别的主机唯一标识,由生产厂家唯一确定。类似这样:

34-E6-AD-17-A5-6B

全球任意一台主机的 Mac 地址都是不同的,它不像 IP 地址可以在别人不用的时候共享。 下面我们要讲一个协议,它完成了主机 IP 地址到 Mac 地址的转换,他就是 ARP 地址解析协议。 ARP 协议其实有点类似于我们之间在应用层介绍的 DNS 协议,输入一个域名 地址,输出一个 IP 地址,而 ARP 而言,输入一个 IP 地址,输出一个 Mac 地址。 网络中的每台主机,包括路由器,都内置的 ARP 模块和 ARP 表。当一份数据报到达链路层时,首先要做的就是以该数据报的目的 IP 作为输入,先查询自己主机的 ARP 模块,如果能够得到该 IP 的目的主机 Mac 地址,那么封装一个以太网帧交给物理层发送出去就好。 而如果本机的 ARP 表中并没有存储目的 IP 主机的 Mac 地址,那么就需要向同网络中的其他主机进行查询。 发送方会构建一个特殊的 ARP 分组,源 Mac 地址为发送方的 Mac 地址,目的 Mac 地址为广播地址:255.255.255.255,以及源和目的 IP 地址,本质上就是一个特殊的以太网帧。 于是该网络下的所有主机都将收到这个 ARP 分组,那么他们要做的就是拆开 IP 地址比对是否和自己的 IP 地址相同,如果是则响应一个 ARP 分组,告诉发送方自己的 Mac 地址。 如果不是自己,则还会检查自己的 ARP 模块,看看是否能提供帮助。 最终,发送方会得到想要的目的 Mac 地址并更新自己的 ARP 表,然后封装一个正常的以太网帧发送出去。

由于以太网采取的是『广播』方式,所以同一子网络中任意一台主机发送报文,所有的其余主机都会收到,但是它们会匹配目的 Mac 地址是否是自己,不是则丢弃,这一点很重要。 好了,那么到此为止我们也简单介绍了链路层的相关内容,关于物理层,其实没什么介绍的,就是 0、1 的电信号传输。

「www.google.com」背后的逻辑

以访问 Google 为例,当我们在浏览器地址栏中敲下回车键之后,整个计算机网络将会发生什么呢?

本机的网络相关参数如下:

首先我们应用层的浏览器决定向 DNS 服务器请求解析域名「www.google.com」,那么就要遵循 DNS 协议。 DNS 运行在 53 号端口,于是浏览器会创建一个 UDP 套接字,标识该套接字的二元组分别是『目的 IP 地址』和『目的端口』。而套接字本质上就是为了唯一标识应用层进程,就是为了让响应报文能够找到目的地。 那么这里会创建一个 UDP 套接字,二元组为「本机 IP 地址 192.168.43.138」和「随机产生一个未使用的端口号」。 接着,浏览器将 DNS 请求报文封装好推入套接字,开始我们的 DNS 解析过程。

到 DNS 服务器的响应报文,运输层拆开数据报,得到该报文的目的 IP 地址和目的端口号,于是对应着去找套接字交付报文即可。 最终我们会从『本地 DNS 服务器』得到 Google 的 IP 地址为:172.194.72.105。 整个 HTTP 请求可以说才刚刚开始:

应用层

浏览器封装 HTTP 请求报文,然后创建一个 TCP 套接字,采用四元组标识,具体为「源 IP 地址:192.168.43.138」+「源端口号:随机的,这里假设为 1234」+「目的 IP 地址:172.194.72.105」+「目的端口号:80」。 HTTP 报文也就是我们的应用层数据报,大致是这样的:

指定了一些请求参数与动作,以及一些要求响应报文的返回格式要求,具体的我们不细说了。

紧接着,这个报文会被推进 TCP 套接字中,等待运输层来收取。

运输层

运输层收取了报文,并判断与目的主机是否建立了 TCP 连接,这里假设没有。

那么,运输层将不急着发送应用层数据,得先判断与目的主机之间能够正常通讯,也就是需要『握手』打招呼。

通过『三次握手』,发送端和接收端确认过发送与确认序号,分配了相应的缓存资源等。

一切准备就绪之后,运输层将应用层发过来的数据报又一层封装,添加进『源端口号』和『目的端口号』以及相关差错检验字段。

最后将 TCP 数据报向下传递到网络层。

网络层

网络层其实很简单,拿到数据报并封装成 IP 数据报,即在原 TCP 报文的前提之上添加『源 IP 地址』和『目的 IP 地址』等字段信息。

然后交由数据链路层。

链路层

数据链路层拿到 IP 数据报,它需要封装成以太网帧才能在网络中传输,也就是它需要目的主机的 Mac 地址,然而我们只知道目的主机的 IP 地址。 所以,链路层有一个 ARP 协议,直接或间接的能够根据目的 IP 地址获得使用该 IP 地址的主机 Mac 地址。 当然,ARP 协议运行的前提是,目的 IP 地址和当前发送方主机处于同一子网络中。如果不然,发送方将目的 Mac 地址填自己网关路由的 Mac 地址,然后通过物理层发送出去。 网关路由由于具有转发表和路由选择算法,所以它知道目的网络该怎么到达,所以一路转发,最终会发送到目的网络的网关路由上。 最后,目的网络的网关路由同样会经由 ARP 协议,取得目的主机的 Mac 地址,然后广播发送,最后被目的主机接受。 这样谷歌的服务器就接受到一个 HTTP 请求,于是它解析这个请求,确定该请求的动作是什么,也就是它需要什么东西,并构建响应报文,以同样的方式从网络到达源主机。

HTTP

状态码

当浏览器接收并显示网页前,此网页所在的服务器会返回一个包含HTTP状态码的信息头(server header)用以响应浏览器的请求。

下面是常见的HTTP状态码:

  • 200 - 请求成功
  • 301 - 资源(网页等)被永久转移到其它URL
  • 404 - 请求的资源(网页等)不存在
  • 500 - 内部服务器错误

HTTP状态码的分类

HTTP状态码由三个十进制数字组成,第一个十进制数字定义了状态码的类型,后两个数字没有分类的作用。HTTP状态码共分为5种类型:

HTTP状态码表

响应头和请求头

HTTP请求头提供了关于请求,响应或者其他的发送实体的信息。HTTP的头信息包括通用头、请求头、响应头和实体头四个部分。每个头域由一个域名,冒号(:)和域值三部分组成。

  • 通用头标:即可用于请求,也可用于响应,是作为一个整体而不是特定资源与事务相关联。
  • 请求头标:允许客户端传递关于自身的信息和希望的响应形式。
  • 响应头标:服务器和于传递自身信息的响应。
  • 实体头标:定义被传送资源的信息。即可用于请求,也可用于响应。

请求头

响应头

缓存

通过网络提取内容既速度缓慢又开销巨大。 较大的响应需要在客户端与服务器之间进行多次往返通信,这会延迟浏览器获得和处理内容的时间,还会增加访问者的流量费用。 因此,缓存并重复利用之前获取的资源的能力成为性能优化的一个关键方面。

好在每个浏览器都自带了 HTTP 缓存实现功能。 您只需要确保每个服务器响应都提供正确的 HTTP 标头指令,以指示浏览器何时可以缓存响应以及可以缓存多久。

注:如果您在应用中使用 Webview 来获取和显示网页内容,可能需要提供额外的配置标志,以确保 HTTP 缓存得到启用、其大小根据用例进行了合理设置并且缓存将持久保存。

当服务器返回响应时,还会发出一组 HTTP 标头,用于描述响应的内容类型、长度、缓存指令、验证令牌等。 例如,在上图的交互中,服务器返回一个 1024 字节的响应,指示客户端将其缓存最多 120 秒,并提供一个验证令牌(“x234dff”),可在响应过期后用来检查资源是否被修改。

通过 ETag 验证缓存的响应

  • 服务器使用 ETag HTTP 标头传递验证令牌。
  • 验证令牌可实现高效的资源更新检查:资源未发生变化时不会传送任何数据。

假定在首次提取资源 120 秒后,浏览器又对该资源发起了新的请求。 首先,浏览器会检查本地缓存并找到之前的响应。 遗憾的是,该响应现已过期,浏览器无法使用。 此时,浏览器可以直接发出新的请求并获取新的完整响应。 不过,这样做效率较低,因为如果资源未发生变化,那么下载与缓存中已有的完全相同的信息就毫无道理可言!

这正是验证令牌(在 ETag 标头中指定)旨在解决的问题。 服务器生成并返回的随机令牌通常是文件内容的哈希值或某个其他指纹。 客户端不需要了解指纹是如何生成的,只需在下一次请求时将其发送至服务器。 如果指纹仍然相同,则表示资源未发生变化,您就可以跳过下载。

在上例中,客户端自动在“If-None-Match” HTTP 请求标头内提供 ETag 令牌。 服务器根据当前资源核对令牌。 如果它未发生变化,服务器将返回“304 Not Modified”响应,告知浏览器缓存中的响应未发生变化,可以再延用 120 秒。 请注意,您不必再次下载响应,这节约了时间和带宽。

作为网络开发者,您如何利用高效的重新验证?浏览器会替我们完成所有工作: 它会自动检测之前是否指定了验证令牌,它会将验证令牌追加到发出的请求上,并且它会根据从服务器接收的响应在必要时更新缓存时间戳。 我们唯一要做的就是确保服务器提供必要的 ETag 令牌。 检查您的服务器文档中有无必要的配置标记。

Cache-Control

  • 每个资源都可通过 Cache-Control HTTP 标头定义其缓存策略
  • Cache-Control 指令控制谁在什么条件下可以缓存响应以及可以缓存多久。

从性能优化的角度来说,最佳请求是无需与服务器通信的请求:您可以通过响应的本地副本消除所有网络延迟,以及避免数据传送的流量费用。 为实现此目的,HTTP 规范允许服务器返回 Cache-Control 指令,这些指令控制浏览器和其他中间缓存如何缓存各个响应以及缓存多久。

注:Cache-Control 标头是在 HTTP/1.1 规范中定义的,取代了之前用来定义响应缓存策略的标头(例如 Expires)。 所有现代浏览器都支持 Cache-Control,因此,使用它就够了。

  • “no-cache”和“no-store”

“no-cache”表示必须先与服务器确认返回的响应是否发生了变化,然后才能使用该响应来满足后续对同一网址的请求。 因此,如果存在合适的验证令牌 (ETag),no-cache 会发起往返通信来验证缓存的响应,但如果资源未发生变化,则可避免下载。

相比之下,“no-store”则要简单得多。 它直接禁止浏览器以及所有中间缓存存储任何版本的返回响应,例如,包含个人隐私数据或银行业务数据的响应。 每次用户请求该资产时,都会向服务器发送请求,并下载完整的响应。

  • “public”与 “private”

如果响应被标记为“public”,则即使它有关联的 HTTP 身份验证,甚至响应状态代码通常无法缓存,也可以缓存响应。 大多数情况下,“public”不是必需的,因为明确的缓存信息(例如“max-age”)已表示响应是可以缓存的。

相比之下,浏览器可以缓存“private”响应。 不过,这些响应通常只为单个用户缓存,因此不允许任何中间缓存对其进行缓存。 例如,用户的浏览器可以缓存包含用户私人信息的 HTML 网页,但 CDN 却不能缓存。

  • “max-age”

指令指定从请求的时间开始,允许提取的响应被重用的最长时间(单位:秒)。 例如,“max-age=60”表示可在接下来的 60 秒缓存和重用响应。

Cache-Control 策略

按照以上决策树为您的应用使用的特定资源或一组资源确定最佳缓存策略。 在理想的情况下,您的目标应该是在客户端上缓存尽可能多的响应,缓存尽可能长的时间,并且为每个响应提供验证令牌,以实现高效的重新验证。

废弃和更新缓存的响应

  • 在资源“过期”之前,将一直使用本地缓存的响应。
  • 您可以通过在网址中嵌入文件内容指纹,强制客户端更新到新版本的响应。
  • 为获得最佳性能,每个应用都需要定义自己的缓存层次结构。

浏览器发出的所有 HTTP 请求会首先路由到浏览器缓存,以确认是否缓存了可用于满足请求的有效响应。 如果有匹配的响应,则从缓存中读取响应,这样就避免了网络延迟和传送产生的流量费用。

不过,如果您想更新或废弃缓存的响应,该怎么办?例如,假定您已告诉访问者将某个 CSS 样式表缓存长达 24 小时 (max-age=86400),但设计人员刚刚提交了一个您希望所有用户都能使用的更新。 您该如何通知拥有现在“已过时”的 CSS 缓存副本的所有访问者更新其缓存?在不更改资源网址的情况下,您做不到。

浏览器缓存响应后,缓存的版本将一直使用到过期(由 max-age 或 expires 决定),或一直使用到由于某种其他原因从缓存中删除,例如用户清除了浏览器缓存。 因此,构建网页时,不同的用户可能最终使用的是文件的不同版本;刚提取了资源的用户将使用新版本的响应,而缓存了早期(但仍有效)副本的用户将使用旧版本的响应。

如何才能鱼和熊掌兼得:客户端缓存和快速更新?您可以在资源内容发生变化时更改其网址,强制用户下载新响应。 通常情况下,可以通过在文件名中嵌入文件的指纹或版本号来实现—例如 style.x234dff.css。

因为能够定义每个资源的缓存策略,所以您可以定义“缓存层次结构”,这样不但可以控制每个响应的缓存时间,还可以控制访问者看到新版本的速度。 为了进行说明,我们一起分析一下上面的示例:

  • HTML 被标记为“no-cache”,这意味着浏览器在每次请求时都始终会重新验证文档,并在内容变化时提取最新版本。 此外,在 HTML 标记内,您在 CSS 和 JavaScript 资产的网址中嵌入指纹:如果这些文件的内容发生变化,网页的 HTML 也会随之改变,并会下载 HTML 响应的新副本。
  • 允许浏览器和中间缓存(例如 CDN)缓存 CSS,并将 CSS 设置为 1 年后到期。 请注意,您可以放心地使用 1 年的“远期过期”,因为您在文件名中嵌入了文件的指纹:CSS 更新时网址也会随之变化。
  • JavaScript 同样设置为 1 年后到期,但标记为 private,这或许是因为它包含的某些用户私人数据是 CDN 不应缓存的。
  • 图像缓存时不包含版本或唯一指纹,并设置为 1 天后到期。

您可以组合使用 ETag、Cache-Control 和唯一网址来实现一举多得:较长的过期时间、控制可以缓存响应的位置以及随需更新。

缓存检查清单

在制定缓存策略时,您需要牢记下面这些技巧和方法:

  • 使用一致的网址:如果您在不同的网址上提供相同的内容,将会多次提取和存储这些内容。 提示:请注意,网址区分大小写。
  • 确保服务器提供验证令牌 (ETag):有了验证令牌,当服务器上的资源未发生变化时,就不需要传送相同的字节。
  • 确定中间缓存可以缓存哪些资源:对所有用户的响应完全相同的资源非常适合由 CDN 以及其他中间缓存进行缓存。
  • 为每个资源确定最佳缓存周期:不同的资源可能有不同的更新要求。 为每个资源审核并确定合适的 max-age。
  • 确定最适合您的网站的缓存层次结构:您可以通过为 HTML 文档组合使用包含内容指纹的资源网址和短时间或 no-cache 周期,来控制客户端获取更新的速度。
  • 最大限度减少搅动:某些资源的更新比其他资源频繁。 如果资源的特定部分(例如 JavaScript 函数或 CSS 样式集)会经常更新,可以考虑将其代码作为单独的文件提供。 这样一来,每次提取更新时,其余内容(例如变化不是很频繁的内容库代码)可以从缓存提取,从而最大限度减少下载的内容大小。

HTTPS 加密

HTTPS 全称 HTTP over TLS。TLS是在传输层上层的协议,应用层的下层,作为一个安全层而存在,翻译过来一般叫做传输层安全协议。

对 HTTP 而言,安全传输层是透明不可见的,应用层仅仅当做使用普通的 Socket 一样使用 SSLSocket 。

TLS是基于 X.509 认证,他假定所有的数字证书都是由一个层次化的数字证书认证机构发出,即 CA。另外值得一提的是 TLS 是独立于 HTTP 的,任何应用层的协议都可以基于 TLS 建立安全的传输通道,如 SSH 协议。

假设现在 A 要与远端的 B 建立安全的连接进行通信。

  • 直接使用对称加密通信,那么密钥无法安全的送给 B 。
  • 直接使用非对称加密,B 使用 A 的公钥加密,A 使用私钥解密。但是因为B无法确保拿到的公钥就是A的公钥,因此也不能防止中间人攻击。

CA

为了解决上述问题,引入了一个第三方,也就是上面所说的 CA(Certificate Authority)。 CA 用自己的私钥签发数字证书,数字证书中包含A的公钥。然后 B 可以用 CA 的根证书中的公钥来解密 CA 签发的证书,从而拿到合法的公钥。那么又引入了一个问题,如何保证 CA 的公钥是合法的呢。答案就是现代主流的浏览器会内置 CA 的证书。

中间证书

当然,现在大多数CA不直接签署服务器证书,而是签署中间CA,然后用中间CA来签署服务器证书。这样根证书可以离线存储来确保安全,即使中间证书出了问题,可以用根证书重新签署中间证书。

校验过程

那么实际上,在 HTTPS 握手开始后,服务器会把整个证书链发送到客户端,给客户端做校验。校验的过程是要找到这样一条证书链,链中每个相邻节点,上级的公钥可以校验通过下级的证书,链的根节点是设备信任的锚点或者根节点可以被锚点校验。那么锚点对于浏览器而言就是内置的根证书啦。请注意上文的说辞,根节点并不一定是根证书,下面会有说明。

校验通过后,视情况校验客户端,以及确定加密套件和用非对称密钥来交换对称密钥。从而建立了一条安全的信道。

HTTPS API

SSLSocketFactory

Android 使用的是 Java 的 API。那么 HTTPS 使用的 Socket 必然都是通过SSLSocketFactory 创建的 SSLSocket,当然自己实现了 TLS 协议除外。

一个典型的使用 HTTPS 方式如下:

URL url = new URL("https://google.com");
HttpsURLConnection urlConnection = url.openConnection();
InputStream in = urlConnection.getInputStream();

此时使用的是默认的SSLSocketFactory,与下段代码使用的SSLContext是一致的

private synchronized SSLSocketFactory getDefaultSSLSocketFactory() {
  try {
    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(null, null, null);
    return defaultSslSocketFactory = sslContext.getSocketFactory();
  } catch (GeneralSecurityException e) {
    throw new AssertionError(); // The system has no TLS. Just give up.
  }
}

默认的 SSLSocketFactory 校验服务器的证书时,会信任设备内置的100多个根证书。

TrustManager

上文说了,SSL 握手开始后,会校验服务器的证书,那么其实就是通过 X509ExtendedTrustManager 做校验的,更一般性的说是 X509TrustManager :

/**
 * The trust manager for X509 certificates to be used to perform authentication
 * for secure sockets.
 */
public interface X509TrustManager extends TrustManager {

    public void checkClientTrusted(X509Certificate[] chain, String authType)
            throws CertificateException;

    public void checkServerTrusted(X509Certificate[] chain, String authType)
            throws CertificateException;

    public X509Certificate[] getAcceptedIssuers();
}

那么最后校验服务器证书的过程会落到 checkServerTrusted 这个函数,如果校验没通过会抛出 CertificateException 。

很多博客说,配置 SSL 差不多是这样的:

private static synchronized SSLSocketFactory getDefaultSSLSocketFactory() {
    try {
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, new TrustManager[]{
                new X509TrustManager() {
                    public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {

                    }

                    public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
                    }

                    public X509Certificate[] getAcceptedIssuers() {
                        return new X509Certificate[0];
                    }
                }
        }, null);
        return sslContext.getSocketFactory();
    } catch (GeneralSecurityException e) {
        throw new AssertionError();
    }
}

好的,如果你这么用的话,随便什么证书你都会信任,网络毫无安全可言,可以随意的被中间人攻击,所以千万不要这样做。

SSL的配置

自定义信任策略

如果不清楚怎么配置 SSL ,最好的办法就是不配置他,系统会为你配置好一个安全的 SSL 。

但是如果用系统默认的 SSL,那么就是假设一切 CA 都是可信的。虽然 Android 系统自身可以更新信任的 CA 列表,以防止一些 CA 的失效。那么为了更高的安全性,我们希望指定信任的锚点,可以类似采用如下的代码:

// 取到证书的输入流
InputStream is = new FileInputStream("anchor.crt");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Certificate ca = cf.generateCertificate(is);

// 创建 Keystore 包含我们的证书
String keyStoreType = KeyStore.getDefaultType();
KeyStore keyStore = KeyStore.getInstance(keyStoreType);
keyStore.load(null);
keyStore.setCertificateEntry("anchor", ca);

// 创建一个 TrustManager 仅把 Keystore 中的证书 作为信任的锚点
String algorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(algorithm);
trustManagerFactory.init(keyStore);
TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();

// 用 TrustManager 初始化一个 SSLContext
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, null);
return sslContext.getSocketFactory();

那么只有我们的 anchor.crt 才会作为信任的锚点,只有 anchor.crt 以及他签发的证书才会被信任。

说起来有个很有趣的玩法,考虑到证书会过期、升级,我们既不想只信任我们服务器的证书,又不想信任 Android 所有的 CA 证书。有个不错的的信任方式是把签发我们服务器的证书的根证书导出打包到 APK 中,然后用上述的方式做信任处理。

仔细思考一下,这未尝不是一种好的方式。只要日后换证书还用这家 CA 签发,既不用担心失效,安全性又有了一定的提高。因为比起信任100多个根证书,只信任一个风险会小很多。

正如最开始所说,信任锚点未必需要根证书。

服务器下发证书不全

上文提到现在大多数的场景是根证书离线存储,使用二级证书签发服务器证书。而系统默认是只信任根证书的,因此就产生了一个小小的信任的缝隙。

如果服务器下发证书的时候没有发送一条证书链,而是只发了自己的证书,那么信任链就因为缺一环而导致校验会失败。

一般发现这种情况笔者只建议去联系运维的同学去配置服务器而不会在应用端做任何更改。

域名校验

Android 内置的 SSL 的实现是引入了Conscrypt 项目,而 HTTP(S)层则是使用的2.x的 OkHttp。

而 SSL 层只负责校验证书的真假,对于所有基于SSL 的应用层协议,需要自己来校验证书实体的身份,因此 Android 默认的域名校验则由 OkHostnameVerifier 实现的,从 HttpsUrlConnection 的代码可见一斑:

static {
    try {
        defaultHostnameVerifier = (HostnameVerifier)
                Class.forName("com.android.okhttp.internal.tls.OkHostnameVerifier")
                .getField("INSTANCE").get(null);
    } catch (Exception e) {
        throw new AssertionError("Failed to obtain okhttp HostnameVerifier", e);
    }
}

如果校验规则比较特殊,可以传入自定义的校验规则给 HttpsUrlConnection。

同样,如果要基于 SSL 实现其他的应用层协议,千万别忘了做域名校验以证明证书的身份。

证书固定

上文自定义信任锚点的时候说了一个很有意思的方式,只信任一个根CA,其实更加一般化和灵活的做法就是用证书固定。

其实 HTTPS 是支持证书固定技术的(CertificatePinning),通俗的说就是对证书公钥做校验,看是不是符合期望。

HttpsUrlConnection 并没有对外暴露相关的API,而在 Android 大放光彩的 OkHttp 是支持证书固定的,虽然在 Android 中,OkHttp 默认的 SSL 的实现也是调用了 Conscrypt,但是重新用 TrustManager 对下发的证书构建了证书链,并允许用户做证书固定。具体 API 的用法可见 CertificatePinner 这个类,这里不再赘述。