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

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

  • 一个HTTP服务器的基本配置无非是

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

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

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

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

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

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

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

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

  • 上代码

      static const char * config_path_search[] = {CONFIG_FILE_PATH, "./wsx.conf", "/etc/wushxin/wsx.conf", NULL};
    
      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;
      	}
      	if (NULL == file) {
      #if defined(WSX_DEBUG)
      		fprintf(stderr, "Check For the Config file, does it stay its life?\n"
      		"In Such Path: \n%s\n%s\n%s\n", config_path_search[0], config_path_search[1], config_path_search[2]);
      #endif
      		exit(-1);
      	}
      ...未结束
    

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

      	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);
      			}
      		} /* if pos != NULL */
      	ret = fgets(buf, PATH_LENGTH, file);
      	} /* while */
      	fclose(file);
      	return 0;
      }
    

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

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

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

  • 配置文件的样式

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

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

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

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

      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;
    
      	/* 实际上这一行完全可以在上面使用 初始化来达到目的。
      	 * struct addrinfo info_of_host = {0}; 需要c99
      	 */
      	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 */
      		optimizes(listenfd);
      		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) {
      		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);
      	return listenfd;
      }
    

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

    套接字选项分别是TCP_NODELAYSO_REUSEADDR

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

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

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

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

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

      int main(int argc, char * argv[]) {
      	wsx_config_t config = {0};
      	init_config(&config)
    
      	int sock_type = 0;
      	int listenfd = open_listenfd(config.use_addr, config.listen_port, &sock_type);
      	listen(listenfd, SOMAXCONN);
      	signal(SIGPIPE, SIG_IGN);
      	handle_loop(listenfd, sock_type, &config);
      	return 0;
      }
    

    这个逻辑已经十分清晰,为了方便我省去了错误检查,在代码中应该自己添加,这里面有两个新事物: 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就干了两件事
      • 准备一下服务器资源(包括存储新连接的各种信息)
      • 创建子线程用来 监听服务器套接字处理新连接事件
  • 几个全局变量

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

      void handle_loop(int file_dsption, int sock_type, const wsx_config_t * config) {
      	workers = config->core_num - listeners;
      	int listen_epfd = epoll_create1(0);
      	{ /* Register listen fd to the listen_epfd */
      		struct epoll_event event;
      		event.data.fd = file_dsption;
      		event.events = EPOLLET | EPOLLERR | EPOLLIN;
      		/* 以ET方式监听file_dsption的读事件,错误事件 */
      		epoll_ctl(listen_epfd, EPOLL_CTL_ADD, file_dsption, &event);
      	}
      	/* Prepare Workers Sources */
      	prepare_workers(config);
      	pthread_t listener_set[listeners];
      	pthread_t worker_set[workers];
      	for (int i = 0; i < listeners; ++i) 
      		pthread_create(&listener_set[i], NULL, listen_thread, (void*)listen_epfd);
      	for (int j = 0; j < workers; ++j) {
      		pthread_create(&worker_set[j], NULL, workers_thread, (void*)(epfd_group[j]));
      		pthread_detach(worker_set[j]);
      	}
      	for (int k = 0; k < listeners; ++k) 
      		pthread_join(listener_set[k], NULL);
      	destroy_resouce();
      }
    

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

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

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

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

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

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

下一节将会详细讲解

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

书籍推荐