从零构建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_id 和 push_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"的话,那么就发送这样一串二进制(用换行分割,这样更容易看清楚):
这样表示看起来可行,不过遇到一个问题,就是如果这一串二进制中间就出现了000怎么办? 因为计算机读取的时候是从头开始读的,这样子计算机就会乱掉。
为了解决这个问题,我们修改一下协议,在111之后加上发送者地址+接受者地址+所要发送的 信息的长度。我们用 16个字节来表示,也就是说这中间不能发送多于 2 ** 16 个bit。
所以协议变成了:
- 最开始我们发送111表示信息开始
- 随后我们用16个bit表示包的长度
- 然后,我们先有48个bit表示发送者的MAC地址,再有48个bit表示接受者的MAC地址
- 之后,就是我们要发送的信息
- 最后我们发送000表示结束,如果开头和结尾不是这样的,那么说明这是假的信息。
发送者地址+接收者地址+hello的bit长度是 6 * 8 + 6 * 8 + 5 * 8 = 136,二进制表示 为: 00000000 10001000
所以发送的整个信息变成了:
网络层
现在我们终于可以发送信息了。不过有个缺点,我们只能在相邻的时候才可以发送信息, 那有没有办法可以借助两两传递,在不同的地方也发送信息呢?有,那就是我们的网络层 也就是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 之后回车,百度就给我返回了下面的一长串,然后浏览器再根据 返回的内容进行渲染,这又是一个大话题了,不讲了不讲了,收工 :)