从本文开始,我们正式开始Java的网络编程。
还是老规矩,在开始写第一行Socket代码之前,我们讲解一些前置知识。
今天,我们就讲讲网络编程避不开的TCP/IP协议。
扯两句闲话,记得当年腾讯校招一面的时候,面试过程中有这样一个问题:
简单描述一下我们在浏览器中输入域名回车后发生了什么?
那时候我面的是网络工程师岗位,一听到这题,那自信马上就来了,从OSI七层模型扯到了TCP/IP四层模型,从TCP三次握手讲到四次挥手,从DNS解析聊到网卡、路由器。
我感觉我能通过一面完全靠的就是这道题跟面试官的神吹鬼吹。(其实在楼下等面试的我都还在看这个过程,算是正中下怀了)
后来转码之后,就更关注应用层和传输层的东西了,第一个上手的项目就跟爬虫有关,妥妥的学以致用。
扯完,开始我们今天的正题吧。希望看完本文,我们听到TCP/IP协议也能侃侃而谈。
一、OSI七层模型
1.1 没有OSI七层模型之前
熟悉我的人都知道,我喜欢看历史,讲故事。各位坐稳扶好,懒惰蜗牛号时光穿梭机要出发了。

这是当年(60年代),IBM推出的IBM System/360系列大型主机。
那个红色的大家伙里面装的就是CPU和内存,不过当初的CPU和内存跟我们现在看到的已经完全不是一个样了。

可能都不敢想这是那个时代的CPU,那时候还没有现在的超大规模集成电路。
他的逻辑功能是靠成千上万个小的电路模块拼出来的,就像搭积木一样。
下面那些密密麻麻的线缆,每一根线都是手工插上去的物理连接。

这个东西就是当初的内存,这么大一个模块的容量,你猜猜有多大?
想把这个照片原图存进去,可能需要几百个这样的模块才存得下。
为什么我讲个OSI七层模型,要扯到六七十年代的计算机?因为我一直相信一个事物的产生总是有他的原因的。
那个时候买一台这种大型机就跟现在买一栋楼一样,买来就是公司的核心资产了。
有钱的大型企业才能买得起,而且总部或者计算中心才能使用。
那下面的那些分公司或者研发部门也想用上计算机怎么办,没那么多预算买大型机啊,就只能买DEC PDP-11那样的小型机。

IBM和DEC的架构是完全不一样的,前者是60年代架构的霸主,后者则是70年代的新秀。
两家不管是编码、网络架构都自成一派。
现在我们拉根网线、连个WIFI,不关你是惠普、联想、IBM、华为的计算机,都能正常通信。
那时候就单这两家,分公司的PDP-11处理的数据想拿到总部的System/360去汇总,就是痴人说梦。
IBM有自己的网络机构SNA、DEC也有自己的网络架构DECnet。
这两个的通信机制,也就是怎么打包数据、怎么确认收到数据这些东西完全不一样。
因为网络根本连不上,最后只能靠人来传输数据。
DEC的数据拷到磁盘上,人抱着磁盘跑到IBM的机器上处理,关键是还不像现在复制粘贴那样。
拿到IBM机器上的数据还要码农写汇编或者C,处理原始的比特流,因为IBM机器根本不认识DEC存储的数据。
先读个Block,剥离掉DEC特有的头部和尾部元数据,然后查表,做映射,才能写到IBM的本地磁盘上。
所以当初要让两个不同的计算机进行通信,是个非常复杂的事情。
要么人肉搬数据,要么拿根线把两种计算机连接起来,注意,那时候可没有现在的RJ45网口和成品网线。
牛马手里拿的是电烙铁和螺丝刀,得自己对照着复杂的电路图,一根针脚一根针脚地焊接线序。
要么就是使用70年代末出现的一种协议转换器硬件设备。一端插在IBM的SNA网络接口上,模拟成IBM的一台 3270控制器。然后另外一端插在DEC的网络接口上,模拟成DECnet的一个节点。
不过这种设备当时也没多少企业买得起, 基本上大银行或者军方才用得上。
所以那个年代就是盘古开天辟地之前的混沌世界,什么都一片混乱,各自为政。
你说IBM这种大企业,为什么不一统天下,搞一套统一的标准,这样大家都能互联互通了。
一个是IBM当时的商业考量,自己在大型机玩那么好,买了他的机器,接下来的所有网络架构上的东西都得用他的,一家独大不是挺好的。
第二就是市场需求问题,当初用得上计算机的毕竟是少数企业、大机构,没有那么多设备、终端需要互联互通。
1.2 OSI七层模型形成
全世界人民都讨厌集权、垄断这样的词,当时的IBM已经显现出了用SNA垄断整个计算机网络世界的端倪。
政府和其他计算机厂商哪儿能忍啊, 他们也怕,怕哪天SNA赢了,那IBM就会控制全球数据流动的规则了。
所以大家希望通过建立一个公开的、非私有的国际标准,来削弱IBM的控制力。
上一节中也提到了市场上已经慢慢的出现了不同计算机之间的通信需求,随着时代的发展,需求也慢慢扩大。
各方势力都开始骚动了,当时的CCITT,也就是国际电报电话咨询委员会,现在应该叫ITU-T、ISO(国际标准化组织)都开始推动大一统的计算机网络世界,目标都是建立一个"书同文,车同轨"统一标准。
ISO是77年开始搞的,他们眼里的网络叫做Open Systems Interconnection (OSI)。注意这里的重点是 Systems。ISO关心的是怎么让两台计算机系统通过某种架构解耦,从而实现对话。
CCITT也是70年代末开始搞的,不过他们眼里的网络叫做Public Data Networks。CCITT关心的是我作为一个运营商,怎么搭建一个像电话网一样的数据网,让用户的终端能接进来。
等到80年代初的时候,双方一合计:我俩搞的好像是一个东西啊,如果弄出两套标准,那还叫标准吗?
于是ISO跟CCITT就准备合并他们之前的成果。
1984年,ISO发布了ISO 7498标准。CCITT发布了X.200标准。这两份文档内容基本上都是一样的,只是一个盖了ISO的章,一个盖了CCITT的章。

合是合了,但是有点面和心不和,毕竟最初ISO是无连接计算机思维,而CCITT是面向连接电信思维。
既然合并,肯定谁也不会让着谁,这个是我想要的,你得支持。双方有来有回,就导致整个OSI模型变得非常臃肿。
苦的不是定标准的人,苦的是实现OSI协议栈的牛马,这要支持,那要支持。
不过好歹也是有个标准了,万物互联不是梦。
1.3 哪七层
既然ISO和CCITT吵吵闹闹那么多年,终于定下了这个七层模型,那他到底长什么样?
很多文章上来就让我们背:物理层、数据链路层、网络层......背完就忘了。
今天我们换个角度。大家都是写代码的,我们把OSI模型看成是一个数据打包的过程。
我们用在淘宝买东西,商家给我们快递发货映射到OSI的七层模型。
第七层 应用层
相当于老板接单,这是离用户最近的一层。
当我们点击购买后,商家老板收到了订单,喊了一句,老王,把XXX发给懒惰蜗牛。
这一层只关心要发什么和发给谁。
对于Java来说,就是Controller接收到了请求,或者我们封装好了一个User对象准备发出去。
第六层 表示层
我们买的东西,总不能裸发吧,怎么都得要个袋子或者盒子,不然磕了碰了就GG了。
如果是不想让别人看见的特殊东西,还得用黑袋子套起来。
所以这一层就是负责把数据打包、格式化的。
比如把Java对象转成JSON字符串,或者把数据加密(HTTPS),免得快递小哥偷看。
第五层 会话层
在把商品交给快递员之前,店里的管理员还需要记录这笔交易的状态。
比如,如果这批货分了三次发,管理员就要记住,这是第2次发货,还有1次没发。
如果传输中断了,下次能接着传,而不是全部重发。
这一层负责建立和维护两台机器之间对话的状态。
第四层 传输层
这一层就是快递打包与服务选择了,是物流的核心。要干两件事情:
要把大箱子拆成适合运输的小包裹,然后贴上端口号(比如8080),这样才能确保包裹到了我们家,是送给Tomcat这个接收人,而不是送给微信了。
再一个就是决定走什么通道,是选TCP还是UDP。
TCP就像VIP尊享通道,保证包裹一个都不能少,丢了负责重发,可靠。
UDP就像平邮传单通道,塞给我们就行,丢了也不管,不可靠,但是快。
Java中的Socket编程,TCP三次握手就发生在这一层。
第三层 网络层
这一层是在填写快递单和分拨,包裹打包好了,得贴上最终的发货单(IP地址)。
类似导航。顺丰的分拨中心(路由器)要根据我们的IP地址,计算出一条最佳路线,是从北京走空运去上海,还是走陆运绕道天津。
他不关心包裹里是啥,只关心下一站去哪。
从Java的视角看,就是IP协议、路由器。
第二层 数据链路层
这是数据最后一公里的配送了,我们的包裹千里迢迢到了家附近的配送站,这时候IP地址(省市区)已经不重要了。
快递配送员看的是小区楼栋门牌号,计算机世界就是MAC地址。他骑着电动车,在局域网里精准地把包裹送到我们的网卡接口。
第一层 物理层
这一层是货车和公路,是真正意义上的路。只负责搬运。
他把上面的所有数据变成电流强弱(0和1)或者光信号,在网线、光纤、空气(WiFi)中传播。
发送数据,就是从第7层到第1层,一层层套箱子的过程。
接收数据,就是从第1层到第7层,一层层拆箱子的过程。
其中第2层到第7层可以看作是数据流转过程中的逻辑节点,都是在处理数据。
而第一层物理层,是现实世界连接这些节点的路和车,是数据赖以生存的物理环境。
第2层到第7层是在处理信息,第一层是承载信息。
来通过图感受一下OSI七层模型的套娃行为:

我们平时写代码,90%的时间都在应用层,偶尔优化性能的时候会碰到传输层(L4),至于剩下的底层细节,就是操作系统和网工兄弟们在负责。
二、TCP/IP四层模型
2.1 七层是理想,四层是现实
上面我们讲了OSI七层模型的前世今生,讲他多美完美,多么严谨。但是我们都听过一句话,理想很丰满,现实很骨感。
如果我们去翻现在的Linux内核源码,或者去抓个包看看,就会发现根本找不到严格对应OSI七层的结构。
我用WireShark抓了http://httpbin.org/html的数据包:

数据的第六行表示我们的电脑正在向服务器申请网页数据。
把第6行打开后:

第一行的Frame 6不用看,这是物理层元数据,相当于Wireshark的入库记录,记录数据包作为物理信号被网卡接收到的那一瞬间的状态,跟协议没有关系。
Ethernet II对应的是链路层,之前我们讲链路层的时候,是不是提到链路层已经在找门牌号了(MAC地址),我们看下这里面有没有MAC地址相关信息。

我们确实找到了来源和目的地的MAC地址。

Src: AzureWaveTec_5c:35:ab (90:e8:68:5c:35:ab)是我本机的无线网卡MAC地址。
Dst: H3CTechnolog_17:94:83 (70:f9:6d:17:94:83)并不是我们访问网站的服务器MAC地址,Wireshark自动识别了厂商标识,H3C应该是我本地路由器。这个地址应该是他的。
MAC地址只在局域网有效,我的电脑不知道服务器在哪,只知道把包交给网关(路由器)。
接着看下一层Internet Protocol Version 4。
IPv4应该还是很熟悉吧,妥妥的网络层。

这里面记录本地和远端的IP地址。也明确了IP层里面包裹的是一个TCP数据段。
远端的IP:3.219.87.227就不是我家路由器的IP了,是目标服务器的IP。

然后看Transmission Control Protocol:

这不就是TCP嘛,传输层的核心。
Src: 59195 -> Dst: 80
80是HTTP的标准端口。如果说IP地址找到了服务器,端口号就是找到了服务器上运行的具体程序。
Seq: 1, Next Seq: 431
这是TCP的账本。给每一个字节都编了号,发送了430字节,下次就从431开始。这样TCP发出去的数据才不会丢。
来看最后一块:Hypertext Transfer Protocol

这里已经是应用层了。
核心其实是GET /html HTTP/1.1。这是纯粹的应用层逻辑。就是把/html这个网页发给我们。
Accept-Encoding: gzip, deflate这是在高速服务器,我能读懂gzip格式的压缩包,压缩后再发给我。
数据压缩、格式转换本来是OSI表示层应该干的活儿,现在HTTP一个头部字段就搞定了。
Connection: keep-alive是告诉服务器传完网页保持连接,我一会儿可能还要下图片。
连接保持、状态管理本来是OSI会话层应该干的事情,现在也是HTTP顺手就干了。
从Wireshark的抓包数据来看,整个过程是采用的四层模型。
Ethernet II是网络接口层,负责物理寻址(MAC),对应OSI的第一二层。TCP/IP把这两层看成一层。
IPv4是网络层,负责逻辑寻址(IP),对应OSI的第三层。
TCP是传输层,负责端到端连接(端口),对应OSI的第四层。
HTTP是应用层,处理了ISO中第五六七层的逻辑。
这也是现实中运行的实际逻辑,TCP/IP的四层模型。
为什么会出现这样的情况呢?不是OSI才是逻辑严密的七层模型吗?OSI不是统一标准吗?
主要就是因为OSI模型太过官僚,追求完美了。等ISO和CCITT为了各自主张争论不休,拖拖拉拉把标准制定出来的时候,市场已经没有他的位置了。
一群追求实用主义、代码先行的工程师在ARPANET(阿帕网)实验室搞出了TCP/IP。
这群人简单粗暴,他们不要什么完美的文档,只要一个大概的共识,能让代码跑起来就行了,像不像一个创业团队。
ISO和CCITT还在争论的时候,他们让TCP/IP协议栈搭上了UNIX 系统的顺风车,免费分发到了全世界的大学和科研机构。
然后的然后,TCP/IP成了事实上的标准,OSI模型只能躺在教科书里了。
2.2 四层模型
下面是OSI模型和TCP/IP模型的映射关系
| OSI 七层模型 | TCP/IP 四层模型 | 映射关系与核心职责 | 常见协议 | Java开发视角 |
| 7. 应用层 | 应用层 | TCP/IP把OSI 的上三层彻底合并。不区分会话管理和格式转换,全部由应用层协议自己决定。职责: 处理具体的业务逻辑,直接为用户提供服务。 | HTTP/HTTPSDNS, FTPSMTP, SSH | @RestControllerHttpClient / OkHttpUser对象 / 业务逻辑 |
| 6. 表示层 | 应用层 | 数据格式转换、加密解密、压缩。(在 TCP/IP中,这通常表现为JSON序列化、Gzip压缩、SSL/TLS 加密) | SSL/TLSASCII, JPEG | FastJson/ JacksonBase64GzipOutputStream |
| 5. 会话层 | 应用层 | 建立、管理和终止应用程序之间的会话。(在 TCP/IP 中,这通常表现为 Session ID、Keep-Alive) | RPCNFS | HttpSessionCookie 管理断点续传逻辑 |
| 4. 传输层 | 传输层 | 这一层在两个模型里完全一致。提供端到端的、可靠或不可靠的数据传输服务(端口到端口)。 | TCP (可靠)UDP (快速) | Socket编程java.net.SocketServerSocketDatagramSocket |
| 3. 网络层 | 网络互连层 | TCP/IP把网络改叫"Internet",确立了IP协议的霸主地位。逻辑寻址 (IP)、路由选择、分组转发。 | IP (IPv4/IPv6)ICMP (Ping)ARP (查MAC) | InetAddressping 命令(日常开发很少直接操作此层) |
| 2. 数据链路层 | 网络接口层 | TCP/IP对底层硬件不作具体规定,只要能发数据就行。物理寻址 (MAC)、帧的组装与校验、局域网内传输。 | Ethernet (以太网)Wi-Fi (802.11)PPP | NetworkInterface获取本机MAC地址(通常由操作系统驱动处理) |
| 1. 物理层 | 网络接口层 | 传输比特流 (0/1)、定义电压、接口形状、传输介质。 | 双绞线光纤电磁波 | 无(这是网线和网卡的物理世界) |
|---|
三、TCP/IP协议
接下来我们看看具体的TCP/IP协议。
我们经常讲的TCP/IP协议其实不单单只是TCP和IP两个协议。而是TCP/IP协议簇。
这个协议簇里包含了上百种的协议。
比如应用层的HTTP, FTP, SMTP, DNS、传输层的TCP, UDP、网络层的IP, ICMP, ARP、接口层的Ethernet, Wi-Fi等等。
之所以叫他TCP/IP是因为TCP和IP是最核心、最不可或缺的两个协议。
3.1 数据封装
我们写Java代码的时候,比如socket.write("LazySnail"),发送的是一个字符串。
但在网线里跑的,是前面抓包看到的那种包含了一堆16进制数字的复杂数据包。
这中间发生的过程,就是封装。
回忆一下Wireshark抓的数据包,我们把这个过程倒推一遍,看看数据在TCP/IP协议栈里是怎么进行封装的。
封装

解封装

3.2 IP协议
IP协议可以说是互联网的基础,所有的网络编程最终都要依赖他来找到另一端的机器。
IP协议主要解决的就是数据包去哪里的问题。
核心特性
IP协议是无连接的,发送数据前不需要握手,可以理解成我们往邮筒里丢一封信,丢进去就不管了。
IP协议没有任何保障,路由器收到IP包,如果忙不过来,或者觉得这包有点问题,就会直接丢弃,而且不会通知我们。
IP协议也是无状态的,他记不住之前发生了什么,第2个包跟第1个包没有任何关系,他俩可能走不同的路线到达另一端。
一般设计得糙的东西都是为了追求效率,IP协议也是如此。
这不管那不管,就是快。
正是因为IP协议足够简单、足够笨,路由器才能处理得足够快。如果路由器也要像TCP那样维护连接状态、负责重传,那互联网早就堵死了。
TTL
之前我们用Wireshark抓包的数据里,有这样一段:Time to Live: 128
假设网络里有一个配置错误的路由器环路(A发给B,B发给C,C又发回给A)。如果没有机制控制,一个数据包会在这个圈里无限循环,永远不死。这种包多了,带宽就被吃光了。
所以IP协议在头部设计了TTL。这个不是时间,是跳数。
每经过一个路由器,TTL就会减1。
当TTL变成0的时候,路由器就会干掉这个包,然后向源头发送一个ICMP报错。
我们用的ping命令或者tracert命令其实就是用的TTL机制来实现的。
分片和MTU
IP协议理论上可以封装65535字节的大包,但底层的以太网(链路层)很娇气,有一个限制叫MTU(最大传输单元),通常是1500字节。
就像开着一辆大卡车(IP包),通过一个限高的小隧道(以太网)。
这种情况IP协议只有拿把刀,把大包切成几个小包,分别塞进以太网帧里发出去。到了目的地,接收方的IP层再把他们拼起来。
在之前的抓包数据里我们看到了Flags: Don't fragment。
现在的网络编程(包括TCP)都会尽量协商好,每次发的包都小于MTU(加上包头不超过1500),避免在IP层进行分片。因为一旦分片,只要丢了一个小片,整个大包都得重传,效率太低了。
3.3 TCP协议
TCP的中文名称叫传输控制协议,最核心的就是他的可靠性。为了实现可靠必然就会很复杂。
下面我们一点点拆解TCP协议。
TCP特性
TCP是面向连接的,就像打电话,通话前必须先拨号(三次握手),确认对方拿起话筒了才能说话。 而UDP就像大喇叭广播,喊出去就行,不管有没有人听。
TCP是可靠传输,不丢包、不乱序、无差错。只要发送的数据没有收到确认回执(ACK),TCP就会一直重发,直到对方确认为止。
TCP是面向字节流的,不会把数据看作一个个独立的包,而是看作水流。我们发了三次 "ABC",对方收到的可能是一次 "ABCABCABC"。
TCP是全双工的,一条TCP连接建立之后,通信双方可以同时给对方发数据,有点像双车道。
TCP为什么可靠
我们之前的Wireshark数据画的图中,有块是TCP头,这里面其实藏了很多东西。

TCP的标准头部是20字节。
源端口和目的端口各占2个字节,主要用来精准定位应用程序。
序列号(SEQ)和确认号(ACK)各占4个字节。
SEQ可以看成数据的身份证,TCP把发送的每一个字节都编了号。比如发送 "LazySnail",'L' 是100,'a' 是 101。接收方收到之后,会按照这个号码重新排序。如果收到了100和102,就知道101丢了。
ACK可以看成是收货回执,大概就是编号X之前的数据我收到了,下次请从X+1开始发我。
这部分就保证了TCP传输的数据不丢包、不乱序。
4位首部长度是告诉接收方TCP头到哪里结束,数据从哪里开始。(因为最后的选项字段长度不固定)。
6位的标志位,每一个位都代表一种状态,用来控制连接的生命周期。
比如SYN代表发起连接,这是三次握手的第一步。ACK确认号有效,握手成功后一直为1。
FIN表示结束连接,也就是我们说的四次挥手。RST代表前强制断开,出错了。
PSH表示有数据,要推给应用层。
接着是16位的窗口大小,这是接收方的缓冲区剩余大小。这是一个自我保护机制。接收方会告诉发送方:我仓库还剩1000字节的空位,你最多只能发这么多。
校验和类似安检员,用算法计算头部和数据的哈希值。接收方收到后重算一遍,如果不一致,说明数据在路上坏了(比特翻转),直接丢弃。
紧急指针是当Flags里的URG位为1时,这个指针有效。他告诉接收方:有紧急数据(比如Telnet的中断命令),别排队了,优先处理。
总的来说,TCP用序列号控制顺序,用确认号控制丢包,用窗口控制速度,用端口控制方向。
三次握手
TCP是面向连接的。在传数据之前,必须先建立一条可靠的通道。
这个过程不能太随便,必须确认双方的发送能力和接收能力都是正常的。
我们用打电话举个例子:
电话两头一个是客户端,一个是服务端。看他们在正式的通话内容之前的过程。
第一次握手(Client->Server)
我说:"喂?听得到吗?我是懒惰蜗牛。"
这句话意味着,我想建立一个连接,我的发送能力是正常的。
第二次握手(Server->Client)
对方说:"听到了。你能听到我说话吗?"
这句话意味着服务端收到了我们的请求,说明客户端的发送能力和服务端的接收能力没问题。现在服务端想测试客户端的接收能力。
第三次握手(Client->Server)
我说:"听到了,咱开始聊正事儿吧"
这句话意味着客户端收到了服务端的回复,说明服务端的发送能力和客户端的接收能力也没问题。连接建立。
回到技术层面,这三次交互其实就是TCP头部里Flags(标志位)和Seq/Ack(序号)的变化。

为什么要三次?
不讲那么多为什么,直接看如果只有两次握手会有什么问题。

这是一个我们假设的场景,客户端发了第一个请求A,但是可能因为延迟在网络里迷路了,没到达服务端。
客户端看没反应,又发了第二个请求 B。B到了,服务端回复,连接建立,数据传完,断开。
搞笑的是这时候,那个迷路的请求A 突然到了。
服务端以为客户端又要连,于是回复"好的",并建立了连接。但客户端早就干完活了,理都不理服务端。
服务端就这样挂着一个连接,等着永远不会发来的数据,白白浪费内存。
这种情况如果有了第三次握手,服务端回复A的时候,客户端会发现:"我没想连啊?",然后发个RST(复位)包拒绝掉,就不会浪费资源了。

四次挥手
相聚容易别离难。TCP的断开过程比建立更复杂,因为他是全双工的。
也就是说,我说完话了,不代表你也说完话了。
还是来个比喻,假设我们要辞职了。
第一次挥手(Client->Server)
客户端说:老板,我的工作汇报完了,我要辞职。
第二次挥手(Server->Client)
服务端说:好的,我知道你要走了。但我手里还有几个文件没发给你,你先等等。
第三次挥手(Server->Client)
服务端说:好了,文件都发完了。你走吧,我也挂了。
第四次挥手(Client->Server)
客户端说:好的,收到,拜拜。

那为什么挥手要四次?

结语
上面讲了那么多的理论,回到代码开发。
当我们写new Socket("ip", 80) 的时候,程序卡住(阻塞)的那一瞬间,就是在进行三次握手。
当我们调用socket.read() 却读不到数据时,可能是因为对方的滑动窗口满了,或者网络拥堵包丢了在重传。
当我们调用socket.close() 时,程序发出了FIN包,开始了四次挥手。
下一篇预告
待定
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!