前言
关于网络编程这一块的内容,其实很早就想写一块的内容。毕竟网络编程这一块的内容是Android开发中,除了ui和framework以外,最常接触的模块。这个部分的知识是横跨所有的编程的知识栈。因此,我们必须深入的掌握这部分的内容。
本系列本来想放在Android重学系列的,毕竟后面的篇章会进入Android的Linux内核中解析socket的源码,不过考虑到这是共有的知识栈,也就独立出一个专题来总结。
特别是做后端的开发们,对这一部分的内容应该是了然于心,甚至对socket的底层源码都十分熟悉。而我们作为Android开发,socket作为经常接触Android的核心模块一部分,有什么理由不去探索一二呢?
本系列将会以OkHttp为核心,从Http协议一路到底层看看网络通信的过程中Linux内核都做了什么?在来看看腾讯的开源库mars中究竟都做了什么优化,能做到跨平台高并发的网络吞吐量压力?
其实在去年,我已经对这一部分进行了铺垫,写了https://www.jianshu.com/p/5061860545ef关于OKio的源码解析,有兴趣可以去看看。
这里也算是对去年,专门学习研究socket和网络协议一次系列总结。
如果遇到什么问题欢迎来到本文探讨https://www.jianshu.com/p/ba60ff3c56e6
正文
我们先来对整个网络通信协议有一个大体的了解,才好继续下去。一聊到网络通信协议,就不得不提及网络通信的七层协议,以及TCP/IP模型的五层协议
下面是一幅图:
这个基础模型就是网络通信协议的根本。如果不熟悉这个基础模型,去聊底层的代码那是不现实的。
我们先解释OSI七层协议的每一个意义:
物理层
物理层就是物理硬件层面上的接口,还记得在大学网络工程课程中做过的1-3,2-6交叉法做的水晶头接线。物理层就是这个物理层面上的链接。也就是在TCP/IP协议中标注的ethernet
端口
数据链路层
当只是两个电脑进行通信还好说,两个电脑通过接线头进行链接即可。但是一旦是3个以上的电脑,就需要如路由器,交换机,,集线器。但是这样就出现了一个很大的问题,三台电脑需要发送到正确的电脑,就需要解决如下几个问题:
- 1.数据包发给谁,怎么发送?
- 2.如果一起发,怎么保证这些数据不混乱
- 3.发送出错时候怎么办?
而这里就需要数据链路层,也就是常说的MAC(Medium Access Control
也就是多媒体控制访问
)层解决的问题。名字叫做多媒体控制访问,MAC层控制多媒体发送数据的时候,规定了什么数据先发,什么数据后发,防止出现了混乱,也就是解决了第二个问题,这个方式也叫多路访问。
多路访问分为三种方式:
- 1.信道划分 不同的数据包走不同的通道
- 2.轮作协议 不同的数据包传输通道按照一定的规律,轮流协作发送数据包
- 3.随机接入协议 尝试先发送,但是发现网络环境拥堵,则先阻塞,等到通畅后在发送
聊到mac层,就肯定会聊到mac层的数据封包,而mac的数据封包就是为了解决第一个问题和第三个问题,数据发给谁怎么发,发给谁,怎么考验是否错误。
在每一台机器的网卡中都存在一个物理地址,这个物理地址可以说在以太网中唯一的网卡机器标识(绝大部分情况),能通过这个标识找到需要通信的目标。
- 源mac地址就是指 发送网络请求的mac地址
- 目标mac地址就是指需要通信的mac地址。
- 类型 则是指当前数据中,保存的数据类型是什么?是IP报文还是ARP报文
- CRC冗余校验 通过XOR算法,来确定发送的过程中是否发生了错误。
有没有想过如果此时,电脑是第一次接入到网络,它并不知道目标的mac地址怎么办?
ARP协议
此时就需要一个叫做ARP协议
了。但是ARP协议
也不是单独可以运行,还需要知道后面数据中的IP地址,两个联动起来才能正确的找到需要通信起来。
换句话说就是已知IP地址,求目标的mac地址。
整个流程如下图:
只有经历了这个过程,第一次接入网络,才能正确的找到需要通信的ip地址对应的mac地址。
VLAN 协议
当出现更大规模的网络接入,如一个办公大楼接入同一个网络之后。如果都是接入同一个交换机,太多的消息都经过同一个交换机的局域网很容易被抓包,那么就没有什么安全性。
解决这个的方案有两种:
- 1.物理隔离 多弄一台交换机,分成两个局域网,但是不好控制容易浪费
- 2.虚拟隔离 也就是VLAN协议,构建多个虚拟局域网
整个协议如图
在原来的二层上新增一个TAG,这个TAG里面有一个VLAN ID
,这个ID 有12位,也就是有4096
个VLAN。如果交换机是支持VLAN
,就会把这个TAG取出来获取其中的VLAN ID
并识别。只有相同的VLAN ID
包才能互相转发,不同的VLAN
包是不能互相看到的。
交换机之间有一个Trunk
口,互相链接交换机。
网络层
IP地址
有了mac地址,可能不少人就觉得,因为它是唯一的所以可以进行全局的通信了,就以寻找mac地址为基准。
然而,这是不可能实现的。mac地址就像一个人的身份证一样,但是我们想要找到一个人除了知道找谁之外,还需要知道这个人住哪里才行。这就需要网络成的IP地址了。
有了IP地址才知道需要往哪里通信,通过IP地址找到对应的地方后,就需要通过mac地址找到具体的人在哪里
IP地址的组成往往分为5种类型:
我们常用的A,B,C三类网络地址。前一部分位网络号,后一部分为主机号。相当于住在4单元406号房一个意思。主机号可以看成一个个主机接入的序列。如果是按照这种设计,很容出现过于浪费的情况。
特殊的D类网络地址,属于组播地址,一般是用于如邮件往某个邮件组发送是的这个邮件接受组都能接收到。
比如B类地址,就有65534个主机号,也就是能接入这么多电脑,不就浪费了这么多的位置吗?
所以诞生了一个无类型域间选路(CIDR)
的概念。
一般的,一个CIDR的IP地址会写成如下格式:
192.168.0.1/24
24代表32位IP地址中前24位为网络号,最后9位代表主机号。伴随着CIDR,存在一个所有人都能监听到的广播地址,与一个子网掩码。
比如一个网络号10.100.122
那么发送一个数据包往10.100.122.255
所有主机都能监听到。
子网掩码是用来计算一个CIDR 中IP地址的网络号。比如子网掩码为255.255.255.0
,代表头24位就是网络号。
IP报文结构
有了IP地址还不够,还需要更多的信息才能正确的定位。当我们发送一个邮件,需要什么呢?发件人,发件人地址,收件人以及收件人地址,以及邮件内容。
转化过来就是需要源IP地址,目标IP地址,以及传输过来的内容,由于网络环境很复杂还需要一些版本号,校验码等数据。就有了下面这幅著名的IP报文结构图:
所谓IP报文实际上就是在MAC层的封装基础上填写更多的内容。
有了IP报文之后,我们就可以往更广大的网络世界进行通信了。
IP 路由协议
路由协议,实际上可以和Android 开发中的Router的概念做对比,一个页面对应一个url路由。而这里则是不同局域网不同的路由。
当我们想要访问一个服务器网卡时候,会经过网关,就会尝试的判断是否在网关同一个网段(也就是网络号)。
- 在同一个网段,则使用
ARP
协议,尝试查找这个局域网内对应的主机 - 不是同一个网段,就会先发往默认的网关,这个默认网关一定和发送者处于同一个网络号。过程是先通过
ARP协议
找到网关,接着把MAC地址和网关的MAC地址封装起来,然后网关发送出去。
路由协议分为两大类:
- 静态路由
- 动态路由
静态路由
实际上就是在路由器上记录一条条规则,比如想要访问A,需要从哪个端口出去,下一个IP地址是谁。
在这个过程中,会遇到两种网关:
- 转发网关 不会改变IP地址
- NAT网关 会改变IP地址
因此就会出现两种情况:
1.网关之间IP地址是可见的:
每一次经过一个网关,都更换目标MAC地址以及源MAC地址,而保留IP地址来记录原本的发送方和接收方是谁。2.网关之间IP地址是不可见的:
首先每一个服务器在因特网内有一个ip地址,当然这个服务器在自己的局域网内有另一个面向局域网的ip地址。
当服务器A想要访问服务器B的时候,经历两种网关,一个是转发网关路由器A,一个是NAT网关路由器B:
核心就是不断的切换MAC地址,抵达下一个网关。而在静态路由表中记录了国际IP地址和本地局域网IP地址的映射,在进出NAT网关的时候进行一次切换,可以直接找到。
核心是根据目的 IP 地址来配置路由。
静态路由表,NAT网关映射一般都是配置在IPTable中,进行查询的。
$ ip route list table main
60.190.27.189/30 dev eth3 proto kernel scope link src 60.190.27.190
183.134.188.1 dev eth2 proto kernel scope link src 183.134.189.34
192.168.1.0/24 dev eth1 proto kernel scope link src 192.168.1.1
127.0.0.0/8 dev lo scope link
default via 183.134.188.1 dev eth2
比如说运营商静态的写入给这个路由器写下了如上规则:
给网卡eth2 分配了国际IP地址为183.134.189.34
,对应的网关是183.134.188.1
因此到了不同的网关,就对应了不同的全局IP地址。注意一个路由器能配置多个网关
动态路由
然而这种写死的路由表策略往往不足以应付现在的网络的复杂情况。
动态路由实际上是指动态的在整个网络中构建整个通信结构,而不是写在命令ip route中。
动态构建ip 网络关系图有两种算法:
1.距离矢量路由算法
每一个路由器中都包含了全局的路由表。每隔一段时间就同步一下局域网内路由表的链接状态。有了这个表就能知道如何通过最短的路程找到目标地址。这种的问题是,好消息传输快,坏消息传输慢。这么一个场景,比如说有某个主机失联了,此时需要访问所有路由器之后,拿到全局的路由表,才知道这个主机原来断开链接了。
一般应用于 外网之间的路由,又称为外网路由协议(简称 BGP)
。为什么外部变化大的反而使用这种收敛坏消息速度慢的协议呢?
你可以想想在一个国家中有数量有限的大型自治系统成为AS
。
而AS
分为3种:
1.
Stub AS
这种AS系统对外链接只有一个,不会传输其他AS
的包。如个人和小公司网络2.
Multihomed AS
这种AS系统可能链接多个AS系统,但是拒绝帮助他AS传输包3.
Transit AS
这种AS系统相当于高速公路,会链接多个AS系统,也会帮助其他AS传输包
每一个AS 自治系统都有自己的边界路由器,使用这种路由器和外界交互。
说回来,现在运用的BGP
分为两种eBGP
和iBGP
。自治系统之间使用eBGP
进行交互,而自治系统内部使用iBGP
使得内部路由找到抵达外网目的地最好的边界路由器。
正因为这些大型的自治系统不容易变更,所以反而使用这种方式会更好。
2.链路状态路由算法
当前路由器把自己和邻居路由表的关系发送出去,路由器会接受每一个路由器邻居的关系构建出一个路由表。
常用的对应算法是OSPF(Open Shortest Path First,开放式最短路径优先).一般用于数据中心内部进行决策,因此也叫内部网关协议
传输层
从这里开始就是日常开发耳熟能详的协议都在这里了,比如TCP,UDP,用于ping的ICMP等。当然还有更加快速基于UDP开发的QUIC,已经经常用于流媒体的协议RTMP都在这一层。
这里我们先只讨论TCP和UDP,关于RTMP的我会放到后面音视频模块中讲解。
TCP和UDP之间有什么区别?
TCP会建立三次握手的链接,而UCP不会。所以就有说法说,TCP面向链接,UDP面向无链接。
所谓的链接,就是为了维护客户端和服务端之间的链接而建立的数据结构来维护双方的状态,用这样的数据结构来保证面向链接的特性。
更加仔细的区别大致有如下几点:
1.TCP提供可靠交付。通过TCP链接传输的数据,无差错,不丢失,不重复,按顺序达到;UDP继承了IP包的特性,不保证不丢失,不保证顺序。
2.TCP面向字节流,发送的是一个流没头没尾,这是因为TCP在维护自己的状态。而UDP则是基于IP报文,一个个发送,一个个接受。
3.TCP有阻塞拥塞控制,通过阻塞窗口来进行限制发送流量。而UDP都没有,要发送就发送
4.TCP是一个有状态的服务,能够精确的记录那些包发送了,接受了。而UDP则是无状态服务。
UDP
UDP的结构如上。
UDP的使用场景
一般来说UDP经常用于如下场景:
1.需要资源少,内网情况很好的,对于数据丢包不敏感。比如一些硬件通过udp联检到app中,发送一些对时效不太重要的数据
2.不需要一对一沟通,建立链接,而是进行广播
3.需要处理速度快,时延低,可以容忍少数丢包,就算是网络拥塞也直接发送。
QUIC
正因为有这种特殊特性,所以诞生了QUIC协议。我记得这个协议还是2年前一个微信哥们和我提到过,说他们那边研究过这个觉得很不错。这是Google基于UDP协议上进一步开发的,目的是为了减少延时。
为什么QUIC能办到呢?其实原理很简单。这也是分场景的。因为移动App现在都是基于Http协议,而Http协议又是基于TCP的。那么就需要阻塞拥塞窗口进行流量控制。
那么问题就来了,因为是移动设备,和主机设备不一样。主机设备如果发现拥塞窗口阻塞的厉害那么可以认为当前的通信到服务器的网络环境比较拥挤,需要让一点资源让服务器那边反应过来。
但是移动设备往往是移动的,可能动着动着出现网络环境不好的情况,那么每一次断开TCP的握手,又重新握手就会出现延时十分厉害。
因此诞生了QUIC,QUIC在UDP快速的基础上,再加上一些校验的逻辑。这部分内容看看后面有没有想法,可以和大家聊聊底层设计。
RTMP
因为这种机制,在流媒体中也迅速普及UDP的使用,如RTMP协议。因为老得视屏帧数丢了也就丢了,在直播这些流媒体领域如何追求画面的实时同步才是更加重要的考察点。
当然还有游戏中传送包等情况。
TCP
先来看看TCP的数据结构:
一聊到TCP就一定会聊到3次握手和4次挥手。在我们大学的网络编程的课本中经常出现下面2幅图:
TCP三次握手
三次握手中发生了几次状态的变化。其实也是保证了包的顺序以及应答之间的状态。
总的来说就是三个步骤:
- 请求
- 应答
- 应答之应答
实际上就是为了应付复杂的网络环境,而出现的一种保证机制。当然有人会稳为什么不是4次,5次呢?实际上确实可以这样下去,甚至40次都可以。但是只需要保证客户端A确保链接上了服务器B,B就会立即发送数据的流程即可。
图中seq就是代表了当前发送包的序列,每一个包的序列默认来说都是每次递增1.所以可以通过这个规律知道包那些的发送漏了,从而在底层进行排序,排序好后在发送到上层。
整个流程如下:
A发起
SYN
后,就进入了SYN_SEND
状态B收到
SYN
数据包后,返回SYN(属于自己的序列y)
,以及ack(客户端上一个包的序列+1)
,然后处于SYN_RCVD
状态。ack
记录了客户端接受到的包应该消费的序列号A收到B的应答也就是服务端的
SYN
和ACK
后,返回AC
K给服务端。这个过程seq
是从B收到的ack
的序列 以及ack
中记录服务器上一个包的seq序列
加1。
这里ack
记录了服务器收到客户端消息后应该对应的序列号
换句话说,就是通过拿到对方的ack来校验本次seq的序列是否正确。
TCP四次挥手
在4次挥手整个流程如下:
1.客户端A传送完数据后,决定关闭TCP的通信。则发出一个
FIN
和seq(序列号)为q
给服务器B,进入FIN_WAIT_1
的状态。2.B收到了A的
FIN
信息后,进入到CLOSED_WAIT
。接着发送ACK
ack为p+13.当A收到了
ACK
之后,就进入到了FIN_WAIT_2
状态,等待服务器发送下一个状态。这一次接受ACK实际上就是拿到刚才的A发出的应答,说明服务B接收到这个关闭消息,但是此时服务器B可能还需要处理点事情才能正式关闭这条链路,所以还需要等一次。如果B服务器不发送后续的自己关闭的状态,则会一直停在这个状态中,但是在Linux中会有一个超时时间进行设置。4.如果B没有结束,则发送一个
FIN
和ACK
以及seq为q
以及ack=p+1
.进入到了LAST_ACK
状态5.A客户端之后知道B也发送了B即将关闭的信息后,A接收到到之后则进入到
TIME_WAIT
状态。等待时间为2MSL(2个单位的最大报文生存时间,协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟等
)。发送一个ACK
ack为q+1。经过2MSL后,进入到CLOSED
状态
这个过程的行为原因有2点:
1.A如果此时直接走人了,此时TCP链接需要A等待一个
TIME_WAIT
的时间,这个时间需要足够长。如果B没有收到A发送的ACK消息,则B会重新发送FIN
和ACK
也就是第4部的过程。等到B发送的包都死掉了,再关闭2.A直接走人,那么A的端口就空出来了。B不知道还是继续发送到这个端口,就会出现发送错应用的问题
如果这个过程中,B超过了2MSL的时间,都没有收到A发送的FIN的ACK
包,就不等了,直接设置为RST
关闭这个口
- 6.当服务器B收到了A发送的
ACK
则进入到CLOSED。
这个过程seq的转化,首先是A往B通信,所以第一次为p,第二次B回应了一次ACK
之后就是p+1.
其次,是B往A通信,所以seq重新设置为q,ack代表应答的是p+1。此时A收到后不需要B处理所以seq不需要设置,设置为p+1告诉B这个p+1对应序列号的消息已经应答了。
拥塞窗口
.一开始窗口只有一个mss
大小叫做慢启动。接着翻倍的增长窗口大小,直到ssthresh
临界值,之后就变成线性增长。这个过程我们成为拥塞避免
。当出现丢包的时候,就说明网络环境开始变得紧张,就会开始调整窗口大小。
拥塞窗口有两种调整窗口大小的逻辑:
-
- 将窗口大小重新调整为1个
mss
,重新经历翻倍和线性增长。
- 将窗口大小重新调整为1个
- 2.将当前的窗口大小调整为当前的一半,重新以线性进行增长。
通过这种方式调整客户端的发送包的速度。
滑动窗口
在TCP中,控制包的发送主要是通过拥塞窗口分为如下几个部分进行管理,从而得知缓冲队列哪些包发送且确认回收了,哪些包发送了在等待服务器回收,哪些包准备发送,哪些包不能发送。
在这里面有一个滑动窗口的概念,控制哪些发送的包行为。具体在后文会聊到。
Socket
而Socket 套字节就是面向开发者最常用的api,而这个api实际上是四层协议也就是传输层的api封装。我们可以在socket中选择对应的协议去执行不同的传输层TCP还是UDP的协议。不过更多的还是关注TCP和UDP相关的内容。更加详细的内容会在之后解析源码中放出。
应用层
Http协议与Https协议
对于Http协议还是Https协议,都是我们应用开发接触频率最高的。简单的来说Https协议就是在Http协议的基础上进行了加密安全保护。
无论是哪种协议,一旦聊起来,我们必定会聊到的下面这两幅Http协议结构图:
Http请求的结构:
都是很熟悉的内容:
大致分为三个部分:
请求行 里面保存当前的请求方式(get,post,put,delete)等;URL资源路径;Http协议版本
首部 里面就是我们经常用请求头。里面包含了用于设置客户端可接受的字符集
Accept-Charset
;正文格式Content-Type
(如Json,xml等);用于控制缓存的Cache-control
(当客户端存在max-age
则比较资源缓存的时间和max-age
的大小,资源缓存的小(没有超出缓存失效)则客户端可以接受缓存资源,如果为0则直接交给服务器应用获取最新资源);If-Modified-Since
也是关于资源缓存,如果资源更新了则下载最新资源,没有更新则返回304让客户端处理。实体 里面包含了请求的数据,如Post就会在里面设置数据字节内容
Http响应的结构:
整个结构和Http的请求结构很像。实际上变化的是从请求行变成了状态行,其他都是类似的,不过是从客户端设置的内容变成服务端设置的内容。
一般来的,我们开发最重点关注的还是状态码,这里列一下简单的例子:
- 200 代表请求成功返回
- 304 代表结果没变,请从缓存读取
- 404 代表资源找不到,一般是url输出错误
- 500 代表服务异常
当然还有其他的,如重定向等。这部分内容放在OkHttp的解析,来看看这个库是怎么处理的。
Http 1.1
Http 1.1是基于Http 1.0的基础上发展过来的。
HTTP 1.0规定浏览器与服务器只保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接,服务器完成请求处理后立即断开TCP连接,服务器不跟踪每个客户也不记录过去的请求。
HTTP 1.1的持续连接,也需要增加新的请求头来帮助实现,例如,Connection请求头的值为Keep-Alive时,客户端通知服务器返回本次请求结果后保持连接;Connection请求头的值为close时,客户端通知服务器返回本次请求结果后关闭连接。HTTP 1.1还提供了与身份认证、状态管理和Cache缓存等机制相关的请求头和响应头。
Http 2.0
Http 2.0是基于Http 1.1的基础上发展过来的。
Http 1.1是以纯文本的形式进行传输,每一次都会带上完整的Http头部不考虑pipeline模式,每一次都是完整的一来一回不断的发出了重复的部分,这样对实时性上存在不少问题。
因此Http 2.0在1.1之上做了如下改进:
1.Http 2.0 会对Http的头部进行压缩,原来的头部(首部)持有了大量的key和value,在2.0中会建立一个索引表,对相同的头部只会发送这个索引表
2.Http 2.0 会将一个TCP链接中切分为多个流,每个流有自己的ID,这个流可以从客户端发送给服务器,也能从服务器发送给客户端(也就是流的复用)
3.Http 2.0 将传输信息分为更小的消息和帧,并对他们采用二进制格式编码。常用的帧为Header帧,用于传输Header的内容并开启新的流。接着就是Data帧用于传输正文实体内容(多个Data同属一个流)
通过上面三点,Http 2.0把多个请求划分在不同的流中,把内容拆分成为帧进行二进制传输。这些帧可以打乱顺序传输,最后根据帧的首部流标识符。
下面是一个示意图:
在这个过程中服务器和客户端不需要再对请求一一对应,可以同时处理多个请求和应答。
实际上是在把三次串行的请求转化成三个流,将数据分成帧乱序发送。
每一个数据帧的数据格式为:
注意Type代表当前的数据帧是什么类型,Flag代表当前数据帧是什么状态,StreamID当前数据帧是属于哪一个复用流的,Frame PayLoad就是当前数据帧所荷载的内容。
所有的这些都能先通过长度获取到后整个数据帧的内存范围后再逐步解析。
Https 协议
再聊Https协议之前需要明白下面两个知识点:
加密
对称加密 就是指客户端和服务端公用一个密钥,通过这个密钥加密和解密获得网络请求传输内容。对称加密保密性不好容易被破解,对称加密如果是通过网络请求获取的,就容易被拦截获取到。
非对称加密 分成一对密钥,有公钥和私钥。一般是通过公钥加密,私钥解密。这样就能将公钥往外传,自己保护好私钥就不容易被破解。
数字证书
不对称加密如何把公钥发送出去呢?这又牵扯到另一个概念,数字证书CA。
公钥发送出去要么就是放在公网的某个地址让人下载,要么就是请求的时候下载。
现在公认一套流程是借助一个权威网站,从这个权威网站中下载公钥。而这个权威网站下发的东西就是我们常说的证书
。证书
中包含了证书所有者,发布机构以及日期。
而发布证书的权威网站就是我们常说的CA
( Certificate Authority)。
证书请求可以通过这个命令生成:
openssl req -key cliu8siteprivate.key -new -out cliu8sitecertificate.req
将这个请求发给权威机构,权威机构会给它盖一个章也就是使用签名算法生成一个签名:
openssl x509 -req -in cliu8sitecertificate.req -CA cacertificate.pem -CAkey caprivate.key -out cliu8sitecertificate.pem
而 cliu8sitecertificate.pem 就是签过名的证书.
里面有个 Issuer(谁颁发的这个证书);Subject(证书发给谁的);Validity(证书有效期限);Public-Key(公钥内容);Signature Algorithm 是签名算法。
怎么保证这个权威机构是没问题的呢?那么就需要更加上层的权威机构给这个权威机构添加一个CA证书。在这一层层的嵌套上,就有一个Root CA作为最全球最为权威的机构。通过这种层层授权,才让非对称模式在互联网上流行。
所以说,看见有的网站需要你添加Root CA到自己的电脑时候请注意了,一旦根CA出现了问题,之后Https也会变得不安全。
Https通信模型
DNS
当网络的七层协议都了解之后,可以聊聊其他常用的概念,如DNS。
一般来说我们平时访问网站的时候不是通过ip地址访问,而是通过一个url域名访问的。而映射ip地址和url的地址薄是通过一个名为DNS的转化的。所以DNS很重要通常设置为高并发,高可用,分布式的。
DNS分为三种:
- 根DNS 返回顶级域的DNS服务器 IP地址
- 顶级域DNS 返回权威DNS服务器IP地址 (如.com ,.net,.cn)
- 权威DNS 返回相应主机的ip地址
解析流程:
1.客户端访问一个www.163.com,就会发出DNS请求给本地域名服务器 (本地 DNS),请求这个网站对应的IP地址什么?本地 DNS是指 如果是通过DHCP配置那么本地DNS就是由移动运营商配置的某个机房中一个主机
2.本地 DNS 收到来自客户端的请求.就会从本地缓存表中查找。如果没有就去根DNS中询问。根DNS不会告诉你具体的ip而是指明一个道路。
3.根DNS接受到本地DNS的请求后,查询后缀,把请求交给顶级域DNS中处理
4.本地 DNS 转向问顶级域名服务器,而顶级进一步的解析这个url地址就会进一步的确定到权威DNS服务器是哪个?
5.最后本地DNS访问权威DNS服务机,最终拿到www.163.com的ip地址返回
HttpDNS
传统的DNS访问会遇到如下几个问题:
1.域名缓存问题 因为外部的url可能和缓存的ip地址映射对不上,可能会返回过时的IP地址,导致访问错地方。运营商会把一些静态页面,缓存到本运营商的服务器内,此时也会因为缓存指向了老的页面。
2.域名转发问题 如果是 A 运营商的客户,访问自己运营商的 DNS 服务器,如果 A 运营商去权威 DNS 服务器查询的话,权威 DNS 服务器知道你是 A 运营商的,就返回给一个部署在 A 运营商的网站地址,这样针对相同运营商的访问,速度就会快很多。但是A运营商偷懒直接把请求交给B运营商处理,那么权威服务器就会以为是A运营商管理的路由,就会一直访问A运营商导致慢
3.域名更新 本地DNS是不同运营商部署的,解析的逻辑上也有区别。有的会忽略掉TTL的超时限制,导致权威DNS解析变更时候过慢。
4.解析延迟问题 可能会遇到多层DNS服务器,由于是递归导致耗时严重
为了解决这个问题,就诞生了HttpDns。HttpDns本质上就是在自己客户端搭建一个基于Http协议本地的DNS服务器集群,自己做映射缓存。一般都是手机端自己使用,手机端想要使用就需要手机添加HttpDNS的SDK。
工作流程也很简单,如果访问一个地址,则先从自己的缓存访问是否有缓存,有则返回。至于什么时候过时,那就是自己进行设置。如果本地没有,则需要访问HttpDNS的服务器,在本地HttpDNS服务器中IP列表中,选择一个发出Http请求,访问UP地址。
手机客户端自然知道手机在哪个运营商、哪个地址。由于是直接的 HTTP 通信,HttpDNS 服务器能够准确知道这些信息,因而可以做精准的全局负载均衡。
总结起来就是解决亮点问题:
- 1.解析速度和更新速度上的问题
- 2.智能调度上的问题
Okhttp的设计
Okhttp可以分为如下七层协议:
- 1.retryAndFollowUpInterceptor 重试拦截器
- 2.BridgeInterceptor 建立网络桥梁的拦截器,主要是为了给网络请求时候,添加各种各种必要参数。如Cookie,Content-type
- 3.CacheInterceptor 缓存拦截器,主要是为了在网络请求时候,根据返回码处理缓存。
- 4.ConnectInterceptor 链接拦截器,主要是为了从链接池子中查找可以复用的socket链接。
- 5.CallServerInterceptor 真正执行网络请求的逻辑。
- 6.Interceptor 用户定义的拦截器,在重试拦截器之前执行
- 7.networkInterceptors 用户定义的网络拦截器,在CallServerInterceptor(执行网络请求拦截器)之前运行。
头三层协议专门用于处理状态码重试缓存几种情况:
Http响应状态码大致上可以分为如下几种情况:
2XX 代表请求成功
200,203,204 代表请求成功,可以对响应数据进行缓存
30X 代表资源发生变动或者没有变动
300 是指有多种选择。请求的资源包含多个位置,此时请求也可以看作成功,此时也会进行缓存起来。此时也会记录下需要跳转Header中的Location,并重新设置为全新的跳转url。记住这个过程是先执行了缓存拦截器后,再执行跳转拦截器。
301 请求的资源已经永久移动了 会自动重定向。此时还是一样会缓存当前的结果后,尝试获取Location的url 进行重定向(Http 1.0内容),不允许重定向时候改变请求方式(如get转化成post)
302 代表临时移动的资源,所以没有特殊处理并不会缓存结果,因为这个响应数据很可能时效性很短;但是如果设置了
Cache-Control
,Expires
这些缓存时效头部就会进行缓存,接着会获取Location的url 进行重定向(Http 1.0内容),不允许重定向时候改变请求方式(如get转化成post)303 代表查看其他资源,而这个过程可以不视作一个正常的响应结果,也因为允许改变请求方式;因此也不会进行缓存,接着会获取Location的url 进行重定向.
304 代表资源没有发生变动,且缓存策略是允许刷新的。那么就说明服务器这段时间内对这个请求的应答没有变化,客户端直接从缓存获取即可。此时客户端就会从缓存拦截器中的缓存对象获取缓存好的响应信息。
307 同302 也是一个临时移动资源的标志位,不同的是这是来自Http 1.1协议。为什么出现一个一样的呢?因为302在很多浏览器的实现是允许改变请求方式,因此307强制规定不允许改变
308 同301 是一个永久移动的资源路径,来自Http 1.1.原因也是因为强制规范不允许改变请求方式,但是允许进行缓存。
4XX 客户端异常或者客户端需要特殊处理
401 请求要求用户的身份认证。这个过程就会获取设置在Authenticator 中的账号密码,添加到头部中重试这个请求。
403 代表拒绝访问,okhttp不会做任何处理直接返回
404 代表客户端请求异常,说明这个url的请求状态有问题,okhttp也会进行缓存学习,下一次再一次访问的时候就会直接返回异常。
405 代表当前请求的方式出错了,这个请求不支持这种请求方式
407 和401类似 不过在这里面代表的是使用代理的Authenticator. authenticate 进行账号密码的校验
408 服务器等待客户端发送请求超时处理 状态码408不常见,但是在HAProxy中会比较常见。这种情况说明我们可以重复的进行没有修改过的请求(甚至是非幂等请求),从头部中获取对应的key,从而决定是否立即重试
410 代表资源已经不可用了,此时okhttp也会学习,缓存这个结果直到超过缓存时效。
414 代表请求的URL长度超出了服务器可以处理的长度。很少见这种情况,这种也是数据一种异常,所以okhttp也会获取摘要学习
421 代表客户端所在的ip地址到服务器的连接数超过了服务器最大的连接数。此时还是有机会进行重新请求,因为在Http 2.0协议中允许流的复用。
5XX 服务端异常
500 服务端出现了无法处理的错误,直接报错了。 这种情况不会做处理,直接抛出错误即可
501 服务端此时不支持请求所需要的功能,服务器无法识别请求的方法,并且无法支持对任何资源的请求。 这种错误okhhtp可以缓存学习,因为是服务器的web系统需要升级了。
503 服务器过载,暂时不处理。一般会带上
Retry-After
告诉客户端延时多少时间之后再次请求。然而okhttp不会做延时处理,而是交给开发者处理,他只会处理Retry-After
为0的情况,也就是立即处理504 一般是指网关超时,注意如果okhttp禁止了网络请求和缓存也会返回504
retryAndFollowUpInterceptor
主要处理了如下几个方向的问题:
- 1.异常,或者协议重试(408客户端超时,权限问题,503服务暂时不处理,retry-after为0)
- 2.重定向
- 3.重试的次数不能超过20次。
BridgeInterceptor
主要是把Cookie,Content-type设置到头部中。很多时候,初学者会疑惑为什么自己加的头部会失效,就是因为在Application拦截器中处理后,又被BridgeInterceptor 覆盖了。需要使用networkInterceptor
CacheInterceptor
主要是处理304等响应体的缓存。通过DiskLruCache缓存起来。
到这里前三层属于对Http协议处理的拦截器就完成了,接下来几层就是okhttp如何管理链接的。
ConnectInterceptor
ConnectInterceptor 链接拦截器在okhttp中做了如下几件事情:
1.尝试从ConnectionPool 中获取可以进行多路复用的socket链接(当然需要http 2.0协议的请求)
-
2.从ProxySelector 中获取直连或者代理的资源路径,在这个过程中,会处理三种情况:
- 2.1 如果是
Proxy.Type.SOCKET
则通过InetSocketAddress.createUnresolved
解析获取到InetSocketAddress
对象 - 2.2 如果是
Proxy.Type.DIRECT
或者Proxy.Type.HTTP
则通过Address的DNS方法调用lookup 查询 host对应对应的ip地址,生成InetSocketAddress
对象
- 2.1 如果是
-
3.当拿到了该资源路径对应所有的ip地址,(不是一对一,是因为可能是408等情况存在了负载均衡实际关联不同的服务器),并开始尝试链接。
- 3.1 如果是简单的知道了当前的代理模式是非Http代理模式,那么就会直接调用socket.connect 进行链接
- 3.2. 如果当前的代理模式是Http代理模式,那么会先先构造一个虚假的请求和应答,交给本地的proxyAuthenticator 尝试获取该代理服务器的需要的账号和密码,这样就不会出现407/408等权限异常再重新进行一次请求。当获取好后就添加到头部,并且调用socket.connect.
- 3.3 获取socket的输入输出流,保存在全局。
-
- establishProtocol 尝试这处理具体的协议,这个方法主要处理了Http 1.0和Http 2.0。
-
4.1. Http 1.0 存入了SSLSocketFactory对象,说明允许进行TLS/SSL 的加密传输(一般都存在一个默认的对象)。就会依次执行握手过程,依次为:
- 4.1.1. 根据原来的socket对象,通过SSLSocketFactory 包裹生成一个新的sslSocket对象
- 4.1.2. sslSocket.startHandshake sslsocket对象开始进行握手
- 4.1.3. 获取sslSocket.session 对象,调用他的handshake方法,开始握手
- 4.1.4. 从Address的certificatePinner 中检索握手成功的证书,保证合法信任并且是X509Certificate
-
4.2. 如果是Http 2.0 除了进行connectTls的操作之外,还会开始依次传输如下三种类型的http 数据帧:
- 4.2.1. TYPE_PING 代表当前链接还活跃着,在RFC说明协议中,说明这个协议优先级是最高,必须先发送。
- 4.2.2 往服务端发送序言,说明客户端的流开始传递的了,会发送如下的数据:
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
- 4.2.3. TYPE_SETTINGS 把当前的okhttp设置的客户端的设置,如一个数据帧最大的负载容量是多少(默认为16kb),当前的允许控制的最大(默认是Int的最大值相当于不限制)交给服务器,服务器会把客户端的配置合并起来,进行对客户端的适配
- 4.2.4 TYPE_WINDOW_UPDATE 严格来说这个也是保存在settings中的,然而服务器只会读取前6个数组,刚好就没有读取这个所有复用并发流窗口大小。而是专门通过TYPE_WINDOW_UPDATE 和初始的65535大小的并发流窗口大小进行调节
到这里就完成了整个ConnectInterceptor 的工作。比起http 2.0的协议初始化,我们更需要关注的是,这个过程中由如下两个核心的方法:
- dns lookup 把资源地址转化为ip地址
- socket.connect 通过socket把客户端和服务端联系起来
- socket.starthandshake
- socket.handshake
CallServerInterceptor
先来看看Okhttp的管理活跃链接
实际上是由一个RealConnectionPool 缓存所有的RealConnection。实际上对应上层来说每一个RealConnection就是代表每一个网络链接的抽象门面。
而实际上真正工作的是其中的Socket对象。整个socket链接大致可以分为如下几个步骤:
- dns lookup 把资源地址转化为ip地址
- socket.connect 通过socket把客户端和服务端联系起来
- socket.starthandshake
- socket.handshake
这四个步骤都是在ConnectionInterceptor 拦截器中完成。
虽然都是RealConnection对象,但是分发到CallServerInterceptor之前会生成一个Exchange对象,其中这个对象就会根据Http1.0/1.1 或者Http2.0 协议 对应生成不同的Http1ExchangeCodec 以及 Http2ExchangeCodec. 这两个对象就是根据协议类型对数据流进行解析。
无论这两个协议做了什么,都可以抽象成如下几个方法:
1.Exchange.writeRequestHeaders http1中就是把请求行和头部写入了socket临时缓冲区;http2就是把代表Header的数据帧数写到okio临时缓冲区。
2.Exchange.readResponseHeaders http1情况下如果没有请求体,那么则是尝试的读取响应体中的状态行头部等数据;如果是http2则是等待读取从服务端传递过来的头部数据帧数据到缓存队列中。
3.Exchange.createRequestBody http1则是获取ChunkedSink一个写入流;http2则是获取一个FrameSink写入流。
4.requestBody.writeTo 往createRequestBody创建的写入流写入数据。
5.Exchange.finishRequest 把请求体等数据一口气上传到服务端
6.Exchange.openResponseBody 获取响应体的读取流保存到Response对象中。当需要获取时候,就调用toString就会读取读取流的数据转化为字符串。
后话
有了这些基础后,我们再从OkHttp开始阅读源码,看看这个手机端最出名的网络请求库是怎么设计的。
本文不是最终版本,之后会陆续更新详细。