前面一篇我们讲了网络的基础,网络协议栈是什么样的,数据如何流动传输的;接下来这篇,我们将进行实践操作,真正的让数据跨网络进行传输;
1.网络编程储备知识
1.1 初步认识网络编程
首先我们需要知道我们的网络编程实例化到现实生活中就是使用app进行交互,这样的交互其实就是两台不同机器上的进程在进行通信,这其实也叫做进程间通信 ,不过是通过网络来进行的罢了;此时两个进程的共享内存叫做网络;
1.网络协议栈的下三层主要是用来保证数据的安全传输的问题
2.我们编程主要编写的是应用层代码
3.用户通过使用我们编写好的程序来进行数据发送与接收
1.2 端口号
认识端口号
我们编写的应用层代码形成程序载入内存时成为进程需要被标识,让网络可以通过IP找到机器通过标识找到当前进程,而这个标识就叫做------端口号;
1.端口号是一个2字节16比特位的整数
2.可以使用端口号找到机器上唯一进程
3.一个端口号只能对应一个进程(但一个进程可以有多个端口号)
端口号与pid
在我们前面linux系统的学习中,我们知道进程都有自己唯一的pid,那为什么我们不直接使用pid来标识进程呢,为什么还要引出一个端口号?
因为端口号是属于网络体系的,而pid是属于系统体系的,如果将pid直接作为端口号来标识唯一进程也是可以的,可是这样会存在一些问题:
1.pid是随机变化的,每次进程启动是pid都会发生改变,有些端口号是固定不变的
2.当系统中的pid需要调整时会导致网络中的端口号也随之改变,会增加维护的难度
3.高内聚,低耦合的思想
端口号绑定进程
我们如何理解将端口号绑定到一个进程上?
我们可以看作端口号与进程的pcb指针形成了hash键值对,从而可以通过端口号找到相应的进程;
1.3 网络字节序
世界上的机器是无穷多的,所以设备是个性化的,机器的实现一定是具有差异的,但是为了让机器可以正常通信,要使用网络覆盖底层的实现,让上层的交互规则是一样的;网络字节序就是这样一种覆盖的方式,我们的机器是存在大小端之分的,数据的传输也会存在数据的发送顺序问题,为了解决不同机器数据传输顺序的问题,需要使用网络下的函数,让传输进入网络中的数据按照网络字节序的形式存在;
只要是要传输到网络中的数据都要使用下面的函数对数据进行转换:
记忆方法:h为host(主机),n为network,l为长整型32位,s位短整型16位
htons就是将16位短整型数据由主机字节序转换为网络字节序;
ntohs就是将16位短整型数据由网络字节序转换为主机字节序;
1.4 初步认识tcp与udp
tcp
理解为打电话形式,数据是一定准确的传输到对方的
1.传输可靠(中性词,可靠但复杂)
2.有连接
3.面向字节流传输
udp
理解为发电报模式,我们不清楚我们的数据是否成功送达
1.传输不可靠(中性词,不可靠但简单)
2.无连接
3.面向数据报传输
1.5 socket套接字接口
套接字我们可以理解为底层开放给我们的接口,我们可以通过这个接口将数据送入底层进行传输;下面是socket套接字的接口:
1.创建socket套接字(我们可以理解为打开底层的网卡文件)
2.绑定套接字与端口号(也就是将进程和网卡连接起来,让进程可以向网卡发送数据)
3.udp接收数据报接口
4.udp发送数据报接口
2.实现upd客户端服务器通信
接下来我们通过实践来学习网络编程:
下面是我编写好的一份udp客户端与服务端通信的代码:
network_code/socket_2024_9_17 · future/Linux - 码云 - 开源中国 (gitee.com)
我们使用udp模拟的现象是两个进程可以通过网络进行通信;
下面是对代码关键地方的讲解:
2.1 socket与bind
首先,进程如果想通过网络进行交互,那么肯定需要先连接网络;那么如何连接呢,我们需要使用socket和bind函数;
2.1.1 socket
socket的头文件是<sys/types.h>与<sys/socket.h>
socket的第一个参数用来指定套接字是在哪个域中属于协议家族中的哪个协议,man手册中展示了有这些协议:
socket的第二个参数是指定套接字的数据类型 ,指定通信语义,其中sock_stream是面向字节流的数据类型,sock_dgram是面向数据报的数据类型
最后一个参数一般设置为0即可,可以自动绑定协议,我们也可以显示的设置
socket函数的返回值是一个文件描述符,这个文件描述符指向的是我们的网卡文件(linux下一切皆文件),当返回值为-1时代表打开socket失败并会设置errno错误码;
2.1.2 bind
我们通过上一步创建了套接字后,我们接下来就要将进程绑定上套接字,使得可以让网络找到当前进程;
bind函数的返回值成功绑定返回0失败返回-1并设置errno错误码,第一个参数是通过socket函数打开的网卡文件的文件描述符,第二参数是一个结构体的指针,第三个参数是这个结构体的大小;这个结构体指针是一个结构类型,可以用来接收两种不同类型的结构体;
2.1.3 sockaddr
这是bind函数的第二个参数的结构体类型,这个类型作为指针时可以接收两种不同结构体:
这样bind就可以通过一个参数接收不同类型的数据了;
我们在真正编程的时候是需要设置好sockaddr_in的各个成员变量的:
cpp
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_port = htons(_port);
上面的代码中我们一个个的设置好了上面各个参数的值,这些值因为要输入网络中,所以都需要是网络字节序,我们要使用htons函数与inet_addr来操作修改为网络字节序;其中htons的头文件是<arpa/inet.h>,inet_addr的头文件是<netinet/in.h>和<arpa/inet.h>;
我们设置好变量后通过bind函数进行绑定即可;
2.2 recvfrom与sendto
我们打开socket并绑定bind好后,就可以向网络中发送数据了,由于我们现在使用的是udp协议的网络编程,我们使用recvfrom与sendto来进行数据接收与发送;
实现示例:
cpp
char buffer[1024];
struct sockaddr_in client;
socklen_t len = sizeof(client);
int n = recvfrom(_fd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&client, &len);
if (n == -1)
{
log(WARNING, "recvfrom fail!");
}
buffer[n] = '\0';
将从网络中接收到的数据放入buffer中;
实现示例:
cpp
string info = "get message: ";
info += buffer;
n = sendto(_fd, info.c_str(), info.size(), 0, (const struct sockaddr *)&client, len);
if (n == -1)
{
log(WARNING, "sendto fail!");
}
2.3 网络状态查看指令
netstat -naup(-nlup)
2.4 IP地址绑定细节
进程在绑定自己的ip地址时一般是不需要自己进行绑定的,我们设置绑定ip地址为0.0.0.0即可
原因:
1.云服务器上的ip地址是虚拟地址,无法绑定虚拟ip
2.一台主机可以有多张网卡,当我们显示绑定其中一张的ip时其他ip的信息我们无法收到,所以我们直接设置为0可以接收所有发送到本机ip上的信息
所以我们可以这样绑定ip地址:
sin_addr.s_addr=htonl(INADDR_ANY);
INADDR_ANY这个宏在底层定义的也是0.0.0.0地址
2.5 端口号绑定细节
对于用户端,绑定端口号时我们不需要显示绑定,当客户端进程,开始发送消息时,系统会帮进程自动绑定本地的某个端口号与本地IP地址;
原因:
1.用户使用的客户端是不同厂家写的,不同厂家在写程序时如果显式的绑定了端口号,无法预测会不会和用户机器上其他的进程冲突,所以会将绑定端口号的操作交给用户机器的操作系统;
2.端口号对于用户来说并不重要,只需要让端口号识别唯一进程即可;
2.6 用户如何知道服务端端口号与ip地址
其实端口号和地址可以看作我们平时上网用的网站,网站的字符串可以被解析为IP地址与端口号,而浏览器就是通过这个ip地址与端口号找到相应服务器的;而服务器的厂商会通过宣传让人们知道它网站的域名;
2.8 云服务器防火墙
在我们使用云服务器时,我们是无法做到网络连接的,因为我们本主机上的端口号为了安全是被云服务器厂商设置了防火墙的,目的就是为了防止有人通过外部设备访问云服务器主机上的进程;而我们想要进行网络通信就必须得开放这个端口,我的腾讯云服务器是在控制台的防火墙处进行配置,要先配置好端口号才可以让其他进程通过网络连接云服务器上进程;如果你也遇到了明明编写好了程序可是就是无法进行网络交互,你试着把IP地址改为127.0.0.1这是本地环回地址,可以连接本地的网络,如果这样成功了就代表是你的防火墙拦住了你,你去设置一下即可;
2.9 实现的现象
3.udp服务器和客户端升级
3.1 服务器端接收数据处理的封装
上面我们实现了基础的udp客户端和服务器,接下来,我们可以将服务器的功能进行封装,使其成为回调函数,让服务器中的main函数,通过参数传递给服务器类,从而实现一个执行命令,和通信的功能:
network_code/socket_2024_9_17/2_udp_pro · future/Linux - 码云 - 开源中国 (gitee.com)
上面的代码相比与最前面的代码仅仅只是对处理客户端发送来的数据进行了封装,模拟处来了现实生活中,客户端对服务器发送请求,服务器接收请求后对请求进行处理,随后再返回给客户端的情况:
下面是实现的情况:
cpp
#include"server.hpp"
string addStr(const string& buffer)
{
string info = "get message: ";
info += buffer;
cout << info << endl;
return info;
}
bool checksafe(const string& comd)
{
vector<string> v={
"rm"
};
for(auto word:v)
{
if(comd.find(word)!=string::npos)
{
return true;
}
}
return false;
}
string command(const string& comd)
{
cout<<comd<<endl;
if(checksafe(comd))
{
return "command not safe";
}
FILE*f=popen(comd.c_str(),"r");
if(f==nullptr)
{
cout<<strerror(errno)<<endl;
exit(-1);
}
string back_info;
while(true)
{
char buffer[4096]={0};
char * ret=fgets(buffer,sizeof(buffer),f);
if(ret==nullptr)break;
back_info+=buffer;
}
return back_info;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
log(ERROR,"argc should be 2");
exit(-1);
}
uint16_t port=stoi(argv[1]);
unique_ptr<udpserver> ptr(new udpserver(port));
ptr->run(addStr);
return 0;
}
通过将函数方法传递实现了对数据的不同方式的处理,上面的addStr和command两个函数就是我们封装好的方法可以通过将这个两个方法进行传递从而,从而改变,服务端对数据的处理,其实在未来的工作中,我们的代码一般都是合作交互的,所以我们设计一个这样的接口交给其他人时,就可以减少耦合度,其他人只需要编写他们想要的接口,不会和我们的代码产生修改的矛盾;
3.2 windows下udp客户端实现
其实这并没有难度,代码实现是与我们linux客户端一样的只不过,我们需要注意windows下的库是如何处理的,我们需要将windows下的网络库先初始化,之后方才可以进行访问,使用库的功能,下面是windows下udp客户端的实现:
cpp
#define _WINSOCK_DEPRECATED_NO_WARNINGS 1
#include <WinSock2.h>
#include <iostream>
#pragma comment(lib,"ws2_32.lib")
#include<string>
#include <windows.h> // 用于字符编码转换
using namespace std;
// 将 GBK 编码转换为 UTF-8
string GbkToUtf8(const string& gbkStr)
{
int len = MultiByteToWideChar(CP_ACP, 0, gbkStr.c_str(), (int)gbkStr.length(), NULL, 0);
wchar_t* wstr = new wchar_t[len + 1];
MultiByteToWideChar(CP_ACP, 0, gbkStr.c_str(), (int)gbkStr.length(), wstr, len);
wstr[len] = '\0';
len = WideCharToMultiByte(CP_UTF8, 0, wstr, -1, NULL, 0, NULL, NULL);
char* str = new char[len + 1];
WideCharToMultiByte(CP_UTF8, 0, wstr, -1, str, len, NULL, NULL);
str[len] = '\0';
string utf8Str(str);
delete[] wstr;
delete[] str;
return utf8Str;
}
// 将 UTF-8 编码转换为 GBK
string Utf8ToGbk(const string& utf8Str)
{
int len = MultiByteToWideChar(CP_UTF8, 0, utf8Str.c_str(), -1, NULL, 0);
wchar_t* wstr = new wchar_t[len + 1];
MultiByteToWideChar(CP_UTF8, 0, utf8Str.c_str(), -1, wstr, len);
wstr[len] = '\0';
len = WideCharToMultiByte(CP_ACP, 0, wstr, -1, NULL, 0, NULL, NULL);
char* str = new char[len + 1];
WideCharToMultiByte(CP_ACP, 0, wstr, -1, str, len, NULL, NULL);
str[len] = '\0';
string gbkStr(str);
delete[] wstr;
delete[] str;
return gbkStr;
}
int main()
{
// 初始化套接字库
WORD mVersion;
WSADATA wsaData;
int err;
mVersion = MAKEWORD(1, 1);
err = WSAStartup(mVersion, &wsaData);
if (err != 0)
{
return err;
}
// 创建 UDP 套接字
SOCKET sockCli = socket(AF_INET, SOCK_DGRAM, 0);
SOCKADDR_IN addrSrv;
addrSrv.sin_addr.S_un.S_addr = inet_addr("111.229.31.168"); // 服务器 IP 地址
addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons(10000); // 服务器端口
SOCKADDR_IN addrCli;
int len = sizeof(SOCKADDR);
while (true)
{
cout << "请输入:";
string sendBuf;
getline(cin, sendBuf);
string sendMsg = GbkToUtf8(sendBuf);
char recvBuf[100] = { 0 };
// 发送数据到服务器
sendto(sockCli, sendMsg.c_str(), sendMsg.size(), 0, (SOCKADDR*)&addrSrv, len);
// 接收服务器发送的数据
recvfrom(sockCli, recvBuf, sizeof(recvBuf)-1, 0, (SOCKADDR*)&addrCli, &len);
string recvMsg = Utf8ToGbk(recvBuf);
cout << recvMsg << endl;
}
// 关闭套接字并清理库
closesocket(sockCli);
WSACleanup();
return 0;
}
未来防止windows上编译器编码格式和linux客户端的不同,我们对数据进行了处理,使得windows客户端上发送的信息发送到服务器上不会变成乱码,我们还加入了两个编码转换函数,当然这两个编码转换函数的实现不是我自己实现的,因为windows下的底层编码,我没有怎么学过;但我知道有编码转换的问题,我通过使用gpt找到编码转换的函数,载入我的代码中,成功的实现了windows和linux下进程的交互工作,下面是实现的现象:
从而,我们也可以理解,我们为什么在linux操作环境下编写代码部署到linux机器上,而大多数使用windows机器的人也都可以享受linux服务器的服务,这便是网络带来的便利;
3.3 服务器与客户端改造形成聊天室
接下来,我们将服务器再改造一下,使其可以将一个客户端发送的数据进行处理后发送给所有的客户端,使得可以形成一个群聊的模式;
network_code/socket_2024_9_17/3_udp_chatRoom · future/Linux - 码云 - 开源中国 (gitee.com)
上面是我代码的完整实现;
其中主要有这几个功能:
1.我们要识别每个不同的机器
2.我们要将接收的信息转发给每个连接了服务器的客户端
3.客户端接收信息和发送信息是并发的
解决方式:
1.将接收到的客户端套接字信息sockaddr中的port和ip获取出来,标识每个不同机器
2.通过存入不同机器的sockaddr和ip信息来分别发送给每个不同的机器
3.通过多线程的方式让客户端的接收和发送功能同时运行
下面是实现的现象:
本篇我们实现了udp客户端和服务器的功能下一篇我们将实现tcp服务器;
3.4 补充
补充俩个指令:
sz(文件名) 发送数据到本地主机
rz 从本地主机上获取数据
对网络字节序转换函数的提醒:
我们在转换ip地址时,有可能需要将网络字节序转回本机,我们可以使用:
inet_ntoa(可能存在线程问题)
inet_ntop(使用是安全的)