TCP/IP简明教程 - 从零构建TCP/IP协议(这次叫PCT协议)

这篇博客是读完《图解TCP/IP协议》和《TCP/IP协议详解卷一:协议》之后的总结

我从0构建了一个可靠的双工的有序的基于流的协议,叫做PCT协议 :)

OSI七层模型和TCP/IP四层模型

谈到计算机网络,就一定会说起OSI七层模型和TCP/IP四层模型,不过我们先从为何分层 说起。

为什么要分层

软件开发的过程中,我们经常听到的词语是”解耦”,”高内聚,低耦合”等等诸如此类的 词语,又常听见写Java的同学念叨着”桥接模式”,”面向接口”等词语,那么他们说的这些 词语的核心问题是什么呢?我们先从一个简单的问题看起:

现在我们需要做一个推送系统,要对接Android和iOS两个系统,大家都知道,Apple有统一 的推送渠道,APNs,所以我们只要接入这个就好,但是Android的推送在国内是百家争鸣, 就拿之前我为公司接入推送通知来举例,要接入极光,小米,可能要接入华为推送。

那我要怎么从具体的推送里抽象出来呢?运用面向对象的想法,我们很容易就能想到, 我们有一个父类,叫 BasePush,他的子类就是具体的 MiPush, JPush, HMSPush。 父类中有 push_by_idpush_by_tag 等方法,子类重写。这样我们在具体实现的时候 实例化子类,并且调用对应的方法就好。这种思想其实就是面向接口编程,在Java中我们 可以转变一下编程的写法,把继承变成接口。在Python中我们就可以直接脑补这种写法。 用图来表示,纯粹面向对象的时候我们的想法是这样的:

面向对象

如果我们把上面的图倒过来,就变成了面向接口:

面向接口

在使用面向接口之后,我们就是做了这样一种假设:

def push(pusher, id):
    pusher.push_by_id(id)

即,传给push函数的pusher实例一定存在 push_by_id 方法。正是基于这样一种假设, 我们得以把具体业务代码和具体的推送商划分开来,这就是所谓的抽象,也就是一种分层。

要分层的原因也就显现出来了,为了把不同的东西错综复杂的关系划分开来,也就是古话 说的”快刀斩乱麻”的这种感觉。

两种网络模型

日常编程里我们用的最多的就是TCP了,UDP也是有的,但是很少,举一些常见的例子:

  • DNS -> UDP
  • 连接MySQL -> TCP
  • 连接Redis -> TCP
  • RPC -> TCP
  • 访问网站 -> TCP

当然了,这只是常见实现方式如此,其实用UDP也是可以实现的。这篇博客里我们暂时不讨论 UDP。我们先来看TCP/IP四层是怎么分层的:

ascii 表格其实挺好看的,最后渲染的时候因为宽字符的原因格式有点乱掉了,下同

+------------+-----------------------+
| 层         | 例如                  |
+------------+-----------------------+
| 应用层     | HTTP协议              |
+------------+-----------------------+
| 传输层     | TCP                   |
+------------+-----------------------+
| 网络互连层 | IP                    |
+------------+-----------------------+
| 网络接口层 | 如网线,双绞线,Wi-Fi |
+------------+-----------------------+

我们直接把 TCP/IP 四层协议 映射到 OSI七层协议 上看:

+--------------+---------------+----------------+
| OSI 七层协议 | 例如          | 对应TCP/IP四层 |
+--------------+---------------+----------------+
| 应用层       | HTTP协议      |                |
+--------------+---------------+                |
| 表示层       |               | 应用层         |
+--------------+---------------+                |
| 会话层       |               |                |
+--------------+---------------+----------------+
| 传输层       | TCP           | 传输层         |
+--------------+---------------+----------------+
| 网络层       | IP            | 网际层         |
+--------------+---------------+----------------+
| 数据链路层   | 因特网,Wi-Fi |                |
+--------------+---------------+ 网络接口层     |
| 物理层       | 双绞线,光缆  |                |
+--------------+---------------+----------------+

接下来我们将从底层逐层向上来解析网络,最后我们将简略的介绍TCP(TCP的知识足够 写好几本书,一篇博客里远远介绍不完。不信可以看看TCP/IP协议详解那三卷书加起来 有多厚)。

物理层

物理层,顾名思义,就是物理的,可见的东西。也就是平时我们所说的光纤,Wi-Fi(无线电波) 等,我们知道计算机是用0和1来表示的,对应到不同的介质里是不同的表现形式, 因此为了把物理层的实现屏蔽掉,我们把这些都分到一层里,例如Wi-Fi通过波的 波峰与波谷可以表示出0和1的状态(我们平时会说成1和-1,对应计算机里其实就是1和0)。 对应到电里,我们可以用高电压和低电压来表示出1和0。如同最开始讲的例子一样, 我们不管具体的介质是什么,只知道,我们用的这个介质有办法表示1和0。

数据链路层

如果我们去邮局写一封信,填完收件人之后,邮局派发的顺序可能是,先投递到指定的 国家,然后投递到具体的省,然后市。。。逐次投递下去。那么我们玩电脑的时候,计算机 要怎么把A发给B的信息准确送达呢?

肯定大家都要有一个地址,上一节我们知道了,不同的介质都有他的方式表示1和0,那么 我们给介质的两端加上地址,我们叫做MAC地址,如何?就拿路由器来说吧,路由器的 MAC地址叫做 router ,手机的MAC地址叫做 phoner,为了表示成0和1,我们分别取 字符串的ASCII的二进制来表示,路由器叫做 1110010 1101111 1110101 1110100 1100101 1110010, 而手机则叫做:1110000 1101000 1101111 1101110 1100101 1110010,现在我们终于可以发信息 了,最少是相邻的两个东西可以透过某种介质来发信息,所以我们定下这样的协议:

协议,其实就是一种约定 :)

  • 最开始我们发送111表示信息开始
  • 然后,我们先有48个bit表示发送者的MAC地址,再有48个bit表示接受者的MAC地址
  • 之后,就是我们要发送的信息
  • 最后我们发送000表示结束,如果开头和结尾不是这样的,那么说明这是假的信息。

知道上面为啥手机叫 phoner 而不叫 phone 了嘛 :) 就是为了保证地指名长度一样

“hello” 的二进制表示是 “1101000 1100101 1101100 1101100 1101111”,如果路由器要向 手机发送 “hello”的话,那么就发送这样一串二进制(用换行分割,这样更容易看清楚):

111
01110010 01101111 01110101 01110100 01100101 01110010
01110000 01101000 01101111 01101110 01100101 01110010
01101000 01100101 01101100 01101100 01101111
000

这样表示看起来可行,不过遇到一个问题,就是如果这一串二进制中间就出现了000怎么办? 因为计算机读取的时候是从头开始读的,这样子计算机就会乱掉。

为了解决这个问题,我们修改一下协议,在111之后加上发送者地址+接受者地址+所要发送的 信息的长度。我们用 16个bit来表示,也就是说这中间不能发送多于 2 ** 16 个bit。

所以协议变成了:

  • 最开始我们发送111表示信息开始
  • 随后我们用16个bit表示包的长度
  • 然后,我们先有48个bit表示发送者的MAC地址,再有48个bit表示接受者的MAC地址
  • 之后,就是我们要发送的信息
  • 最后我们发送000表示结束,如果开头和结尾不是这样的,那么说明这是假的信息。

发送者地址+接收者地址+hello的bit长度是 6 * 8 + 6 * 8 + 5 * 8 = 136,二进制表示 为: 00000000 10001000

所以发送的整个信息变成了:

111
00000000 10001000
01110010 01101111 01110101 01110100 01100101 01110010
01110000 01101000 01101111 01101110 01100101 01110010
01101000 01100101 01101100 01101100 01101111
000

网络层

现在我们终于可以发送信息了。不过有个缺点,我们只能在相邻的时候才可以发送信息, 那有没有办法可以借助两两传递,在不同的地方也发送信息呢?有,那就是我们的网络层 也就是ip(我们能遇到的最通俗易懂的一个名词了,暂时把它当作网络层的代名词也不为过)。

刚刚我们已经学会了一种技术,就是分配一个地址,刚刚的叫做MAC地址,我们用来做 相邻两个节点的定位。其实这个地址也可以用来在多个节点之间找人,基于这样一种 技术:每个节点都知道和自己相邻的节点的MAC地址,那么,比如这样一种连接方式:

A - B - C - E
 \     /
  - D -

A向E发送消息,就可以这样:

  • A向B和D发消息:给我发到E去
  • B和D接到之后发现来源是A,所以就只给C发消息:给我发到E去
  • C接到消息之后发现来源是B和D,所以就给E发消息:给我发到E去
  • E接到消息之后发现接收方是自己,所以就把消息吞了

你别说,这种方式好像真的行得通呢,除了有一个显著的问题,A向E发送一份消息, 最后E收到了两份,这个我们需要到后面进行去重。我们先打上一个TODO的标签吧。

还有一个细节问题,不知道大家发现了么,刚才我们说过,MAC地址是相邻两个节点 通信用的,里面有来源地址和目标地址,如果我们向上面这样传输的话,每个节点都 只是把里面的信息传过去,但是来源地址却改要改写成自己的MAC地址,要不然的话, B就不知道信息是A发来的还是C发来的呀,对不对?那问题就来了,E要怎么知道信息 其实是从A发过来的呢?

没办法了,我们只好在传输的信息里把真正的来源地址写进去,所以我们又定了一个 协议,我们管它叫做ip:

  • MAC携带的信息的开始,是来源的ip地址,32个bit表示
  • 然后是目标的ip地址,32个bit表示
  • 然后是我要带的信息

那和上面的数据链路层的协议合一下起来,假设来源地址是 192.168.1.1,目标地址是 192.168.1.2,发送的信息还是 “hello”,整个包就像这样:

111(开始)
00000000 11001000(长度)
01110010 01101111 01110101 01110100 01100101 01110010(来源MAC地址)
01110000 01101000 01101111 01101110 01100101 01110010(目标MAC地址)
11000000 10101000 00000001 00000001(来源ip地址)
11000000 10101000 00000001 00000010(目标ip地址)
01101000 01100101 01101100 01101100 01101111(字符串"hello")
000(结束)

这样是不是就很科学?那必须的。哎呀,终于可以跨节点发送消息了,小开心~

可是还是有问题,如果我想确定A发的信息一定送达了E怎么办?怎么提供可靠性?IP这一层 并不提供可靠性,只是说尽量送达。看来有必要再来一层!

传输层

我们知道,一台计算机上可能有很多个程序在运行,那怎么区分不同的程序呢?所以我们 给程序加上了id,叫做pid。那计算机网络通信的时候怎么区分呢?又假设n个进程想和另外 一台机器上的某一个进程通信呢?怎么办?

不如我们再分配一个id吧,他们共同持有这个id就好了。我们把这个id叫做端口(port)。 这样子的话,通过ip地址我们可以确定计算机,通过端口我们可以确定一个或多个进程。

我们继续造协议,不过这一次我们想要这个协议贼可靠,所以要多做一些工作。其实要是 按照七层协议来实现的话,完全不必在这一层干这么多事情,不同的层干不同的事情嘛, 对不对。不过为了理解TCP协议,我们呀,也跟着来自己捏造一个协议,不如叫PCT好了。

继续,我们要在ip带的信息里规定好我们这样发:

  • 首先是来源地址的端口号,8个bit来表示,因为ip里面已经待了ip地址,我这里就不重复带了
  • 然后是目标地址的端口号,8个bit来表示

这样,简单的PCT协议就做好了。

还有一个问题,就是我们要保证发出去的信息是有序的,因为可能有的信息走光纤, 有的信息走Wi-Fi,他们传输速率不一样嘛。

所以我们在协议里这样写:

  • 首先是来源地址的端口号,8个bit来表示,因为ip里面已经待了ip地址,我这里就不重复带了
  • 然后是目标地址的端口号,8个bit来表示
  • 然后是这个包的序号,8个bit来表示

但是我们说好了要把这个协议打造成一个可靠的协议,可不能食言。我想想,怎么让他 可靠呢,无非就是我发一个信息,你告诉我你收到了,要是你不告诉我,我就发到你告诉我 为止。差不多就是这么个意思。但是呢,又不想构造多个不同的协议,你知道,编程的时候 要是写一堆的if-else树那可就很蛋疼了。再改改协议:

  • 首先是来源地址的端口号,8个bit来表示,因为ip里面已经待了ip地址,我这里就不重复带了
  • 然后是目标地址的端口号,8个bit来表示
  • 然后是这个包的序号,8个bit来表示
  • 然后是想确认的包的序号,8个bit来表示

咦,点睛之笔耶,这个确认的包的序号,因为我们是双向通信,我发他信息的时候还可以顺便 确认我收到了他的包啊,真是一箭双雕。

TCP是一个面向流的协议,什么叫流?车流,水流,车流比较形象。车和车之间是分开的, 但是速度一快起来,就可以把它们看成连起来的。TCP也是这样,单个包之间是分开的, 但是却可以看作是连起来,为什么呢?因为每个包里都带了ip地址和端口号,ip地址和端口 号一样的,就可以看作是连起来的 :)

所以我们可以想象一下,我们的ip地址是192.168.1.1, 端口号是 1, 目标的ip地址是 192.168.1.2, 端口号是 2。那我们发送这样的包:

111(开始)
00000000 11101000(长度)
01110010 01101111 01110101 01110100 01100101 01110010(来源MAC地址)
01110000 01101000 01101111 01101110 01100101 01110010(目标MAC地址)
11000000 10101000 00000001 00000001(来源ip地址)
11000000 10101000 00000001 00000010(目标ip地址)
00000001(来源的端口号)
00000010(目标的端口号)
00000001(发送的包的序号是1)
00000000(已经确认的包的序号是0,表示啥都没有嘛)
01101000 01100101 01101100 01101100 01101111(字符串"hello")
000(结束)

duang,就这样,我们构建起了属于自己的可靠的基于流的双工的协议 :)

顺便我们还完成了上面的TODO,通过序号我们就可以判断这个包是不是重复了,哈哈哈, 一箭n雕~

TCP三次握手四次挥手滑动窗口拥塞控制等就不讲了,还是去看《TCP/IP协议详解卷一》吧 :)

应用层

这下我们终于可以放心大胆的发送消息了,PCT协议是个负责任的协议,如果能送到,他就一定 会送到,并且是有序的,要是网络坏掉了,实在连不上,他就会告诉我网络连不上。

这样子来编程方便多了呀。

现在我想知道浏览器和服务器是怎么通信的。我们来看看百度。

$ telnet www.baidu.com 80
Trying 183.232.231.173...
Connected to www.baidu.com.
Escape character is '^]'.
GET / HTTP/1.1

HTTP/1.1 302 Moved Temporarily
Date: Sat, 12 Aug 2017 10:45:14 GMT
Content-Type: text/html
Content-Length: 215
Connection: Keep-Alive
Location: http://www.baidu.com/search/error.html
Server: BWS/1.1
X-UA-Compatible: IE=Edge,chrome=1
BDPAGETYPE: 3
Set-Cookie: BDSVRTM=0; path=/

<html>
<head><title>302 Found</title></head>
<body bgcolor="white">
<center><h1>302 Found</h1></center>
<hr><center>pr-nginx_1-0-350_BRANCH Branch
Time : Tue Aug  8 20:41:04 CST 2017</center>
</body>
</html>
^]
telnet> 
Connection closed.

输入 GET / HTTP/1.1 之后回车,百度就给我返回了下面的一长串,然后浏览器再根据 返回的内容进行渲染,这又是一个大话题了,不讲了不讲了,收工 :)


更多文章
  • 避免全局变量
  • Go的unsafe包
  • Golang中实现禁止拷贝
  • 人生如戏,全靠演技 -- 《日常生活中的自我呈现》读后感
  • Golang的反射
  • 数据库事务
  • 把网站去掉CSS之后
  • 处理并发的方式
  • 常见的索引方式
  • Golang 实践经验
  • 高性能MySQL笔记第一章
  • 面试的一些技巧
  • HTTP/2 简介
  • 独立运营博客一年的一些数据分享
  • To B(usiness) 和 To C(ustomer)