我们回到WebServer,继续看Config类中的parse_arg函数(命令行参数解析器)。
Config::Config(){
//端口号,默认9006
PORT = 9006;
//日志写入方式,默认同步
LOGWrite = 0;
//触发组合模式,默认listenfd LT + connfd LT
TRIGMode = 0;
//listenfd触发模式,默认LT
LISTENTrigmode = 0;
//connfd触发模式,默认LT
CONNTrigmode = 0;
//优雅关闭链接,默认不使用
OPT_LINGER = 0;
//数据库连接池数量,默认8
sql_num = 8;
//线程池内的线程数量,默认8
thread_num = 8;
//关闭日志,默认不关闭
close_log = 0;
//并发模型,默认是proactor
actor_model = 0;
}
void Config::parse_arg(int argc, char*argv[]){
int opt;
const char *str = "p:l:m:o:s:t:c:a:";
while ((opt = getopt(argc, argv, str)) != -1)
{
switch (opt)
{
case 'p':
{
PORT = atoi(optarg);
break;
}
case 'l':
{
LOGWrite = atoi(optarg);
break;
}
case 'm':
{
TRIGMode = atoi(optarg);
break;
}
case 'o':
{
OPT_LINGER = atoi(optarg);
break;
}
case 's':
{
sql_num = atoi(optarg);
break;
}
case 't':
{
thread_num = atoi(optarg);
break;
}
case 'c':
{
close_log = atoi(optarg);
break;
}
case 'a':
{
actor_model = atoi(optarg);
break;
}
default:
break;
}
}
}
我们先理解一下这个getopt函数做了什么。getopt函数是Linux/Unix系统中用来解析命令行参数的标准库函数。
函数原型:
int getopt(int argc,char *const argv[],const char *optstring)
argc和argv就不再赘述了,在01中学习过。
opstring的格式规则:单个字母表示选项,后面跟冒号表示需要参数
"abc" //-a,-b,-c选项都不需要参数 "a:b:c:" //-a,-b,-c选项都需要参数 "ab:c" //-a和-c不需要参数
它使用几个全局变量:(即这些变量在<unistd.h>中已经声明了)
optarg:当前选项的参数值
optind:下一个要处理的参数索引。初始值为1,解析时自动递增。
opterr:是否输出错误信息(默认为1,输出错误)
optopt:当getopt遇到错误时,optopt会存储导致错误的选项字符。当getopt遇到错误时,会返回"?".
例子:
#include <iostream>
#include <unistd.h>
int main(int argc, char *argv[]) {
int opt;
const char *str="a:b:c:";
while ((opt = getopt(argc, argv, str)) != -1) {
switch (opt) {
case 'a':
std::cout << "Option a with value: " << optarg << std::endl;
break;
case 'b':
std::cout << "Option b with value: " << optarg << std::endl;
break;
case 'c':
std::cout << "Option c with value: " << optarg << std::endl;
break;
case '?':
if (optopt == 'a' || optopt == 'b' || optopt == 'c') {
std::cerr << "Option -" << static_cast<char>(optopt) << " requires an argument." << std::endl;
} else {
std::cerr << "Unknown option: -" << static_cast<char>(optopt) << std::endl;
}
return 1;
default:
return 0;
}
}
return 0;
}
编译:
g++ -std=c++20 -O1 main.cpp -o main
运行
./main -a cpp -b java -c golang
结果:
Option a with value:cpp Option b with value:java Option c with value:golang
所有参数解析完毕后,getopt函数返回-1.
-p:配置项为PORT(服务器端口号),例如-p 9006
-l:配置项为LOGWrite(日志写入模式),例如-l 1
-m:配置项为TRIGMode(触发模式),例如-m 3
-o:配置项为OPT_LINGER(优雅关闭选项)
-s:配置项为sql_num(数据库连接数)
-t:配置项为thread_num(线程池连接数)
-c:配置项为close_log(是否关闭日志)
-a:配置项为actor_model(并发模型)
atoi(optarg):将字符串参数转换为整数
这么一来,我们就可以使用多种方式启动服务器,不用重新编译就能改变服务器行为:
# 自定义端口和线程数
./server -p 8080 -t 16
解析过程:
第一次循环:opt='p',optarg="8080" -> PORT=8080
第二次循环:opt='t',optarg="16" -> thread_num=12
第三次循环:getopt返回-1,循环结束
总结:parse_arg的作用是解析启动程序时传入的命令行参数,用来配置WebServer的各种运行参数,提高了运行灵活性。
WebServer类
webserver.h:
#ifndef WEBSERVER_H
#define WEBSERVER_H
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <stdlib.h>
#include <cassert>
#include <sys/epoll.h>
#include "./threadpool/threadpool.h"
#include "./http/http_conn.h"
const int MAX_FD = 65536; //最大文件描述符
const int MAX_EVENT_NUMBER = 10000; //最大事件数
const int TIMESLOT = 5; //最小超时单位
class WebServer
{
public:
WebServer();
~WebServer();
void init(int port , string user, string passWord, string databaseName,
int log_write , int opt_linger, int trigmode, int sql_num,
int thread_num, int close_log, int actor_model);
void thread_pool();
void sql_pool();
void log_write();
void trig_mode();
void eventListen();
void eventLoop();
void timer(int connfd, struct sockaddr_in client_address);
void adjust_timer(util_timer *timer);
void deal_timer(util_timer *timer, int sockfd);
bool dealclientdata();
bool dealwithsignal(bool& timeout, bool& stop_server);
void dealwithread(int sockfd);
void dealwithwrite(int sockfd);
public:
//基础
int m_port;
char *m_root;
int m_log_write;
int m_close_log;
int m_actormodel;
int m_pipefd[2];
int m_epollfd;
http_conn *users;
//数据库相关
connection_pool *m_connPool;
string m_user; //登陆数据库用户名
string m_passWord; //登陆数据库密码
string m_databaseName; //使用数据库名
int m_sql_num;
//线程池相关
threadpool<http_conn> *m_pool;
int m_thread_num;
//epoll_event相关
epoll_event events[MAX_EVENT_NUMBER];
int m_listenfd;
int m_OPT_LINGER;
int m_TRIGMode;
int m_LISTENTrigmode;
int m_CONNTrigmode;
//定时器相关
client_data *users_timer;
Utils utils;
};
#endif
我们给类成员变量做个分类:
1.网络通信核心
int m_listenfd;//监听socket
int m_epollfd;//epoll实例
int m_pipefd[2];//用于信号处理的管道
http_conn *users;//http连接
epoll_event events[MAX_EVENT_NUMBER];//epoll事件数组
2.组件管理
threadpool<http_conn> *m_pool;//线程池
connection_pool *m_connPool;//数据库连接池
3.配置参数
int m_listenfd;//监听Socket的文件描述符
int m_OPT_LINGER;//socket关闭时的行为选项,0为默认关闭,1为优雅关闭(等待数据发送完毕)
int m_TRIGMode;//触发模式
int m_LISTENTrigmode;//监听socket的触发模式,决定epoll如何通知有新连接到达
int m_CONNTrigmode;//连接socket的触发模式,决定epoll如何通知连接上有数据可读/写
int m_thread_num;//线程数量
int m_sql_num;//连接池连接数量
int m_port;//端口号
char *m_root;//网站根目录的路径
int m_log_write;//日志写入方式(同步/异步)
int m_close_log;//是否关闭日志功能
int m_actormodel;//并发模型(0:Proactor模式,异步I/O;1:Reactor模式,同步I/O+非阻塞)
//Reactor:主线程监听事件,工作线程处理业务逻辑
//Proactor:主线程完成I/O操作,工作线程直接处理数据
4.定时器相关
client_data *users_timer; // 用户定时器数据
Utils utils;// 工具类
再给类成员函数做个分类:
1.初始化方法
void init(int port , string user, string passWord, string databaseName,
int log_write , int opt_linger, int trigmode, int sql_num,
int thread_num, int close_log, int actor_model);
这个方法接收所有配置参数,为服务器运行做准备。
2.组件初始化
void thread_pool();//初始化线程池
void sql_pool();//初始化数据库连接池
void log_write();//初始化日志系统
void trig_mode();//设置触发模式
3.事件循环核心
void eventListen();//创建监听socket,设置epoll
void eventLoop();//主事件循环
4.事件处理
bool dealclientdata();//处理新连接
bool dealwithsignal(bool& timeout, bool& stop_server);//处理信号
void dealwithread(int sockfd);//处理读事件
void dealwithwrite(int sockfd);//处理写事件
5.定时器管理
void timer(int connfd, struct sockaddr_in client_address);//为新连接创建定时器
void adjust_timer(util_timer *timer);//调整定时器
void deal_timer(util_timer *timer, int sockfd);//处理超时连接
现在我们对服务器的启动流程有了一个大致的了解:
1.解析参数:init()->各组件初始化。
2.创建监听:eventListen()->创建Socket,绑定端口,开始监听。
3.进入循环:eventLoop()->主事件循环。
这个过程和我之前写的聊天室还是很像的。
构造函数实现
WebServer::WebServer()
{
//http_conn类对象
users = new http_conn[MAX_FD];
//root文件夹路径
char server_path[200];
getcwd(server_path, 200);
char root[6] = "/root";
m_root = (char *)malloc(strlen(server_path) + strlen(root) + 1);
strcpy(m_root, server_path);
strcat(m_root, root);
//定时器
users_timer = new client_data[MAX_FD];
}
一、http连接对象数组初始化
users = new http_conn[MAX_FD];
预分配了MAX_FD(65536)个http_conn对象,每个文件描述符对应一个http_conn实例,这样文件描述符直接作为数组索引,实现了O(1)时间复杂度的查找。
为什么选择MAX_FD为65536?因为Linux默认文件描述符上限通常是65536(2的16次方)。而且每个http连接大约是1KB,总共也就大约为64MB,这是可以接受的内存开销。
什么是文件描述符?它是操作系统为了管理打开的文件/资源而分配的一个非负整数,是进程级别的标识符。更通俗地说,文件对象本身是唯一的,但在不同进程下,它们可能有不同的编号,每个进程都有自己的文件描述符表。
举个例子:
// 假设有个文件 /home/user/data.txt
// 内核中只有一个对应的文件对象
// 进程A
int fd1 = open("/home/user/data.txt", O_RDONLY); // 返回3
int fd2 = open("/home/user/data.txt", O_RDWR); // 返回4(再次打开)
// 进程B
int fd1 = open("/home/user/data.txt", O_RDONLY); // 返回3(不同进程!)
// 实际情况:
// 内核:只有一个 /home/user/data.txt 的文件对象
// 进程A:文件描述符3和4都指向这个文件对象
// 进程B:文件描述符3也指向这个文件对象
需要注意的是,文件描述符的分配发生在socket创建时,而不是http_conn实例化时。
//创建监听socket(eventListen方法中)
m_listenfd=socket(AF_INET,SOCK_STREAM,0);//操作系统分配文件描述符,假设返回3
此时,文件描述符3已经分配给了监听socket,users[3]还是一个未初始化的http_conn对象
//接受新连接
int connfd=accept(m_listenfd,...);//操作系统分配新的文件描述符,假设返回4
//初始化对应的http_conn对象
users[connfd].init(connfd,client_addr);
现在users[4]才被真正激活。文件描述符4是在accept调用时由操作系统分配的,然后我们才用这个文件描述符濑初始化对应的http_conn对象。最终,通过close(connfd),将文件描述符4回收,并通过users[connfd].close_conn()将对应的http_conn对象重置为空闲状态。
关于socket
先说一下socket。可以将它视为“接待室”。
a.创建接待室
int reception = socket(AF_INET, SOCK_STREAM, 0);
socket函数原型:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数:
domain(协议族):AF_INET(IPv4)、AF_INTE6(IPv6)、AF_UNIX(本地通信)
type(通信类型):SOCK_STREAM(面向连接的流式socket,TCP)、SOCK_DGRAM(无连接的数据报socket,UDP)
protocol:具体协议,通常填0.
b.布置接待室
struct sockaddr_in address = {...};
bind(reception, (sockaddr*)&address, sizeof(address));
c.开始营业(listen监听)
listen(reception, 5); // 允许5个客户在等候区等待
d.接待客户(accept阻塞等待)
int private_room = accept(reception, &client_addr, &addrlen);
//返回一个"私密会议室"的文件描述符,专门与这个客户交流
accept函数原型:
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
sockfd:监听socket的文件描述符
addr:输出参数,存放客户端地址信息
addrlen:输入输出参数,地址结构体长度
成功了就返回新的文件描述符,用于与客户端通信。失败了就返回-1.
例子:
// 1. 创建socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 设置socket选项(可选)
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定地址和端口
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
server_addr.sin_port = htons(8080); // 端口8080
int result = bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (result == 0) {
printf("绑定成功!服务器将在 0.0.0.0:8080 监听\n");
} else {
perror("绑定失败");
// 常见错误:EADDRINUSE(端口被占用)、EACCES(权限不足)
}
// 4. 开始监听
listen(listen_fd, 10);
// 5. 接受连接
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (conn_fd >= 0) {
printf("New connection from %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
// 6. 使用conn_fd与客户端通信
// ...
// 7. 关闭连接
close(conn_fd);
}
// 8. 关闭监听socket
close(listen_fd);
关于第三点:
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
server_addr.sin_port = htons(8080); // 端口8080
第一行:定义了一个IPv4地址结构体
struct sockaddr_in server_addr;
sockaddr_in结构:
struct sockaddr_in {
sa_family_t sin_family; // 地址族:AF_INET
in_port_t sin_port; // 端口号(16位)
struct in_addr sin_addr; // IPv4地址(32位)
char sin_zero[8]; // 填充字段
};
第二行:清空结构体
memset(&server_addr, 0, sizeof(server_addr));
将结构体中所有字节设为0,避免未初始化数据,防止结构体中的填充字段包含随机值。
第三行:设置地址族
server_addr.sin_family = AF_INET;
表示使用IPv4协议族。
第四行:设置IP地址
server_addr.sin_addr.s_addr = INADDR_ANY;
INADDR_ANY是一个特殊常量,值为0.0.0.0,表示监听所有可用的网络接口。
第五行:设置端口号
server_addr.sin_port = htons(8080);
htons全称:Host To Network Short,它将16位整数从主机字节序转换为网络字节序。之所以要进行转换,是因为不同的CPU架构使用不同的字节序。网络字节序是大端序,x86主机是小端序。转换函数有以下四个:
htons(8080) // Host to Network Short (16位)
htonl(12345) // Host to Network Long (32位)
ntohs() // Network to Host Short
ntohl() // Network to Host Long
端口号规则:
// 特权端口(0-1023):需要root权限
server_addr.sin_port = htons(80); // HTTP(需要sudo)
// 注册端口(1024-49151):常用服务
server_addr.sin_port = htons(8080); // Web开发常用
server_addr.sin_port = htons(3306); // MySQL
// 动态端口(49152-65535):临时使用
server_addr.sin_port = htons(0); // 系统自动分配
第六行至结尾:将socket与特定的IP地址和端口号关联
bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
bind()函数原型:
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
1.int sockfd
它是要绑定的socket文件描述符,其来源是socket()的返回值,必须要是未绑定的socket.
2.const struct sockaddr *addr
它是指向地址结构体的指针。要注意的是,需要将具体的地址结构体转换为通用的sockaddr指针,因为IPv4专用结构体和IPv6专用结构体都是通用地址结构体的派生类:
// 通用地址结构体(基类)
struct sockaddr {
sa_family_t sa_family; // 地址族
char sa_data[14]; // 地址数据
};
// IPv4专用结构体
struct sockaddr_in {
sa_family_t sin_family; // 地址族:AF_INET
in_port_t sin_port; // 端口号
struct in_addr sin_addr; // IP地址
// ... 填充字段
};
// IPv6专用结构体
struct sockaddr_in6 {
sa_family_t sin6_family; // AF_INET6
in_port_t sin6_port; // 端口号
struct in6_addr sin6_addr; // IPv6地址
// ...
};
bind()被设计为通用接口,可以处理多种地址族,但我们在使用时传递的是具体的结构体,所以需要强制类型转换。
3.socklen_t addrlen
它是地址结构体的长度,通常使用sizeof()获取。
绑定失败的常见原因:
1.端口被占用(EADDRINUSE)
// 错误:Address already in use
// 解决:设置SO_REUSEADDR选项
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(listen_fd, ...);
2.权限不足(EACCES):绑定1024以下的特权端口需要root权限
// 错误:Permission denied
server_addr.sin_port = htons(80); // HTTP端口需要sudo
3.地址不可用
需要注意的是,bind()不是建立连接,而是给socket分配一个本地地址,是告诉别人自己的位置。
比喻:socket()是建好酒店大楼,bind()是给大楼挂上地址门牌,listen()是开始营业,accept()是接待客人。
服务端:
//告诉别人我在哪里
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 设置服务器地址
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY; // 本机所有IP
server_addr.sin_port = htons(8080); // 端口8080
// bind:声明:将在8080端口提供服务
bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// listen:开始监听连接请求
listen(server_fd, 10);
// accept:等待客户端连接
int client_fd = accept(server_fd, ...);
客户端:
// 主动连接服务器
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
// 设置目标服务器地址
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr);// 绑定到特定IP
// connect:主动连接到服务器
connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 这里才真正建立网络连接(三次握手)
另外,一个监听socket可以服务多个客户端:
int listen_fd = socket(...);
bind(...);
listen(...);
while (true) {
// 每次accept返回一个新的通信socket
int client1_fd = accept(listen_fd, ...); // 客户端1
int client2_fd = accept(listen_fd, ...); // 客户端2
int client3_fd = accept(listen_fd, ...); // 客户端3
// listen_fd 继续监听新连接
// client1_fd, client2_fd, client3_fd 分别与不同客户端通信
}
二、 根目录路径设置
char server_path[200];
getcwd(server_path, 200); // 获取当前工作目录
char root[6] = "/root";
m_root = (char *)malloc(strlen(server_path) + strlen(root) + 1);
strcpy(m_root, server_path);
strcat(m_root, root);
getcwd的全称:Get Current Working Directory
函数原型:
#include <unistd.h>
char *getcwd(char *buf, size_t size);
buf:存储路径的缓冲区,size:缓冲区大小。成功就返回指向缓冲区的指针,失败则返回NULL.
执行过程:getcwd()获取程序运行的当前目录,拼接/root子目录作为网站根目录,动态分配内存以存储完整路径。
例子:如果程序在`/home/user/TinyWebServer中运行,那么m_root=/home/user/TinyWebServer/root
三、定时器数据初始化
users_timer = new client_data[MAX_FD];
作用:为每个可能的文件描述符预分配定时器数据,与users数组一一对应,用来管理连接超时。
构造函数中的预分配的设计体现了用空间换时间的思想。
优点:
1.高性能:文件描述符直接作为索引,查找操作时间复杂度为O(1)
2.避免内存碎片:一次性分配大块内存
3.线程安全:每个连接独立,减少锁竞争
缺点:
1.内存浪费:可能有大部分槽位空闲
2.扩展性限制:最大连接数固定
❓ 如果服务器需要支持更多连接,如何改进这个设计?
我们可以使用连接池:
class ConnectionPool {
private:
std::queue<http_conn*> free_connections;
std::unordered_map<int, http_conn*> active_connections;
std::mutex pool_mutex;
public:
ConnectionPool(size_t initial_size = 10000) {
// 预分配一批对象
for (size_t i = 0; i < initial_size; ++i) {
free_connections.push(new http_conn());
}
}
http_conn* acquire(int fd) {
std::lock_guard<std::mutex> lock(pool_mutex);
http_conn* conn = nullptr;
if (!free_connections.empty()) {
conn = free_connections.front();
free_connections.pop();
} else {
conn = new http_conn();
}
active_connections[fd] = conn;
return conn;
}
void release(int fd) {
std::lock_guard<std::mutex> lock(pool_mutex);
auto it = active_connections.find(fd);
if (it != active_connections.end()) {
free_connections.push(it->second); //对象复用
active_connections.erase(it);
}
}
};
1120

被折叠的 条评论
为什么被折叠?



