本篇主要讲解:
认识端口号, 网络字节序等网络编程中的基本概念;
学习socket api的基本用法;
能够实现简单的udp客户端/服务器;
如果屏幕前的你对于网络的一些基本概念(比如网络的分层模型、两台主机如何通信、IP地址和MAC地址等等)还不是很了解的话,可以先看看我这篇博客:【网络】网络扫盲篇 ——用简单语言和图解带你入门网络。
提个问题,两台主机都有了对方的IP地址,这两台主机就可以进行通信了吗?
答案是不能的,还缺点东西。
缺个端口号。
IP地址在全网中能够标定唯一一台主机。当两台主机通过IP地址发数据的时候,把数据发送到了对方主机就完了吗?并没有。如图:
两个黑框是网络的各个分层,我就不画了。
说数据发给了对面的主机不太准确的,更准确的来说是发给对面主机的某个进程了。
当你打开一个浏览器,在浏览器中输入www.baidu.com,这时我们的主机会向百度的主机进行请求,然后百度的主机将其首页的信息发给了我们当前的主机,更准确的说是将其首页给了我们电脑上的某一个进程,而这个进程就是浏览器。
同理,屏幕前的你刷B站的时候,是B站的服务器将视频资源推送给了你手机上的B站客户端APP,而不会推送给其他的APP。
那么上面的图还是有点不准确的,发送数据时,应该也是一个进程发送,接收时是一个进程接收:
在请求资源的时候是客户端软件帮你请求,让你的主机帮你将请求信息发送给对方的主机,而对方也有一个服务器软件,所有的推送都是这个服务器软件做的,说白了就是客户端进程和服务端进程在通信,所以通信的时候并不是机器在通信,而是最上层的应用在通信,且客户端和服务端都是要处于运行状态的,也就是客户端进程和服务端进程:
同样的,服务端也可以给客户端发数据。
所以网络通信的本质是进程间通信,将数据在主机间转发仅仅是手段,机器收到之后,需要将数据交付给指定的进程。
但是在交付的时候如何确定是交付给哪一个进程呢?
就是通过端口号。
网络进程会有一个端口号,且该端口号在主机上是唯一的,当收到数据的时候,在传输层的报头会包含数据要发送给指定进程的端口号。
通过IP能够确定一个主机,通过端口号能确定某一个主机中的一个网络进程。所以IP + 端口号就能确定全网中的唯一的一个进程。所以任何一个报文,一定要带上IP和port(端口号),用IP找到目标主机,用port找到目标进程。
端口号(port)是传输层协议的内容。
端口号是一个2字节16位的整数;
端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
一个端口号只能被一个进程占用
进程的PID也可以确定一个主机的唯一进程,但是PID和端口号没有半毛钱关系,虽然用PID也可以做到在网络中标识进程,但是PID是进程管理的范畴,若用了PID,那进程管理和网络就会有关联,让两个毫不相关的模块产生关联是没有必要的,二者解耦,更容易维护。
还有一点,并不是所有的进程都需要端口号的,但是所有的进程都是要有PID的,因为不是所有的进程都要作为客户端/服务端,所以只需要给网络进程提供端口号就行。
一个进程可以和多个端口号绑定,意思就是多个端口号都可以指向一个进程。但是一个端口号只能绑定一个进程,不然发送的数据就跑不到指定进程那了。
客户端向服务端发送请求的时候,客户端是源端口号,服务器为目的端口号。
服务端向客户端产生相应的时候,服务器是源端口号,客户端为目的端口号。
意思就是谁发数据谁就是源端口号,谁接收谁就是目的端口号。
将IP和端口号结合起来就是套接字,IP又分为源IP和目的IP,端口号也分为源端口号和目的端口号。
{SRC_IP, SRCPORT} 和 {DST_IP 和 DST_PORT}都是套接字。
网络编程又称为套接字编程。我后面说套接字时部分地方就直接说成socket了,屏幕前的你不要看见了不认识。
上手前得先简单说说这两个东西。
说归说,只是一带而过,后面我会有专门的博客细讲这两个东西。而且本篇只是写简易的UDP服务器和客户端。
此处我们先对TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识; 后面我的博客中再详谈TCP。
连着下面的UDP一块说。
此处也是对UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识; 后面博客再详细讨论.
- 传输层协议
TCP和UDP都是传输层的协议。
2.有无连接
TCP有连接,意思是通信前要先和对方建立好连接才能通信,比如说我们打电话的时候,一方打过去,另一方得接住才能说话,不能说刚打过去你就开始说了,那是在和空气说。
UDP无连接,就是通信前不需要通知对方,直接说好了一块发过去,比如说写信,我们发给对方的时候对方是不知道你写了信的,但是到时候自然有人通知TA有信来了。
- 可不可靠
屏幕前的你先不要拿这一点来判断谁好谁坏。TCP和UDP都能够存在说明是都能被接受的,各有各的特色。
先说点UDP不好的,UDP会出现丢包问题,TCP不会出现。不过现在的网络出现丢包问题概率并不大,即使出现了大部分场景下也是可以容忍的。先不要对UDP产生偏见,等会讲的时候你就知道为啥了。
二者的可不可靠只是二者的特点。并不是单方的缺点。TCP比UDP可靠,那么TCP就要做更多的工作,可靠性是要靠大量的编码和数据处理工作来实现的,所以TCP想要保证可靠性就一定会使得其在数据通信时为了可靠性而设计出更多的策略,如面向连接、确认应答、超时重传、流量控制、拥塞控制等机制,而这些机制都要靠TCP协议自己来完成,所以说虽然保证了可靠性,但是也就导致了该协议更复杂,维护起来成本更高。
此时UDP就可以对TCP说:咋俩都是协议,你把自己搞那么累干嘛,丢包就丢包了,心放大点,这都不是事,所以UDP只要把数据交给下层就行了,然后就啥也不用管了。而TCP就得管一大堆事,即TCP更安全,UDP更简单。
- 但是我们大部分情况下传输层协议用的是TCP。像直播、视频这种数据可以用UDP,比如我们有时候看着直播 / 视频时突然卡一下/没声了/屏幕花了等等问题,就可能是因为传输层用的是UDP协议导致丢包了,但是这样就卡一两秒对整体的观看体验影响不大(足球比赛快要进球的时候卡了当我没说)。不过有钱的公司也在直播或视频这种资源上也可以用TCP协议,不过TCP协议花费更多,但是能够保证数据的安全,客户能有更好的体验。
- 面向字节流和面向数据报流
这两点我这里没法讲,字节流和数据报流得我后面写代码的时候各位才能稍微品一品,直接将的话很难讲清楚。所以这两点先暂时搁置。
屏幕前的你学过大端小端没,没学过或者掌握的不扎实的看这篇:点击目录:3. 大小端字节序介绍及判断。
想一想,如果两个主机都不知道对方的字节序是大端还是小端,接收到数据的一方应该怎么取其接收到的数据呢?
有的同学说,可以在发送的数据中加上一个标识位来表示当前发送数据的主机是大端还是小端。这种做法是错误的,对方并不知道发送的机器是大端还是小端,取这个标识位的时候怎么取呢?是按照大端还是按照小端,这就是先有蛋还是先有鸡的问题。
那么怎么搞呢?
网络规定:发送的网络中的数据都必须是大端。
如果发送方主机是小端,那就得先把数据转成大端的才能发到网络中,如果是大端那就直接发就行。
如果接收方主机是小端,那就得先把从网络中的数据转成小端,然后再存入内存中,如果是大端就直接存就行。
可能有的同学觉得这里会比较麻烦,不要担心,有专门的接口做这件事的:
这些函数名很好记:
看着函数名,就是什么to什么,转的数据是4字节的就用l,是2字节的就用s。
例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;
如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
主要接口如下(先不用详细看):
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
不过我们这里等会先写一个简单的UDP服务器,就只会用到前两个。
写之前先讲点套接字相关的东西。
网络通信套接字标准是基于POSIX标准的,和多线程一样。
常见的套接字种类有:
本篇主要讲网络socket。
域间socket也可说是基于套接字的管道通信,适用于本地,像进程间通信时的管道一样,mkfifo啥的,所以域间socket是在网络通信标准当中定义出的本地通信方案。网络socket学会了这个也就会了,所以先不考虑域间的。
原始socket很少会用到,其可以直接调用网络层和数据链路层的接口,主要是用来写出各种各样的工具的,本篇不做详谈。
为啥要把这三个都说说呢?
很明显,这三个socket通信的手段和目的都是不一样的,所以理论上这是三种应用场景,对应的应该是三套接口。但是Linux中并没有设计三套,而是统一处理了,都用的是一套接口,只是传参有点差别。
再看一下前面的一个接口:
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
其中有一个参数const struct sockaddr *address,这个结构体就是和套接字相关的。
不过这个用起来像多态一样。
我们在传这个参数的时候传的不是sockaddr这个类型,而是sockaddr_in或者sockaddr_un,网络通信的时候用的是sockaddr_in。这里的sockaddr_in中包含了IP地址和port端口号:
我们在用的时候要把sockaddr_in对象中的成员给好,然后再将这个对象的地址传给sockaddr的参数。
域间通信的时候用的就是sockaddr_un,也是成员给好后再将地址传给那个参数。
通用类型sockaddr指针就像void*,会在函数内部对in和un类型的对象进行修改,但这里没有用void是因为这些接口设计之前C语言还没有支持void的用法,也就是说这些接口比void*更古老一些。
下面就来简单写一个UDP的服务端。
先来简单写出服务端的框架,一个UDP_server.hpp,一个UDP_client.cpp。
定义一个服务端类(hpp中):
#include
#include
class ServerUDP
{
public:
ServerUDP(const std::string& IP = "0.0.0.0", uint16_t port = 0)
: _IP(IP)
, _port(port)
{}
private:
std::string _IP; // 服务端的IP
uint16_t _port; // 服务端的端口号
};
服务端运行(cpp):
#include "UDP_server.hpp"
#include
// 规定服务端执行的方式
void usage()
{
printf("usage:\n./UDP_server IP port\n");
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
// 正确用法:./UDP_server IP port
usage();
exit(1);
}
// 获取服务端的IP
std::string serverIp = argv[1];
// 获取服务端端口号
uint16_t serverPort = std::stoi(argv[2]);
// 用智能指针维护
std::unique_ptr<ServerUDP> up(new ServerUDP(serverIp, serverPort));
return 0;
}
目前为止还没有用到套接字相关的东西。
搞一个接口进行服务端的初始化工作,一个接口进行服务端的启动工作:
初始化时,要调用系统接口进行网络通信,如果想要进行网络通信,第一步就要创建套接字,用这个接口:
socket,是传输层对系统文件操作进行封装而来的一个接口,所以其返回值是一个文件描述符:
所以创建一个套接字就会得到一个文件描述符。
那么我们可以直接用文件操作相关的接口来进行网络读写吗?
理论上可以,但是我前面博客中讲的文件读写操作都是面向字节流式的,所以在UDP这里不适用,在TCP才可以,UDP有自己专属的读写接口。
来说说socket接口的各个参数。
============================================================================
因为这里要写一个UDP服务器,所以就得用SOCK_DGRAM,后面的解释Supports datagrams (connectionless, unreliable messages of a fixed maximum length).就是数据报,无连接,不可靠。
第一个参数和第二个参数有什么区别呢?
第一个是决定进行网络通信还是本地通信,第二个参数是确定了网络/本地通信后用流还是数据报通信。写UDP的时候就可以吧AF_INET和SOCK_DGRAM当成固定用法就行。
============================================================================
第三个参数protocol是只要前两个定成了AF_INET和SOCK_DGRAM,这个就跟着定了,直接给0让系统自动给UDP协议就行。
在InitServer中创建套接字,但是别的地方也可能会使用这个套接字,所以要多加一个成员_sock来表示其返回值,前面也说了这个返回值相当于文件描述符,所以类型给int就行:
日志打印:
#pragma once
#include
#include
#include
#include
#include
#include
// 文件名
#define _F __FILE__
// 所在行
#define _L __LINE__
enum level
{
DEBUG, // 0
NORMAL, // 1
WARING, // 2
ERROR, // 3
FATAL // 4
};
std::vector<const char*> gLevelMap = {
"DEB",
"NOR",
"WAR",
"ERR",
"FAT"
};
#define FILE_NAME "./log.txt"
void LogMessage(int level, const char* file, int line, const char* format, ...)
{
#ifdef NO_DEBUG
if(level == DEBUG) return;
#endif
// 固定格式
char FixBuffer[512];
time_t tm = time(nullptr);
// 日志级别 时间 哪一个文件 哪一行
snprintf(FixBuffer, sizeof(FixBuffer), "==================================\
\n<%s> -> %s[%s] [%d]\n", gLevelMap[level], ctime(&tm), file, line);
// 用户自定义格式
char DefBuffer[512];
va_list args; // 定义一个可变参数
va_start(args, format); // 用format初始化可变参数
vsnprintf(DefBuffer, sizeof DefBuffer, format, args); // 将可变参数格式化打印到DefBuffer中
va_end(args); // 销毁可变参数
// 往显示器打
printf("%s%s\n\n", FixBuffer, DefBuffer);
// 往文件中打
// FILE* pf = fopen(FILE_NAME, "a");
// fprintf(pf, "%s%s\n\n", FixBuffer, DefBuffer);
// fclose(pf);
}
第一步是创建套接字,让IO的时候通过文件描述符来进行IO。至此已经完成。但通信的时候需要用IP和端口号的绑定来表示唯一进程。所以下面就要进行这个工作。
第二步是进行bind绑定IP和端口号,bind函数可以将用户设定的IP和port在内核中和当前进程进行关联,这样底层在收到数据后,就可以通过端口号将数据交给当前的服务端进程了。
先说一下返回值,成功返回0,失败返回-1并设置错误码。
第一个参数sockfd就是刚刚socket创建套接字返回的文件描述符。
第二个参数就是上面说的那个结构体sockaddr,这里要进行网络通信,所以就需要用sockaddr_in对象。先来看看sockaddr_in类中都有哪些成员:
看起来很复杂,其实就四个成员。挨个说说。
这是一个宏,转到定义看看:
里面用到了##这个东西,这个东西作用是拼接两个字符串,这里是将sa_prefix,也就是宏括号中的那个东西,而__SOCKADDR_COMMON (sin_)传过来的就是sin_,所以拼接到一块就是sin_family,这个字段的类型是sa_family_t,就是上面的unsinged short int。
所以整个__SOCKADDR_COMMON (sin_)就是:
unsinged short int sin_family。
前面在讲sockaddr的时候说了,socket接口中的第一个参数domain要和sockaddr_in中的一个成员的值相同,这个成员就是这里的sin_family。也就是下面这张图中sockaddr_in的前两个字节:
这个in_port_t类型就是uint16_t,也就是端口号的类型
所以这个成员就是专门给端口号准备的。化成最简就是:
unint16_t sin_port;
这个in_addr结构体封装了两层:
其实in_addr中里面就一个成员,类型还是unint32_t的。其实是给IP地址用的。
这里要讲讲。
我们平时看到的IP地址都是像"192.168.216.198"这种的点分十进制表示的,而这种用字符串风格的IP地址,用.分开的数字范围为[0, 255],即8位能表示的数,故是将一个四字节的数一个字节一个字节的分开了,更方便我们阅读,但是用字符串存取这个数的话太浪费空间了,我们可以直接用一个4字节数来表示一个IP地址就够了,用字符串表示的话至少得花费17个字节,至少相差4倍,所以实际传输IP地址的时候用的是一个unint32_t类型的整数,而非字符串,只有在现实的时候才会用点分十进制表示。
所以这里化成最简就是:
unint32_t sin_addr;
所以最终sockaddr_in中就是四个成员:
第一个是要和socket第一个参数domain相同的sin_family
第二个是当前进程的端口号sin_port
第三个是当前主机的IP地址sin_addr
第四个是需要全部填充为0的填充字段sin_zero
不过还有点问题,我最开始写代码的时候虽然有一个成员_IP存放了ip地址,但是这个_IP是点分十进制的,所以得先转成数字形式的才能用。
还有一个问题网络通信的时候一方给另一方发送数据的同时也要把IP和端口号发送同时发送过去,因为这样对方在回消息的时候才能知道给谁回(就像回信一样),所以服务器的IP和端口号也是要发送给客户端的主机的,所以要先把IP和端口号数据发送到网络中,但是_port有两字节,转化成数字形式的IP有4字节,发送到网络之前都要先转化成大端才能发送,不然就会乱掉,但是也不用担心,库中提供了专门的接口来转换。
对于端口号_port,直接就是一个两字节的数,所以转换的时候用我前面介绍的htons就行。
库中还有一个接口,可以直接让点分十进制的字符串IP转换成一个4字节的大端数,这个函数就是inet_addr:
再来说StartServer,作为一款网络服务器,就得做到永远不退出,所以Start中必须是一个死循环,服务器一启动,就是一个永远在内存中存在的进程,除非挂了,这种永远运行的进程就叫做常驻进程,所以服务器在写时一定要保证不能出现内存泄漏,不然过段时间吃一点内存,过段时间吃一点内存,进程一定会挂掉。
服务器操作大致分为三步:
不过这里只是为了简单写一个UDP的服务器,那么就写一个最简单的,直接客户端发送过来啥就返回啥,这种服务器叫做echo服务器,等于说没有第二步。
读取数据的时候用recvfrom:
说参数:
第一个参数就是刚刚创建socket时返回的文件描述符。
第二个参数是你要将数据读取到哪个缓冲区中。第三个参数为你期望读取到数据的大小。函数的返回值是实际读取到的数据的大小。
第四个参数flag是读取方式,给0为以阻塞的方式读取,这里等会写的时候就按照阻塞的方式读取。
倒数第二个src_addr是一个输出型参数,除了得到数据,我们也想知道是谁发的数据,所以就用这个参数来拿发送方的套接字,虽然还是sockaddr类型的结构体指针,但这里写UDP服务器还是用sockaddr_in类型的对象来拿,不过要记得传入参数之前要将sockaddr_in对象都置为0,不然会出现奇怪的问题。
最后一个参数addrlen是一个输入输出型参数,意思就是输入的时候该变量的数值有用,输出的时候这个变量的数值也有用。所以定义一个socklen_t对象时,传参前其值要为sockaddr_in结构体对象的大小。
这里虽然只是一个简单的echo服务器,但是最起码要将客户端的IP和端口号找出来,不过二者都是从网络中来的,还是需要先转换一下,port直接用ntohs就行。IP要转成点分十进制的,方便查看,库中也有一个直接将网络字节序的数转成点分十进制的字符串的函数inet_ntoa:
等会直接用这两个函数就行。
写回数据用的是sendto接口:
这里既然就是写一个简单的echo服务器,所以直接就可以复用前面recvfrom得到的数据。直接原封不动写回去就行。
#include "LogMessage.hpp"
#include
#include
#include
#include
#include
#include
#include
const int BUFFER_SIZE = 128;
class ServerUDP
{
public:
ServerUDP(const std::string &IP = "0.0.0.0", uint16_t port = 0, int sock = -1)
: _IP(IP), _port(port), _sock(sock)
{}
// 服务端初始化
void InitServer()
{
// 创建套接字
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (_sock == -1) // 创建失败就退出
{
LogMessage(FATAL, _F, _L, "creat socket fail ::%s", strerror(errno));
exit(2);
}
sockaddr_in local; // 初始化的时候全部清零,可以用memset,也可以用bzero直接清零
bzero(&local, sizeof(local)); // bzero会将local中的内容清零,这样local中的zero成员就不用管了
// sin_zero已经初始化为0
local.sin_family = AF_INET; // sin_family
local.sin_addr.s_addr = inet_addr(_IP.c_str()); // sin_addr
local.sin_port = htons(_port); // sin_port
/*用reinterpret_cast强转一下*/
if(bind(_sock, reinterpret_cast<const sockaddr*>(&local), sizeof(local)) < 0)
{
LogMessage(FATAL, _F, _L, "bind fail ::%s", strerror(errno));
exit(3);
}
LogMessage(NORMAL, _F, _L, "InitServer success");
}
// 服务端启动
void StartServer()
{
char buffer[BUFFER_SIZE] = {0};
// 服务器要写成死循环
for(;;)
{
// 1.接收数据
sockaddr_in peer; // sockaddr_in对象,倒数第二个参数
socklen_t len = sizeof(peer); // 输入输出型参数
bzero(&peer, len); // 注意传入前要初始化为0
/*注意强转*/
ssize_t res = recvfrom(_sock, buffer, BUFFER_SIZE, 0, reinterpret_cast<sockaddr*>(&peer), &len);
if(res > 0)
{
// 接收到数据了,最后一位要置为0
buffer[res] = 0;
}
// 2.分析和处理数据,这里只进行简单打印即可
// 不过先将对方主机的IP和端口号找出来
std::string cli_IP = inet_ntoa(peer.sin_addr);
uint16_t cli_port = ntohs(peer.sin_port);
printf("[%s:%d]:%s\n", cli_IP.c_str(), cli_port, buffer);
// 写回数据 /*0表示阻塞写*/
sendto(_sock, buffer, strlen(buffer), 0, reinterpret_cast<sockaddr*>(&peer), len);
}
}
~ServerUDP()
{
if(_sock >= 0)
close(_sock);
}
private:
std::string _IP; // 服务端的IP
uint16_t _port; // 服务端的端口号
int _sock;
};
运行起来后用netstat -aunp(a代表全部,u代表UDP,n代表能用数字显示的就用数字显示,p代表进程)来查看当前本地主机服务器的启动情况和连接到主机上的客户端个数,这个命令后面讲TCP的时候会详谈:
这里服务器已经启动了,只不过没有人访问罢了。服务端后面的0.0.0.0这个IP等会再说,各位先不要乱给IP,没什么用。
到这里一个简易UDP服务器就写好了,下面来写写客户端。
客户端和服务端写起来很相似,稍微有点出入。也是先创建套接字,但是第二步不是我们手动绑定,而是直接向服务器进行请求。
来说说为啥客户端不能手动绑定。
服务端能手动进行bind是因为服务端进程所在的主机能够确定一个唯一的端口号,这个是程序员自己就能确定的事情。
客户端不能进行手动bind。如果能手动bind的话,那么所有的互联网公司都可以手动对客户端进行bind,但此时各个公司之间并不知道对方公司bind的端口号是多少,这样就可能会导致重复,一重复就会导致一个端口号绑定到了多个进程上,那么这样不同公司的服务端在发送数据的时候就会乱,比如说CSDN的服务端数据发到了某个用户的B站的客户端上。如果出现这样的错误那就有点搞笑了。
所以bind的工作不是由程序员来做的,而是让os自动bind,当客户端首次给服务器发数据时os就会自动给客户端进行bind,所以说第二步bind就省略了,直接向服务端请求就行。
那么下面我就来写一个简易的UDP服务器。
详细过程我就不说了,里面所有的代码和服务器的代码一样,只是接收的地方少了一点。
#include "LogMessage.hpp"
#include
#include
#include
#include
#include
#include
#include
// 接收到服务器发过来的数据的缓冲区大小
const int BUFF_SIZE = 128;
// 这里客户端的用法是输入服务器的IP和端口号
void usage(const char* fileName)
{
printf("usage :\n%s IP port\n", fileName);
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
usage(argv[0]);
exit(1);
}
// 获取IP,该IP为服务端的IP
in_addr_t serverIP = inet_addr(argv[1]);
// 获取端口,该端口为服务端进程的端口
in_port_t serverPort = htons(atoi(argv[2]));
// 找到服务端的IP和端口
sockaddr_in sendTo;
socklen_t lenS = sizeof(sendTo);
// 把服务端的IP和端口填好
memset(&sendTo, 0, lenS);
sendTo.sin_family = AF_INET;
sendTo.sin_addr.s_addr = serverIP;
sendTo.sin_port = serverPort;
// 创建套接字,和服务器中的选项都一样
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if(sock < 0)
{
LogMessage(FATAL, _F, _L, "client create socket fail :: %s", strerror(errno));
exit(2);
}
char buffer[BUFF_SIZE] = {0};
// 进行通信
while (1)
{
// 用户输入数据
std::string message;
printf("请输入你要发送的数据 ::");
std::getline(std::cin, message);
// 如果用户输入quit就退出客户端
if(message == "quit") break;
// 发送
sendto(sock, message.c_str(), message.size(), 0, reinterpret_cast<sockaddr*>(&sendTo), lenS);
// 用户接收服务端发回来的数据
/* 我们这里的逻辑已经知道了是唯一一个服务端在发送数据,而且服务端的IP和端口
号已经知道了,所以是不需要再用sockaddr_in接收发送数据方的IP和端口了。*/
/* 但是有些场景下会出现UDP的客户端既作为其他客户端的服务端,又作为一个服务端的
客户端的场景,所以这里的接口还是保留了这个recvfrom,而非为客户端直接新整
一个光接收数据不接受发送方IP和port的接口。*/
/* 不过我们这里还是需要写一个sockaddr_in来作为占位的字段,不能直接传给倒数第二个
参数传nullptr,不然会出现意想不到的问题*/
sockaddr_in get_IP_port;
socklen_t lenG = sizeof(get_IP_port);
ssize_t res = recvfrom(sock, buffer, BUFF_SIZE, 0, reinterpret_cast<sockaddr*>(&get_IP_port), &lenG);
if(res > 0)
{
buffer[res] = 0;
printf("server echo ::%s\n", buffer);
}
}
// 通信结束,关闭套接字所开的文件
close(sock);
return 0;
}
下面来说说两个可执行文件后面跟的IP 127.0.0.1和 前面的0.0.0.0 都是啥。
二者都可以看做是主机本地的IP,但是稍微有点差别。
下面这点对于二者的解释看不懂没关系,留个印象就好。
0.0.0.0:这个IP地址是一个特殊的地址,通常用于指示所有可用的IP地址,或者表示未指定具体的IP地址。在网络编程中,服务器可以监听0.0.0.0,表示监听所有可用的网络接口。
127.0.0.1:这是一个保留的IPv4回环地址,也称为环回地址(环回地址是一类特殊的IP地址, 127.0.0.1 —> 127.255.255.254(去掉0和255) 的范围都是本地环回地址)。它指向本机上的网络接口,用于在同一台机器上进行网络通信测试或访问本地服务。
这里服务端bind成127.0.0.1可以看做是将当前的服务器仅仅绑定在我本机上了,这里的绑定并不会绑定到网络当中,仅仅是对于我这个主机的绑定,此时只有我主机上的进程可以和当前的服务器之间发送数据,不是我这个主机的进程就不能给我这里的服务器发送数据。
127.0.0.1这个地址是一个本地环回地址,我这里执行客户端和服务端都是在我本机上执行的,也就是说我这里的测试只是一个本机上的测试。所以这里测试的时候客户端和服务端发送数据只会在本地主机中发送和接收,也就是说只会在本地协议栈中进行数据流动,不会将数据发送到网络中,如图:
所以此时如果网络中如果有数据想进入到该服务器中是进不去的,有的同学可能会问这个有什么用,其实用处还是有的,这种做法适用于本地服务器的测试,只要在127.0.0.1这个本地环回地址中测试通过了,若再进行联网测试时发现客户端和服务端不能通信时,那么这时候99%的概率是网络出问题了。
那么怎么将服务器连接到网络中呢?
可以让服务器bind 0.0.0.0这个地址,先看看:
此时也能发出去,我这里的server端也就连接到网络中了。不过这里的数据还是不会经过网络,因为客户端和服务端都是在本机中的进程,但是如果说我此时开着我这里写的服务器,各位再用我这里写的客户端可执行文件,连接到我的云服务器所属的IP的话,你们就可以向我这里的服务器发消息,而且效果和这里的一样。但是可惜你在看这篇博客的时候我的这里的服务端已经关掉了,我这里也就不发我的云服务器的IP了。
这里说一下,我用的是云服务器,无法绑定公网IP,非(本地环回地址和0.0.0.0)的IP,也就是一个具体的IP在云服务器上无法绑定。看我再绑定一个数值最大的本地环回地址:
照样可以通信。
对于服务端来讲,不推荐bind一个确定的IP,比如说上面绑定一个127.0.0.1,更推荐绑定任意IP。因为有时候一个服务器可能不止一张网卡,每张网卡可能都配有不同IP,若在服务端绑定了具体的IP,那么服务端只能收到具体IP的消息,但若直接绑定0.0.0.0就是在告诉OS,凡是发给这台主机且是我这个端口号的数据都得发给我。
那么我就可以把服务端的代码改一改:
服务端类:
就改了上面圈红框的地方,其中INADDR_ANY是一个宏,其实就是0.0.0.0:
这里意思就是让服务端绑定的时候如果没有传指定IP,那么就让服务端bind上0.0.0.0 IP地址。这样就能收到网络中的数据了。
创建服务端:
此时就会比上面手动去bind 0.0.0.0方便一点。
即使服务器绑定的是0.0.0.0,本机上的进程也可以通过环回地址向本地的服务器发信息:
因为二者都可以表示本机的地址。
bind 0.0.0.0: 这个选项表示将服务绑定到所有可用的网络接口。也就是说,服务将监听在服务器上的所有网卡上的IP地址,包括本地网络和外部网络。这样,其他计算机可以通过网络连接到这个服务。
bind 127.0.0.1: 这个选项表示将服务绑定到本地回环接口(loopback interface),也就是说,服务只能通过本地访问。回环接口是一个特殊的网络接口,用于在同一台计算机上进行网络通信。通过这个选项,服务无法被其他计算机访问,只能被本地访问。
区别:
使用 bind 0.0.0.0 可以使服务在网络上可见,其他计算机可以通过网络访问该服务。
使用 bind 127.0.0.1 只能使服务在本地可见,其他计算机无法通过网络访问该服务。
我这里可以让客户端发送一些命令行上的命令,比如说什么ls啥的,用exec系列的函数就能实现,把其在服务端的运行结果交回到客户端中。
不过我自己再创建子进程然后执行exec系列的函数优点麻烦,我就直接用一个C中的接口:
这里的popen会创建一个子进程,并在子进程中调用exec系列的函数去执行command命令,然后再将执行的结果写入到一个打开的文件中,并将这个文件的文件指针返回。其中type参数是你想以什么方式打开这个被写入的文件。就和fopen中的选项"r"、"w"差不多。
当你对这个文件操作结束后,可以调用pclose关闭这个文件。
那么我就来改改:
#include "LogMessage.hpp"
#include
#include
#include
#include
#include
#include
#include
const int BUFFER_SIZE = 128;
const int EXEC_SIZE = 512;
class ServerUDP
{
public:
/*这里将port和IP换一下位置,不然创建对象的时候会出问题*/
ServerUDP(uint16_t port = 0, const std::string &IP = "", int sock = -1)
: _IP(IP), _port(port), _sock(sock)
{}
// 服务端初始化
void InitServer()
{
// 创建套接字
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (_sock == -1) // 创建失败就退出
{
LogMessage(FATAL, _F, _L, "creat socket fail ::%s", strerror(errno));
exit(2);
}
sockaddr_in local; // 初始化的时候全部清零,可以用memset,也可以用bzero直接清零
bzero(&local, sizeof(local)); // bzero会将local中的内容清零,这样local中的zero成员就不用管了
// sin_zero已经初始化为0
local.sin_family = AF_INET; // sin_family
local.sin_addr.s_addr = _IP == "" ? INADDR_ANY : inet_addr(_IP.c_str()); // sin_addr
local.sin_port = htons(_port); // sin_port
/*用reinterpret_cast强转一下*/
if(bind(_sock, reinterpret_cast<const sockaddr*>(&local), sizeof(local)) < 0)
{
LogMessage(FATAL, _F, _L, "bind fail ::%s", strerror(errno));
exit(3);
}
LogMessage(NORMAL, _F, _L, "InitServer success");
}
// 服务端启动
void StartServer()
{
char buffer[BUFFER_SIZE] = {0};
// 服务器要写成死循环
for(;;)
{
// 1.接收数据
sockaddr_in peer; // sockaddr_in对象,倒数第二个参数
socklen_t len = sizeof(peer); // 输入输出型参数
bzero(&peer, len); // 注意传入前要初始化为0
/*注意强转*/
ssize_t res = recvfrom(_sock, buffer, BUFFER_SIZE, 0, reinterpret_cast<sockaddr*>(&peer), &len);
char execBuff[EXEC_SIZE] = {0};
std::string execRes;
if(res > 0)
{
// 接收到数据了,最后一位要置为0
buffer[res] = 0;
// 2.分析和处理数据,这里让服务端执行客户端发来的命令
// 不要rm / rmdir命令
if(strcasestr(buffer, "rm") != nullptr || strcasestr(buffer, "rmdir") != nullptr)
{
std::string err_message = "You can't use rm / rmdir";
sendto(_sock, err_message.c_str(), err_message.size(), 0, (struct sockaddr *)&peer, len);
continue;
}
// 执行命令
FILE* pf = popen(buffer, "r");
if(pf == nullptr)
{
printf("popen err\n");
exit(4);
}
// 让所有的结果拼到一块
while(fgets(execBuff, EXEC_SIZE, pf) != nullptr)
{
execRes += execBuff;
}
pclose(pf);
}
std::cout << execRes << std::endl;
// 不过先将对方主机的IP和端口号找出来
std::string cli_IP = inet_ntoa(peer.sin_addr);
uint16_t cli_port = ntohs(peer.sin_port);
// printf("[%s:%d]:%s\n", cli_IP.c_str(), cli_port, buffer);
// 写回数据 /*0表示阻塞写*/
sendto(_sock, execRes.c_str(), execRes.size(), 0, reinterpret_cast<sockaddr*>(&peer), len);
}
}
~ServerUDP()
{
if(_sock >= 0)
close(_sock);
}
private:
std::string _IP; // 服务端的IP
uint16_t _port; // 服务端的端口号
int _sock;
};
相当于一个简易的远端shell。但是这里用不了像top这样的交互命令,不然就卡住了。因为这里代码中popen返回值所指向的文件中只能打印出来固定数据,一变成动的就不行了。
还是基于上面的代码改改。
很简单,说说大致思路:
服务器还是接收数据,但这会得将接收到的数据 + 发送方的 IP + 端口号 都记下来。
一有数据来,就把数据交给所有记录下来的IP+端口号。
这样就是一个简易的群聊。
但是前面的这个客户端在这个场景下有点问题,因为客户端是一个单线程的,假如说此时有两个客户端A和B,按照上面代码的逻辑,AB客户端都是先进行写,当A和B都写了之后,二者的IP和端口就记录在服务器上了,但是,此时A写阻塞,B也写阻塞,当A写了之后,就算服务器将A写的数据发回到AB两个客户端了,B此时是收不到的,因为B还在写处阻塞:
所以可以改改,我们能让客户端一个线程进行接收客户的输入并进行发送。一个线程进行读取服务端发来的数据,这样就不会因为IO问题而接收不到服务端的数据了。
也就是会出现这样的结果(这里IP和port连一块了,但是我写完了才发现的,各位懂我意思就好):
每次只能读一个数据,这样只能发送一个接受一个。
这里我已经把服务端的写好了:
#include "LogMessage.hpp"
#include
#include
#include
#include
#include
#include
#include
#include
#include
const int BUFFER_SIZE = 128;
const int EXEC_SIZE = 512;
class ServerUDP
{
public:
/*这里将port和IP换一下位置,不然创建对象的时候会出问题*/
ServerUDP(uint16_t port = 0, const std::string &IP = "", int sock = -1)
: _IP(IP), _port(port), _sock(sock)
{}
// 服务端初始化
void InitServer()
{
// 创建套接字
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (_sock == -1) // 创建失败就退出
{
LogMessage(FATAL, _F, _L, "creat socket fail ::%s", strerror(errno));
exit(2);
}
sockaddr_in local; // 初始化的时候全部清零,可以用memset,也可以用bzero直接清零
bzero(&local, sizeof(local)); // bzero会将local中的内容清零,这样local中的zero成员就不用管了
// sin_zero已经初始化为0
local.sin_family = AF_INET; // sin_family
local.sin_addr.s_addr = _IP == "" ? INADDR_ANY : inet_addr(_IP.c_str()); // sin_addr
local.sin_port = htons(_port); // sin_port
/*用reinterpret_cast强转一下*/
if(bind(_sock, reinterpret_cast<const sockaddr*>(&local), sizeof(local)) < 0)
{
LogMessage(FATAL, _F, _L, "bind fail ::%s", strerror(errno));
exit(3);
}
LogMessage(NORMAL, _F, _L, "InitServer success");
}
// 服务端启动
void StartServer()
{
char buffer[BUFFER_SIZE] = {0};
// 服务器要写成死循环
for(;;)
{
// 1.接收数据
sockaddr_in peer; // sockaddr_in对象,倒数第二个参数
socklen_t len = sizeof(peer); // 输入输出型参数
bzero(&peer, len); // 注意传入前要初始化为0
/*注意强转*/
ssize_t res = recvfrom(_sock, buffer, BUFFER_SIZE, 0, reinterpret_cast<sockaddr*>(&peer), &len);
if(res > 0)
{
// 接收到数据了,最后一位要置为0
buffer[res] = 0;
// 2.分析和处理数据,这里让服务端执行客户端发来的命令
}
// 不过先将对方主机的IP和端口号找出来
std::string cli_IP = inet_ntoa(peer.sin_addr);
uint16_t cli_port = ntohs(peer.sin_port);
// printf("[%s:%d]:%s\n", cli_IP.c_str(), cli_port, buffer);
/*发送方的IP + port组成的字符串*/
std::string sender;
sender += '[' + cli_IP + std::to_string(cli_port) + ']';
_clients.insert(std::pair<std::string, sockaddr_in>(sender, peer));
sender += '#';
sender += buffer;
std::cout << "sender::" << sender << std::endl;
// 写回数据 /*0表示阻塞写*/
for(auto & kv : _clients)
{
std::cout << kv.first << std::endl;
sendto(_sock, sender.c_str(), sender.size(), 0, reinterpret_cast<sockaddr*>(&kv.second), len);
}
}
}
~ServerUDP()
{
if(_sock >= 0)
close(_sock);
}
private:
std::string _IP; // 服务端的IP
uint16_t _port; // 服务端的端口号
int _sock;
std::unordered_map<std::string, struct sockaddr_in> _clients;
};
然后改改客户端:
#include "LogMessage.hpp"
#include "Thread.hpp"
#include
#include
#include
#include
#include
#include
#include
#include
const int BUFF_SIZE = 512;
// 服务端IP+port
sockaddr_in sendTo;
socklen_t lenS = sizeof(sendTo);
void* SendMes(void* args)
{
std::string name = ((Thread_name_and_Args*)args)->_name;
int sock = *(int*)((Thread_name_and_Args*)args)->_args;
// 发送数据
while(1)
{
// 用户输入数据
std::string message;
std::cerr << "请输入你要发送的数据 ::";
std::getline(std::cin, message);
// 如果用户输入quit就退出客户端
if(message == "quit") break;
// 发送
sendto(sock, message.c_str(), message.size(), 0, reinterpret_cast<sockaddr*>(&sendTo), lenS);
}
exit(10);
}
void* RecvMes(void* args)
{
std::string name = ((Thread_name_and_Args*)args)->_name;
int sock = *(int*)((Thread_name_and_Args*)args)->_args;
// 接收数据
while (1)
{
char buffer[BUFF_SIZE] = {0};
sockaddr_in get_IP_port; // 占位
socklen_t lenG = sizeof(get_IP_port);
ssize_t res = recvfrom(sock, buffer, BUFF_SIZE, 0, reinterpret_cast<sockaddr*>(&get_IP_port), &lenG);
if(res > 0)
{
buffer[res] = 0;
std::cout << buffer << std::endl;
}
}
}
// 这里客户端的用法是输入服务器的IP和端口号
void usage(const char* fileName)
{
printf("usage :\n%s IP port\n", fileName);
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
usage(argv[0]);
exit(1);
}
// 获取IP,该IP为服务端的IP
in_addr_t serverIP = inet_addr(argv[1]);
// 获取端口,该端口为服务端进程的端口
in_port_t serverPort = htons(atoi(argv[2]));
// 找到服务端的IP和端口
// 把服务端的IP和端口填好
memset(&sendTo, 0, lenS);
sendTo.sin_family = AF_INET;
sendTo.sin_addr.s_addr = serverIP;
sendTo.sin_port = serverPort;
// 创建套接字,和服务器中的选项都一样
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if(sock < 0)
{
LogMessage(FATAL, _F, _L, "client create socket fail :: %s", strerror(errno));
exit(2);
}
// 这里用到了我前面线程池时封装的一个线程类,代码在下面
Thread sender("sender", SendMes, &sock);
Thread recver("recver", RecvMes, &sock);
sender.CreateThread();
recver.CreateThread();
sender.JoinThread();
recver.JoinThread();
// 通信结束,关闭套接字所开的文件
close(sock);
return 0;
}
线程封装
#ifndef __THREAD_HPP__
#define __THREAD_HPP__
#include
#include
typedef void*(*pfunc)(void*);
#include
// 封装线程名称和线程回调函数的参数
class Thread_name_and_Args
{
public:
Thread_name_and_Args(const std::string& name, void* args)
: _name(name)
, _args(args)
{}
public:
std::string _name;
void* _args;
};
// 线程接口的封装
class Thread
{
public:
Thread(const std::string& name, pfunc func, void* args)
: _NA(name, args)
, _func(func)
{}
// 创建线程
void CreateThread()
{
pthread_create(&_tid, nullptr, _func, &_NA);
}
// 等待线程
void JoinThread()
{
pthread_join(_tid, nullptr);
}
const std::string& getName()const
{
return _NA._name;
}
~Thread()
{}
private:
pthread_t _tid; // 线程id
Thread_name_and_Args _NA; // 线程名称和回调函数参数
pfunc _func; // 回调函数的指针
};
#endif
到这里就ok了,还是和前面的两个例子一样,只要我服务器没关,屏幕前的你连上我云服务器的IP和这里服务端进程的端口号就能和我这里发消息,就是一个简单的群聊功能。只可惜你看的时候我已经关掉了。
这里服务端还可以优化成生产消费者模型,直接搞一个循环队列,一个线程消息,一个线程回消息,但是我懒得搞了,不过我前面一篇博客讲了这个循环队列,感兴趣的同学可以看看:【Linux】线程详解完结篇——信号量 + 线程池 + 单例模式 + 读写锁
这里带大家见见。
Windows下套接字接口和Linux下非常相似,稍微有点出入。
写的时候就是头尾不一样,剩下的可以说一样的,服务端不用改,我这里直接把Windows下的客户端给出来,我这里是直接在vs2019下面执行的:
#pragma warning(disable:4996) // 这里是为了不让sendto报警告,不然老有安全警告
#include
#include
#include
using namespace std;
#pragma comment(lib,"ws2_32.lib") //固定用法
uint16_t serverport = 8080;
std::string serverip = "43.138.118.133";
int main()
{
// windows 独有的
WSADATA WSAData;
WORD sockVersion = MAKEWORD(2, 2);
if (WSAStartup(sockVersion, &WSAData) != 0)
return 0;
// 从这里开始就基本上和Linux一样
SOCKET clientSocket = socket(AF_INET, SOCK_DGRAM, 0);
if (INVALID_SOCKET == clientSocket)
{
cout << "socket error!";
return 0;
}
sockaddr_in dstAddr;
dstAddr.sin_family = AF_INET;
dstAddr.sin_port = htons(serverport);
dstAddr.sin_addr.S_un.S_addr = inet_addr(serverip.c_str());
char buffer[1024];
while (true)
{
std::string message;
std::cout << "请输入# ";
std::getline(std::cin, message);
sendto(clientSocket, message.c_str(), (int)message.size(), 0, (sockaddr*)&dstAddr, sizeof(dstAddr))
std::cout << "发送失败" << std::endl;
std::cout << "发送成功" << std::endl;
struct sockaddr_in temp;
int len = sizeof(temp);
int s = recvfrom(clientSocket, buffer, sizeof buffer, 0, (sockaddr*)&temp, &len);
if (s > 0)
{
buffer[s] = '\0';
std::cout << "server echo# " << buffer << std::endl;
}
}
// windows 独有
closesocket(clientSocket);
WSACleanup();
return 0;
}
这里写的是单线程的,不像上面Linux客户端那个多线程的,所以也会出现前面Linux单线程的问题,但是问题不大,只要给各位演示出来效果就行。如果你想改成多线程的话可以自己动手试试,很简单,我就不改了。
让Linux上跑服务端,Windows上跑客户端,完全OK:
如果你试了但是Windows下发不过去,且你用的是云服务器的话,出问题的同学可以参考一下这篇文章:【云服务器】关于UDP/TCP跨平台网络通信服务器无响应的情况及解决办法
我用的时候也出问题了,就是看这篇博客改好的。
该讲的都讲了。
到此结束。。。