16. 网络编程详解

1.前言

前面几个章节我们大致过了一遍MFC的内容,相信现在的你已经有能力开发一些简单的软件!

但现在的软件,很少有不需要网络的,别的不说,至少也会有一个软件检测升级的功能。

而这就是本章要介绍的网络编程!

不过在学习网路编程之前,我们需要有一个总体观念:

网络通信是对于整个电脑而言,而不单单是我们写的这个程序,我们所写的程序实际上是在调用我们电脑网络通信的能力

2.网络编程基础

首先还是来考虑一个问题,我们为什么需要网络?

这个问题应该是很简单的,因为我们需要和他人交换信息,通信。

虽然目前大部分电脑的操作系统本身,都是支持多用户使用同一台电脑,但实际上,现在个人电脑应该是占主导地位的,即自己独占一台电脑。

Windows系统也是一样,你可以在电脑上建立多个用户,且这多个用户可以互相不影响,只是大部分人不会这么做而已。

也就是说,如果我想要和朋友通信,或者从别人的网站上下载资料,就只需要让我的电脑和它的电脑通信即可,即两台电脑互相通信。

但问题来了,通信肯定需要介质来传输信息,就算是我们说话,那也需要用空气传递声波别人才能听到!

而电脑之间的传输介质就很多了,比如无线Wifi,网线、光纤等等。

虽然介质很多,但对于我们而言,可以直接抽象一下,即两个电脑之间可以直接看作是有一根线将它们连接了起来。

因为我们不需要关心各个电脑之间是用什么传输的,我们只需要知道它们能够传输信息。

graph LR
A[电脑]--- B[电脑]

但这只是最简化的想法,因为现实中不可能给每个电脑或手机都拉一根线。

因为当前世界的电子产品数量大的惊人,所以很多时候,很多电脑都是共用一根线来节约成本的。

graph LR
A[电脑1]--- C[公共线路]
C---B[电脑2]
A1[电脑3]---C
C---B1[电脑4]

但这时候就出现问题了,即一个电脑发送的消息,如何判断它能够准确的发送到目的计算机上呢?

因为此时大家都共用一根线传输数据,你得区分各个电脑才知道数据应该发给谁。

所以这时候就有了地址,每个连上了网的电脑都会被分配一个地址,即我们常说的IP地址:

graph LR
A[电脑1 : 192.168.0.1]--- C[公共线路]
C---B[电脑2: 192.168.0.2]
A1[电脑3: 192.168.0.3]---C
C---B1[电脑4: 192.168.0.4]

当然,上面这些地址都是我随意写的,理解一下即可。

此时一个电脑想要发送信息,就只需要在信息前面添加一个IP地址,然后将这个信息发送到共有线路上。

如果接收方看到这个信息的接收地址是自己,那就接收,不是自己的话,要么转发,要么直接丢弃

比如上图中,如果电脑1想要给电脑4发送一条消息,那么就会在这条信息前面添加上目的地的IP地址,即192.168.0.4

但这条信息会发送到这条线路上的每一个电脑,当电脑2电脑3看到这条信息目的地IP不是自己,就丢弃了,而电脑4看到这个IP地址就是自己的地址,才会接收这个信息。

上面这种通过给每个电脑都分配IP的方法,是最开始的设想,它被称为IPv4

但后来,谁也没想到世界的电子设备迅速增长,IP地址不够用了!

因为IPv4是用的32位二进制表示的地址,即可以表示 232 个地址,虽然实际上要比这个数小,但也大约可以有43亿个设备。

可现在世界上的人口都有七八十亿了,这肯定是不够的!

所以后来就出现了Ipv6地址,用64位来表示,据说其数量之大,就算给地球上每粒沙子都分配一个ip地址也是绰绰有余的。

但由于Ipv4的基础设施已经搭建好了,想要迅速过渡到Ipv6,就意味着需要大量更换基础设施,这样成本太高了。

所以为了正常过渡到Ipv6,同时不影响现在使用,就出现了局域网!仅仅使用Ipv4就能基本满足现在的需求。

专业点来说叫做NAT机制,除此之外还有动态分配机制等等技术来解决这一问题。

如果你现在电脑连的Wifi上网,那么就已经身处局域网之内了。

局域网的概念就是,不用每一个电脑都连上公网,只需要一个设备连接上公网,比如路由器,而其它设备连接路由器即可:

graph LR
A[设备1 : 公网ip1]--- C[公网ip线路]
C---B[设备2 : 公网ip2]
A1[设备3: 公网ip3]---C
C---B1[设备4: 公网ip4]
D[设备5 : 局域网ip1] --- A
D1[设备6 : 局域网ip2] --- A

这样,就能够极大缓解IP资源不够的情况,并且上面也仅仅只是公网IP下的一层,但其实还可以继续递推嵌套下去,我们很可能现在就处于局域网中的局域网。

比如我们常见的IP地址:192.168.0.1,前面由192.168开头的就是一个局域网IP,虽然在同一个局域网下这个IP地址不能重复,但在不同局域网下,这个IP地址是可以重复的。

比如我当前电脑就连接的Wifi,这就是在局域网下,给我分配的IP地址为192.168.3.8

image-20231212103240028

同时也能看到,上面还有一个IPv6地址,因为它是64位,如果用十进制表示就太长了,所以它用的十六进制表示。

局域网ip地址有三种:

10.0.0.0 - 10.255.255.255 
172.16.0.0 - 172.31.255.255 
192.168.0.0 -192.168.255.255 

如果你的电脑IP在这个范围里面,那就说明你现在处于局域网下。

这也就导致了,如果你的朋友也在另外一个局域网下,你两是无法直接通过对方IP地址通信的!

但为啥我们用QQ、微信也能通信呢?

这是因为腾讯在公网IP上架设的服务器,你发送给朋友的信息,实际上是先发送给腾讯,腾讯再帮你转发给你的朋友。

所以那些网络犯罪还使用这种第三方聊天工具的,大多数也并不是无迹可寻,只在于人家愿不愿意来抓你罢了,而这也是为什么如今很多诈骗团伙都喜欢诱骗人先下载它们自己的通信软件。

我们为什么能上网看到网页呢?这是因为网站的服务器都是放在了公网IP上,所以你可以访问。

比如本站点,就是我在腾讯云租的云服务器,在租了之后,该云服务器就会被分配一个公网ip,然后我在这个公网ip上搭建服务器,你才能访问到本页面。

总结来说就是:

  1. 处在同一局域网中可以互相访问。
  2. 不同局域网之间不能直接互相访问。
  3. 任何网络都可以访问公网IP。
  4. 公网IP想要访问局域网IP,就必须局域网IP先访问自己,才能回访。

3.网络协议

很多新手一听到协议两个字,就会觉得协议很高级的样子,很抽象,无法理解。

但其实熟悉了之后你会发现,所谓协议不过是官方定制的标准而已,也就是规矩!

比如,上面我们已经讲了两台电脑之间传输数据需要对方的ip地址,那么ip地址放在哪里呢?

放在数据的最前面,中间,或是最后面?似乎都行啊!

所以为了避免出现混乱,就出现了规矩,即协议,规定IP地址只能写在数据的最前面。

你要是放在其它地方,其它电脑就不认识,因为根据协议,它们只看数据的最前面,不是自己的IP就丢弃。

对于我们程序开发人员而言,网络协议一般只需要知道两种即可,即我们常能看到的TCP协议、UDP协议。

直接介绍协议的概念,可能你会有点抽象,所以这里我们还是用两个计算机的通信举例子。

3.1 TCP

首先是TCP协议,即想要建立TCP连接的规矩,当A计算机想要和B计算机通信时:

  1. 根据协议,A必须先给B发送一个请求连接的消息。
  2. B接收到了A的请求连接,就给A发送一个应答消息。
  3. A接收到了B的应答消息,就再给B回复一个应答消息,便于B确定A收到了自己的回复。

也就是说,在TCP连接的阶段,就需要发送三次信息,第一次和第三次都是请求方发送的,第二次是等待连接方发送的。

上述过程也被称为三次握手,只有完成了三次握手,通信双方才可以自由的发送信息。

当发送消息完成了之后,想要断开连接时,比如A想要断开双方的连接:

  1. A向B发送断开连接请求。
  2. B收到了之后,向A发送确认自己收到的请求。
  3. B再向A发送断开请求。
  4. A向B发送确认自己收到的请求。

可以看到,断开连接阶段,需要发送4次信息,即一次为主动发出断开请求,一次为被动发出确认自己收到的请求。

这也被称为4次挥手

通过以上的过程,我们看得出来,TCP协议重在严谨!

因为它每次给对方发送了信息之后,都要等待对方回应它收没收到信息。

虽然这里仅仅只是介绍了它的连接、断开阶段,但实际上在正常发送消息时,它同样会进行一系列操作来确认自己收发的消息准确无误。

所以我们对TCP就有了以下结论:

  1. 面向连接:只有双方建立起来连接,才能互相发送数据。
  2. 单播传输:即每次只能两个计算机之间进行传输数据。
  3. 可靠传输:因为TCP发送数据之后,都要等待对方回应自己收到的信息,所以很可靠(如果数据半路丢失,没有收到对方的回应,数据就会重新传输)

至于如何编程,我们后面马上就会提到,这里先过一遍概念。

3.2 UDP

看到上方的TCP协议,你可能会觉得很麻烦,而且消耗资源。

因为每次发送数据都要等待对方的回应,这数据传输的速度肯定就慢了呀!

所以就有了UDP协议,该协议很简单,不用连接,只要你知道对方电脑的IP地址就能直接给对方发送信息!

不过前提是对方在公网上,或者与自己在同一个局域网内。

也正是因为UDP这种不需要连接的特性,它发送数据的速度非常快,而且还可以同时向多个目标发送数据!

4.TCP实现聊天

上面讲了这么多理论的东西,可能看完都没什么概念,所以下面我们直接开始从0写一个小的聊天程序。

这次就不在以前的解决方案中建立项目了,因为那个解决方案的项目太多了,看着麻烦。

新建一个解决方案,为了更加直观的理解,我们采用控制台项目,首先建立Tcp服务器项目:TcpChatServer

image-20231212105637585

然后自行新建一个源文件main.cpp就可以了!

4.1 Tcp服务器

这里还是有必要说明一下为什么要写Tcp服务器。

两个电脑想要进行通信,就必须得有一台电脑等待另一台电脑来连,而等待别人连接的我们称它为服务器,主动去连接的我们称它为客户端。

不同平台的网络编程并不完全相同,这是因为C/C++标准至今没有推出通用的网络标准库,不过想来再过几年可能就有了。

同时由于我们这里使用的是windows平台,所以开发的服务器和客户端也只能在windows平台使用。

首先,在windows平台上想要进行网络编程,就得引入对应的网络库文件与头文件:

#include<WinSock2.h>
#pragma comment(lib,"ws2_32.lib")

既然是控制台程序,最基本的输入输出库还是得有,不然不好观察现象:

#include<iostream>
using namespace std;

然后就是初始化网络环境:

int main() {
	WSADATA wsadata;
	int sta = WSAStartup(MAKEWORD(2, 2), &wsadata);
	if (sta != 0) {
		cout << "初始化网络环境失败!";
		return 0;
	}
	
}

这里调用的WSAStartup函数,其作用就是初始化网络环境,这是必须调用的,且在一个程序中只需要调用一次。

第一个参数为网络库的版本号,通过宏MAKEWORD生成,现在一般都是2.2版本,不用改,然后第二个参数就是初始化的数据,一般来说我们都用不着。

返回值如果不为0,就说明初始化失败了

也就是说,上面这段代码基本就是标准写法了,无需修改任何东西,想要了解更多细节的可以参看本站的这篇文章:网络编程

初始化网络环境后,我们需要创建套接字:

SOCKET sockSev = socket(AF_INET, SOCK_STREAM, 0);

所谓套接字,就相当于门牌号,而电脑就相当于一个公寓小区。

因为我们一台电脑里面可以有很多软件,每一个软件都需要通信的话,电脑如何区分呢?

答案就是通过套接字,我们可以通过套接字绑定电脑的网络资源,然后就可以使用电脑的网络资源与其它电脑进行通信。

这里创建套接字的参数有三个:

  • 第一个参数:套接字的地址族,通俗点说就是用ipv4AF_INET、还是ipv6AF_INET6

当然了,地址族其实不止这两个,知道怎么看吗?右键它速览定义,就能看到它附近有哪些地址族,但没必要管,就目前而言,一般我们都还是用的ipv4进行编程,即第一个参数一般都固定为AF_INET

  • 第二个参数:这个参数用于选择使用协议类型,一般我们需要的也就是TCPUDP协议,所以这个参数其实也是二选一,TCP:SOCK_STREAMUDP:SOCK_DGRAM,更多协议类型查看方法同上。

  • 第三个参数:为子协议类型,但这个我们一般都用不着,填0即可。

然后该函数创建成功后,就会返回一个套接字,但这个套接字现在还没有绑定我们电脑的网络资源!

而且一般来说,我们可能还需要对返回值加上一个判断:

	if (sockSev == INVALID_SOCKET) {
		return -1;
	}

INVALID_SOCKET意思就是无效socket,其真实值就是一个-1而已。

然后来到下一步绑定电脑网络资源,调用函数:bind

	SOCKADDR_IN addrSev;
	addrSev.sin_family = AF_INET;
	addrSev.sin_port = htons(5000);
	addrSev.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
	int ret=bind(sockSev, (sockaddr*)&addrSev, sizeof(addrSev));
	if (ret == -1) {
		cout << "绑定失败!";
		return -1;
	}
  • 第一个参数很简单,就是前面生成的套接字
  • 第二参数有点复杂,也就是我们需要绑定的电脑网络资源。
  • 第三个参数很简单,就是第二个参数的大小
  • 返回值如果为-1,则说明绑定失败,这一般是因为该端口号已经被其它程序绑定了,试着换一个端口号试一试,不为-1则成功。

下面详解一下第二个参数的使用。

它是一个结构体参数SOCKADDR_IN ,这个结构体是ipv4的结构体,如果你选用了ipv6,这个参数是不同的,因为不同协议需要绑定的信息各不相同。

也正因为各个协议需要的参数不同,所以bind函数为了通用化,就将第二个参数改为sockaddr*类型,无论你是什么协议的参数,只需要强制转换为它为该类型即可,然后第三个参数告诉它,这个参数有多大。

对于ipv4的这个参数而言,其中有三个参数,第一个是sin_family ,即地址族,要和前面的socket套接字一致,即ipv4的AF_INET

然后是端口号sin_port ,这就是电脑的网络资源,一台电脑最多只有65535个。

紧接着是sin_addr.S_un.S_addr,这个是要绑定的当前电脑ip地址。

这里详细聊一下端口号的问题,因为电脑硬件用的都是16位二进制表示端口,216 等于65536,即我们能使用的端口号范围就是0-65535

但小于1024的端口号一般都有默认的功能,比如我们上网浏览网页,其网站默认就是使用的80端口或443端口。

举个例子,你可以通过下面这个网址连接百度的服务器电脑:

https://www.baidu.com/

但其实这就是省略了端口号,选用的默认端口,上面实际上应该为:

https://www.baidu.com/:443

如果你将443改为其它端口就连接不上了。

因此我们一般选用较大的端口号,比如几千,一两万的都是可以的,太大也不好,因为它是系统自动分配的端口号。

比如还是连接百度的服务器为例,我们只是指定了要连接对方的哪个端口号,却并没有指定自己用哪个端口去连接,咋也能连上去?这其实就是系统帮我们自动分配的端口号。

回到本文,在代码中使用端口号时,还存在一个问题,那就是数据的编码序列,比如我当前使用的Intel系列CPU,它采用的小端序,网络端口数据必须用大端序,那就必须进行转换。

而这就是下面这个htons函数的作用:

addrSev.sin_port = htons(5000);

那什么是大端口,什么是小端口呢?拿一个十六进制数0x12345678来说:

在这里插入图片描述

大端序,就是将高位放在低地址,而小端序,却是将高位放在高地址

由于我们看数据都是习惯从低位向高位看,所以对于小端序中的数据:78 56 34 12 就必须倒一下变为 12 34 56 78才是真实数据。

实在不理解也不重要,代码中,我们只需要记住需要通过函数htonshome to network short:主机到网络转换,short类型)函数来完成这一转换步骤:

htons(5000)

之后就是地址了,因为我们现在就是服务器,所以就需要让我们的电脑监听某个地址等待连接,127.0.0.1这个地址比较特殊,这是本机回环地址,只能用于本机的应用之间进行通信:

addrSev.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");

可以将电脑理解为一栋大楼,ip就是大楼在地球上的地址,而端口就是这栋大楼中具体的每家每户门牌号,即各种应用程序,而127.0.0.1这个地址为本楼各个用户之间交换数据的内部地址,外部其他楼是看不到的。

对于ip地址,由于纯数字不便于人记忆,所以有了127.0.0.1这种字符串的写法。

但编程中,它需要的是ip地址纯数字的形式,所以就需要用inet_addr函数进行转换一下,将我们的字符串ip,转换为ip地址数字形式。

同时这里我们可以看到,它使用了一长串的东西表示地址:

sin_addr.S_un.S_addr

这其实等价于第一个:

sin_addr

但如果你只写第一个,肯定会报错,因为它不能够直接接收ip地址的数据,它的数据结构定义为:

typedef struct in_addr {
        union {
                struct { UCHAR s_b1,s_b2,s_b3,s_b4; } S_un_b;
                struct { USHORT s_w1,s_w2; } S_un_w;
                ULONG S_addr;
        } S_un;
#define s_addr  S_un.S_addr /* can be used for most tcp & ip code */
#define s_host  S_un.S_un_b.s_b2    // host on imp
#define s_net   S_un.S_un_b.s_b1    // network
#define s_imp   S_un.S_un_w.s_w2    // imp
#define s_impno S_un.S_un_b.s_b4    // imp #
#define s_lh    S_un.S_un_b.s_b3    // logical host
} IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;

怎么看就不说了,就一步一步速览定义即可。

首先你肯定先注意到了这里面有很多宏,这就是为了简便书写的,比如上面的就可以用宏写成: