WebServer 03

21 阅读15分钟

我们回到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);
        }
    }
};