Linux网络套接字编程

1.地址和端口

1.1.IP 地址

在传输层发送报文的时候会包含两个地址:

  • 源 IP 地址(Source IP Address):这是指发起通信的设备的 IP 地址。在网络通信中,每个主机设备都被分配一个唯一的 IP 地址,用于标识其在网络上的位置。

  • 目的 IP 地址(Destination IP Address):这是指接收通信的主机设备的 IP 地址,可以告诉网络中的路由器或交换机将数据包发送到哪个设备。

1.2.端口号

端口 端口号(Port) 是传输层协议(如 TCPUDP)中的一个概念,用于标识网络中一个特定的进程或服务。它是一个 16 位的整数,取值范围是 0-65535。端口号告诉操作系统,对端主机接收到的数据包应该被交给哪个进程或服务处理。IP 地址和端口号的组合可以唯一标识网络上的某一台主机的某一个进程。

端口号和进程 ID 都是用来 唯一标识 一个进程的。

  • 端口号是用于标识网络中不同服务或应用程序的
  • 而进程 ID 是操作系统内部用于标识运行中的进程的。

注意:实际上端口号和进程 ID 很是相似(当然也有区别),主要时为了使网络和进程两个知识领域进行概念解耦…

一个进程可以同时绑定多个端口号,以便提供多个不同的服务,但是一个端口号不能被多个进程 ID 绑定。

如果你有一个使用 IP 地址和端口号的通信场景,比如 QQ 消息的发送,源 IP 地址是发送方的 IP 地址,目的 IP 地址是接收方的 IP 地址,而端口号则用于区分不同的服务或程序,确保消息到达对端中正确的应用程序或进程。

这样,用户主机和服务主机内的通信,也就是客户端进程和服务端进程之间的进程通信,这就是网络通信的本质。数据在主机间转发仅仅是基本的手段,更为重要的是将数据交给双方主机中对应的进程,也就是将进程和一台主机的特定端口号来关联。

补充:端口号有一些常见的分类。

  1. 系统端口(Well-known ports):这些端口号范围是从 01023,它们通常用于一些众所周知的网络服务,例如 HTTP(80)、FTP(21)、SSH(22)、Telnet(23)、SMTP(25) 等。这些端口号在系统级别被分配给特定的网络服务,因此一般情况下需要特权用户才能使用或绑定到这些端口上。
  2. 注册端口(Registered ports):这些端口号范围是从 102449151,它们用于向公众公布的网络服务。这些端口号通常被软件开发人员分配给自己的网络应用程序,用于在 Internet 上提供服务。尽管这些端口号是注册的,但并不保证某个特定端口号未被使用,因此在选择端口号时需要注意避免与其他已知的服务冲突。
  3. 动态/私有端口(Dynamic/Private ports):这些端口号范围是从 4915265535,也称为临时端口或私有端口。它们通常由客户端程序动态分配,用于临时网络连接或通信。例如,当客户端通过 TCP 建立连接时,会动态地选择一个未被占用的端口号作为源端口,然后与服务器的目标端口进行通信(这点后面编写客户端代码时有提及)。

1.3.套接字

IP地址 + 端口号 就标识了全网内唯一的一个进程,两份这样的“数据对”:

  • 套接字:SRC_IP + SRC_PORT
  • 套接字:DST_IP + DST_PORT

两份套接字再进行关联就建立了客户端和服务端的连接,其后续相关的编程也就是 套接字编程

而端口又涉及到 TCP 协议和 UDP 协议,这两种协议都可以在传输层被使用,可以被相互替换。

补充 1:云服务器的端口需要被开放,才能被别的服务器访问,这方面我写在另外一篇博文里…

补充 2:简单理解两个协议

简单理解 UDP 协议

  • 传输层协议:该协议在传输层中被使用
  • 无连接:不用在代码中刻意建立连接,是直接发送的(类似给某人写信)
  • 不可靠传输:在网络不可靠的情况下,有可能出现丢包和乱序的问题(但在网络中依旧被采纳),使用于实时性要求较高的应用(不可靠是中性词,这也意味着 UDP 的实现比较简单)
  • 面向数据报:以后补充…

简单理解 TCP 协议

  • 传输层协议:该协议在传输层中被使用
  • 有连接:需要在代码中建立连接(类似给某人打电话)
  • 可靠传输:适用于需要可靠数据传输的应用,但可靠的编码代价有可能会让应用缺失实时性,并且更加复杂(因此可靠不一定是好事)
  • 面向字节流:以后待补充…

补充 3:一般实时性不强的应用会优先使用 TCP,有些应用还支持用户自定义选择。

2.网络字节序

内存中的多字节数据相对于内存地址有大小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,而网络数据流同样也有大端小端之分。

发送主机通常将发送缓冲区中的数据按内存地址 从低字节到高字节的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是 按内存地址从低到高的顺序保存,因此网络数据流的地址应规定:

  • 先发出的数据是低地址,后发出的数据是高地址
  • TCP/IP 协议规定, 网络数据流应采用大端字节序,即低地址放高位字节
  • 不管主机是大端机还是小端机,都需要按照 TCP/IP 规定的网络字节序来发送/接收数据
  • 如果当前发送主机为小端,就需要先将数据转成大端,否则忽略转化直接发送即可

为使网络程序具有可移植性, 使同样的 C 代码在大端和小端计算机上编译后都能正常运行, 可以调用以下库函数做网络字节序和主机字节序的转换。

涉及到主机序列和网络序列的相关函数有 htons()、ntohs()、htonl()、ntohl()、inet_addr()、inet_ntoa()

//htons()、ntohs()、htonl()、ntohl()、inet_addr()、inet_ntoa() 声明
uint16_t htons(uint16_t hostshort); //将 16 位主机字节序的 port 转换为网络字节序(用在端口号转化)
uint16_t ntohs(uint16_t netshort); //将 16 位网络字节序的 port 转换为主机字节序(用在端口号转化)

uint32_t htonl(uint32_t hostlong); //将 32 位主机字节序的 port 转换为网络字节序(用在端口号转化)
uint32_t ntohl(uint32_t netlong); //将 32 位网络字节序的 port 转换为主机字节序(用在端口号转化)

in_addr_t inet_addr(const char *ip); //将主机字节序 ip 从点分十进制字符串形式转换为网络字节序, 不过推荐使用 inet_pton()
char *inet_ntoa(struct in_addr in); //将网络字节序的 ip 从二进制形式转换回点分十进制字符串

补充:h 表示 host,而 n 表示 network

//ip 的主机和网络转化
#include <iostream>
#include <stdlib.h>
#include <arpa/inet.h>

int main()
{
    const char* ip_address_str = "192.168.1.1";

    //将点分十进制字符串转换为网络字节序的二进制形式
    in_addr_t ip_address_bin = inet_addr(ip_address_str);

    if (ip_address_bin == INADDR_NONE)
    {
        std::cout << "Invalid IP address." << std::endl;
        exit(-1);
    }

    std::cout << ip_address_bin << std::endl;

    //将网络字节序的二进制形式转换回点分十进制字符串
    struct in_addr addr_struct;
    addr_struct.s_addr = ip_address_bin;
    char* ip_address_str_back = inet_ntoa(addr_struct);

    if (ip_address_str_back == NULL)
    {
        std::cout << "Conversion failed." << std::endl;
        exit(-1);
    }

    std::cout << ip_address_str_back << std::endl;

    return 0;
}

Linux 下进行套接字编程时,通常只需要对 IP 地址和 port 进行字节序的转换,而不需要对发送的数据进行转换,因为套接字接口已经处理了发送数据的转换。

在本系列文章中,我们主要使用 htons()inet_addr() 来对端口号和地址进行字节序转化。

从主机字节序
从主机字节序
转化为网络字节序发送出去
转化为网络字节序发送出去
从网络字节序
从网络字节序
转化为主机字节序
转化为主机字节序
本端
设置运行的 ip 和运行的 port
htons()/htonl()
inet_addr()
对端
ntohs()/ntohl()
inet_ntoa()
接受

3.套接字编程

3.1.套接字编程分类

socket 被翻译为“套接字”,实际上这个翻译还是很迷惑的,翻译成“插座/软件插座/软插座”会更好理解。

这套接口的标准是基于 POSIX 的,常见的 socket 编程分类主要有:

  1. 原始 socket(Raw Socket):通常指的是在网络层(OSI 模型的第三层)直接操作数据包的一种套接字。使用原始套接字,需要特殊的权限,程序可以发送和接收未封装的数据包,而不被协议栈处理,这使得程序可以更底层地控制网络数据的处理(但是这样的操作可能对网络和系统造成风险)。
  2. 域间 socket(Local Socket):常见于本地通信,和命名管道通信很是类似。通过文件系统中的特殊文件来实现通信,通常被用于实现进程间的 IPC(进程间通信)。在 Unix/Linux 系统中,这种套接字通常是基于文件系统中的文件路径进行通信的,比如基于文件名的命名套接字和抽象命名套接字。
  3. 网络 socket(Network Socket):通常用于描述一般的网络编程中的套接字。常见的网络 socket 包括 TCP socketUDP socket,它们分别基于 TCPUDP 传输协议,是应用层与传输层之间的接口。

上述三种应用场景,理论上应该是有三种接口,但是 Linxu 上统一使用同种接口(只是在传递参数的时候会根据结构体的不同进行区分),而我们重点学习最后一种网络套接字。

3.2.套接字编程接口

3.2.1.套接相关接口

//套接字初始和绑定 API
#include <sys/types.h>
#include <sys/socket.h>

//1.创建 socket,成功的时候返回一个套接字描述符(类似文件描述符,由于 UDP 是面向数据报的,有专门的根据描述符来使用的读写接口。但是 TCP 中直接可以像文件一样使用,这是因为文件和 TCP 都是面向字节流的),失败返回负值并且设置 errno。值得注意的是,可以使用 close() 来关闭这个描述符
int socket (
    int domain, //设置协议家族/地址族/域
    int type, //设置通信类型
    int protocol //设置协议类别
);
//(1)domain(协议家族/地址族/域):指明将来创建的套接字类型,常见类型如下:
    //a)AF_PACKET:
        //用于底层数据包操作,可以发送和接收原始数据帧。
        //通常需要特殊权限。
    //b)AF_UNIX 或 AF_LOCAL:
        //用于本地通信,套接字由文件系统路径标识。
        //在 Unix/Linux 系统中常用于进程间通信。
    //c)AF_INET(本系列文章多用这个)和 AF_INET6:
        //用于 IPv4/IPv6 网络通信。
        //常用于 TCP 和 UDP 套接字编程。
    //d)其他:AF_IPX、AF_NETLINK(用于 Linux 内核与用户空间之间的通信)AF_25、AF_AX25、AF_ATMPVC、AF_APPLETALK、AF_PACKET、AF_UNSPEC(未指定地址族,由系统自动选择合适的地址族)、AF_BLUETOOTH(用于蓝牙通信)。
//(2)type(通信类型):
    //a)SOCK_DGRAM:UDP 是面向数据报的,无需建立连接,应该使用这个
    //b)SOCK_STREAM:TCP 是面向字节流的,需要建立连接,应该使用这个
    //c)其他:待补充...
//(3)protocol(协议类别):基本上前面两个参数填好了这个就固定了,通常填 0 即可自动推导选择相应的协议
//在本系列文章里 socket(AF_INET, SOCK_DGRAM, 0); 或 socket(AF_INET, SOCK_STREAM, 0); 基本是固定写法

//2.绑定套接字,成功返回 0,失败返回负值并且设置 error
int bind (
    int socket, //填入获取到的套接字标识符
    const struct sockaddr *address, //强转后的套接字结构体
    socklen_t address_len //address 结构体的字节大小
);
//可以将用户指定的 ip 和 port 在内核中进行强关联,使用结构体 sockaddr_in 的时候还需要额外加多两个头文件 <netinet/in.h> <arpa/inet.h>,该结构体内需要设置好 sin_family 和 sin_port 和 sin_addr.s_addr,设置之前可以先使用 void bzero(void*s, size_t n) 把指定的空间进行清零(该库函数的头文件为 <strings.h>)
//(1)socket:直接填入之前获取到的套接字标识符
//(2)address:可以填上强转后的套接字结构体,例如 sockaddr_in{/*...*/};、sockaddr_in6{/*...*/};、sockaddr_un{/*...*/}; 内部包含 IP 和 PORT
//(3)address_len:传入的对象结构体的字节长度(sizeof)

套接字 API 是一层抽象的网络编程通用接口, 适用于各种底层网络协议。然而,各种网络协议的地址格式并不相同,其中参数 const struct sockaddr* sockaddr 的结构有如下三种选择:

在这里插入图片描述

  1. struct sockaddr:通用的抽象结构,最原始的结构
  2. struct sockaddr_un:是用于 UNIX 域套接字的结构体
  3. struct sockaddr_in:用于 IPv4 网络地址的结构体(我们重点学习这个)

上述结构体中,接口根据地址类型来判断是哪一种 socket 通信,使用接口的时候只需要进行强制类型转化即可。

吐槽:为什么不直接设计为 const void* sockaddr 呢?有一个可能的猜测是套接字接口设计之初并没有 void* 的语法,当然这只是猜测…

3.2.2.读写相关接口

读写相关的接口在 UDPTCP 两种协议上有些许不同,并且 TCP 的读写步骤会较多。

//UDP 的数据读写接口(由于 UDP 是面向数据报的,因此就需要特别于字节流的读写接口)
#include <sys/types.h>
#include <sys/socket.h>

//1.服务端读取客户端的数据,读取失败返回 -1
ssize_t recvfrom (
    int sockfd,
    void *buf, size_t len,
    int flags,
    struct sockaddr* src_addr,
    socklen_t* addrlen
);
//(1)sockfd:套接字标识符
//(2)buf、len:buf 指向用于读取的缓冲区,len 是该读取缓冲区的大小(读取缓冲区由程序员来设定)
//(3)flag:表示调用操作的可选标志,常用的标志有 MSG_CONFIRM、MSG_DONTWAIT、MSG_ERRQUEUE 等,不用设置为 0 即可
//(4)src_addr(输出型参数):表示用于存储发送端地址信息的结构体 sockaddr 的指针,如果不需要知道发送端地址则可以传入 NULL
//(5)addrlen(输出型参数):对于输入参数,表示指定 src_addr 缓冲区的长度,对于输出参数,表示返回 src_addr 结构体的实际长度
//最后两个参数会得到发送端的套接字信息,也相当于一种读取
    
//2.服务端写回客户端的数据
ssize_t sendto (
    int sockfd,
    const void* buf, size_t len,
    int flags, 
    const sockaddr* dest_addr,
    socklen_t addrlen
);
//(1)sockfd:套接字标识符
//(2)buf、len:buf 指向用于写回的缓冲区,len 是该写回缓冲区的大小(写回缓冲区由程序员来设定)
//(3)flag:表示调用操作的可选标志,常用的标志有 MSG_CONFIRM、MSG_DONTWAIT、MSG_ERRQUEUE 等,不用时设置为 0 即可
//(4)dest_addr(输入型参数):表示用于存储写回端地址信息的结构体 sockaddr 的指针
//(5)addrlen(输入型参数):对于输入参数,表示指定 dest_addr 缓冲区的长度,对于输出参数,表示返回 src_addr 结构体的实际长度
//最后两个参数会写回写回端的套接字信息,也相当于一种写回
//TCP 的数据读写接口(由于 TCP 是面向数据流的,还需要更多的安全连接工作,再使用和文件 IO 一样的读写接口或者特用的读写接口)
//1.开始监听客户端的请求(实际上是设置套接字的状态)
int listen (
    int socket,
    int backlog
);
//(1)socket:是之前调用 socket() 函数返回的套接字描述符
//(2)backlog:参数 backlog 是指定连接队列的最大长度。连接队列用于存放尚未被 accept() 函数接受的连接请求。当有新的连接请求到达监听套接字时,如果连接队列已满,新的连接请求将被拒绝。但是以我们当前的知识储备还无法理解这个参数,先照着下面代码用就行


//2.服务端接收客户端的请求,该函数最重要的是返回值,会返回一个提供 IO 服务的套接字,失败返回 -1,并且设置 error
int accept (
    int socket,
    struct sockaddr* address,
    socklen_t* address_len
);
//(1)socket:是之前调用 socket() 函数返回的套接字描述符
//(2)address:当 accept() 函数成功接受了客户端的连接请求时,会将客户端的地址信息填充到这个结构体中
//(3)address_len:当 accept() 函数成功接受了客户端的连接请求时,会更新这个参数,返回客户端地址信息的实际长度


//3.客户端建立连接
int connect (
    int sockfd, 
    const struct sockaddr *addr,
    socklen_t addrlen
);
//(1)socket:客户端调用 connect() 函数时,需要将套接字描述符和服务端的地址信息传递给 connect() 函数,以便建立连接。
//(2)addr:结构体包含了服务端的 IP 地址和端口号等连接信息。
//(3)addrlen:在调用 connect() 函数之前,需要将这个参数设置为 addr 结构体的实际长度。

//4.读写接口
//和文件 IO 的读写接口一样,或者使用下面两个
//读接口
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
//(1)sockfd 是套接字描述符,对于 TCP 套接字,它是由 socket 函数返回的套接字描述符。
//(2)buf 是接收数据的缓冲区的指针。
//(3)len 是要接收的最大字节数。
//(4)flags 是可选的标志参数,通常设置为 0。

//写接口
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
//(1)sockfd 是套接字描述符,对于 TCP 套接字,它是由 socket 函数返回的套接字描述符。
//(2)buf 是要发送的数据的缓冲区的指针。
//(3)len 是要发送的字节数。
//(4)flags 是可选的标志参数,通常设置为 0。

3.3.套接字编程应用

3.3.1.UDP 套接字编程应用

client绑定套接字
int socket(int domain, int type, int protocol);
OS 在 sendto() 自动随机绑定,通常无需手动 bind()
server绑定套接字
int socket(int domain, int type, int protocol);
int bind(int socket, const struct sockaddr *address, socklen_t address_len)
UDP 下的套接字编程
服务端
客户端
read:recvfrom()/write:sendto()
3.3.1.1.通信基本程序
3.3.1.1.1.前要准备工作

使用上述 API 来做一个 CV 框架,并且先制作出 makefile 方便后续的自动化运行。然后拿出我们以前编写的日志头文件,方便我们打印日志消息。

# makefile
.PHONY:all
all:udp_client udp_server

udp_client:udp_client.cpp
	g++ -o $@ $^ -std=c++11
udp_server:udp_server.cpp
	g++ -o $@ $^ -std=c++11	
	
.PHONY:clean
clean:
	rm -f udp_client udp_server
	rm -rf log_dir
//log.hpp

/* 使用方法
Log log = Log(bool debugShow = true,    //选择是否显示 DEBUG 等级的日志消息
    std::string writeMode = "SCREEN",   //选择日志的打印方式
    std::string logFileName = "log"     //选择日志的文件名称
);
log.WriteModeEnable();      //中途可以修改日志的打印方式
log.LogMessage(DEBUG | NORMAL | WARNING | ERROR | FATAL, "%s %d", __FILE__, __LINE__));     //打印日志
*/

#pragma once

#include <iostream>
#include <string>
#include <fstream>

#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>

//日志级别
#define DEBUG 0 //调试
#define NORMAL 1 //正常(或者叫 INFO)
#define WARNING 2 //警告
#define ERROR 3 //错误
#define FATAL 4 //致命

enum WriteMode
{
    SCREEN = 5,
    ONE_FILE,
    CLASS_FILE
};

const char* gLevelMap[] = {
    "DEBUG", //debug 模式
    "NORMAL", //正常(或者叫 INFO)
    "WARNING", //警告
    "ERROR", //非致命错误
    "FATAL" //严重错误
};

const std::string logdir = "log_dir";

//日志功能主要有:日志等级、发送时间、日志内容、代码行数、运行用户
class Log
{
private:
    void __WriteLogToOneFile(std::string logFileName, const std::string& message)
    {
        std::ofstream out(logFileName, std::ios::app);
        if (!out.is_open())
            return;
        out << message;
        out.close();
    }
    void __WriteLogToClassFile(const int& level, const std::string& message)
    {
        std::string logFileName = "./";
        logFileName += logdir;
        logFileName += "/";
        logFileName += _logFileName;
        logFileName += "_";
        logFileName += gLevelMap[level];

        __WriteLogToOneFile(logFileName, message);
    }
    void _WriteLog(const int& level, const std::string& message)
    {
        switch (_writeMode)
        {
        case SCREEN: //向屏幕输出
            std::cout << message;
            break;
        case ONE_FILE: //向单个日志文件输出
            __WriteLogToOneFile("./" + logdir + "/" + _logFileName, message);
            break;
        case CLASS_FILE: //向多个日志文件输出
            __WriteLogToClassFile(level, message);
            break;
        default:
            std::cout << "write mode error!!!" << std::endl;
            break;
        }
    }

public:
    //构造函数,debugShow 为是否显示 debug 消息,writeMode 为日志打印模式,logFileName 为日志文件名
    Log(bool debugShow = true, const WriteMode& writeMode = SCREEN, std::string logFileName = "log")
        : _debugShow(debugShow), _writeMode(writeMode), _logFileName(logFileName)
    {
        mkdir(logdir.c_str(), 0775); //创建目录
    }

    //调整日志打印方式
    void WriteModeEnable(const WriteMode& mode)
    {
        _writeMode = mode;
    }

    //拼接日志消息并且输出
    void LogMessage(const int& level, const char* format, ...)
    {
        //1.若不是 debug 模式,且 level == DEBUG 则不做任何事情
        if (_debugShow == false && level == DEBUG)
            return;

        //2.收集日志标准部分信息
        char stdBuffer[1024];
        time_t timestamp = time(nullptr); //获得时间戳
        struct tm* local_time = localtime(&timestamp); //将时间戳转换为本地时间

        snprintf(stdBuffer, sizeof stdBuffer, "[%s][pid:%s][%d-%d-%d %d:%d:%d]",
            gLevelMap[level],
            std::to_string(getpid()).c_str(),
            local_time->tm_year + 1900, local_time->tm_mon + 1, local_time->tm_mday,
            local_time->tm_hour, local_time->tm_min, local_time->tm_sec
        );

        //3.收集日志自定义部分信息
        char logBuffer[1024];
        va_list args; //声明可变参数列表,实际时一个 char* 类型
        va_start(args, format); //初始化可变参数列表
        vsnprintf(logBuffer, sizeof logBuffer, format, args); //int vsnprintf(char *str, size_t size, const char *format, va_list ap); 是一个可变参数函数,将格式化后的字符串输出到缓冲区中。类似带 v 开头的可变参数函数有很多
        va_end(args); //清理可变参数列表,类似 close() 和 delete

        //4.拼接为一个完整的消息
        std::string message;
        message += "--> 标准日志:"; message += stdBuffer;
        message += "\t 用户日志:"; message += logBuffer;
        message += "\n";

        //5.打印日志消息
        _WriteLog(level, message);
    }
    
private:
    bool _debugShow;
    WriteMode _writeMode;
    std::string _logFileName;
};

3.3.1.1.2.编写基础的服务端程序

然后先编写 udp_server.hpp,先把我们的基础服务器跑起来。

//udp_server.hpp
#pragma once

#include <string>

#include <sys/types.h> //套接字编程的头文件
#include <sys/socket.h>

#include <netinet/in.h> //转化字节序的头文件
#include <arpa/inet.h>

#include <strings.h>

#include "log.hpp"

#define DEBUG_SHOW

class UdpServer
{
    //2.初始服务器自己的 ip 和 port
    public:UdpServer(uint16_t port, std::string ip = "")
        : _port(port), _ip(ip), _sock(-1)
    {
        //3.创建套接字
        if ((_sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0)
        {
            LogMessage(FATAL, "%d %s %s %d", errno, strerror(errno), __FILE__, __LINE__);
            exit(10);
        }

        //4.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_port = htons(_port); //设置 port(两字节),主机序列转为网络序列
        local.sin_addr.s_addr = inet_addr(_ip.c_str()); //设置 ip(四字节),内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            LogMessage(FATAL, "%d %s %s %d", errno, strerror(errno), __FILE__, __LINE__);
            exit(20);
        }
        /* 注意,发送消息时,也需要把本地的 ip 和 port 发送对方 */

        //5.提示初始化成功
        LogMessage(NORMAL, "init udp server done ... %d %s %s %d", errno, strerror(errno), __FILE__, __LINE__);
    }

    public:void Start()
    {
        while (true);
    }

    public:~UdpServer()
    {}

private:
    //1.设置成员变量
    uint16_t _port; //port
    std::string _ip; //ip
    int _sock; //socket
};
//udp_server.cpp
#include <iostream>
#include <memory>
#include <string>
#include <cstdlib>

#include "udp_server.hpp"

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //服务器启动指令 ./udp_server ip port
{
    //1.检查命令是否合法,否则打印使用手册
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(10);
    }

    //2.分割出 IP 和 PORT 信息
    std::string ip = argv[1];
    uint16_t port = atoi(argv[2]);

    //3.使用智能指针托管服务端指针
    std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));

    //4.启动服务端服务
    svr->Start();

    return 0;
}
//udp_client.cpp
int main(int argc, char const *argv[])
{
    return 0;
}

make 编译通过后,使用 ./udp_server 0.0.0.0 8080 即可运行服务端程序,并且可以使用 netstat -anup 检验服务端是否可以成功启动。

# 查看服务器运行情况
# (1)运行服务端程序
$ ./udp_server 0.0.0.0 8080
| 标准日志:[NORMAL][1710299783] | 自定义日志:init udp server done ... 0 Success udp_server.hpp 53 |

# (2)使用 netstat 查看服务端运行情况
$ netstat -anup # -a:所有 -n:尽可能显示数字信息 -u:udp -p:进程
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
udp        0      0 0.0.0.0:8080            0.0.0.0:*                           25163/./udp_server

可以注意到,现在的服务器是永远不会退出的(因为在 Start() 中使用了 while(true);),这种进程就是常驻进程,直到服务器宕机才终止。而这种程序也是最担心内存泄漏的,那些只需跑一次的程序哪怕真的内存泄漏了,通常也没有太多的影响。

前面我们只是完成了服务端和套接字的初始和绑定工作,接下来我们进一步编写服务端的读写服务(可能有代码被微调)。

//udp_server.hpp
#pragma once

#include <string>
#include <iostream>
#include <cstring>


#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>

#include "log.hpp"

#define DEBUG_SHOW

const int buffSize = 1024;

class UdpServer
{
    //2.初始服务器自己的 ip 和 port
public:
    UdpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _sock(-1)
    {
        //3.创建套接字
        if ( (_sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0 )
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(10);
        }

        //4.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = inet_addr(_ip.c_str()); //设置 ip(四字节),内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节),主机序列转为网络序列
        if ( bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0 )
        {
            _log.LogMessage(FATAL, "bind() error, %s %d", __FILE__, __LINE__);
            exit(20);
        }
        /* 注意,发送消息时,也需要把本地的 ip 和 port 发送对方 */

        //5.提示初始化成功
        _log.LogMessage(NORMAL, "UdpServer() success, %s %d", __FILE__, __LINE__);
    }

    void Start()
    {
        while (true)
        {
            //6.提前备好一个结构体,方便读取和写入接口临时使用
            struct sockaddr_in peer;
            bzero(&peer, sizeof(peer)); //比特位置零
            socklen_t peerLen = sizeof(peer); //输入输出型参数 (1)输入缓冲区大小 (2)输出实际读到的 peer 大小,最好写明一下返回的类型

            //7.读取数据
            char readBuff[buffSize] = { 0 }; //初始化为 0,后续就不用添加 '\0' 了
            if ( recvfrom( _sock, readBuff, sizeof(readBuff) - 1, 0, (struct sockaddr*)&peer, &peerLen) > 0 ) //读取成功
            {
                //读取到数据后把客户端发送的数据打印出来,包括客户端的 ip 和 port
                uint16_t cli_port = ntohs(peer.sin_port); //需要反序列
                std::string cli_ip = inet_ntoa(peer.sin_addr); //反序列后转化为点分十进制字符串(还有其他类似的接口)
                std::cout << "ip:[" << cli_ip << "] port:[" << cli_port << "]" << " sad:" << readBuff << std::endl;
                //LogMessage(NORMAL, "server read done ... %d %s %s %d", errno, strerror(errno), __FILE__, __LINE__);
            }
            else //读取失败
            {
                _log.LogMessage(FATAL, "recvfrom() error, %s %d", __FILE__, __LINE__);
                exit(30);
            }

            //8.分析数据
            //TODU...

            //9.写回数据
            //char writerBuff[buffSize] = "Ok~"; //初始化为 0,后续就不用添加 '\0' 了
            //if (sendto(_sock, writerBuff, strlen(writerBuff), 0, (struct sockaddr*)&peer, peerLen) > 0) //读取成功
            //{
            //    LogMessage(NORMAL, "server write done ... %d %s %s %d", errno, strerror(errno), __FILE__, __LINE__);
            //}
            //由于我们希望客户端之间可以通过服务端来通信,因此这里直接把服务端从客户端中读取的数据返回客户端即可,这也后续有多个客户端向服务端发送消息时,服务端返回的信息可以同步给每一个客户端
            if ( sendto(_sock, readBuff, strlen(readBuff), 0, (struct sockaddr*)&peer, peerLen) > 0 ) //写回成功,注意这里是 strlen(),只发送有效的数据,并且前面读取数据的时候也获取了客户端的套接字消息,可以直接在这里使用
            {
                _log.LogMessage(NORMAL, "server write done ...%s %d", __FILE__, __LINE__);
            }
            else //写回失败
            {
                _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
                exit(40);
            }
        }
    }

    ~UdpServer()
    {
        //10.关闭套接字
        if (_sock >= 0)
        {
            close(_sock); //关闭套接字描述符
            _log.LogMessage(NORMAL, "close udp server done ... %s %d", __FILE__, __LINE__);
        }
    }

private:
    //1.设置成员变量
    std::string _ip; //ip
    uint16_t _port; //port
    int _sock; //socket
    Log _log;
};
3.3.1.1.3.编写基础的客户端程序

然后开始完善和编写客户端代码。

//udp_client.cpp
#include <string>
#include <iostream>

#include <cerrno>
#include <cstring>
#include <cstdlib>

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>

#include "log.hpp"

const int readBuffSize = 1024;

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //./udp_client 127.0.0.1 8080
{
    Log log;

    //1.检查命令行输入
    if(argc != 3)
    {
        Usage(argv[0]);       
        exit(50);
    }

    //2.创建套接字
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if ( sock < 0 ) //补充一个小知识点:也有人把 AF_INET 换成等价的 PF_INET
    {
        log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
        exit(60);
    }

    //3.绑定套接字
    //但是一般不需要程序员自己 bind,这是因为客户端是被客户端使用的,如果程序员自己 bind 了,
    //那么该客户端的一定是绑定了某个固定的 ip 和 port 万一在多个不关联的客户端同时启动的情况下,
    //就会出现 port 绑定失败的情况,进而导致客户端启动失败。
    //将 bind 操作交给操作系统来做,操作系统对于哪些 port 没有被占用的情况最清楚了。
    
    //4.存储需要访问服务端的 ip 和 port
    struct sockaddr_in server;
    bzero(&server, sizeof(server)); //比特位置零
    server.sin_family = AF_INET; //设置协议家族/域
    server.sin_addr.s_addr = inet_addr(argv[1]); //设置 ip(四字节),内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
    server.sin_port = htons(atoi(argv[2])); //设置 port(两字节),主机序列转为网络序列

    while (true)
    {
        //5.向服务端发送消息 message
        std::string message;
        std::cout << "clien >: ";
        std::getline(std::cin, message); //用户输入想要发送的数据
        if ( message == "exit" ) 
            break;
        if ( sendto(sock, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server)) > 0 ) //写入成功
        {
            //LogMessage(NORMAL, "client write done ... %d %s %s %d", errno, strerror(errno), __FILE__, __LINE__);
        }
        else //写入失败
        {
            log.LogMessage(FATAL, "sendto() success, %s %d", __FILE__, __LINE__);
            exit(70);
        }

        //6.读取服务端返回的消息
        char readBuff[readBuffSize] = { 0 }; //第一次向服务器发送数据时,操作系统会在 sendto() 执行过程程中自动为服务端绑定

        struct sockaddr_in temp;
        bzero(&temp, sizeof(temp)); //比特位置零
        socklen_t len = sizeof(temp);
        if ( recvfrom(sock, readBuff, sizeof(readBuff) - 1, 0, (struct sockaddr*)&temp, &len) > 0 )
        {
            std::cout << "server echo >: " << readBuff << std::endl;
        }
    }

    //7.关闭套接字
    close(sock);
    log.LogMessage(NORMAL, "close udp client done ... %s %d", __FILE__, __LINE__);
    std::cout << "bye~" << std::endl;

    return 0;
}

使用本地 ip = 127.0.0.1(也叫“本地环回”,CS 模型只在本地协议栈中进行数据流行,不会把数据发送到网络中,只是把协议栈走一个回环,多用于本地测试)启动服务端和 8080 端口来启动服务端程序和用户端程序,启动结果如下:

# 服务端运行结果
$ make clean
rm -f udp_client udp_server
rm -rf log_dir

$ make
g++ -o udp_client udp_client.cpp -std=c++11
g++ -o udp_server udp_server.cpp -std=c++11

$ ./udp_server 127.0.0.1 8080
| 标准日志:[NORMAL][1710316375] | 自定义日志:init udp server done ... 0 Success udp_server.hpp 54 |
ip:[127.0.0.1] port:[43571] sad:Hello, I am Limou3434.
| 标准日志:[NORMAL][1710316409] | 自定义日志:server write done ... 0 Success udp_server.hpp 94 |
# 客户端运行结果
$ ./udp_client 127.0.0.1 8080
clien >: Hello, I am Limou3434.
server echo >: Hello, I am Limou3434.

可以看到通信成功,但是这里面是有很多的坑点和补充点的…

3.3.1.1.4.更换服务端程序的 ip 地址为任意

0.0.0.0 代表所有可用网络接口,而 127.0.0.1 代表本地主机回环地址。

云服务器的服务端程序无法直接绑定某个特定的 ip 的(除了上述的两个 ip),主要原因是云服务器给的 ip 是虚拟后的 ip,而不是真实的 ip 地址。并且一般也不建议这么做,推荐直接使用 0.0.0.0 的方案(实际上也确实有一个关键字 INADDR_ANY 的值为 0.0.0.0。)

因此我们可以再修改一下,并且总结一下目前的代码。

# makefile(通信基本程序)
.PHONY:all
all:udp_client udp_server

udp_client:udp_client.cpp
	g++ -o $@ $^ -std=c++11
udp_server:udp_server.cpp
	g++ -o $@ $^ -std=c++11	
	
.PHONY:clean
clean:
	rm -f udp_client udp_server
	rm -rf log_dir
//log.hpp(通信基本程序)

/* 使用方法
Log log = Log(bool debugShow = true,    //选择是否显示 DEBUG 等级的日志消息
    std::string writeMode = "SCREEN",   //选择日志的打印方式
    std::string logFileName = "log"     //选择日志的文件名称
);
log.WriteModeEnable();      //中途可以修改日志的打印方式
log.LogMessage(DEBUG | NORMAL | WARNING | ERROR | FATAL, "%s %d", __FILE__, __LINE__));     //打印日志
*/

#pragma once

#include <iostream>
#include <string>
#include <fstream>

#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>

//日志级别
#define DEBUG 0 //调试
#define NORMAL 1 //正常(或者叫 INFO)
#define WARNING 2 //警告
#define ERROR 3 //错误
#define FATAL 4 //致命

enum WriteMode
{
    SCREEN = 5,
    ONE_FILE,
    CLASS_FILE
};

const char* gLevelMap[] = {
    "DEBUG", //debug 模式
    "NORMAL", //正常(或者叫 INFO)
    "WARNING", //警告
    "ERROR", //非致命错误
    "FATAL" //严重错误
};

const std::string logdir = "log_dir";

//日志功能主要有:日志等级、发送时间、日志内容、代码行数、运行用户
class Log
{
private:
    void __WriteLogToOneFile(std::string logFileName, const std::string& message)
    {
        std::ofstream out(logFileName, std::ios::app);
        if (!out.is_open())
            return;
        out << message;
        out.close();
    }
    void __WriteLogToClassFile(const int& level, const std::string& message)
    {
        std::string logFileName = "./";
        logFileName += logdir;
        logFileName += "/";
        logFileName += _logFileName;
        logFileName += "_";
        logFileName += gLevelMap[level];

        __WriteLogToOneFile(logFileName, message);
    }
    void _WriteLog(const int& level, const std::string& message)
    {
        switch (_writeMode)
        {
        case SCREEN: //向屏幕输出
            std::cout << message;
            break;
        case ONE_FILE: //向单个日志文件输出
            __WriteLogToOneFile("./" + logdir + "/" + _logFileName, message);
            break;
        case CLASS_FILE: //向多个日志文件输出
            __WriteLogToClassFile(level, message);
            break;
        default:
            std::cout << "write mode error!!!" << std::endl;
            break;
        }
    }

public:
    //构造函数,debugShow 为是否显示 debug 消息,writeMode 为日志打印模式,logFileName 为日志文件名
    Log(bool debugShow = true, const WriteMode& writeMode = SCREEN, std::string logFileName = "log")
        : _debugShow(debugShow), _writeMode(writeMode), _logFileName(logFileName)
    {
        mkdir(logdir.c_str(), 0775); //创建目录
    }

    //调整日志打印方式
    void WriteModeEnable(const WriteMode& mode)
    {
        _writeMode = mode;
    }

    //拼接日志消息并且输出
    void LogMessage(const int& level, const char* format, ...)
    {
        //1.若不是 debug 模式,且 level == DEBUG 则不做任何事情
        if (_debugShow == false && level == DEBUG)
            return;

        //2.收集日志标准部分信息
        char stdBuffer[1024];
        time_t timestamp = time(nullptr); //获得时间戳
        struct tm* local_time = localtime(&timestamp); //将时间戳转换为本地时间

        snprintf(stdBuffer, sizeof stdBuffer, "[%s][pid:%s][%d-%d-%d %d:%d:%d]",
            gLevelMap[level],
            std::to_string(getpid()).c_str(),
            local_time->tm_year + 1900, local_time->tm_mon + 1, local_time->tm_mday,
            local_time->tm_hour, local_time->tm_min, local_time->tm_sec
        );

        //3.收集日志自定义部分信息
        char logBuffer[1024];
        va_list args; //声明可变参数列表,实际时一个 char* 类型
        va_start(args, format); //初始化可变参数列表
        vsnprintf(logBuffer, sizeof logBuffer, format, args); //int vsnprintf(char *str, size_t size, const char *format, va_list ap); 是一个可变参数函数,将格式化后的字符串输出到缓冲区中。类似带 v 开头的可变参数函数有很多
        va_end(args); //清理可变参数列表,类似 close() 和 delete

        //4.拼接为一个完整的消息
        std::string message;
        message += "--> 标准日志:"; message += stdBuffer;
        message += "\t 用户日志:"; message += logBuffer;
        message += "\n";

        //5.打印日志消息
        _WriteLog(level, message);
    }
    
private:
    bool _debugShow;
    WriteMode _writeMode;
    std::string _logFileName;
};

//udp_server.hpp(通信基本程序)
#pragma once

#include <string>
#include <iostream>

#include <cstring>

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>

#include "log.hpp"

#define DEBUG_SHOW

const int buffSize = 1024;

class UdpServer
{
    //2.初始服务器自己的 ip 和 port
public:
    UdpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _sock(-1)
    {
        //3.创建套接字
        if ( (_sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0 )
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(10);
        }

        //4.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip(四字节),内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节),主机序列转为网络序列
        if ( bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0 )
        {
            _log.LogMessage(FATAL, "bind() error, %s %d", __FILE__, __LINE__);
            exit(20);
        }
        /* 注意,发送消息时,也需要把本地的 ip 和 port 发送对方 */

        //5.提示初始化成功
        _log.LogMessage(NORMAL, "UdpServer() success, %s %d", __FILE__, __LINE__);
    }

    void Start()
    {
        while (true)
        {
            //6.提前备好一个结构体,方便读取和写入接口临时使用
            struct sockaddr_in peer;
            bzero(&peer, sizeof(peer)); //比特位置零
            socklen_t peerLen = sizeof(peer); //输入输出型参数 (1)输入缓冲区大小 (2)输出实际读到的 peer 大小,最好写明一下返回的类型

            //7.读取数据
            char readBuff[buffSize] = { 0 }; //初始化为 0,后续就不用添加 '\0' 了
            if ( recvfrom( _sock, readBuff, sizeof(readBuff) - 1, 0, (struct sockaddr*)&peer, &peerLen) > 0 ) //读取成功
            {
                //读取到数据后把客户端发送的数据打印出来,包括客户端的 ip 和 port
                uint16_t cli_port = ntohs(peer.sin_port); //需要反序列
                std::string cli_ip = inet_ntoa(peer.sin_addr); //反序列后转化为点分十进制字符串(还有其他类似的接口)
                std::cout << "ip:[" << cli_ip << "] port:[" << cli_port << "]" << " sad:" << readBuff << std::endl;
                _log.LogMessage(NORMAL, "server read done ... %s %d", __FILE__, __LINE__);
            }
            else //读取失败
            {
                _log.LogMessage(FATAL, "recvfrom() error, %s %d", __FILE__, __LINE__);
                exit(30);
            }

            //8.分析数据
            //TODU...

            //9.写回数据
            //char writerBuff[buffSize] = "Ok~"; //初始化为 0,后续就不用添加 '\0' 了
            //if (sendto(_sock, writerBuff, strlen(writerBuff), 0, (struct sockaddr*)&peer, peerLen) > 0) //读取成功
            //{
            //    LogMessage(NORMAL, "server write done ... %s %d", __FILE__, __LINE__);
            //}
            //由于我们希望客户端之间可以通过服务端来通信,因此这里直接把服务端从客户端中读取的数据返回客户端即可,这也后续有多个客户端向服务端发送消息时,服务端返回的信息可以同步给每一个客户端
            if ( sendto(_sock, readBuff, strlen(readBuff), 0, (struct sockaddr*)&peer, peerLen) >= 0 ) //写回成功,注意这里是 strlen(),只发送有效的数据,并且前面读取数据的时候也获取了客户端的套接字消息,可以直接在这里使用
            {
                _log.LogMessage(NORMAL, "server write done ...%s %d", __FILE__, __LINE__);
            }
            else //写回失败
            {
                _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
                exit(40);
            }
        }
    }

    ~UdpServer()
    {
        //10.关闭套接字
        if (_sock >= 0)
        {
            close(_sock); //关闭套接字描述符
            _log.LogMessage(NORMAL, "close udp server done ... %s %d", __FILE__, __LINE__);
        }
    }

private:
    //1.设置成员变量
    std::string _ip;
    uint16_t _port;
    int _sock;
    Log _log;
};
//udp_server.cpp(通信基本程序)
#include <iostream>
#include <memory>
#include <string>
#include <cstdlib>

#include "udp_server.hpp"

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip(optional)] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //服务器启动指令 ./udp_server ip port
{
    //1.检查命令是否合法,否则打印使用手册
    uint16_t port;
    std::string ip;
    if (argc == 2)
    {
        //2.分割出 PORT 信息
        port = atoi(argv[1]);
    }
    else if (argc == 3)
    {
        //3.分割出 IP 和 PORT 信息
        ip = argv[1];
        port = atoi(argv[2]);
    }
    else
    {
        Usage(argv[0]);
        exit(-1);
    }

    //4.使用智能指针托管服务端指针
    std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));

    //5.启动服务端服务
    svr->Start();

    return 0;
}
//udp_client.cpp(通信基本程序)
#include <string>
#include <iostream>

#include <cerrno>
#include <cstring>
#include <cstdlib>

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>

#include "log.hpp"

const int readBuffSize = 1024;

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //./udp_client 127.0.0.1 8080, 注意这里填写的是服务端运行的 ip 和 port
{
    Log log;

    //1.检查命令行输入
    if(argc != 3)
    {
        Usage(argv[0]);       
        exit(50);
    }

    //2.创建套接字
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if ( sock < 0 ) //补充一个小知识点:也有人把 AF_INET 换成等价的 PF_INET
    {
        log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
        exit(60);
    }

    //3.绑定套接字
    //但是一般不需要程序员自己 bind,这是因为客户端是被客户端使用的,如果程序员自己 bind 了,
    //那么该客户端的一定是绑定了某个固定的 ip 和 port 万一在多个不关联的客户端同时启动的情况下,
    //就会出现 port 绑定失败的情况,进而导致客户端启动失败。
    //将 bind 操作交给操作系统来做,操作系统对于哪些 port 没有被占用的情况最清楚了。
    
    //4.存储需要访问服务端的 ip 和 port
    struct sockaddr_in server;
    bzero(&server, sizeof(server)); //比特位置零
    server.sin_family = AF_INET; //设置协议家族/域
    server.sin_addr.s_addr = inet_addr(argv[1]); //设置 ip(四字节),内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
    server.sin_port = htons(atoi(argv[2])); //设置 port(两字节),主机序列转为网络序列

    while (true)
    {
        //5.向服务端发送消息 message
        std::string message;
        std::cout << "clien >: ";
        std::getline(std::cin, message); //用户输入想要发送的数据
        if ( message == "exit" ) 
            break;
        if ( sendto(sock, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server)) > 0 ) //写入成功
        {
            log.LogMessage(NORMAL, "client write done ... %s %d", __FILE__, __LINE__);
        }
        else //写入失败
        {
            log.LogMessage(FATAL, "sendto() success, %s %d", __FILE__, __LINE__);
            exit(70);
        }

        //6.读取服务端返回的消息
        char readBuff[readBuffSize] = { 0 }; //第一次向服务器发送数据时,操作系统会在 sendto() 执行过程程中自动为服务端绑定

        struct sockaddr_in temp;
        bzero(&temp, sizeof(temp)); //比特位置零
        socklen_t len = sizeof(temp);
        if ( recvfrom(sock, readBuff, sizeof(readBuff) - 1, 0, (struct sockaddr*)&temp, &len) > 0 )
        {
            std::cout << "server echo >: " << readBuff << std::endl;
        }
    }

    //7.关闭套接字
    close(sock);
    log.LogMessage(NORMAL, "close udp client done ... %s %d", __FILE__, __LINE__);
    std::cout << "bye~" << std::endl;

    return 0;
}

尝试运行一下,就可以看到客户端和服务端通信成功。接下来,我们在这份代码的基础上进行修改和微调(注意不是照搬,有些代码和注释会进行改动,您可以使用一些 diff 工具进行比对),创造出更多常见的网络应用。

3.3.1.2.远程操作程序

除了发送单纯的文本信息,还可以让服务器根据接受到的指令来执行其他的逻辑。

首先使用介绍 popen() 及其系列的方法,该函数原型如下:

//popen() 及其方法
#include <stdio.h>
//该函数会通过建立管道和 fork() 出子进程,然后通过 exec 系列接口自动执行 command 命令(内部自动解析命令),失败返回 nullptr 但是这只是证明一个该函数创建管道失败,而不是执行命令失败
FILE* popen(const char* command, const char* type); //type 可以是 "r" 即只读模式
//返回 FILE* 指针,可以当作一个文件来读取任务执行的返回结果

在这里插入图片描述

补充:关于 popen()type 的用起来其实不奇怪,就像打开一个文件一样来使用即可,参数 type 指定了管道的类型,有两种可能的取值:"r""w"

  • 如果 type"r",则 popen 打开的是一个用于读取输出的管道。这意味着你可以从命令的标准输出中读取数据。
  • 如果 type"w",则 popen 打开的是一个用于写入输入的管道。这意味着你可以将数据写入到命令的标准输入中。

换句话说,type 参数用于指定你打算与 popen 打开的进程进行的通信方式:是从其读取输出,还是向其提供输入。

当您使用 popen("...", "w") 时打开的也是一个管道,可以向 shell 命令的标准输入中写入数据。下面是一个简单示例:

//以 "w" 方式使用 popen()
#include <stdio.h>
#include <stdlib.h>

int main()
{
    FILE *fp;

    //打开一个用于写入输入的管道,并执行命令 "cat > output.txt"
    fp = popen("cat > output.txt", "w");
    if (fp == NULL)
    {
        perror("popen");
        exit(EXIT_FAILURE);
    }

    //向管道中写入数据
    fprintf(fp, "Hello, World!\n");
    fprintf(fp, "This is a test.\n");

    //关闭管道
    pclose(fp);

    return 0;
}

在这个例子中,popen() 函数以写入方式打开一个管道,并执行命令 cat > output.txt。然后,通过 fprintf() 函数向管道中写入数据。最后,使用 pclose 函数关闭管道。

这样,"Hello, World!\n""This is a test.\n" 这两行文本就会被写入到名为 "output.txt" 的文件中。

如果您不用这个命令,也可以选择使用 pipe() 在执行 fork(),然后使用 exec 系列的系统调用进行替换即可(当然,这有点麻烦)。

我们再次修改一下服务端的代码:

# makefile(远程操作程序)
.PHONY:all
all:udp_client udp_server

udp_client:udp_client.cpp
	g++ -o $@ $^ -std=c++11
udp_server:udp_server.cpp
	g++ -o $@ $^ -std=c++11	
	
.PHONY:clean
clean:
	rm -f udp_client udp_server
//log.hpp(远程操作程序)

/* 使用方法
Log log = Log(bool debugShow = true,    //选择是否显示 DEBUG 等级的日志消息
    std::string writeMode = "SCREEN",   //选择日志的打印方式
    std::string logFileName = "log"     //选择日志的文件名称
);
log.WriteModeEnable();      //中途可以修改日志的打印方式
log.LogMessage(DEBUG | NORMAL | WARNING | ERROR | FATAL, "%s %d", __FILE__, __LINE__));     //打印日志
*/

#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>

//日志级别
#define DEBUG 0     //调试
#define NORMAL 1    //正常(或者叫 INFO)
#define WARNING 2   //警告
#define ERROR 3     //错误
#define FATAL 4     //致命

enum WriteMode
{
    SCREEN = 5,
    ONE_FILE,
    CLASS_FILE
};

const char* gLevelMap[] = {
    "DEBUG",    //debug 模式
    "NORMAL",   //正常(或者叫 INFO)
    "WARNING",  //警告
    "ERROR",    //非致命错误
    "FATAL"     //严重错误
};

const std::string logdir = "log_dir";

//日志功能主要有:日志等级、发送时间、日志内容、代码行数、运行用户
class Log
{
private:
    void __WriteLogToOneFile(std::string logFileName, const std::string& message)
    {
        std::ofstream out(logFileName, std::ios::app);
        if (!out.is_open())
            return;
        out << message;
        out.close();
    }
    
    void __WriteLogToClassFile(const int& level, const std::string& message)
    {
        std::string logFileName = "./";
        logFileName += logdir;
        logFileName += "/";
        logFileName += _logFileName;
        logFileName += "_";
        logFileName += gLevelMap[level];

        __WriteLogToOneFile(logFileName, message);
    }
    
    void _WriteLog(const int& level, const std::string& message)
    {
        switch (_writeMode)
        {
        case SCREEN: //向屏幕输出
            std::cout << message;
            break;
        case ONE_FILE: //向单个日志文件输出
            __WriteLogToOneFile("./" + logdir + "/" + _logFileName, message);
            break;
        case CLASS_FILE: //向多个日志文件输出
            __WriteLogToClassFile(level, message);
            break;
        default:
            std::cout << "write mode error!!!" << std::endl;
            break;
        }
    }


public:
    //构造函数,debugShow 为是否显示 debug 消息,writeMode 为日志打印模式,logFileName 为日志文件名
    Log(bool debugShow = true, const WriteMode& writeMode = SCREEN, std::string logFileName = "log")
        : _debugShow(debugShow), _writeMode(writeMode), _logFileName(logFileName)
    {
        mkdir(logdir.c_str(), 0775); //创建目录
    }

    //调整日志打印方式
    void WriteModeEnable(const WriteMode& mode)
    {
        _writeMode = mode;
    }

    //拼接日志消息并且输出
    void LogMessage(const int& level, const char* format, ...)
    {
        //1.若不是 debug 模式,且 level == DEBUG 则不做任何事情
        if (_debugShow == false && level == DEBUG)
            return;

        //2.收集日志标准部分信息
        char stdBuffer[1024];
        time_t timestamp = time(nullptr); //获得时间戳
        struct tm* local_time = localtime(&timestamp); //将时间戳转换为本地时间

        snprintf(stdBuffer, sizeof stdBuffer, "[%s][pid:%s][%d-%d-%d %d:%d:%d]",
            gLevelMap[level],
            std::to_string(getpid()).c_str(),
            local_time->tm_year + 1900, local_time->tm_mon + 1, local_time->tm_mday,
            local_time->tm_hour, local_time->tm_min, local_time->tm_sec
        );

        //3.收集日志自定义部分信息
        char logBuffer[1024];
        va_list args; //声明可变参数列表,实际时一个 char* 类型
        va_start(args, format); //初始化可变参数列表
        vsnprintf(logBuffer, sizeof logBuffer, format, args); //int vsnprintf(char *str, size_t size, const char *format, va_list ap); 是一个可变参数函数,将格式化后的字符串输出到缓冲区中。类似带 v 开头的可变参数函数有很多
        va_end(args); //清理可变参数列表,类似 close() 和 delete

        //4.拼接为一个完整的消息
        std::string message;
        message += "--> 标准日志:"; message += stdBuffer;
        message += "\t 用户日志:"; message += logBuffer;
        message += "\n";

        //5.打印日志消息
        _WriteLog(level, message);
    }


private:
    bool _debugShow;
    WriteMode _writeMode;
    std::string _logFileName;
};
//udp_server.hpp(远程操作程序)
#pragma once
#include <string>
#include <iostream>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>
#include "log.hpp"

#define DEBUG_SHOW

const int buffSize = 1024;

class UdpServer
{
    //2.初始服务器自己的 ip 和 port
public:
    UdpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _sock(-1)
    {
        //3.创建套接字
        if ((_sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0)
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //4.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域

        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip, 空的话设置位任意 ip 地址, 非空的话使用用户传递的 ip 地址
        //注意 inet_addr() 内部做了两个步骤:(1)将 ip 转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节),主机序列转为网络序列

        if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            _log.LogMessage(FATAL, "bind() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //5.提示初始化成功
        _log.LogMessage(NORMAL, "init udp server done ... %s %d", __FILE__, __LINE__);
    }

    void Start()
    {
        while (true)
        {
            //6.提前备好一个结构体, 方便读取和写入接口临时使用
            struct sockaddr_in peer;
            bzero(&peer, sizeof(peer)); //比特位置零
            socklen_t peerLen = sizeof(peer); //输入输出型参数 (1)输入缓冲区大小 (2)输出实际读到的 peer 大小, 最好写明一下返回的类型

            //7.读取数据
            char readBuff[buffSize] = { 0 }; //初始化为 0, 后续就不用添加 '\0' 了
            std::string cmd_echo; //用于存储指令执行的结果返回给用户
            if (recvfrom(_sock, readBuff, sizeof(readBuff) - 1, 0, (struct sockaddr*)&peer, &peerLen) > 0) //读取成功
            {
                //8.检查客户端发送过来指令
                if (strcmp(readBuff, "rm") == 0 || strcmp(readBuff, "rmdir") == 0) //排除危险的指令(不过这里写的不全)
                {
                    _log.LogMessage(WARNING, "The user executes dangerous instructions! %s %d", __FILE__, __LINE__);
                    cmd_echo = "server refuse!";
                }
                else //9.远程执行非危险的指令
                {
                    FILE* fp = popen(readBuff, "r"); //只读方式创建管道执行指令

                    if (nullptr == fp) //管道创建出错
                    {
                        _log.LogMessage(ERROR, "%s popen() error, %s %d", readBuff, __FILE__, __LINE__);
                        exit(-1);
                    }
                    else //管道创建无错
                    {
                        _log.LogMessage(NORMAL, "popen() success, %s %d", __FILE__, __LINE__);
                        char result[256] = { 0 };
                        while (fgets(result, sizeof(result), fp) != nullptr) //反复读取
                        {
                            cmd_echo += result;
                        }

                        if (cmd_echo.size() == 0) //有可能出现没有返回值, 这里比较武断直接判断为非法指令(实际上有些不准确)
                            cmd_echo = "command not found";

                        fclose(fp); //关闭管道文件
                    }
                }
            }
            else //读取失败
            {
                _log.LogMessage(FATAL, "recvfrom() error, %s %d", __FILE__, __LINE__);
                exit(-1);
            }

            //9.写回数据
            if (sendto(_sock, cmd_echo.c_str(), cmd_echo.size(), 0, (struct sockaddr*)&peer, peerLen) > 0) //写回成功, 注意这里是 strlen(), 只发送有效的数据, 并且前面读取数据的时候也获取了客户端的套接字消息, 可以直接在这里使用
            {
                _log.LogMessage(NORMAL, "server write done... %s %d", __FILE__, __LINE__);
            }
            else //写回失败
            {
                _log.LogMessage(FATAL, "sendto() error, %s %d", __FILE__, __LINE__);
                exit(-1);
            }
        }
    }

    ~UdpServer()
    {
        //10.关闭套接字
        if (_sock >= 0)
        {
            close(_sock); //关闭套接字描述符
            _log.LogMessage(NORMAL, "close udp server done ... %s %d", __FILE__, __LINE__);
        }
    }

private:
    //1.设置成员变量
    std::string _ip;
    uint16_t _port;
    int _sock;
    Log _log;
};
//udp_server.cpp(远程操作程序)
#include <iostream>
#include <memory>
#include <string>
#include <cstdlib>
#include "udp_server.hpp"

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip(optional)] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //服务器启动指令 ./udp_server ip port
{
    //1.检查命令是否合法,否则打印使用手册
    uint16_t port;
    std::string ip;
    if (argc == 2)
    {
        //2.分割出 PORT 信息
        port = atoi(argv[1]);
    }
    else if (argc == 3)
    {
        //3.分割出 IP 和 PORT 信息
        ip = argv[1];
        port = atoi(argv[2]);
    }
    else
    {
        Usage(argv[0]);
        exit(-1);
    }

    //4.使用智能指针托管服务端指针
    std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));

    //5.启动服务端服务
    svr->Start();

    return 0;
}
//udp_client.cpp(远程操作程序)
#include <string>
#include <iostream>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>
#include "log.hpp"

const int readBuffSize = 1024;

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //./udp_client 127.0.0.1 8080
{
    Log log;

    //1.检查命令行输入
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(-1);
    }

    //2.创建套接字
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0) //补充一个小知识点: 也有人把 AF_INET 换成等价的 PF_INET
    {
        log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
        exit(-1);
    }

    //3.绑定套接字
    //但是一般不需要程序员自己 bind, 这是因为客户端是被客户端使用的, 如果程序员自己 bind 了, 
    //那么该客户端的一定是绑定了某个固定的 ip 和 port 万一在多个不关联的客户端同时启动的情况下, 
    //就会出现 port 绑定失败的情况, 进而导致客户端启动失败。
    //将 bind 操作交给操作系统来做, 操作系统对于哪些 port 没有被占用的情况最清楚了。

    //4.存储需要访问服务端的 ip 和 port
    struct sockaddr_in server;
    bzero(&server, sizeof(server)); //比特位置零
    server.sin_family = AF_INET; //设置协议家族/域
    server.sin_addr.s_addr = inet_addr(argv[1]); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
    server.sin_port = htons(atoi(argv[2])); //设置 port(两字节), 主机序列转为网络序列

    while (true)
    {
        //5.向服务端发送消息 message
        std::string message;
        std::cout << "clien >: ";
        std::getline(std::cin, message); //用户输入想要发送的数据
        if (message == "exit")
            break;

        if (sendto(sock, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server)) >= 0) //写入成功
        {
            //写入成功, 但是这里的输出有点烦躁
            //Log.LogMessage(NORMAL, "client write done... %s %d", __FILE__, __LINE__);
        }
        else //写入失败
        {
            log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //6.读取服务端返回的消息
        char readBuff[readBuffSize] = { 0 }; //第一次向服务器发送数据时, 操作系统会在 sendto() 执行过程程中自动为服务端绑定

        //实际上我们已经有了服务端的 ip 和 port 的信息了, 这里我只是为了占位和调用规范写的, 在本项目中可以不怎么处理(有时候客户端可以是其他端的客户端)
        struct sockaddr_in temp;
        bzero(&temp, sizeof(temp)); //比特位置零
        socklen_t len = sizeof(temp);
        if (recvfrom(sock, readBuff, sizeof(readBuff) - 1, 0, (struct sockaddr*)&temp, &len) >= 0)
        {
            std::cout << "server echo >: " << readBuff << std::endl;
        }
        else //读取失败
        {
            log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }
    }

    //7.关闭套接字
    close(sock);
    log.LogMessage(NORMAL, "close udp client done... %s %d", __FILE__, __LINE__);
    std::cout << "bye~" << std::endl;

    return 0;
}

这样就可以让客户端操控服务端,没错,如果您有机会给客户端做一个界面,那就是一个简易版的远端云服务器的操作端…

注意:当然目前的服务端还是不支持类似 vimtop 等指令,原因是这些指令都需要进行交互,会比较麻烦一些。

3.3.1.3.群体聊天程序

我们稍微改一下 udp_server.hpp 即可,来尝试开发一个群聊系统(BBS)。

但这里有一个坑,由于 UDP 的读写接口中,如果客户端需要读取数据时必须处于运行状态,而不能进入阻塞,然而客户端的写入操作就有可能使得客户端陷入阻塞状态,因此必须要将读写操作分离,也就意味着我们必须写出多线程的代码。

我们引入之前封装的线程库再对客户端进行修改(要使用线程时,也需要修改一下)。

# makefile(群体聊天程序)

.PHONY:all
all:udp_client udp_server

udp_client:udp_client.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
	mkfifo fifo1
udp_server:udp_server.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
	mkfifo fifo2
	
.PHONY:clean
clean:
	rm -rf udp_client udp_server log_dir
	unlink fifo1
	unlink fifo2
//log.hpp(群体聊天程序)

/* 使用方法
Log log = Log(bool debugShow = true,    //选择是否显示 DEBUG 等级的日志消息
    std::string writeMode = "SCREEN",   //选择日志的打印方式
    std::string logFileName = "log"     //选择日志的文件名称
);
log.WriteModeEnable();      //中途可以修改日志的打印方式
log.LogMessage(DEBUG | NORMAL | WARNING | ERROR | FATAL, "%s %d", __FILE__, __LINE__));     //打印日志
*/

#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>

//日志级别
#define DEBUG 0     //调试
#define NORMAL 1    //正常(或者叫 INFO)
#define WARNING 2   //警告
#define ERROR 3     //错误
#define FATAL 4     //致命

enum WriteMode
{
    SCREEN = 5,
    ONE_FILE,
    CLASS_FILE
};

const char* gLevelMap[] = {
    "DEBUG",    //debug 模式
    "NORMAL",   //正常(或者叫 INFO)
    "WARNING",  //警告
    "ERROR",    //非致命错误
    "FATAL"     //严重错误
};

const std::string logdir = "log_dir";

//日志功能主要有:日志等级、发送时间、日志内容、代码行数、运行用户
class Log
{
private:
    void __WriteLogToOneFile(std::string logFileName, const std::string& message)
    {
        std::ofstream out(logFileName, std::ios::app);
        if (!out.is_open())
            return;
        out << message;
        out.close();
    }
    
    void __WriteLogToClassFile(const int& level, const std::string& message)
    {
        std::string logFileName = "./";
        logFileName += logdir;
        logFileName += "/";
        logFileName += _logFileName;
        logFileName += "_";
        logFileName += gLevelMap[level];

        __WriteLogToOneFile(logFileName, message);
    }
    
    void _WriteLog(const int& level, const std::string& message)
    {
        switch (_writeMode)
        {
        case SCREEN: //向屏幕输出
            std::cout << message;
            break;
        case ONE_FILE: //向单个日志文件输出
            __WriteLogToOneFile("./" + logdir + "/" + _logFileName, message);
            break;
        case CLASS_FILE: //向多个日志文件输出
            __WriteLogToClassFile(level, message);
            break;
        default:
            std::cout << "write mode error!!!" << std::endl;
            break;
        }
    }


public:
    //构造函数
    // debugShow 为是否显示 debug 消息, writeMode 为日志打印模式, logFileName 为日志文件名
    Log(bool debugShow = true, const WriteMode& writeMode = SCREEN, std::string logFileName = "log")
        : _debugShow(debugShow), _writeMode(writeMode), _logFileName(logFileName)
    {
        mkdir(logdir.c_str(), 0775); //创建目录
    }

    //调整日志打印方式
    void WriteModeEnable(const WriteMode& mode)
    {
        _writeMode = mode;
    }

    //拼接日志消息并且输出
    void LogMessage(const int& level, const char* format, ...)
    {
        //1.若不是 debug 模式, 且 level == DEBUG 则不做任何事情
        if (_debugShow == false && level == DEBUG)
            return;

        //2.收集日志标准部分信息
        char stdBuffer[1024];
        time_t timestamp = time(nullptr); //获得时间戳
        struct tm* local_time = localtime(&timestamp); //将时间戳转换为本地时间

        snprintf(stdBuffer, sizeof stdBuffer, "[%s][pid:%s][%d-%d-%d %d:%d:%d]",
            gLevelMap[level],
            std::to_string(getpid()).c_str(),
            local_time->tm_year + 1900, local_time->tm_mon + 1, local_time->tm_mday,
            local_time->tm_hour, local_time->tm_min, local_time->tm_sec
        );

        //3.收集日志自定义部分信息
        char logBuffer[1024];
        va_list args; //声明可变参数列表, 实际时一个 char* 类型
        va_start(args, format); //初始化可变参数列表
        vsnprintf(logBuffer, sizeof logBuffer, format, args); //int vsnprintf(char *str, size_t size, const char *format, va_list ap); 是一个可变参数函数, 将格式化后的字符串输出到缓冲区中。类似带 v 开头的可变参数函数有很多
        va_end(args); //清理可变参数列表, 类似 close() 和 delete

        //4.拼接为一个完整的消息
        std::string message;
        message += "--> 标准日志:"; message += stdBuffer;
        message += "\t 用户日志:"; message += logBuffer;
        message += "\n";

        //5.打印日志消息
        _WriteLog(level, message);
    }


private:
    bool _debugShow;
    WriteMode _writeMode;
    std::string _logFileName;
};
//thread.hpp(群体聊天程序)

/* 使用方法
void Func(int data) { sleep(3); std::cout << data << std::endl; }
Thread<int>::thread_func_t func = Func;
Thread<int> t("threadName", func, 10);
std::cout << t.ThreadName() << std::endl;
t.Start();
while(t.IsRunning() == false) { t.Join(); }
*/

#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>

//线程类
template<class T>
class Thread
{
public:
    //线程方法类型
    using thread_func_t = std::function<void(T)>;


private:
    //线程调用方法
    static void* ThreadRoutine(void* args) //使用 static 去除方法的 this 参数, 这是一种小技巧
    {
        Thread* ts = static_cast<Thread*>(args);
        ts->_func(ts->_data);
        ts->_isrunning = false; //标识运行结束
        return nullptr;
    }


public:
    //创建线程对象: 传递线程名、thread_func_t 类型方法(void(T))、T 类型参数
    Thread(const std::string& threadname, thread_func_t func, T data)
        : _tid(0)
        , _threadname(threadname)
        , _isrunning(false)
        , _func(func)
        , _data(data)
    {}

    //运行线程对象
    bool Start()
    {
        if (pthread_create(&_tid, nullptr, ThreadRoutine, this) == 0) //如果创建线程成功
        {
            //注意这里传递的线程方法对应的参数时 this 指针, 原因是 ThreadRoutine 是一个 static 函数
            _isrunning = true;
            return true;
        }
        else //创建失败
        {
            return false; //构造默认就是 false
        }
    }

    //释放线程对象
    bool Join()
    {
        if (_isrunning == false) 
            return true;

        if (pthread_join(_tid, nullptr) == 0)
        {
            _isrunning = false;
            return true;
        }

        return false;
    }

    //取得线程名字
    std::string ThreadName()
    {
        return _threadname;
    }

    //取得线程状态
    bool IsRunning()
    {
        return _isrunning;
    }


private:
    pthread_t _tid;             //线程 id
    std::string _threadname;    //线程名字
    bool _isrunning;            //线程状态
    thread_func_t _func;        //线程方法
    T _data;                    //线程数据
};
//udp_server.hpp(群体聊天程序)

/* 使用方法
    std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));
    svr->Start();
*/

#pragma once
#include <string>
#include <iostream>
#include <unordered_map>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>
#include "log.hpp"

#define DEBUG_SHOW

class UdpServer
{
private:
    static const int buffSize = 1024; //缓冲区大小, 简单说 const 其实是 "readonly", constexpr 才是 "const", 前者保证运行中无法修改, 后置保证编译期间就被填充

public:
    //初始服务器(ip 和 port)
    UdpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _sock(-1)
    {
        //1.创建套接字
        if ((_sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0)
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //2.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节), 主机序列转为网络序列
        if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            _log.LogMessage(FATAL, "bind() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }
        _log.LogMessage(NORMAL, "init udp server done ... %s %d", __FILE__, __LINE__);
    }

    //释放服务器(sock)
    ~UdpServer()
    {
        //关闭套接字
        if (_sock >= 0)
        {
            close(_sock); //关闭套接字描述符
            _log.LogMessage(NORMAL, "close udp server done ... %s %d", __FILE__, __LINE__);
        }
    }

    //启动服务器
    void Start()
    {
        while (true)
        {
            //提前备好一个结构体, 方便读取和写入接口临时使用
            struct sockaddr_in peer;
            bzero(&peer, sizeof(peer)); //比特位置零
            socklen_t peerLen = sizeof(peer); //输入输出型参数 (1)输入缓冲区大小 (2)输出实际读到的 peer 大小, 最好写明一下返回的类型

            //读取数据
            char readBuff[buffSize] = { 0 }; //初始化为 0, 后续就不用添加 '\0' 了
            uint16_t cli_port = 0;
            std::string cli_ip;
            if (recvfrom(_sock, readBuff, sizeof(readBuff) - 1, 0, (struct sockaddr*)&peer, &peerLen) > 0) //读取成功
            {
                //读取到数据后把客户端发送的数据打印出来, 包括客户端的 ip 和 port
                cli_port = ntohs(peer.sin_port); //需要反序列
                cli_ip = inet_ntoa(peer.sin_addr); //反序列后转化为点分十进制字符串(还有其他类似的接口)
                std::cout << "ip:[" << cli_ip << "] port:[" << cli_port << "]" << " sad:" << readBuff << std::endl;
                //_log.LogMessage(NORMAL, "server read done ... %s %d", __FILE__, __LINE__);
            }
            else //读取失败
            {
                _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
                exit(-1);
            }

            //分析数据, 添加用户
            char key[64] = { 0 }; //初始化为 0, 后续就不用添加 '\0' 了
            snprintf(key, sizeof(key), "{%s-%u}", cli_ip.c_str(), cli_port);

            auto it = _users.find(key);
            if (it == _users.end()) //若用户不存在用户列表中, 则添加进用户表中作记录
            {
                _log.LogMessage(NORMAL, "add new user %s, %s %d", key, __FILE__, __LINE__);
                _users[key] = peer;
            }

            //群聊广播推送
            for (auto& iter : _users)
            {
                std::string sendMessage = key;
                sendMessage += "# ";
                sendMessage += readBuff;
                if (sendto(_sock, sendMessage.c_str(), sendMessage.size(), 0, (struct sockaddr*)&(iter.second), peerLen) >= 0) //写回成功
                {
                    _log.LogMessage(NORMAL, "push message to %s, server write done... %s %d", iter.first.c_str(), __FILE__, __LINE__);
                }
                else //写回失败
                {
                    _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
                    exit(-1);
                }
            }
        }
    }


private:
    std::string _ip;
    uint16_t _port;
    int _sock;
    std::unordered_map<std::string, struct sockaddr_in> _users; //存储不同<客户端, 套接字信息>
    Log _log;
};
//udp_server.cpp(群体聊天程序)

#include <iostream>
#include <memory>
#include <string>
#include <cstdlib>
#include "udp_server.hpp"

//使用手册
static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip(optional)] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //服务器启动指令 ./udp_server ip port
{
    //1.检查命令是否合法, 否则打印使用手册
    uint16_t port;
    std::string ip;
    if (argc == 2)
    {
        //2.分割出 PORT 信息
        port = atoi(argv[1]);
    }
    else if (argc == 3)
    {
        //3.分割出 IP 和 PORT 信息
        ip = argv[1];
        port = atoi(argv[2]);
    }
    else
    {
        Usage(argv[0]);
        exit(-1);
    }

    //4.使用智能指针托管服务端指针
    std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));

    //5.启动服务端服务
    svr->Start();

    return 0;
}
//udp_client.cpp(群体聊天程序)

#include <string>
#include <iostream>
#include <memory>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>
#include <pthread.h> 
#include "log.hpp"
#include "thread.hpp"

const int readBuffSize = 1024;
std::string serverIp;
uint16_t serverPort = 0;
Log log;

//使用手册
static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip] [port]\n" << std::endl;
}

//线程方法: 写方法
static void UdpSend(int* psock)
{
    struct sockaddr_in server; //对应服务端套接字信息变量
    bzero(&server, sizeof(server)); //比特位置零
    server.sin_family = AF_INET; //设置协议家族/域
    server.sin_addr.s_addr = inet_addr(serverIp.c_str()); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
    server.sin_port = htons(serverPort); //设置 port(两字节), 主机序列转为网络序列

    while (true)
    {
        std::string message;
        std::cerr << "clien >: "; //这里只是利用 cerr 方便重定向而已, 虽然不太合理, 但是有效
        std::getline(std::cin, message); //用户输入想要发送的数据
        if (message == "exit")
            break;
        if (sendto(*psock, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server)) >= 0) //写入成功
        {
            //LogMessage(NORMAL, "client write done... %s %d", __FILE__, __LINE__);
        }
        else //写入失败
        {
            log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }
    }
}

//线程方法: 读方法
static void UdpRecv(int* psock)
{
    while (true)
    {
        char readBuff[readBuffSize] = { 0 }; //第一次向服务器发送数据时, 操作系统会在 sendto() 执行过程程中自动为服务端绑定

        //实际上我们已经有了服务端的 ip 和 port 的信息了, 这里我只是为了占位和调用规范写的, 在本项目中可以不怎么处理(有时候客户端可以是其他端的客户端)
        struct sockaddr_in temp;
        bzero(&temp, sizeof(temp)); //比特位置零
        socklen_t len = sizeof(temp);
        if (recvfrom(*psock, readBuff, sizeof(readBuff) - 1, 0, (struct sockaddr*)&temp, &len) >= 0)
        {
            std::cout << "server echo >: " << readBuff << std::endl;
        }
        else //读取失败
        {
            log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }
    }
}

int main(int argc, char* argv[]) //./udp_client 127.0.0.1 8080
{
    //1.检查命令行输入
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(-1);
    }

    //2.创建套接字
    int sock = socket(AF_INET, SOCK_DGRAM, 0); //套接字改为全局变量, 方便多线程编写代码
    if (sock < 0) //补充一个小知识点:也有人把 AF_INET 换成等价的 PF_INET
    {
        log.LogMessage(FATAL, "%d %s %s %d", errno, strerror(errno), __FILE__, __LINE__);
        exit(-1);
    }

    //3.绑定套接字
    //交给操作系统自动 bind()

    //4.存储需要访问服务端的 ip 和 port
    serverIp = argv[1];
    serverPort = atoi(argv[2]);

    //5.创建写线程和读线程
    std::unique_ptr<Thread<int*>> sender(new Thread<int*>("client_send_thread", UdpSend, &sock));
    std::unique_ptr<Thread<int*>> recver(new Thread<int*>("client_recv_thread", UdpRecv, &sock));

    //6.向服务端发送消息和读取消息
    sender->Start();
    recver->Start();

    //TODO: 这里有点小问题: 客户端写线程结束了, 而客户端读线程仍在执行一个死循环, 导致 Join() 的时候使得主线程加入阻塞
    
    //7.销毁线程
    sender->Join();
    recver->Join();

    //8.关闭套接字
    close(sock);
    log.LogMessage(NORMAL, "close udp client done ... %s %d", __FILE__, __LINE__);
    std::cout << "bye~" << std::endl;

    return 0;
}

在运行代码的时候,聊天记录可能会有点混乱,可以借助一个管道文件,让客户端可以在一个会话终端中发送消息,在另外一个会话终端实时显示消息(用 cerr 打印),也就是下面图片的效果(可能有点小)。

到这里可以注意到,无论读线程还是写线程,都是对同一个 sock 进行的,sock 可以看作一个文件,因此 UDP 本身就是全双工的,可以同时进行收发并且不受影响。

注意:上述代码在客户端退出的时候是这里有点小问题: 客户端写线程结束了, 而客户端读线程仍在执行一个死循环, 导致 Join() 的时候使得主线程加入阻塞。您可以稍微改一下,这里我为了不加长代码就偷了下懒…

3.3.1.4.Windows 客户端

我们的服务器部署/运行在 linux 下,但是怎么做到让 Windows 的客户端也能进行通信呢?实际上在 Windows 下关于 UDP 在这方面的接口是类似(甚至可以说是完全一样的,只是某些预先处理操作不太一样)。

//udp_client.cc(Windows 也适用的客户端版本)
#pragma warning(disable:4996)
#include <WinSock2.h>
#include <iostream>
#include <string>
#include <cstdlib>

#pragma comment(lib,"ws2_32.lib") //这一句基本是 Windows 下套接字编程的固定用法,Windows 独有的


int main(int argc, char* argv[])
{
    std::string serverip = argv[1]; //服务端程序所占 ip 地址
    uint16_t serverport = atoi(argv[1]); //服务端程序所占 port 号

	WSADATA WSAData;
	WORD sockVersion = MAKEWORD(2, 2); //设置库版本,Windows 独有的
	if (WSAStartup(sockVersion, &WSAData) != 0) //根据版本加载对应的库,Windows 独有的
		return 0;

	//初始套接字
	SOCKET clientSocket = socket(AF_INET, SOCK_DGRAM, 0);
	if (INVALID_SOCKET == clientSocket)
	{
		std::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());

	while (true)
	{
		//向服务端发送
		std::string message;
		std::cout << "client >: ";
		std::getline(std::cin, message);
		if (message == "exit")
			break;
		if (sendto(clientSocket, message.c_str(), (int)message.size(), 0, (sockaddr*)&dstAddr, (int)sizeof(dstAddr)) >= 0)
		{
			//读取正确...
		}
		else
		{
			std::cout << "sendto() error!";
			exit(10);
		}

		//从服务端读取
		char buffer[1024] = { 0 };
		struct sockaddr_in temp;
		int len = sizeof(temp);
		if (recvfrom(clientSocket, buffer, sizeof buffer, 0, (sockaddr*)&temp, &len) >= 0)
		{
			std::cout << "server echo >: " << buffer << std::endl;
		}
		else
		{
			std::cout << "recvfrom() error!";
			exit(20);
		}
	}

	closesocket(clientSocket); //关闭文件描述符,windows 独有的
	WSACleanup(); //库的清理,windows 独有的

	return 0;
}

注意:如果发现无法向服务器传输数据,很可能是您云服务器的防火墙问题(防火墙可以让云服务器的某些端口对外进行开放)。

补充:您还可以结合我们之前写的生产者消费者模型,把服务端的读和写进行分离,一个线程从客户端接收到消息存储到 std::queue<std::string> messageQueue; 中,再由另外一个进程从队列中拿数据进行发送。不过这里我暂时不写,您有时间可以试一下,我将在后续的 TCP 编程中演示。

3.3.2.TCP 套接字编程应用

客户端申请连接服务端
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
client绑定套接字
int socket(int domain, int type, int protocol);
OS 自动在 connect() 时随机绑定,无需 bind()
返回服务套接字等待客户端连接
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
设置为监听套接字
int listen(int socket, int backlog);
server绑定套接字
int socket(int domain, int type, int protocol);
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
TCP 下的套接字编程
服务端
客户端
read:read()、recv()/write:write()、send()
3.3.2.1.通讯基本程序

TCP 的网络编程要比 UDP 的网络多一些前要步骤,并且可以直接使用文件 IO 接口进行通信,我们先来大概理一下调用框架和服务端的启动逻辑。

# makefile(基本框架)

.PHONY:all
all:tcp_client tcp_server

tcp_client:tcp_client.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
tcp_server:tcp_server.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
	
.PHONY:clean
clean:
	rm -rf tcp_client tcp_server log_dir
//log.hpp(基本框架)

/* 使用方法
Log log = Log(bool debugShow = true,    //选择是否显示 DEBUG 等级的日志消息
    std::string writeMode = "SCREEN",   //选择日志的打印方式
    std::string logFileName = "log"     //选择日志的文件名称
);
log.WriteModeEnable();      //中途可以修改日志的打印方式
log.LogMessage(DEBUG | NORMAL | WARNING | ERROR | FATAL, "%s %d", __FILE__, __LINE__));     //打印日志
*/

#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>

//日志级别
#define DEBUG 0     //调试
#define NORMAL 1    //正常(或者叫 INFO)
#define WARNING 2   //警告
#define ERROR 3     //错误
#define FATAL 4     //致命

enum WriteMode
{
    SCREEN = 5,
    ONE_FILE,
    CLASS_FILE
};

const char* gLevelMap[] = {
    "DEBUG",    //debug 模式
    "NORMAL",   //正常(或者叫 INFO)
    "WARNING",  //警告
    "ERROR",    //非致命错误
    "FATAL"     //严重错误
};

const std::string logdir = "log_dir";

//日志功能主要有:日志等级、发送时间、日志内容、代码行数、运行用户
class Log
{
private:
    void __WriteLogToOneFile(std::string logFileName, const std::string& message)
    {
        std::ofstream out(logFileName, std::ios::app);
        if (!out.is_open())
            return;
        out << message;
        out.close();
    }
    
    void __WriteLogToClassFile(const int& level, const std::string& message)
    {
        std::string logFileName = "./";
        logFileName += logdir;
        logFileName += "/";
        logFileName += _logFileName;
        logFileName += "_";
        logFileName += gLevelMap[level];

        __WriteLogToOneFile(logFileName, message);
    }
    
    void _WriteLog(const int& level, const std::string& message)
    {
        switch (_writeMode)
        {
        case SCREEN: //向屏幕输出
            std::cout << message;
            break;
        case ONE_FILE: //向单个日志文件输出
            __WriteLogToOneFile("./" + logdir + "/" + _logFileName, message);
            break;
        case CLASS_FILE: //向多个日志文件输出
            __WriteLogToClassFile(level, message);
            break;
        default:
            std::cout << "write mode error!!!" << std::endl;
            break;
        }
    }


public:
    //构造函数
    // debugShow 为是否显示 debug 消息, writeMode 为日志打印模式, logFileName 为日志文件名
    Log(bool debugShow = true, const WriteMode& writeMode = SCREEN, std::string logFileName = "log")
        : _debugShow(debugShow), _writeMode(writeMode), _logFileName(logFileName)
    {
        mkdir(logdir.c_str(), 0775); //创建目录
    }

    //调整日志打印方式
    void WriteModeEnable(const WriteMode& mode)
    {
        _writeMode = mode;
    }

    //拼接日志消息并且输出
    void LogMessage(const int& level, const char* format, ...)
    {
        //1.若不是 debug 模式, 且 level == DEBUG 则不做任何事情
        if (_debugShow == false && level == DEBUG)
            return;

        //2.收集日志标准部分信息
        char stdBuffer[1024];
        time_t timestamp = time(nullptr); //获得时间戳
        struct tm* local_time = localtime(&timestamp); //将时间戳转换为本地时间

        snprintf(stdBuffer, sizeof stdBuffer, "[%s][pid:%s][%d-%d-%d %d:%d:%d]",
            gLevelMap[level],
            std::to_string(getpid()).c_str(),
            local_time->tm_year + 1900, local_time->tm_mon + 1, local_time->tm_mday,
            local_time->tm_hour, local_time->tm_min, local_time->tm_sec
        );

        //3.收集日志自定义部分信息
        char logBuffer[1024];
        va_list args; //声明可变参数列表, 实际时一个 char* 类型
        va_start(args, format); //初始化可变参数列表
        vsnprintf(logBuffer, sizeof logBuffer, format, args); //int vsnprintf(char *str, size_t size, const char *format, va_list ap); 是一个可变参数函数, 将格式化后的字符串输出到缓冲区中。类似带 v 开头的可变参数函数有很多
        va_end(args); //清理可变参数列表, 类似 close() 和 delete

        //4.拼接为一个完整的消息
        std::string message;
        message += "--> 标准日志:"; message += stdBuffer;
        message += "\t 用户日志:"; message += logBuffer;
        message += "\n";

        //5.打印日志消息
        _WriteLog(level, message);
    }


private:
    bool _debugShow;
    WriteMode _writeMode;
    std::string _logFileName;
};
//tcp_server.hpp(基本框架)

/* 使用方法
std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));
svr->Start();
*/

#pragma once
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "log.hpp"

#define DEBUG_SHOW
const int buffSize = 1024;
const int g_backlog = 20; //这个数值一般不会太大, 也不会太小, 但是我们以后提及 TCP 报文的时候再来详细讲解

class TcpServer
{
public:
    //初始服务器
    TcpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _sock(-1)
    {
        //1.创建套接字
        if ((_sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) //注意换成流式类型, 而不是数据报类型
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }
        _log.LogMessage(NORMAL, "sock: %d, %s %d", _sock, __FILE__, __LINE__); //检验套接字是不是真的是一个文件描述符, 若是理应返回 3

        //2.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节), 主机序列转为网络序列
        if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //3.设置套接字为监听状态
        /* 因为 TCP 是面向连接的, 因此正确通信时需要建立连接, 而这就注定一个服务端必须处于一个等待连接的状态 */
        if (listen(_sock, g_backlog) < 0) //设置 _sock 为监听状态
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //4.提示初始化成功
        _log.LogMessage(NORMAL, "init tcp server done... %s %d", __FILE__, __LINE__);
    }

    //运行服务器
    void Start()
    {
        while (true)
        {
            usleep(1000);
        }
    }

    //销毁服务器
    ~TcpServer()
    {
        //...
    }


private:
    std::string _ip;
    uint16_t _port;
    int _sock;
    Log _log;
};
//tcp_server.cpp(基本框架)

#include <iostream>
#include <memory>
#include <string>
#include <cstdlib>
#include "tcp_server.hpp"

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip(optional)] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //服务器启动指令 ./udp_server ip port
{
    //1.检查命令是否合法, 否则打印使用手册
    uint16_t port;
    std::string ip;
    if (argc == 2)
    {
        //2.分割出 PORT 信息
        port = atoi(argv[1]);
    }
    else if (argc == 3)
    {
        //3.分割出 IP 和 PORT 信息
        ip = argv[1];
        port = atoi(argv[2]);
    }
    else
    {
        Usage(argv[0]);
        exit(-1);
    }

    //4.使用智能指针托管服务端指针
    std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));

    //5.启动服务端服务
    svr->Start();

    return 0;
}
//tcp_client.cpp(基本框架)

#include "log.hpp"

int main(int argc, char* argv[]) //./udp_client 127.0.0.1 8080
{
    return 0;
}

这里关于 PCB 服务端的类内就可以看到进行了套接字监听状态的设置,并且启动服务端后可以打印出套接字的数值为 3

刚好就在其他标准文件的文件描述符之后,这也是在间接暗示您:套接字读写和可以和文件读写一样操作,两者几乎在使用上是等价的。

我们如果多次启动上述进程,就会发现无法正常启动,这是因为 _sock 只能被绑定一次,只能有一个进程绑定一个端口号。

之前我们使用 netstat -anup 查看的是 udp 服务,而这次我们可以使用 netstat -antptcp 服务(-l 选项还可以限定处于监听状态的 TCP 进程)。

那此时的套接字是否可以进行读写服务了呢?还不行,除了设置为监听状态,还需要做进一步的修改:

# makefile(通讯基本程序)

.PHONY:all
all:tcp_client tcp_server

tcp_client:tcp_client.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
tcp_server:tcp_server.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
	
.PHONY:clean
clean:
	rm -rf tcp_client tcp_server log_dir
//log.hpp(通讯基本程序)

/* 使用方法
Log log = Log(bool debugShow = true,    //选择是否显示 DEBUG 等级的日志消息
    std::string writeMode = "SCREEN",   //选择日志的打印方式
    std::string logFileName = "log"     //选择日志的文件名称
);
log.WriteModeEnable();      //中途可以修改日志的打印方式
log.LogMessage(DEBUG | NORMAL | WARNING | ERROR | FATAL, "%s %d", __FILE__, __LINE__));     //打印日志
*/

#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>

//日志级别
#define DEBUG 0     //调试
#define NORMAL 1    //正常(或者叫 INFO)
#define WARNING 2   //警告
#define ERROR 3     //错误
#define FATAL 4     //致命

enum WriteMode
{
    SCREEN = 5,
    ONE_FILE,
    CLASS_FILE
};

const char* gLevelMap[] = {
    "DEBUG",    //debug 模式
    "NORMAL",   //正常(或者叫 INFO)
    "WARNING",  //警告
    "ERROR",    //非致命错误
    "FATAL"     //严重错误
};

const std::string logdir = "log_dir";

//日志功能主要有:日志等级、发送时间、日志内容、代码行数、运行用户
class Log
{
private:
    void __WriteLogToOneFile(std::string logFileName, const std::string& message)
    {
        std::ofstream out(logFileName, std::ios::app);
        if (!out.is_open())
            return;
        out << message;
        out.close();
    }
    
    void __WriteLogToClassFile(const int& level, const std::string& message)
    {
        std::string logFileName = "./";
        logFileName += logdir;
        logFileName += "/";
        logFileName += _logFileName;
        logFileName += "_";
        logFileName += gLevelMap[level];

        __WriteLogToOneFile(logFileName, message);
    }
    
    void _WriteLog(const int& level, const std::string& message)
    {
        switch (_writeMode)
        {
        case SCREEN: //向屏幕输出
            std::cout << message;
            break;
        case ONE_FILE: //向单个日志文件输出
            __WriteLogToOneFile("./" + logdir + "/" + _logFileName, message);
            break;
        case CLASS_FILE: //向多个日志文件输出
            __WriteLogToClassFile(level, message);
            break;
        default:
            std::cout << "write mode error!!!" << std::endl;
            break;
        }
    }


public:
    //构造函数
    // debugShow 为是否显示 debug 消息, writeMode 为日志打印模式, logFileName 为日志文件名
    Log(bool debugShow = true, const WriteMode& writeMode = SCREEN, std::string logFileName = "log")
        : _debugShow(debugShow), _writeMode(writeMode), _logFileName(logFileName)
    {
        mkdir(logdir.c_str(), 0775); //创建目录
    }

    //调整日志打印方式
    void WriteModeEnable(const WriteMode& mode)
    {
        _writeMode = mode;
    }

    //拼接日志消息并且输出
    void LogMessage(const int& level, const char* format, ...)
    {
        //1.若不是 debug 模式, 且 level == DEBUG 则不做任何事情
        if (_debugShow == false && level == DEBUG)
            return;

        //2.收集日志标准部分信息
        char stdBuffer[1024];
        time_t timestamp = time(nullptr); //获得时间戳
        struct tm* local_time = localtime(&timestamp); //将时间戳转换为本地时间

        snprintf(stdBuffer, sizeof stdBuffer, "[%s][pid:%s][%d-%d-%d %d:%d:%d]",
            gLevelMap[level],
            std::to_string(getpid()).c_str(),
            local_time->tm_year + 1900, local_time->tm_mon + 1, local_time->tm_mday,
            local_time->tm_hour, local_time->tm_min, local_time->tm_sec
        );

        //3.收集日志自定义部分信息
        char logBuffer[1024];
        va_list args; //声明可变参数列表, 实际时一个 char* 类型
        va_start(args, format); //初始化可变参数列表
        vsnprintf(logBuffer, sizeof logBuffer, format, args); //int vsnprintf(char *str, size_t size, const char *format, va_list ap); 是一个可变参数函数, 将格式化后的字符串输出到缓冲区中。类似带 v 开头的可变参数函数有很多
        va_end(args); //清理可变参数列表, 类似 close() 和 delete

        //4.拼接为一个完整的消息
        std::string message;
        message += "--> 标准日志:"; message += stdBuffer;
        message += "\t 用户日志:"; message += logBuffer;
        message += "\n";

        //5.打印日志消息
        _WriteLog(level, message);
    }


private:
    bool _debugShow;
    WriteMode _writeMode;
    std::string _logFileName;
};
//tcp_server.hpp(通讯基本程序)

/* 使用方法
std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));
svr->Start();
*/

#pragma once
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "log.hpp"

const int buffSize = 1024;
const int g_backlog = 20; //这个数值一般不会太大, 也不会太小, 但是我们以后提及 TCP 报文的时候再来详细讲解

static void Service(const int& serviceSock, const std::string& client_ip, const uint16_t& client_port)
{
    Log log;
    while (true)
    {
        //直接套用文件 IO 的接口 read() 和 write()

        //从服务端读取数据
        char readBuffer[1024] = { 0 };
        ssize_t s = read(serviceSock, readBuffer, sizeof(readBuffer) - 1);
        if (s > 0)
        {
            std::cout << "[" << client_ip << ":" << client_port << "] echo " << readBuffer << std::endl;
        }
        else if (s == 0) //代表对端关闭了
        {
            log.LogMessage(NORMAL, "write close, me too! %s %d", __FILE__, __LINE__);
            break;
        }
        else //代表读取出现异常
        {
            log.LogMessage(FATAL, "read() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //写入数据到服务端
        write(serviceSock, readBuffer, strlen(readBuffer)); //这里读到什么数据就直接传送回去
    }
}

class TcpServer
{
public:
    //初始服务器
    TcpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _ListenSock(-1)
    {
        //1.创建套接字
        if ((_ListenSock = socket(AF_INET, SOCK_STREAM, 0)) < 0) //注意换成流式类型, 而不是数据报类型
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }
        _log.LogMessage(NORMAL, "sock: %d, %s %d", _ListenSock, __FILE__, __LINE__); //检验套接字是不是真的是一个文件描述符, 若是理应返回 3

        //2.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节), 主机序列转为网络序列
        if (bind(_ListenSock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //3.设置套接字为监听状态
        /* 因为 TCP 是面向连接的, 因此正确通信时需要建立连接, 而这就注定一个服务端必须处于一个等待连接的状态 */
        if (listen(_ListenSock, g_backlog) < 0) //设置 _ListenSock 为监听状态
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //4.提示初始化成功
        _log.LogMessage(NORMAL, "init tcp server done... %s %d", __FILE__, __LINE__);
    }

    //运行服务器
    void Start()
    {
        while (true)
        {
            //1.获取和客户端的连接(如果没有任何一个客户端连接服务端,服务端就会陷入阻塞状态)
            struct sockaddr_in src;
            socklen_t len = sizeof(src);
            int serviceSock = accept(_ListenSock, (struct sockaddr*)&src, &len); //获取 IO 用的套接字(这个套接字才是做 IO 服务的套接字, 而之前申请的 _ListenSock 应该叫做监听套接字)
            /* _ListenSock vs serviceSock ? 注意这里的 _ListenSock 其实就是之前代码中的 _sork,不过我换了一个更加简介的名字 */
            /* 这两个套接字的区别就在于:_ListenSock 是提供揽客的揽客员,serviceSock 是提供服务的服务员 */
            /* _ListenSock 作用是获取新的连接,serviceSock 作用是提供 IO 服务,这点和 UDP 编程很不一样 */

            if (serviceSock < 0) //若申请
            {
                _log.LogMessage(WARNING, "accept() error... , %s %d", __FILE__, __LINE__);
                continue; //即使获取不到新的连接也不影响,重新循环尝试链接即可
            }

            std::string client_ip = inet_ntoa(src.sin_addr);
            uint16_t client_port = ntohs(src.sin_port);
            _log.LogMessage(NORMAL, "link success, the \"service sock\" is [%d], the \"client sock\" is [%s:%d], %s %d", serviceSock, client_ip.c_str(), client_port, __FILE__, __LINE__); //提示连接成功

            //2.开始进行通信服务
            //version 1 -- 单进程循环版本
            Service(serviceSock, client_ip, client_port);

            //3.关闭服务套接字
            close(serviceSock);
        }
    }

    //销毁服务器
    ~TcpServer()
    {
        if (_ListenSock < 0)
            close(_ListenSock);
    }


private:
    std::string _ip;
    uint16_t _port;
    int _ListenSock; //int _sock; //我们更愿意叫这个套接字为监听套接字,因此我们改一下名字
    Log _log;
};
//tcp_server.cpp(通讯基本程序)

#include <iostream>
#include <memory>
#include <string>
#include <cstdlib>
#include "tcp_server.hpp"

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip(optional)] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //服务器启动指令 ./udp_server ip port
{
    //1.检查命令是否合法, 否则打印使用手册
    uint16_t port;
    std::string ip;
    if (argc == 2)
    {
        //2.分割出 PORT 信息
        port = atoi(argv[1]);
    }
    else if (argc == 3)
    {
        //3.分割出 IP 和 PORT 信息
        ip = argv[1];
        port = atoi(argv[2]);
    }
    else
    {
        Usage(argv[0]);
        exit(-1);
    }

    //4.使用智能指针托管服务端指针
    std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));

    //5.启动服务端服务
    svr->Start();

    return 0;
}
//tcp_client.cpp(通讯基本程序)

#include "log.hpp"

int main(int argc, char* argv[]) //./udp_client 127.0.0.1 8080
{
    return 0;
}

这里我们先不写客户端,尝试使用一个快速启动客户端的工具 telnet(可能需要使用 sudo yum install 下载)。

补充:Telnet 是一种用于在网络上进行远程登录的协议,它允许用户通过网络连接到远程主机,并在远程主机上执行命令。Telnet 协议使用 TCP 协议作为传输层协议。

用户可以通过命令行界面(CLI)或者图形用户界面(GUI)的 Telnet 客户端连接到远程主机。一旦连接建立,用户就可以在远程主机上执行命令、浏览文件、编辑文本等操作,就好像他们直接坐在那台远程主机的控制台前一样。

# 使用 telnet
# 服务端程序使用 ./tcp_server 8080 启动之后,使用下述命令
$ telnet 127.0.0.1 8080
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'. # ^] 代表建立已和服务端链接, 这里提示使用 [ctrl+]] 快捷键进入 telnet 命令行
telnet> 
nihao # 输入后回车向服务端发送数据
nihao # 就会在客户端里回显复制结果

使用 [ctrl+]] 再回车两下,此时就可以向服务端发送数据。如果想要退出就可以使用 [ctrl+]] 打开 telnet 的对话框,直接输入字符回车后就会向对于的服务器发送数据,而再次使用快捷键 [ctrl+]] 输入 quit 即可退出该工具。

但是这个服务端还有些问题,一次只能处理一个客户端(其他客户端只有等待当前链接的客户端断开链接才可以连接),一旦有其他客户端想要进行连接和服务时,必须等待第一个客户端退出时,才能依次处理新的客户端。

这主要是因为调用函数 Service() 的内部实际是一个死循环的读写代码,由于 TCP 通信必须要进行连接。而代码流在调用 Service() 时,控制权从 main() 中交给了 Service(),导致其他客户端连接时必须等待第一个客户端退出时才能继续进行新的连接和通信。

因此我们就需要把 main() 中的循环代码修改为多进程版本的代码,让连接部分代码和服务部分代码由两个进程来执行。

3.3.2.2.多进程死循环服务端

让我们尝试修改一下服务端的代码,使其支持多线程。

# makefile(多进程死循环服务端)

.PHONY:all
all:tcp_client tcp_server

tcp_client:tcp_client.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
tcp_server:tcp_server.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
	
.PHONY:clean
clean:
	rm -rf tcp_client tcp_server log_dir
//log.hpp(多进程死循环服务端)

/* 使用方法
Log log = Log(bool debugShow = true,    //选择是否显示 DEBUG 等级的日志消息
    std::string writeMode = "SCREEN",   //选择日志的打印方式
    std::string logFileName = "log"     //选择日志的文件名称
);
log.WriteModeEnable();      //中途可以修改日志的打印方式
log.LogMessage(DEBUG | NORMAL | WARNING | ERROR | FATAL, "%s %d", __FILE__, __LINE__));     //打印日志
*/

#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>

//日志级别
#define DEBUG 0     //调试
#define NORMAL 1    //正常(或者叫 INFO)
#define WARNING 2   //警告
#define ERROR 3     //错误
#define FATAL 4     //致命

enum WriteMode
{
    SCREEN = 5,
    ONE_FILE,
    CLASS_FILE
};

const char* gLevelMap[] = {
    "DEBUG",    //debug 模式
    "NORMAL",   //正常(或者叫 INFO)
    "WARNING",  //警告
    "ERROR",    //非致命错误
    "FATAL"     //严重错误
};

const std::string logdir = "log_dir";

//日志功能主要有:日志等级、发送时间、日志内容、代码行数、运行用户
class Log
{
private:
    void __WriteLogToOneFile(std::string logFileName, const std::string& message)
    {
        std::ofstream out(logFileName, std::ios::app);
        if (!out.is_open())
            return;
        out << message;
        out.close();
    }
    
    void __WriteLogToClassFile(const int& level, const std::string& message)
    {
        std::string logFileName = "./";
        logFileName += logdir;
        logFileName += "/";
        logFileName += _logFileName;
        logFileName += "_";
        logFileName += gLevelMap[level];

        __WriteLogToOneFile(logFileName, message);
    }
    
    void _WriteLog(const int& level, const std::string& message)
    {
        switch (_writeMode)
        {
        case SCREEN: //向屏幕输出
            std::cout << message;
            break;
        case ONE_FILE: //向单个日志文件输出
            __WriteLogToOneFile("./" + logdir + "/" + _logFileName, message);
            break;
        case CLASS_FILE: //向多个日志文件输出
            __WriteLogToClassFile(level, message);
            break;
        default:
            std::cout << "write mode error!!!" << std::endl;
            break;
        }
    }


public:
    //构造函数
    // debugShow 为是否显示 debug 消息, writeMode 为日志打印模式, logFileName 为日志文件名
    Log(bool debugShow = true, const WriteMode& writeMode = SCREEN, std::string logFileName = "log")
        : _debugShow(debugShow), _writeMode(writeMode), _logFileName(logFileName)
    {
        mkdir(logdir.c_str(), 0775); //创建目录
    }

    //调整日志打印方式
    void WriteModeEnable(const WriteMode& mode)
    {
        _writeMode = mode;
    }

    //拼接日志消息并且输出
    void LogMessage(const int& level, const char* format, ...)
    {
        //1.若不是 debug 模式, 且 level == DEBUG 则不做任何事情
        if (_debugShow == false && level == DEBUG)
            return;

        //2.收集日志标准部分信息
        char stdBuffer[1024];
        time_t timestamp = time(nullptr); //获得时间戳
        struct tm* local_time = localtime(&timestamp); //将时间戳转换为本地时间

        snprintf(stdBuffer, sizeof stdBuffer, "[%s][pid:%s][%d-%d-%d %d:%d:%d]",
            gLevelMap[level],
            std::to_string(getpid()).c_str(),
            local_time->tm_year + 1900, local_time->tm_mon + 1, local_time->tm_mday,
            local_time->tm_hour, local_time->tm_min, local_time->tm_sec
        );

        //3.收集日志自定义部分信息
        char logBuffer[1024];
        va_list args; //声明可变参数列表, 实际时一个 char* 类型
        va_start(args, format); //初始化可变参数列表
        vsnprintf(logBuffer, sizeof logBuffer, format, args); //int vsnprintf(char *str, size_t size, const char *format, va_list ap); 是一个可变参数函数, 将格式化后的字符串输出到缓冲区中。类似带 v 开头的可变参数函数有很多
        va_end(args); //清理可变参数列表, 类似 close() 和 delete

        //4.拼接为一个完整的消息
        std::string message;
        message += "--> 标准日志:"; message += stdBuffer;
        message += "\t 用户日志:"; message += logBuffer;
        message += "\n";

        //5.打印日志消息
        _WriteLog(level, message);
    }


private:
    bool _debugShow;
    WriteMode _writeMode;
    std::string _logFileName;
};
//tcp_server.hpp(多进程死循环服务端)

/* 使用方法
std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));
svr->Start();
*/

#pragma once
#include <cerrno>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>
#include "log.hpp"

const int buffSize = 1024;
const int g_backlog = 20; //这个数值一般不会太大, 也不会太小, 但是我们以后提及 TCP 报文的时候再来详细讲解

static void Service(const int& serviceSock, const std::string& client_ip, const uint16_t& client_port)
{
    Log log;

    while (true)
    {
        //直接套用文件 IO 的接口 read() 和 write()

        //从服务端读取数据
        char readBuffer[1024] = { 0 };
        ssize_t s = read(serviceSock, readBuffer, sizeof(readBuffer) - 1);
        if (s > 0)
        {
            std::cout << "[" << client_ip << ":" << client_port << "] echo " << readBuffer << std::endl;
        }
        else if (s == 0) //代表对端关闭了
        {
            log.LogMessage(NORMAL, "write close, me too! %s %d", __FILE__, __LINE__);
            break;
        }
        else //代表读取出现异常
        {
            log.LogMessage(FATAL, "read() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //写入数据到服务端
        write(serviceSock, readBuffer, strlen(readBuffer)); //这里读到什么数据就直接传送回去
    }
}

class TcpServer
{
public:
    //初始服务器
    TcpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _ListenSock(-1)
    {
        //1.创建套接字
        if ((_ListenSock = socket(AF_INET, SOCK_STREAM, 0)) < 0) //注意换成流式类型, 而不是数据报类型
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }
        _log.LogMessage(NORMAL, "sock: %d, %s %d", _ListenSock, __FILE__, __LINE__); //检验套接字是不是真的是一个文件描述符, 若是理应返回 3

        //2.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节), 主机序列转为网络序列
        if (bind(_ListenSock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //3.设置套接字为监听状态
        /* 因为 TCP 是面向连接的, 因此正确通信时需要建立连接, 而这就注定一个服务端必须处于一个等待连接的状态 */
        if (listen(_ListenSock, g_backlog) < 0) //设置 _ListenSock 为监听状态
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //4.提示初始化成功
        _log.LogMessage(NORMAL, "init tcp server done... %s %d", __FILE__, __LINE__);
    }

    //运行服务器
    void Start()
    {
        signal(SIGCHLD, SIG_IGN); //父进程由程序员主动设置忽略,因此子进程退出时会自动释放自己的僵尸状态

        while (true)
        {
            //1.获取和客户端的连接(如果没有任何一个客户端连接服务端,服务端就会陷入阻塞状态)
            struct sockaddr_in src;
            socklen_t len = sizeof(src);
            int serviceSock = accept(_ListenSock, (struct sockaddr*)&src, &len); //获取 IO 用的套接字(这个套接字才是做 IO 服务的套接字, 而之前申请的 _ListenSock 应该叫做监听套接字)
            /* _ListenSock vs serviceSock ? 注意这里的 _ListenSock 其实就是之前代码中的 _sork, 不过我换了一个更加简介的名字 */
            /* 这两个套接字的区别就在于:_ListenSock 是提供揽客的揽客员, serviceSock 是提供服务的服务员 */
            /* _ListenSock 作用是获取新的连接, serviceSock 作用是提供 IO 服务, 这点和 UDP 编程很不一样 */

            if (serviceSock < 0) //若申请
            {
                _log.LogMessage(WARNING, "accept() error... , %s %d", __FILE__, __LINE__);
                continue; //即使获取不到新的连接也不影响,重新循环尝试链接即可
            }

            std::string client_ip = inet_ntoa(src.sin_addr);
            uint16_t client_port = ntohs(src.sin_port);
            _log.LogMessage(NORMAL, "link success, the \"service sock\" is [%d], the \"client sock\" is [%s:%d], %s %d", serviceSock, client_ip.c_str(), client_port, __FILE__, __LINE__); //提示连接成功

            //2.开始进行通信服务
            //version 1 -- 单进程循环版本
            //Service(serviceSock, client_ip, client_port);

            //version 2 -- 多进程循环版本
            pid_t id = fork(); //创建子进程
            assert(id != -1);
            if (id == 0) //子进程, 也能看到文件描述符, 因此也可以提供服务工作, 让连接工作交给父进程
            {
                close(_ListenSock); //但是子进程无需知道监听套接字, 关闭即可, 避免文件描述符资源越来越少
                Service(serviceSock, client_ip, client_port); //执行服务任务
                close(serviceSock); //服务任务执行结束后最好关闭下服务套接字(虽然该进程自动释放后也会自动释放该套接字资源罢了, 不过我们严谨一点)
                exit(-1);
            }

            //waitpid(); //这里我不设置非阻塞等待回收, 也不设置子进程退出时信号的处理机制, 我们使用信号忽略的方式, 对应代码就在 Start() 方法的开头处

            //3.关闭服务套接字, 因为主进程不需要这个套接字, 有监听套接字就够了
            close(serviceSock);
        }
    }

    //销毁服务器
    ~TcpServer()
    {
        if (_ListenSock < 0)
            close(_ListenSock);
    }


private:
    std::string _ip;
    uint16_t _port;
    int _ListenSock; //int _sock; //我们更愿意叫这个套接字为监听套接字,因此我们改一下名字
    Log _log;
};
//tcp_server.cpp(多进程死循环服务端)

#include <iostream>
#include <memory>
#include <string>
#include <cstdlib>
#include "tcp_server.hpp"

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip(optional)] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //服务器启动指令 ./udp_server ip port
{
    //1.检查命令是否合法, 否则打印使用手册
    uint16_t port;
    std::string ip;
    if (argc == 2)
    {
        //2.分割出 PORT 信息
        port = atoi(argv[1]);
    }
    else if (argc == 3)
    {
        //3.分割出 IP 和 PORT 信息
        ip = argv[1];
        port = atoi(argv[2]);
    }
    else
    {
        Usage(argv[0]);
        exit(-1);
    }

    //4.使用智能指针托管服务端指针
    std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));

    //5.启动服务端服务
    svr->Start();

    return 0;
}
//tcp_client.cpp(多进程死循环服务端)

#include <string>
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <unistd.h>
#include "log.hpp"

const int readBuffSize = 1024;

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " [ip] [port]\n" << std::endl;
}

int main(int argc, char* argv[]) //./udp_client 127.0.0.1 8080
{
    Log log;

    //1.检查命令行输入
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(-1);
    }

    //2.创建套接字
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) //补充一个小知识点:也有人把 AF_INET 换成等价的 PF_INET
    {
        log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
        exit(-1);
    }

    //3.绑定套接字
    //但是一般不需要程序员自己 bind

    //4.存储需要访问服务端的 ip 和 port
    std::string serverIp = argv[1];
    uint16_t serverPort = atoi(argv[2]);

    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr(serverIp.c_str());
    server.sin_port = htons(serverPort);

    //5.连接服务端
    if (connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0)
    {
        log.LogMessage(FATAL, "connect() error, %s %d", __FILE__, __LINE__);
        exit(-1);
    }

    //6.向服务端发送数据
    while (true)
    {
        std::cout << "client >: ";
        std::string line;
        std::getline(std::cin, line);
        if (line == "exit")
            break;

        send(sock, line.c_str(), line.size(), 0); //向服务端发送数据

        char readBuffer[readBuffSize] = { 0 };
        ssize_t s = recv(sock, readBuffer, sizeof(readBuffer) - 1, 0);
        if (s > 0)
        {
            std::cout << "server echo:> " << readBuffer << std::endl;
        }
        else if (s == 0) //对端关闭连接
        {
            break;
        }
        else
        {
            log.LogMessage(FATAL, "recv() error, %s %d", __FILE__, __LINE__);
        }
    }

    //7.关闭套接字
    close(sock);
    log.LogMessage(NORMAL, "close udp client done... %s %d", __FILE__, __LINE__);
    std::cout << "bye~" << std::endl;

    return 0;
}

再次启用多个 telnet 客户端,或者用我们自己编写的客户端。就会发现此时就允许多个客户端连接一个服务端了。

补充:查看当前可以打开的文件标识符的最大上限。

# 查看系统能打开的文件描述符个数
$ ulimit
unlimited
[ljp@VM-8-9-centos ~]$ ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 7902
max locked memory       (kbytes, -l) unlimited
max memory size         (kbytes, -m) unlimited
open files                      (-n) 100001 # 可以看到可以打开 100001 个文件标识符
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 7902
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

而从代码上来说,TCP 所谓的面向连接,就是服务端需要 listen() 和不断的 accept(),而客户端则需要使用 connect() 进行连接。当然,这只是在代码上体现出来,相关的原理我们在后续会进行详细展开。

3.3.2.3.孤儿进程自动回收服务端

我们还有一种既能防止代码阻塞,也能保证子进程被自动回收的方案。其他代码保持不变,只修改 tcp_server.hpp 即可。

//tcp_server.hpp(孤儿进程自动回收服务端)

/* 使用方法
std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));
svr->Start();
*/

#pragma once
#include <cerrno>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include "log.hpp"

const int buffSize = 1024;
const int g_backlog = 20; //这个数值一般不会太大, 也不会太小, 但是我们以后提及 TCP 报文的时候再来详细讲解

static void Service(const int& serviceSock, const std::string& client_ip, const uint16_t& client_port)
{
    Log log;

    while (true)
    {
        //直接套用文件 IO 的接口 read() 和 write()

        //从服务端读取数据
        char readBuffer[1024] = { 0 };
        ssize_t s = read(serviceSock, readBuffer, sizeof(readBuffer) - 1);
        if (s > 0)
        {
            std::cout << "[" << client_ip << ":" << client_port << "] echo " << readBuffer << std::endl;
        }
        else if (s == 0) //代表对端关闭了
        {
            log.LogMessage(NORMAL, "write close, me too! %s %d", __FILE__, __LINE__);
            break;
        }
        else //代表读取出现异常
        {
            log.LogMessage(FATAL, "read() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //写入数据到服务端
        write(serviceSock, readBuffer, strlen(readBuffer)); //这里读到什么数据就直接传送回去
    }
}

class TcpServer
{
public:
    //初始服务器
    TcpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _ListenSock(-1)
    {
        //1.创建套接字
        if ((_ListenSock = socket(AF_INET, SOCK_STREAM, 0)) < 0) //注意换成流式类型, 而不是数据报类型
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }
        _log.LogMessage(NORMAL, "sock: %d, %s %d", _ListenSock, __FILE__, __LINE__); //检验套接字是不是真的是一个文件描述符, 若是理应返回 3

        //2.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节), 主机序列转为网络序列
        if (bind(_ListenSock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //3.设置套接字为监听状态
        /* 因为 TCP 是面向连接的, 因此正确通信时需要建立连接, 而这就注定一个服务端必须处于一个等待连接的状态 */
        if (listen(_ListenSock, g_backlog) < 0) //设置 _ListenSock 为监听状态
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //4.提示初始化成功
        _log.LogMessage(NORMAL, "init tcp server done... %s %d", __FILE__, __LINE__);
    }

    //运行服务器
    void Start()
    {
        //signal(SIGCHLD, SIG_IGN); //父进程由程序员主动设置忽略, 因此子进程退出时会自动释放自己的僵尸状态

        while (true)
        {
            //1.获取和客户端的连接(如果没有任何一个客户端连接服务端, 服务端就会陷入阻塞状态)
            struct sockaddr_in src;
            socklen_t len = sizeof(src);
            int serviceSock = accept(_ListenSock, (struct sockaddr*)&src, &len); //获取 IO 用的套接字(这个套接字才是做 IO 服务的套接字, 而之前申请的 _ListenSock 应该叫做监听套接字)
            /* _ListenSock vs serviceSock ? 注意这里的 _ListenSock 其实就是之前代码中的 _sork, 不过我换了一个更加简介的名字 */
            /* 这两个套接字的区别就在于:_ListenSock 是提供揽客的揽客员, serviceSock 是提供服务的服务员 */
            /* _ListenSock 作用是获取新的连接, serviceSock 作用是提供 IO 服务, 这点和 UDP 编程很不一样 */

            if (serviceSock < 0) //若申请
            {
                _log.LogMessage(WARNING, "accept() error... , %s %d", __FILE__, __LINE__);
                continue; //即使获取不到新的连接也不影响, 重新循环尝试链接即可
            }

            std::string client_ip = inet_ntoa(src.sin_addr);
            uint16_t client_port = ntohs(src.sin_port);
            _log.LogMessage(NORMAL, "link success, the \"service sock\" is [%d], the \"client sock\" is [%s:%d], %s %d", serviceSock, client_ip.c_str(), client_port, __FILE__, __LINE__); //提示连接成功

            //2.开始进行通信服务
            //version 1 -- 单进程循环版本
            //Service(serviceSock, client_ip, client_port);

            //version 2 -- 多进程循环版本
            //pid_t id = fork(); //创建子进程
            //assert(id != -1);
            //if (id == 0) //子进程, 也能看到文件描述符, 因此也可以提供服务工作, 让连接工作交给父进程
            //{
            //    close(_ListenSock); //但是子进程无需知道监听套接字, 关闭即可, 避免文件描述符资源越来越少
            //    Service(serviceSock, client_ip, client_port); //执行服务任务
            //    close(serviceSock); //服务任务执行结束后最好关闭下服务套接字(虽然该进程自动释放后也会自动释放该套接字资源罢了, 不过我们严谨一点)
            //    exit(-1);
            //}

            //waitpid(); //这里我不设置非阻塞等待回收, 也不设置子进程退出时信号的处理机制, 我们使用信号忽略的方式, 对应代码就在 Start() 方法的开头处

            //version 3 -- 多进程循环版本(孙进程托管版本)
            pid_t id = fork(); //创建子进程
            if (id == 0) //(2)子进程部分
            {
                close(_ListenSock); //提前关闭监听套接字, 这样孙进程就不用再次关闭了
                if (fork() > 0) //子进程创建完孙线程后, 自己立刻退出
                    exit(-1);

                //(3)孙进程部分
                Service(serviceSock, client_ip, client_port); //孙进程被子进程抛弃, 成为孤儿进程被操作系统托养, 因此由操作系统决定释放(相当于由操作系统自动释放)
                close(serviceSock); //执行完服务任务后, 关闭服务套接字
                exit(-1); //注意这里孙进程执行完自己的任务后一定要退出, 不然会出现意想不到的错误(例如在客户端使用 "exit" 退出时会导致父进程陷入死循环, 这点您可以研究一下)
            }

            waitpid(id, nullptr, 0); //父进程阻塞等待回收子进程, 由于子进程立刻退出, 因此父进程可以立刻接收子进程再进行释放不会导致父进程进入阻塞, 而孙进程此时就在执行和服务端交互的任务

            //(1)主进程部分
            //3.关闭服务套接字, 因为主进程不需要这个套接字, 有监听套接字就够了
            close(serviceSock);
        }
    }

    //销毁服务器
    ~TcpServer()
    {
        if (_ListenSock < 0)
            close(_ListenSock);
    }


private:
    std::string _ip;
    uint16_t _port;
    int _ListenSock; //int _sock; //我们更愿意叫这个套接字为监听套接字, 因此我们改一下名字
    Log _log;
};

上述代码只修改了服务端的代码(其他代码保持不变),这是利用操作系统托管孤儿进程的机制,算是一种取巧的方式。

可以使用 ps -ao pid,ppid,cmd 来查看 PID 的变化。

3.3.2.4.多线程服务端

而由于进程还是有些“重”,您还可以尝试修改为多线程的代码。其他代码保持不变,只修改 tcp_server.hpp 即可。

不过尤其需要提醒您两点:

  • 多线程版本不能提前关闭服务套接字,否则会导致整个代码被异常关闭。这也很好理解,这里不是多进程,主线程关闭服务套接字会导致共享的子线程读写服务套接字时出现错误。
  • 最好做下线程分离,如果主线程做 join 的话会导致陷入阻塞,影响主线程使用监听套接字建立链接,进而影响创建新的服务套接字。

我们可以直接使用原生的接口。

//tcp_server.hpp(多线程服务端-原生的接口)

/* 使用方法
std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));
svr->Start();
*/

#pragma once
#include <string>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include "log.hpp"

const int buffSize = 1024;
const int g_backlog = 20; //这个数值一般不会太大, 也不会太小, 但是我们以后提及 TCP 报文的时候再来详细讲解

//线程数据类
struct ThreadData
{
    int _sock;
    std::string _ip;
    uint16_t _port;
};

static void Service(const int& serviceSock, const std::string& client_ip, const uint16_t& client_port)
{
    Log log;

    while (true)
    {
        //直接套用文件 IO 的接口 read() 和 write()

        //从服务端读取数据
        char readBuffer[1024] = { 0 };
        ssize_t s = read(serviceSock, readBuffer, sizeof(readBuffer) - 1);
        if (s > 0)
        {
            std::cout << "[" << client_ip << ":" << client_port << "] echo " << readBuffer << std::endl;
        }
        else if (s == 0) //代表对端关闭了
        {
            log.LogMessage(NORMAL, "write close, me too! %s %d", __FILE__, __LINE__);
            break;
        }
        else //代表读取出现异常
        {
            log.LogMessage(FATAL, "read() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //写入数据到服务端
        write(serviceSock, readBuffer, strlen(readBuffer)); //这里读到什么数据就直接传送回去
    }
}

class TcpServer
{
private:
    static void* ThreadRoutine(void* args) //static 去除 this 参数
    {
        pthread_detach(pthread_self()); //线程分离, 让子线程自己释放自己
        
        ThreadData* td = (ThreadData*)args;

        int serviceSock = td->_sock;
        std::string client_ip = td->_ip;
        int client_port = td->_port;

        Service(serviceSock, client_ip, client_port); //读写任务

        delete td; //这一步一定要记住
    }

public:
    //初始服务器
    TcpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _ListenSock(-1)
    {
        //1.创建套接字
        if ((_ListenSock = socket(AF_INET, SOCK_STREAM, 0)) < 0) //注意换成流式类型, 而不是数据报类型
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }
        _log.LogMessage(NORMAL, "sock: %d, %s %d", _ListenSock, __FILE__, __LINE__); //检验套接字是不是真的是一个文件描述符, 若是理应返回 3

        //2.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节), 主机序列转为网络序列
        if (bind(_ListenSock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //3.设置套接字为监听状态
        /* 因为 TCP 是面向连接的, 因此正确通信时需要建立连接, 而这就注定一个服务端必须处于一个等待连接的状态 */
        if (listen(_ListenSock, g_backlog) < 0) //设置 _ListenSock 为监听状态
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //4.提示初始化成功
        _log.LogMessage(NORMAL, "init tcp server done... %s %d", __FILE__, __LINE__);
    }

    //运行服务器
    void Start()
    {
        //signal(SIGCHLD, SIG_IGN); //父进程由程序员主动设置忽略, 因此子进程退出时会自动释放自己的僵尸状态

        while (true)
        {
            //1.获取和客户端的连接(如果没有任何一个客户端连接服务端, 服务端就会陷入阻塞状态)
            struct sockaddr_in src;
            socklen_t len = sizeof(src);
            int serviceSock = accept(_ListenSock, (struct sockaddr*)&src, &len); //获取 IO 用的套接字(这个套接字才是做 IO 服务的套接字, 而之前申请的 _ListenSock 应该叫做监听套接字)
            /* _ListenSock vs serviceSock ? 注意这里的 _ListenSock 其实就是之前代码中的 _sork, 不过我换了一个更加简介的名字 */
            /* 这两个套接字的区别就在于:_ListenSock 是提供揽客的揽客员, serviceSock 是提供服务的服务员 */
            /* _ListenSock 作用是获取新的连接, serviceSock 作用是提供 IO 服务, 这点和 UDP 编程很不一样 */

            if (serviceSock < 0) //若申请
            {
                _log.LogMessage(WARNING, "accept() error... , %s %d", __FILE__, __LINE__);
                continue; //即使获取不到新的连接也不影响, 重新循环尝试链接即可
            }

            std::string client_ip = inet_ntoa(src.sin_addr);
            uint16_t client_port = ntohs(src.sin_port);
            _log.LogMessage(NORMAL, "link success, the \"service sock\" is [%d], the \"client sock\" is [%s:%d], %s %d", serviceSock, client_ip.c_str(), client_port, __FILE__, __LINE__); //提示连接成功

            //2.开始进行通信服务
            //version 1 -- 单进程循环版本
            //Service(serviceSock, client_ip, client_port);

            //version 2 -- 多进程循环版本
            //pid_t id = fork(); //创建子进程
            //assert(id != -1);
            //if (id == 0) //子进程, 也能看到文件描述符, 因此也可以提供服务工作, 让连接工作交给父进程
            //{
            //    close(_ListenSock); //但是子进程无需知道监听套接字, 关闭即可, 避免文件描述符资源越来越少
            //    Service(serviceSock, client_ip, client_port); //执行服务任务
            //    close(serviceSock); //服务任务执行结束后最好关闭下服务套接字(虽然该进程自动释放后也会自动释放该套接字资源罢了, 不过我们严谨一点)
            //    exit(-1);
            //}
            //waitpid(); //这里我不设置非阻塞等待回收, 也不设置子进程退出时信号的处理机制, 我们使用信号忽略的方式, 对应代码就在 Start() 方法的开头处

            //version 3 -- 多进程循环版本(孙进程托管版本)
            //pid_t id = fork(); //创建子进程
            //if (id == 0) //(2)子进程部分
            //{
            //    close(_ListenSock); //提前关闭监听套接字, 这样孙进程就不用再次关闭了
            //    if (fork() > 0) //子进程创建完孙线程后, 自己立刻退出
            //        exit(-1);
            //    //(3)孙进程部分
            //    Service(serviceSock, client_ip, client_port); //孙进程被子进程抛弃, 成为孤儿进程被操作系统托养, 因此由操作系统决定释放(相当于由操作系统自动释放)
            //    close(serviceSock); //执行完服务任务后, 关闭服务套接字
            //    exit(-1); //注意这里孙进程执行完自己的任务后一定要退出, 不然会出现意想不到的错误(例如在客户端使用 "exit" 退出时会导致父进程陷入死循环, 这点您可以研究一下)
            //}
            //waitpid(id, nullptr, 0); //父进程阻塞等待回收子进程, 由于子进程立刻退出, 因此父进程可以立刻接收子进程再进行释放不会导致父进程进入阻塞, 而孙进程此时就在执行和服务端交互的任务
            (1)主进程部分
            
            //version 4 -- 多线程版本(使用原生的接口)
            pthread_t tid;
            ThreadData* td = new ThreadData(); //这里最好用指针, 因为有可能在多线程的场景下 td 发生二义性, 而我又懒得加锁... 不过代价就是必须注意在线程方法内释放指针
            td->_sock = serviceSock;
            td->_ip = client_ip;
            td->_port = client_port;
            pthread_create(&tid, nullptr, ThreadRoutine, td);
            
            //3.关闭服务套接字(多线程版本不能关闭, 否则会导致服务端被异常关闭, 这也很好理解, 这里不是多进程, 主线程关闭服务套接字会导致共享的子线程读写套接字时出现错误)
            //close(serviceSock);
        }
    }

    //销毁服务器
    ~TcpServer()
    {
        if (_ListenSock < 0)
            close(_ListenSock);
    }


private:
    std::string _ip;
    uint16_t _port;
    int _ListenSock; //int _sock; //我们更愿意叫这个套接字为监听套接字, 因此我们改一下名字
    Log _log;
};

也改为我们之前封装的线程头文件然后做修改。

//tcp_server.hpp(多线程服务端-自定义封装)

/* 使用方法
std::unique_ptr<TcpServer> svr(new TcpServer(port, ip));
svr->Start();
*/

#pragma once
#include <string>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include "log.hpp"
#include "thread.hpp"

const int buffSize = 1024;
const int g_backlog = 20; //这个数值一般不会太大, 也不会太小, 但是我们以后提及 TCP 报文的时候再来详细讲解

//线程数据类
struct ThreadData
{
    int _sock;
    std::string _ip;
    uint16_t _port;
};

static void Service(const int& serviceSock, const std::string& client_ip, const uint16_t& client_port)
{
    Log log;

    while (true)
    {
        //直接套用文件 IO 的接口 read() 和 write()

        //从服务端读取数据
        char readBuffer[1024] = { 0 };
        ssize_t s = read(serviceSock, readBuffer, sizeof(readBuffer) - 1);
        if (s > 0)
        {
            std::cout << "[" << client_ip << ":" << client_port << "] echo " << readBuffer << std::endl;
        }
        else if (s == 0) //代表对端关闭了
        {
            log.LogMessage(NORMAL, "write close, me too! %s %d", __FILE__, __LINE__);
            break;
        }
        else //代表读取出现异常
        {
            log.LogMessage(FATAL, "read() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //写入数据到服务端
        write(serviceSock, readBuffer, strlen(readBuffer)); //这里读到什么数据就直接传送回去
    }
}

class TcpServer
{
private:
    static void* ThreadRoutine(void* args) //static 去除 this 参数
    {
        pthread_detach(pthread_self()); //线程分离, 让子线程自己释放自己

        ThreadData* td = (ThreadData*)args;

        int serviceSock = td->_sock;
        std::string client_ip = td->_ip;
        int client_port = td->_port;

        Service(serviceSock, client_ip, client_port); //读写任务

        delete td; //这一步一定要记住
    }

public:
    //初始服务器
    TcpServer(uint16_t port, std::string ip)
        : _ip(ip), _port(port), _ListenSock(-1)
    {
        //1.创建套接字
        if ((_ListenSock = socket(AF_INET, SOCK_STREAM, 0)) < 0) //注意换成流式类型, 而不是数据报类型
        {
            _log.LogMessage(FATAL, "socket() error, %s %d", __FILE__, __LINE__);
            exit(-1);
        }
        _log.LogMessage(NORMAL, "sock: %d, %s %d", _ListenSock, __FILE__, __LINE__); //检验套接字是不是真的是一个文件描述符, 若是理应返回 3

        //2.绑定套接字
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); //比特位置零
        local.sin_family = AF_INET; //设置协议家族/域
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); //设置 ip(四字节), 内部做了两个步骤:(1)转化为四字节 (2)再转化为网络序列
        local.sin_port = htons(_port); //设置 port(两字节), 主机序列转为网络序列
        if (bind(_ListenSock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //3.设置套接字为监听状态
        /* 因为 TCP 是面向连接的, 因此正确通信时需要建立连接, 而这就注定一个服务端必须处于一个等待连接的状态 */
        if (listen(_ListenSock, g_backlog) < 0) //设置 _ListenSock 为监听状态
        {
            _log.LogMessage(FATAL, "%s %d", __FILE__, __LINE__);
            exit(-1);
        }

        //4.提示初始化成功
        _log.LogMessage(NORMAL, "init tcp server done... %s %d", __FILE__, __LINE__);
    }

    //运行服务器
    void Start()
    {
        //signal(SIGCHLD, SIG_IGN); //父进程由程序员主动设置忽略, 因此子进程退出时会自动释放自己的僵尸状态

        while (true)
        {
            //1.获取和客户端的连接(如果没有任何一个客户端连接服务端, 服务端就会陷入阻塞状态)
            struct sockaddr_in src;
            socklen_t len = sizeof(src);
            int serviceSock = accept(_ListenSock, (struct sockaddr*)&src, &len); //获取 IO 用的套接字(这个套接字才是做 IO 服务的套接字, 而之前申请的 _ListenSock 应该叫做监听套接字)
            /* _ListenSock vs serviceSock ? 注意这里的 _ListenSock 其实就是之前代码中的 _sork, 不过我换了一个更加简介的名字 */
            /* 这两个套接字的区别就在于:_ListenSock 是提供揽客的揽客员, serviceSock 是提供服务的服务员 */
            /* _ListenSock 作用是获取新的连接, serviceSock 作用是提供 IO 服务, 这点和 UDP 编程很不一样 */

            if (serviceSock < 0) //若申请
            {
                _log.LogMessage(WARNING, "accept() error... , %s %d", __FILE__, __LINE__);
                continue; //即使获取不到新的连接也不影响, 重新循环尝试链接即可
            }

            std::string client_ip = inet_ntoa(src.sin_addr);
            uint16_t client_port = ntohs(src.sin_port);
            _log.LogMessage(NORMAL, "link success, the \"service sock\" is [%d], the \"client sock\" is [%s:%d], %s %d", serviceSock, client_ip.c_str(), client_port, __FILE__, __LINE__); //提示连接成功

            //2.开始进行通信服务
            //version 1 -- 单进程循环版本
            //Service(serviceSock, client_ip, client_port);

            //version 2 -- 多进程循环版本
            //pid_t id = fork(); //创建子进程
            //assert(id != -1);
            //if (id == 0) //子进程, 也能看到文件描述符, 因此也可以提供服务工作, 让连接工作交给父进程
            //{
            //    close(_ListenSock); //但是子进程无需知道监听套接字, 关闭即可, 避免文件描述符资源越来越少
            //    Service(serviceSock, client_ip, client_port); //执行服务任务
            //    close(serviceSock); //服务任务执行结束后最好关闭下服务套接字(虽然该进程自动释放后也会自动释放该套接字资源罢了, 不过我们严谨一点)
            //    exit(-1);
            //}
            //waitpid(); //这里我不设置非阻塞等待回收, 也不设置子进程退出时信号的处理机制, 我们使用信号忽略的方式, 对应代码就在 Start() 方法的开头处

            //version 3 -- 多进程循环版本(孙进程托管版本)
            //pid_t id = fork(); //创建子进程
            //if (id == 0) //(2)子进程部分
            //{
            //    close(_ListenSock); //提前关闭监听套接字, 这样孙进程就不用再次关闭了
            //    if (fork() > 0) //子进程创建完孙线程后, 自己立刻退出
            //        exit(-1);
            //    //(3)孙进程部分
            //    Service(serviceSock, client_ip, client_port); //孙进程被子进程抛弃, 成为孤儿进程被操作系统托养, 因此由操作系统决定释放(相当于由操作系统自动释放)
            //    close(serviceSock); //执行完服务任务后, 关闭服务套接字
            //    exit(-1); //注意这里孙进程执行完自己的任务后一定要退出, 不然会出现意想不到的错误(例如在客户端使用 "exit" 退出时会导致父进程陷入死循环, 这点您可以研究一下)
            //}
            //waitpid(id, nullptr, 0); //父进程阻塞等待回收子进程, 由于子进程立刻退出, 因此父进程可以立刻接收子进程再进行释放不会导致父进程进入阻塞, 而孙进程此时就在执行和服务端交互的任务
            (1)主进程部分
            
            //version 4 -- 多线程版本(使用原生的接口)
            //pthread_t tid;
            //ThreadData* td = new ThreadData(); //这里最好用指针, 因为有可能在多线程的场景下 td 发生二义性, 而我又懒得加锁... 不过代价就是必须注意在线程方法内释放指针
            //td->_sock = serviceSock;
            //td->_ip = client_ip;
            //td->_port = client_port;
            //pthread_create(&tid, nullptr, ThreadRoutine, td);
            
            //version 5 -- 多线程版本(自己封装的线程对象)
            ThreadData* td = new ThreadData(); //这里最好用指针, 因为有可能在多线程的场景下 td 发生二义性, 而我又懒得加锁... 不过代价就是必须注意在线程方法内释放指针
            td->_sock = serviceSock;
            td->_ip = client_ip;
            td->_port = client_port;
            Thread<ThreadData*> t("aThread", ThreadRoutine, td);
            t.Start();

            //3.关闭服务套接字(多线程版本不能关闭, 否则会导致服务端被异常关闭, 这也很好理解, 这里不是多进程, 主线程关闭服务套接字会导致共享的子线程读写套接字时出现错误)
            //close(serviceSock);
        }
    }

    //销毁服务器
    ~TcpServer()
    {
        if (_ListenSock < 0)
            close(_ListenSock);
    }


private:
    std::string _ip;
    uint16_t _port;
    int _ListenSock; //int _sock; //我们更愿意叫这个套接字为监听套接字, 因此我们改一下名字
    Log _log;
};

4.协议间对比

实际上,我们上面学习的都是应用层偏上接口的应用,但是会涉及到传输层中 TCP 协议和 UDP 协议的知识,两者具体的区别在代码中是可以看出来一些的。这里提前先简单归纳一下,知道一些实时即可,后面还会细化。

  1. UDP 是面向无链接和数据报的可靠性通信协议,出现数据丢包时不会重传数据包,但凭借现在的网络环境我们很难测试出来这一现象。其中无链接我们在代码中就可以看到(没有像 TCP 一样使用 listen()accept() 接口),而数据报是指“发一条消息得到一条完整消息”。

  2. TCP 是面向有连接和字节流的不可靠通信协议,出现数据丢包时会重新传数据包。其中字节流是指“只有到读的时候才把数据一次读走”。

    在内部会自动进行“三次握手”来建立连接,这个过程是在调用 connect() 后,发起的,但是 accept() 本身不参与“三次握手”,该过程由操作系统来自动完成,只是“三次握手”结束后,accept() 才把建立好的连接从内核中返回到用户层中(也就是返回一个文件描述符 fd),而通过这个文件标识符就可以进行 IO 操作。

    而两个端都使用 close(fd) 断开连接时,而断开连接时就会释放连接资源,会导致“四次挥手”的行为,该行为需要双方共同协作,而不是仅一方主动。

当然,我们下一节将会从这节套接字编程的实践基础上,从应用层一直讲解到应用层、传输层、网络、数据链路层的常见协议。详细讲解每一层的某一具体协议的报头部分。

  • 20
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

limou3434

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值