目录
0.上篇文章
1.V1 版本 - echo server
1.1认识接口
1.创建socket
它允许不同计算机或同一计算机上的不同进程之间进行数据交换。Socket编程基于客户端-服务器模型,其中服务器监听来自客户端的连接请求,并在连接建立后交换数据。
- socket():创建一个新的socket。
- 原型:
int socket(int domain, int type, int protocol);
- 参数:
domain
:指定socket使用的协议族,如AF_INET(IPv4)或AF_INET6(IPv6)。type
:指定socket的类型,如SOCK_STREAM(TCP)或SOCK_DGRAM(UDP)。- 此处我们先对 TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识;
后面我们再详细讨论 TCP 的一些细节问题.
• 传输层协议
• 有连接
• 可靠传输(可靠性高)
• 面向字节流- 此处我们也是对 UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识; 后
面再详细讨论.
• 传输层协议
• 无连接
• 不可靠传输(但操作简单)
• 面向数据报protocol
:通常设置为0,表示选择给定domain和type的默认协议。AF_UNIX表示本地协议族,是进行本地通信的;AF_INET表示网络协议族,是进行网络通信的。
返回值概述
- 成功时:
socket()
函数成功执行时,会返回一个非负整数,这个整数被称为socket的文件描述符。文件描述符是一个非负整数,用于标识打开的文件、管道、socket等I/O资源。在后续的网络通信中,会使用这个文件描述符来引用和操作这个socket。- 失败时:如果
socket()
函数执行失败,它会返回-1,并设置全局变量errno
以指示错误的具体原因。errno
是一个由系统定义的全局变量,用于在函数调用失败时提供错误代码。2.bind
络服务的 bind(绑定):在网络编程中,
bind
是一个系统调用,用于将套接字(socket)与一个特定的 IP 地址和端口号关联起来。这是服务器程序启动时的常见步骤,它告诉操作系统这个套接字将监听来自特定 IP 地址和端口号的连接请求。参数说明:
1.
sockfd
- 类型:
int
- 描述:这是由
socket
函数返回的套接字文件描述符(socket file descriptor)。它代表了创建的套接字,是后续网络通信操作的基础。2.
addr
- 类型:
const struct sockaddr *
- 描述:这是一个指向
sockaddr
结构体的指针,但实际上更常用的是其派生结构体如sockaddr_in
(用于IPv4)或sockaddr_in6
(用于IPv6)。这个结构体包含了要绑定的IP地址和端口号信息。
- 对于
sockaddr_in
结构体,它至少包含以下成员:
sin_family
:地址族,对于IPv4,此值应为AF_INET
。sin_port
:端口号,在网络字节序中。sin_addr
:IP地址,也是以网络字节序表示。- 需要注意的是,由于
bind
函数要求的是sockaddr
类型的指针,因此在使用sockaddr_in
或sockaddr_in6
时,需要将其地址强制转换为sockaddr*
类型。3.
addrlen
- 类型:
socklen_t
- 描述:这个参数指定了
addr
参数所指向的地址结构体的长度(以字节为单位)。由于sockaddr
是一个通用结构体,其大小可能因不同的地址族而异(尽管sockaddr
本身的大小是固定的,但使用其派生结构体时,实际长度会更大),因此需要通过这个参数来明确告诉bind
函数应该如何处理addr
参数。3.recvfrom收消息
这个函数允许程序从指定的套接字(socket)接收数据,并且能够获取发送数据的源地址信息。
参数说明
- sockfd:要接收数据的套接字文件描述符。
- buf:指向数据缓冲区的指针,用于存储接收到的数据。
- len:缓冲区
buf
的长度,即最大可接收的数据量。- flags:调用标志,通常设置为 0,但在某些情况下可以指定特殊的行为(如 MSG_DONTWAIT)。
- src_addr:
src_addr
是一个指向sockaddr
结构(或其特定类型,如sockaddr_in
用于 IPv4)的指针。当recvfrom
函数被调用时,如果成功接收到数据,发送方的地址信息(包括 IP 地址和端口号)将被填充到这个结构中。这样,接收方就可以知道数据是从哪里来的,并可能基于此信息进行后续操作,比如回复发送方。- addrlen:
addrlen
是一个指向socklen_t
类型变量的指针。在调用recvfrom
之前,这个变量应该被设置为src_addr
指向的缓冲区的大小(即sockaddr_in
或其他sockaddr
派生类型的大小)。recvfrom
函数在成功执行后,会通过这个指针返回实际存储在src_addr
中的地址结构的大小。这允许调用者知道有多少字节的src_addr
被实际使用,尽管在大多数情况下,对于特定的sockaddr
类型(如sockaddr_in
),这个大小是固定的。返回值
- 成功时,
recvfrom
返回接收到的字节数。- 如果连接被对方正常关闭,返回 0。
- 如果发生错误,返回 -1,并设置
errno
以指示错误的原因。4.sento发消息
用于在网络编程中发送数据,特别是与UDP(用户数据报协议)通信时。
参数说明:
- sockfd:套接字文件描述符,表示要发送数据的套接字。
- buf:指向要发送数据的缓冲区的指针。
- len:要发送数据的字节数。
- flags:发送数据的标志位,通常设置为0。
- dest_addr:指向目的地址结构体的指针,包括目的IP地址和端口号等信息。
- addrlen:目的地址结构体的长度。
特点:
- 无连接发送:
sendto
支持无连接的发送方式,发送方不需要与接收方建立连接即可发送数据。这使得UDP协议在实时通信、视频流传输等场景中具有优势。- 灵活性:
sendto
允许发送方指定目标地址和端口号,这使得它可以向不同的接收方发送数据,而不需要为每个接收方建立单独的连接。- 可靠性:虽然UDP协议本身不提供可靠性保证(如TCP的确认和重传机制),但
sendto
函数可以通过分片发送数据的方式,在网络环境不稳定或拥塞的情况下,尽量保证数据的完整性和准确性。返回值:
- 成功时:
sendto
函数成功执行时,其返回值是成功发送的数据的字节数。这意味着如果应用程序请求发送的数据量为N字节,并且这N字节全部被成功发送,那么sendto
将返回N。- 失败时:如果发送操作失败,
sendto
函数将返回-1。此时,可以通过检查全局变量errno
来获取具体的错误原因。
1.2实现 V1 版本 - echo server(细节)
该服务器就是创建好对应的套接字,和网络信息进行绑定,服务器不断地接收和发送数据。因为我们对应的服务器和客户端都在一台机器上,
127.0.0.1
是一个特殊的IP地址,被称为回环地址,当你在计算机上访问127.0.0.1
时,实际上是在与本机上的某个服务或应用程序进行通信,而不是通过网络与其他计算机通信。当然如果你是使用的云服务器,你当然可以使用你服务器的ip。但云服务器上,服务端不能直接(也强烈不建议)bind自己的公网ip!因为该ip是虚拟出来的,该机器上也没有这个ip。下面这个才是你机器上的ip(内网ip)
但是bind内网ip的话,就不会往服务器上收消息了,外部无法直接访问内网。在云服务器上,ip地址一般设置为0,那如何理解呢?这可以让服务器bind任意ip。
一般现在的服务器只有一张网卡,绑定了一个公网ip。但如果你的服务器上有很多的ip地址,一个ip1,一个ip2,(如内网ip,回环ip)而我们上层的端口号为:8888。如果你的服务器bind ip1和8888,未来你的服务器收到各种报文都是发给8888的。有ip1的也有ip2的,但服务器只会接收ip1的报文,但如果服务器bind的ip为0,就意味着不管发送的ip是谁,只要是发给端口号为:8888的,服务器都会接收!!!(在做本地测试的时候,你也可以使用公网ip向你的服务器发消息,能不能成功,就看你的云服务器是怎么设定的了)
因此,我们在server端就不需要ip了,bind端口号为8888就行了
1.3添加的日志系统(代码)
LockGuard.hpp
#pragma once #include <pthread.h> class LockGuard { public: LockGuard(pthread_mutex_t *mutex):_mutex(mutex) { pthread_mutex_lock(_mutex); } ~LockGuard() { pthread_mutex_unlock(_mutex); } private: pthread_mutex_t *_mutex; };
Log.hpp
#pragma once #include <iostream> #include <sys/types.h> #include <unistd.h> #include <ctime> #include <cstdarg> #include <fstream> #include <cstring> #include <pthread.h> #include "LockGuard.hpp" namespace log_ns { enum { DEBUG = 1, INFO, WARNING, ERROR, FATAL }; std::string LevelToString(int level) { switch (level) { case DEBUG: return "DEBUG"; case INFO: return "INFO"; case WARNING: return "WARNING"; case ERROR: return "ERROR"; case FATAL: return "FATAL"; default: return "UNKNOWN"; } } std::string GetCurrTime() { time_t now = time(nullptr); struct tm *curr_time = localtime(&now); char buffer[128]; snprintf(buffer, sizeof(buffer), "%d-%02d-%02d %02d:%02d:%02d", curr_time->tm_year + 1900, curr_time->tm_mon + 1, curr_time->tm_mday, curr_time->tm_hour, curr_time->tm_min, curr_time->tm_sec); return buffer; } class logmessage { public: std::string _level; pid_t _id; std::string _filename; int _filenumber; std::string _curr_time; std::string _message_info; }; #define SCREEN_TYPE 1 #define FILE_TYPE 2 const std::string glogfile = "./log.txt"; pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER; // log.logMessage("", 12, INFO, "this is a %d message ,%f, %s hellwrodl", x, , , ); class Log { public: Log(const std::string &logfile = glogfile) : _logfile(logfile), _type(SCREEN_TYPE) { } void Enable(int type) { _type = type; } void FlushLogToScreen(const logmessage &lg) { printf("[%s][%d][%s][%d][%s] %s", lg._level.c_str(), lg._id, lg._filename.c_str(), lg._filenumber, lg._curr_time.c_str(), lg._message_info.c_str()); } void FlushLogToFile(const logmessage &lg) { std::ofstream out(_logfile, std::ios::app); if (!out.is_open()) return; char logtxt[2048]; snprintf(logtxt, sizeof(logtxt), "[%s][%d][%s][%d][%s] %s", lg._level.c_str(), lg._id, lg._filename.c_str(), lg._filenumber, lg._curr_time.c_str(), lg._message_info.c_str()); out.write(logtxt, strlen(logtxt)); out.close(); } void FlushLog(const logmessage &lg) { // 加过滤逻辑 --- TODO LockGuard lockguard(&glock); switch (_type) { case SCREEN_TYPE: FlushLogToScreen(lg); break; case FILE_TYPE: FlushLogToFile(lg); break; } } void logMessage(std::string filename, int filenumber, int level, const char *format, ...) { logmessage lg; lg._level = LevelToString(level); lg._id = getpid(); lg._filename = filename; lg._filenumber = filenumber; lg._curr_time = GetCurrTime(); va_list ap; va_start(ap, format); char log_info[1024]; vsnprintf(log_info, sizeof(log_info), format, ap); va_end(ap); lg._message_info = log_info; // 打印出来日志 FlushLog(lg); } ~Log() { } private: int _type; std::string _logfile; }; Log lg; #define LOG(Level, Format, ...) \ do \ { \ lg.logMessage(__FILE__, __LINE__, Level, Format, ##__VA_ARGS__); \ } while (0) #define EnableScreen() \ do \ { \ lg.Enable(SCREEN_TYPE); \ } while (0) #define EnableFILE() \ do \ { \ lg.Enable(FILE_TYPE); \ } while (0) };
1.4 解析网络地址
此功能实现较为简单,请看注释:
#pragma once #include <iostream> #include <string> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> //网络地址 class InetAddr { private: void ToHost(const struct sockaddr_in &addr)//网络序列转主机序列 { _port = ntohs(addr.sin_port);//这个端口号是随机bind的 _ip = inet_ntoa(addr.sin_addr);//四字节地址转字符串 } public: InetAddr(const struct sockaddr_in &addr):_addr(addr) { ToHost(addr); } std::string Ip() { return _ip; } uint16_t Port() { return _port; } ~InetAddr() { } private: std::string _ip; uint16_t _port; struct sockaddr_in _addr;//保存一下网络序列 };
1.5 禁止拷贝逻辑(基类)
禁止对象之间的拷贝,而非智能指针的,因为有很多网络相关的,这样可以避免出错:
nocopy.hpp
#pragma once //禁止拷贝 class nocopy { public: nocopy(){} ~nocopy(){} nocopy(const nocopy&) = delete; const nocopy& operator=(const nocopy&) = delete; };
1.6 服务端逻辑 (代码)
UdpServer.hpp:
以下是
UdpServer
类的主要功能和组成部分:
构造函数:接收一个可选的本地端口号参数,默认为
8888
。构造函数初始化了文件描述符_sockfd
为-1
(表示无效的文件描述符),设置了本地端口号_localport
,并将服务器运行状态_isrunning
设置为false
。
InitServer
方法:用于初始化服务器。这个方法首先创建一个UDP套接字,如果创建失败,则记录一条致命错误日志并退出程序。然后,它将套接字绑定到一个本地地址和端口上。如果绑定失败,同样记录一条致命错误日志并退出程序。
Start
方法:用于启动服务器。这个方法将_isrunning
设置为true
,然后进入一个循环,不断接收来自客户端的数据,处理数据,并向客户端发送响应。如果接收数据失败,它会打印一条错误消息。析构函数:在
UdpServer
类的实例被销毁时调用。如果文件描述符_sockfd
是有效的(即大于-1
),则关闭该套接字。整个类使用了日志记录功能来记录关键事件,如套接字创建和绑定成功或失败。这有助于调试和监控服务器的运行状态。
因为实现比较简单,结合逻辑和注释,理解起来较为容易:
#pragma once #include <iostream> #include <unistd.h> #include <string> #include <cstring> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include "nocopy.hpp" #include "Log.hpp" #include "InetAddr.hpp" using namespace log_ns; static const int gsockfd = -1; static const uint16_t glocalport = 8888; enum { SOCKET_ERROR = 1, BIND_ERROR//绑定失败 }; // UdpServer user("192.1.1.1", 8899); // 一般服务器主要是用来进行网络数据读取和写入的。IO的 // 服务器IO逻辑 和 业务逻辑 解耦 class UdpServer : public nocopy//继承,禁止拷贝构造,禁止赋值, { //禁止对象之间的拷贝,而非智能指针的 public: //因为有很多网络相关的,这样可以避免出错 UdpServer(uint16_t localport = glocalport) : _sockfd(gsockfd),//初始化为-1 _localport(localport), _isrunning(false) { } void InitServer()//初始化服务器 { // 1. 创建socket文件 //AF_INET表示的是网络套接(IPv4),SOCK_DGRAM表示为UDP协议 _sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);//系统调用,创建套接字 if (_sockfd < 0) { LOG(FATAL, "socket error\n");//提示创建失败,致命错误,日志 exit(SOCKET_ERROR); } // 把此次套接字所对应的文件fd也写进日志 LOG(DEBUG, "socket create success, _sockfd: %d\n", _sockfd); // 把此次套接字所对应的文件fd也写进日志 // 2. bind struct sockaddr_in local;//用于表示Internet地址,特别是IPv4地址和端口号。 memset(&local, 0, sizeof(local));//先清空再使用 local.sin_family = AF_INET;//表示你的套接字将使用IPv4协议。 local.sin_port = htons(_localport);//端口号,htons主机序列转为网络序列 // local.sin_addr.s_addr = inet_addr(_localip.c_str()); // 1. 需要4字节IP 2. 需要网络序列的IP -- 暂时 local.sin_addr.s_addr = INADDR_ANY; // 服务器端,进行任意IP地址绑定 int n = ::bind(_sockfd, (struct sockaddr *)&local, sizeof(local));//填充进入bind if (n < 0) { LOG(FATAL, "bind error\n"); exit(BIND_ERROR); } LOG(DEBUG, "socket bind success\n"); } void Start()//启动服务器 { _isrunning = true; char inbuffer[1024]; while (_isrunning) { struct sockaddr_in peer; socklen_t len = sizeof(peer); //读取数据 // (struct sockaddr *)&peer,接收客户端的套接字(知道客户端是谁) ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len); if (n > 0) { InetAddr addr(peer); inbuffer[n] = 0; //客户端发的消息 std::cout << "[" << addr.Ip() << ":" << addr.Port() << "]# " << inbuffer << std::endl; std::string echo_string = "[udp_server echo] #"; echo_string += inbuffer;//返回给客户端的内容 sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len); } else { std::cout << "recvfrom , error" << std::endl; } } } ~UdpServer() { if(_sockfd > gsockfd) ::close(_sockfd); } private: int _sockfd;//文件描述符 //socket()函数成功执行时,会返回一个非负整数,这个整数被称为socket的文件描述符。 uint16_t _localport;//端口号 // std::string _localip; //不需要了 bool _isrunning;//服务器是否在运行 };
UdpServerMain.cc:
服务端启动程序:
#include "UdpServer.hpp" #include <memory> // ./udp_server local-port // ./udp_server 8888 int main(int argc, char *argv[]) { if(argc != 2) { std::cerr << "Usage: " << argv[0] << " local-port" << std::endl; exit(0); } uint16_t port = std::stoi(argv[1]); EnableScreen(); std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port); //C++14的标准 usvr->InitServer(); usvr->Start(); return 0; }
1.7客户端逻辑(代码)
UdpClientMain.cc:
参数检查:程序首先检查用户是否提供了正确的参数数量(即服务器的IP地址和端口号)。如果参数数量不正确,程序将打印出正确的使用方式并退出。
解析参数:程序从命令行参数中解析出服务器的IP地址和端口号,并将它们存储在相应的变量中。
创建套接字:程序调用
socket
函数创建一个UDP套接字。如果套接字创建失败,程序将打印出错误信息并退出。填充服务器信息:程序创建一个
sockaddr_in
结构体来存储服务器的地址信息,包括IP地址和端口号。这些信息将用于向服务器发送数据。主循环:程序进入一个无限循环,等待用户输入。用户输入的字符串将被发送到服务器。
发送数据:程序使用
sendto
函数将用户输入的字符串发送到服务器。如果发送失败,程序将打印出错误信息并退出循环。接收数据:发送数据后,程序使用
recvfrom
函数等待并接收来自服务器的响应。如果接收到数据,程序将打印出服务器的响应。如果接收失败,程序将打印出错误信息并退出循环。关闭套接字:当程序退出循环时(通常是因为用户输入了某些特定的命令或发生了错误),它将关闭套接字并退出。
#include <iostream> #include <string> #include <cstring> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> // 客户端在未来一定要知道服务器的IP地址和端口号 // ./udp_client server-ip server-port // ./udp_client 127.0.0.1 8888 int main(int argc, char *argv[])//获取服务端的ip和端口号 { if(argc != 3)//参数个数不对就报错用法不对 { std::cerr << "Usage: " << argv[0] << " server-ip server-port" << std::endl; exit(0); } std::string serverip = argv[1]; uint16_t serverport = std::stoi(argv[2]); int sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);//和服务端一样创建 if(sockfd < 0) { std::cerr << "create socket error" << std::endl; exit(1); } //服务器端口号是固定的,但是客户端不是,因为客服是随机的 // client的端口号,一般不让用户自己设定,而是让client OS随机选择?怎么选择,什么时候选择呢? // client 需要 bind它自己的IP和端口, 但是client 不需要 “显示指明” bind它自己的IP和端口, // client 在首次向服务器发送数据的时候,OS会自动给client bind它自己的IP和端口, //避免端口冲突 //填充服务器的相关信息 struct sockaddr_in server;//服务器的套接字信息 memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverport);//要把主机序列转为网络序列 // inet_addr把字符串形式的ip转为4字节 server.sin_addr.s_addr = inet_addr(serverip.c_str()); while(1) { std::string line; std::cout << "Please Enter# "; std::getline(std::cin, line); // std::cout << "line message is@ " << line << std::endl; //向服务端发消息 ,并且自动的将客户端的ip和端口号进行绑定 int n = sendto(sockfd, line.c_str(), line.size(), 0, (struct sockaddr*)&server, sizeof(server)); // 你要发送消息,你得知道你要发给谁啊! //接收来自服务器的信息 if(n > 0) { //一个客户端可能会访问多个服务器,但今天我们只是测试 //一个客服端一台服务器的情况,所以我们不用考虑是谁发的,因为只能是该服务器 //下面的收端当占位符用就行了 struct sockaddr_in temp; socklen_t len = sizeof(temp); char buffer[1024]; int m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&temp, &len); if(m > 0) { //收到服务器的字符串 buffer[m] = 0; std::cout << buffer << std::endl; } else { std::cout << "recvfrom error" << std::endl; break; } } else { std::cout << "sendto error" << std::endl; break; } } ::close(sockfd); return 0; }
1.8 用例测试
如果你的云服务器支持通过公网ip访问,当然是可以通过网络跨机器进行交互的。那么就可以通过公网ip找到你的主机,通过端口号找到你对应的进程了。