从0到服务器开发——TinyWebServer(中)

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 从0到服务器开发——TinyWebServer

从0到服务器开发——TinyWebServer(上):https://developer.aliyun.com/article/1508267

功能细究

关于登录功能,页面跳转逻辑如下图所示,原图来自两猿社

上图的逻辑已经很清晰,根据HTTP请求的方法是GET还是POST,确定是获取注册/登录用户界面,还是更新用户密码跳转到登录成功界面。有关HTTP部分的介绍参考三、拔萝带泥-HTTP

具体一点,首先需要从数据库中获取所有的用户名和密码(PS:在实际的大型项目中用户密码的传输可以参考用户登录实践),这些用户名和密码以某种数据结构(如哈希表)保存。

当浏览器请求到达时,根据其请求访问,返回对应的界面html或是错误提示

整个过程其实是一个有限状态机。有限状态机?

有限状态机就是指系统状态从某一种状态转移到另外一种状态,表示“选择”和“更新状态”的过程。想进一步了解请参考:有限状态机?

由于该功能内部细节太多,请跳转阅读第三章、拔萝带泥-HTTP。

三、拔萝带泥——HTTP

这个部分是对第二章登录注册功能的详细解析。首先介绍Epoll的使用,再介绍HTTP的相关知识,而后在给出“用户登录注册”过程的细节。

Epoll

这个部分主要介绍epoll的函数调用框架,先看看epoll常用的函数。

常用函数

epoll_create

//创建一个指示epoll内核事件表的文件描述符
//该描述符将用作其他epoll系统调用的第一个参数
//size不起作用。
int epoll_create(int size)

epoll_ctl

//操作内核事件表监控的文件描述符上的事件:注册、修改、删除
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

其中,epfd:为epoll_creat的句柄

op:表示动作,用3个宏来表示:

  • EPOLL_CTL_ADD (注册新的fd到epfd),
  • EPOLL_CTL_MOD (修改已经注册的fd的监听事件),
  • EPOLL_CTL_DEL (从epfd删除一个fd);

event:告诉内核需要监听的事件

event结构体定义如下:

struct epoll_event {
    __uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
4};

events描述事件类型,其中epoll事件类型有以下几种

  • EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
  • EPOLLOUT:表示对应的文件描述符可以写
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读
  • EPOLLERR:表示对应的文件描述符发生错误
  • EPOLLHUP:表示对应的文件描述符被挂断
  • EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
  • EPOLLET: 边缘触发模式
  • EPOLLRDHUP:表示读关闭,对端关闭,不是所有的内核版本都支持;

epoll_wait

//该函数用于等待所监控文件描述符上有事件的产生
//返回就绪的文件描述符个数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

其中,

  • events:用来存内核得到事件的集合,
  • maxevents:告之内核这个events有多大,不能大于epoll_create()时的size;
  • timeout:是超时时间;
  • 返回值:成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1;

例子

实际应用中,epoll是怎么起作用的?代码原链接

//tcp server epoll并发服务器 
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <ctype.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#define  MAX_LINK_NUM   128
#define  SERV_PORT      8888
#define  BUFF_LENGTH    320
#define  MAX_EVENTS     5

int  count = 0;
int tcp_epoll_server_init(){
    //创建服务器端口的常用套路代码
  int sockfd = socket(AF_INET,SOCK_STREAM,0);
  if(sockfd == -1){
    printf("socket error!\n");
    return -1;
  }
  struct sockaddr_in serv_addr;
  struct sockaddr_in clit_addr;
  socklen_t clit_len;
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_port = htons(SERV_PORT);
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);// 任意本地ip
  int ret = bind(sockfd,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
  if(ret == -1){
    printf("bind error!\n");
    return -2;
  }
  listen(sockfd,MAX_LINK_NUM);

  //创建epoll
  int  epoll_fd = epoll_create(MAX_EVENTS);
  if(epoll_fd == -1){
    printf("epoll_create error!\n");
    return -3;
  }
  //向epoll注册sockfd监听事件
  struct epoll_event ev;   //epoll事件结构体
  struct epoll_event events[MAX_EVENTS];  //事件监听队列
  ev.events = EPOLLIN;
  ev.data.fd = sockfd;
  int ret2 = epoll_ctl(epoll_fd,EPOLL_CTL_ADD,sockfd,&ev);
  if(ret2 == -1){
    printf("epoll_ctl error!\n");
    return -4;
  }
  int connfd = 0;
  while(1){
    //epoll等待事件发生
      int nfds = epoll_wait(epoll_fd,events,MAX_EVENTS,-1);
      if(nfds == -1){
        printf("epoll_wait error!\n");
        return -5;
      }
      printf("nfds: %d\n",nfds);
      //检测
      for(int i = 0;i<nfds;++i){
        //客服端有新的请求
        if(events[i].data.fd == sockfd){
          //取出该连接
                connfd =  accept(sockfd,(struct sockaddr*)&clit_addr,&clit_len);
          if(connfd == -1){
            printf("accept error!\n");
            return -6;
          }
          ev.events = EPOLLIN;
          ev.data.fd = connfd;
              if(epoll_ctl(epoll_fd,EPOLL_CTL_ADD,connfd,&ev) == -1){
            printf("epoll_ctl add error!\n");
            return -7;
          }
          printf("accept client: %s\n",inet_ntoa(clit_addr.sin_addr));
          printf("client %d\n",++count);
        }
        //客户端有数据发送过来
        else{
          char buff[BUFF_LENGTH];
          int ret1 = read(connfd,buff,sizeof(buff));
          printf("%s",buff);
        }
      }

  }

  close(connfd);
  return 0;

}

int main(){
  tcp_epoll_server_init();
}

HTTP

HTTP介绍

HTTP报文

HTTP报文分为请求报文(浏览器端向服务器发送)和响应报文(服务器处理后返回给浏览器端)两种,每种报文必须按照特有格式生成,才能被浏览器端识别。

  • 请求报文=请求行(request line)、请求头部(header)、空行和请求数据四个部分组成。

请求行,用来说明请求类型(方法),要访问的资源以及所使用的HTTP版本

请求头部,紧接着请求行(即第一行)之后的部分,用来说明服务器要使用的附加信息

空行,请求头部后面的空行是必须的即使第四部分的请求数据为空,也必须有空行。

请求数据也叫主体,可以添加任意的其他数据。

  • 响应报文=状态行+消息报头+空行+响应正文四个部分组成

状态行,由HTTP协议版本号,状态码,状态消息 三部分组成。

消息报头,用来说明客户端要使用的一些附加信息。

空行,消息报头后面的空行是必须的。

响应正文,服务器返回给客户端的文本信息等。

HTTP状态码与请求方法

HTTP有5种类型的状态码,具体的:

  • 1xx:指示信息–表示请求已接收,继续处理。
  • 2xx:成功–表示请求正常处理完毕。

200 OK:客户端请求被正常处理。

206 Partial content:客户端进行了范围请求。

  • 3xx:重定向–要完成请求必须进行更进一步的操作。

301 Moved Permanently:永久重定向,该资源已被永久移动到新位置,将来对该资源访问都要使用本响应返回的若干个URI之一。

302 Found:临时重定向,请求的资源临时从不同的URI中获得。


4xx:客户端错误–请求有语法错误,服务器无法处理请求。

400 Bad Request:请求报文存在语法错误。


403 Forbidden:请求被服务器拒绝。


404 Not Found:请求不存在,服务器上找不到请求的资源。

  • 5xx:服务器端错误–服务器处理请求出错。

500 Internal Server Error:服务器在执行请求时出现错误。

HTTP1.1之后共有八种方法名,见下:

由于该项目主要涉及GET和POST,那么这两个指令有什么区别和联系呢?


简单来说,GET主要是用来获取新的网页;POST用作向服务器传递用户的表单数据,如用户名、密码、留言等等;


进一步,GET把参数包含在URL中,POST通过request body传递参数。


其实GET和POST只是HTTP定义的两种输出传输标识,他们的传输大小限制是TCP/IP协议所限制的,并且POST一般需要两次传输,强烈推荐博客:GET/POST的区别。


这里是两个GET和POST的典例:


GET

GET /562f2.jpg HTTP/1.1
Host:img.mukewang.com
User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36
Accept:image/webp,image/*,*/*;q=0.8
Referer:http://www.imooc.com/
Accept-Encoding:gzip, deflate, sdch
Accept-Language:zh-CN,zh;q=0.8
空行
请求数据为空

POST

POST / HTTP1.1
Host:www.wrox.com
User-Agent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
Content-Type:application/x-www-form-urlencoded
Content-Length:40
Connection: Keep-Alive
空行
name=Professional%20Ajax&publisher=Wiley

HTTP处理流程

HTTP的处理流程分为以下三个步骤:

  • **连接处理:**浏览器端发出http连接请求,主线程创建http对象接收请求并将所有数据读入对应buffer,将该对象插入任务队列,等待工作线程从任务队列中取出一个任务进行处理。
  • 处理报文请求:工作线程取出任务后,调用进程处理函数,通过主、从状态机对请求报文进行解析。
  • **返回响应报文:**解析完之后,生成响应报文,返回给浏览器端。

接下来依次介绍三个步骤:

连接处理

在连接阶段,最重要的是tcp连接过程和读取http的请求报文(其实读取请求报文就是读取客户端发送的数据而已)。tcp连接过程涉及epoll内核事件创建等,详见后续的epoll部分

服务器是如何实现读取http的报文的呢?首先,服务器需要对每一个已建立连接http建立一个http的类对象,这部分代码如下(服务器一直在运行eventloop即回环事件,因为整个服务器其实是事件驱动):

//事件回环(即服务器主线程)
void WebServer::eventLoop()
{
    ......
    while (!stop_server)
    {
        //等待所监控文件描述符上有事件的产生
        int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
        if (number < 0 && errno != EINTR)
        {
            LOG_ERROR("%s", "epoll failure");
            break;
        }
        //对所有就绪事件进行处理
        for (int i = 0; i < number; i++)
        {
            int sockfd = events[i].data.fd;
            //处理新到的客户连接
            if (sockfd == m_listenfd)
            {
                bool flag = dealclinetdata();
                if (false == flag)
                    continue;
            }
            //处理异常事件
            else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))
            {
                //服务器端关闭连接,移除对应的定时器
                util_timer *timer = users_timer[sockfd].timer;
                deal_timer(timer, sockfd);
            }
            //处理信号
            else if ((sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN))
            {
                bool flag = dealwithsignal(timeout, stop_server);
                if (false == flag)
                    LOG_ERROR("%s", "dealclientdata failure");
            }
            //处理客户连接上接收到的数据 可读
            else if (events[i].events & EPOLLIN)
            {
                dealwithread(sockfd);
            }
            //处理客户连接上接收到的数据 可写
            else if (events[i].events & EPOLLOUT)
            {
                dealwithwrite(sockfd);
            }
        }
       ......
    }
}

22行的dealclientdata()函数调用timer()创建新的client客户端连接user,同时新增一个定时事件(见后续部分)


完成这一系列步骤之后,服务器中就维护着一系列的客户端client连接,当其中一个客户点击网页某一按钮,生成一个请求报文并传输到服务器时,在上述事件回环代码中调用dealwithread()。

该函数中将该端口事件append加入任务请求队列,等待线程池中的线程执行该任务。根据Reactor/Proactor模式,工作线程对http请求报文数据的读取由read_once()函数完成,见http_conn.cpp。

read_once()函数将浏览器(客户端)端的数据读入到缓存数组,以待后续工作线程进行处理。

请求报文处理

在webserver的线程池有空闲线程时,某一线程调用process()来完成请求报文的解析以及报文相应任务。详见http_conn/process():

//处理http报文请求与报文响应
void http_conn::process()
{
    //NO_REQUEST,表示请求不完整,需要继续接收请求数据
    HTTP_CODE read_ret = process_read();
    if (read_ret == NO_REQUEST)
    {
        //注册并监听读事件
        modfd(m_epollfd, m_sockfd, EPOLLIN, m_TRIGMode);
        return;
    }
    //调用process_write完成报文响应
    bool write_ret = process_write(read_ret);
    if (!write_ret)
    {
        close_conn();
    }
    //注册并监听写事件
    modfd(m_epollfd, m_sockfd, EPOLLOUT, m_TRIGMode);
}

介绍请求报文的处理,也就是process_read()函数

该函数通过while循环,对主从状态机进行封装,对报文的每一行进行循环处理。这里的主状态机,指的是process_read()函数,从状态机是指parse_line()函数。

从状态机负责读取报文的一行(并对其中的\r\n进行修改为\0\0),主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机。它们之间的关系如下图所示:


process_read()这个函数对HTTP连接和处理部分的理解极为重要,必须得抬出源码看看,请在学习过程中结合源码、上述流程图来理解

//有限状态机处理请求报文
http_conn::HTTP_CODE http_conn::process_read()
{
    //
    LINE_STATUS line_status = LINE_OK;
    HTTP_CODE ret = NO_REQUEST;
    char *text = 0;
    while ((m_check_state == CHECK_STATE_CONTENT && line_status == LINE_OK) || ((line_status = parse_line()) == LINE_OK))
    {
        text = get_line();
        m_start_line = m_checked_idx;
        LOG_INFO("%s", text);
        switch (m_check_state)
        {
        case CHECK_STATE_REQUESTLINE:
        {
            ret = parse_request_line(text);
            if (ret == BAD_REQUEST)
                return BAD_REQUEST;
            break;
        }
        case CHECK_STATE_HEADER:
        {
            ret = parse_headers(text);
            if (ret == BAD_REQUEST)
                return BAD_REQUEST;
            else if (ret == GET_REQUEST)
            {
                return do_request();
            }
            break;
        }
        case CHECK_STATE_CONTENT:
        {
            ret = parse_content(text);
            if (ret == GET_REQUEST)
                return do_request();
            line_status = LINE_OPEN;
            break;
        }
        default:
            return INTERNAL_ERROR;
        }
    }
    return NO_REQUEST;
}

上述代码是使用switch…case来体现主状态机的选择,而主状态机的状态是由CHECK_STATE_REQUESTLINE/HEADER/CONTENT,这三个标志来表示的:正在解析请求行、解析请求头、解析消息体(body)。有关判断条件和循环体的补充见下:


  • 判断条件
  • 主状态机转移到CHECK_STATE_CONTENT,该条件涉及解析消息体
  • 从状态机转移到LINE_OK,该条件涉及解析请求行和请求头部
  • 两者为或关系,当条件为真则继续循环,否则退出
  • 循环体
  • 从状态机读取数据
  • 调用get_line函数,通过m_start_line将从状态机读取数据间接赋给text
  • 主状态机解析text

PS:这个部分的阅读一定得结合源码!其中涉及很多字符数组指针的加减,请仔细体会

主状态机初始状态是CHECK_STATE_REQUESTLINE,而后调用parse_request_line()解析请求行,获得HTTP的请求方法、目标URL以及HTTP版本号,状态变为CHECK_STATE_HEADER。


此时进入循环体之后,调用parse_headers()解析请求头部信息。先要判断是空行还是请求头,空行进一步区分POST还是GET。若是请求头,则更新长短连接状态、host等等。


注:GET和POST请求报文的区别之一是有无消息体部分。


当使用POST请求时,需要进行CHECK_STATE_CONTENT的解析,取出POST消息体中的信息(用户名、密码)。


参考链接:


https://mp.weixin.qq.com/s/wAQHU-QZiRt1VACMZZjNlw

从0到服务器开发——TinyWebServer(下):https://developer.aliyun.com/article/1508289

相关文章
【网络编程】第2章(4)服务器软件设计概论
【网络编程】第2章(4)服务器软件设计概论
|
7天前
|
安全 Linux Windows
小试跨平台局域网文件传输工具NitroShare,几点感想
小试跨平台局域网文件传输工具NitroShare,几点感想
|
5月前
|
Java 数据库连接 API
从0到服务器开发——TinyWebServer(下)
从0到服务器开发——TinyWebServer
86 2
|
5月前
|
网络协议 Java Unix
从0到服务器开发——TinyWebServer(上)
从0到服务器开发——TinyWebServer
213 1
|
5月前
|
监控 Java 数据库连接
【后台开发】TinyWebser学习笔记(1)网络编程基础知识
【后台开发】TinyWebser学习笔记(1)网络编程基础知识
48 3
|
Linux
【Linux网络编程】服务端编程初体验
【Linux网络编程】服务端编程初体验
100 0
|
Oracle Java 关系型数据库
从0开始搭建一台服务器开发环境(上)
这套文章将教您如何一步一步搭建一台服务器,通过这套文章你将搭建的服务,以及配置如下:
641 1
从0开始搭建一台服务器开发环境(上)
|
NoSQL 网络协议 Java
从0开始搭建一台服务器开发环境(中)
4,安装maven 5,安装tomcat 6,安装redis 8,安装mysql 9,安装svn
192 0
从0开始搭建一台服务器开发环境(中)
|
监控 Java API
手把手教会你 | 多用户-服务器聊天室应用软件开发
手把手教会你 | 多用户-服务器聊天室应用软件开发
257 0
手把手教会你 | 多用户-服务器聊天室应用软件开发
|
jenkins 应用服务中间件 持续交付
从0开始搭建一台服务器开发环境(下)
10,安装NgInx 11,安装jenkins 12,开放服务器指定端口 13,其他注意细节,避免采坑
98 0
下一篇
无影云桌面