一、本章重点
1. tcp服务器实现思路,进一步了解和总结相关的接口
2. 了解日志和守护进程
二、tcp服务器核心思路
tcp版的服务器与udp的不同在于,udp是面向数据报传输数据,在数据传输中不需要建立与客户端的链接,直接用recvfrom和sendto这两个接口进行消息的收发,而tcp版的服务器则是面向字节流的,需要与客户端建立连接
1. 创建监听套接字listen_sock
这个监听套接字是用于监听的,监听就可以看做为等待客户端连接,监听套接字只负责与客户端建立起连接,然后剩下的任务交给其他的套接字去执行
cpp
// 1. 创建ListenSock
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
std::cerr << "create socket error" << std::endl;
exit(SOCKET_ERR);
}
2. 创建好struct sockaddr_in ,并将其与套接字bind
这里和udp是一样的步骤
cpp
// 2. bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_addr.s_addr = htonl(INADDR_ANY);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
std::cerr << "bind error" << std::endl;
exit(BIND_ERR);
}
3. 监听 -- listen
//?
cpp
if (listen(_listensock, backlog) < 0)
{
std::cerr << "listen socket error" << std::endl;
exit(LISTEN_ERR);
}
4. 获取连接 -- accept
获取连接其实就是获取客户端的连接,它会返回一个套接字,后续的业务处理则是根据这个套接字去实现和完成的
cpp
// 1. 获取连接 -- accept
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sock = accept(_listensock, (struct sockaddr *)&client, &len);
if (sock < 0)
{
std::cerr << "sock error" << std::endl;
exit(SOCKET_ERR);
}
5. 开展业务处理.
具体开展的业务处理要采用多进程或者多线程的方式,主线程负责监听连接,而其他线程负责为客户端提供具体服务。tcp的读写可以直接使用以往的文件相关的读写函数,如read、write等等
cpp
// 3. 开展业务处理 -- service
// 测试
std::cout << "连接成功:" << sock << " from " << _listensock << "," << client_ip << " - " << client_port << std::endl;
// service(sock,client_ip,client_port);
// 业务处理需要和监听同时进行,因为这里的业务处理是阻塞等待客户端的
// 3.1 多进程方案 -- 如何处理进程等待的阻塞问题?
// pid_t id = fork();
// if (id < 0)
// {
// close(sock);
// continue;
// ;
// }
// else if (id == 0) // child
// {
// close(_listensock);
// service(sock, client_ip, client_port);
// exit(0);
// }
// close(sock); -- 线程方案中不能关闭这个
// 进程等待 -- waitpid
// 方案一:信号忽略 -- signal(SIGCHLD, SIG_IGN);(推荐)
// 方案二:信号捕捉 -- signal(SIGCHLD, handler);(麻烦,不太推荐)
// 方案三:轮询等待 -- WNOHANG(不太推荐)
// 方案四:孤儿进程 -- if(fork() > 0) exit(0);(不太推荐,但思路很优秀,但是会对系统有负担)
// pid_t ret = waitpid(id, nullptr, 0);
// if (ret == id)
// std::cout << "wait child: " << id << " success" << std::endl;
// 3.2 多线程方案 -- 线程池优化
// pthread_t tid;
// ThreadData* td = new ThreadData(sock,client_ip,client_port,this);
// pthread_create(&tid,nullptr,threadRoutine,td);
Task t(sock, client_ip, client_port, std::bind(&TcpServer::service, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));//?
ThreadPool<Task>::getinstance()->pushTask(t);
三、Socket编程相关接口整理
1. 网络编程相关的头文件
cpp
#include<sys/types.h> // 包含很多系统数据类型
#include<sys/socket.h> // 包含了基本的 socket 函数和数据结构定义。
#include<netinet/in.h> // 定义了 Internet 地址族相关的结构和函数。
#include<arpa/inet.h> // 提供 IP 地址转换函数。
2. socket -- 创建套接字函数
cpp
int socket(int domain, int type, int protocol);
domain: 指定通信域,常见的有AF_INET(IPv4网络协议) 和AF_INET6(IP6网络协议)。
type: 指定套接字类型,常见的有SOCK_STREAM(面向字节流,对应TCP)和SOCK_DGRAM(面向数据报,对应UDP)
**protocol:**通常设置为0,表示使用默认协议。
**返回值:**一般是文件文件描述符fd,若失败则返回小于0的值
3. struct sockaddr_in
sin_family:第一行的宏转换后就是这个,它指定的是地址族,对于IPv4就设置为AF_INET
sin_port:端口号,要以网络字节序去表示,所以这里一般在填写的时候,还要配合网络字节序的接口htons
sin_addr:里面封装的内容实际就是IP地址,可以使用inet_addr 或 inet_pton函数将点分十进制的 IP 地址字符串转换为合适的格式并存储在
sin_addr.s_addr
中。当然这里由于我们用的是云服务器,前面提到云服务器的IP地址不能填唯一确定的,而是这里需要填INADDR_ANY
4. bind 绑定套接字和struct sockaddr的函数接口
cpp
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd: 是通过socket函数创建的套接字描述符。
addr :该结构体的解释在知识点三和知识点四有讲解,对于 IPv4 通常是
struct sockaddr_in
类型,对于 IPv6 是struct sockaddr_in6
类型。这个地址结构包含了要绑定的 IP 地址和端口号等信息。addrlen:结构体的大小
返回值说明:成功返回0,失败返回-1,并将错误码进行设置
5. recvfrom -- 从套接字中获取信息
cpp
int recvfrom(int sockfd, void *buf, int len, unsigned int flags, struct sockaddr *from, int *fromlen);
sockfd:套接字描述符
buf:接受数据的数据缓冲区指针,用于接受数据的
len:数据缓冲区的大小
flags :一般设置为0,表示默认的接收行为,也可以设置一些标志位来影响接收操作的行为,例如
MSG_PEEK
(查看数据但不实际读取,数据仍留在接收队列中)、MSG_WAITALL
(尽可能等待接收完整的请求数据量)等,但这些标志位的使用相对较少**from:**是一个struct sockaddr*类型的结构体指针,它用来获取发送方的地址信息
fromlen: 是一个指向整数的指针,用于指定
from
所指向的地址结构的长度。在调用recvfrom
之前,应该将*fromlen
设置为sizeof(struct sockaddr)
(或对应的具体地址结构的大小);当recvfrom
返回时,fromlen
会被修改为实际存储在from
中的地址结构的长度**返回值说明:**成功返回收到的字节数,返回值为0说明对端已经关闭了连接,返回-1表示接受数据发生了错误
6. sendto -- 向套接字中写入信息
cpp
int sendto(int sockfd, const void *buf, int len, unsigned int flags, const struct sockaddr *to, int tolen);
sockfd:是通过socket函数创建的套接字描述符
buf:要向套接字发送的数据所在的缓冲区指针
len:指定要发生内容的长度
flags: 一般设置为 0,表示默认的发送行为。也可以设置一些标志位来影响发送操作的行为,不过这些标志位的使用相对较少
**to:**要发送到的目的地的struct sockadd的指针
tolen:to的大小
7. 网络字节序接口
cpp
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);//将源主机中32位长整形转换成网络字节序要求的大端格式
uint16_t htons(uint16_t hostshort);//将源主机中16位短整形转换成网络字节序要求的大端格式
uint32_t ntohl(uint32_t netlong);//将网络中32位长整形(大端)转换成当前主机的格式
uint16_t ntohs(uint16_t netshort);//将网络中16位短整形(大端)转换成当前主机的格式
8. listen 监听
cpp
int listen(int sockfd, int backlog);
它用于将一个套接字标记为被动套接字,也就是将其设置为用于监听连接请求的状态。这个套接字通常是由socket
函数创建并经过bind
函数绑定到一个本地地址和端口之后使用。
sockfd
:是由socket
函数创建并经过bind
绑定后的套接字描述符。backlog
:指定了在拒绝连接之前,操作系统可以暂存的未完成连接请求的最大数量。它实际上定义了连接请求队列的长度。当有多个客户端同时尝试连接服务器时,这些连接请求会被放入这个队列中等待服务器处理。如果队列已满,新的连接请求可能会被拒绝(具体行为可能因操作系统而异)。- 返回值:如果
listen
函数调用成功,则返回 0;如果失败,则返回 -1,并设置相应的错误代码,可以通过perror
等函数查看错误信息
9. accept 连接
cpp
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
它用于从处于监听状态的套接字的连接请求队列中取出一个连接请求,并创建一个新的套接字来与客户端进行通信。这个新的套接字与原来监听的套接字不同,它是专门用于和客户端进行数据交互的。
sockfd
:仍然是最初由socket
函数创建并经过bind
和listen
操作的监听套接字描述符。addr
:是一个指向sockaddr
结构体的指针,用于存储客户端的地址信息。如果不需要获取客户端地址信息,可以将其设置为NULL
。addrlen
:是一个指向socklen_t
类型变量的指针,它用于在函数调用前传入addr
所指向的结构体的长度,在函数返回时,它会被设置为实际存储客户端地址信息所占用的长度。- 返回值:如果
accept
函数调用成功,则返回一个新的套接字描述符,这个描述符用于与客户端进行后续的通信;如果失败,则返回 -1,并设置相应的错误代码,可以通过perror
等函数查看错误信息。
四、日志
往往在项目工程中,我们会有需要不同的调试信息,以及可能一些需要标记或者记录的信息,例如用户连接成功的信息,用户的消息记录等等,这些我们之前都是直接打印在屏幕上的,但实际在工程项目中,我们都需要将这些信息打印到文件中进行管理,这里我们简单实现一下应该粗糙的日志。
1. 可变参数
在写日志代码前,还需要对可变参数有一些了解,像C语言中的printf的实现就是用可变参数的方式实现,那么我们要如何使用呢?
#include <cstdarg> // 头文件
void func(const char *format,...) // 可变参数前,至少要有一个确定的参数format
va_list p; // p变量本质是一个char*的指针
va_start(p, format); // p指向可变参数部分的起始地址
int a = va_arg(p, int); // 根据类型提取参数
va_end(p); // p = NULL;
以上是对于可变参数的预备知识,我们要用可变参数的是为了让打印日志信息函数中,可以让外部传入的日志信息可以是多样的,像printf一样使用,所以可以用到一个函数帮助我们
cpp
int vsnprintf(char *str, size_t size, const char *format, va_list ap);
str是数据缓冲区
size是缓冲区大小
format是一个格式化字符串,用于指定输出的格式,其格式规则与
printf
函数中的格式化字符串类似,包含普通字符和格式转换说明符(如%d
、%f
、%s
等)ap是一个
va_list
类型的变量,用于传递可变参数列表。它通常是由va_start
宏初始化后得到的
2. 参考代码
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <cstdarg>
#include <sys/types.h>
#include <unistd.h>
// 日志是有日志等级的
const std::string filename = "log/tcpserver.log";
enum
{
Debug = 0,
Info,
Warning,
Error,
Fatal,
Uknown
};
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 "Uknown";
}
}
static std::string getTime()
{
time_t curr = time(nullptr);
struct tm *tmp = localtime(&curr);
char buffer[128];
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 消息体
// logMessage(DEBUG, "hello: %d, %s", 12, s.c_str()); // DEBUG hello:12, world
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] ", 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);
// 打印
// printf("%s%s\n", logLeft, logRight);
// 保存到文件中
FILE *fp = fopen(filename.c_str(), "a");
if(fp == nullptr)return;
fprintf(fp,"%s%s\n", logLeft, logRight);
fflush(fp); //可写也可以不写
fclose(fp);
// 预备
// va_list p; // char *
// int a = va_arg(p, int); // 根据类型提取参数
// va_start(p, format); //p指向可变参数部分的起始地址
// va_end(p); // p = NULL;
}
五、守护进程
1. 概念
要理解守护进程,我们先需要知道一些关于Liunx系统的概念。
首先要理解关于进程、进程组、会话,这三个概念
- 进程是基础
进程是计算机系统中正在运行的程序的实例,是操作系统进行资源分配和调度的基本单位。它拥有自己独立的内存空间和系统资源,有自己的生命周期。- 进程组是相关进程的集合
进程组由一个共同的祖先进程创建的多个相关进程组成。这些进程在某些行为上具有一致性,比如共享一个进程组 ID,对某些信号的处理方式相同。一个进程可以属于一个进程组。- 会话是包含多个进程组的逻辑概念
会话通常与一个控制终端相关联,当用户登录系统时创建。一个会话可以包含多个不同类型的进程组,这些进程组中的进程对于某些信号的处理方式是相关的。会话支持作业控制操作。- 总体关系
一个进程可以属于一个进程组,一个进程组可以属于一个会话。从范围上看,会话包含多个进程组,进程组包含多个相关进程。
我们现在使用的Xshell去登录远端的Linux服务器,每次登录其实是在整个Linux系统上启动了一个会话,而bash进程作为首个登入的进程,同时也是对应进程组的组长
而守护进程就是,我们要将原先我们在该会话中写的进程,将其独立出去作为一个单独的会话层面的进程,让它在后台运行,一般的服务器都是如此,这样就不会因为我们会话关闭而服务器挂掉。
守护进程要实现还有几个条件:
- 与控制终端脱离
- 守护进程需要与任何控制终端脱离关系。这是因为如果守护进程依赖于控制终端,当终端关闭时(例如用户退出登录),守护进程可能会收到一些信号(如 SIGHUP)导致其异常终止。通常通过调用
setsid
函数来创建一个新的会话,使守护进程成为新会话的首进程,从而脱离原来的控制终端。- 改变工作目录
- 守护进程一般会改变其工作目录到根目录(
/
)或者其他合适的目录。这是为了避免因为工作目录所在的文件系统被卸载等原因导致守护进程出现问题。例如,如果守护进程的工作目录在一个用户可卸载的文件系统上,当该文件系统被卸载时,守护进程可能无法正常访问其工作目录中的文件。- 重设文件描述符
- 守护进程需要关闭所有不需要的文件描述符。在进程创建时,会继承父进程的一些文件描述符,如果不关闭这些不必要的文件描述符,可能会导致资源浪费以及潜在的问题。例如,守护进程可能会因为某个打开的文件描述符指向一个已经不存在的文件(如终端相关的文件描述符)而出现错误。通常会关闭标准输入、标准输出和标准辅助输入输出等文件描述符(0、1、2),并可以根据需要重新打开一些文件描述符用于日志记录等用途。
- 设置合适的文件权限掩码
- 守护进程通常会设置合适的文件权限掩码(
umask
)。文件权限掩码决定了新创建文件的默认权限。通过设置合适的文件权限掩码,可以确保守护进程创建的文件具有合适的权限,避免出现安全问题或文件无法正常使用的情况。- 忽略某些信号
- 守护进程需要忽略一些信号,比如 SIGHUP 信号。因为守护进程已经与控制终端脱离,如果不忽略 SIGHUP 信号,当终端关闭时,守护进程可能会收到该信号并错误地终止。同时,守护进程也可能需要忽略其他一些不相关的信号,以确保其稳定运行。
2. 参考代码
cpp
#pragma once
// 1. setsid();
// 2. setsid(), 调用进程,不能是组长!我们怎么保证自己不是组长呢?
// 3. 守护进程a. 忽略异常信号 b. 0,1,2要做特殊处理 c. 进程的工作路径可能要更改 /
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "log.hpp"
#include "err.hpp"
//守护进程的本质:是孤儿进程的一种!
void Daemon()
{
// 1. 忽略信号
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
// 2. 让自己不要成为组长
if (fork() > 0)
exit(0);
// 3. 新建会话,自己成为会话的话首进程
pid_t ret = setsid();
if ((int)ret == -1)
{
logMessage(Fatal, "deamon error, code: %d, string: %s", errno, strerror(errno));
exit(SETSID_ERR);
}
// 4. 可选:可以更改守护进程的工作路径
// chdir("/")
// 5. 处理后续的对于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_ERR);
}
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
总结
本篇文章主要是整理了Socket编程常见的接口,还有对tcp服务器相关的概念知识点