目录
1. 预备知识
1.1 源IP地址和目的IP地址
1.2 端口号port和套接字socket
1.3 网络通信的本质
1.4 TCP和UDP协议
1.5 网络字节序
2. socket套接字
2.1 socket创建套接字
2.2 bind绑定
2.3 sockaddr结构体
3. UDP网络编程
3.1 server的初始化服务器
3.2 server的数据处理Start
3.3 客户端udp_client.cc
3.4 多线程收发数据
本篇完。
1. 预备知识
1.1 源IP地址和目的IP地址
通过上一篇我们知道,在网络通信中,存在两套地址,一套是IP地址,另一套是MAC地址。
IP地址:标识计算机在网络中的唯一性。
而IP地址又分为源IP地址和目的IP地址:
- 源IP地址:标识网络通信发起方。
- 目的IP地址:标识网络通信的接收方。
在网络通信的报文中,其中报头包含着源IP和目的IP。
1.2 端口号port和套接字socket
如上图所示,报文从用户A的计算机传送到了用户B的计算机,但是网络通信的目的就是将报文从一台计算机传送到另一台计算机吗?
将数据从计算机A传送到计算机B是手段,并不是网络通信的目的。
真正进行通信的是用户A和用户B,也就是计算机A上的某个应用程序和计算机B上的某个应用程序之间在通信。
网络通信的目的就是让两台计算机上的两个进程在进行通信。
IP地址可以标识两台计算机的唯一性,但是每台计算机上会存在大量的进程,如何保证计算机A某个进程发送的数据能让计算机B指定的进程接收到呢?
换句话说,如何标识一台计算机上进程的唯一性呢?
采用端口号port来标识计算机上进程的唯一性。
- 端口号是一个2字节16位的整数。
- 端口号用来标识一个进程,告诉操作系统要把数据交给哪一个进程。
- 一个端口号只能被一个进程占用。
现在我们有用来标识计算机在网络中唯一性的IP地址,又有用来标识进程在计算机中唯一性的端口号port。
- 全网唯一进程 = IP地址(全网主机唯一性) + 端口号port(该主机上进程唯一性)
socket(套接字) = IP地址 + 端口号port。
所以要想两个进程间实现通信,必须各自有各自的套接字。
1.3 网络通信的本质
网络通信实际上是两台计算机或者多台计算机上的进程之间在通信,和我们之前Linux学习的进程间通信的区别在于进程位于不同的计算机上。
网络通信的本质:进程间通信。
- 要实现进程间通信,必须有共享资源,而网络通信中的网络就是共享资源。
- 网络通信其实就是在做IO,我们上网的所有行为就两种:①把数据发出去。 ②把数据读回来。
Linux下一切皆文件,所以网络在系统中也是一个"文件",也有struct结构体,也有文件描述符。
我们知道,每个进程都有一个pid来标识它在当前计算机上的唯一性,为什么网络中还需要一个端口号port来标识进程的唯一性呢?不能用pid吗?
在技术实现上是完全可以用pid的,所以就需要考虑为什么不用pid,用了端口号port?:
- 系统是系统,网络是网络,系统使用pid,网络使用port来标识进程的唯一性,实现了系统与网络的解耦。
- 不是所有进程都提供网络服务或者网络请求的,但是所有的进程都需要pid,只有需要网络的进程才会分配一个port。
- 客户端需要能够直接找到服务器的进程,服务器进程的唯一性不能做任何改变。
比如平时使用的QQ,手机上的QQ都是客户端,打开QQ使用都是在向服务器上的QQ进程发起网络请求,而这个服务器位腾讯公司,服务进程根据用户的网络请求再做出对应的反馈交给用户。
下载了某个应用程序以后,该程序里就绑定了服务端对应进程的IP地址和端口号。
所以使用应用程序的时候,就能精准的和服务端上对应的进程进行网络通信。
服务器的IP地址并不会随意变化,为了保证客户端每次都能找到服务端的进程,服务端的port也不能变化。
如果使用pid来代替端口号的话,服务器每重启一次,服务进程的pid值就会改变,客户端就无法找到服务进程了。
绑定了port的进程PCB会被维护在一个哈希表中,port就是key值,操作系统能够根据key值找到对应的PCB,然后再执行它。
1.4 TCP和UDP协议
这两个协议的具体原理和细节在后面会详细讲解,这里仅需要大概了解一下特性即可。
UDP协议:(User Datagram Protocol 用户数据报协议)。
- 传输层协议。
- 不需要通信双方建立连接,直接发生即可。
- 不可靠传输,可能会发生丢包等问题。
- 面向数据报。
TCP协议:(Transmission Control Protocol 传输控制协议)。
- 传输层协议。
- 需要通信双方建立连接。
- 是一种可靠传输,不会发生丢包等问题。
- 面向字节流。
可靠和不可靠传输并没有相对的好坏,比如可靠传输付出的代价就比较大,具体这些特点是什么意思,后面会讲解到,这里只需要记住以上内容即可。
1.5 网络字节序
计算机分为大端机和小端机,不同的电脑型号就不一样,两台计算机大小端不同,接收到的数据解释出来意义也不同。
规定:网络中的字节序都采用大端。
如果你的计算机是大端机,那么就可以直接向网络中发数据和从网络中接收数据,不用做转换。
如果你的计算机是小端机,那么在向网络中发送数据时,需要先将数据转换成大端,再发送到网络中。从网络中接收下来的数据,需要先转换成小端再使用。
此时就存在两个问题:
- 自己的电脑是大端还是小端?还需要自己去判断一下。
- 如果自己的电脑是小端,需要自己去将数据转换成大端。
这两个问题虽然我们自己能解决,但是比较繁琐,而且很容易出错,所以操作系统提供了相应的接口来进行大小端转换
#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类型的数据转换成当前计算机字节序
- 这些函数名很好记,h表示host,代表着主机,n表示network,代表着网络,s表示unit16_t,l表示uint32_t。
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回。
- 如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
2. socket套接字
2.1 socket创建套接字
man socket:
返回值 int
成功返回一个int类型的值,其实就是一个文件描述符sockfd。
失败返回-1,并且设置错误码errno。
socket系统调用专门用来创建套接字,在创建的时候指定使用哪种通信协议。看看参数:
int domain
这是地址族,用来指定创建的套接字进行的是网络通信还是本地通信。
该参数可以填上图所示中的任何一个,经常使用的是AF_INET表示使用IPv4的网络套接字进行网络通信
int type
这是用来指定socket提供的能力类型,比如是面向字节流还是面向用户数据报。
该参数可以使用上图中的任何一个,其中常用的是画红色框的是面向字节流和面向用户数据报,也就是TCP和UDP。
int protocol
该参数是用来指定具体的协议名的,比如指定TCP或者DUP,但是根据前两个参数就可以确定使用哪个协议了,这个一般设置为0即可。
2.2 bind绑定
man 2 bind:
bind用来将IP地址和端口号port创建的socket套接字绑定,也就是将IP地址和端口号port和系统绑定。
返回值int
成功返回0,失败返回-1,并且设置错误码errno。
int sockfd
使用socket()返回的文件描述符sockfd,用来指定绑定哪个套接字。
const struct sockaddr * addr
struct sockaddr是一个结构体。
socklen_t addrlen
这个参数是表示sockaddr结构体大小的,单位是字节,socklen_t本质是unsigned int类型的32位变量。
其它接口:
// 开始监听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);
这几个接口的是TCP协议才会用到,后面再详细讲解。
2.3 sockaddr结构体
套接字有很多种类型,常见的有三种:
- 网络套接字:用户跨主机之间的通信,也能支持本地通信。
- 原始套接字:可以跨过传输层(TCP/UDP)访问底层的数据。
- 域间套接字:只能在本地通信。
这些套接字的应用场景完全不同,所以不同种类的套接字就对应一套系统调用接口,所以三套就会对应三套不同的接口。
网络套接字:
struct sockaddr_in {short int sin_family; // 地址族,一般为AF_INETunsigned short int sin_port; // 端口号,网络字节序struct in_addr sin_addr; // IP地址unsigned char sin_zero[8]; // 用于填充,使sizeof(sockaddr_in)等于16
};
通过sockaddr_in结构体,将IP地址,端口号,以及网络通信AF_INET通过系统调用bind与系统绑定,从而进行网络通信。等下我们写代码用的就是sockaddr_in结构体,用之前先清零,看个接口,man bzero:
这是一个库函数,需要包含头文件<strings.h>,该函数的作用和memset一样,不同之处在于bzero只能清零,第一个参数是目标地址,第二个参数是要清零的字节数。
在填充sockaddr_in结构体的时候,将地址类型sin_family填充为AF_INET表示网络通信。
在填充端口号sin_port的时候,需要使用htons()函数,将主机字节序转换成网络字节序,然后再进行填充。
域间套接字:
struct sockaddr_un {sa_family_t sun_family; /* AF_UNIX */char sun_path[108]; /* 带有路径的文件名 */
};
sockaddr_un只有域间通信方式AF_UNIX以及域间通信的路径名。
设计者为了方便使用,无论是网络通信还是域间通信,都使用一套接口,通过设置不同参数来解决所有通信场景。
sockaddr_in和sockaddr_un是用于网络通信和域间通信两个不同的通信场景,它们的区别就在于结构体起始处的16位地址类型不同,网络通信使用AF_INET,域间通信使用AF_UNIX。
但由于要使用一套接口,所以此时无论哪种通信,都使用sockaddr结构体。
- 在填充IP地址,端口号,以及地址类型的时候,仍然是对sockaddr_in进行填充。
- 在使用bind系统调用时,将sockaddr_in强转成sockaddr类型,在函数内部它会根据前两个字节自行判断是什么类型的通信,然后再强制转回去。
可以将sockaddr看成是基类,把sockaddr_in和sockaddr_un看出是派生类,此时就构成了多态体系。
3. UDP网络编程
网络通信一定是双方的,一端是服务端(Server)接收数据,另一端是客户端(Client)发送数据。
3.1 server的初始化服务器
我们在服务端server建个server.hpp,客户端就不建头文件了,把以前的日志拷过来,先放一部分代码:
Makefile
.PHONY:all
all:udp_client udp_serverudp_client:udp_client.ccg++ -o $@ $^ -std=c++11 -lpthread
udp_server:udp_server.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f udp_client udp_server
log.hpp
#pragma once#include <iostream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <string>// 日志是有日志级别的
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4const char *gLevelMap[] = {"DEBUG","NORMAL","WARNING","ERROR","FATAL"
};#define LOGFILE "./threadpool.log"// 完整的日志功能,至少: 日志等级 时间 支持用户自定义(日志内容, 文件行,文件名)
void logMessage(int level, const char *format, ...) // 可变参数
{
#ifndef DEBUG_SHOWif(level== DEBUG) {return;}
#endifchar stdBuffer[1024]; // 标准日志部分time_t timestamp = time(nullptr); // 获取时间戳// struct tm *localtime = localtime(×tamp); // 转化麻烦就不写了snprintf(stdBuffer, sizeof(stdBuffer), "[%s] [%ld] ", gLevelMap[level], timestamp);char logBuffer[1024]; // 自定义日志部分va_list args; // 提取可变参数的 -> #include <cstdarg> 了解一下就行va_start(args, format);// vprintf(format, args);vsnprintf(logBuffer, sizeof(logBuffer), format, args);va_end(args); // 相当于ap=nullptrprintf("%s%s\n", stdBuffer, logBuffer);// FILE *fp = fopen(LOGFILE, "a"); // 追加到文件,这里写好了就不演示了// fprintf(fp, "%s%s\n", stdBuffer, logBuffer);// fclose(fp);
}
udp_server.hpp(建议复制到VSCode跟着注释看)
#ifndef _UDP_SERVER_HPP
#define _UDP_SERVER_HPP#include "log.hpp"
#include <iostream>
#include <unordered_map>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h> // 网络四件套
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>#define SIZE 1024class UdpServer
{
public:UdpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), _sock(-1){}bool initServer() // 初始化服务器{// 从这里开始,就是新的系统调用,来完成网络功能// 1. 创建套接字(返回值是文件描述符(套接字))_sock = socket(AF_INET, SOCK_DGRAM, 0); // 域 + 类型 + 0if (_sock < 0) // 创建套接字失败,打印日志并退出{logMessage(FATAL, "%d:%s", errno, strerror(errno));exit(2);}// 2. udp -> bind: 将用户设置的ip和port在内核中和我们当前的进程强关联// "192.168.110.132" -> 点分十进制字符串风格的IP地址 -> 给用户看的// 上面每一个区域取值范围是[0-255]: 1字节 -> 4个区域,理论上,表示一个IP地址,其实4字节就够了// 点分十进制字符串风格的IP地址 <-互相转化-> 4字节struct sockaddr_in local; // -> 四个字段,有一个字段清零了不用管了bzero(&local, sizeof(local)); // 清零结构体local.sin_family = AF_INET; // 协议解锁->AF_INET上面sock的第一个参数// 服务器的IP和端口未来也是要发送给对方主机的 -> 先要将数据发送到网络local.sin_port = htons(_port); // 考虑大小端 -> 主机序列转成网络序列,短整数// 1. 同上,先要将点分十进制字符串风格的IP地址 -> 转成4字节// 2. 4字节主机序列 -> 转成网络序列// 有一套接口,可以一次帮我们做完这两件事情,,让服务器在工作过程中,可以从任意IP中获取数据->inet_addrlocal.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0){ // bind: 将ip+prot和进程强关联,参数:套接字 + 清零的结构体 + 结构体字段的长度logMessage(FATAL, "%d:%s", errno, strerror(errno)); // 小于零绑定失败就打日志和退出exit(3);}logMessage(NORMAL, "init udp server done ... %s", strerror(errno));return true;}void Start(){}~UdpServer(){}protected:uint16_t _port; // 一个服务器,一般必须需要ip地址和port(16位的整数)std::string _ip;int _sock;
};#endif
udp_server.cc
#include "udp_server.hpp"
#include <memory>
#include <cstdlib>static void usage(std::string proc) // usage:使用手册,proc:程序名称
{std::cout << "\nUsage: " << proc << " ip port\n" << std::endl;
}// 运行服务端的方式 ./udp_server ip port // 云服务器的问题 bug
int main(int argc, char *argv[])
{if(argc != 3){usage(argv[0]);exit(1);}std::string ip = argv[1];uint16_t port = atoi(argv[2]);std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));svr->initServer();svr->Start();return 0;
}
udp_client.cc
#include <iostream>int main()
{return 0;
}
此时就能运行起来了。
3.2 server的数据处理Start
在预备工作做好以后,还需启动服务器,服务器进程是一个常驻内存的进程,也就是一个while(1)的死循环,在这个循环中进行网络数据的接收,处理,以及写回数据。
看看几个用到的接口,man recvfrom:
上图所示的系统调用recvfrom()用来接收网络中发过来的数据,也就是从套接字中接收。
- 第一个参数是
sockfd
,是创建套接字时返回的文件描述符fd
。 - 第二个参数
buf
是用来存储从网络中读取下来的数据的缓冲区。 - 第三个参数是
buf
缓冲区的大小。 - 第四个参数
flags
是读取的方式,一般设置为0,即阻塞读取数据。 - 第五个参数
sockaddr* src_addr
是一个输出型参数,同样传参sockaddr_in
结构体,系统会自动对这个结构体进行填充,可以获取数据的来源,包括发送方的地址类型,端口号port以及IP地址。 - 返回值ssize_t,返回读取到的数据个数,单位是字节,如果读取失败则返回-1。
sendto() 函数是向服务器主机发送数据的:
man sendto:
- 第一个参数
sockfd
是创建的套接字的文件描述符。 - 第二个参数
buf
是要发送的数据所在的缓冲区。 - 第三个参数
len
是要发生的数据个数,以字节为单位。 - 第四个参数
flags
是发送方式,一般设置为0,表示阻塞发送。 - 第五个参数
dest_addr
是存放服务器IP地址和端口号port的sockaddr_in
结构体变量,在传参的时候需要强转为struct sockaddr*
。 - 第六个参数,是第五个参数中结构体变量的大小,以字节为单位。
上面udp_server.hpp的Start函数:
void Start(){// 作为一款网络服务器,永远不退出的// 服务器启动-> 常驻进程 -> 永远在内存中存在,除非挂了 -> 小心内存泄漏// 目前类似echo server: client给我们发送消息,我们原封不动返回char buffer[SIZE];while(true){// 注意:peer,纯输出型参数struct sockaddr_in peer;bzero(&peer, sizeof(peer));// 输入: peer 缓冲区大小// 输出: 实际读到的peersocklen_t len = sizeof(peer);// start. 读取数据ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);if (s > 0) // 读取数据成功{buffer[s] = 0; // 先不考虑协议问题,目前数据当做字符串// 1. 输出发送的数据信息// 2. 输出是谁发送的信息uint16_t cli_port = ntohs(peer.sin_port); // 从网络中来的 -> 网络序列转成主机序列std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节的网络序列的IP->本主机的字符串风格的IP,方便显示printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);}// 分析和处理数据,TODO// end. 写回数据,类似recvfrom,后两个参数是把数据写给谁sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);}}
编译运行:
使用指令netstat -nuap可以查看当前服务器上的网络进程,就看见有个17602的服务器运行了。
至此服务端的工作就做完了,只要客户端发送数据,服务端就可以收到。
3.3 客户端udp_client.cc
这里在客户端就不写头文件封装了,根据上面基础,直接放代码:udp_client.cc:
#include <iostream>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h> // 网络四件套
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>static void usage(std::string proc) // usage:使用手册,proc:程序名称
{std::cout << "\nUsage: " << proc << " serverIp serverPort\n" << std::endl;
}// ./udp_client 127.0.0.1 7070
int main(int argc, char *argv[])
{if (argc != 3){usage(argv[0]);exit(1);}int sock = socket(AF_INET, SOCK_DGRAM, 0); // 域 + 类型 + 0if (sock < 0){std::cerr << "socket error" << std::endl;exit(2);}// client要不要bind?要,但是一般client不会显示的bind,程序员不会自己bind// 因为client是一个客户端 -> 普通人下载安装启动使用的-> 如果程序员自己bind了->// client 一定bind了一个固定的ip和port,万一,其他的客户端提前占用了这个port呢?// client一般不需要显示的bind指定port,而是让OS自动随机选择(什么时候做的呢?)std::string message;struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET; // 协议解锁->AF_INET上面sock的第一个参数server.sin_addr.s_addr = inet_addr(argv[1]); // 1.先要将点分十进制字符串风格的IP地址 -> 转成4字节// 2. 4字节主机序列 -> 转成网络序列server.sin_port = htons(atoi(argv[2])); // 考虑大小端 -> 主机序列转成网络序列,短整数char buffer[1024];while (true){std::cerr << "请输入你的信息# "; // 标准错误 2打印std::getline(std::cin, message);// 下面向服务器发送消息,当client首次发送消息的时候,OS会自动给client bind他的ip和portsendto(sock, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));if (message == "quit")break;struct sockaddr_in temp;socklen_t len = sizeof(temp);ssize_t s = recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr*)&temp, &len);if (s > 0){buffer[s] = 0;std::cout << "server echo# " << buffer << std::endl;}}close(sock);return 0;
}
运行客户端程序,发送数据,可以看到,客户端新收到的数据中,端口号变了,这是因为客户端的端口号是由操作系统分配的,并不是自己指定的,所以每次运行时端口号都不一样。
(此时的ip地址已经不能乱传了,上面传的是127.0.0.1是本地环回(client和server发送数据只在本地协议栈中进行数据流动,不会把我们的数据发送到网络中本地网络服务器的测试),常常在server中一般不自己传ip,而是设置成0,可以自己改一改,或者直接传INADDR_ANY即0.0.0.0(可以接收任意ip发来的数据),你把udp_client发给其他人,再链接阿里云的ip,就能收到其他人发的信息了,这里就不演示了)
还可以改一下udp_server.hpp的Statr,改成传命令的版本:
void Start(){// 作为一款网络服务器,永远不退出的// 服务器启动-> 常驻进程 -> 永远在内存中存在,除非挂了 -> 小心内存泄漏// 目前类似echo server: client给我们发送消息,我们原封不动返回char buffer[SIZE];while(true){// 注意:peer,纯输出型参数struct sockaddr_in peer;bzero(&peer, sizeof(peer));// 输入: peer 缓冲区大小// 输出: 实际读到的peersocklen_t len = sizeof(peer);char result[256];std::string cmd_echo; // 读取的是指令的话// start. 读取数据ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);if (s > 0) // 读取数据成功{buffer[s] = 0; // 先不考虑协议问题,目前数据当做字符串// 1. 输出发送的数据信息// 2. 输出是谁发送的信息// uint16_t cli_port = ntohs(peer.sin_port); // 从网络中来的 -> 网络序列转成主机序列// std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节的网络序列的IP->本主机的字符串风格的IP,方便显示// printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);if(strcasestr(buffer, "rm") != nullptr || strcasestr(buffer, "rmdir") != nullptr){std::string err_message = "被禁止的指令";std::cout << err_message << buffer << std::endl;sendto(_sock, err_message.c_str(), err_message.size(), 0, (struct sockaddr *)&peer, len);continue;}FILE *fp = popen(buffer, "r");if (nullptr == fp){logMessage(ERROR, "popen: %d:%s", errno, strerror(errno));continue;}while (fgets(result, sizeof(result), fp) != nullptr){cmd_echo += result;}fclose(fp);}// 分析和处理数据,TODO// end. 写回数据,类似recvfrom,后两个参数是把数据写给谁// sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);sendto(_sock, cmd_echo.c_str(), cmd_echo.size(), 0, (struct sockaddr*)&peer, len);}}
到这也和上面做点处理也能让其他人操作你的机器了。
3.4 多线程收发数据
在上面的基础上对client.cc改成多线程的,把以前写的thread.hpp拷过来:
#pragma once
#include <iostream>
#include <string>
#include <cstdio>// typedef std::function<void* (void*)> fun_t;
typedef void *(*fun_t)(void *); // 定义函数指针->返回值是void*,函数名是fun_t,参数是void*->直接用fun_tclass ThreadData // 线程数据
{
public:void *_args; // 真实参数std::string _name; // 名字
};class Thread // 封装的线程
{
public:Thread(int num, fun_t callback, void *args) : _func(callback) // 回调函数{char nameBuffer[64];snprintf(nameBuffer, sizeof(nameBuffer), "Thread-%d", num); // 格式化到nameBuffer_name = nameBuffer;_tdata._args = args; // 线程构造时把参数和名字带给线程数据_tdata._name = _name;}void start() // 启动线程{pthread_create(&_tid, nullptr, _func, (void*)&_tdata); // 传入线程数据}void join() // join自己{pthread_join(_tid, nullptr);}std::string name() // 返回线程名{return _name;}~Thread() // 析构什么也不做{}protected:std::string _name; // 线程名字pthread_t _tid; // 线程tidfun_t _func; // 线程要执行的函数ThreadData _tdata; // 线程数据
};
client.cc
#include <iostream>
#include <cstring>
#include <string>
#include <unistd.h>
#include <memory>
#include <sys/types.h> // 网络四件套
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "thread.hpp"uint16_t serverport = 0;
std::string serverip;static void usage(std::string proc) // usage:使用手册,proc:程序名称
{std::cout << "\nUsage: " << proc << " serverIp serverPort\n" << std::endl;
}// 下面的两个接口,一个线程调用一个
// 无论是多线程读还是写,用的sock都是一个,sock代表就是文件,UDP是全双工的-> 可以同时进行收发而不受干扰
static void *udpSend(void *args) // 发送数据
{int sock = *(int *)((ThreadData *)args)->_args;std::string name = ((ThreadData *)args)->_name;std::string message;struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET; // 协议解锁->AF_INET上面sock的第一个参数server.sin_port = htons(serverport); // serverport和serverip是全局的server.sin_addr.s_addr = inet_addr(serverip.c_str());while (true){std::cerr << "请输入你的信息# "; //标准错误 2打印std::getline(std::cin, message);if (message == "quit")break;// 当client首次发送消息给服务器的时候,OS会自动给client bind他的IP和PORTsendto(sock, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof server);}return nullptr;
}static void *udpRecv(void *args) // 接收数据
{int sock = *(int *)((ThreadData *)args)->_args;std::string name = ((ThreadData *)args)->_name;char buffer[1024];while (true){memset(buffer, 0, sizeof(buffer));struct sockaddr_in temp;socklen_t len = sizeof(temp);ssize_t s = recvfrom(sock, buffer, sizeof buffer, 0, (struct sockaddr *)&temp, &len);if (s > 0){buffer[s] = 0;std::cout << buffer << std::endl;}}
}// ./udp_client 127.0.0.1 7070
int main(int argc, char *argv[])
{if (argc != 3){usage(argv[0]);exit(1);}int sock = socket(AF_INET, SOCK_DGRAM, 0); // 域 + 类型 + 0if (sock < 0){std::cerr << "socket error" << std::endl;exit(2);}// // client要不要bind?要,但是一般client不会显示的bind,程序员不会自己bind// // 因为client是一个客户端 -> 普通人下载安装启动使用的-> 如果程序员自己bind了->// // client 一定bind了一个固定的ip和port,万一,其他的客户端提前占用了这个port呢?// // client一般不需要显示的bind指定port,而是让OS自动随机选择(什么时候做的呢?)// std::string message;// struct sockaddr_in server;// memset(&server, 0, sizeof(server));// server.sin_family = AF_INET; // 协议解锁->AF_INET上面sock的第一个参数// server.sin_addr.s_addr = inet_addr(argv[1]); // // 1.先要将点分十进制字符串风格的IP地址 -> 转成4字节// // 2. 4字节主机序列 -> 转成网络序列// server.sin_port = htons(atoi(argv[2])); // 考虑大小端 -> 主机序列转成网络序列,短整数// char buffer[1024];// while (true)// {// std::cerr << "请输入你的信息# "; // 标准错误 2打印// std::getline(std::cin, message);// // 下面向服务器发送消息,当client首次发送消息的时候,OS会自动给client bind他的ip和port// sendto(sock, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));// if (message == "quit")// break;// // 下面是接收消息// struct sockaddr_in temp;// socklen_t len = sizeof(temp);// ssize_t s = recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr*)&temp, &len);// if (s > 0)// {// buffer[s] = 0;// std::cout << "server echo# " << buffer << std::endl;// }// }// 一个接收的线程,一个读取的线程serverport = atoi(argv[2]);serverip = argv[1];std::unique_ptr<Thread> sender(new Thread(1, udpSend, (void *)&sock));std::unique_ptr<Thread> recver(new Thread(2, udpRecv, (void *)&sock));sender->start();recver->start();sender->join();recver->join();close(sock);return 0;
}
改一下udp_server.hpp的Start,这里直接放udp_server.hpp了。
#ifndef _UDP_SERVER_HPP
#define _UDP_SERVER_HPP#include "log.hpp"
#include <iostream>
#include <unordered_map>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h> // 网络四件套
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <queue>#define SIZE 1024class UdpServer
{
public:UdpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), _sock(-1){}bool initServer() // 初始化服务器{// 从这里开始,就是新的系统调用,来完成网络功能// 一. 创建套接字(返回值是文件描述符(套接字))_sock = socket(AF_INET, SOCK_DGRAM, 0); // 域 + 类型 + 0if (_sock < 0) // 创建套接字失败,打印日志并退出{logMessage(FATAL, "%d:%s", errno, strerror(errno));exit(2);}// 二. udp -> bind: 将用户设置的ip和port在内核中和我们当前的进程强关联// "192.168.110.132" -> 点分十进制字符串风格的IP地址 -> 给用户看的// 上面每一个区域取值范围是[0-255]: 1字节 -> 4个区域,理论上,表示一个IP地址,其实4字节就够了// 点分十进制字符串风格的IP地址 <-互相转化-> 4字节struct sockaddr_in local; // -> bind的第二个参数,四个字段,有一个字段清零了不用管了bzero(&local, sizeof(local)); // 清零结构体local.sin_family = AF_INET; // 协议解锁->AF_INET上面sock的第一个参数// 服务器的IP和端口未来也是要发送给对方主机的 -> 先要将数据发送到网络local.sin_port = htons(_port); // 考虑大小端 -> 主机序列转成网络序列,短整数// 1. 同上,先要将点分十进制字符串风格的IP地址 -> 转成4字节// 2. 4字节主机序列 -> 转成网络序列// 有一套接口inet_addr,可以一次帮我们做完这两件事情,让服务器在工作过程中,可以从任意IP中获取数据->任意IP:INADDR_ANYlocal.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0){ // bind: 将ip+prot和进程强关联,参数:套接字 + 清零的结构体 + 结构体字段的长度logMessage(FATAL, "%d:%s", errno, strerror(errno)); // 小于零绑定失败就打日志和退出exit(3);}logMessage(NORMAL, "init udp server done ... %s", strerror(errno));return true;}void Start(){// 作为一款网络服务器,永远不退出的// 服务器启动-> 常驻进程 -> 永远在内存中存在,除非挂了 -> 小心内存泄漏// 目前类似echo server: client给我们发送消息,我们原封不动返回char buffer[SIZE];while(true){// 注意:peer,纯输出型参数struct sockaddr_in peer;bzero(&peer, sizeof(peer));// 输入: peer 缓冲区大小// 输出: 实际读到的peersocklen_t len = sizeof(peer);char result[256];char key[64]; // key存ip和portstd::string cmd_echo; // 读取的是指令的话// start. 读取数据ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);if (s > 0) // 读取数据成功{buffer[s] = 0; // 先不考虑协议问题,目前数据当做字符串// 1. 输出发送的数据信息// 2. 输出是谁发送的信息uint16_t cli_port = ntohs(peer.sin_port); // 从网络中来的 -> 网络序列转成主机序列std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节的网络序列的IP->本主机的字符串风格的IP,方便显示// printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);snprintf(key, sizeof(key), "%s-%u", cli_ip.c_str(), cli_port); // 127.0.0.1-8080logMessage(NORMAL, "key: %s", key);auto it = _users.find(key);if (it == _users.end()){logMessage(NORMAL, "add new user : %s", key);_users.insert({key, peer});}// // 下面是指令版本// if(strcasestr(buffer, "rm") != nullptr || strcasestr(buffer, "rmdir") != nullptr)// {// std::string err_message = "被禁止的指令";// std::cout << err_message << buffer << std::endl;// sendto(_sock, err_message.c_str(), err_message.size(), 0, (struct sockaddr *)&peer, len);// continue;// }// FILE *fp = popen(buffer, "r");// if (nullptr == fp)// {// logMessage(ERROR, "popen: %d:%s", errno, strerror(errno));// continue;// }// while (fgets(result, sizeof(result), fp) != nullptr)// {// cmd_echo += result;// }// fclose(fp);}// 分析和处理数据// end. 写回数据,类似recvfrom,后两个参数是把数据写给谁// sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);// sendto(_sock, cmd_echo.c_str(), cmd_echo.size(), 0, (struct sockaddr*)&peer, len);for (auto &iter : _users){std::string sendMessage = key;sendMessage += "# ";sendMessage += buffer; // 此时消息就类似:127.0.0.1-1234# 你好logMessage(NORMAL, "push message to %s", iter.first.c_str());sendto(_sock, sendMessage.c_str(), sendMessage.size(), 0, (struct sockaddr*)&(iter.second), sizeof(iter.second));}}}~UdpServer(){if (_sock >= 0)close(_sock);}protected:uint16_t _port; // 一个服务器,一般必须需要ip地址和port(16位的整数)std::string _ip;int _sock;std::unordered_map<std::string, struct sockaddr_in> _users; // first存ip和prot,second存消息std::queue<std::string> messageQueue; // 用户层与网络的解耦->多线程->生产者消费者模型(这里就不改了)
};#endif
顺便把udp_server.cc放出来:
#include "udp_server.hpp"
#include <memory>
#include <cstdlib>static void usage(std::string proc) // usage:使用手册,proc:程序名称
{std::cout << "\nUsage: " << proc << " ip port\n" << std::endl;
}// 运行服务端的方式 ./udp_server ip port // 云服务器的问题 bug
int main(int argc, char *argv[])
{if(argc != 3){usage(argv[0]);exit(1);}std::string ip = argv[1];uint16_t port = atoi(argv[2]);std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));svr->initServer();svr->Start();return 0;
}
编译运行:(左边是服务端,中上是A用户发的消息,右上是A用户收到的消息,中下是B用户发的消息,右下是B用户收到的消息)
如果你把client发给其他人,就能实现类似群聊的效果了。
本篇完。
加上代码两万多字了,不过放的代码有重复的,可以自己试着敲一下。
下一篇:网络和Linux网络_3(套接字编程)TCP网络通信(多个版本)。