likes
comments
collection
share

C++ Linux轻量级WebServer(三)解析请求

作者站长头像
站长
· 阅读数 41

介绍

上一章讲解了WebServer的并发模型,谈到了Reactor、Epoll和线程池,当任务队列中有任务(请求)到来时如读写操作,线程池中的线程是如何解析请求并封装响应呢?

DealRead

读取数据

当Epoll检测到EPOLLIN事件时,表示数据达到TCP读缓冲区,则需要处理读操作,而读的处理逻辑是封装在HttpConn类中,读时采用ET + 非阻塞 的方式一次性将TCP读缓冲区中的数据读取出来,以避免Epoll事件被反复触发。

HttpConn中有Buffer类型的readBuffer_对象成员,而数据是读取到readBuffer_vector类型容器buffer_中的,面对TCP读缓冲区不断来到的未知数据,它是如何实现的呢?

vector容器大小默认是1k,但由于容器大小的限制,会采用分散读的形式即在readBuffer_对象的ReadFd函数中定义一个临时数组buff,大小64k,再定义struct iovec iov[2],再将buffer_buffer的首地址与大小赋值给iov[].iov_baseiov[].iov_len中,再使用readv(fd, iov, 2)进行读操作,以实现vector自动增长的缓冲区,具体策略则是如果buffer_之后的剩余空间大于TCP接收缓冲区的数据大小,则会直接拼接到buffer_之后的剩余空间,否则如果buffer_之前已读的空间大小和之后剩余的空间大小之和大于TCP接收缓冲区的大小,则会将原先内容copybuffer_开头,再将TCP接收缓冲区的内容拼接至buffer_之后,否则就对buffer_自动扩容至之后的空间和扩容的空间能装下TCP接收缓冲区的内容,下图可更直观的表示:

C++ Linux轻量级WebServer(三)解析请求

源代码如下(append函数未展开):

ssize_t Buffer::ReadFd(int fd, int* saveErrno) {
    // 64KB
    char buff[65535];   // 临时的数组,保证能够把所有的数据都读出来
    
    struct iovec iov[2];
    const size_t writable = WritableBytes();
    
    /* 分散读,保证数据全部读完 */
    // iov[0] Buffer内置的数组_buffer,默认大小是1024
    // iov[1] buff临时数组,大小为65535
    iov[0].iov_base = BeginPtr_() + writePos_;
    iov[0].iov_len = writable;
    iov[1].iov_base = buff;
    iov[1].iov_len = sizeof(buff);

    const ssize_t len = readv(fd, iov, 2);  // 真正的读操作
    if(len < 0) {
        *saveErrno = errno;
    }
    else if(static_cast<size_t>(len) <= writable) {
        writePos_ += len;
    }
    else {
        writePos_ = buffer_.size();
        Append(buff, len - writable);
    }
    return len;
}

提问:为什么会采用自动增长的缓冲区呢?,如果vector容器空间更大,不就省去的copy所带来的时间消耗。

回答:因为这一次线程读取客户端发送过来的数据的同时,另一个线程还会将上一次buffer_中读取的数据取出并处理,所以读取数据时需从右指针WritePos_开始,即维持了一个窗口去读、取数据。(这个说法不准确,因为每个客户端都注册了EPOLLONESHOT事件,而EPOLLONESHOT事件保证每一个socket即每一个客户端实际上只能被一个线程处理)。

逻辑处理

解析请求

当读取完TCP接收缓冲区中的数据时,解析请求即对业务逻辑的处理,而解析请求是由HttpConn中的request_对象完成的,先初始化请求对象的信息即将请求方法、请求路径、协议版本与请求体置为空,再将初始状态置为请求首行即state_ = REQUEST_LINE,解析请求数据采用有限状态机模型,由于HTTP协议以换行作为每行的结束符,所以以\r\n作为获取一行的结束标志,先解析请求首行获取请求方法、URL和协议版本,改变状态至请求头部即state_ = HEADERS,下一轮再解析请求头以key: val对的形式如Connection: keep-alivecontent-length: 4560content-type: text/html等,以请求空行作为请求头的结束标志,改变状态至解析请求体即state_ = BODY,下一轮解析请求体,之后以state_ = FINISH结束解析请求的操作。

解析请求核心函数:

bool HttpRequest::parse(Buffer& buff) {
    const char CRLF[] = "\r\n"; // 行结束符(回车换行)
    if(buff.ReadableBytes() <= 0) {
        return false;
    }
    // buff中有数据可读,并且状态没有到FINISH,就一直解析
    while(buff.ReadableBytes() && state_ != FINISH) {
        // 获取一行数据,根据\r\n为结束标志
        const char* lineEnd = search(buff.Peek(), buff.BeginWriteConst(), CRLF, CRLF + 2);
        std::string line(buff.Peek(), lineEnd);
        switch(state_)
        {
        case REQUEST_LINE:
            // 解析请求首行
            if(!ParseRequestLine_(line)) {
                return false;
            }
            // 解析出请求资源路径
            ParsePath_();
            break;    
        case HEADERS:
            // 解析请求头
            ParseHeader_(line);
            if(buff.ReadableBytes() <= 2) {  // 此时已读到buff末尾
                state_ = FINISH;
            }
            break;
        case BODY:
            // 解析请求体
            ParseBody_(line);
            break;
        default:
            break;
        }
        if(lineEnd == buff.BeginWrite()) { break; }
        buff.RetrieveUntil(lineEnd + 2);
    }
    LOG_DEBUG("[%s], [%s], [%s]", method_.c_str(), path_.c_str(), version_.c_str());
    return true;
}

封装响应

解析完请求数据后,需要封装响应,而封装响应是由HttpConn中的response_对象完成的,先初始化响应对象即将解析得到的状态码、是否保持活跃连接、资源路径赋值给response_对象的成员,再生成响应数据,而生成的响应数据会封装在writeBuffer_中以便后续处理写操作,而如何封装响应数据则由AddStateLine(buff)AddHeader_(buff)AddContent(buff)三个函数完成,值得注意的是在添加响应体时会对资源文件做一个mmap的内存映射,以提高文件的访问速度,实际上的响应报文是并没有响应体的。

之后使用iov_[0].iov_base、iov_[0].iov_leniov_[1].iov_base、iov_[1].iov_len分别保存响应头与资源文件的首地址与长度,以方便后续的分散写操作。

封装响应的核心函数:

void HttpResponse::MakeResponse(Buffer& buff) {
    /* 判断请求的资源文件 */
    // index.html
    // /home/nowcoder/WebServer-master/resources/index.html
    if(stat((srcDir_ + path_).data(), &mmFileStat_) < 0 || S_ISDIR(mmFileStat_.st_mode)) {
        code_ = 404;  // 服务器上无法找到请求的资源
    }
    else if(!(mmFileStat_.st_mode & S_IROTH)) {
        code_ = 403;  // 请求资源的访问被服务器拒绝
    }
    else if(code_ == -1) { 
        code_ = 200; 
    }
    ErrorHtml_();
    AddStateLine_(buff);
    AddHeader_(buff);
    AddContent_(buff);
}

添加响应头(文件资源的内存映射):

void HttpResponse::AddContent_(Buffer& buff) {
    int srcFd = open((srcDir_ + path_).data(), O_RDONLY);  // 得到资源文件的文件描述符
    if(srcFd < 0) { 
        ErrorContent(buff, "File NotFound!");
        return; 
    }

    /*  将文件映射到内存提高文件的访问速度 
        MAP_PRIVATE 建立一个写入时拷贝的私有映射  */
    LOG_DEBUG("file path %s", (srcDir_ + path_).data());
    int* mmRet = (int*)mmap(0, mmFileStat_.st_size, PROT_READ, MAP_PRIVATE, srcFd, 0);
    if(*mmRet == -1) {
        ErrorContent(buff, "File NotFound!");
        return; 
    }
    mmFile_ = (char*)mmRet;
    close(srcFd);
    buff.Append("Content-length: " + to_string(mmFileStat_.st_size) + "\r\n\r\n");
}

DealWrite

fd注册EPOLLIN事件时,当TCP读缓冲区有数据到达时就会触发EPOLLIN事件。当fd注册EPOLLOUT事件时,当TCP写缓冲区有剩余空间时就会触发EPOLLOUT事件,此时DealWrite就是处理EPOLLOUT事件。

处理写操作是由HttpConn对象的write函数执行的,采用writev(fd_, iov_, iovCnt_)分散写数据,并使用ET模式一次性将数据写完,由此会对地址偏移和长度变化,如果两块内存都写完了则传输完成,继续处理读事件,如果未写到第二块内存则对第一块内存进行地址偏移和长度变化,否则对第二块内存做地址偏移和长度变化。(如果多次写到第二块内存时,其实代码逻辑是有问题的,不能有效的对地址偏移和长度变化。

处理写操作核心函数:

ssize_t HttpConn::write(int* saveErrno) {
    ssize_t len = -1;
    do {
        // 分散写数据
        len = writev(fd_, iov_, iovCnt_);
        if(len <= 0) {
            *saveErrno = errno;
            break;
        }
        // 这种情况是所有数据都传输结束了
        if(iov_[0].iov_len + iov_[1].iov_len  == 0) { break; } /* 传输结束 */
        // 写到了第二块内存,做相应的处理
        else if(static_cast<size_t>(len) > iov_[0].iov_len) {
            iov_[1].iov_base = (uint8_t*) iov_[1].iov_base + (len - iov_[0].iov_len);
            iov_[1].iov_len -= (len - iov_[0].iov_len);
            if(iov_[0].iov_len) {
                writeBuff_.RetrieveAll();
                iov_[0].iov_len = 0;
            }
        }
        // 还没有写到第二块内存的数据
        else {
            iov_[0].iov_base = (uint8_t*)iov_[0].iov_base + len; 
            iov_[0].iov_len -= len; 
            writeBuff_.Retrieve(len);
        }
    } while(isET || ToWriteBytes() > 10240);
    return len;
}

结束语

至此,前三章的内容已将系统的核心骨架讲完,当然讲的并不全面,后续也还会补充和修改。之后会继续讲解定时器的超时连接,异步的日志系统,数据库连接池等内容。

转载自:https://juejin.cn/post/7106108431395717128
评论
请登录