网络通信的本质(port标识的进程间通信)
只要有目的ip地址和源IP地址就能够完成客户端和服务器的通信了吗?并不是这样的,实际通信的并不是两台主机,而是两台主机上分别的客户端进程和服务器进程,IP地址能够标识主机的全网唯一性,那用什么来标识客户端进程和服务器进程的唯一性呢?其实是用端口号port来标识的。所以只要有IP地址+port就能够确定数据包发送给哪一个主机的哪一个进程了。
端口号是传输层协议的内容,应用层可以通过system call来获取端口号,端口号是一个2字节16位的整数,最大可达到65536的大小,因为传输层和网络层是操作系统实现的,所以port可以告诉操作系统应该将数据包发送给目标主机的哪一个进程。端口号在同一个ip地址对应的主机内只能被一个进程所占用,所以不同主机内部可能会出现相同端口号,这是很正常的事情,因为port标识的进程唯一性是在一台主机内部的,不同主机内出现相同port是很正常的。
例如下面图中主机A和主机B分别通过自己的IP+port标定了各自内部的进程在全网中的唯一性,从而实现跨局域网的网路通信。
全网唯一进程 = IP地址(全网主机唯一性) + 端口号port(该主机上进程唯一性)
我们知道,每个进程都有一个PID来标识它在当前计算机上的唯一性,为什么网络中还需要一个端口号port来标识进程的唯一性呢?不能用PID吗?
在技术实现上是完全可以用PID的,所以就需要考虑为什么偏偏就用了端口号port?
系统是系统,网络是网络,系统使用PID,网络使用port来标识进程的唯一性,实现了系统与网络的解耦。
不是所有进程都提供网络服务或者网络请求的,但是所有的进程都需要PID,只有需要网络的进程才会分配一个port。
客户端需要能够直接找到服务器的进程,服务器进程的唯一性不能做任何改变。
比如我们平时使用的QQ,我们手机上的QQ都是客户端,我们打开QQ使用都是在向服务器上的QQ进程发起网络请求,而这个服务器位腾讯公司,服务进程根据用户的网络请求再做出对应的反馈交给用户。
服务器的IP地址并不会随意变化,为了保证客户端每次都能找到服务端的进程,服务端的port也不能变化。
如果使用PID来代替端口号的话,服务器每重启一次,服务进程的PID值就会改变,客户端就无法找到服务进程了。
绑定了port的进程PCB会被维护在一个哈希表中,port就是key值,操作系统能够根据key值找到对应的PCB,然后再执行它
认识TCP/UDP协议
这两个协议的具体原理和细节在后面会详细讲解,这里仅需要大概了解一下它两的特定即可。
TCP协议:(Transmission Control Protocol 传输控制协议)。
传输层协议。
需要通信双方建立连接。
是一种可靠传输,不会发生丢包等问题。
面向字节流。
UDP协议:(User Datagram Protocol 用户数据报协议)。
传输层协议。
不需要通信双方建立连接,直接发生即可。
不可靠传输,可能会发生丢包等问题。
面向数据报。
等到后面进行套接字编程的时候你就能体会到了,UDP在通信时,客户端发什么服务器就接受什么,通信起来非常的方便,TCP在通信时就比较繁琐,需要先建立链接,然后用文件IO(字节流)那一套来进行客户端和服务器的通信.
但需要注意的是可靠和不可靠都是中性词,并不是说不可靠是贬义词,针对不同的常见适合不同的传输层协议,例如银行转账时一定是要用TCP协议的,数据的传输必须是稳定可靠的,但某些网络广告推送就比较适合用UDP,因为稳定可靠一定是有代价的,在代码处理上一定是更为繁琐复杂的,维护和编码的成本一定是比较高的。而广告推送这样的场景对稳定可靠的要求没那么高,自然就比较适合使用UDP协议,因为维护和编码的成本低。
网络字节序
协议谈完之后,需要面临的第一个问题就是网络字节序的问题,因为我们知道一般企业级的服务器一般都是大端字节序,我们用户级的笔记本都是小端,不同的主机使用的大小端都是不同的,这该怎么统一 一下呢?如果某个主机发送的数据是小端字节序,而接收的主机按照大端字节序来进行数据解释,这一定是会出问题的。
所以早在网络还没有大面积推广的时候,就已经规定了网络中的数据必须是大端的,如果你是小端机那就必须先将数据转为大端然后再发送到网络中,如果是大端机则直接发送数据即可。
其实这样规定也是有一定道理的,因为小端规定数据的高位在高地址处,低位在低地址处,而地址是从左向右逐渐增大的,数据的比特位是从左向右逐渐减小的,则内存中的存放和逻辑上的形式正好是反过来的,不利于看待,大端字节序更符合我们的逻辑认知。
主机在发送数据和接收数据时,都是按照从低地址到高地址的顺序来进行发送和接收。
网络字节序转化API
小端和大端之间的转换工作谁来做呢?Linux早已为我们提供好了一批字节序的转换API了。主机和网络分别对应host和net,l和s代表long和short,主机转网络时,会统一将数据转换为大端,网络转主机时,会将数据转换成主机的字节序,可能是大端也可能是小端,这取决于主机的字节序。
上面接口只提供了short和long两种数据类型,那如果有char和double的数据类型要进行主机和网络的转换呢?一般在网络发送的时候发送的数据都是字符串,如果能显示用上面的接口那就显示用,如果类型不匹配,那就发送隐式类型转换,系统帮我们做这个工作。
#include <arpa/inet.h>//必须包含的头文件
// 主机序列转网络序列
uint32_t htonl(uint32_t hostlong);//将主机上unsigned int类型的数据转换成对应网络字节序
uint16_t htons(uint16_t hostshort);//将主机上unsigned short类型的数据转换成对应网络字节序
// 网络序列转主机序列
uint32_t ntohl(uint32_t netlong);//将从网络中读取的unsigned int类型的数据转换成当前计算机字节序
uint16_t ntohs(uint16_t netshort);//将从网络中读取的unsigned short类型的数据转换成当前计算机字节序
UDP socket套接字
- socket
应用程序通过调用socket()系统调用创建一个套接字。在创建套接字时,需要指定套接字的类型(如TCP套接字或UDP套接字)以及地址族(如IPv4或IPv6)
参数详解:
- 第一个参数:它表示通信所使用的地址族。在网络编程中,地址族指的是网络地址的格式。常见的地址族包括:
- 第二个参数:它指定了套接字的类型,从而决定了套接字的通信方式。常见的套接字类型包括:
- 第三个参数:它用于指定通信所使用的协议。通常情况下,可以将该参数设置为0,表示让系统自动选择合适的协议。在这种情况下,系统会根据地址族和套接字类型来选择默认的协议。
- 返回值:socket成功是返回一个文件描述符,错误返回-1
- bind
bind() 函数用于将一个套接字与一个地址(IP地址和端口号)绑定在一起,从而使套接字可以在网络上接收来自该地址的数据
参数详解:
- 第一个参数:套接字描述符(Socket Descriptor):即之前通过 socket() 函数创建的套接字描述符。
- 第二个参数:指向 sockaddr 结构体的指针:该结构体包含了要绑定的地址信息,包括地址族、IP地址和端口号等。在 C 语言中,通常使用 sockaddr_in 结构体(对应IPv4)或 sockaddr_in6 结构体(对应IPv6)来表示网络地址。
第二个参数的类型是 struct sockaddr,这个是统一的类型接口,但是根据协议的不同我们应该使用不同的结构体,然后在bind接口中进行类型强转- 第三个参数:地址结构体的长度,这个参数通常是 sizeof(struct sockaddr),表示地址结构体的大小。
- sockaddr结构体
套接字有很多种类型,常见的有三种:
网络套接字:用户跨主机之间的通信,也能支持本地通信。
struct sockaddr_in {
short int sin_family; // 地址族,一般为AF_INET
unsigned short int sin_port; // 端口号,网络字节序
struct in_addr sin_addr; // IP地址
unsigned char sin_zero[8]; // 用于填充,使sizeof(sockaddr_in)等于16
};
原始套接字:可以跨过传输层(TCP/UDP)访问底层的数据。
域间套接字:只能在本地通信。 这些套接字的应用场景完全不同,所以不同种类的套接字就对应一套系统调用接口,所以三套就会对应三套不同的接口。
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* 带有路径的文件名 */
};
sockaddr_in和sockaddr_un是用于网络通信和域间通信两个不同的通信场景,它们的区别就在于结构体起始处的16位地址类型不同,网络通信使用AF_INET,域间通信使AF_UNIX。
- 但由于要使用一套接口,所以此时无论哪种通信,都使用sockaddr结构体。
- 在填充IP地址,端口号,以及地址类型的时候,仍然是对sockaddr_in进行填充。
- 在使用bind系统调用时,将sockaddr_in强转成sockaddr类型,在函数内部它会根据前两个字节自行判断是什么类型的通信,然后再强制转回去。
- 可以将sockaddr看成是基类,把sockaddr_in和sockaddr_un看出是派生类,此时就构成了多态体系。
UDP网络编程
网络通信一定是双方的,一端是服务端(Server)接收数据,另一端是客户端(Client)发送数据
服务端实现
网络通信首先就是要有套接字,也就是要有IP地址和端口号port,所以我封装的服务器中必须有这两项
const std::string defaultIP = "0.0.0.0"; //默认使用这个IP地址
const uint16_t defaultPort = 8080; //默认使用该端口
class UdpSever
{
private:
int _socket_fd; // 套接字文件描述符
uint16_t _port; // 端口号
std::string _ip;
public:
UdpSever(uint16_t port = defaultPort, const std::string &ip = defaultIP) : _port(port), _ip(ip), _isrunning(false)
{}
~UdpSever()
{}
void Init() //创建套接字
{}
void Run() //启动服务端
{}
- 在shell上使用指令ifconfig,可以看到服务器的IP信息,如上图所示,红色框中的inet:127.0.0.1是本地环回,因为回环接口将数据包在本地循环传输,所以发送和接收的数量是相同的。
- 数据从当前计算机上的客户端进程流向了当前计算机上的服务的进程。所以可以使用本地环回IP地址来进行测试。
- 但是我们知道,每台计算机在网络中都有一个公网IP,这个IP和本地回环不一样,还有局域网IP等等。存在这么多IP,都标识着服务器这一台计算机。
- 如果服务器仅仅绑定本地换行的IP地址,当另一台计算机的服务端想要通过公网IP地址发起申请的时候,由于两个IP地址不一样,所以就会忽略客户端的申请。
- 如果服务器端bind的IP地址是本地回环地址(127.0.0.1),然而客户端绑定的是一个正常的一个IP地址,它们是无法通信的。原因在于,本地回环地址 127.0.0.1 是专门用于本地主机内部通信的地址,数据包发送到这个地址后会立即返回到本地主机,而不会通过网络传输到其他设备。因此客户端需要绑定的是本地主机的正常IP地址,这样才可以进行本地的套接字测试
- 当服务器端绑定的IP地址是0.0.0.0,这表示该套接字将会绑定到服务器主机上的所有可用网络接口上。换句话说,它会监听服务器主机上所有网络接口的连接请求。它会接受来自所有本地网络接口的连接请求,无论是来自本地主机还是来自网络中其他设备的请求。
void Init()
{
// 1.创建UDP套接字 协议族 套接字类型 套接字所用的协议 | 0:表示根据第一个和第二个参数的类型自动选择合适的默认协议
_socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket_fd < 0)
{
_Log.LogMessage(Fatal, "socket create error, Error Message is : %s", strerror(errno));
exit(errno);
}
_Log.LogMessage(Info, "socket create sucess %d", _socket_fd);
// 2.绑定端口 和 IP
// bind接口上使用的通用struct sockaddr结构体,因为我们创建一个UDP的IPv4套接字所以需要使用这个结构
struct sockaddr_in addr;
// 对结构清空 0
bzero(&addr, sizeof(addr));
// 2.1指定IPv4套接字地址的数据结构成员
// 表示地址族
addr.sin_family = AF_INET;
// 将该端口从主机字节序列转化为网络字节序列-》大端
addr.sin_port = htons(_port);
// IPv4地址 addr里面的sin_addr是一个in_addr类型的结构,in_addr里面只有一个成员变量就是s_addr是一个uint32_t -》unsigned int
// inet_aton(_ip.c_str(), &(addr.sin_addr));
addr.sin_addr.s_addr = inet_addr(_ip.c_str());
// 2.1绑定
socklen_t size = sizeof(addr);
if (bind(_socket_fd, (const struct sockaddr *)&addr, size) < 0)
{
_Log.LogMessage(Error, "bind binding error, Error Message is : %s", strerror(errno));
exit(errno);
}
_Log.LogMessage(Info, "bind binding sucess: %s", strerror(errno));
}
上述代码中bind的时候,我们bind第二个参数是将我们设置的IP地址、port等设置到内核中,经过Init()函数后我们的服务端的准备工作已经完成
void Run()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in client;
bzero(&client, sizeof(client));
socklen_t size = sizeof(client);
// 3. 接收数据包
// 从指定UDP套接字中接收数据
char inbuffer[1024];
ssize_t n = recvfrom(_socket_fd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&client, &size); //xxxxxxxxx????????
if (n < 0)
{
_Log.LogMessage(Error, "recvfrom reveice error, Error Message is : %s", strerror(errno));
continue;
}
inbuffer[n] = '\0';
// 4. 数据的处理
std::string info = inbuffer;
std::string echo_strng = "sever reveice and echo:" + info;
std::cout << echo_strng << std::endl;
//std::string echo_strng = func(info);
// 5. 将处理完成的数据发送回给客户端
sendto(_socket_fd, echo_strng.c_str(), echo_strng.size(), 0, (const sockaddr *)&client, size);
}
}
上图所示的系统调用recvfrom()
用来接收网络中发过来的数据,也就是从套接字中接收。
- 第一个参数是sockfd,是创建套接字时返回的文件描述符fd。
- 第二个参数buf是用来存储从网络中读取下来的数据的。
- 第三个参数是buf缓冲区的大小。
- 第四个参数flags是读取的方式,一般设置为0,即阻塞读取数据。
- 第五个参数sockaddr* src_addr是一个输出型参数,同样传参sockaddr_in结构体,系统会自动对这个结构体进行填充,可以获取数据的来源,包括发送方的地址类型,端口号port以及IP地址。
- 第六个参数addrlen是第五个输出型结构体变量的大小所在的地址,注意类型是socklen_t*的,和bind的时候不一样。
- 返回值ssize_t,返回读取到的数据个数,单位是字节,如果读取失败则返回-1。
Main.cc
我们使用命令行参数的形式,告诉服务器需要bind的port是多少
void Useage(const std::string& argv)
{
std::cout << argv << " -> Should Enter port 1024+" << std::endl;
}
// ./udpsever port
int main(int argc, char* argv[])
{
if(argc != 2)
{
Useage(argv[0]);
return 0;
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<UdpSever> sever(new UdpSever(port));
sever->Init();
sever->Run();
return 0;
}
现在我们服务端正常运行,绑定的IP地址是0.0.0.0 以及 端口是8080
netstat -naup
命令将显示当前系统上所有的 UDP 连接信息,包括本地地址、外部地址、端口号以及与其相关联的进程信息。
客户端实现
客户端需要bind,但是不需要用户显式的bind。客户端一定需要IP地址和端口,在调用sendto向网络中发送数据的时候,操作系统会自动将客户端的IP地址和端口号绑定,并且一起发送出去。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include "Log.hpp"
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
using namespace std;
Log _Log;
void Useage(const std::string &argv)
{
std::cout << argv << " -> Should Enter severip severprot" << std::endl;
}
//./udpclient severip severprot
int main(int argc, char *argv[])
{
if (argc != 3)
{
Useage(argv[0]);
return 0;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
// 1.创建UDP套接字 协议族 套接字类型 套接字所用的协议 | 0:表示根据第一个和第二个参数的类型自动选择合适的默认协议
int sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (sock_fd < 0)
{
_Log.LogMessage(Fatal, "socket create error, Error Message is : %s", strerror(errno));
exit(errno);
}
_Log.LogMessage(Info, "socket create sucess %d", sock_fd);
// 发数据
struct sockaddr_in sever;
bzero(&sever, sizeof(sever));
sever.sin_family = AF_INET;
sever.sin_port = htons(port);
// inet_aton(ip.c_str(), &(sever.sin_addr));
sever.sin_addr.s_addr = inet_addr(ip.c_str());
//cout << "sever.sin_addr.s_addr = inet_addr(ip.c_str()); " << sever.sin_addr.s_addr<< endl;
socklen_t size = sizeof(sever);
std::string message;
while (true)
{
std::cout << "Please Enter Message: " << std::endl;
getline(cin, message);
ssize_t sendto_ret = sendto(sock_fd, message.c_str(), message.size(), 0, (struct sockaddr *)&sever, size);
if (sendto_ret < 0)
{
_Log.LogMessage(Error, "sendto Error %s", strerror(errno));
}
_Log.LogMessage(Info, "sendto sucess");
char inbuffer[1024];
// 此时客户端只与一台主机通信,所以发送过来的一定是这个主机,所以第五 六个参数没有实际意义了
struct sockaddr temp;
socklen_t temp_lent = sizeof(temp);
ssize_t n = recvfrom(sock_fd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&temp, &temp_lent);
if (n > 0)
{
inbuffer[n] = '\0';
std::cout << inbuffer << std::endl;
}
}
close(sock_fd);
return 0;
}
客户端通过用户的输入发给服务器,然后服务器将消息添加后回显出来
命令执行-服务器
我们在服务器端使用回调函数,在服务器收到客户端发送过来的数据之后,之前我们的服务器端就仅仅只是将消息回显回去,现在我们处理一个xshell中的命令,然后服务器将子进程执行的命令结果回显回去
//udpsever.hpp
#pragma once
#include <iostream>
#include <sys/socket.h>
#include "Log.hpp"
#include <string>
#include <strings.h>
#include <errno.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <functional>
Log _Log;
const std::string defaultIP = "0.0.0.0";
const uint16_t defaultPort = 8080;
using func_t = std::function<std::string(std::string&)>;
class UdpSever
{
private:
int _socket_fd; // 套接字文件描述符
uint16_t _port; // 端口号
std::string _ip;
bool _isrunning;
public:
UdpSever(uint16_t port = defaultPort, const std::string &ip = defaultIP) : _port(port), _ip(ip), _isrunning(false)
{
}
void Init()
{
// 1.创建UDP套接字 协议族 套接字类型 套接字所用的协议 | 0:表示根据第一个和第二个参数的类型自动选择合适的默认协议
_socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket_fd < 0)
{
_Log.LogMessage(Fatal, "socket create error, Error Message is : %s", strerror(errno));
exit(errno);
}
_Log.LogMessage(Info, "socket create sucess %d", _socket_fd);
// 2.绑定端口 和 IP
// bind接口上使用的通用struct sockaddr结构体,因为我们创建一个UDP的IPv4套接字所以需要使用这个结构
struct sockaddr_in addr;
// 对结构清空 0
bzero(&addr, sizeof(addr));
// 2.1指定IPv4套接字地址的数据结构成员
// 表示地址族
addr.sin_family = AF_INET;
// 将该端口从主机字节序列转化为网络字节序列-》大端
addr.sin_port = htons(_port);
// IPv4地址 addr里面的sin_addr是一个in_addr类型的结构,in_addr里面只有一个成员变量就是s_addr是一个uint32_t -》unsigned int
// inet_aton(_ip.c_str(), &(addr.sin_addr));
addr.sin_addr.s_addr = inet_addr(_ip.c_str());
// 2.1绑定
socklen_t size = sizeof(addr);
if (bind(_socket_fd, (const struct sockaddr *)&addr, size) < 0)
{
_Log.LogMessage(Error, "bind binding error, Error Message is : %s", strerror(errno));
exit(errno);
}
_Log.LogMessage(Info, "bind binding sucess: %s", strerror(errno));
}
void Run(func_t func)
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in client;
bzero(&client, sizeof(client));
socklen_t size = sizeof(client);
// 3. 接收数据包
// 从指定UDP套接字中接收数据
char inbuffer[1024];
ssize_t n = recvfrom(_socket_fd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&client, &size); //xxxxxxxxx????????
if (n < 0)
{
_Log.LogMessage(Error, "recvfrom reveice error, Error Message is : %s", strerror(errno));
continue;
}
inbuffer[n] = '\0';
// 4. 数据的处理
std::string info = inbuffer;
// std::string echo_strng = "sever reveice and echo to client: " + info;
// std::cout << echo_strng << std::endl;
std::string echo_strng = func(info);
// 5. 将处理完成的数据发送回给客户端
sendto(_socket_fd, echo_strng.c_str(), echo_strng.size(), 0, (const sockaddr *)&client, size);
}
}
~UdpSever()
{
close(_socket_fd);
}
};
//Main.cc
#include"UdpSever.hpp"
#include<memory>
std::string RunCommand(std::string& command)
{
FILE* fp = popen(command.c_str(), "r");
if(fp == nullptr)
{
perror("popen error");
return "Error";
}
std::string str;
char buffer[4096];
while(true)
{
char* ptr = fgets(buffer, sizeof(buffer), fp);
if(ptr == nullptr)break;
str += ptr;
}
fclose(fp);
return str;
}
void Useage(const std::string& argv)
{
std::cout << argv << " -> Should Enter port 1024+" << std::endl;
}
// ./udpsever port
int main(int argc, char* argv[])
{
if(argc != 2)
{
Useage(argv[0]);
return 0;
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<UdpSever> sever(new UdpSever(port));
sever->Init();
sever->Run(RunCommand);
return 0;
}
- Main.cc中
popen
函数是用于创建管道并启动另一个进程的函数。它通常用于执行外部命令,并且可以方便地从该命令的标准输出或标准错误中读取数据- 创建管道:popen 函数会创建一个管道,该管道连接到一个新的进程,允许在两个进程之间进行通信。通常,这个管道用于将另一个进程的标准输出或标准错误连接到当前进程的文件流中。
- 启动另一个进程:popen 函数启动一个新的进程来执行指定的命令。这个命令可以是任何可以在系统上执行的命令,例如执行系统命令、运行其他可执行文件等。
- 指定模式:popen 函数可以以读取模式(“r”)或写入模式(“w”)打开管道。读取模式意味着你希望从另一个进程中读取输出,而写入模式意味着你希望将数据写入到另一个进程的标准输入中。
- 返回文件指针:popen 函数返回一个指向文件流的指针,你可以使用这个指针来读取从另一个进程的标准输出或标准错误中发送的数据,或者将数据写入到另一个进程的标准输入中。
- 执行外部命令:通过 popen 函数,你可以执行各种外部命令,并从这些命令的输出中获取信息。这在需要与外部系统进行交互或执行系统级任务时非常有用。
- 关闭管道:在完成与另一个进程的通信后,你应该使用 pclose 函数关闭管道。这样可以确保释放资源并终止与另一个进程的连接。
如果我们不想使用popen函数,我们可以在会调方法创建子进程,然后让子进程执行命令
if ((child_pid = fork()) == 0) {
handle_client_command(sockfd, cliaddr, len);
exit(0);
} else if (child_pid < 0) {
perror("fork failed");
exit(EXIT_FAILURE);
}
// Parent process
// Wait for any child process to terminate and clean up resources
//每一个客户端发消息都会创建一个子进程,所以需要父进程循环等待所有的子进程
while (waitpid(-1, NULL, WNOHANG) > 0);