HTTP协议之编写简单的Web服务器
花开花落,倏忽一载便逝矣,新的一年,祝大家天天开心
HTTP即超文本传输协议(Hypertext Transfer Protocol),是Web通信所使用的协议。它是基于TCP/IP实现的协议,所以本篇需要先了解TCP通信,我们将使用TCP来写一个简单的Web服务器端,它可以响应浏览器的访问。
通信需要服务端和客户端,在这里浏览器就属于客户端,当访问一个网页时,浏览器内部会创建套接字和服务器进行通信。服务器会响应请求返回一些HTML格式的数据给浏览器,浏览器来把这些HTML数据解析成我们看到的漂亮的页面。
当我们在浏览器的地址栏上敲下一个域名地址后,浏览器会先通过默认DNS服务器获取该域名对应的IP地址,然后向服务器发送请求,请求有一定的标准,分为:
- 请求行
- 消息头
- 空行
- 消息体
现在来随便访问一个网址,这里使用的是 Firefox 浏览器,按 Ctrl+Shift+E
可以查看网络请求。
左边对应的是浏览器对服务器发出的请求,右边对应的是该条请求相关的信息。
我们先看左边的第一条,这里的请求方式是GET,表示想从服务器获取文件,获取的文件目录是/。然后服务器响应请求,发回了状态码302,表示Found重定向,我们本来访问的是 www.bing.com
,现在被重定向到了 https://cn.bing.com/
。这个重定向地址是通过响应头的location指定的,可以在右边看到。
https是加了SSL/TLS的协议,在需要安全的环境下,比如发送银行卡,身份信息等地方都会使用。只使用http这些信息很容易被窃听,SSL/TLS会对请求和响应的信息进行加密解密操作,保证数据安全。
接着同样是一些GET请求用于从服务器获取数据,状态码200 OK表示请求已经成功。还有常见的404 Not Found,表示找不到客户端请求的资源,这种情况我们就把链接转移到404错误页。现在来把对应的请求整理如下:
请求行:
GET / HTTP/2.0
消息头:
Accept:text/html,application/xhtml+xm…plication/xml;q=0.9,*/*;q=0.8
Accept-Encoding:gzip, deflate, br
Accept-Language:zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Connection:keep-alive
Cookie:_EDGE_V=1; MUID=3802FC7ABA5D6F…ndefined; _UR=OMD=13190641321
Host:cn.bing.com
Upgrade-Insecure-Requests:1
User-Agent:Mozilla/5.0 (Windows NT 10.0; …) Gecko/20100101 Firefox/64.0
空行:
消息体:
请求行为一行数据,其中包含着请求方式,请求文件,HTTP版本。
这里的请求方式为 GET
。文件为 /
,表示根目录,这种情况一般都会被重定向到别的页面上,一般好多服务器都会自动检查有没有index.html/index.php/
等等,发现有就转到这些页面。最后是使用的HTTP版本,为2.0版本。
主要来看消息头。
- Accept:表示客户端可以处理的文件类型
- Accept-Encoding:表示客户端能理解的内容的编码方式
- Accept-Language:表示支持的语言,比如此处支持中文,英文等等
- Connection:这个决定着当前事务完成后,是否会关闭网络连接。此处指定为keep-alive,表示持久,不会关闭。指定close会关闭。
- Cookie:由于服务器响应客户端的请求后,需要断开连接去接收下一个请求,所以服务端无法记住客户端的状态,哪怕下一次再连接服务器也无法辩认为原先访问的。所以就有了Cookie,当客户端连接后服务器可以通过Set-Cookie给客户端设置一些信息,第二次连接时这个Cookie就包含的有服务器给此客户端设置的Cookie值,这样服务器就能识别客户端了。例如那些密码记录,购物车清单啊,都有用到。
- Host:表示主机域名
- Upgrade-Insercure-Request:表示客户端优先选择加密及带有身份验证的响应,并且它可以成功处理 upgrade-insecure-requests CSP 指令。
- user-Agent:用户代理,包含着我们访问所使用的应用类型,操作系统,版本号等等信息。
空行是为了区分前面的信息和消息体的,消息体是在 POST
请求时提交给服务器的,此处没有 POST
请求,所以也就没有消息体了。
现在来看响应消息的格式:
- 状态行
- 消息头
- 空行
- 消息体
把响应的内容整理如下:
状态行:
HTTP/2.0 200 OK
消息头:
cache-control:private, max-age=0
content-encoding:br
content-type:text/html; charset=utf-8
date:Sun, 30 Dec 2018 11:11:10 GMT
p3p:CP="NON UNI COM NAV STA LOC CURa DEVa PSAa PSDa OUR IND"
set-cookie:SNRHOP=I=&TS=; domain=.bing.com; path=/
strict-transport-security:max-age=31536000; includeSubDomains; preload
vary:Accept-Encoding
X-Firefox-Spdy:h2
x-msedge-ref:Ref A: 7F437C20D4F049CB86705CA…6 Ref C: 2018-12-30T11:11:10Z
空行:
消息体:
<html>
....
</html>
第一行用于表示状态行,为HTTP版本 + 状态码。
在消息头中设置了内容编码,类型,日期,cookie等等信息,这里就不细说了。服务器需要向客户端返回数据,这里的数据就在消息体中进行发送的,这里返回了主页的html数据。
现在对这些基本知识有了了解,我们就可以来完成一个简单的Web服务器。
先创建一个 WebServer
类,作为网络服务器:
#pragma once
#include <string>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib")
class WebServer
{
public:
WebServer();
~WebServer();
void RespondRequest(); //响应请求
private:
void InitSock(); //初始化Socket
void RequestHandler(); //处理请求的线程
void SendData(const std::string ct, const std::string fileName); //发送数据
void SendNotFound(); //找不到指定文件时,发送404 Not Found页面
private:
WSADATA m_wsaData;
SOCKET m_servSock, m_clntSock;
SOCKADDR_IN m_servAddr, m_clntAddr;
};
关于TCP的东西就不再多说了,相信大家已经很熟悉了。这里只需开放一个接口供用户调用,其它由我们内部使用。
在实现文件中,我们还需要包含以下文件:
#include "WebServer.h"
#include <iostream>
#include <cassert> //断言
#include <future> //线程用
#include <fstream> //文件操作用
#pragma warning(disable:4996) //忽略一些警告
const int CONTENT_SIZE = 2048; //内容大小
const int BUF_SIZE = 100; //缓冲区大小
先来看 InitSock()
函数,在这里就是TCP的一些绑定,监听操作:
void WebServer::InitSock()
{
//初始化Winsock库
assert(WSAStartup(MAKEWORD(2, 2), &m_wsaData) == 0);
//创建服务器套接字
m_servSock = socket(PF_INET, SOCK_STREAM, 0);
//设置地址信息
memset(&m_servAddr, 0, sizeof(m_servAddr));
m_servAddr.sin_family = AF_INET;
m_servAddr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
m_servAddr.sin_port = htons(8000);
//绑定地址信息
assert(bind(m_servSock, (SOCKADDR *)&m_servAddr, sizeof(m_servAddr)) != SOCKET_ERROR);
//监听
assert(listen(m_servSock, 5) != SOCKET_ERROR);
}
为了代码更加清晰,所以这里就用断言检查了。初始化信息我们在构造函数中调用:
WebServer::WebServer()
{
InitSock();
}
同时需要在析构函数中关闭释放:
WebServer::~WebServer()
{
closesocket(m_servSock);
WSACleanup();
}
大家可能发现这里只关闭了服务器套接字,这是因为前面说过,HTTP是无状态的,它响应了一个客户端后就会断开来响应别的请求,所以这里我们不需要关闭,而是在响应的函数中关闭。
现在来看响应请求的函数 RespondRequest()
:
void WebServer::RespondRequest()
{
for (;;)
{
int nClntAddrSize = sizeof(m_clntAddr);
//接收客户端连接
m_clntSock = accept(m_servSock, (SOCKADDR *)&m_clntAddr, &nClntAddrSize);
//获取客户端信息
char* szClntAddr = inet_ntoa(m_clntAddr.sin_addr);
int nClntPort = ntohs(m_clntAddr.sin_port);
//打印信息
char szBuf[BUF_SIZE] = { 0 };
snprintf(szBuf, BUF_SIZE, "Connection Request: %s %d", szClntAddr, nClntPort);
std::cout << szBuf << std::endl;
//开启线程,发送数据
auto fu = std::async(std::launch::async, std::bind(&WebServer::RequestHandler, this));
}
}
这里我们一直循环着来接收客户端的请求,当有客户端连接后,读取客户端的地址和端口关打印出来。关于发送数据我们专门开启一个线程交给 RequestHandler
处理,因为类成员函数其实都隐含了一个 this
指针,所以还得传入 this
,这里的 std::bind
会把成员函数和 this
绑定起来,返回一个函数对象。
接着来看处理数据的线程函数:
void WebServer::RequestHandler()
{
char szContent[CONTENT_SIZE] = { 0 }; //接收到的内容
recv(m_clntSock, szContent, CONTENT_SIZE, 0); //接收
//打印接收到的内容
std::cout << "-------------------\n" << szContent << std::endl;
std::string content(szContent);
//检测是否使用的是HTTP协议
int protocol = content.find("HTTP/");
if (protocol == std::string::npos)
{
std::cerr << "Error Request!" << std::endl;
closesocket(m_clntSock);
return;
}
int loc = content.find_first_of("/");
std::string method = content.substr(0, loc); //解析请求方式
std::string fileName = content.substr(loc + 1, protocol - loc - 1); //解析请求文件名
std::string contentType;
loc = fileName.find_first_of(".") + 1;
std::string type = fileName.substr(loc);
//解析类型
if (type.compare("html ") == 0 || type.compare("htm ") == 0)
contentType = "text/html"; //html格式
else
contentType = "text/plain"; //纯文本格式
SendData(contentType, fileName); //发送数据
}
我们可以先测试下能否收到客户端的请求,前面指定的端口为8000,所以我们使用浏览器访问 localhost:8000
,接收到的数据如图:
当成功等到这些信息后,我们需要解析出它的请求方式,请求文件名,通过请求文件名我们再判断出请求类型,若是 html
,请求类型则为 text/html
。若不是,我们设置为 text/plain
。当给服务器返回时若是 text/plain
,服务器就会把收到的内容当作文本显示出来,若是html,则会是解析出显示。
再来看发送数据之前,我们首先来新建两个HTML文件,一个为正常请求的文件 index.html
,一个为失踪网页 404.html
,并将它们放到工程目录下。
<!-- index.html -->
<html>
<head>
<title>My Web Server</title>
</head>
<body style="background:#0f0;">
<div style="text-align:center; font-size:50px; margin-top:300px" >Request Successful!</div>
</body>
</html>
<!-- 404.html -->
<html>
<head>
<title>Not Found</title>
</head>
<body style="background:#f00;">
<div style="text-align:center; font-size:100px; margin-top:300px">404!</div>
<div style="text-align:center; font-size:50px;" >Oops! That page can't be found.</div>
</body>
</html>
然后我们继续来看发送数据:
void WebServer::SendData(const std::string ct, const std::string fileName)
{
std::string protocol = "HTTP/1.0 200 OK\r\n"; //状态行
std::string servName = "Server:My Web Server\r\n"; //服务器名称
std::string contentLen = "Content-length:2048\r\n"; //内容长度
std::string contentType = "Content-type:" + ct + "\r\n\r\n"; //内容类型
//打开请求的文件
std::ifstream sendFile(fileName);
if (!sendFile.is_open())
{
//打开失败,说明当前路径无此文件,发送Not Found
SendNotFound();
return;
}
//发送状态行,消息头
send(m_clntSock, protocol.c_str(), protocol.size(), 0);
send(m_clntSock, servName.c_str(), servName.size(), 0);
send(m_clntSock, contentLen.c_str(), contentLen.size(), 0);
send(m_clntSock, contentType.c_str(), contentType.size(), 0);
//发送消息体
char buf[CONTENT_SIZE] = { 0 };
sendFile.getline(buf, CONTENT_SIZE);
for (; !sendFile.eof();)
{
send(m_clntSock, buf, strlen(buf), 0);
sendFile.getline(buf, CONTENT_SIZE);
}
sendFile.close();
closesocket(m_clntSock); //由HTTP协议响应后断开连接
}
开始我们组织一下状态行和消息头,因为客户端请求为 HTTP/1.0
,所以我们也使用 HTTP/1.0
,状态设置为 200 OK
,表示成功。接着设置了服务器名,返回的内容长度,内容类型,需要注意的是在内容类型后有两个 \r\n
,后一个代表着空行,若是忘记了,客户端就识别不了你返回的消息体了。
接着,我们在目录中读取请求文件,若是读取失败,则表示请求文件不存在,那就返回 404
了。若存在,我们将文件读取出来发送给客户端,这些内容就是消息体。
最后,我们需要断开与客户端的连接,继续接收其它请求,所以说HTTP协议是无状态的协议。
最后剩一个 404
页面,操作和发送数据就没什么两样,便不细说了:
void WebServer::SendNotFound()
{
std::string protocol = "HTTP/1.0 404 Not Found\r\n";
std::string servName = "Server:My Web Server\r\n";
std::string contentLen = "Content-length:2048\r\n";
std::string contentType = "Content-type:text/html\r\n\r\n";
std::ifstream sendFile("404.html");
if (!sendFile.is_open())
{
std::cout << "Not found 404.html" << std::endl;
return;
}
//发送状态行,消息头
send(m_clntSock, protocol.c_str(), protocol.size(), 0);
send(m_clntSock, servName.c_str(), servName.size(), 0);
send(m_clntSock, contentLen.c_str(), contentLen.size(), 0);
send(m_clntSock, contentType.c_str(), contentType.size(), 0);
char buf[CONTENT_SIZE] = { 0 };
sendFile.getline(buf, CONTENT_SIZE);
for (; !sendFile.eof();)
{
//发送文件
send(m_clntSock, buf, strlen(buf), 0);
sendFile.getline(buf, CONTENT_SIZE);
}
sendFile.close();
closesocket(m_clntSock);
}
不同之处在于状态行得设置为 404 Not Found
。
在 main
中调用:
#include "WebServer.h"
int main() {
WebServer webServ;
webServ.RespondRequest();
}
运行程序,当浏览器访问 localhost:8000/index.html
时:
当访问其他任何页面时:
当然,懂了HTTP协议后,我们也可以作为客户端去访问各种网页,关于这个后面准备写一个网络爬虫。不过之前准备先把SMTP,select,重叠IO,IOCP,boost::asio,regex等等写了再去写,所以就都是明年的事了。