应用层协议HTTP

一、HTTP协议

我们日常浏览网页、接口请求、手机APP联网,所有Web交互的底层核心,都离不开 HTTP 协议。它是互联网最基础、应用最广泛的应用层网络协议,定义了客户端与服务器之间的数据传输规则和通信格式。

1.1 什么是HTTP协议

HTTP全称为HyperText Transfer Protocol(超文本传输协议),基于 TCP/IP 协议栈工作,默认端口为 80。它定义了客户端(如浏览器)与服务器之间如何通信,以交换或传输超文本(如 HTML 文档)。

简单来说,HTTP就是客户端和服务器沟通的统一语言。客户端通过HTTP 协议向服务器发送请求,服务器收到请求后处理并返回响应。HTTP 协议是一个无连接、无状态的协议,即每次请求都需要建立新的连接,且服务器不会保存客户端的状态信息。

1.2 认识URL和URI

1.2.1 URL

平时我们所俗称的"网址",其实就说的是URL(统一资源定位服务)

https是安全超文本传输协议,本质是 HTTP 协议结合 SSL/TLS 加密层构建的网络通信方案,默认使用 443 端口传输数据,它会对客户端与服务器之间交互的所有报文进行加密处理,既能防止传输数据被窃听、篡改,还能通过 CA 数字证书校验服务器真实身份,规避钓鱼网站风险,弥补了 HTTP 明文传输的安全缺陷,也是当前网页、接口服务普遍采用的标准通信协议。

www.example.com是域名。在访问目标服务器时,永远都只能通过IP地址进行访问,而不是域名,因为计算机只认IP地址。

当浏览器需要访问一个网址(www.example.com),不会先直接向服务器发送请求,而是先向域名解析服务器发送DNS请求,得到域名对应的IP地址。得到对应的IP地址之后再向对应的服务器端发送HTTP请求申请网页,最后收到HTTP应答返回网页。

那为什么计算机不直接一点直接使用域名呢?

因为,人对于数字类的IP不太敏感,而对于域名这种字符串的印象比较深刻,所以使用域名对人比较友好,便于记忆。

根据前面我们学习到的知识,访问服务器需要IP+端口号,但是为什么这个例子中没有与端口号的相关内容呢?

bash 复制代码
https://www.csdn.net/?spm=1000.2115.3001.10426

其实是存在端口号的,只是在使用浏览器访问时,浏览器没有显示出来而已。同时,由于使用的是HTTPS协议,HTTPS协议绑定的端口号是固定的--443,而HTTP协议是固定端口号80。通过前面的http还是https就能确定端口号,再根据后面的域名,就能访问到对应的服务器。

服务器为了保证自己的网页文件被用户找到,就必须提供文件路径,也就是下面这部分内容

也就是说前半部分是帮助我们找到提供服务的服务器进程,而后半部分是帮助我们在指定路径下找到对应的内容。

也就是说,HTTP协议本质上就是为了获取服务器上的文件内容。

URL传参就是把参数拼在 URL 地址里,使用问号 ? 开启查询字符串,多个参数用&分隔

上面的例子中问号后面的内容就是典型的URL传参。

当你在浏览器搜索框输入?/+这样的特殊符号时,为了不让用户输入的字符干扰,浏览器会对当前输入的特殊符号进行编码,然后数据发送到服务器端再进行解码。我们把这种编码和解码的操作称为urlencodeurldecode

特殊字符编码的规则如下: 将需要转码的字符转为16进制,然后从右到左,取4位 (不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式。

1.2.2 URI

与URL相对应的是URI(统一资源标识符)

URI用于唯一标识互联网上各种各样的资源,URL属于URI的子集,URL不仅能够标识资源,还给出了访问该资源的定位方式;而URI侧重完成资源的唯一识别,部分URI只做标识并不包含访问路径,二者常常被混用。URL一定是URI,但URI不一定是URL。

1.3 HTTP协议请求与响应格式

1.3.1 http请求

HTTP 请求的第一行为请求行,请求行下方是多行请求报头,行与行之间通过换行符 \r\n 或 \n 分隔。在 HTTP 请求中,请求正文部分是可选的,而请求行和请求报头部分则是必备的。

请求行与请求报头加在一起,就对应我们在前面学习协议时提到的报头,而请求正文部分则对应有效载荷。为了实现有效载荷与报头的分离,http请求中存在一个空行(\r\n),将请求正文与请求报文隔开。

上图中,我们看到http请求的内容是一行一行排列的,但是其实可以将http请求看作一个大的"字符串"(字节流)

请求的接收方在读取请求时就必须解决两个问题,一是如何将报文完整读完,二是要将报文反序列化。当接收方读取到连续的两个换行符时,说明报头被完整读取完了;而在请求报头中存在一个Content-length,用于标记有效载荷的长度,根据这个长度接收方就能将请求正文完整读取完。

而反序列化部分,http协议依靠换行符把连续字节流切分成请求行、请求报头等多个部分,完成报文的解析工作,如何依靠上层应用处理完成反序列化的工作。

在请求行中,请求行被分为三个不同部分,每个部分用空格分割开来。其中请求方法最常用的是GET和POST方法,GET方法用于获取数据,而POST方法用于上传数据。

http版本

http版本有三种版本,http/1.0、http/1.1、http/2.0。日常在使用时通常都是使用http1.1版本。http1.1版本支持长链接。

那什么是短链接,什么是长链接呢?

我们访问网页拉取资源时,页面的界面、图片、视频这些数据都得从服务器拿,拿数据前要先建立连接再发起请求。 举个例子,一个页面里包含十张图片,再算上页面本身的html文件,一共 11 份资源。 用http1.0的话,每一份资源都要单独新建一条连接,前后总共建立 11 次连接、分 11 次获取资源,这种机制就叫短链接。 而http1.1支持另一种方式,即全程只建立一次连接,之后反复在这条连接里发送资源请求,服务器依次返回对应数据,这种持续复用同一条连接的机制就是长链接。

而由于请求是由客户端发送的,所以这个http版本表示的是客户端client的版本。正因为有了版本号,服务器就可以根据客户端的版本,提供客户端对应版本号的服务。

复制代码
GET /user/login?name=zhangsan HTTP/1.1
Host: 127.0.0.1:8080
User‑Agent: curl/7.68.0

上面是一个http请求具体的示例,而这里的/user/login?name=zhangsan就是URI!URI是统一资源标识符,用来标识服务器上资源。与URL的区别在于,URI只用来标识资源唯一性,而URL不仅能标识资源的唯一性,还给出了资源的定位方式。URL = http:// + Host + URI。上面这个例子中的URL:http://127.0.0.1:8080/user/login?name=zhangsan。

**/**user/login?name=zhangsan中的"/"我们称之为web根目录。这个web根目录不是Linux机器的根目录,而是wwwroot。如果用户请求的uri只有一个/时,服务器不可能把这个根目录下的所有文件都返回给用户,而是在返回时添加上一个默认首页,即/+index.html。

1.3.2 http响应

http响应与请求的很多地方都有相似之处,不同的地方在于第一行的内容中,返回的是状态码以及状态码描述。而这个状态码对应着前面我们实现的网络版计算器中的code。

http协议规定,只要TCP连接正常建立、服务器收到完整http请求报文、服务器进程正常运行,无论请求成功与否,服务器都必须返回http应答报文。

响应正文中的内容,包含的是向服务器端获取的Linux文件,一般是html、css、js、图片、视频或音频。响应正文与请求正文一样,也可以为空。

1.4 HTTP的状态码

客户端在接到服务器的应答报文之后,肯定需要知道自己的请求是否成功,而状态码就是让客户端知道请求是否成功的一个媒介。如果请求成功,服务器受理了,状态码一般都是200(2XX),状态码描述为OK;请求出错有三种情况:1、资源找不到,返回404 Not Found;2、参数非法,返回400 Bad Request;3、服务器内部出错 ,返回500 Internal Server Error。

下面是HTTP的一些常出现的状态码的含义及其应用样例:

状态码 含义 应用样例
100 Continue 上传大文件时,服务器告诉客户端可以继续上传
200 OK 访问网站首页,服务器返回网页内容
201 Created 发布新文章,服务器返回文章创建成功的信息
204 No Content 删除文章后,服务器返回 "无内容" 表示操作成功
301 Moved Permanently 网站换域名后,自动跳转到新域名;搜索引擎更新网站链接时使用
302 Found 或 See Other 用户登录成功后,重定向到用户首页
304 Not Modified 浏览器缓存机制,对未修改的资源返回 304 状态码
400 Bad Request 填写表单时,格式不正确导致提交失败
401 Unauthorized 访问需要登录的页面时,未登录或认证失败
403 Forbidden 尝试访问你没有权限查看的页面
404 Not Found 访问不存在的网页链接
500 Internal Server Error 服务器崩溃或数据库错误导致页面无法加载
502 Bad Gateway 使用代理服务器时,代理服务器无法从上游服务器获取有效响应
503 Service Unavailable 服务器维护或过载,暂时无法处理请求

1.5 HTTP常见header

Content‑Type: 数据类型 (text/html 等)

Content‑Length: 请求或应答正文(Body)的长度,没有正文则没有这一项;

Host: 客户端告知服务器,所请求的资源是在哪个主机的哪个端口上;

User‑Agent: 声明用户的操作系统和浏览器版本信息,通常存在于请求中;

Referer: 当前页面是从哪个页面跳转过来的;

Location: 搭配 3xx 状态码使用,告诉客户端接下来要去哪里访问;

Cookie: 用于在客户端存储少量信息。通常用于实现会话 (session) 的功能;

1.6 HTTP的方法

在前面有提到,GET和POST是http请求中最常见的两种方法

方法 说明 支持的 HTTP 协议版本
GET 获取资源 1.0、1.1
POST 传输实体主体 1.0、1.1
PUT 传输文件 1.0、1.1
HEAD 获得报文首部 1.0、1.1
DELETE 删除文件 1.0、1.1
OPTIONS 询问支持的方法 1.1
TRACE 追踪路径 1.1
CONNECT 要求用隧道协议连接代理 1.1
LINK 建立和资源之间的联系 1.0
UNLINE 断开连接关系 1.0

1.GET方法

GET方法可以常用于获取静态资源,同时也可以用于向目标服务器端传递参数(GET方法传参)。其中,参数是通过URI方式进行传递的。

示例:

访问博客首页:GET https://yourblog.com/

查看某篇文章:GET https://yourblog.com/post/123

搜索博客内容:GET https://yourblog.com/search?q=HTTP

2.POST方法

POST方法也可以用于向目标服务器传参,但是POST方法是将参数放到正文部分进行传递。POST方法相比GET方法传参,更加私密。但POST方法和GET方法的传参都不安全,因为传递的参数都是明文的,即字符串信息没有进行保密处理。

示例:

提交博客评论:POST https://yourblog.com/post/123/comment(携带评论内容)

发布新文章:POST https://yourblog.com/post/new(携带文章标题、内容)

用户登录:POST https://yourblog.com/login(携带账号密码)

3.PUT方法

用于传输文件,将请求报文主体中的文件保存到请求 URL 指定的位置。常用于更新 / 替换已有资源,通常需要指定资源完整ID。

示例:

修改已发布的文章:PUT https://yourblog.com/post/123(携带更新后的文章内容)

更新个人资料:PUT https://yourblog.com/user/profile(携带新的昵称、头像信息)

4.HEAD方法

只获取响应头,不返回报报文主体部分,常用于检查资源是否存在、是否被修改,确认URL的有效性及资源更新的日期时间。

示例:

检查文章post/123是否存在:HEAD https://yourblog.com/post/123

检查博客服务器是否正常运行:HEAD https://yourblog.com/

这里可以使用curl(终端命令行网络请求工具)。在命令行中输入curl -I,用于模拟一个客户端发起的常规的GET请求,用于确认URL的有效性及资源更新的日期时间。如果想要用HEAD方法发起请求,我们可以输入curl --head,此时只会返回http应答的报头部分的信息:

上图说明该网站资源没有挂掉,更新时间是2026.9.2

5.DELETE方法

用于删除指定资源,是PUT的相反方法

示例:

删除某篇文章:DELETE https://yourblog.com/post/123

删除某条评论:DELETE https://yourblog.com/comment/456

6.OPTION方法

查询服务器支持的HTTP 方法,常用于跨域请求预检。

示例:

查询博客 API 支持的方法:OPTIONS https://yourblog.com/api/post,服务器会返回Allow: GET,POST,PUT,DELETE这类响应头

二、手写HTTP协议

结合咱们前面学过的内容不难看出,HTTP 协议通信由请求、响应两部分组成。接下来我们封装两个对应的类,分别实现各自的处理逻辑。

2.1 HTTP请求

首先是http请求类,我们需要借助字符串来保存请求中所有的结构化数据:

cpp 复制代码
    std::string _method;
    std::string _uri; 
    std::string _version;
    std::unordered_map<std::string, std::string> _header;
    std::string _blank_line;
    std::string _text;

要让服务器正常获取完整客户端请求,核心要处理两件事:一是完整读取客户端传输的请求报文,二是对报文中携带的 JSON 字符串完成反序列化。考虑到 HTTP 解析模块不引入第三方序列化库,JSON 的序列化与反序列化逻辑我们全部手动编写实现。

我们在对http请求进行序列化时,首先需要解析请求行的内容,然后解析报头部分的内容,最后解析正文部分的内容。所以我们需要三个成员函数bool ParseReqLine(std::string &httpstr)、ParseHeaderkv(std::string &httpstr)和bool ParseText(std::string &httpstr)。

为了处理读取的问题,我们需要封装一个工具类Util,在里面实现对报文的完整读取和对正文内容的读取。在读取报文时会出现三种情况:1.读取的内容为空,那我们直接返回空串;2.不为空,则截取到换行符(linesep="\r\n")处字符串的内容;3.如果这一行的内容为空,说明只有换行符,则返回换行符。

cpp 复制代码
static std::string ReadLine(std::string &str)
{
    auto pos = str.find(linesep);
    if (pos == std::string::npos)
        return std::string();
    std::string line = str.substr(0, pos);
    str.erase(0, line.size() + linesep.size());
    if (line.empty())
        return linesep;
    return line;
}

有了这个工具函数之后,我们就可以将请求行中的内容保存起来了:

cpp 复制代码
 if (req_line.empty() || req_line == linesep)
            return false;
        std::stringstream ss(req_line);
        ss >> _method >> _uri >> _version;

作为一个HTTP网络服务,必须得有自己的web根目录,所以我们需要在读取请求行时编写相关的代码。

如果请求行中的uri只有一个/,则在需要添加上一个默认首页,即/+index.html。然后无论是不是uri只有/的情况,都需要在_uri前面加上web根目录,后续在请求资源的时候就是在web根目录下请求特路径下的资源。

cpp 复制代码
 if (_uri == "/")
        _uri += g_first_page; // uri: /-> uri: /index.html
        _uri = g_wwwroot + _uri; // wwwroot/index.html: web 根目录 // wwwroot/Login

所以http请求的本质,其实就是通过网络服务器, 把Linux机器上的特定目录下的文件,以http应答的方式返回给客户端client(浏览器或app)

完成对请求行的读取之后,就要进行解析报头的工作了:

读取报头时,需要循环读取,直到读取到空行(\r\n)停止循环。如果读取到的报头内容为空,说明报头内容残缺,直接返回false终止解析;如果读取不到报头键值的分隔符":",说明报头格式不对,返回false终止解析;如果没有上述问题,则将键值插入到我们先前定义的unordered_map中:

cpp 复制代码
 bool ParseHeaderkv(std::string &httpstr)
    {
        std::string header_line;
        do
        {
            header_line = Util::ReadLine(httpstr);
            if (header_line.empty())
                return false;
            if (header_line != linesep)
            {
                auto pos = header_line.find(headersep);
                if (pos == std::string::npos)
                {
                    return false;
                }

                std::string key = header_line.substr(0, pos);
                std::string value = header_line.substr(pos + headersep.size());

                // _header[key] = value;
                _header.insert(std::make_pair(key, value));
                // _header.insert({key, value});
            }
        } while (header_line != linesep);
        return true;
    }

完成了对报头的解析后,接下来是对正文部分的解析。

在对正文部分进行解析时,首先需要读取正文部分的信息,这里又需要一个工具函数来完成对正文信息的读取:

cpp 复制代码
static std::string ReadFile(const std::string &filename)
{
    // 采用二进制方式读取数据
    std::ifstream in(filename, std::ios::binary);
    if (!in.is_open())
    {
        return std::string();
    }
    in.seekg(0, in.end);
    int filesize = in.tellg();
    in.seekg(0, in.beg);

    std::string content;
    content.resize(filesize);
    in.read((char *)content.c_str(), filesize);
    in.close();
    return content;

由于我们在对正文内容进行读取的时候,正文内容可能不仅仅是文本内容,也有可能是音频、图片、视频这些内容,以字节流的形式对正文内容进行读取不可取。所以这里我们采用二进制的方式读取数据。

cpp 复制代码
 bool ParseText(std::string &httpstr)
    {
            if (_header.find("Content-Length") == _header.end())
            {
                _text = "";
            }
            int content_len = std::stoi(_header["Content-Length"]);
            _text = httpstr.substr(0, content_len);
            httpstr.erase(0, content_len);
        return true;
    }

2.2 HTTP应答

对于http应答,我们需要将服务器端发送的信息进行序列化后,再发送给客户端。应答共有三个部分:状态行、响应报头、响应正文。首先我们需要按照状态行的格式构建状态行:

cpp 复制代码
std::string respstr = _version;
respstr += spacesep;
respstr += std::to_string(_code);
respstr += spacesep;
respstr += _code_desc;
respstr += linesep;

接下来构建报头:

cpp 复制代码
for (auto &header : _header)
{
    std::string line = header.first + headersep + header.second + linesep;
    respstr += line;
}

添加空行并添加响应正文:

cpp 复制代码
respstr += _blank_line;
respstr += _text;

同时需要添加下面几个函数,用于设置状态码与正文、返回正文长度以及添加报头:

cpp 复制代码
void SetCode(int code)
{
    _code = code;
    _code_desc = CodeToDesc(_code);
}
void SetBody(const std::string &content)
{
    _text = content;
}
int BodySize()
{
    return _text.size();
}
void AddHeader(const std::string &key, const std::string &value)
{
    // 如果存在就覆盖,如果不存在就添加
    _header[key] = value;
}

同时,在设置好状态码的同时,我们需要根据状态码设置状态码描述,状态码与状态码描述:

cpp 复制代码
std::string CodeToDesc(int code)
{
    switch (code)
    {
    // 1xx
    case 100:
        return "Continue";
    case 101:
        return "Switching Protocols";

    // 2xx
    case 200:
        return "OK";
    case 201:
        return "Created";
    case 202:
        return "Accepted";
    case 204:
        return "No Content";
    case 206:
        return "Partial Content";

    // 3xx
    case 301:
        return "Moved Permanently";
    case 302:
        return "Found";
    case 304:
        return "Not Modified";
    case 307:
        return "Temporary Redirect";
    case 308:
        return "Permanent Redirect";

    // 4xx
    case 400:
        return "Bad Request";
    case 401:
        return "Unauthorized";
    case 403:
        return "Forbidden";
    case 404:
        return "Not Found";
    case 405:
        return "Method Not Allowed";
    case 408:
        return "Request Timeout";
    case 409:
        return "Conflict";
    case 413:
        return "Payload Too Large";
    case 429:
        return "Too Many Requests";

    // 5xx
    case 500:
        return "Internal Server Error";
    case 501:
        return "Not Implemented";
    case 502:
        return "Bad Gateway";
    case 503:
        return "Service Unavailable";
    case 504:
        return "Gateway Timeout";
    case 505:
        return "HTTP Version Not Supported";

    // 默认情况
    default:
        return "Unknown Status Code";
    }
}

2.3 HTTP协议

协议部分的代码首先需要创建请求结构体并解析客户端发来的原始报文,同时提前创建一个空的响应结构体,为后续构造回复报文做准备。

cpp 复制代码
HttpRequest http_req;
http_req.Deserialize(req);
HttpResponse http_resp;

然后需要分析客户端发来的的正文部分是否有内容,并设置状态码。如果内容为空则设置状态码为404,并填充报头部分的内容。

cpp 复制代码
std::string RequestContent()
{
    return Util::ReadFile(_uri);
}
if (content.empty())
{
    http_resp.SetCode(404);
    http_resp.SetBody(Util::ReadFile(page_404));
    http_resp.AddHeader("Content-Length", std::to_string(http_resp.BodySize()));

}
else
{
    http_resp.SetCode(200);
    http_resp.AddHeader("Content-Length", std::to_string(content.size()));
    http_resp.AddHeader("Connection", "close");
    http_resp.SetBody(content);
}

当正文内容为空时,返回的其实是一个404页面,而page_404就保存者下面的用于构建这个页面的前端代码。当使用工具类Util中的ReadFile函数读取page_404中的内容时,会把整个 html 文本塞进响应体,最后在客户端就会看到一个404界面。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>404 页面不存在</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            height: 100vh;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            font-family: Arial, sans-serif;
            background: #f5f5f5;
        }
        h1 {
            font-size: 80px;
            color: #333;
        }
        p {
            font-size: 18px;
            color: #666;
            margin: 10px 0 30px;
        }
        a {
            padding: 10px 24px;
            background: #2563eb;
            color: #fff;
            text-decoration: none;
            border-radius: 4px;
        }
    </style>
</head>
<body>
    <h1>404</h1>
    <p>你访问的页面找不到了</p>
    <a href="/">返回首页</a>
</body>
</html>

<a href="/">返回首页</a>是HTML里标准的a超链接标签。href="/" 代表跳转网站根目录首页,页面展示文字为 "返回首页",点击后浏览器会向服务端请求首页资源。随后服务端将首页index.html的内容返回给客户端,用户就会看到首页的内容。

所以网站的本质其实是一棵多叉树,其中我们的首页对应的就是这棵多叉树的根节点。而在这个首页里又会有各种A标签,可以直接指向下一级页面,下级页面也可以继续再点击进入下下级页面,然后也可以回退到前一个页面。这些操作的本质就是一个一个的http请求

2.4 状态码

解析正文内容后,会根据内容是否为空设置状态码。状态码返回之后,可以运行客户端向服务端发起二次请求,当服务器发起302 301这样的重定向状态码时,浏览器会得到http response应答报文。应答报文部分有一个属性叫做location,而location可以指向另一个全新的网页,也可以指向当前站点中的某些资源。

2.5 临时重定向和永久重定向

在某些情况下,可能需要我们从一个页面自动跳转到另一个页面,而不需要用户进行主动点击。那么这种自动从一个页面跳转到另一个页面的过程就叫做重定向。而重定向又分为临时重定向和永久重定向。

临时重定向就是旧页面节点只是临时换一条分支跳转,浏览器不会缓存新地址,每次访问旧链接都会发送两次 HTTP 请求,等需求结束就能恢复原页面;而永久重定向代表旧页面节点彻底废弃、永久迁移到新地址,浏览器会缓存新节点地址,后续访问旧链接不会再请求旧页面。

所以上面返回404页面的代码就可以改为通过设置状态码为302,然后临时重定向跳转到404页面:

cpp 复制代码
if (content.empty())
{
    http_resp.SetCode(302);
    http_resp.AddHeader("Location", "/404.html");
}

2.6 后缀

图片、视频、音频、css、js都是静态资源,我们只需要获取即可。但是浏览器是怎么知道response的正文部分具体是上面格式的文件呢?在response报头部分存在一个键值对Content-Type,用于标识正文部分的数据类型(text/html等)。我们可以在应答部分添加上这个报头就可以识别正文的格式了。

但是我们能成功设置正文部分的格式,首先我们需要知道正文部分是上面格式的内容。因此在在这些图片、音频这些静态资源的后面,都要有对应的后缀(.jpg、.html等)。有了这些后缀,我们就能区分这些资源的格式了。

所以我们需要在请求部分识别客户端请求资源的后缀,并用字符串保存起来:

cpp 复制代码
std::string _suffix; // 后缀类型

然后我们在解析请求行时,以及有了请求的uri,我们从后往前搜索,找到后缀分隔符".",然后将分隔符后面的内容提取出来,放到_suffix保存起来。

cpp 复制代码
auto pos = _uri.rfind(suffixsep);
        if (pos == std::string::npos)
            _suffix = ".html";
        else
            _suffix = _uri.substr(pos);

但是我们在填充Content-Type时不是直接把_suffix的字符串填充进去,而是要根据不同数据类型,查找Content-Type对照表,下面是一些常用的后缀Content-Type对照表:

Content-Type 对应文件 / 用途
text/html html 页面
text/css css 样式文件
text/javascript js 脚本
text/plain 纯文本
text/csv csv 表格
application/json json 接口数据
application/x-www-form-urlencoded 普通表单提交
multipart/form-data 文件上传表单
application/xml xml 数据
application/pdf pdf 文档
application/zip zip 压缩包
application/octet-stream 二进制文件流
image/jpeg jpg 图片
image/png png 图片
image/gif gif 动图
image/webp webp 图片
image/svg+xml svg 矢量图
audio/mpeg mp3 音频
video/mp4 mp4 视频
cpp 复制代码
// 初始化10种常见的文件后缀到Content-Type的映射
std::unordered_map<std::string, std::string> MiniType::_mime_map = {
    // 文本类型
    {".txt", "text/plain"},
    {".html", "text/html"},
    {".htm", "text/html"},
    {".css", "text/css"},

    // 图片类型
    {".jpg", "image/jpeg"},
    {".jpeg", "image/jpeg"},
    {".png", "image/png"},
    {".gif", "image/gif"},

    // 应用类型
    {".pdf", "application/pdf"},
    {".json", "application/json"},
    {".xml", "application/xml"},

    // 脚本类型
    {".js", "application/javascript"},

    // 其他常用类型
    {".zip", "application/zip"},
    {".mp3", "audio/mpeg"},
    {".mp4", "video/mp4"}};

所以我们还需要编写一个函数,将后缀转化为Content-Type对照表中对应的应该填充的内容:

cpp 复制代码
static std::string Suffix2MimeType(const std::string &suffix)
{
    auto iter = _mime_map.find(suffix);
    if (iter != _mime_map.end())
        return iter->second;
    else
        return "text/html";
}

2.7 http表单

图片、音视频、CSS、JS 这类静态资源,客户端仅能单向向服务器请求获取,服务器只会原样返回文件,无法完成数据交互。 而注册、登录、查询、支付等需要收发业务数据的双向交互场景,必须依靠动态站点来实现。

前后端交互时,存在一个叫做表单的东西,当用户填写表单后,浏览器会自动提取表单里面的内容,并构造带参的http请求。而表单就是是网页中用于收集用户输入数据、提交给服务器实现双向交互的容器组件,是前端和后端传递业务数据最基础的载体。

在提交表单时,如果方法method处不写,默认使用的方法是GET方法。而GET方法使用的是URL传参,浏览器跳转后完整的URL是:

复制代码
/a/b/c?name=zhangsan&passwd=123456

使用GET方法时,会将参数以问号的形式跟在action地址后面。而action属性指向的是表单数据要提交到的服务端资源地址。

当使用的方法是POST方法,这里提取的参数会和GET方法有明显的不同。请求行的URL依旧是 /a/b/c,但是地址栏看不到参数。而这些参数,也就是表单数据会被放在请求体body当中。

html 复制代码
POST /a/b/c HTTP/1.1
Content-Type: application/x-www-form-urlencoded

name=zhangsan&passwd=123456

前面我们提到过,URI通常指向的是Linux服务器端的web根目录。但是URI还可以指向一种web服务。所以我们就需要添加与服务相关的代码。

cpp 复制代码
class HttpProtocol
{
private:
    std::unordered_map<std::string, service_t> _http_services; // 根据uri(服务路径)进行功能路由
};
cpp 复制代码
using service_t = std::function<void (HttpRequest&req, HttpResponse &resp)>;

创建一个回调函数service_t类型,它可以根据request请求得到对应的response应答。有了这个函数类型,在http协议内部,我们可以再添加一个方法:RegisterService,用于把URI前缀和接口路径拼接成完整的key,将URI和上层业务回调函数绑定,存入路由表,供后续 HTTP 请求到来时查找调用。

cpp 复制代码
void RegisterService(const std::string &uri, service_t service)
{
    std::string key = g_wwwroot + uri;
    _http_services[key] = service; // key -> wwwroot/Login
}

当http启动的时候,我们可以向http注册服务,如果服务已经存在就覆盖先前的服务;如果没有被注册,就设置新的服务。然后在主函数中,我们可以创建注册、登录、搜索等接口,然后再在定义http协议后,识别用户传入的uri,如果传入的是/Login,则回调处理Login;如果传入的是/Register,则回调处理Register。

http服务可以使用这种注册服务的方式,对外提供各种各样的服务。

cpp 复制代码
std::unique_ptr<HttpProtocol> protocol = std::make_unique<HttpProtocol>();
protocol->RegisterService("/Login", Login);
protocol->RegisterService("/Register", Register);
protocol->RegisterService("/Search", Search);

然后我们需要在创建应答后添加一个判断函数------如果用户请求的是某种服务,就按照上面的服务来进行回调处理;如果不是请求某种服务,就按照正常返回网页资源。

cpp 复制代码
void RegisterService(const std::string &uri, service_t service)
{
    std::string key = g_wwwroot + uri;
    _http_services[key] = service; // key -> wwwroot/Login
}
bool IsReqService(const std::string &uri)
{
    auto iter = _http_services.find(uri);
    if (iter == _http_services.end())
        return false;
    else
        return true;
}
//...
HttpRequest http_req;
http_req.Deserialize(req);
HttpResponse http_resp;
if (IsReqService(http_req.Uri()))
{
    // 功能路由
    _http_services[http_req.Uri()](http_req, http_resp);
}
else
{
    if (content.empty())
    {
        http_resp.SetCode(302);
        http_resp.AddHeader("Location", "/404.html");
    }
    else
    {
        http_resp.SetCode(200);
        http_resp.AddHeader("Content-Length", std::to_string(content.size()));
        http_resp.AddHeader("Content-Type", MiniType::Suffix2MimeType(http_req.Suffix()));
        http_resp.AddHeader("Connection", "close");
        http_resp.SetBody(content);
    }
}

有了上面的代码之后,我们的http服务既能做静态页面资源分发,又能对动态业务进行处理。

由于上述例子中使用的是POST方法,而GET方法会将提取的参数跟在uri后面,用"?"作为分隔符隔开,这样就无法与我们的注册回调函数时存入map里的路由key匹配。所以我们就需要在解析正文部分添加一个判断逻辑,如果读取到分隔符,就将分隔符前面的纯路径作为最终路由URI提取出来,并把分割符后面的参数存入请求正文:

cpp 复制代码
if (strcasecmp(_method.c_str(), "GET") == 0)
{
    auto pos = _uri.find(argsep);
    if(pos == std::string::npos)
    {
        _text = std::string();
        return true;
    }
    else
    {
        std::string temp = _uri;
        _uri = temp.substr(0, pos);
        _text = temp.substr(pos+argsep.size());
    }
}
else
...

需要注意的是,登录、注册 操作选用POST而非GET。一是由于GET会将账号密码明文拼接在URL中,易留存于日志、浏览记录造成信息泄露,POST数据存放于请求正文更安全;二是GET存在URL长度上限,无法承载大量表单信息;三是二者语义不同,GET用于只读查询,登录注册属于提交数据、变更服务端状态的操作,适配POST语义,同时POST不会被浏览器缓存自动重复提交,避免重复注册、重复登录等问题。

重识URI

有了以上认识之后,我们就可以重新构建对http request中URI的认识了:

1.首先URI是Linux web根目录下存在的一个文件路径 (静态资源访问),视频,音频,html...(静态资源访问)

2.其次,我们可以把它当做一个字符串,用来请求 http server 中所对应的服务。"字符串" 也往往以路径形式体现,只不过,这个路径不能在 web 根目录下存在!像/Login、/Register、/Searcher这种动态接口,只是一种路由标识,绑定并触发对应的业务服务。(提供动态服务)

三、http会话管理

一个网站在我们进行了一次登录之后,后续我们很长时间都会在线,不需要再进行登录操作。这种能力依托http会话管理实现,能够有效避免用户频繁登录的繁琐操作。

由于http是无状态的,也就是说它不会记录下自己曾经访问过哪些网页,但是浏览器会留存用户的站点访问历史,并且可以缓存网页的静态资源。当用户再次访问同一网站时,浏览器直接加载本地缓存资源,页面加载速度会明显提升。

http也是无连接的,每次HTTP请求都新建TCP 连接,响应完毕就断开;后续请求需要重新建立连接。http只关心请求与响应的通信规范,而连接的建立、传输和断开工作是由TCP来完成的。

3.1 认识cookie

当用户进行登录操作后,输入对应的账户和密码,浏览器就会以POST方法将表单数据上传到服务器,服务器读取账号与密码并比对数据库,校验通过则返回成功信息,同时返回 301 重定向状态码,跳转至登录成功页面。由于 HTTP 是无状态协议,服务器无法留存用户登录身份信息。当用户后续请求图片、视频等静态资源时,服务端无法识别当前登录用户,会强制要求用户进行重复登录,最终使得资源请求流程中断了。

为了解决这个问题,开发人员在报头中新增了一种属性,叫做Set-Cookie。Set-Cookie中就包含用户的用户名和密码。然后服务端在应答时添加上报头Cookie,并且返回给客户端。客户端接收到之后,就会将这个数据自行设置并保存起来,而这个保存起来的数据我们称之为cookie。

后续客户端在向服务器端请求数据的时候,会自动在请求头中携带Cookie字段,里面存储了用户账号与密码相关信息,这样服务端在得到Cookie字段后,会解析Cookie中的用户信息,然后自动进行认证操作。

Cookie的保存有两种方式,一是写入到文件中进行保存;二是保存到客户端进程的内部,即内存中。如果是保存到文件中,每次点开网站都会加载对应的文件,就能成功识别用户信息,并进行认证,这种方式就是持久化的存储方式;如果是保存至内存中,一旦浏览器进程关闭,内存数据就会随之清空,cookie就会消失,这种存储形式日常场景很少使用。

但是用户登录的认证信息不可能一直保存着,肯定需要进行时间的限定。而Set-Cookie可以在其后面设置过期时间。一旦时间到了,浏览器就会自动删除掉Cookie文件。

Set-Cookie示例:

复制代码
Set‑Cookie: username=peter; expires=Thu, 18 Dec 2024 12:00:00 UTC; path=/; 
domain=.example.com; secure; HttpOnly

其中,时间格式必须遵守RFC 1123标准,具体格式样例:Tue, 01 Jan 2030 12:34:56 GMT。GMT是格林威治标准时间。

上面这种以http cookie和Set-Cookie的形式来保持用户在线的模式我们称之为会话管理。

但是Cookie这种方式也存在一定的缺点,当网站存在XSS跨站脚本漏洞时,攻击者可注入恶意脚本用于读取用户身份Cookie,并将Cookie数据外传至攻击者服务端。攻击者获取Cookie后,无需用户账号密码即可伪造用户身份完成服务端鉴权,这样就会造成用户账号被盗的问题。

基于前面我们所写的代码,我们可以在返回静态资源页面的响应里手动添加该响应头,浏览器接收到响应后,便可以在用户登录流程中自动保存对应的 Cookie 信息。

cpp 复制代码
if (content.empty())
    {
        http_resp.SetCode(302);
        http_resp.AddHeader("Location", "/404.html");
    }
else
    {
        http_resp.SetCode(200);
        http_resp.AddHeader("Content-Length", std::to_string(content.size()));
        http_resp.AddHeader("Content-Type", MiniType::Suffix2MimeType(http_req.Suffix()));
        http_resp.AddHeader("Connection", "close");
        http_resp.AddHeader("Set-Cookie", "username=peter;");
        http_resp.SetBody(content);
    }

3.2 http session

上面的纯Cookie的做法,是把用户的私密数据保存在客户端,由于客户端环境不受服务端管控,加上用户安全防护意识不足,可能会被攻击者窃取,造成用户账号被盗的问题。

所以就出现了session+cookie组合技术来进行会话管理。当客户端将用户信息发送到服务端,服务端得到账号密码后进行认证。用户的个人信息中都是一些结构化信息,服务器端会将这些结构化信息以文件的形式保存起来,然后使用成熟的算法,得到整个服务端唯一的数字。然后将保存用户信息的这个文件用这个唯一的数字进行标识。

如此一来,用户的会话信息就全部保存在服务端。我们把保存的用户的基本信息叫做session。用于标识该会话的数字标识叫做session id。每个用户都有自己的独立的session。

当服务端向客户端发送应答时,将session id保存在Set-Cookie中。浏览器在接收到这个cookie时,就会将这个id记录下来。后续每一次要向服务器端获取资源,向服务器发送对应的请求时,Cookie字段都会带上session id,然后服务器端在特定路径下,找到session id对应的session文件,并对文件内的信息进行分析,如果用户的状态处于登录状态,就不再进行审核。

上面的将用户信息保存在文件中是较为传统的实现方案。如果把session数据存储在文件中,服务器读取会话数据时需要进行磁盘IO 操作,而磁盘IO会拖慢HTTP接口的响应速度

为解决该性能问题,诞生了诸多后端中间件组件,Redis便是典型的键值(KV)型内存数据库。这些用户会话信息会被直接存入Redis内存数据库中,这样就能规避磁盘IO带来的性能损耗。

四、完整代码

Http.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <cstdio>
#include <string>
#include <sstream>
#include <unordered_map>
#include <fstream>
#include <functional>
#include "Logger.hpp"

const std::string linesep = "\r\n";
const std::string spacesep = " ";
const std::string headersep = ": ";
const std::string suffixsep = ".";
const std::string argsep = "?";
const std::string g_http_version = "HTTP/1.0";
const std::string g_first_page = "index.html";
const std::string g_wwwroot = "wwwroot";
const std::string page_404 = "404.html";

using namespace NS_LOG_MODULE;

class Util
{
public:
    // 1. 空:没有完整行
    // 2. \r\n: 读取到了空行
    // 3. 其他情况: 分析到了一行具体内容
    static std::string ReadLine(std::string &str)
    {
        auto pos = str.find(linesep);
        if (pos == std::string::npos)
            return std::string();
        std::string line = str.substr(0, pos);
        str.erase(0, line.size() + linesep.size());
        if (line.empty())
            return linesep;
        return line;
    }
    static std::string ReadFile(const std::string &filename)
    {
        // 采用二进制方式读取数据
        std::ifstream in(filename, std::ios::binary);
        if (!in.is_open())
        {
            return std::string();
        }
        in.seekg(0, in.end);
        int filesize = in.tellg();
        in.seekg(0, in.beg);

        std::string content;
        content.resize(filesize);
        in.read((char *)content.c_str(), filesize);
        in.close();
        return content;
    }
};

class HttpRequest
{
private:
    bool ParseReqLine(std::string &httpstr)
    {
        std::string req_line = Util::ReadLine(httpstr);
        if (req_line.empty() || req_line == linesep)
            return false;
        std::stringstream ss(req_line);
        ss >> _method >> _uri >> _version;

        if (_uri == "/")
            _uri += g_first_page; // uri: /-> uri: /index.html

        _uri = g_wwwroot + _uri; // wwwroot/index.html: web 根目录 // wwwroot/Login

        auto pos = _uri.rfind(suffixsep);
        if (pos == std::string::npos)
            _suffix = ".html";
        else
            _suffix = _uri.substr(pos);
        return true;
    }

    bool ParseHeaderkv(std::string &httpstr)
    {
        std::string header_line;
        do
        {
            header_line = Util::ReadLine(httpstr);
            if (header_line.empty())
                return false;
            if (header_line != linesep)
            {
                auto pos = header_line.find(headersep);
                if (pos == std::string::npos)
                {
                    return false;
                }

                std::string key = header_line.substr(0, pos);
                std::string value = header_line.substr(pos + headersep.size());

                // _header[key] = value;
                _header.insert(std::make_pair(key, value));
                // _header.insert({key, value});
            }
        } while (header_line != linesep);
        return true;
    }

    //
    bool ParseText(std::string &httpstr)
    {
        if (strcasecmp(_method.c_str(), "GET") == 0)
        {
            auto pos = _uri.find(argsep);
            if(pos == std::string::npos)
            {
                _text = std::string();
                return true;
            }
            else
            {
                std::cout << "_URI: "<< _uri << std::endl;
                std::string temp = _uri;
                _uri = temp.substr(0, pos);
                std::cout << "_URI: "<< _uri << std::endl;
                _text = temp.substr(pos+argsep.size());
                std::cout << "_text: "<< _text << std::endl;
            }
        }
        else
        {
            if (_header.find("Content-Length") == _header.end())
            {
                _text = "";
            }
            // POST方法
            int content_len = std::stoi(_header["Content-Length"]);
            _text = httpstr.substr(0, content_len);
            httpstr.erase(0, content_len);
        }

        return true;
    }

public:
    // 序列化
    bool Deserialize(std::string &httpstr)
    {
        std::cout << httpstr;

        // 1. 解析请求行
        bool n = ParseReqLine(httpstr);
        (void)n;

        // LOG(LogLevel::DEBUG) << "_method# " << _method;
        // LOG(LogLevel::DEBUG) << "_uri# " << _uri;
        // LOG(LogLevel::DEBUG) << "_version# " << _version;

        //  std::cout << httpstr;

        // 2. 解析报头
        n = ParseHeaderkv(httpstr);
        (void)n;

        _blank_line = linesep;

        // 3. 解析正文部分
        n = ParseText(httpstr);
        (void)n;

        return true;
    }

    std::string Uri() { return _uri; }
    std::string RequestContent()
    {
        return Util::ReadFile(_uri);
    }
    std::string Suffix() { return _suffix; }

    void DebugPrint()
    {
        std::cout << "_version: " << _version << std::endl;
        std::cout << "_uri: " << _uri << std::endl;
        std::cout << "_method: " << _method << std::endl;

        for (auto &header : _header)
        {
            std::cout << "==>" << header.first << " # " << header.second << std::endl;
        }

        std::cout << _blank_line;

        std::cout << _text << std::endl;
    }
    std::string Text()
    {
        return _text;
    }
    // 反序列化
private:
    // 结构化字段
    std::string _method;
    std::string _uri; //
    std::string _version;
    std::unordered_map<std::string, std::string> _header;
    std::string _blank_line;
    std::string _text;

    // 私有数据
    std::string _suffix; // 后缀类型
};

class HttpResponse
{
private:
    std::string CodeToDesc(int code)
    {
        switch (code)
        {
        // 1xx
        case 100:
            return "Continue";
        case 101:
            return "Switching Protocols";

        // 2xx
        case 200:
            return "OK";
        case 201:
            return "Created";
        case 202:
            return "Accepted";
        case 204:
            return "No Content";
        case 206:
            return "Partial Content";

        // 3xx
        case 301:
            return "Moved Permanently";
        case 302:
            return "Found";
        case 304:
            return "Not Modified";
        case 307:
            return "Temporary Redirect";
        case 308:
            return "Permanent Redirect";

        // 4xx
        case 400:
            return "Bad Request";
        case 401:
            return "Unauthorized";
        case 403:
            return "Forbidden";
        case 404:
            return "Not Found";
        case 405:
            return "Method Not Allowed";
        case 408:
            return "Request Timeout";
        case 409:
            return "Conflict";
        case 413:
            return "Payload Too Large";
        case 429:
            return "Too Many Requests";

        // 5xx
        case 500:
            return "Internal Server Error";
        case 501:
            return "Not Implemented";
        case 502:
            return "Bad Gateway";
        case 503:
            return "Service Unavailable";
        case 504:
            return "Gateway Timeout";
        case 505:
            return "HTTP Version Not Supported";

        // 默认情况
        default:
            return "Unknown Status Code";
        }
    }

public:
    HttpResponse() : _version(g_http_version), _code(0), _blank_line(linesep)
    {
    }
    void SetCode(int code)
    {
        _code = code;
        _code_desc = CodeToDesc(_code);
    }
    void SetBody(const std::string &content)
    {
        _text = content;
    }
    int BodySize()
    {
        return _text.size();
    }
    void AddHeader(const std::string &key, const std::string &value)
    {
        // 如果存在就覆盖,如果不存在就添加
        _header[key] = value;
    }
    std::string Serialize()
    {
        // 构建状态行
        std::string respstr = _version;
        respstr += spacesep;
        respstr += std::to_string(_code);
        respstr += spacesep;
        respstr += _code_desc;
        respstr += linesep;

        // 构建报头
        for (auto &header : _header)
        {
            std::string line = header.first + headersep + header.second + linesep;
            respstr += line;
        }

        // 添加换行符
        respstr += _blank_line;
        // 添加正文
        respstr += _text;

        return respstr;
    }

private:
    std::string _version;
    int _code;
    std::string _code_desc;
    std::unordered_map<std::string, std::string> _header;
    std::string _blank_line;
    std::string _text;
};

using service_t = std::function<void(HttpRequest &req, HttpResponse &resp)>;

class MiniType
{
public:
    static std::string Suffix2MimeType(const std::string &suffix)
    {
        auto iter = _mime_map.find(suffix);
        if (iter != _mime_map.end())
            return iter->second;
        else
            return "text/html";
    }

private:
    static std::unordered_map<std::string, std::string> _mime_map;
};

// 初始化10种常见的文件后缀到Content-Type的映射
std::unordered_map<std::string, std::string> MiniType::_mime_map = {
    // 文本类型
    {".txt", "text/plain"},
    {".html", "text/html"},
    {".htm", "text/html"},
    {".css", "text/css"},

    // 图片类型
    {".jpg", "image/jpeg"},
    {".jpeg", "image/jpeg"},
    {".png", "image/png"},
    {".gif", "image/gif"},

    // 应用类型
    {".pdf", "application/pdf"},
    {".json", "application/json"},
    {".xml", "application/xml"},

    // 脚本类型
    {".js", "application/javascript"},

    // 其他常用类型
    {".zip", "application/zip"},
    {".mp3", "audio/mpeg"},
    {".mp4", "video/mp4"}};

class HttpProtocol
{

public:
    HttpProtocol()
    {
    }

    void RegisterService(const std::string &uri, service_t service)
    {
        std::string key = g_wwwroot + uri;
        _http_services[key] = service; // key -> wwwroot/Login
    }
    bool IsReqService(const std::string &uri)
    {
        auto iter = _http_services.find(uri);
        if (iter == _http_services.end())
            return false;
        else
            return true;
    }
    std::string HandlerHttpRequest(std::string &req)
    {
        HttpRequest http_req;
        http_req.Deserialize(req);
        HttpResponse http_resp;
        if (IsReqService(http_req.Uri()))
        {
            // 功能路由
            _http_services[http_req.Uri()](http_req, http_resp);
        }
        else
        {
            // 1. 保证报文完整性
            // 2. 反序列化
            // 3. 根据不同的请求方法和请求URI,给用户返回不同的报文
            // 根据结构化http_req 转换成为结构化的http_resp
            std::string content = http_req.RequestContent();
            if (content.empty())
            {
                http_resp.SetCode(302);
                http_resp.AddHeader("Location", "/404.html");
            }
            else
            {
                http_resp.SetCode(200);
                http_resp.AddHeader("Content-Length", std::to_string(content.size()));
                http_resp.AddHeader("Content-Type", MiniType::Suffix2MimeType(http_req.Suffix()));
                http_resp.AddHeader("Connection", "close");
                http_resp.AddHeader("Set-Cookie", "username=peter;");
                http_resp.SetBody(content);
            }
        }

        return http_resp.Serialize();
    }
    ~HttpProtocol()
    {
    }

private:
    std::unordered_map<std::string, service_t> _http_services; // 根据uri(服务路径)进行功能路由
};
相关推荐
JeJe同学1 小时前
Linux远程服务器网络访问优化:使用SSH反向端口转发共享本地HTTP代理
服务器·网络·ssh
rcms152702692181 小时前
Advanced Energy 31512411-338直流电源
网络
明航咨询-陈老师2 小时前
信息安全服务资质扫盲:CNITSEC 与 CCRC,两套国字号资质的完整对比(技术向)
网络·安全·web安全
宗介波妞2 小时前
主站与场站多业务链路通断测试实战指南(电力自动化联调)
网络·数据库
为思念酝酿的痛2 小时前
应用层自定义协议与序列化
网络
Titan20242 小时前
网络学习:网络层与数据链路层知识梳理
服务器·网络·学习·tcp/ip
志栋智能2 小时前
运维超自动化中的配置管理实践
运维·网络·数据库·自动化
一条泥憨鱼2 小时前
【从0开始学习计算机网络】| TIME_WAIT 为什么是 2MSL,CLOSE_WAIT / TIME_WAIT 堆积怎么排查
服务器·开发语言·网络·计算机网络·网络安全
FW-Linker2 小时前
多卡聚合的数据包级并行传输原理在广电直播场景中详解
网络·5g·架构·智能路由器