转载

一个HTTP服务器的C之路(上)

记录近来写的HTTP服务器的 经历,坑,收获

万事皆有起因

  • 大三了,又在反思自己和科班的差距,课少了,空闲时间突然多了起来,在这个大学里,这么一路走过来,自己摸索的不容易
  • 天天看着浏览器的 F12 界面和 Wireshark 的抓取交互界面,突然想要自己做一个HTTP服务器,首先冷静了三天,发现这个念头依旧强烈,墙裂。
  • 在翻看 TCP/IP卷一协议 无果后,于是我开始蹲点图书馆,借 HTTP权威指南图解HTTP ,然而事与愿违,这个号称 ”黑龙江最大图书馆“ 称呼的学校图书馆竟无法找到这本书。行吧,那我不借了总行吧
  • 回想起自己大二节选看的 深入理解计算机操作系统(CSAPP) 这本著名的书,似乎后面有一个Web服务器实现,虽然知道这大概是一个十分简陋的单处理的程序,但是对于理清自己思路很有帮助,特别是协议的交互
  • 翻看一开,还真的有,说来惭愧前面的进程部分至今还是崭新的。首先看到了 CGI ,后来发现了后方的 GET 方法实现,大概了解了交互的过程,这之间在 w3cschool 查找了(恶补了一下HTTP状态码)一些HTTP的状态,以及 维基百科 里查看了HTTP报文的组成,当然最详细的还是使用WireShark抓取的交互包,而最简单明了的方法则是用 F12 查看交互信息。
  • 这之中有两个网站对我助力很大

    • Questions regarding both Clients and Servers
    • Socket options SO_REUSEADDR and SO_REUSEPORT, how do they differ?
  • Linux 的 man page 是另一个最有用的工具。

用C编写一个HTTP服务器?!

  • 我说了这个想法给我的一个同学,他以为我疯了。
  • 并没有,我在网上查了近来的许多模型,最后选择参考 事件驱动的epoll + 多线程 进行实现,一方面直接避免了逻辑上的惊群现象,一方面也能充分利用多核。
  • 我怎么想的,在最开始我对照着 CSAPP 对最开始的版本代码进行编写,不得不说里面有一个对系统调用( read , write )的调用在后面给我一个大坑,也给我很多思路上的启发。

    • 启发就在于,将系统调用进行自己的包装,可以提供很干净的C代码,并且可以进行良好功能的编写,这里特指对 数据的缓存错误处理 ,而这两个都是网络编程里的重头戏
    • 大坑在于,我到后期发现,一个新的客户端在连接上本服务器之后,其信息应该尽可能集中,而不是分散在各处不同的结构体中,不容易维护,而且出了错还不容易发现。特别在于 CSAPP 中的 rio 形式的读写封装,无法进行长久性的数据缓存,在这里长久性的数据缓存指的是,假设对端( peer )正忙,或者自己的TCP发送缓冲区已满,那这个数据要怎么办?
  • 不过读了 CSAPP 之后,最基本的服务器工作原理还是可以知道的,至少我看到了某些让我目瞪口呆的HTTP协议之间的交互方式。

    • 比如报文还真的是如我想象那般,完全自己构造的。
    • 对于一个几乎没有高级字符串处理功能的C语言来说,这的确是一个苦活,但是正因为如此,才能接触到相对真实的 HTTP服务器

      首先

  • 每个人最开始学网络编程的时候,或者说几乎每本讲述网络编程的书籍,都会教你实现一个 echo 程序,无论是 TCP 还是 UDP , 当然这并不是说没有用,再复杂的网络程序,只要还是使用 套接字进行编程,那就不会走出这个最简单的模型

    • 服务器的 socket->bind->listen->accept deal_event
    • 客户端的 socket->bind->connect deal_event
    • 这是 TCP 的,因为说的是HTTP服务器。
  • HTTP服务器程序也是如此,由于HTTP协议支持几个标准的 方法 ,但是一般的浏览器实现会实现两个: GET, POST

  • 用人话科普一下:
    • GETPOST 本质功能 都是查询,只不过有时候用途不同,所以会造成某些人认为这两个的意义不同
    • GET 很明确,就是提供了查询功能,如果使用它对服务器进行访问,那么它所要查询的信息会放在状态信息中: GET /3/18/hello-world.html HTTP/1.1 。这就是每个浏览器向一个网站请求时,在成功建立 TCP 连接之后的 第一条信息 ,也就是服务器可以从 peer 读取的第一条信息。
      • 其实 HTTP 协议并没有限制中间那个参数的长度!很多人认为的长度限制是 1024 ,那只是你所用的浏览器的实现中,浏览器规定这个请求资源的参数长度不能超过 1024 ,你要是自己做一个浏览器,你可以放长点。
      • 就某些情况而言,用 GET 方法会让某些特殊用处 参数 显示在你的地址栏中,例如动态请求一些东西的时候,也就是我们在HTTP服务器中的一个叫做 CGI 程序的概念。
        • POST 则是将请求放在报文段,这样就给了很多人误解,就是上面所说的 GET 所传的参数长度有限,而 POST 是几乎无限的。两个实际上没有差太多。至少在服务器这一端,只不过是获取参数的时候,方式稍微有些不同。

其次

  • 在用C写这个HTTP服务器的时候,有几个难点和遇到的磕磕绊绊,在这里记录一下

我所踩过的泥

说在前头的程序配置

  • 对于有网络编程经验的人来说,都不太陌生,哪怕只写过 echo 程序,那些服务器所必须的硬配置,也就是 五元组 里的 <IP address, Port>
  • 好一些的教科书会告诉我们,将这两个参数硬编码进代码中,是不好的选择,一旦需要修改,调试,测试,就需要重新编译程序。
    • 所以,他们每次都通过命令行进行传递,并在程序中显式地进行转换。
    • 稍有不慎,就是大错,而且这在调试的时候,十分不方便(还好 Shell 自带记录功能,不然更累)
  • 所以,在写一个大一些的网络程序之前,请务必写一个 配置文件 ,再多花一个函数的时间,写一个能够处理它的小方法,这并不难,甚至还可以给它添上一些注释的小功能。
  • 让自己的调试之路更加顺畅,那就用配置文件的形式进行传递参数,不然效率一定会被这个给拖住。

    int init_config(wsx_config_t * config){     const char ** roll = config_path_search;     FILE * file;     for (int i = 0; roll[i] != NULL; ++i) {             file = fopen(roll[i], "r");             if (file != NULL)                 break;     }     char buf[PATH_LENGTH] = {"/0"};     char * ret;     ret = fgets(buf, PATH_LENGTH, file);     while (ret != NULL) {         char * pos = strchr(buf, ':');         char * check = strchr(buf, '#'); /* Start with # will be ignore */         if (check != NULL)             *check = '/0';         if (pos != NULL) {             *pos++ = '/0';             if (0 == strncasecmp(buf, "thread", 6))                 sscanf(pos, "%d", &config->core_num);             else if (0 == strncasecmp(buf, "root", 4)) {                 sscanf(pos, "%s", &config->root_path);                     /* End up without "/", Add it */                     if ((config->root_path)[strlen(config->root_path)-1] != '/')                         strncat(config->root_path, "/", 1);             }             else if (0 == strncasecmp(buf, "port", 4))                 sscanf(pos, "%s", &config->listen_port);             else if (0 == strncasecmp(buf, "addr", 4))                 sscanf(pos, "%s", &config->use_addr);         }         ret = fgets(buf, PATH_LENGTH, file);     }/* while */     fclose(file);     return 0; } 

整个程序的构造

  1. 由于C语言没有像 Java 那样的包 inport 机制,甚至连C++那样的 namespace 都没有。那对于C语言程序的整体把握,就都在程序员手里,如果没有办法做好模块化,那真的是世界上最可怕的事情
  2. 而对于C语言的模块化,唯一能起到作用的就是 文件夹 ,没错就是强行做一波解释,让自己相信这就是隔离开的模块。但确实很有效果:有两种形式:

    1. 发布的库形式 : 将所有可提供向外的接口 API 分发在不同的头文件中(以功能作为模块划分的标准),在根目录下建立 include 文件夹,将这些头文件都放进去。而源文件,以及一些自用的头文件放在根目录下的 src 文件夹中, src 文件夹中再进行模块细分,例如 内存管理(manage)页面分发(http_page) 之类的模块文件夹。
    2. 工程形式 : 就是消除 include 文件夹,只保留 src 文件夹,不对外提供作为的API,可以让工作量减少,尽量的使用 static 来修饰函数。
  3. 在目前阶段,先不要考虑使用外来库,对于一些不必要的库一定要自行编写,而不要采用第三方库,这样会提高编译成本。让用户使用体验降低。

    1. 在后期可以考虑使用一些优化,强化的库,例如 tcmalloc/jemalloc 以及 ssl 的通信加密库之类的,而对于数据结构而言,千万要选择自行编写,而不应该依赖其他库例如 glib 之类的。
    2. 可以选择使用 对标准库函数进行封装,这样提高程序的扩展性,这点体现在,随时可以通过宏来替换调用的版本,而不用在源码中大肆修改,一个最典型的例子就是内存分配的包装。(万恶的 realloc )

功能封装

  1. 封装这个词,我也不知道起源何处,只是第一次听说是从面向对象里听来的,但是觉得就算是过程式的编程,也可以有这个方面的用处,当然不像那些概念说的那么严谨,我一直觉得功能拿来用就很好,不要墨守成规,虽然没有规矩不成方圆,但那对于程序员这种职业,如果思维都被限制了,那还怎么改变世界,对吧。

    1. 就像数据结构 Trie 树,当初第一眼看到这个数据结构的时候,心里一直打鼓这什么东西,结果仅仅只是去百科了一下这个树的概念,脑海里立即浮现出这个树的构造来,就是每个节点一个字符,依次向下增长,空间换时间的意思,正好依此做了一个生命学院里DNA序列匹配的程序,还实现了一个很纯粹的 KMP 对比了一下,前后不过一天时间而已。
    2. 做东西还是不能被已有的事物左右,最起码一开始的时候不行,后期等你形成了自己的思路后再去参考别人的也不迟,就像当初学数据结构的时候,只知道链表的下一个节点指针就应该用本类型的指针指向,从未想过实现一个通用的链表,可以无所谓节点的类型,就能指向下一个节点(Linux内核链表实现)。
    3. 所有的一切都说明,思维不该被现有的模式所控制。
  2. 要尽量将某些零散的功能进行合并,保持代码的模块化,而且容易维护,例如对于打开监听套接字这个功能

    1. socket->bind ,像这种代码就不需要整个塞在 main 函数里,那样的话错误处理代码会把真个函数挤满。而不如使用一个函数将这些代码包装起来,咋 main 中调用。

      int open_listenfd(const char * restrict host_addr, const char * restrict port, int * restrict sock_type){     int listenfd = 0; /* listen the Port, To accept the new Connection */     struct addrinfo info_of_host;     struct addrinfo * result;     struct addrinfo * p;     memset(&info_of_host, 0, sizeof(info_of_host));     info_of_host.ai_family = AF_UNSPEC; /* Unknown Socket Type */     info_of_host.ai_flags = AI_PASSIVE; /* Let the Program to help us fill the Message we need */     info_of_host.ai_socktype = SOCK_STREAM; /* TCP */      int error_code;     if(0 != (error_code = getaddrinfo(host_addr, port, &info_of_host, &result))){         fputs(gai_strerror(error_code), stderr);         return ERR_GETADDRINFO; /* -2 */     }      for(p = result; p != NULL; p = p->ai_next) {         listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);          if(-1 == listenfd)             continue; /* Try the Next Possibility */         if(-1 == bind(listenfd, p->ai_addr, p->ai_addrlen)){             close(listenfd);             continue; /* Same Reason */         }         break; /* If we get here, it means that we have succeed to do all the Work */     }     freeaddrinfo(result);     if (NULL == p) {     /* TODO ERROR HANDLE */         fprintf(stderr, "In %s, Line: %d/nError Occur while Open/Binding the listen fd/n",__FILE__, __LINE__);         return ERR_BINDIND;     }      fprintf(stderr, "DEBUG MESG: Now We(%d) are in : %s , listen the %s port Success/n", listenfd,              inet_ntoa(((struct sockaddr_in *)p->ai_addr)->sin_addr), port);     *sock_type = p->ai_family;     set_nonblock(listenfd);     optimizes(listenfd);     return listenfd; } 

      这段代码就是对打开监听套接字的 socket->bind 的一个功能封装,使用的是 getaddrinfo 而不是传统的手工转换,降低了出错的概率而且更加可靠。

      有一个问题就是,注释太多,没有必要,这个只是在编写过程中让我快速回溯自己的想法,所以写了许多注释。

      set_nonblockoptimizes 都是自己包装的函数,功能和名字一样。在用程序内信息Debug的时候,记得不要使用 stdout ,而使用 stderr ,并且在正式使用时,程序应该 尽可能减少输出到终端

未完待续,上课略累

  • 具体项目源码 : httpd3 ·
  • 在并发下有BUG,还没时间去调试,等有空了再去Debug,欢迎你们帮我 : )
原文  http://www.wushxin.top/2016/03/23/一个HTTP服务器的C之路(上).html
正文到此结束
Loading...