网络套接字编程(三)
文章目录
- 网络套接字编程(三)
- 简易日志组件
- 引入日志的原因
- 日志等级
- 打印日志函数
- 将日志组件使用到服务端中
- 守护进程
- 概念
- 进程组、终端、会话
- 守护进程的实现原理
- 守护进程化组件
- 将守护进程化组件使用到服务端中
- 补充知识
- 关于inet_ntoa
在上一篇博客 网络套接字编程(二)-CSDN博客中讲解了单执行流、多执行流、线程池版的简易TCP程序的编写,本文将讲解与其有关的组件编写。
简易日志组件
引入日志的原因
在实际开发中,服务端是需要不间断运行来保证无论何时都能给客户端提供网络服务,因此在程序遇到某些不影响程序运行的问题,不会主动终止程序,而是将错误信息以日志的形式打印。服务端维护人员,会通过日志中记录的错误信息,来进行程序错误的定位和解决。
日志等级
在日志系统中,常常使用不同的日志等级来对日志进行分类和标记,以便根据重要性和紧急程度进行过滤、查看和处理。不同的日志等级通常表示了不同的日志消息类型和优先级。
以下是常见的日志等级,按照从高到低的顺序排列:
- 致命错误(Fatal):表示严重的错误或故障,导致系统无法正常运行或继续执行。这类错误需要立即解决,并可能需要中断程序的执行。
- 错误(Error):表示一些关键操作或功能发生了错误,但系统仍然可以继续运行。这类错误需要进行修复,以确保系统正常运行。
- 警告(Warning):表示一些潜在问题或异常情况,可能会影响系统的正常运行或导致错误。这类日志用于提示潜在的风险或不寻常的行为,需要进行检查和调查。
- 信息(Info/Information):表示一般的信息性消息,用于记录程序的正常运行状态、关键路径、重要操作等。这类日志用于追踪应用程序的运行情况和关键事件。
- 调试(Debug):表示开发过程中的调试信息,用于调试和分析程序的内部逻辑、变量状态等。这类日志通常只在开发和测试阶段启用,可用于排查问题和验证程序行为。
本文采用枚举的方式来表示各个日志等级:
enum
{
Debug=0,
Info,
Warning,
Error,
Fatal,
Unknown
};
打印日志函数
本文想实现的日志组件中,打印的日志格式为[日志等级][时间][进程pid][日志消息体],日志组件的实现中核心部分就是打印日志函数,日志组件的具体实现如下:
#pragma once
#include <cstdio>
#include <cstring>
#include <cstdarg>
#include <iostream>
#include <time.h>
#include <sys/types.h>
#include <unistd.h>
const std::string filename = "./log.txt";
enum
{
Debug = 0,
Info,
Warning,
Error,
Fatal,
Unknown
};
static std::string toLevelString(int level)
{
switch (level)
{
case Debug:
return "Debug";
case Info:
return "Info";
case Warning:
return "Warning";
case Error:
return "Error";
case Fatal:
return "Fatal";
default:
return "Unknown";
}
}
static std::string getTime()
{
const time_t cur = time(nullptr);
struct tm *tmp = localtime(&cur);
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%d-%d-%d %d:%d:%d", (tmp->tm_year) + 1900, tmp->tm_mon+1, tmp->tm_mday,
tmp->tm_hour, tmp->tm_min, tmp->tm_sec);
return buffer;
}
// 等级 时间 pid 消息体
void LogMessage(int level, const char *format, ...)
{
char logLeft[1024];
std::string level_string = toLevelString(level);
std::string curr_time = getTime();
snprintf(logLeft, sizeof(logLeft), "[%s][%s][%d]\n", level_string.c_str(), curr_time.c_str(), getpid());
char logRight[1024];
va_list p;
va_start(p, format);
vsnprintf(logRight, sizeof(logRight), format, p);
va_end(p);
FILE *fp = fopen(filename.c_str(), "a");
if (fp == nullptr)
return;
fprintf(fp, "%s%s\n", logLeft, logRight);
fflush(fp);
fclose(fp);
}
识别日志等级函数
我们预期的打印中,日志等级是通过字符串形式打印的,而不是枚举对应的数值,因此需要实现一个识别日志等级函数,将传入的枚举数值转换成字符串形式表示的日志等级。
该函数实现只需要简单的switch case语句将输入的数值对应成的字符串返回即可。
时间获取函数
我们预期的打印中,包含一个以字符串形式打印的时间部分,并且希望这个时间具体到年月日,时分秒,因此我们需要实现一个时间获取函数。
要实现这个时间获取函数,需要用到如下两个函数:
#include <time.h>
time_t time(time_t *t);
- t参数: 如果t指针不为NULL,则time函数会将计算出的时间值存储在t指向的变量中,并返回该值;否则,time函数直接返回计算出的时间值。
- time函数返回自1970年1月1日经过的秒数(也称为Unix时间戳),其类型为time_t。
#include <time.h>
struct tm *localtime(const time_t *timep);
- 该函数能够将传入的time_t类型的时间值转换为本地时间(系统默认时区)的tm结构体类型。
- 返回值是转换后得到的tm结构体类型。
- 由于localtime函数返回的tm结构体中有些成员的范围并不完全符合人们常用的表示方式,比如tm_mon表示的月份范围是0到11,因此在使用这些值进行操作和显示时,可能需要进行适当的转换和调整。
tm结构体的定义如下:
struct tm {
int tm_sec; // 秒 [0, 60]
int tm_min; // 分钟 [0, 59]
int tm_hour; // 小时 [0, 23]
int tm_mday; // 月份中的日期 [1, 31]
int tm_mon; // 月份 [0, 11],0表示一月
int tm_year; // 年份,减去1900后的值
int tm_wday; // 一周中的星期几 [0, 6],0表示星期日
int tm_yday; // 一年中的第几天 [0, 365],0表示1月1日
int tm_isdst; // 夏令时标识符,负数表示不可确定状态
};
可变参数列表
C/C++语言标准库提供的 <cstdarg>
头文件中提供了可变参数列表的操作方法:
va_list
是一个类型,在实际的使用中,它通常被定义为指向变长参数列表的指针。va_start
是一个宏函数,它用于对va_list
类型的变量进行初始化,将其指向第一个可变参数的位置。在如上代码中,p是一个va_list
类型的变量,format
是可变参数列表中的第一个参数。vsnprintf
是一个函数,它可以根据提供的格式字符串format
和va_list
类型的变量 p,将可变参数列表中的值按照指定的格式进行格式化输出,并将结果存储到 logRight 字符数组中。va_end
是一个宏函数,它用于结束可变参数的获取,进行必要的清理工作。在这个例子中,它将释放 p 变量所占用的资源。
将日志组件使用到服务端中
我们将日志组件使用在上一篇博客网络套接字编程(二)-CSDN博客中实现的线程池版的服务端中,添加日志组件需要需添加到服务端类内部的函数,添加日志组件后具体的代码实现如下:
enum
{
SOCKET_ERROR = 1,
BIND_ERROR,
LISTEN_ERROR,
USAGE_ERROR
};
static const uint16_t default_port = 8081;
static const int backlog = 32;
class TcpServer
{
public:
TcpServer(uint16_t port = default_port) : _port(port) {}
void InitServer()
{
// 创建套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
LogMessage(Fatal, "create socket error, %d:%s", errno, strerror(errno)); // 打印日志
exit(SOCKET_ERROR);
}
LogMessage(Info, "create socket success"); // 打印日志
// 绑定IP地址和端口号
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(_port);
if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
LogMessage(Fatal, "bind socket error, %d:%s", errno, strerror(errno)); // 打印日志
exit(BIND_ERROR);
}
LogMessage(Info, "bind socket success"); // 打印日志
// 监听
if (listen(_listensock, backlog) < 0)
{
LogMessage(Fatal, "listen socket error, %d:%s", errno, strerror(errno)); // 打印日志
exit(LISTEN_ERROR);
}
LogMessage(Info, "listen socket success"); // 打印日志
}
void StartServer()
{
ThreadPool<Task> tp;
tp.Start();
while (true)
{
// 获取连接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(_listensock, (struct sockaddr *)&peer, &len);
if (sock < 0)
{
LogMessage(Fatal, "accept error, %d:%s", errno, strerror(errno)); // 打印日志
continue;
}
std::string clientip = inet_ntoa(peer.sin_addr);
uint16_t clientport = ntohs(peer.sin_port);
LogMessage(Info, "accept socket success, %s-%d,for%d", clientip.c_str() ,clientport, sock); // 打印日志
// 网络服务
Task t(sock, clientip, clientport);
tp.PushTask(t);
}
}
private:
uint16_t _port;
int _listensock;
};
程序测试
启动服务端,并且查看日志文件,可以看到服务端将信息打印到了日志文件中:
使用客户端连接服务端进行网络通信,退出客户端,查看日志内容:
守护进程
概念
守护进程(daemon)是在操作系统后台运行且独立于终端会话的一种特殊进程。它通常用于在系统启动时执行某些长期运行的任务或服务,如网络服务等。守护进程在启动时会脱离当前终端会话,以独立的进程在后台运行,不受终端关闭或用户注销的影响。
将服务端变成守护进程后,即使关闭启动进程的终端,也不会影响守护进程的运行,只要运行守护进程的主机不关闭,并且进程不出错,就可以实现让进程无停止的运行。实现在网络服务端中,就是让该服务端始终能够给客户端提供网络服务。
进程组、终端、会话
指令查看进程相关信息
在Xshell下每打开一个窗口就是建立一个终端,建立两个终端,其中一个启动一个后台运行的sleep进程,另一个使用指令ps axj | head -1 && ps axj | grep sleep
查看这个进程的信息:
其中有如下信息:
- PGID-进程组ID
- SID-会话ID
- TTY-终端文件
其中TTY下为?的进程与终端没有关系,显示为pts/n
,即表示该进程打开了n号终端。在操作系统看来,就是该进程打开了该终端对应的终端文件,这些终端文件存在/dev
设备文件路径下。如果向终端文件写入数据,就会显示在对应终端上:
会话和进程组的概念
在Xshell下每启动一个终端,Linux操作系统就会为其创建一个会话,会话中会存在若干个包含一个或多个进程的进程组,其中存在bash以及其所在的进程组。而后在终端下所作的所有操作都会在这个会话中进行。
进程组可能不止一个进程,进程组ID是一组进程中的第一个进程的进程ID或者具有”血缘“的进程中”辈分“最大的进程,比如一对父子进程,父进程ID为进程组ID。
启动一组sleep进程,查看它们的进程组ID:
该组进程的进程组ID为第一个进程的进程ID。
进程组的作用是完成任务,其中任务是操作系统分配给进程或线程执行的工作。也就是操作系统会将一项工作交给一个进程组来完成,这个工作可能一个进程就能完成,也可能需要多个进程完成。如果一个进程能完成,该进程就会独立成组,多个进程才能完成,就会让多个进程形成进程组。
在Linux操作系统下,使用jobs
指令可以查看该会话下的任务:
使用fg 任务编号
指令可以让后台进程变成前台进程:
使用ctrl+z
指令可以暂停前台进程并让他变成后台进程:
使用bg 任务编号
可以让暂停的后台进程运行:
如果将一个后台任务变成前台任务,之前的前台任务就无法运行了,一个会话下只能有一个前台任务运行。开启一个终端,Linux操作系统就会为了对应创建一个会话,如果关闭这个终端,Linux操作系统就会销毁对应的会话,会话销毁就会影响会话中原有的进程。
守护进程的实现原理
守护进程的实现是让进程脱离启动它的终端对应的会话,自身独自处于一个会话中:
进程独自处于一个会话后,就不会受到其他会话的影响,只要操作系统不停止运行,该进程就能一直运行下去。这就是一些提供网络服务的服务端进程的运行原理。
守护进程化组件
守护进程化组件的功能是让调用它的进程变成守护进程。
为了让进程变成守护进程需要使用Linux操作系统下提供的创建会话让进程独享的setsid函数:
#include <unistd.h>
pid_t setsid(void);
- 该函数的功能是让调用的进程独自处于一个新的会话中。
- 返回值: 调用成功,返回新的会话ID(调用该函数的进程的ID)。调用失败,返回-1,错误码被设置。
- 注意: 调用该函数的进程不能是所处进程组的组长。
进程守护进程化的步骤:
- 忽略相关信号
- 让进程进程不再是组长
- 创建新的会话,让进程成为会话的首个进程
- (可选)更改进程的工作路径
- 处理进程的0,1,2号文件描述符
守护进程化组件的具体代码实现如下:
void Daemon()
{
//忽略相关信号
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
//让自己不再是组长
if (fork() > 0) exit(0);
//创建新的会话,让自己成为首个进程
pid_t ret = setsid();
if ((int)ret == -1)
{
LogMessage(Fatal, "deamon error, code: %d, string: %s", errno, strerror(errno));//打印日志
exit(SETSID_ERROR);
}
//处理0,1,2文件描述符
int fd = open("/dev/null", O_RDWR);
if (fd < 0 )
{
LogMessage(Fatal, "open error, code: %d, string: %s", errno, strerror(errno));//打印日志
exit(OPEN_ERROR);
}
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
守护进程是一种特殊的孤儿进程
由于调用setsid
函数的进程不能是进程组组长,因此需要创建子进程完成setsid
函数的调用,然后终止无用的父进程,因此最终完成任务的子进程,由于父进程终止了,因此守护进程是一种特殊的孤儿进程。执行任务是网络服务时,由于套接字操作中,始终是和相关的套接字文件进行操作,因此拷贝了父进程文件描述符表的子进程可以完成父进程的任务。
处理0,1,2文件描述符
由于守护进程不想受到外部设备输入的影响,也不想向外部设备输出,因此需要关闭0,1,2文件描述符,Linux操作系统中/dev/null
是一个不会有任何实质性数据的文件,因此让守护进程从这个文件读取,不会读取到任何数据,让守护进程向这个文件写入,不会写入任何数据到外部设备上。落实到代码上就是让0,1,2文件描述符指向该文件。
将守护进程化组件使用到服务端中
使用守护进程化组件,只需要在创建服务端类并初始化后调用组件即可,具体的代码实现如下:
void Usage(const char *proc)
{
std::cout << "Usage:\n\t" << proc << " port\n" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[1]);
std::unique_ptr<TcpServer> tsvr(new TcpServer(port));
tsvr->InitServer();
Daemon();//守护进程化
tsvr->StartServer();
return 0;
}
程序测试
在一个终端中启动服务端进程,然后使用指令ps axj | head -1 && ps axj | grep sleep
查看进程信息:
服务端进程独自使用了一个会话(该终端的会话ID和grep进程的一样),并且与终端无关。即使关闭终端也不会影响该服务端的运行了。
关闭该终端,启动一个新的终端运行客户端连接该服务器:
另外还可以查看日志,了解这个服务端的信息:
补充知识
关于inet_ntoa
inet_ntoa
函数是系统提供的将四字节整形IP地址转换成char*类型的IP地址。
//inet_ntoa所在的头文件和函数声明
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr in);
inet_ntoa这个函数返回了一个char*, 很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果. 那么是否需要调用者手动释放呢?
man手册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们手动进行释放。但man手册也提到了,每次调用该函数都会覆盖该缓冲区,如果您需要在多个地方使用返回的字符串,应该立即将其复制到另一个缓冲区中。如果一个线程一直使用该返回值指向的字符串作为参数进行操作,其他线程再调用该函数就会覆盖这个字符串,导致数据不一致。
因此,inet_ntoa不是线程安全的函数。但是在centos7上测试, 并没有出现问题, 可能内部的实现加了互斥锁。在多线程环境下, 推荐使用inet_ntop
, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题。
#include <arpa/inet.h>
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
- af参数: 指定了地址族,可以取值AF_INET或AF_INET6,分别表示IPv4和IPv6地址族。
- src参数: 是一个指向待转换的二进制地址的指针。
- dst参数: 是一个用于存储转换结果的缓冲区指针。
- size参数: 指定缓冲区的大小。
串,应该立即将其复制到另一个缓冲区中。如果一个线程一直使用该返回值指向的字符串作为参数进行操作,其他线程再调用该函数就会覆盖这个字符串,导致数据不一致。