C++ Linux轻量级WebServer(三)解析请求
介绍
上一章讲解了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_base
与iov[].iov_len
中,再使用readv(fd, iov, 2)
进行读操作,以实现vector
自动增长的缓冲区,具体策略则是如果buffer_
之后的剩余空间大于TCP接收缓冲区的数据大小,则会直接拼接到buffer_
之后的剩余空间,否则如果buffer_
之前已读的空间大小和之后剩余的空间大小之和大于TCP接收缓冲区的大小,则会将原先内容copy
至buffer_
开头,再将TCP接收缓冲区的内容拼接至buffer_
之后,否则就对buffer_
自动扩容至之后的空间和扩容的空间能装下TCP接收缓冲区的内容,下图可更直观的表示:
源代码如下(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-alive
、content-length: 4560
和content-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_len
与iov_[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