0x15-套接字编程-HTTP服务器(3)

  • 在一切开始之前,我们需要设想一下,为了让自己的HTTP服务器变得更加灵活,我们可以让某些参数不必硬编码进程序中,而是用配置文件的方式读取
  • 一个HTTP服务器的基本配置无非是

    • IP地址端口号根目录路径
    • 额外增加一个 线程数
    • 实际上,应该不需要我们人为指定,但为了调试方便,所以选择放在配置文件中
  • 接下来我们写一个可以解析配置文件的小模块函数

    1. struct init_config_from_file {
    2. int core_num; /* CPU Core numbers */
    3. #define PORT_SIZE 10
    4. char listen_port[PORT_SIZE]; /* */
    5. #define ADDR_SIZE IPV6_LENGTH_CHAR
    6. char use_addr[ADDR_SIZE]; /* NULL For Auto select(By Operating System) */
    7. #define PATH_LENGTH 256
    8. char root_path[PATH_LENGTH]; /* page root path */
    9. };
    10. typedef struct init_config_from_file wsx_config_t;

    这个是配置文件的所有属性,可以将读取的参数,存进这个结构体中,与主线程交互

    1. /*
    2. * Read the config file "wsx.conf" in particular path
    3. * and Put the data to the config object
    4. * @param config is aims to be a parameter set
    5. * @return 0 means Success
    6. * */
    7. int init_config(wsx_config_t * config);

    交互的接口,我的配置文件叫做 wsx.conf

对于配置文件存放位置而言,可以灵活一些,例如可以额外添加一个命令行参数,用来指定本次需要使用的配置文件路径: ./httpd -f /path/to/wsx.conf 当然这用在开发版本可以方便调试,实际上的HTTP服务器并不行,参见守护进程的定义

最经典的做法还是指定默认路径,将配置文件都存放在某个地方,可以多设定几个,并设定优先级

  • 想想,我们需要什么功能,我给自己的配置文件添加了注释功能,以#开头的都是注释,这点十分容易做到。
  • 上代码

    1. static const char * config_path_search[] = {CONFIG_FILE_PATH, "./wsx.conf", "/etc/wushxin/wsx.conf", NULL};
    2. int init_config(wsx_config_t * config){
    3. const char ** roll = config_path_search;
    4. FILE * file;
    5. for (int i = 0; roll[i] != NULL; ++i) {
    6. file = fopen(roll[i], "r");
    7. if (file != NULL)
    8. break;
    9. }
    10. if (NULL == file) {
    11. #if defined(WSX_DEBUG)
    12. fprintf(stderr, "Check For the Config file, does it stay its life?\n"
    13. "In Such Path: \n%s\n%s\n%s\n", config_path_search[0], config_path_search[1], config_path_search[2]);
    14. #endif
    15. exit(-1);
    16. }
    17. ...未结束

    这是很简单的文件操作,包括打开文件,验证是否成功,可以选择将其封装成一个inline函数,来模块化这个逻辑。

    1. char buf[PATH_LENGTH] = {"\0"};
    2. char * ret;
    3. ret = fgets(buf, PATH_LENGTH, file);
    4. while (ret != NULL) {
    5. char * pos = strchr(buf, ':');
    6. char * check = strchr(buf, '#'); /* Start with # will be ignore */
    7. if (check != NULL)
    8. *check = '\0';
    9. if (pos != NULL) {
    10. *pos++ = '\0';
    11. if (0 == strncasecmp(buf, "thread", 6)) {
    12. sscanf(pos, "%d", &config->core_num);
    13. }
    14. else if (0 == strncasecmp(buf, "root", 4)) {
    15. sscanf(pos, "%s", &config->root_path);
    16. /* End up without "/", Add it */
    17. if ((config->root_path)[strlen(config->root_path)-1] != '/') {
    18. strncat(config->root_path, "/", 1);
    19. }
    20. }
    21. else if (0 == strncasecmp(buf, "port", 4)) {
    22. sscanf(pos, "%s", &config->listen_port);
    23. }
    24. else if (0 == strncasecmp(buf, "addr", 4)) {
    25. sscanf(pos, "%s", &config->use_addr);
    26. }
    27. } /* if pos != NULL */
    28. ret = fgets(buf, PATH_LENGTH, file);
    29. } /* while */
    30. fclose(file);
    31. return 0;
    32. }

    真正的核心代码没几行,四个if,使用strncasecmp函数,检测参数。但是并没有 验证参数的正确性

    当然你也可以写成 json 的形式,再用第三方库,比如c-json之类的解析,但 那不是要依赖第三方了吗?所以我的建议还是自己写一个解析的函数。

    如果没能理解这小段代码,建议翻一下C语言的入门教材,回顾一下语法。

  • 配置文件的样式

    1. # Just Edit this Config file Or
    2. # You can Create a new one and save the Old to
    3. # Back up
    4. # But Remember that , that file can only parse
    5. # the FOUR CONFIGURATION :
    6. # thread root port address
    7. # Watch out the case sensitive !!!
    8. # thread -- For the Worker thread number
    9. # root -- For the WebSite's root path
    10. # port -- Listen Port
    11. # address -- Host's address(Note it If you can)
    12. # Or empty For the auto select by Operating System
    13. thread:8
    14. # Using shell Command (pwd) to show your root Path!
    15. root:/root/ClionProjects/httpd3/
    16. port:9998 # That is a port
    17. address:192.168.141.149
  • 配置文件读取完成了,我们是时候设计一下主函数的流程了,回想一下流程图,下一步就应该创建套接字,绑定,并监听(listen)了!(流程图中没有画出listen,过于冗余,但却必不可少)

  • 可以将 创建,绑定合并成一个函数,在成功之后,再执行listen

    1. /*
    2. * Open The Listen Socket With the specific host(IP address) and port
    3. * That must be compatible with the IPv6 And IPv4
    4. * host_addr could be NULL
    5. * port MUST NOT BE NULL !!!
    6. * sock_type is the pointer to a memory ,which comes from the Outside(The Caller)
    7. * */
    8. int open_listenfd(const char * restrict host_addr,const char * restrict port, int * restrict sock_type);

    可以看出来,需要一个IP, 一个PORT, 第三个参数是套接字类型担不是传入参数,而是传出参数。

    1. int open_listenfd(const char * restrict host_addr, const char * restrict port, int * restrict sock_type){
    2. int listenfd = 0; /* listen the Port, To accept the new Connection */
    3. struct addrinfo info_of_host;
    4. struct addrinfo * result;
    5. struct addrinfo * p;
    6. /* 实际上这一行完全可以在上面使用 初始化来达到目的。
    7. * struct addrinfo info_of_host = {0}; 需要c99
    8. */
    9. memset(&info_of_host, 0, sizeof(info_of_host));
    10. info_of_host.ai_family = AF_UNSPEC; /* Unknown Socket Type */
    11. info_of_host.ai_flags = AI_PASSIVE; /* Let the Program to help us fill the Message we need */
    12. info_of_host.ai_socktype = SOCK_STREAM; /* TCP */
    13. int error_code;
    14. if(0 != (error_code = getaddrinfo(host_addr, port, &info_of_host, &result))){
    15. fputs(gai_strerror(error_code), stderr);
    16. return ERR_GETADDRINFO; /* -2 */
    17. }
    18. for(p = result; p != NULL; p = p->ai_next) {
    19. listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
    20. if(-1 == listenfd)
    21. continue; /* Try the Next Possibility */
    22. optimizes(listenfd);
    23. if(-1 == bind(listenfd, p->ai_addr, p->ai_addrlen)){
    24. close(listenfd);
    25. continue; /* Same Reason */
    26. }
    27. break; /* If we get here, it means that we have succeed to do all the Work */
    28. }
    29. freeaddrinfo(result);
    30. if (NULL == p) {
    31. fprintf(stderr, "In %s, Line: %d\nError Occur while Open/ Binding the listen fd\n",__FILE__, __LINE__);
    32. return ERR_BINDIND;
    33. }
    34. fprintf(stderr, "DEBUG MESG: Now We(%d) are in : %s , listen the %s port Success\n", listenfd,
    35. inet_ntoa(((struct sockaddr_in *)p->ai_addr)->sin_addr), port);
    36. *sock_type = p->ai_family;
    37. set_nonblock(listenfd);
    38. return listenfd;
    39. }

    其中有一个optimizes,是用来设置一些套接字选项的,现在只需要知道有这些选项就行

    套接字选项分别是TCP_NODELAYSO_REUSEADDR

    细看之下,和前面介绍的几个接口几乎是完全一致的用法。但如果认为网络编程就是这样接口调用的话,那就是大错特错。

    就这样,如果你的配置文件中,没什么差错的话,我们就完成了打开服务器套接字的工作,这时候你可以组织并且运行一下前面说的这些代码,看看是否如此。

运行成功与否可以通过你的终端是否显示上述的调试信息看出来:

DEBUG MESG: Now We(x) are in : %s , listen the xx port Success

  • 写到这里,实际上整个主函数的代码已经接近尾声,来看看全部的过程调用

    1. int main(int argc, char * argv[]) {
    2. wsx_config_t config = {0};
    3. init_config(&config)
    4. int sock_type = 0;
    5. int listenfd = open_listenfd(config.use_addr, config.listen_port, &sock_type);
    6. listen(listenfd, SOMAXCONN);
    7. signal(SIGPIPE, SIG_IGN);
    8. handle_loop(listenfd, sock_type, &config);
    9. return 0;
    10. }

    这个逻辑已经十分清晰,为了方便我省去了错误检查,在代码中应该自己添加,这里面有两个新事物: signal(), handle_loop()

  • 来解释一下signal(SIGPIPE, SIG_IGN)是什么以及为什么

    • signal是信号函数,还记得之前的章节用它来当做函数指针类型的一个练习思考题吗?它的作用就是在本进程/线程接收到该信号(SIGPIPE)时候,会进行这样的(SIG_IGN)处理
    • 当然它有更好更推荐的做法sigation,比较复杂但是也比较推荐你用它,这里为了减少概念,就用了最原始的signal
    • SIGPIPE是一个关于写的错误,触发条件是向一个发送了RST的对端进行写操作,默认行为就是结束本进程,我们当然不愿意结束了,明明是对方的错,怎么要我们死。最基本的做法就是忽略它SIG_IGN
    • 稍微解释一下SIGPIPE,模拟一下情形,这里需要对TCP的工作方式有一定了解,不了解的可以跳过:
      • TCP是全双工的,意味着可读可写,假设有A,B端,本来工作的好好的,突然B端崩溃退出了,那自然联系A,B端的套接字连接就断了,但是A端并不懂啊,它这时候只知道B端不会再发送消息给自己了(因为接到了B发给自己的FIN,自己回复了ACK,关闭了接收通道),并不懂自己还能不能发消息给B啊(所以A当做自己能发给B端)
      • 然而实际上,现在哪里还能发消息给B啊,这就回到了上面,如果向一个发送了RST的对端进行写操作的话,就会触发SIGPIPE,信号这个东西就是全局的,所以如果你想知道哪个线程触发了这个信号,还需要检查写操作是否返回了EPIPE错误
    • 看不懂也无所谓,来日方长,细水长流。这就是这一行代码的意义,就是为了忽略这个信号。
  • handle_loop 是一个事件循环的入口

    • 就是所有的事务处理准备都在里面,回想一下流程图,我们接下来该干什么
    • 使用epoll监听服务器套接字,用来建立新连接
    • 分配新连接给子线程,在其中处理各种事件。
    • 呐,实际上handle_loop就干了两件事
      • 准备一下服务器资源(包括存储新连接的各种信息)
      • 创建子线程用来 监听服务器套接字处理新连接事件
  • 几个全局变量

    1. static int * epfd_group = NULL; /* Workers' epfd set */
    2. static int epfd_group_size = 0; /* Workers' epfd set size */
    3. static int workers = 0; /* Number of Workers */
    4. static int listeners = MAX_LISTEN_EPFD_SIZE; /* Number of Listenner */
    5. static conn_client * clients; /* Client set */
  • handle_loop()

    1. void handle_loop(int file_dsption, int sock_type, const wsx_config_t * config) {
    2. workers = config->core_num - listeners;
    3. int listen_epfd = epoll_create1(0);
    4. { /* Register listen fd to the listen_epfd */
    5. struct epoll_event event;
    6. event.data.fd = file_dsption;
    7. event.events = EPOLLET | EPOLLERR | EPOLLIN;
    8. /* 以ET方式监听file_dsption的读事件,错误事件 */
    9. epoll_ctl(listen_epfd, EPOLL_CTL_ADD, file_dsption, &event);
    10. }
    11. /* Prepare Workers Sources */
    12. prepare_workers(config);
    13. pthread_t listener_set[listeners];
    14. pthread_t worker_set[workers];
    15. for (int i = 0; i < listeners; ++i)
    16. pthread_create(&listener_set[i], NULL, listen_thread, (void*)listen_epfd);
    17. for (int j = 0; j < workers; ++j) {
    18. pthread_create(&worker_set[j], NULL, workers_thread, (void*)(epfd_group[j]));
    19. pthread_detach(worker_set[j]);
    20. }
    21. for (int k = 0; k < listeners; ++k)
    22. pthread_join(listener_set[k], NULL);
    23. destroy_resouce();
    24. }

    使用了最原始的线性数组来存储所有的连接信息(conn_client),这其实弊端很大,比如最明显的数量以及预分配的资源过大。但关键是够简单,且效率最高。

    整个的原理就是,在接受到新连接以后,按照某种规则分配给第i个子线程,每个子线程中有一个工作epoll(epoll_group[i-1]),用来监听新连接的事件,并处理。

    prepare_workers 就是分配内存空间的相关工作。这段代码,同样省略了错误检查,希望自己添加。

    {}里面可以看出来怎么向epoll实例中注册监听实体,以及监听事件。

    整段代码的后半部分,是关于线程的启动,操作,销毁。pthread_detach意味着放弃线程的资源回收权,用通俗的话来说就是:“撒丫子跑吧,我管不着你了!”。

  • 这就是完整的一个主函数逻辑,实际上非常简单,到现在为止也没出现过十分复杂的东西,就像在做繁琐的准备工作一样。

下一节将会详细讲解

  1. 连接信息都有哪些需要存储的
  2. 如何处理读事件,字符数据的管理呢?