用C语言做QQ (初级版)
目录
- 用C语言做QQ (初级版)
- 网络基础入门:什么是网络
- 网络简史
- 网络的诞生
- 90年代的上网方式:电话拨号上网
- 00年代的上网方式:宽带连接上网
- 10年代的上网方式:无线网络的兴起和光纤的应用
- 无线网络的兴起
- 光纤的应用
- 初识网络
- 第一层:物理层 —— 集线器
- 第二层:数据链路层 —— 交换机
- 第三层:网络层 —— 路由器
- 第四层:传输层 —— TCP 与 UDP 协议
- 什么是"协议"
- TCP 协议:保障你数据传输的可靠利器
- UDP 协议:不可靠但高速的运输
- 第五层:应用层 —— 数据处理与过滤
- 网络应用程序体系结构
- 应用层的组成
- Socket编程:如何做QQ
- 局域网(内网)通信
- 初识 Winsock 库
- 初始化设备
- 创建套接字
- 客户端代码
- 服务端代码
- while 循环实现连续会话
- 跨内网通信(内网穿透)
- 为什么要获取公网IP
- 如何获取公网 IP
- 防火墙开放端口
- 客户端优化
- 服务端优化
- 客户端 socket 管理数组
- 多线程优化
- 最终优化代码
- 最终测试
- 内网测试
- 跨内网测试
网络基础入门:什么是网络
网络简史
网络的诞生
1957 年 10 月 4 日,苏联发射了人类第一颗人造卫星 “斯普特尼克一号” 。这颗卫星的升空,轰动了整个世界,也让当时的美国政府震惊不已。他们恐惧:在日趋激烈的冷战对抗中,自己将全面落后于苏联。
为了扭转这一局面,美国国防部于 1969 年 研究一种 “分布式” 的指挥系统 ARPANET(阿帕网)。它由无数的节点组成,当若干节点被摧毁后,其它节点仍能相互通信。 这便是最早网络的诞生。
20世纪60年代早期,计算机还是独立模式,计算机之间相互独立,并不能进行相互通信,只提供终端和主机之间的通信。
20世纪60年代中期,随着时代的发展, 越来越需要计算机之间相互通信、共享软件和数据了,即以多个计算机协同工作来完成业务,就有了网络互连。
网络互连:将多台计算机连接在一起,完成数据共享。
数据共享本质是网络数据传输,即计算机之间通过网络来传输数据,也称为网络通信。
70年代末,微型计算机问世,更是加速了网络的发展。
1991年8月6日,在瑞士日内瓦的核子研究中心(CERN)工作的英国物理学家蒂姆·伯纳斯·李(Tim Berners-Lee),正式提出了World Wide Web,也就是如今我们非常熟悉的www万维网。
他还提出了 HTTP(超文本传送协议) 和 HTML(超文本标记语言),设计了第一个网页浏览器,并建立了世界上第一个web网站。
1992年,几个因特网组织合并,成立因特网协会ISOC。此时的因特网,已经拥有超过100万台主机,并持续指数级疯狂增长。
Internet,真正变成了全球互联网,开始走进人们的生活。
如今,全球互联网用户数已经达到45.4亿,普及率超过59%。与其说它是一场技术革命,它更像是一场社会革命。它颠覆了整个社会的运作模式,推动了人类文明的跨越式发展。
90年代的上网方式:电话拨号上网
上世纪90年代,刚有互联网的时候,老百姓上网使用最为普遍的一种方式是电话拨号上网。
在90年代,由于互联网的用户不多,架设网线的成本没有办法分摊,所以当时并没有互联网专用线路。
网民只能借着已经发展了将近一个世纪的 电话网络 来帮助自己上网,也就是拔掉座机电话线,连到电脑上。
可是,电话线传来的信号是 模拟信号(也就是电磁波),但电脑只能识别 数字信号(也就是0和1),想让电脑正确显示网络的内容,就必须用到这两种信号的翻译官—— 调制解调器(Modem),也就是我们现在俗称的 “猫”。
当你要上网的时候,"猫"首先将计算机的数字信号进行调制 (数字信号转换成模拟信号) ,然后传输到相应的终端,再通过解调 (模拟信号转换成数字信号) ,从而实现网络数据的传输。
早期上网算是比较麻烦的,需要把座机的电话线拆下来,接到电脑上,然后还要向本地运营商申请接入号,最后拨通接入号才行。
由于早期设备的原因,拨号上网的特点是贵和慢:
贵:那个时候的电话费相当贵,1994年的网费高达28元每小时,直到1998年末才降到每小时3-5元不等,即使这样对于当时的家庭用户来说也是一笔不小的费用,寒假上一个月网就能产生3000元的电话费账单了,学生上网上久了会被家长揍。
慢:早期家用猫的速度最快也只能跑到 56KB/s,相当于网上一个五分钟的视频至少要3个小时才能下载完,对现在 几MB/s 的速度来说这速度是非常不够看的。
早期由于网络和电话用到同一根电话线,所以早期上网时会接不到电话,向你打电话的那个人会收到"电话已占线"的消息,甚至有时候你的电脑会直接断网,非常不方便。
所以当时的"上网"对老百姓来说只是一个烧钱还没体验的"奢侈品"罢了。
直到 ADSL拨号 的出现才使上网变得简单一些。
00年代的上网方式:宽带连接上网
ADSL拨号与传统拨号的最大不同就是多了一个 “分离器”,它把电话线路能传输的 低频信号 和 高频信号 分开,分别供电话和上网使用,这样就能在同一根电话铜线上同时传输数据和语音信号了,在你上网的时候也不会影响到妈妈接电话。
而且,最重要的一点是,ADSL使用了电话不用的拓展频段,传输速度可以达到 512Kb/s ~ 1Mb/s,所以既能不占线,速度又快,对于在线缓存一些小视频,玩网页小游戏已经足够了。更重要的是网费价格也大幅下降了。ADSL的广泛应用开启了一个新的时代:宽带连接时代。
“宽带” 是相对于 “窄带” 而言的,它是一种高速传输标准(服务),当这个服务的网速(带宽)达到 1Mb/s,我们称这个服务为宽带。你可以认为,90年代的拨号上网用的基本上是窄带,00年代基本上都是宽带连接了。
宽带连接的盛行促进了一些企业发展为未来互联网龙头,例如腾讯:
百度:
00年代堪称网游的黄金时代,例如传奇类游戏鼻祖 盛大传奇:
DOTA 的前身 WOW 魔兽世界
在ASDL的加持下,以电话线形态为主的铜线曾经是互联网传输的"脊梁":
宽带的流行还引发了"网吧热",许多青少年都来网吧上QQ或打网游:
后来,在ADSL基础上,又升级出了ADSL2、ADSL2+,速率也一度能达到20Mbps。
再后来上网的人多了,人们觉得电话线传输太慢了,于是就换成了专用的网线(双绞线),又有了VDSL、VDSL2等一系列技术。这些技术,通常被统称为xDSL技术。
当然,那个时候大多数人的上网体验还是不及现在的,网络经常卡顿也是家常便饭。
10年代的上网方式:无线网络的兴起和光纤的应用
无线网络的兴起
乔布斯的 IPhone 一出,立马就爆发了移动端设备的狂潮。
为了满足日趋愈高的手机联网需求,Wi-Fi技术应运而生。
因为手机等等这些移动端设备(包括平板、笔记本电脑、智能手表等等)不能像台式机那样,拖着一条长长的网线来上网,所以一套无线的网络通信技术—— WIFI ,至关重要。
要做到无线上网,首先你的设备需要一个支持 WIFI 的无线网卡 (移动端设备通常都会内置无线网卡。台式电脑一般不内置,需要自己买,因为台式电脑可以直接连网线):
然后还需要一个无线路由器(Wireless Router),路由器是把信息从源穿过网络传递到目的,连接两个或多个网络的硬件设备。
无线路由器就是在宽带路由器上增加了能发射无线电的天线,使其具备一定范围内信号覆盖的能力。
无线路由器虽然和猫长得很像,但是两者作用是不同的,猫负责信号翻译,路由器负责信号传递。
无线路由器主要作用是网络互连和控制访问:
- 网络互连:互连局域网和广域网,实现不同网络互相通信。
路由器与连上 路由器WIFI 的所有设备组成的网络系统叫局域网(内网),而路由器之外的互联网叫广域网(公网):
像百度,淘宝,华为这些它们的服务器都架设在公网上,只要拿到他们的IP地址就可以任意访问,但我们的设备是在内网上的,内网、外网一开始并不知道相互的IP地址,都要拿到相互的IP地址才能互相访问,这时候路由器的作用就是充当内网与外网的"中介",转发内网、外网的IP地址和数据,让内网与外网互通。
而内网IP与公网IP之间的转换也大有学问,这个转换技术就叫 NAT(网络地址转换)技术。
外面的网线接猫,猫负责转换信号,无线路由器负责无线网络覆盖
- 控制访问:控制内网与外网的访问,监测网络流量,筛选过滤数据包
有了路由器,那么公网的服务器是不是都能任意访问内网设备?不是的! 路由器会不停地接收到来自公网任何地方发过来的数据包,很多数据包其实并不是我们想要的,它们只是借道中转,因为我们的路由器在它们的最佳路径内。
如何才能发送和接收我们期望的数据包?路由器在收到数据包后,会查一遍 转发表和路由表,看看这个数据包是不是和自己有关,有关就转发,无关就丢弃(这里的"丢弃"指的是传递给下一个路由器),这样就控制了数据包的转发。
有些时候数据包确实是发给自己的,但是来源不明,或者是已记录的网站不知道什么原因发过来的,我们很担心发来的是不是病毒一类的东西,怎么办?
防火墙技术能解决这个问题,它能根据安全规则决定是否允许数据包的传输,通过流量过滤、阻止恶意攻击和记录网络活动,保护内部网络免受外部威胁,防止未经授权的访问。这样就能实现控制外网对内网的访问,筛选并过滤数据包。
通过路由器,我们可以轻而易举地控制与外界的数据交流,就像在自己家开关家门一样,家门阻挡陌生人进来,外面有人敲门想进来,也是由我们自己决定开不开门。因此,我们也把路由器叫做网关(Gateway)。
当然网关还包括三层交换机和防火墙等等,这是后话了。
光纤的应用
宽带已经走入寻常百姓家,随着科技日新月异的发展,手机、电脑、平板、智能电视都在快速的更新换代,同时对网络接入和网速带来更高的需求。
之前我们提到,由于移动端设备火了,家庭路由器往往要连接好几台手机、电脑、还要加上网络电视,网络也是会"堵车"的!连的越多,数据传输越慢,网速越慢(带宽越低)。由铜线电缆组成的3G网络也不够用了。
怎么办?电信号不够快,我可以用光信号啊!
我们都知道光也是一种电磁波,也可以做模拟信号,而且它拥有全宇宙最快的速度——光速,传输数据基本上是一瞬间的事,可是如何实现光的运输呢?这个就要用到初中物理我们学到的光的全发射现象:
我们再设计一种用特殊材料制成的导线,这种导线能使光在里面都能发生全反射,这样就不会发生折射丢失数据了。我们把这种导线称为光纤。
出生在中国上海的英籍华人高锟,1966年发表论文《光频介质纤维表面波导》,提出用石英玻璃纤维(光纤)传送光信号来进行通信,可实现长距离、大容量通信。
到了2010年,由于我国“光进铜退”,铜线电缆的应用就渐渐地消失了。采用FTTB(光纤到楼)方式,光纤网络延伸到住宅楼,多个用户共享光路分支带宽,速率10-50M。全光网络建设开启,全国需要重新采用光纤网进行覆盖,这对通信业发展来说是个巨大的工程。
光纤也有了,接入方式也得改改!这就是 GPON 光纤接入 的由来,现在我们用的4G网原理都是 GPON,5G实际上也是 GPON 的拓展。
换过光纤的人都知道,运营商为了响应国家"提速降费"的号召,已经把带宽提升到了100M,甚至更高兆数。但是以前老的接入方式是支持不了这么高的带宽的。
初识网络
原文地址《如果让你来设计网络》
你是一台电脑,你的名字叫 A
很久很久之前,你不与任何其他电脑相连接,孤苦伶仃。
直到有一天,你希望与另一台电脑 B 建立通信,于是你们各开了一个网口,用一根网线连接了起来。
用一根网线连接起来怎么就能"通信"了呢?我可以给你讲 IO、讲中断、讲缓冲区,但这不是研究网络时该关心的问题。
如果你纠结,要么去研究一下操作系统是如何处理网络 IO 的,要么去研究一下包是如何被网卡转换成电信号发送出去的,要么就仅仅把它当做电脑里有个小人在开枪吧~
反正,你们就是连起来了,并且可以通信。
第一层:物理层 —— 集线器
有一天,一个新伙伴 C 加入了,但聪明的你们很快发现,可以每个人开两个网口,用一共三根网线,彼此相连。
随着越来越多的人加入,你发现身上开的网口实在太多了,而且网线密密麻麻,混乱不堪。(而实际上一台电脑根本开不了这么多网口,所以这种连线只在理论上可行,所以连不上的我就用红色虚线表示了,就是这么严谨哈哈~)
于是你们发明了一个中间设备,你们将网线都插到这个设备上,由这个设备做转发,就可以彼此之间通信了,本质上和原来一样,只不过网口的数量和网线的数量减少了,不再那么混乱。
你给它取名叫集线器,它仅仅是无脑将电信号转发到所有出口(广播),不做任何处理,你觉得它是没有智商的,因此把人家定性在了物理层。
由于转发到了所有出口,那 BCDE 四台机器怎么知道数据包是不是发给自己的呢?
首先,你要给所有的连接到交换机的设备,都起个名字。原来你们叫 ABCD,但现在需要一个更专业的,全局唯一的名字作为标识,你把这个更高端的名字称为 MAC 地址。
你的 MAC 地址是 aa-aa-aa-aa-aa-aa,你的伙伴 b 的 MAC 地址是 bb-bb-bb-bb-bb-bb,以此类推,不重复就好。
这样,A 在发送数据包给 B 时,只要在头部拼接一个这样结构的数据,就可以了。
B 在收到数据包后,根据头部的目标 MAC 地址信息,判断这个数据包的确是发给自己的,于是便收下。
其他的 CDE 收到数据包后,根据头部的目标 MAC 地址信息,判断这个数据包并不是发给自己的,于是便丢弃。
虽然集线器使整个布局干净不少,但原来我只要发给电脑 B 的消息,现在却要发给连接到集线器中的所有电脑,这样既不安全,又不节省网络资源。
第二层:数据链路层 —— 交换机
如果把这个集线器弄得更智能一些,只发给目标 MAC 地址指向的那台电脑,就好了。
虽然只比集线器多了这一点点区别,但看起来似乎有智能了,你把这东西叫做交换机。也正因为这一点点智能,你把它放在了另一个层级:数据链路层。
如上图所示,你是这样设计的。
交换机内部维护一张 MAC 地址表,记录着每一个 MAC 地址的设备,连接在其哪一个端口上。
MAC地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 3 |
aa-aa-aa-aa-aa-aa | 4 |
dd-dd-dd-dd-dd-dd | 5 |
假如你仍然要发给 B 一个数据包,构造了如下的数据结构从网口出去。
到达交换机时,交换机内部通过自己维护的 MAC 地址表,发现目标机器 B 的 MAC 地址 bb-bb-bb-bb-bb-bb 映射到了端口 1 上,于是把数据从 1 号端口发给了 B,完事~
你给这个通过这样传输方式而组成的小范围的网络,叫做以太网。
当然最开始的时候,MAC 地址表是空的,是怎么逐步建立起来的呢?
假如在 MAC 地址表为空是,你给 B 发送了如下数据:
由于这个包从端口 4 进入的交换机,所以此时交换机就可以在 MAC地址表记录第一条数据:
MAC:aa-aa-aa-aa-aa-aa-aa
端口:4
交换机看目标 MAC 地址(bb-bb-bb-bb-bb-bb)在地址表中并没有映射关系,于是将此包发给了所有端口,也即发给了所有机器。
之后,只有机器 B 收到了确实是发给自己的包,于是做出了响应,响应数据从端口 1 进入交换机,于是交换机此时在地址表中更新了第二条数据:
MAC:bb-bb-bb-bb-bb-bb
端口:1
过程如下:
经过该网络中的机器不断地通信,交换机最终将 MAC 地址表建立完毕~
随着机器数量越多,交换机的端口也不够了,但聪明的你发现,只要将多个交换机连接起来,这个问题就轻而易举搞定~
你完全不需要设计额外的东西,只需要按照之前的设计和规矩来,按照上述的接线方式即可完成所有电脑的互联,所以交换机设计的这种规则,真的很巧妙。你想想看为什么(比如 A 要发数据给 F)。
但是你要注意,上面那根红色的线,最终在 MAC 地址表中可不是一条记录呀,而是要把 EFGH 这四台机器与该端口(端口6)的映射全部记录在表中。
最终,两个交换机将分别记录 A ~ H 所有机器的映射记录。
左边的交换机
MAC地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 3 |
aa-aa-aa-aa-aa-aa | 4 |
dd-dd-dd-dd-dd-dd | 5 |
ee-ee-ee-ee-ee-ee | 6 |
ff-ff-ff-ff-ff-ff | 6 |
gg-gg-gg-gg-gg-gg | 6 |
hh-hh-hh-hh-hh-hh | 6 |
右边的交换机
MAC地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 1 |
aa-aa-aa-aa-aa-aa | 1 |
dd-dd-dd-dd-dd-dd | 1 |
ee-ee-ee-ee-ee-ee | 2 |
ff-ff-ff-ff-ff-ff | 3 |
gg-gg-gg-gg-gg-gg | 4 |
hh-hh-hh-hh-hh-hh | 6 |
这在只有 8 台电脑的时候还好,甚至在只有几百台电脑的时候,都还好,所以这种交换机的设计方式,已经足足支撑一阵子了。
但很遗憾,人是贪婪的动物,很快,电脑的数量就发展到几千、几万、几十万。
第三层:网络层 —— 路由器
交换机已经无法记录如此庞大的映射关系了。
此时你动了歪脑筋,你发现了问题的根本在于,连出去的那根红色的网线,后面不知道有多少个设备不断地连接进来,从而使得地址表越来越大。
那我可不可以让那根红色的网线,接入一个新的设备,这个设备就跟电脑一样有自己独立的 MAC 地址,而且同时还能帮我把数据包做一次转发呢?
这个设备就是路由器,它的功能就是,作为一台独立的拥有 MAC 地址的设备,并且可以帮我把数据包做一次转发,你把它定在了网络层。
注意,路由器的每一个端口,都有独立的 MAC 地址。
好了,现在交换机的 MAC 地址表中,只需要多出一条 MAC 地址 ABAB 与其端口的映射关系,就可以成功把数据包转交给路由器了,这条搞定。
那如何做到,把发送给 C 和 D,甚至是把发送给 DEFGH… 的数据包,统统先发送给路由器呢?
不难想到这样一个点子,假如电脑 C 和 D 的 MAC 地址拥有共同的前缀,比如分别是:
C 的 MAC 地址:FFFF-FFFF-CCCC
D 的 MAC 地址:FFFF-FFFF-DDDD
那我们就可以说,将目标 MAC 地址为 FFFF-FFFF-? 开头的,统统先发送给路由器。
这样是否可行呢?答案是否定的。
我们先从现实中 MAC 地址的结构入手,MAC地址也叫物理地址、硬件地址,它用于在网络上标识一个网卡,长度为 48 位,一般这样来表示:
00-16-EA-AE-3C-40
其中前 24 位(00-16-EA)代表网络硬件制造商的编号,后 24 位(AE-3C-40)是该厂家自己分配的,一般表示系列号。只要不更改自己的 MAC 地址,MAC 地址在世界是唯一的。形象地说,MAC地址就如同身份证上的身份证号码,具有唯一性。
那如果你希望向上面那样表示将目标 MAC 地址为 FFFF-FFFF-?开头的,统一从路由器出去发给某一群设备(后面会提到这其实是子网的概念),那你就需要要求某一子网下的人统统买一个厂商制造的设备,或者是要求厂商在生产网络设备烧录 MAC 地址时,提前按照你规划好的子网结构来定 MAC 地址,并且日后这个网络的结构都不能轻易改变。
这显然是不现实的。
于是你发明了一个新的地址,给每一台机器一个 32 位的编号,如:
11000000101010000000000000000001
你觉得有些不清晰,于是把它分成四个部分,中间用点相连。
11000000.10101000.00000000.00000001
你还觉得不清晰,于是把它转换成 10 进制。
192.168.0.1
最后你给了这个地址一个响亮的名字,IP 地址。现在每一台电脑,同时有自己的 MAC 地址,又有自己的 IP 地址,只不过 IP 地址是软件层面上的,可以随时修改,MAC 地址一般是无法修改的。
这样一个可以随时修改的 IP 地址,就可以根据你规划的网络拓扑结构,来调整了。
如上图所示,假如我想要发送数据包给 ABCD 其中一台设备,不论哪一台,我都可以这样描述,“将 IP 地址为 192.168.0 开头的全部发送给到路由器,之后再怎么转发,交给它!”,巧妙吧。
那交给路由器之后,路由器又是怎么把数据包准确转发给指定设备的呢?
别急我们慢慢来。
我们先给上面的组网方式中的每一台设备,加上自己的 IP 地址:
现在两个设备之间传输,除了加上数据链路层的头部之外,还要再增加一个网络层的头部。
假如 A 给 B 发送数据,由于它们直接连着交换机,所以 A 直接发出如下数据包,通过交换机送到B即可,其实网络层没有体现出作用。
但假如 A 给 C 发送数据,A 就需要先转交给路由器,然后再由路由器转交给 C。由于最底层的传输仍然需要依赖以太网,所以数据包是分成两段的。
A ~ 路由器这段的包如下:
路由器到 C 这段的包如下:
好了,上面说的两种情况(A->B,A->C),相信细心的读者应该会有不少疑问,下面我们一个个来展开。
A 给 C 发数据包,怎么知道是否要通过路由器转发呢?
答案:子网。
如果源 IP 与目的 IP 处于一个子网,直接将包通过交换机发出去。
如果源 IP 与目的 IP 不处于一个子网,就交给路由器去处理。
好,那现在只需要解决,什么叫处于一个子网就好了。
- 192.168.0.1 和 192.168.0.2 处于同一个子网
- 192.168.0.1 和 192.168.1.1 处于不同子网
这两个是我们人为规定的,即我们想表示,对于 192.168.0.1 来说:
http://192.168.0.xxx 开头的,就算是在一个子网,否则就是在不同的子网。
那对于计算机来说,怎么表达这个意思呢?于是人们发明了子网掩码的概念。
假如某台机器的子网掩码定为 255.255.255.0
这表示,将源 IP 与目的 IP 分别同这个子网掩码进行与运算,相等则是在一个子网,不相等就是在不同子网,就这么简单。
比如:
- A电脑:192.168.0.1 & 255.255.255.0 = 192.168.0.0
- B电脑:192.168.0.2 & 255.255.255.0 = 192.168.0.0
- C电脑:192.168.1.1 & 255.255.255.0 = 192.168.1.0
- D电脑:192.168.1.2 & 255.255.255.0 = 192.168.1.0
那么 A 与 B 在同一个子网,C 与 D 在同一个子网,但是 A 与 C 就不在同一个子网,与 D 也不在同一个子网,以此类推。
所以如果 A 给 C 发消息,A 和 C 的 IP 地址分别 & A 机器配置的子网掩码,发现不相等,则 A 认为 C 和自己不在同一个子网,于是把包发给路由器,就不管了,之后怎么转发,A 不关心。
A 如何知道,哪个设备是路由器?
答案:在 A 上要设置默认网关。
上一步 A 通过是否与 C 在同一个子网内,判断出自己应该把包发给路由器,那路由器的 IP 是多少呢?
其实说发给路由器不准确,应该说 A 会把包发给默认网关。
对 A 来说,A 只能直接把包发给同处于一个子网下的某个 IP 上,所以发给路由器还是发给某个电脑,对 A 来说也不关心,只要这个设备有个 IP 地址就行。
所以默认网关,就是 A 在自己电脑里配置的一个 IP 地址,以便在发给不同子网的机器时,发给这个 IP 地址。
仅此而已!
路由器如何知道C在哪里?
答案:路由表。
现在 A 要给 C 发数据包,已经可以成功发到路由器这里了,最后一个问题就是,路由器怎么知道,收到的这个数据包,该从自己的哪个端口出去,才能直接(或间接)地最终到达目的地 C 呢。
路由器收到的数据包有目的 IP 也就是 C 的 IP 地址,需要转化成从自己的哪个端口出去,很容易想到,应该有个表,就像 MAC 地址表一样。
这个表叫路由表。
至于这个路由表是怎么出来的,有很多路由算法,本文不展开,因为我也不会哈哈~
不同于 MAC 地址表的是,路由表并不是一对一这种明确关系,我们下面看一个路由表的结构。
目的地址 | 子网掩码 | 下一跳 | 端口 |
---|---|---|---|
192.168.0.0 | 255.255.255.0 | 0 | |
192.168.0.254 | 255.255.255.255 | 0 | |
192.168.1.0 | 255.255.255.0 | 1 | |
192.168.1.254 | 255.255.255.255 | 1 |
我们学习一种新的表示方法,由于子网掩码的前多少位表示子网的网段,所以如 192.168.0.0(255.255.255.0) 也可以简写为 192.168.0.0/24 :
目的地址 | 下一跳 | 端口 |
---|---|---|
192.168.0.0/24 | 0 | |
192.168.0.254/32 | 0 | |
192.168.1.0/24 | 1 | |
192.168.1.254/32 | 1 |
这就很好理解了,路由表就表示,http://192.168.0.xxx 这个子网下的,都转发到 0 号端口,http://192.168.1.xxx 这个子网下的,都转发到 1 号端口。下一跳列还没有值,我们先不管
配合着结构图来看(这里把子网掩码和默认网关都补齐了)
刚才说的都是 IP 层,但发送数据包的数据链路层需要知道 MAC 地址,可是我只知道 IP 地址该怎么办呢?
答案:ARP
假如你(A)此时不知道你同伴 B 的 MAC 地址(现实中就是不知道的,刚刚我们只是假设已知),你只知道它的 IP 地址,你该怎么把数据包准确传给 B 呢?
答案很简单,在网络层,我需要把 IP 地址对应的 MAC 地址找到,也就是通过某种方式,找到 192.168.0.2 对应的 MAC 地址 BBBB。
这种方式就是 ARP 协议,同时电脑 A 和 B 里面也会有一张 ARP 缓存表,表中记录着 IP 与 MAC 地址的对应关系。
IP地址 | MAC地址 |
---|---|
192.168.0.2 | BBBB |
一开始的时候这个表是空的,电脑 A 为了知道电脑 B(192.168.0.2)的 MAC 地址,将会广播一条 ARP 请求,B 收到请求后,带上自己的 MAC 地址给 A 一个响应。此时 A 便更新了自己的 ARP 表。
这样通过大家不断广播 ARP 请求,最终所有电脑里面都将 ARP 缓存表更新完整。
于是网络数据就可以在路由器之间畅通无阻了~
第四层:传输层 —— TCP 与 UDP 协议
完事具备,只欠东风,现在我们要解决数据如何传输的问题。
因为网络层只能提供主机到主机的通信服务,但我们的网络通信,严格的说 ,是主机中的一个进程和另一个主机中的进程通信。
什么叫"进程与进程之间的通信"呢?进程通常被认为是一个正在执行的程序实例。 如下图所示,你可以打开电脑上的 “任务管理器”,表上列出来的都叫进程。
75+6=81,一共有 81 个进程在运行!我们怎么确定哪一个才是 需要网络通信的进程 呢?
传输协议应运而生!按照传输形式分类,有 TCP 协议 和 UDP 协议 这两种。
什么是"协议"
协议是一种约定
在之前打电话要花钱,接电话是不需要花钱的
假设你的手机里只有 20 30块话费,因为花费很少所以避免自己打电话
所以你在向家里打电话时
若响一声就挂掉,说明给家里报平安
若响两声才挂掉,说明你没钱了,该打钱了
若响三声才挂掉,说明是其他特殊事情
将约定做好,相隔几百里,通过曾经约定好的事情 快速形成共识 做出约定的动作,被称为协议
协议本质是为了提高协同效率TCP 协议:保障你数据传输的可靠利器
TCP(Transmission Control Protocol 传输控制协议)是一种基于IP的传输层协议,TCP协议是面向连接、正面确认与重传、缓冲机制、流量控制、差错控制、拥塞控制,可保证高可靠性(数据无丢失、数据无失序、数据无错误、数据无重复到达) 传输层协议。
简单来说:是用来运输数据的,会对数据的传输进⾏⼀个详细的控制,保证数据完整、正确地送达目的进程。
TCP 协议是一个"完美主义者",它非常注重数据的完整性,容不得差错,所以每次发送数据之前,都要在数据前面嵌入一段裹的像"胖宝宝"一样的 TCP 头,来验证每次传递时数据的完整性,然后再把数据送达到进程上:
同时,TCP 协议为了保证进程间通信万无一失,防止出现设备/进程不明原因没连上,或者断网,失去连接 导致浪费网络资源的情况,甚至规定了 连接时要"三次握手",断开时要"四次挥手":
而在传输层之上的应用层,有好多协议都是建立在 TCP 协议 的基础上的:
端口号 | 协议 | 说明 | 备注 |
---|---|---|---|
7 | Echo | 将收到的数据报发回发送端 | 常用于网络调试与检测 |
20 | FTP(Data) | 文件传送协议(数据连接) | 用于上传、下载数据,这两个端口常常是一起用的 |
21 | FTP(Control) | 文件传送协议(控制连接) | 用于发送FTP命令信息,这两个端口常常是一起用的 |
23 | TELNET | 远程登录 | 用于远程桌面软件(向日葵,ToDesk等) |
25 | SMTP | 简单邮件传送协议 | 用于"推"邮箱,将电子邮件发送到服务器 |
53 | DNS | 域名服务 | 用于域名(网址)与IP的转换,例如 www.baidu |
80 | HTTP | 超文本传送协议 | 常用于网页 |
110 | POP3 | 邮件传送协议 | 用于"拉"邮箱,主动从服务器上拉取邮件 |
TCP协议在生活中很常见,文件传输、电子邮件、远程登录等等这些都依赖于TCP协议。
UDP 协议:不可靠但高速的运输
相比TCP协议,UDP协议则放宽了很多,它的协议头很简单,没有TCP协议那么的条条框框:
UDP主要特点是无连接,不保证可靠传输和面向报文:
虽然说用 UDP 协议很容易出现数据包丢失的情况,但也正由于它速度快的特点 (无需多重验证,不需要等待数据完整发送),视频会议和直播,广播与多播,网络游戏,甚至 QQ收发消息也是用的 UDP协议(QQ有一套保证完整消息正确收发的机制)。
TCP协议 和 UDP协议 建立了数据连接的通道,为后面应用层处理数据打下了根基。
第五层:应用层 —— 数据处理与过滤
研发网络应用程序的核心是写出能够运行在不同的端系统和通过网络彼此通信的程序。所以我们写程序时只会用到应用层和传输层,底层(网络层,数据链路层,物理层)我们接触不到。
在应用层,不同的网络应用有不同的要求,应用层就是处理这个的:
顺带一提,上文提到的防火墙也属于应用层。
网络应用程序体系结构
现在我们来想想,既然我们要写一个 QQ程序,应该怎么写呢?首先要解决"对象"问题:谁负责接收消息?谁负责发送消息?
解决此问题,有三种架构:
- 客户-服务端架构(Client-Server,简称 C/S 架构)
- 端到端架构(Peer to peer, 简称 P2P 架构)
- 混合架构(C/S架构与P2P架构的混合)
我们要用 第三种架构(混合架构) 实现一个 QQ 。
应用层的组成
- Web和HTTP
- Email电子邮件
- DNS
- FTP文件传输
- 视频流
- Socket套接字编程
由 应用层->传输层->网络层->数据链路层->物理层 组成的链状体系,我们叫它网络五层模型。
Socket编程:如何做QQ
既然是初级版,我们也不搞这么复杂,直接用 DevC++ 写。
Socket (套接字) 本质上是一个 IP地址 + 端口 组成的对象,我们用这个对象来实现网络会话 (Session)。
所有 Socket 相关的编程技术,我们就叫它 Socket编程。
局域网(内网)通信
初识 Winsock 库
Windows Socket 是在 Windows系统 下的一套网络编程技术,要使用这个 Windows Socket,我们就要调用 winsock2.h (winsock有两个版本:1991年的 winsock 1.0 和 1995年的 winsock 2.0,我们这里用 2.0 版本)
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
我们使用 Dev C++ 进行C语言编程时,如果我们引入的库是C语言标准库(例如 stdio.h, stdlib.h, string.h 这些),那我们是不用在编译器选项中进行额外的设置的,但是如果我们使用的是一些不是C语言标准库,那我们可能就需要在编译器选择中进行设置。
工具 -> 编译选项 -> “在连接器命令行加入以下命令” -> 加上 -lws2_32
注意:这里如果不加会导致编译失败!因为调用 winsock2.h 里面的函数,需要在命令行链接相关的 DLL !
初始化设备
winsock 在使用前,必须先初始化设备:
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
}
创建套接字
现在,我们要正式开始创建 socket 对象了!
使用 socket() 函数创建 socket 对象:
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建一个 TCP socket 对象
SOCKET Socket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
// socket(af, type, protocol)
// 第一个参数 af 表示 IP地址如何表示,我们使用 IPV4(AF_INET),IPV6(AF_INET6)暂时不用
// 第二个参数 type 表示使用 socket 类型,常见的有 TCP(SOCK_STREAM) 和 UDP(SOCK_DGRAM)
// 第三个参数 protocol 表示使用的协议,IPPROTO_TCP 表示使用 TCP 协议,如果不想指定协议,可以填 0
// 这里表示:我要创建一个" 网络地址用 IPV4 表示,协议用 TCP协议 " 的 socket
}
这样就可以了吗?当然不行!
前文我们提到过,为了解决"谁发送,谁接收"的问题上,我们提出了三种架构:C/S (客户端-服务端)架构、P2P (端到端)架构、混合架构。
我们先不急着实现混合架构,简化问题,先考虑最简单的C/S架构,即服务端如何发送消息到客户端上呢?
服务端本质上是一台电脑(主机),客户端本质上是另一台电脑(主机),为了解决这个问题,我们需要准备两份代码:一份用于 Client 客户端,一份用于 Server 服务端。
新建两个文件:
Client.cpp 表示客户端代码;
Server.cpp 表示服务端代码。
我们先写 Client 客户端:
客户端代码
目前我们的客户端只需要接收消息就行,所以只会用到下面三个函数:
- connect() 指定 IP+端口 连接到对应的服务端
- recv() 等待服务端发来的消息
- closesocket() 关闭连接,释放socket对象
// Client.cpp 客户端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ClientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// socket(af, type, protocol)
// 第一个参数 af 表示 IP地址如何表示,我们使用 IPV4(AF_INET),IPV6(AF_INET6)暂时不用
// 第二个参数 type 表示使用 socket 类型,常见的有 TCP(SOCK_STREAM) 和 UDP(SOCK_DGRAM)
// 第三个参数 protocol 表示使用的协议,IPPROTO_TCP 表示使用 TCP 协议,如果不想显式指定协议,可以填 0
// 这里表示:我要创建一个" 网络地址用 IPV4 表示,协议用 TCP协议 " 的 socket
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(8888); // 绑定到 8888 端口
// 绑定到 本机 IP 127.0.0.1,这个叫 "回送地址",主要用于本机调试
// inet_addr 用于将 IPV4 地址的字符串 转换成 二进制数据
server_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// 将 socket 连接到本机服务端程序 127.0.0.1: 8888
int result = connect(ClientSocket, (sockaddr*)&server_addr, sizeof(server_addr));
// result = connect(s, name, len)
// 第一个参数 s 表示上文创建的 socket 对象
// 第二个参数 name 表示已填写目标 IP+端口 的结构体信息
// 第三个参数 len 表示第二个参数 name 结构体的长度
// 返回值 result 表示连接状态,没成功连接会返回 -1(SOCKET_ERROR)
// 如果连接失败就退出
if(result == -1)
{
// WSAGetLastError() 用于显示错误代码,它返回一个整数
// 你可以到 Microsoft Learn 文档上搜这个代码,查找具体错误原因
printf("连接失败!返回值是 %d", WSAGetLastError());
WSACleanup();
return -1;
}
printf("连接成功!\n");
printf("等待服务端发来的消息...\n\n");
// 用于接收消息的缓冲
char buffer[400];
// 接收消息
recv(ClientSocket,buffer,400,0);
// recv(s, buf, len, flags)
// 第一个参数 s 连接的 socket 对象
// 第二个参数 buf 用于接收消息的字符指针 (缓冲)
// 第三个参数 len 接收消息的长度,超过长度的不会进缓冲
// 第四个参数 flags 标志位,一般设 0
printf("服务端发送消息:%s",buffer);
// 关闭连接,释放 socket 对象
closesocket(ClientSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("任务结束!客户端已断开连接!\n");
system("pause");
return 0;
}
127.0.0.1 是一个回送地址,指本地机,一般主要用于网络软件测试以及本地机进程间通信,无论什么程序,一旦使用回送地址发送数据,协议软件立即返回,不进行任何网络传输。 127.0.0.1 不是公网 IP,它是内网IP,指的就是本地主机(你的电脑)
部分常见错误代码表 | ||
错误代码值 | 错误代码名称 | 说明 |
10049 | WSAEADDRNOTAVAIL | 无法分配地址,说明结构体里的IP或端口填错了,或者相应的IP或端口未开放,检查一下结构体的IP地址是否有效吧 (可在cmd上用 ping 指令检查) |
10051 | WSAENETUNREACH | 无法访问网络,可能是目的地址不是公网IP,不在同一局域网(内网)上,而且路由表上没这个IP,找不到地址(可用 ping 指令检查) |
10060 | WSAETIMEDOUT | 连接超时,说明本机或对方网不好,连接请求因为某种原因长时间未送达/答复 |
10061 | WSAECONNREFUSED | 连接被拒绝,说明本机没联网,或对方主机没开启服务端程序进行 accept,或者连接时被对方NAT、防火墙拦截了等等 |
10064 | WSAEHOSTDOWN | 主机已关闭,说明对方主机没开机,或者断网了 |
关于"错误代码"更多详情可以参考 [Microsoft Learn] Windows套接字错误代码表
服务端代码
客户端只要无脑 connect() 听命从事即可,可是服务端要考虑的事就多了:
- bind() 将 ServerSocket 绑定 本机IP 来监听客户端
- listen() 将 ServerSocket 转换到 LISTEN 监听状态
- accept() 等待 ClientSocket 客户端连接
- send() 向 ClientSocket 客户端发送消息
- closesocket() 关闭 ClientSocket 和 ServerSocket
// Server.cpp 服务端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ServerSocket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
// socket(af, type, protocol)
// 第一个参数 af 表示 IP地址如何表示,我们使用 IPV4(AF_INET),IPV6(AF_INET6)暂时不用
// 第二个参数 type 表示使用 socket 类型,常见的有 TCP(SOCK_STREAM) 和 UDP(SOCK_DGRAM)
// 第三个参数 protocol 表示使用的协议,IPPROTO_TCP 表示使用 TCP 协议,如果不想指定协议,可以填 0
// 这里表示:我要创建一个" 网络地址用 IPV4 表示,协议用 TCP协议 " 的 socket
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(8888); // 绑定到 8888 端口
// 绑定到 本机 IP 127.0.0.1,这个叫 "回送地址",主要用于本机调试
// inet_addr 用于将 IPV4 地址的字符串 转换成 二进制数据
server_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// 处理结果
int result;
// 将 socket 绑定到 127.0.0.1 : 8888, 服务端要用这个 IP 连接并监听客户端
result = bind(ServerSocket,(sockaddr*)&server_addr, sizeof(server_addr));
// result = bind(s, name, len)
// 第一个参数 s 表示上文创建的 socket 对象
// 第二个参数 name 表示要绑定的 IP+端口 的结构体信息
// 第三个参数 len 表示第二个参数 name 结构体的长度
// 返回值 result 表示绑定状态,没成功连接会返回 -1(SOCKET_ERROR),后面同理
if(result == -1)
{
printf("bind 绑定失败!返回值是 %d", WSAGetLastError());
WSACleanup();
return -1;
}
// 将套接字 ServerSocket 由 CLOSE 关闭状态 转换成 LISTEN 监听状态
// 经过 listen() 函数后,socket 会开启一个连接上限为 5 的连接队列
result = listen(ServerSocket,5);
// listen(s, backlog)
// 第一个参数 s 表示上文已经绑定 IP+端口 的 TCP socket
// 第二个参数 backlog 表示连接队列最大 socket 数量,这里填 5 就行
if(result == -1)
{
printf("listen 监听失败!返回值是 %d", WSAGetLastError());
WSACleanup();
return -1;
}
printf("服务端开始监听!等待客户端连接...\n");
// 进程等待,直到有客户端的连接
SOCKET ClientSocket = accept(ServerSocket,NULL,NULL);
// ClientSocket = accept(s, addr, len)
// 第一个参数 s 表示 LISTEN 监听状态下的 tcp socket
// 第二个参数 addr 表示返回的 ClientSocket 的 IP信息,填 NULL 表示不需要用到,不用输出
// 第三个参数 len 表示要接收的第二个参数 addr 的结构体长度,填 NULL 表示不需要,不用输出
// 客户端套接字有两种状态:
// Connecting 状态 表示未完成连接
// Established 状态 表示已完成连接
// 客户端找到服务端 IP地址后,会先进入服务端的连接队列,此时处于 Connecting 状态
// 建立正式连接后会转换成 Established 状态,并通知服务端
// 服务端每次 accept 操作会从连接队列取出 Established 状态的 socket
if(result == -1)
{
printf("accept 接受连接失败!返回值是 %d", WSAGetLastError());
WSACleanup();
return -1;
}
char buffer[255];
strcpy(buffer,"我是服务器");
// 向客户端 socket 发送消息
send(ClientSocket, buffer, strlen(buffer) + 1, 0);
// send(s, buf, len, flags)
// 第一个参数 s 表示要发送的目标 socket
// 第二个参数 buf 表示要发送的字符缓冲(字符串)
// 第三个参数 len 表示要发送的长度 (注意!因为 \0 也要发送,所以这里填 字符串长度 + 1)
// 第四个参数 flags 标志位,一般填 0
printf("已发送消息:%s\n", buffer);
// 关闭连接,释放 socket 对象
closesocket(ClientSocket); // 注意这里!先关闭 客户端 socket,否则消息会没发完!
closesocket(ServerSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!服务端已断开连接!\n");
system("pause");
return 0;
}
注意:先运行服务端程序 Server.cpp,再运行客户端程序 Client.cpp!
如果先运行客户端会报 10061 (连接被拒绝) 错误!
while 循环实现连续会话
我们使用 while 来实现服务端与客户端之间的连续会话,让服务端能发送多条消息给客户端:
服务端 Server.cpp:
// Server.cpp 服务端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ServerSocket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(8888); // 绑定到 8888 端口
// 绑定到 本机 IP 127.0.0.1,这个叫 "回送地址",主要用于本机调试
// inet_addr 用于将 IPV4 地址的字符串 转换成 二进制数据
server_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// 处理结果
int result;
// 将 socket 绑定到 127.0.0.1 : 8888, 服务端要用这个 IP 连接并监听客户端
result = bind(ServerSocket, (sockaddr*)&server_addr, sizeof(server_addr));
if(result == -1)
{
printf("bind 绑定失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
// 将套接字 ServerSocket 由 CLOSE 关闭状态 转换成 LISTEN 监听状态
// 经过 listen() 函数后,socket 会开启一个连接上限为 5 的连接队列
result = listen(ServerSocket,5);
if(result == -1)
{
printf("listen 监听失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
printf("服务端开始监听!等待客户端连接...\n");
// 客户端 IP 和 端口,后面有用
sockaddr_in clientaddr = {};
// 阻塞进程,直到有客户端的连接
SOCKET ClientSocket = accept(ServerSocket, (sockaddr*)&clientaddr, NULL);
if(result == -1)
{
printf("accept 接受连接失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
// inet_ntoa 将 IP地址 二进制数据 转换为 字符串
printf("连接成功!客户端 IP地址:%s\n",inet_ntoa(clientaddr.sin_addr));
printf("服务端现在可以发送消息了,按回车发送(输入\"exit\",按回车结束会话):\n\n");
char buffer[400];
while(1)
{
// fgets 读取一行用户的输入 到 buffer 中
fgets(buffer,400,stdin);
// 将末尾的 \n 替换成 \0
buffer[strlen(buffer)-1] = '\0';
// 向客户端 socket 发送消息
send(ClientSocket, buffer, strlen(buffer) + 1, 0);
// 如果输入"exit",结束循环
if(strcmp("exit",buffer)==0)
{
break;
}
printf("服务端:%s\n", buffer);
}
// 关闭连接,释放 socket 对象
closesocket(ClientSocket); // 注意这里!先关闭 客户端 socket,否则消息会没发完!
closesocket(ServerSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!服务端已断开连接!\n");
system("pause");
return 0;
}
客户端 Client.cpp:
// Client.cpp 客户端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ClientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(8888); // 绑定到 8888 端口
// 绑定到 本机 IP 127.0.0.1,这个叫 "回送地址",主要用于本机调试
// inet_addr 用于将 IPV4 地址的字符串 转换成 二进制数据
server_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// 将 socket 连接到本机服务端程序 127.0.0.1: 8888
int result = connect(ClientSocket, (sockaddr*)&server_addr, sizeof(server_addr));
// 如果连接失败就退出
if(result == -1)
{
printf("连接失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
printf("连接成功!\n");
printf("等待服务端 127.0.0.1 发来的消息...\n\n");
// 用于接收消息的缓冲
char buffer[400];
while(1)
{
// 阻塞进程,等待消息
result = recv(ClientSocket,buffer,400,0);
// 如果 result 是 0, 说明服务端断开连接了
if(result == 0)
{
printf("服务端断开了连接!\n\n");
system("pause");
break;
}
if(strcmp(buffer,"exit")==0)
{
printf("服务端发送了退出请求!已断开连接!\n");
break;
}
printf("服务端发送消息:%s\n",buffer);
}
// 关闭连接,释放 socket 对象
closesocket(ClientSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!客户端已断开连接!\n");
system("pause");
return 0;
}
跨内网通信(内网穿透)
前文我们讲过,127.0.0.1 是一个内网地址,它仅仅只能用于本机调试,别人的设备不在同一局域网上,单用这个地址是做不成网络聊天的。
为什么要获取公网IP
我们可以使用 ipconfig 来查看本机的IP地址。按 Win + R 键,会弹出一个"运行"窗口,输入cmd,在弹出的 cmd 窗口上输入 ipconfig。
看看 IP,10.208.7.6,你以为别人用这个地址就可以找到你的主机了。
实际上还是不行的,cmd 上输入 ping 10.208.7.6,会显示请求超时:
2019年11月25日,互联网号码分配局(IANA)宣布 IPv4地址已经全部分配完毕,也就是说所有可用的 IP 地址,在 5 年前就已经分配完了。
更重要的是, IP地址还分了 5 类地址,面对全世界上亿的网民,更加不够用了。
怎么解决这个问题呢?人们想出了三种方法:
- 子网划分和子网掩码
- NAT 网络地址转换
- IPV6
内网IP都是以 10、172、192 开头的。
你可以复制你的本机IP地址到百度上,看看是不是一个内网地址:
我们 ipconfig 获得的 IPV4 地址,为啥是一个内网 IP?原因就是上文的 NAT。
每当有设备连接到 NAT设备(路由器),NAT设备会从 DHCP池(网络地址池)中分配一个内网 IP地址,当内网设备想要访问公网资源时,就通过 NAT技术 将 内网IP 映射为 路由器IP下的唯一的 IP+端口:
更多有关 内外网 以及 NAT 的知识可跳转 【计算机网络】网络基础
怎么办?我们需要一个公网IP。
如何获取公网 IP
- 端口转发
端口转发其实是在路由器上设置,将内网中的某个 IP+端口 映射到公网上。
我们这里不用调路由器这么麻烦,所以不用端口转发。
- 购买云服务器
最省事的方法,买台云服务器就有公网IP了,缺点是要花钱。
我们不花钱,所以我们不用买云服务器。
- 端口映射
原理是将公网上的 IP 地址 映射到 内网的某一 IP + 端口 上,使不同内网的设备通过该公网 IP地址 便能正常访问该内网设备。
要实现这个,我们要下载内网穿透软件,例如贝锐花生壳(当然有更好的选择也可以用其他的):
注册登录并实名认证后,点击新增映射:
这样就建立好了:
可以看到右边的诊断信息显示内网连接失败,解决需要两步:
1. 主机开放 8888 端口,防火墙入站规则添加 8888 端口,允许 8888 端口的数据包转发到服务器主机。
2. 服务端程序开启 accept 监听 8888 端口。
经历以上两步,再点击 诊断信息右边第三个按钮 刷新,就连接成功了。
防火墙开放端口
Win + R,输入 wf.msc,进入防火墙高级设置:
点击 “入站规则” -> “新建规则”:
这样就大功告成了:
接下来,我们来完善我们的最终程序!
客户端优化
增加了输入界面,增强报错提示:
// Client.cpp 客户端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
// 获取端口
void get_Port(u_short* result)
{
scanf("%hd",result);
while(getchar()!='\n');
}
// 获取 IP
void get_IP(char* buffer)
{
fgets(buffer,100,stdin);
buffer[strlen(buffer)-1] = '\0';
}
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ClientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
char IP[100]; // IP地址
u_short port; // 端口号
printf("\n连接到服务器:\n\n");
printf("请输入IP: ");
get_IP(IP);
printf("请输入端口: ");
get_Port(&port);
server_addr.sin_port = htons(port); // 填端口号
server_addr.sin_addr.S_un.S_addr = inet_addr(IP); // 填 IP
// 连接到客户端
int result = connect(ClientSocket, (sockaddr*)&server_addr, sizeof(server_addr));
if(-1 == result)
{
printf("\n无法连接到 %s: %hd !返回值是 %d \n", IP, port, WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
system("cls");
printf("连接成功!\n");
printf("等待服务端 %s: %hd 发来的消息...\n\n", IP, port);
// 用于接收消息的缓冲
char buffer[400];
while(1)
{
// 阻塞进程,等待消息
result = recv(ClientSocket,buffer,400,0);
// 如果 result 是 0, 说明服务端断开连接了
if(result == 0)
{
printf("服务端断开了连接!\n\n");
system("pause");
break;
}
if(strcmp(buffer,"exit")==0)
{
printf("服务端发送了退出请求!已断开连接!\n");
break;
}
printf("服务端发送消息:%s\n", buffer);
}
// 关闭连接,释放 socket 对象
closesocket(ClientSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!客户端已断开连接!\n");
system("pause");
return 0;
}
服务端优化
客户端 socket 管理数组
建立端口映射时,在公网做映射的服务器会向 本机服务器 发送几条 tcp 连接请求来做连通性检测(我们不知道这些连接请求具体有多少,可能有 2-5 个),短时间内会占用我们的几个 socket。如果我们只是弄了几个客户端 socket,那么程序很容易被这些无关紧要的连接请求 pass 掉,所以我们需要弄一个能管理多个 socket 的数组。
// 服务端 socket
SOCKET ServerSocket;
// 存放客户端 socket 的列表
SOCKET client_list[255];
// 客户端个数
int client_count = 0;
// 获取端口
void get_Port(u_short* result)
{
scanf("%hd",result);
while(getchar()!='\n');
}
// 获取 IP
void get_IP(char* buffer)
{
fgets(buffer,100,stdin);
buffer[strlen(buffer)-1] = '\0';
}
// 添加客户端
void AddClient(SOCKET client)
{
printf("\n客户端 %d 已连接!\n", client);
client_list[client_count++] = client;
}
// 删除客户端
void DelClient(int index)
{
printf("客户端 %d 已退出!\n",client_list[index]);
closesocket(client_list[index]);
for(int i=index+1; i<client_count; i++)
{
client_list[i-1] = client_list[i];
}
client_count--;
}
// 广播到所有客户端
void Boardcast(const char* msg)
{
int need_del_socket[255];
int count = 0;
for(int i=0; i<client_count; i++)
{
// 如果发送消息时,发现有些客户端已退出,先记录索引
if(send(client_list[i], msg, strlen(msg) + 1, 0) == SOCKET_ERROR)
{
need_del_socket[count++] = i;
}
}
// 如果客户端已退出,按索引删除 socket
if(count)
{
for(int i=0; i<count; i++)
{
DelClient(need_del_socket[i]);
}
}
}
多线程优化
我们修改服务端,在监听的同时,我们可以发送消息给其他已连接的客户端,一心多用。
accept() 接收连接 和 scanf() 输入 这两个函数都是阻塞函数,也就是说,在它们的任务没做完之前,程序是不会继续运行下去的。
如何做到一心多用,同时 accept 和 scanf,多个任务同时做呢?
答案:多线程。
怎么实现多线程?我们可以使用 windows.h 里面的 CreateThread 函数来创建线程:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
DWORD dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadID
);
参数的含义如下:
lpThreadAttrivutes: 指向SECURITY_ATTRIBUTES的指针,用于定义新线程的安全属性,一般设置成NULL;
dwStackSize:分配以字节数表示的线程堆栈的大小,默认值是0;
lpStartAddress:指向一个线程函数地址。每个线程都有自己的线程函数,线程函数是线程具体的执行代码;
lpParameter:传递给线程函数的参数;
dwCreationFlags:表示创建线程的运行状态,其中CREATE_SUSPEND表示挂起当前创建的线程,而0表示立即执行当前创建的进程;
lpThreadID:返回新创建的线程的ID编号;
如果函数调用成功,则返回新线程的句柄
一般来说,我们只会用到后面四个参数和返回值。
// 监听线程,用于监听客户端连接
DWORD WINAPI ListenThread(LPVOID args)
{
SOCKET client; // 用于接收 socket
printf("\n监听线程已开启!\n");
while(1)
{
// 阻塞线程,线程用来监听客户端的连接,但不会影响到主进程
client = accept(ServerSocket, NULL, NULL);
// 如果主进程准备退出 (执行到 closesocket),线程也准备退出
if(client == -1)
{
printf("\n监听线程已退出!\n");
break;
}
// 有客户端连接,就添加到列表里
AddClient(client);
}
}
int main()
{
// 创建并运行线程
HANDLE hThread = CreateThread(NULL, 0, ListenThread, NULL, 0, NULL);
}
最终优化代码
// Server.cpp 服务端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
// 服务端 socket
SOCKET ServerSocket;
// 存放客户端 socket 的列表
SOCKET client_list[255];
// 客户端个数
int client_count = 0;
// 获取端口
void get_Port(u_short* result)
{
scanf("%hd",result);
while(getchar()!='\n');
}
// 获取 IP
void get_IP(char* buffer)
{
fgets(buffer,100,stdin);
buffer[strlen(buffer)-1] = '\0';
}
// 添加客户端
void AddClient(SOCKET client)
{
printf("\n客户端 %d 已连接!\n", client);
client_list[client_count++] = client;
}
// 删除客户端
void DelClient(int index)
{
printf("客户端 %d 已退出!\n",client_list[index]);
closesocket(client_list[index]);
for(int i=index+1; i<client_count; i++)
{
client_list[i-1] = client_list[i];
}
client_count--;
}
// 广播到所有客户端
void Boardcast(const char* msg)
{
int need_del_socket[255];
int count = 0;
for(int i=0; i<client_count; i++)
{
// 如果发送消息时,发现有些客户端已退出,先记录索引
if(send(client_list[i], msg, strlen(msg) + 1, 0) == SOCKET_ERROR)
{
need_del_socket[count++] = i;
}
}
// 如果客户端已退出,按索引删除 socket
if(count)
{
for(int i=0; i<count; i++)
{
DelClient(need_del_socket[i]);
}
}
}
// 监听线程,用于监听客户端连接
DWORD WINAPI ListenThread(LPVOID args)
{
SOCKET client; // 用于接收 socket
printf("\n监听线程已开启!\n");
while(1)
{
// 阻塞线程,线程用来监听客户端的连接,但不会影响到主进程
client = accept(ServerSocket, NULL, NULL);
// 如果主进程准备退出 (执行到 closesocket),线程也准备退出
if(client == -1)
{
printf("\n监听线程已退出!\n");
break;
}
// 有客户端连接,就添加到列表里
AddClient(client);
}
}
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
ServerSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
char IP[100]; // IP地址
u_short port; // 端口号
printf("\n服务器绑定端口:\n\n");
printf("请输入IP: ");
get_IP(IP);
printf("请输入端口: ");
get_Port(&port);
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(port); // 填端口号
server_addr.sin_addr.S_un.S_addr = inet_addr(IP); // 填 IP
// 将 socket 绑定到指定 IP+端口, 服务端要用这个 IP 连接并监听客户端
int result = bind(ServerSocket, (sockaddr*)&server_addr, sizeof(server_addr));
if(result == -1)
{
printf("bind 绑定失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
// 将套接字 ServerSocket 由 CLOSE 关闭状态 转换成 LISTEN 监听状态
// 经过 listen() 函数后,socket 会开启一个连接上限为 5 的连接队列
result = listen(ServerSocket,5);
if(result == -1)
{
printf("listen 监听失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
system("cls");
// 创建并运行线程
HANDLE hThread = CreateThread(NULL, 0, ListenThread, NULL, 0, NULL);
Sleep(100);
printf("\n按回车发送(输入\"exit\",按回车结束会话):\n\n");
// 字符缓冲,用于输入字符串
char buffer[400];
while(1)
{
// fgets 读取一行用户的输入 到 buffer 中
fgets(buffer,400,stdin);
// 将末尾的 \n 替换成 \0
buffer[strlen(buffer)-1] = '\0';
// 广播消息到所有已连接客户端
Boardcast(buffer);
// 如果输入"exit",结束循环
if(strcmp("exit",buffer)==0)
{
break;
}
printf("服务端:%s\n", buffer);
}
// 关闭连接,释放 socket 对象
closesocket(ServerSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!服务端已断开连接!\n");
system("pause");
return 0;
}
最终测试
内网测试
不开花生壳的情况下,用回送地址 127.0.0.1 测试客户端和服务端
跨内网测试
开花生壳映射下,远程桌面和服务端主机 (不在同一内网) 使用 映射公网IP 连接
刚开始连接的时候有点不稳定,客户端容易映射到带连通性请求的端口,导致没发送几句话就直接退出,要等服务端手动响应那几个请求,后面的客户端连接才稳定。
下一篇教程,我们将直奔 GUI 图形用户界面,做一个真正意义上的QQ聊天
本节教程最终代码及成品下载:https://wwek.lanzoue/iqQgQ2hvibwh
用C语言做QQ (初级版)
目录
- 用C语言做QQ (初级版)
- 网络基础入门:什么是网络
- 网络简史
- 网络的诞生
- 90年代的上网方式:电话拨号上网
- 00年代的上网方式:宽带连接上网
- 10年代的上网方式:无线网络的兴起和光纤的应用
- 无线网络的兴起
- 光纤的应用
- 初识网络
- 第一层:物理层 —— 集线器
- 第二层:数据链路层 —— 交换机
- 第三层:网络层 —— 路由器
- 第四层:传输层 —— TCP 与 UDP 协议
- 什么是"协议"
- TCP 协议:保障你数据传输的可靠利器
- UDP 协议:不可靠但高速的运输
- 第五层:应用层 —— 数据处理与过滤
- 网络应用程序体系结构
- 应用层的组成
- Socket编程:如何做QQ
- 局域网(内网)通信
- 初识 Winsock 库
- 初始化设备
- 创建套接字
- 客户端代码
- 服务端代码
- while 循环实现连续会话
- 跨内网通信(内网穿透)
- 为什么要获取公网IP
- 如何获取公网 IP
- 防火墙开放端口
- 客户端优化
- 服务端优化
- 客户端 socket 管理数组
- 多线程优化
- 最终优化代码
- 最终测试
- 内网测试
- 跨内网测试
网络基础入门:什么是网络
网络简史
网络的诞生
1957 年 10 月 4 日,苏联发射了人类第一颗人造卫星 “斯普特尼克一号” 。这颗卫星的升空,轰动了整个世界,也让当时的美国政府震惊不已。他们恐惧:在日趋激烈的冷战对抗中,自己将全面落后于苏联。
为了扭转这一局面,美国国防部于 1969 年 研究一种 “分布式” 的指挥系统 ARPANET(阿帕网)。它由无数的节点组成,当若干节点被摧毁后,其它节点仍能相互通信。 这便是最早网络的诞生。
20世纪60年代早期,计算机还是独立模式,计算机之间相互独立,并不能进行相互通信,只提供终端和主机之间的通信。
20世纪60年代中期,随着时代的发展, 越来越需要计算机之间相互通信、共享软件和数据了,即以多个计算机协同工作来完成业务,就有了网络互连。
网络互连:将多台计算机连接在一起,完成数据共享。
数据共享本质是网络数据传输,即计算机之间通过网络来传输数据,也称为网络通信。
70年代末,微型计算机问世,更是加速了网络的发展。
1991年8月6日,在瑞士日内瓦的核子研究中心(CERN)工作的英国物理学家蒂姆·伯纳斯·李(Tim Berners-Lee),正式提出了World Wide Web,也就是如今我们非常熟悉的www万维网。
他还提出了 HTTP(超文本传送协议) 和 HTML(超文本标记语言),设计了第一个网页浏览器,并建立了世界上第一个web网站。
1992年,几个因特网组织合并,成立因特网协会ISOC。此时的因特网,已经拥有超过100万台主机,并持续指数级疯狂增长。
Internet,真正变成了全球互联网,开始走进人们的生活。
如今,全球互联网用户数已经达到45.4亿,普及率超过59%。与其说它是一场技术革命,它更像是一场社会革命。它颠覆了整个社会的运作模式,推动了人类文明的跨越式发展。
90年代的上网方式:电话拨号上网
上世纪90年代,刚有互联网的时候,老百姓上网使用最为普遍的一种方式是电话拨号上网。
在90年代,由于互联网的用户不多,架设网线的成本没有办法分摊,所以当时并没有互联网专用线路。
网民只能借着已经发展了将近一个世纪的 电话网络 来帮助自己上网,也就是拔掉座机电话线,连到电脑上。
可是,电话线传来的信号是 模拟信号(也就是电磁波),但电脑只能识别 数字信号(也就是0和1),想让电脑正确显示网络的内容,就必须用到这两种信号的翻译官—— 调制解调器(Modem),也就是我们现在俗称的 “猫”。
当你要上网的时候,"猫"首先将计算机的数字信号进行调制 (数字信号转换成模拟信号) ,然后传输到相应的终端,再通过解调 (模拟信号转换成数字信号) ,从而实现网络数据的传输。
早期上网算是比较麻烦的,需要把座机的电话线拆下来,接到电脑上,然后还要向本地运营商申请接入号,最后拨通接入号才行。
由于早期设备的原因,拨号上网的特点是贵和慢:
贵:那个时候的电话费相当贵,1994年的网费高达28元每小时,直到1998年末才降到每小时3-5元不等,即使这样对于当时的家庭用户来说也是一笔不小的费用,寒假上一个月网就能产生3000元的电话费账单了,学生上网上久了会被家长揍。
慢:早期家用猫的速度最快也只能跑到 56KB/s,相当于网上一个五分钟的视频至少要3个小时才能下载完,对现在 几MB/s 的速度来说这速度是非常不够看的。
早期由于网络和电话用到同一根电话线,所以早期上网时会接不到电话,向你打电话的那个人会收到"电话已占线"的消息,甚至有时候你的电脑会直接断网,非常不方便。
所以当时的"上网"对老百姓来说只是一个烧钱还没体验的"奢侈品"罢了。
直到 ADSL拨号 的出现才使上网变得简单一些。
00年代的上网方式:宽带连接上网
ADSL拨号与传统拨号的最大不同就是多了一个 “分离器”,它把电话线路能传输的 低频信号 和 高频信号 分开,分别供电话和上网使用,这样就能在同一根电话铜线上同时传输数据和语音信号了,在你上网的时候也不会影响到妈妈接电话。
而且,最重要的一点是,ADSL使用了电话不用的拓展频段,传输速度可以达到 512Kb/s ~ 1Mb/s,所以既能不占线,速度又快,对于在线缓存一些小视频,玩网页小游戏已经足够了。更重要的是网费价格也大幅下降了。ADSL的广泛应用开启了一个新的时代:宽带连接时代。
“宽带” 是相对于 “窄带” 而言的,它是一种高速传输标准(服务),当这个服务的网速(带宽)达到 1Mb/s,我们称这个服务为宽带。你可以认为,90年代的拨号上网用的基本上是窄带,00年代基本上都是宽带连接了。
宽带连接的盛行促进了一些企业发展为未来互联网龙头,例如腾讯:
百度:
00年代堪称网游的黄金时代,例如传奇类游戏鼻祖 盛大传奇:
DOTA 的前身 WOW 魔兽世界
在ASDL的加持下,以电话线形态为主的铜线曾经是互联网传输的"脊梁":
宽带的流行还引发了"网吧热",许多青少年都来网吧上QQ或打网游:
后来,在ADSL基础上,又升级出了ADSL2、ADSL2+,速率也一度能达到20Mbps。
再后来上网的人多了,人们觉得电话线传输太慢了,于是就换成了专用的网线(双绞线),又有了VDSL、VDSL2等一系列技术。这些技术,通常被统称为xDSL技术。
当然,那个时候大多数人的上网体验还是不及现在的,网络经常卡顿也是家常便饭。
10年代的上网方式:无线网络的兴起和光纤的应用
无线网络的兴起
乔布斯的 IPhone 一出,立马就爆发了移动端设备的狂潮。
为了满足日趋愈高的手机联网需求,Wi-Fi技术应运而生。
因为手机等等这些移动端设备(包括平板、笔记本电脑、智能手表等等)不能像台式机那样,拖着一条长长的网线来上网,所以一套无线的网络通信技术—— WIFI ,至关重要。
要做到无线上网,首先你的设备需要一个支持 WIFI 的无线网卡 (移动端设备通常都会内置无线网卡。台式电脑一般不内置,需要自己买,因为台式电脑可以直接连网线):
然后还需要一个无线路由器(Wireless Router),路由器是把信息从源穿过网络传递到目的,连接两个或多个网络的硬件设备。
无线路由器就是在宽带路由器上增加了能发射无线电的天线,使其具备一定范围内信号覆盖的能力。
无线路由器虽然和猫长得很像,但是两者作用是不同的,猫负责信号翻译,路由器负责信号传递。
无线路由器主要作用是网络互连和控制访问:
- 网络互连:互连局域网和广域网,实现不同网络互相通信。
路由器与连上 路由器WIFI 的所有设备组成的网络系统叫局域网(内网),而路由器之外的互联网叫广域网(公网):
像百度,淘宝,华为这些它们的服务器都架设在公网上,只要拿到他们的IP地址就可以任意访问,但我们的设备是在内网上的,内网、外网一开始并不知道相互的IP地址,都要拿到相互的IP地址才能互相访问,这时候路由器的作用就是充当内网与外网的"中介",转发内网、外网的IP地址和数据,让内网与外网互通。
而内网IP与公网IP之间的转换也大有学问,这个转换技术就叫 NAT(网络地址转换)技术。
外面的网线接猫,猫负责转换信号,无线路由器负责无线网络覆盖
- 控制访问:控制内网与外网的访问,监测网络流量,筛选过滤数据包
有了路由器,那么公网的服务器是不是都能任意访问内网设备?不是的! 路由器会不停地接收到来自公网任何地方发过来的数据包,很多数据包其实并不是我们想要的,它们只是借道中转,因为我们的路由器在它们的最佳路径内。
如何才能发送和接收我们期望的数据包?路由器在收到数据包后,会查一遍 转发表和路由表,看看这个数据包是不是和自己有关,有关就转发,无关就丢弃(这里的"丢弃"指的是传递给下一个路由器),这样就控制了数据包的转发。
有些时候数据包确实是发给自己的,但是来源不明,或者是已记录的网站不知道什么原因发过来的,我们很担心发来的是不是病毒一类的东西,怎么办?
防火墙技术能解决这个问题,它能根据安全规则决定是否允许数据包的传输,通过流量过滤、阻止恶意攻击和记录网络活动,保护内部网络免受外部威胁,防止未经授权的访问。这样就能实现控制外网对内网的访问,筛选并过滤数据包。
通过路由器,我们可以轻而易举地控制与外界的数据交流,就像在自己家开关家门一样,家门阻挡陌生人进来,外面有人敲门想进来,也是由我们自己决定开不开门。因此,我们也把路由器叫做网关(Gateway)。
当然网关还包括三层交换机和防火墙等等,这是后话了。
光纤的应用
宽带已经走入寻常百姓家,随着科技日新月异的发展,手机、电脑、平板、智能电视都在快速的更新换代,同时对网络接入和网速带来更高的需求。
之前我们提到,由于移动端设备火了,家庭路由器往往要连接好几台手机、电脑、还要加上网络电视,网络也是会"堵车"的!连的越多,数据传输越慢,网速越慢(带宽越低)。由铜线电缆组成的3G网络也不够用了。
怎么办?电信号不够快,我可以用光信号啊!
我们都知道光也是一种电磁波,也可以做模拟信号,而且它拥有全宇宙最快的速度——光速,传输数据基本上是一瞬间的事,可是如何实现光的运输呢?这个就要用到初中物理我们学到的光的全发射现象:
我们再设计一种用特殊材料制成的导线,这种导线能使光在里面都能发生全反射,这样就不会发生折射丢失数据了。我们把这种导线称为光纤。
出生在中国上海的英籍华人高锟,1966年发表论文《光频介质纤维表面波导》,提出用石英玻璃纤维(光纤)传送光信号来进行通信,可实现长距离、大容量通信。
到了2010年,由于我国“光进铜退”,铜线电缆的应用就渐渐地消失了。采用FTTB(光纤到楼)方式,光纤网络延伸到住宅楼,多个用户共享光路分支带宽,速率10-50M。全光网络建设开启,全国需要重新采用光纤网进行覆盖,这对通信业发展来说是个巨大的工程。
光纤也有了,接入方式也得改改!这就是 GPON 光纤接入 的由来,现在我们用的4G网原理都是 GPON,5G实际上也是 GPON 的拓展。
换过光纤的人都知道,运营商为了响应国家"提速降费"的号召,已经把带宽提升到了100M,甚至更高兆数。但是以前老的接入方式是支持不了这么高的带宽的。
初识网络
原文地址《如果让你来设计网络》
你是一台电脑,你的名字叫 A
很久很久之前,你不与任何其他电脑相连接,孤苦伶仃。
直到有一天,你希望与另一台电脑 B 建立通信,于是你们各开了一个网口,用一根网线连接了起来。
用一根网线连接起来怎么就能"通信"了呢?我可以给你讲 IO、讲中断、讲缓冲区,但这不是研究网络时该关心的问题。
如果你纠结,要么去研究一下操作系统是如何处理网络 IO 的,要么去研究一下包是如何被网卡转换成电信号发送出去的,要么就仅仅把它当做电脑里有个小人在开枪吧~
反正,你们就是连起来了,并且可以通信。
第一层:物理层 —— 集线器
有一天,一个新伙伴 C 加入了,但聪明的你们很快发现,可以每个人开两个网口,用一共三根网线,彼此相连。
随着越来越多的人加入,你发现身上开的网口实在太多了,而且网线密密麻麻,混乱不堪。(而实际上一台电脑根本开不了这么多网口,所以这种连线只在理论上可行,所以连不上的我就用红色虚线表示了,就是这么严谨哈哈~)
于是你们发明了一个中间设备,你们将网线都插到这个设备上,由这个设备做转发,就可以彼此之间通信了,本质上和原来一样,只不过网口的数量和网线的数量减少了,不再那么混乱。
你给它取名叫集线器,它仅仅是无脑将电信号转发到所有出口(广播),不做任何处理,你觉得它是没有智商的,因此把人家定性在了物理层。
由于转发到了所有出口,那 BCDE 四台机器怎么知道数据包是不是发给自己的呢?
首先,你要给所有的连接到交换机的设备,都起个名字。原来你们叫 ABCD,但现在需要一个更专业的,全局唯一的名字作为标识,你把这个更高端的名字称为 MAC 地址。
你的 MAC 地址是 aa-aa-aa-aa-aa-aa,你的伙伴 b 的 MAC 地址是 bb-bb-bb-bb-bb-bb,以此类推,不重复就好。
这样,A 在发送数据包给 B 时,只要在头部拼接一个这样结构的数据,就可以了。
B 在收到数据包后,根据头部的目标 MAC 地址信息,判断这个数据包的确是发给自己的,于是便收下。
其他的 CDE 收到数据包后,根据头部的目标 MAC 地址信息,判断这个数据包并不是发给自己的,于是便丢弃。
虽然集线器使整个布局干净不少,但原来我只要发给电脑 B 的消息,现在却要发给连接到集线器中的所有电脑,这样既不安全,又不节省网络资源。
第二层:数据链路层 —— 交换机
如果把这个集线器弄得更智能一些,只发给目标 MAC 地址指向的那台电脑,就好了。
虽然只比集线器多了这一点点区别,但看起来似乎有智能了,你把这东西叫做交换机。也正因为这一点点智能,你把它放在了另一个层级:数据链路层。
如上图所示,你是这样设计的。
交换机内部维护一张 MAC 地址表,记录着每一个 MAC 地址的设备,连接在其哪一个端口上。
MAC地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 3 |
aa-aa-aa-aa-aa-aa | 4 |
dd-dd-dd-dd-dd-dd | 5 |
假如你仍然要发给 B 一个数据包,构造了如下的数据结构从网口出去。
到达交换机时,交换机内部通过自己维护的 MAC 地址表,发现目标机器 B 的 MAC 地址 bb-bb-bb-bb-bb-bb 映射到了端口 1 上,于是把数据从 1 号端口发给了 B,完事~
你给这个通过这样传输方式而组成的小范围的网络,叫做以太网。
当然最开始的时候,MAC 地址表是空的,是怎么逐步建立起来的呢?
假如在 MAC 地址表为空是,你给 B 发送了如下数据:
由于这个包从端口 4 进入的交换机,所以此时交换机就可以在 MAC地址表记录第一条数据:
MAC:aa-aa-aa-aa-aa-aa-aa
端口:4
交换机看目标 MAC 地址(bb-bb-bb-bb-bb-bb)在地址表中并没有映射关系,于是将此包发给了所有端口,也即发给了所有机器。
之后,只有机器 B 收到了确实是发给自己的包,于是做出了响应,响应数据从端口 1 进入交换机,于是交换机此时在地址表中更新了第二条数据:
MAC:bb-bb-bb-bb-bb-bb
端口:1
过程如下:
经过该网络中的机器不断地通信,交换机最终将 MAC 地址表建立完毕~
随着机器数量越多,交换机的端口也不够了,但聪明的你发现,只要将多个交换机连接起来,这个问题就轻而易举搞定~
你完全不需要设计额外的东西,只需要按照之前的设计和规矩来,按照上述的接线方式即可完成所有电脑的互联,所以交换机设计的这种规则,真的很巧妙。你想想看为什么(比如 A 要发数据给 F)。
但是你要注意,上面那根红色的线,最终在 MAC 地址表中可不是一条记录呀,而是要把 EFGH 这四台机器与该端口(端口6)的映射全部记录在表中。
最终,两个交换机将分别记录 A ~ H 所有机器的映射记录。
左边的交换机
MAC地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 3 |
aa-aa-aa-aa-aa-aa | 4 |
dd-dd-dd-dd-dd-dd | 5 |
ee-ee-ee-ee-ee-ee | 6 |
ff-ff-ff-ff-ff-ff | 6 |
gg-gg-gg-gg-gg-gg | 6 |
hh-hh-hh-hh-hh-hh | 6 |
右边的交换机
MAC地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 1 |
aa-aa-aa-aa-aa-aa | 1 |
dd-dd-dd-dd-dd-dd | 1 |
ee-ee-ee-ee-ee-ee | 2 |
ff-ff-ff-ff-ff-ff | 3 |
gg-gg-gg-gg-gg-gg | 4 |
hh-hh-hh-hh-hh-hh | 6 |
这在只有 8 台电脑的时候还好,甚至在只有几百台电脑的时候,都还好,所以这种交换机的设计方式,已经足足支撑一阵子了。
但很遗憾,人是贪婪的动物,很快,电脑的数量就发展到几千、几万、几十万。
第三层:网络层 —— 路由器
交换机已经无法记录如此庞大的映射关系了。
此时你动了歪脑筋,你发现了问题的根本在于,连出去的那根红色的网线,后面不知道有多少个设备不断地连接进来,从而使得地址表越来越大。
那我可不可以让那根红色的网线,接入一个新的设备,这个设备就跟电脑一样有自己独立的 MAC 地址,而且同时还能帮我把数据包做一次转发呢?
这个设备就是路由器,它的功能就是,作为一台独立的拥有 MAC 地址的设备,并且可以帮我把数据包做一次转发,你把它定在了网络层。
注意,路由器的每一个端口,都有独立的 MAC 地址。
好了,现在交换机的 MAC 地址表中,只需要多出一条 MAC 地址 ABAB 与其端口的映射关系,就可以成功把数据包转交给路由器了,这条搞定。
那如何做到,把发送给 C 和 D,甚至是把发送给 DEFGH… 的数据包,统统先发送给路由器呢?
不难想到这样一个点子,假如电脑 C 和 D 的 MAC 地址拥有共同的前缀,比如分别是:
C 的 MAC 地址:FFFF-FFFF-CCCC
D 的 MAC 地址:FFFF-FFFF-DDDD
那我们就可以说,将目标 MAC 地址为 FFFF-FFFF-? 开头的,统统先发送给路由器。
这样是否可行呢?答案是否定的。
我们先从现实中 MAC 地址的结构入手,MAC地址也叫物理地址、硬件地址,它用于在网络上标识一个网卡,长度为 48 位,一般这样来表示:
00-16-EA-AE-3C-40
其中前 24 位(00-16-EA)代表网络硬件制造商的编号,后 24 位(AE-3C-40)是该厂家自己分配的,一般表示系列号。只要不更改自己的 MAC 地址,MAC 地址在世界是唯一的。形象地说,MAC地址就如同身份证上的身份证号码,具有唯一性。
那如果你希望向上面那样表示将目标 MAC 地址为 FFFF-FFFF-?开头的,统一从路由器出去发给某一群设备(后面会提到这其实是子网的概念),那你就需要要求某一子网下的人统统买一个厂商制造的设备,或者是要求厂商在生产网络设备烧录 MAC 地址时,提前按照你规划好的子网结构来定 MAC 地址,并且日后这个网络的结构都不能轻易改变。
这显然是不现实的。
于是你发明了一个新的地址,给每一台机器一个 32 位的编号,如:
11000000101010000000000000000001
你觉得有些不清晰,于是把它分成四个部分,中间用点相连。
11000000.10101000.00000000.00000001
你还觉得不清晰,于是把它转换成 10 进制。
192.168.0.1
最后你给了这个地址一个响亮的名字,IP 地址。现在每一台电脑,同时有自己的 MAC 地址,又有自己的 IP 地址,只不过 IP 地址是软件层面上的,可以随时修改,MAC 地址一般是无法修改的。
这样一个可以随时修改的 IP 地址,就可以根据你规划的网络拓扑结构,来调整了。
如上图所示,假如我想要发送数据包给 ABCD 其中一台设备,不论哪一台,我都可以这样描述,“将 IP 地址为 192.168.0 开头的全部发送给到路由器,之后再怎么转发,交给它!”,巧妙吧。
那交给路由器之后,路由器又是怎么把数据包准确转发给指定设备的呢?
别急我们慢慢来。
我们先给上面的组网方式中的每一台设备,加上自己的 IP 地址:
现在两个设备之间传输,除了加上数据链路层的头部之外,还要再增加一个网络层的头部。
假如 A 给 B 发送数据,由于它们直接连着交换机,所以 A 直接发出如下数据包,通过交换机送到B即可,其实网络层没有体现出作用。
但假如 A 给 C 发送数据,A 就需要先转交给路由器,然后再由路由器转交给 C。由于最底层的传输仍然需要依赖以太网,所以数据包是分成两段的。
A ~ 路由器这段的包如下:
路由器到 C 这段的包如下:
好了,上面说的两种情况(A->B,A->C),相信细心的读者应该会有不少疑问,下面我们一个个来展开。
A 给 C 发数据包,怎么知道是否要通过路由器转发呢?
答案:子网。
如果源 IP 与目的 IP 处于一个子网,直接将包通过交换机发出去。
如果源 IP 与目的 IP 不处于一个子网,就交给路由器去处理。
好,那现在只需要解决,什么叫处于一个子网就好了。
- 192.168.0.1 和 192.168.0.2 处于同一个子网
- 192.168.0.1 和 192.168.1.1 处于不同子网
这两个是我们人为规定的,即我们想表示,对于 192.168.0.1 来说:
http://192.168.0.xxx 开头的,就算是在一个子网,否则就是在不同的子网。
那对于计算机来说,怎么表达这个意思呢?于是人们发明了子网掩码的概念。
假如某台机器的子网掩码定为 255.255.255.0
这表示,将源 IP 与目的 IP 分别同这个子网掩码进行与运算,相等则是在一个子网,不相等就是在不同子网,就这么简单。
比如:
- A电脑:192.168.0.1 & 255.255.255.0 = 192.168.0.0
- B电脑:192.168.0.2 & 255.255.255.0 = 192.168.0.0
- C电脑:192.168.1.1 & 255.255.255.0 = 192.168.1.0
- D电脑:192.168.1.2 & 255.255.255.0 = 192.168.1.0
那么 A 与 B 在同一个子网,C 与 D 在同一个子网,但是 A 与 C 就不在同一个子网,与 D 也不在同一个子网,以此类推。
所以如果 A 给 C 发消息,A 和 C 的 IP 地址分别 & A 机器配置的子网掩码,发现不相等,则 A 认为 C 和自己不在同一个子网,于是把包发给路由器,就不管了,之后怎么转发,A 不关心。
A 如何知道,哪个设备是路由器?
答案:在 A 上要设置默认网关。
上一步 A 通过是否与 C 在同一个子网内,判断出自己应该把包发给路由器,那路由器的 IP 是多少呢?
其实说发给路由器不准确,应该说 A 会把包发给默认网关。
对 A 来说,A 只能直接把包发给同处于一个子网下的某个 IP 上,所以发给路由器还是发给某个电脑,对 A 来说也不关心,只要这个设备有个 IP 地址就行。
所以默认网关,就是 A 在自己电脑里配置的一个 IP 地址,以便在发给不同子网的机器时,发给这个 IP 地址。
仅此而已!
路由器如何知道C在哪里?
答案:路由表。
现在 A 要给 C 发数据包,已经可以成功发到路由器这里了,最后一个问题就是,路由器怎么知道,收到的这个数据包,该从自己的哪个端口出去,才能直接(或间接)地最终到达目的地 C 呢。
路由器收到的数据包有目的 IP 也就是 C 的 IP 地址,需要转化成从自己的哪个端口出去,很容易想到,应该有个表,就像 MAC 地址表一样。
这个表叫路由表。
至于这个路由表是怎么出来的,有很多路由算法,本文不展开,因为我也不会哈哈~
不同于 MAC 地址表的是,路由表并不是一对一这种明确关系,我们下面看一个路由表的结构。
目的地址 | 子网掩码 | 下一跳 | 端口 |
---|---|---|---|
192.168.0.0 | 255.255.255.0 | 0 | |
192.168.0.254 | 255.255.255.255 | 0 | |
192.168.1.0 | 255.255.255.0 | 1 | |
192.168.1.254 | 255.255.255.255 | 1 |
我们学习一种新的表示方法,由于子网掩码的前多少位表示子网的网段,所以如 192.168.0.0(255.255.255.0) 也可以简写为 192.168.0.0/24 :
目的地址 | 下一跳 | 端口 |
---|---|---|
192.168.0.0/24 | 0 | |
192.168.0.254/32 | 0 | |
192.168.1.0/24 | 1 | |
192.168.1.254/32 | 1 |
这就很好理解了,路由表就表示,http://192.168.0.xxx 这个子网下的,都转发到 0 号端口,http://192.168.1.xxx 这个子网下的,都转发到 1 号端口。下一跳列还没有值,我们先不管
配合着结构图来看(这里把子网掩码和默认网关都补齐了)
刚才说的都是 IP 层,但发送数据包的数据链路层需要知道 MAC 地址,可是我只知道 IP 地址该怎么办呢?
答案:ARP
假如你(A)此时不知道你同伴 B 的 MAC 地址(现实中就是不知道的,刚刚我们只是假设已知),你只知道它的 IP 地址,你该怎么把数据包准确传给 B 呢?
答案很简单,在网络层,我需要把 IP 地址对应的 MAC 地址找到,也就是通过某种方式,找到 192.168.0.2 对应的 MAC 地址 BBBB。
这种方式就是 ARP 协议,同时电脑 A 和 B 里面也会有一张 ARP 缓存表,表中记录着 IP 与 MAC 地址的对应关系。
IP地址 | MAC地址 |
---|---|
192.168.0.2 | BBBB |
一开始的时候这个表是空的,电脑 A 为了知道电脑 B(192.168.0.2)的 MAC 地址,将会广播一条 ARP 请求,B 收到请求后,带上自己的 MAC 地址给 A 一个响应。此时 A 便更新了自己的 ARP 表。
这样通过大家不断广播 ARP 请求,最终所有电脑里面都将 ARP 缓存表更新完整。
于是网络数据就可以在路由器之间畅通无阻了~
第四层:传输层 —— TCP 与 UDP 协议
完事具备,只欠东风,现在我们要解决数据如何传输的问题。
因为网络层只能提供主机到主机的通信服务,但我们的网络通信,严格的说 ,是主机中的一个进程和另一个主机中的进程通信。
什么叫"进程与进程之间的通信"呢?进程通常被认为是一个正在执行的程序实例。 如下图所示,你可以打开电脑上的 “任务管理器”,表上列出来的都叫进程。
75+6=81,一共有 81 个进程在运行!我们怎么确定哪一个才是 需要网络通信的进程 呢?
传输协议应运而生!按照传输形式分类,有 TCP 协议 和 UDP 协议 这两种。
什么是"协议"
协议是一种约定
在之前打电话要花钱,接电话是不需要花钱的
假设你的手机里只有 20 30块话费,因为花费很少所以避免自己打电话
所以你在向家里打电话时
若响一声就挂掉,说明给家里报平安
若响两声才挂掉,说明你没钱了,该打钱了
若响三声才挂掉,说明是其他特殊事情
将约定做好,相隔几百里,通过曾经约定好的事情 快速形成共识 做出约定的动作,被称为协议
协议本质是为了提高协同效率TCP 协议:保障你数据传输的可靠利器
TCP(Transmission Control Protocol 传输控制协议)是一种基于IP的传输层协议,TCP协议是面向连接、正面确认与重传、缓冲机制、流量控制、差错控制、拥塞控制,可保证高可靠性(数据无丢失、数据无失序、数据无错误、数据无重复到达) 传输层协议。
简单来说:是用来运输数据的,会对数据的传输进⾏⼀个详细的控制,保证数据完整、正确地送达目的进程。
TCP 协议是一个"完美主义者",它非常注重数据的完整性,容不得差错,所以每次发送数据之前,都要在数据前面嵌入一段裹的像"胖宝宝"一样的 TCP 头,来验证每次传递时数据的完整性,然后再把数据送达到进程上:
同时,TCP 协议为了保证进程间通信万无一失,防止出现设备/进程不明原因没连上,或者断网,失去连接 导致浪费网络资源的情况,甚至规定了 连接时要"三次握手",断开时要"四次挥手":
而在传输层之上的应用层,有好多协议都是建立在 TCP 协议 的基础上的:
端口号 | 协议 | 说明 | 备注 |
---|---|---|---|
7 | Echo | 将收到的数据报发回发送端 | 常用于网络调试与检测 |
20 | FTP(Data) | 文件传送协议(数据连接) | 用于上传、下载数据,这两个端口常常是一起用的 |
21 | FTP(Control) | 文件传送协议(控制连接) | 用于发送FTP命令信息,这两个端口常常是一起用的 |
23 | TELNET | 远程登录 | 用于远程桌面软件(向日葵,ToDesk等) |
25 | SMTP | 简单邮件传送协议 | 用于"推"邮箱,将电子邮件发送到服务器 |
53 | DNS | 域名服务 | 用于域名(网址)与IP的转换,例如 www.baidu |
80 | HTTP | 超文本传送协议 | 常用于网页 |
110 | POP3 | 邮件传送协议 | 用于"拉"邮箱,主动从服务器上拉取邮件 |
TCP协议在生活中很常见,文件传输、电子邮件、远程登录等等这些都依赖于TCP协议。
UDP 协议:不可靠但高速的运输
相比TCP协议,UDP协议则放宽了很多,它的协议头很简单,没有TCP协议那么的条条框框:
UDP主要特点是无连接,不保证可靠传输和面向报文:
虽然说用 UDP 协议很容易出现数据包丢失的情况,但也正由于它速度快的特点 (无需多重验证,不需要等待数据完整发送),视频会议和直播,广播与多播,网络游戏,甚至 QQ收发消息也是用的 UDP协议(QQ有一套保证完整消息正确收发的机制)。
TCP协议 和 UDP协议 建立了数据连接的通道,为后面应用层处理数据打下了根基。
第五层:应用层 —— 数据处理与过滤
研发网络应用程序的核心是写出能够运行在不同的端系统和通过网络彼此通信的程序。所以我们写程序时只会用到应用层和传输层,底层(网络层,数据链路层,物理层)我们接触不到。
在应用层,不同的网络应用有不同的要求,应用层就是处理这个的:
顺带一提,上文提到的防火墙也属于应用层。
网络应用程序体系结构
现在我们来想想,既然我们要写一个 QQ程序,应该怎么写呢?首先要解决"对象"问题:谁负责接收消息?谁负责发送消息?
解决此问题,有三种架构:
- 客户-服务端架构(Client-Server,简称 C/S 架构)
- 端到端架构(Peer to peer, 简称 P2P 架构)
- 混合架构(C/S架构与P2P架构的混合)
我们要用 第三种架构(混合架构) 实现一个 QQ 。
应用层的组成
- Web和HTTP
- Email电子邮件
- DNS
- FTP文件传输
- 视频流
- Socket套接字编程
由 应用层->传输层->网络层->数据链路层->物理层 组成的链状体系,我们叫它网络五层模型。
Socket编程:如何做QQ
既然是初级版,我们也不搞这么复杂,直接用 DevC++ 写。
Socket (套接字) 本质上是一个 IP地址 + 端口 组成的对象,我们用这个对象来实现网络会话 (Session)。
所有 Socket 相关的编程技术,我们就叫它 Socket编程。
局域网(内网)通信
初识 Winsock 库
Windows Socket 是在 Windows系统 下的一套网络编程技术,要使用这个 Windows Socket,我们就要调用 winsock2.h (winsock有两个版本:1991年的 winsock 1.0 和 1995年的 winsock 2.0,我们这里用 2.0 版本)
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
我们使用 Dev C++ 进行C语言编程时,如果我们引入的库是C语言标准库(例如 stdio.h, stdlib.h, string.h 这些),那我们是不用在编译器选项中进行额外的设置的,但是如果我们使用的是一些不是C语言标准库,那我们可能就需要在编译器选择中进行设置。
工具 -> 编译选项 -> “在连接器命令行加入以下命令” -> 加上 -lws2_32
注意:这里如果不加会导致编译失败!因为调用 winsock2.h 里面的函数,需要在命令行链接相关的 DLL !
初始化设备
winsock 在使用前,必须先初始化设备:
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
}
创建套接字
现在,我们要正式开始创建 socket 对象了!
使用 socket() 函数创建 socket 对象:
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建一个 TCP socket 对象
SOCKET Socket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
// socket(af, type, protocol)
// 第一个参数 af 表示 IP地址如何表示,我们使用 IPV4(AF_INET),IPV6(AF_INET6)暂时不用
// 第二个参数 type 表示使用 socket 类型,常见的有 TCP(SOCK_STREAM) 和 UDP(SOCK_DGRAM)
// 第三个参数 protocol 表示使用的协议,IPPROTO_TCP 表示使用 TCP 协议,如果不想指定协议,可以填 0
// 这里表示:我要创建一个" 网络地址用 IPV4 表示,协议用 TCP协议 " 的 socket
}
这样就可以了吗?当然不行!
前文我们提到过,为了解决"谁发送,谁接收"的问题上,我们提出了三种架构:C/S (客户端-服务端)架构、P2P (端到端)架构、混合架构。
我们先不急着实现混合架构,简化问题,先考虑最简单的C/S架构,即服务端如何发送消息到客户端上呢?
服务端本质上是一台电脑(主机),客户端本质上是另一台电脑(主机),为了解决这个问题,我们需要准备两份代码:一份用于 Client 客户端,一份用于 Server 服务端。
新建两个文件:
Client.cpp 表示客户端代码;
Server.cpp 表示服务端代码。
我们先写 Client 客户端:
客户端代码
目前我们的客户端只需要接收消息就行,所以只会用到下面三个函数:
- connect() 指定 IP+端口 连接到对应的服务端
- recv() 等待服务端发来的消息
- closesocket() 关闭连接,释放socket对象
// Client.cpp 客户端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ClientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// socket(af, type, protocol)
// 第一个参数 af 表示 IP地址如何表示,我们使用 IPV4(AF_INET),IPV6(AF_INET6)暂时不用
// 第二个参数 type 表示使用 socket 类型,常见的有 TCP(SOCK_STREAM) 和 UDP(SOCK_DGRAM)
// 第三个参数 protocol 表示使用的协议,IPPROTO_TCP 表示使用 TCP 协议,如果不想显式指定协议,可以填 0
// 这里表示:我要创建一个" 网络地址用 IPV4 表示,协议用 TCP协议 " 的 socket
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(8888); // 绑定到 8888 端口
// 绑定到 本机 IP 127.0.0.1,这个叫 "回送地址",主要用于本机调试
// inet_addr 用于将 IPV4 地址的字符串 转换成 二进制数据
server_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// 将 socket 连接到本机服务端程序 127.0.0.1: 8888
int result = connect(ClientSocket, (sockaddr*)&server_addr, sizeof(server_addr));
// result = connect(s, name, len)
// 第一个参数 s 表示上文创建的 socket 对象
// 第二个参数 name 表示已填写目标 IP+端口 的结构体信息
// 第三个参数 len 表示第二个参数 name 结构体的长度
// 返回值 result 表示连接状态,没成功连接会返回 -1(SOCKET_ERROR)
// 如果连接失败就退出
if(result == -1)
{
// WSAGetLastError() 用于显示错误代码,它返回一个整数
// 你可以到 Microsoft Learn 文档上搜这个代码,查找具体错误原因
printf("连接失败!返回值是 %d", WSAGetLastError());
WSACleanup();
return -1;
}
printf("连接成功!\n");
printf("等待服务端发来的消息...\n\n");
// 用于接收消息的缓冲
char buffer[400];
// 接收消息
recv(ClientSocket,buffer,400,0);
// recv(s, buf, len, flags)
// 第一个参数 s 连接的 socket 对象
// 第二个参数 buf 用于接收消息的字符指针 (缓冲)
// 第三个参数 len 接收消息的长度,超过长度的不会进缓冲
// 第四个参数 flags 标志位,一般设 0
printf("服务端发送消息:%s",buffer);
// 关闭连接,释放 socket 对象
closesocket(ClientSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("任务结束!客户端已断开连接!\n");
system("pause");
return 0;
}
127.0.0.1 是一个回送地址,指本地机,一般主要用于网络软件测试以及本地机进程间通信,无论什么程序,一旦使用回送地址发送数据,协议软件立即返回,不进行任何网络传输。 127.0.0.1 不是公网 IP,它是内网IP,指的就是本地主机(你的电脑)
部分常见错误代码表 | ||
错误代码值 | 错误代码名称 | 说明 |
10049 | WSAEADDRNOTAVAIL | 无法分配地址,说明结构体里的IP或端口填错了,或者相应的IP或端口未开放,检查一下结构体的IP地址是否有效吧 (可在cmd上用 ping 指令检查) |
10051 | WSAENETUNREACH | 无法访问网络,可能是目的地址不是公网IP,不在同一局域网(内网)上,而且路由表上没这个IP,找不到地址(可用 ping 指令检查) |
10060 | WSAETIMEDOUT | 连接超时,说明本机或对方网不好,连接请求因为某种原因长时间未送达/答复 |
10061 | WSAECONNREFUSED | 连接被拒绝,说明本机没联网,或对方主机没开启服务端程序进行 accept,或者连接时被对方NAT、防火墙拦截了等等 |
10064 | WSAEHOSTDOWN | 主机已关闭,说明对方主机没开机,或者断网了 |
关于"错误代码"更多详情可以参考 [Microsoft Learn] Windows套接字错误代码表
服务端代码
客户端只要无脑 connect() 听命从事即可,可是服务端要考虑的事就多了:
- bind() 将 ServerSocket 绑定 本机IP 来监听客户端
- listen() 将 ServerSocket 转换到 LISTEN 监听状态
- accept() 等待 ClientSocket 客户端连接
- send() 向 ClientSocket 客户端发送消息
- closesocket() 关闭 ClientSocket 和 ServerSocket
// Server.cpp 服务端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ServerSocket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
// socket(af, type, protocol)
// 第一个参数 af 表示 IP地址如何表示,我们使用 IPV4(AF_INET),IPV6(AF_INET6)暂时不用
// 第二个参数 type 表示使用 socket 类型,常见的有 TCP(SOCK_STREAM) 和 UDP(SOCK_DGRAM)
// 第三个参数 protocol 表示使用的协议,IPPROTO_TCP 表示使用 TCP 协议,如果不想指定协议,可以填 0
// 这里表示:我要创建一个" 网络地址用 IPV4 表示,协议用 TCP协议 " 的 socket
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(8888); // 绑定到 8888 端口
// 绑定到 本机 IP 127.0.0.1,这个叫 "回送地址",主要用于本机调试
// inet_addr 用于将 IPV4 地址的字符串 转换成 二进制数据
server_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// 处理结果
int result;
// 将 socket 绑定到 127.0.0.1 : 8888, 服务端要用这个 IP 连接并监听客户端
result = bind(ServerSocket,(sockaddr*)&server_addr, sizeof(server_addr));
// result = bind(s, name, len)
// 第一个参数 s 表示上文创建的 socket 对象
// 第二个参数 name 表示要绑定的 IP+端口 的结构体信息
// 第三个参数 len 表示第二个参数 name 结构体的长度
// 返回值 result 表示绑定状态,没成功连接会返回 -1(SOCKET_ERROR),后面同理
if(result == -1)
{
printf("bind 绑定失败!返回值是 %d", WSAGetLastError());
WSACleanup();
return -1;
}
// 将套接字 ServerSocket 由 CLOSE 关闭状态 转换成 LISTEN 监听状态
// 经过 listen() 函数后,socket 会开启一个连接上限为 5 的连接队列
result = listen(ServerSocket,5);
// listen(s, backlog)
// 第一个参数 s 表示上文已经绑定 IP+端口 的 TCP socket
// 第二个参数 backlog 表示连接队列最大 socket 数量,这里填 5 就行
if(result == -1)
{
printf("listen 监听失败!返回值是 %d", WSAGetLastError());
WSACleanup();
return -1;
}
printf("服务端开始监听!等待客户端连接...\n");
// 进程等待,直到有客户端的连接
SOCKET ClientSocket = accept(ServerSocket,NULL,NULL);
// ClientSocket = accept(s, addr, len)
// 第一个参数 s 表示 LISTEN 监听状态下的 tcp socket
// 第二个参数 addr 表示返回的 ClientSocket 的 IP信息,填 NULL 表示不需要用到,不用输出
// 第三个参数 len 表示要接收的第二个参数 addr 的结构体长度,填 NULL 表示不需要,不用输出
// 客户端套接字有两种状态:
// Connecting 状态 表示未完成连接
// Established 状态 表示已完成连接
// 客户端找到服务端 IP地址后,会先进入服务端的连接队列,此时处于 Connecting 状态
// 建立正式连接后会转换成 Established 状态,并通知服务端
// 服务端每次 accept 操作会从连接队列取出 Established 状态的 socket
if(result == -1)
{
printf("accept 接受连接失败!返回值是 %d", WSAGetLastError());
WSACleanup();
return -1;
}
char buffer[255];
strcpy(buffer,"我是服务器");
// 向客户端 socket 发送消息
send(ClientSocket, buffer, strlen(buffer) + 1, 0);
// send(s, buf, len, flags)
// 第一个参数 s 表示要发送的目标 socket
// 第二个参数 buf 表示要发送的字符缓冲(字符串)
// 第三个参数 len 表示要发送的长度 (注意!因为 \0 也要发送,所以这里填 字符串长度 + 1)
// 第四个参数 flags 标志位,一般填 0
printf("已发送消息:%s\n", buffer);
// 关闭连接,释放 socket 对象
closesocket(ClientSocket); // 注意这里!先关闭 客户端 socket,否则消息会没发完!
closesocket(ServerSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!服务端已断开连接!\n");
system("pause");
return 0;
}
注意:先运行服务端程序 Server.cpp,再运行客户端程序 Client.cpp!
如果先运行客户端会报 10061 (连接被拒绝) 错误!
while 循环实现连续会话
我们使用 while 来实现服务端与客户端之间的连续会话,让服务端能发送多条消息给客户端:
服务端 Server.cpp:
// Server.cpp 服务端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ServerSocket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(8888); // 绑定到 8888 端口
// 绑定到 本机 IP 127.0.0.1,这个叫 "回送地址",主要用于本机调试
// inet_addr 用于将 IPV4 地址的字符串 转换成 二进制数据
server_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// 处理结果
int result;
// 将 socket 绑定到 127.0.0.1 : 8888, 服务端要用这个 IP 连接并监听客户端
result = bind(ServerSocket, (sockaddr*)&server_addr, sizeof(server_addr));
if(result == -1)
{
printf("bind 绑定失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
// 将套接字 ServerSocket 由 CLOSE 关闭状态 转换成 LISTEN 监听状态
// 经过 listen() 函数后,socket 会开启一个连接上限为 5 的连接队列
result = listen(ServerSocket,5);
if(result == -1)
{
printf("listen 监听失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
printf("服务端开始监听!等待客户端连接...\n");
// 客户端 IP 和 端口,后面有用
sockaddr_in clientaddr = {};
// 阻塞进程,直到有客户端的连接
SOCKET ClientSocket = accept(ServerSocket, (sockaddr*)&clientaddr, NULL);
if(result == -1)
{
printf("accept 接受连接失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
// inet_ntoa 将 IP地址 二进制数据 转换为 字符串
printf("连接成功!客户端 IP地址:%s\n",inet_ntoa(clientaddr.sin_addr));
printf("服务端现在可以发送消息了,按回车发送(输入\"exit\",按回车结束会话):\n\n");
char buffer[400];
while(1)
{
// fgets 读取一行用户的输入 到 buffer 中
fgets(buffer,400,stdin);
// 将末尾的 \n 替换成 \0
buffer[strlen(buffer)-1] = '\0';
// 向客户端 socket 发送消息
send(ClientSocket, buffer, strlen(buffer) + 1, 0);
// 如果输入"exit",结束循环
if(strcmp("exit",buffer)==0)
{
break;
}
printf("服务端:%s\n", buffer);
}
// 关闭连接,释放 socket 对象
closesocket(ClientSocket); // 注意这里!先关闭 客户端 socket,否则消息会没发完!
closesocket(ServerSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!服务端已断开连接!\n");
system("pause");
return 0;
}
客户端 Client.cpp:
// Client.cpp 客户端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ClientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(8888); // 绑定到 8888 端口
// 绑定到 本机 IP 127.0.0.1,这个叫 "回送地址",主要用于本机调试
// inet_addr 用于将 IPV4 地址的字符串 转换成 二进制数据
server_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// 将 socket 连接到本机服务端程序 127.0.0.1: 8888
int result = connect(ClientSocket, (sockaddr*)&server_addr, sizeof(server_addr));
// 如果连接失败就退出
if(result == -1)
{
printf("连接失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
printf("连接成功!\n");
printf("等待服务端 127.0.0.1 发来的消息...\n\n");
// 用于接收消息的缓冲
char buffer[400];
while(1)
{
// 阻塞进程,等待消息
result = recv(ClientSocket,buffer,400,0);
// 如果 result 是 0, 说明服务端断开连接了
if(result == 0)
{
printf("服务端断开了连接!\n\n");
system("pause");
break;
}
if(strcmp(buffer,"exit")==0)
{
printf("服务端发送了退出请求!已断开连接!\n");
break;
}
printf("服务端发送消息:%s\n",buffer);
}
// 关闭连接,释放 socket 对象
closesocket(ClientSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!客户端已断开连接!\n");
system("pause");
return 0;
}
跨内网通信(内网穿透)
前文我们讲过,127.0.0.1 是一个内网地址,它仅仅只能用于本机调试,别人的设备不在同一局域网上,单用这个地址是做不成网络聊天的。
为什么要获取公网IP
我们可以使用 ipconfig 来查看本机的IP地址。按 Win + R 键,会弹出一个"运行"窗口,输入cmd,在弹出的 cmd 窗口上输入 ipconfig。
看看 IP,10.208.7.6,你以为别人用这个地址就可以找到你的主机了。
实际上还是不行的,cmd 上输入 ping 10.208.7.6,会显示请求超时:
2019年11月25日,互联网号码分配局(IANA)宣布 IPv4地址已经全部分配完毕,也就是说所有可用的 IP 地址,在 5 年前就已经分配完了。
更重要的是, IP地址还分了 5 类地址,面对全世界上亿的网民,更加不够用了。
怎么解决这个问题呢?人们想出了三种方法:
- 子网划分和子网掩码
- NAT 网络地址转换
- IPV6
内网IP都是以 10、172、192 开头的。
你可以复制你的本机IP地址到百度上,看看是不是一个内网地址:
我们 ipconfig 获得的 IPV4 地址,为啥是一个内网 IP?原因就是上文的 NAT。
每当有设备连接到 NAT设备(路由器),NAT设备会从 DHCP池(网络地址池)中分配一个内网 IP地址,当内网设备想要访问公网资源时,就通过 NAT技术 将 内网IP 映射为 路由器IP下的唯一的 IP+端口:
更多有关 内外网 以及 NAT 的知识可跳转 【计算机网络】网络基础
怎么办?我们需要一个公网IP。
如何获取公网 IP
- 端口转发
端口转发其实是在路由器上设置,将内网中的某个 IP+端口 映射到公网上。
我们这里不用调路由器这么麻烦,所以不用端口转发。
- 购买云服务器
最省事的方法,买台云服务器就有公网IP了,缺点是要花钱。
我们不花钱,所以我们不用买云服务器。
- 端口映射
原理是将公网上的 IP 地址 映射到 内网的某一 IP + 端口 上,使不同内网的设备通过该公网 IP地址 便能正常访问该内网设备。
要实现这个,我们要下载内网穿透软件,例如贝锐花生壳(当然有更好的选择也可以用其他的):
注册登录并实名认证后,点击新增映射:
这样就建立好了:
可以看到右边的诊断信息显示内网连接失败,解决需要两步:
1. 主机开放 8888 端口,防火墙入站规则添加 8888 端口,允许 8888 端口的数据包转发到服务器主机。
2. 服务端程序开启 accept 监听 8888 端口。
经历以上两步,再点击 诊断信息右边第三个按钮 刷新,就连接成功了。
防火墙开放端口
Win + R,输入 wf.msc,进入防火墙高级设置:
点击 “入站规则” -> “新建规则”:
这样就大功告成了:
接下来,我们来完善我们的最终程序!
客户端优化
增加了输入界面,增强报错提示:
// Client.cpp 客户端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
// 获取端口
void get_Port(u_short* result)
{
scanf("%hd",result);
while(getchar()!='\n');
}
// 获取 IP
void get_IP(char* buffer)
{
fgets(buffer,100,stdin);
buffer[strlen(buffer)-1] = '\0';
}
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
SOCKET ClientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
char IP[100]; // IP地址
u_short port; // 端口号
printf("\n连接到服务器:\n\n");
printf("请输入IP: ");
get_IP(IP);
printf("请输入端口: ");
get_Port(&port);
server_addr.sin_port = htons(port); // 填端口号
server_addr.sin_addr.S_un.S_addr = inet_addr(IP); // 填 IP
// 连接到客户端
int result = connect(ClientSocket, (sockaddr*)&server_addr, sizeof(server_addr));
if(-1 == result)
{
printf("\n无法连接到 %s: %hd !返回值是 %d \n", IP, port, WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
system("cls");
printf("连接成功!\n");
printf("等待服务端 %s: %hd 发来的消息...\n\n", IP, port);
// 用于接收消息的缓冲
char buffer[400];
while(1)
{
// 阻塞进程,等待消息
result = recv(ClientSocket,buffer,400,0);
// 如果 result 是 0, 说明服务端断开连接了
if(result == 0)
{
printf("服务端断开了连接!\n\n");
system("pause");
break;
}
if(strcmp(buffer,"exit")==0)
{
printf("服务端发送了退出请求!已断开连接!\n");
break;
}
printf("服务端发送消息:%s\n", buffer);
}
// 关闭连接,释放 socket 对象
closesocket(ClientSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!客户端已断开连接!\n");
system("pause");
return 0;
}
服务端优化
客户端 socket 管理数组
建立端口映射时,在公网做映射的服务器会向 本机服务器 发送几条 tcp 连接请求来做连通性检测(我们不知道这些连接请求具体有多少,可能有 2-5 个),短时间内会占用我们的几个 socket。如果我们只是弄了几个客户端 socket,那么程序很容易被这些无关紧要的连接请求 pass 掉,所以我们需要弄一个能管理多个 socket 的数组。
// 服务端 socket
SOCKET ServerSocket;
// 存放客户端 socket 的列表
SOCKET client_list[255];
// 客户端个数
int client_count = 0;
// 获取端口
void get_Port(u_short* result)
{
scanf("%hd",result);
while(getchar()!='\n');
}
// 获取 IP
void get_IP(char* buffer)
{
fgets(buffer,100,stdin);
buffer[strlen(buffer)-1] = '\0';
}
// 添加客户端
void AddClient(SOCKET client)
{
printf("\n客户端 %d 已连接!\n", client);
client_list[client_count++] = client;
}
// 删除客户端
void DelClient(int index)
{
printf("客户端 %d 已退出!\n",client_list[index]);
closesocket(client_list[index]);
for(int i=index+1; i<client_count; i++)
{
client_list[i-1] = client_list[i];
}
client_count--;
}
// 广播到所有客户端
void Boardcast(const char* msg)
{
int need_del_socket[255];
int count = 0;
for(int i=0; i<client_count; i++)
{
// 如果发送消息时,发现有些客户端已退出,先记录索引
if(send(client_list[i], msg, strlen(msg) + 1, 0) == SOCKET_ERROR)
{
need_del_socket[count++] = i;
}
}
// 如果客户端已退出,按索引删除 socket
if(count)
{
for(int i=0; i<count; i++)
{
DelClient(need_del_socket[i]);
}
}
}
多线程优化
我们修改服务端,在监听的同时,我们可以发送消息给其他已连接的客户端,一心多用。
accept() 接收连接 和 scanf() 输入 这两个函数都是阻塞函数,也就是说,在它们的任务没做完之前,程序是不会继续运行下去的。
如何做到一心多用,同时 accept 和 scanf,多个任务同时做呢?
答案:多线程。
怎么实现多线程?我们可以使用 windows.h 里面的 CreateThread 函数来创建线程:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
DWORD dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadID
);
参数的含义如下:
lpThreadAttrivutes: 指向SECURITY_ATTRIBUTES的指针,用于定义新线程的安全属性,一般设置成NULL;
dwStackSize:分配以字节数表示的线程堆栈的大小,默认值是0;
lpStartAddress:指向一个线程函数地址。每个线程都有自己的线程函数,线程函数是线程具体的执行代码;
lpParameter:传递给线程函数的参数;
dwCreationFlags:表示创建线程的运行状态,其中CREATE_SUSPEND表示挂起当前创建的线程,而0表示立即执行当前创建的进程;
lpThreadID:返回新创建的线程的ID编号;
如果函数调用成功,则返回新线程的句柄
一般来说,我们只会用到后面四个参数和返回值。
// 监听线程,用于监听客户端连接
DWORD WINAPI ListenThread(LPVOID args)
{
SOCKET client; // 用于接收 socket
printf("\n监听线程已开启!\n");
while(1)
{
// 阻塞线程,线程用来监听客户端的连接,但不会影响到主进程
client = accept(ServerSocket, NULL, NULL);
// 如果主进程准备退出 (执行到 closesocket),线程也准备退出
if(client == -1)
{
printf("\n监听线程已退出!\n");
break;
}
// 有客户端连接,就添加到列表里
AddClient(client);
}
}
int main()
{
// 创建并运行线程
HANDLE hThread = CreateThread(NULL, 0, ListenThread, NULL, 0, NULL);
}
最终优化代码
// Server.cpp 服务端
#include<stdio.h> // C语言标准输入输出库,用于读写数据
#include<string.h> // C语言标准字符串库,用于字符串处理
#include<winsock2.h> // windows socket 网络编程库
#include<windows.h> // windows系统下的通用头文件,注意这个要放在 winsock2.h 的后面 !
#pragma comment(lib,"ws2_32.lib") // 注意这里!我们要链接相关的 Dll ! Dll 是第三方库开发必要的组件!
// 服务端 socket
SOCKET ServerSocket;
// 存放客户端 socket 的列表
SOCKET client_list[255];
// 客户端个数
int client_count = 0;
// 获取端口
void get_Port(u_short* result)
{
scanf("%hd",result);
while(getchar()!='\n');
}
// 获取 IP
void get_IP(char* buffer)
{
fgets(buffer,100,stdin);
buffer[strlen(buffer)-1] = '\0';
}
// 添加客户端
void AddClient(SOCKET client)
{
printf("\n客户端 %d 已连接!\n", client);
client_list[client_count++] = client;
}
// 删除客户端
void DelClient(int index)
{
printf("客户端 %d 已退出!\n",client_list[index]);
closesocket(client_list[index]);
for(int i=index+1; i<client_count; i++)
{
client_list[i-1] = client_list[i];
}
client_count--;
}
// 广播到所有客户端
void Boardcast(const char* msg)
{
int need_del_socket[255];
int count = 0;
for(int i=0; i<client_count; i++)
{
// 如果发送消息时,发现有些客户端已退出,先记录索引
if(send(client_list[i], msg, strlen(msg) + 1, 0) == SOCKET_ERROR)
{
need_del_socket[count++] = i;
}
}
// 如果客户端已退出,按索引删除 socket
if(count)
{
for(int i=0; i<count; i++)
{
DelClient(need_del_socket[i]);
}
}
}
// 监听线程,用于监听客户端连接
DWORD WINAPI ListenThread(LPVOID args)
{
SOCKET client; // 用于接收 socket
printf("\n监听线程已开启!\n");
while(1)
{
// 阻塞线程,线程用来监听客户端的连接,但不会影响到主进程
client = accept(ServerSocket, NULL, NULL);
// 如果主进程准备退出 (执行到 closesocket),线程也准备退出
if(client == -1)
{
printf("\n监听线程已退出!\n");
break;
}
// 有客户端连接,就添加到列表里
AddClient(client);
}
}
int main()
{
WORD sockVersion = MAKEWORD(2,2); // winsock 版本号,我们用 2.0 版本,所以 MAKEWORD(2,2)
WSADATA wsaData; // winsock 设备数据
WSAStartup(sockVersion,&wsaData); // 初始化 winsock 设备
// 创建 TCP socket 对象
ServerSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
char IP[100]; // IP地址
u_short port; // 端口号
printf("\n服务器绑定端口:\n\n");
printf("请输入IP: ");
get_IP(IP);
printf("请输入端口: ");
get_Port(&port);
// 填写服务端 IP 和 端口
sockaddr_in server_addr = {}; // IP地址结构体
server_addr.sin_family = AF_INET; // 使用 IPV4 表示 IP地址
server_addr.sin_port = htons(port); // 填端口号
server_addr.sin_addr.S_un.S_addr = inet_addr(IP); // 填 IP
// 将 socket 绑定到指定 IP+端口, 服务端要用这个 IP 连接并监听客户端
int result = bind(ServerSocket, (sockaddr*)&server_addr, sizeof(server_addr));
if(result == -1)
{
printf("bind 绑定失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
// 将套接字 ServerSocket 由 CLOSE 关闭状态 转换成 LISTEN 监听状态
// 经过 listen() 函数后,socket 会开启一个连接上限为 5 的连接队列
result = listen(ServerSocket,5);
if(result == -1)
{
printf("listen 监听失败!返回值是 %d \n", WSAGetLastError());
WSACleanup();
system("pause");
return -1;
}
system("cls");
// 创建并运行线程
HANDLE hThread = CreateThread(NULL, 0, ListenThread, NULL, 0, NULL);
Sleep(100);
printf("\n按回车发送(输入\"exit\",按回车结束会话):\n\n");
// 字符缓冲,用于输入字符串
char buffer[400];
while(1)
{
// fgets 读取一行用户的输入 到 buffer 中
fgets(buffer,400,stdin);
// 将末尾的 \n 替换成 \0
buffer[strlen(buffer)-1] = '\0';
// 广播消息到所有已连接客户端
Boardcast(buffer);
// 如果输入"exit",结束循环
if(strcmp("exit",buffer)==0)
{
break;
}
printf("服务端:%s\n", buffer);
}
// 关闭连接,释放 socket 对象
closesocket(ServerSocket);
// 我们已经用完 winsock 了,清理服务,防止占资源
WSACleanup();
printf("\n任务结束!服务端已断开连接!\n");
system("pause");
return 0;
}
最终测试
内网测试
不开花生壳的情况下,用回送地址 127.0.0.1 测试客户端和服务端
跨内网测试
开花生壳映射下,远程桌面和服务端主机 (不在同一内网) 使用 映射公网IP 连接
刚开始连接的时候有点不稳定,客户端容易映射到带连通性请求的端口,导致没发送几句话就直接退出,要等服务端手动响应那几个请求,后面的客户端连接才稳定。
下一篇教程,我们将直奔 GUI 图形用户界面,做一个真正意义上的QQ聊天
本节教程最终代码及成品下载:https://wwek.lanzoue/iqQgQ2hvibwh