在我的上一篇博客(初识网络)中介绍了一些对于网络的基础知识,这篇博客,我会再介绍一点关于网络的认识,然后直接开始简单的网络编程,也就是socket编程,但是在编程前我们还需要做一些基础的认识。
1. 前置准备
a. 端口
我前面说网络通信实际上就是远距离的两台电脑之间进行数据传输,这句话是对的,但是不完全对,当你在音乐软件上听一个没有听过的音乐时,此时你这端的音乐软件就会向服务器发出请求,然后服务器把这首音乐给你发过来,在这其中确实是两台机器在通信,但是再具体点实际上是两个进程在通信,即你的音乐软件客户端和服务器所运行的程序,那么网络通信实际上是两个进程在通信,我们可以将这种通信方式也理解为进程间通信。
那既然是进程间通信,我们可以使用IP地址找到目标主机,但是我们怎么找到目标进程呢?找到目标进程的方式很多种,因为进程的内核数据结构能标记唯一进程的字段也很多,但是为了实现进程管理和网络之间的解耦,网络协议中就提出了端口的概念,能够实现网络通信的进程都会有一个属于自己的端口,且在进程中唯一。这样我们就可以定位到一台主机上的一个进程了。
端口是一个两个字节的无符号短整型,而IP地址+端口port的组合就叫套接字也就是socket。
对于端口的认识我们要知道,为了表示端口在进程中唯一,那么一个端口是不可以关联多个进程的,但是一个进程是可以关联多个端口的。
b. UDP/TCP
在传输层中,有两个主要的协议就是udp和tcp,它俩之间的区别就是udp是不可靠的一种网络通信协议,而tcp是可靠的,可靠的原因就是,它能保证数据能无损的传输到目标主机中,但是这种保证也意味着效率的低下,所以关于两种协议的可靠与不可靠就像多线程中函数的可不可重入一样,没有好坏,只是它们自己的一个特点而已。
c. 网络字节序
我们知道,一台机器是有大端和小端的区分的,这说的是数据在内存中如何存储,比如现在有一个数据0x11223344,要存在内存中,假如存在小端机中的话就是这个样子:
而如果是大端的话则是这样:
而网络中的数据传输遵循这样的规则:
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出
接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存
这就会导致假如服务端机器是小端机,客户端机器是大端机的话,数据就会出现读取错误,造成这种的原因就是大小端之间没有谁更好的说法,所以不同的存储介质厂商也就会采用不同的存储方式,所以这时候网络协议就站出来了,无论你的机器是大端机还是小端机,我要求你给我发过来的网络序列必须是大端格式的,这样就解决了这个问题,这就是网络字节序。
而在关于使用网络通信的接口时IP和端口是必要的,系统就提供了这样的接口:
它可以将本地的ip地址和port端口转化为网络字节序列,也可以将网络字节序列转化为本地IP地址和port端口。
2. socket编程——udp协议
a. 本地网络测试
接下来我们就可以进行socket编程了,首次进行编程我们只进行简单的数据传输,我们首先介绍udp协议的socket编程:
1). 服务端
我们都知道在Linux中有着一切皆文件的说法,那么网络通信实际上也是一种文件的IO,所以我们首先就要建立socket文件描述符:
其中第一个参数只需要填一个宏就可以了:
表示它进行网络间通信,
第二个参数填这个宏,表示是以数据段作为对象传输的,也代表使用udp协议,而第三个参数默认填0就可以,因为前两个参数已经能够确定了:
之后我们就需要将这个线程跟IP地址和端口关联起来,让它成为网络线程:
而在这个函数中,第二个参数我们需要着重介绍。
结构体介绍
其实socket编程是有很多方式的,这一点从socket函数也能体现出来(不同的宏),实际上socket编程可以分为三类:
Unix socket,它是一种域间socket编程,也就是一台机器上的内部通信(就跟命名管道差不多)。
而第二种就是网络socket,它是通过套接字(IP地址+port端口)的方式进行网络间通信。
第三种是原始socket,主要是用来编写一些网络工具。
这其中我们主要是网络socket。
可以看到socket编程实际上分很多种,但是socket的API却只有一套:
这其中这个struct sockaddr类型的结构体就内涵玄机了,上面的接口中看似传的结构体指针类型是struct sockaddr*的,其实它还有两个类型:
struct sockaddr_in和struct sockaddr_un,这两个结构体与上面结构体的关系如下:
在实际使用socketAPI过程中,传的是下面这两个结构体的指针,区分这两个结构体的地方就是它们的开始的字段domain,其中struct sockaddr_in是用于网络socket的结构体,struct sockaddr_un是用于Unix socket的。
我们发现虽然它们使用的API一样,但是通过一个同一类型的指针访问到第一个domain字段就能分辨出到底是实施哪种socket编程方式,其中struct socket就好像基类,struct sockaddr_in和struct sockaddr_un像子类,这其中的关系就好像多态一样,而这就是C语言风格的多态。
所以我们就来认识struct sockaddr_in这个结构体:
这个结构体中框框之外的字段就是填充字段,我们现在不会用到,我们使用的是框框中的字段。
我们再来看一下bind;
它的第二个参数就是一个结构体,我们实际要传的就是struct sockaddr_in类型的结构体,也就意味着我们需要填充好struct sockaddr_in中的内容,第三个参数就是我们实际要传的结构体的大小:
在这里,我们需要注意三点,一点是结构体中的IP地址是一个四个字节的无符号网络字节序,而我们的IP地址为了方便阅读,采用的是字符串的方式这其中的转换怎么做?还有一点在我们看结构体struct sockaddr_in时并没有发现它有sin_family字段,那么这个字段是什么?还有我们并没有将端口号编程网络字节序列。
我先来回答第二个问题,这个字段时网络协议家族的意思,传输的依然是我们socket中domain中的宏,这里填充AF_INET意思也是网络socket的意思,那为什么结构体中没有出现这个字段,原因在这里:
第一个问题,其实系统已经提供了对应的接口inet_addr:
它可以将一个字符串形式的IP地址转化为四字节的网络字节序列:
第三个问题也很好解决,接口我们已经介绍过了:
最后还有一个问题,那就是在填充结构体字段之前,需要先初始化一下结构体:
至此,我们的网络的初始化就完成了。
接下来就是让服务端运行起来,我们也知道,一个服务器是永久不退出的,所以我们这里的服务器的运行就是一个死循环:
服务端中,我们一般是要先接收客户端的信息,然后再反馈给客户端信息,这里我们要介绍两个接口,首先是网络上获取信息:
这个函数中第一个参数就是socket文件,第二个参数就是获取信息的缓冲区,第三个是缓冲区大小,第四个是一个标志位,标志位值为0时,表示阻塞等待消息,第五个是客户端的套接字信息,因为服务器要返回消息时,需要知道客户端的套接字信息,所以这是以一个输出型参数,而第六个参数就是实际接收结构体的大小,socklen_t其实就是一个整数:
我们现在就来使用一下这个函数:
接下来就是给客户端发送消息,发送消息的函数如下:
直接使用:
此时作为参数的addr,就是要接收消息的目标主机了。
至此我们的服务端类就写好了,我们现在开始使用它,并加以改造:
在接下来的使用中,我们首先要传入IP地址和port端口号,所以我们的构造得改一下:
对于服务端的使用,我们要实现一个命令行启动的方式来启动服务器,就是通过命令行来传输IP地址和port:
至此我们的服务端代码就完成了。
2). 客户端
对于客户端的写法实际上差不多,但仍然有需要注意的事项。
对于客户端,我们仍然使用通过命令行传入IP地址和port的方式来启动客户端,这里的IP地址和port是服务端的。
这里绑定的套接字,自然是本地的套接字信息,这个工作也是必须的,但是我们不建议显式的bind套接字信息,因为我们的电脑上会有很多的客户端,如果使用了固定端口的话,这样假如同时有两个客户端的端口一样的话,就会导致有一个客户端bind套接字失败,所以我们这里采用随机bind端口号的方式,来避免这种情况的出现,如何bind随机端口号,这个工作不需要我们来做,这个随机bind会在我们第一次进行消息发送的时候操作系统给我们自动bind(这一点我们可以之后验证),至此我们可以直接开始发送消息了:
最后,我们需要关闭socket文件,使用的接口是close:
而对于服务端,因为服务永不退出,所以关闭文件的工作可做可不做,为了保险也可以加上。
现在我们的客户端代码也写好了,接下来就是测试代码:
3). 测试代码
为了测试方便,我们需要在服务端和客户端都将收到的消息给打印出来:
客户端
服务端:
现在我们就可以测试代码了:
当我们创建网络进程时,我们可以使用netstat命令来查看网络进程信息。
-n:显示具体IP地址,等数字信息
-p:显示网络进程的进程信息
-a:显示所有网络进程
-u:显示udp协议的进程
我们可以看到,我们的服务端已经运行起来了,IP地址127.0.0.1,端口号1234(对于端口号的选择,建议在1024以上)。
至于为什么选择127.0.0.1这个IP地址,是因为这个IP地址很特殊,它是专门用于在一个机器中服务端和客户端进行通信的。一般的网络通信是这样:
而127.0.0.1这个地址是这样:
这个地址,也被我们用来进行网络代码的测试,也叫cs(client & server)测试。
现在我们再运行我们的客户端:
我们发现我们运行客户端之后,我们主机的网络信息只有服务端,而没有客户端,这是因为我们还没有进行客户端的套接字bind,在发送之后,才会bind:
我们现在看到了客户端的网络信息,而port是36208,我们可没有设置过这么个端口号,这看起来是随机的。而客户端的IP地址信息是0.0.0.0,这个表示,客户端可以被任意IP地址访问。
我们重新运行客户端:
port变成了50052。确实是随机的。
而怎么验证,我们服务器接收到的就是,这个客户端发来的呢?
别忘了,我们的服务器在接收消息的时候,也接受了客户端的套接字信息:
所以我们这里再次对代码改造:
这里又有一个新的接口inet_ntoa:
它可以将一个网络字节序的四字节IP地址转化为点分十进制的IP地址。
但是上面的代码,有点难看,所以我们对上面进行简单的封装:
我们再次运行起服务端和客户端:
这次更加充分的证明了,客户端的端口号是随机bind的。
b. 网络通信
1). Linux与Linux
上一小节,我们只是演示了本地网络通信,但是真正的两台主机的网络通信如何做到呢?,其实很简单,只需要对代码稍加更改就可以了:
我现在手上有两台机器,一个是轻量级服务器,一个是虚拟机,我现在准备让我的轻量级服务器,作为服务端,虚拟机作为客户端,来实现通信。
在通信之前我们要注意一点,那就是服务端不建议将自己的IP地址作为自己的套接字信息,原因如下:
有的服务器内部可能有多个IP地址,所以当我们编码锁定一个IP地址之后,从此之后我们的服务端只能从这个IP地址获取信息,而客户端发给其他IP地址的信息,该服务端无法获取,所以我们不建议服务端手动bindIP地址,而也是让操作系统自行bind。而这种方式的实现只需要一个宏就可以:
至此我们Server类中的ip字段就可以删除了。
而服务端的主函数中也不需要传递三个命令行参数了:
客户端代码不变,那么我们现在就可以开始进行远端传输数据了:
远端距离传输,可不只能传一个简单的字符串,它甚至可以是一个指令。
这样我们又需要改造一下代码了:
可以看到我们可以采用回调函数的方式来让服务端执行指令处理函数,然后做出返回,那么我现在再介绍一个接口:
这个函数的第一个参数就是Linux中的命令行的一条指令,这个函数的返回值是一个文件,因为有些指令在完成任务之后可能会返回一些东西,而它的第二个参数就是关于这个文件的权限(比如只读只写,可读可写),我们现在就来使用它:
其实这也是我们使用ssh进行远端机器控制时,大致上也是使用这种方式执行的,只不过人家使用的是ssh协议,编码的内容也肯定更复杂。
2). Linux与Windows
我们上面的网络通信都是Linux与Linux进行通信,但是在实际生活中,客户端往往是Windows端,服务端是Linux,那么我们先在的编码可不可以实现Linux与Windows通信呢,答案是可以的。
在这里我附上一段Windows端服务端的使用udp协议通信的代码:
#include <iostream>
#include <string>
#include <cstring>
#include <winsock2.h>
#include <ws2tcpip.h>
using namespace std;
#pragma warning(disable:4996)
#pragma comment(lib, "ws2_32.lib")
#define BUF_SIZE 1024
#define SERVER_IP "43.140.248.105" // 替换为服务器实际IP地址
#define SERVER_PORT 1234 // 替换为服务器实际端口号
int main() {
WSADATA wsaData;
int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (result != 0) {
std::cerr << "WSAStartup failed: " << result << std::endl;
return 1;
}
SOCKET udpSocket = socket(AF_INET, SOCK_DGRAM, 0);
if (udpSocket == INVALID_SOCKET) {
std::cerr << "Could not create socket: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
sockaddr_in serverAddr;
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(SERVER_PORT);
serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);
string buffer;
char massage[1024];
while (true)
{
cout << "please enter# ";
getline(cin, buffer);
sendto(udpSocket, buffer.c_str(), buffer.size(), 0, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
struct sockaddr_in temp;
int len = sizeof(temp);
int s = recvfrom(udpSocket, massage, 1023, 0, (struct sockaddr*)&temp, &len);
if (s > 0)
{
massage[s] = 0;
cout << "server say# " << massage << endl;
}
}
closesocket(udpSocket);
WSACleanup();
return 0;
}
我们前面说过由于网络协议的存在,导致不同平台的接口使用起来是大同小异的,由于Windows端喜欢封装,但其实它的底层还是跟Linux中所使用的变量是一样的,在这里呢只需要注意两点:
当在Windows中使用inet_addr时会被告知不安全,推荐使用人家的接口,而这个问题的报错编号就是4996,所以我们使用一个这样的语句就可以一屏蔽这个错误。
在Windows中使用socket编程,这两步是必须的,使用的时候加上就可以了。
可以看到这段代码中除了有封装的内置类型外,使用起来跟Linux中大差不差。那么我们现在开始进行两个平台间的通信:
可以看到,确实可以。
3). 制作一个简单的聊天室
a. 前置准备
在通过上面的学习之后,我们现在实现一个聊天的功能,让主机作为我们的服务器,让其他主机作为客户端,然后通过链接服务器,可以进行一个群聊的功能。
对于一个聊天功能,我们应该能发出消息,也能接受消息,而且其他人发出的消息我们也能接收到,所以我们的服务端应该能够收发消息。收消息不难实现我们设置可以获取发出该消息的IP地址和port,但是发消息就需要一些准备了。
首先,对于发送消息,我们要能够对所有处于聊天室的用户发送消息,这就意味着我们的客户端有一个存储上线的用户信息的容器。
既然是聊天室,那就意味着,服务器可能同时收到多个用户发来的消息,我们要将这些消息给每一个上线的用户发送,我们可以将这个过程使用多线程来进行处理。
对于多线程的内容,我之前写了一篇关于线程池的内容,可以参考一下:线程池.
其中为了方便代码的Debug和维护,我又引入了日志系统,可以看这一篇博客:简单的日志功能。
b. 开始编写代码
现在我们再来将代码回到最原始的状态:
服务端:
服务端main函数
客户端:
使用到的类:
现在开始我们就可以实现一个聊天室了:
关于接收消息我们依旧,但是要对这个发过来消息的客户端进行记录,而记录的方式也很简单,就是用一个vector来存储,我们就存储上面netaddr类型的,便于编码:
然后我们就开始将该用户端的消息群发给其他连接服务器的用户:
这里需要我们传目标用户端的套接字信息,我们没有,所以就在netaddr中再添加一个成员,并写一个获取它的接口:
我们现在就完成了,群发消息,但是它是串行的,我们希望,服务端在接受消息的时候,仍然可以发消息给其他客户端,所以我们需要多线程,所以我们使用线程池,让线程池来执行群发任务,主线程就进行接收消息就行了:
我们让线程执行的任务就是那个Route函数,所以我们需要对Route函数包装一下:
然后只需要将这个任务Push到线程池中就可以了:
我们又看到,在将Route函数Push到进程池中,会出现多个线程同时执行Route函数,那么_users就成了公共资源,所以我们需要对其加锁:
至此我们的服务端就写好了。
但是我们在实际使用中,对于开发人员来说,很难掌握其中的细节,所以我们需要对服务端使用日志功能,并且我们发现在客户端的用户聊天时,只有当我们发出消息之后我们才会接收到其他人的消息,这显然是不合理的,因为在我们平常使用的聊天软件中,可不会只有我们发出消息之后才能收到消息,这其中的原因就是单线程,所以客户端也需要多线程,在这里我们使用C++的多线程:
c. 优化
可以看到,我们确实可以实现输入和输出的互不干扰,但是这样看起来很不舒服,我们在日常使用聊天软件时可是输入框与看消息的地方是分离的,这一点我们之后再优化,我们先使用日志功能,进行较好的维护:
下面我们来解决上面提到的,客户端输入输出在一个地方的问题,由于不是图形化界面,所以这里就简略处理:
这就是关于套接字编程中使用udp协议进行网络通信的全部内容。