应用层协议——http

目录

[http 介绍](#http 介绍)

[urlencode / urldecode](#urlencode / urldecode)

[http 请求与响应格式](#http 请求与响应格式)

[1 请求](#1 请求)

[2 响应](#2 响应)

[http 状态码](#http 状态码)

长连接

会话保持

[调试 http 的一些基本工具](#调试 http 的一些基本工具)

telnet

postman

fidller


http 介绍

针对常见场景,早已有大佬们写好了对应的协议,最典型的就是 http 和 https ,是针对web端的协议。 这篇文章我们主要讲解 http 协议, 下一篇文章会带来 https 的内容。

http也叫超文本传输协议,在了解 http 协议之前,我们先来谈一下我们平常访问网站或者服务器的网址,网址更专业的叫法其实是 URL

我们拿一个常用的 URL 来分析他的组成。

https://www.bilibili.com/video/BV1u4pueoEGL/?spm_id_from=333.1007.tianma.1-2-2.click&vd_source=2d2268297f979346868568bb0b6b2d00

当前我们的很多的网址都是用https了,所以我们直接解析一个https协议的url,本质上是一样的,就是协议不一样。

我们的url中的 / : ? : =,@,%,& 等字符都是被特殊解释了,一会我们会将如果我们的参数中如果存在特殊字符该怎么做。

首先第一个字段 协议类型,第二个字段就是我们要访问的域名,其实就是要访问的服务器的ip地址,而我们所看到的域名不就是一个字符串吗?他怎么转换为ip地址呢?域名解析,也就是我们的DNS,将字符串解析出特定的 ip ,不管怎么样,他底层一定是转换为ip 来处理的。 但是我们发现这里没有看到我们网络通信所必须的端口号,为什么呢? 因为类似于 http/https/tcp等等这些常用的协议其实与一些特定的端口号是强绑定的,在我们的操作系统的某些配置文件中就已经指明了哪些协议绑定哪些特定的端口就号,也就是说,其实指明了协议类型, 端口号就已经默认了,于是在我们的 url 中就省略了,当然,我们所看到的 url 只是给用户看的,最终真正访问的时候,底层还是会将端口号加上的。 值得注意的是,一般这个端口号我们省略,在有些场景下如果我们强制加上,还可能会导致一些服务器的解析出现问题。 同时,其实原来的 url 中,在协议类型和 域名之间还有一个字段叫做登录信息,只不过现在已经不用了,所以我们也不需要考虑。

然后一个很重要的字段就是一串文件路径**,** 我们可以把服务器当作一台大的 Linux 机器,而http协议的本质就是从远端服务器拿下来对应的资源,也就是拉取到本地,以前我们写的服务器的数据一般都是直接从内存中拿,但是我们的服务器并不一定就从内存中拿资源,也可能或者说大部分情况下会去服务器的磁盘中拿资源。在网络中看到的所有的东西都是资源,而资源就极有可能是在文件中,而文件又是存在我们的服务器的磁盘上,那么要从服务器中拉取资源到本地,首先就是要指明我们要拿的资源是哪个,那么也就是需要文件 路径 来指定。

我们的资源的类型有很多很多,比如源文件,二进制程序,图片文件,视频等等,他们是不同类型的文件或者说资源,但是它们都可以通过 http 协议进行传输,所以 http 又叫 超文本传输协议

那么我们上面的 url 中的资源路径的第一个 / 难道就是服务器的根目录吗? 不一定或者说绝大部分情况下都不是,这个 / 叫做Web根目录,和Linux的根目录不是一个东西

我们上网的行为无外乎就两种,一种是将资源从服务器拉取到本地,二是将本地资源上传到服务器。

而将本地资源上传到服务器的方式其中一种就是 url 中的参数传递,url 中 ? 后面的就是我们的参数,而参数一般以 kv 格式传递,key=value ,多个参数之间使用&连接,比如我们搜索时传的关键字就是一种将本地资源上传到服务器的操作。

但是有时候我们的参数中可能也会有 上面所说的特殊的字符,这时候就需要我们能够将特殊字符转义,就跟我们的C语言的printf 函数中的 % 或者字符换串中的 \ 一样,需要对其进行转义来表达这些特殊的字符本身。 而在http中用到的策略就是 urlencode 和 ueldecode

urlencode / urldecode

url中以?作为分隔符的后面的字符串就是我们的参数,而如果我们的参数部分有特殊字符,就需要使用 urlencode 进行转义。

urlencode转移规则:

将需要转义的字符转换为 16 进制,在前面加上 % ,编码成 %XY 的形式

如果转换出来的十六进制只有一位,要在左侧补 0 ,比如我们的空格,他的 ASCII码值为8,那么他转义之后就是 %08 。

同时我们的汉字也是可能需要 urlencode转码的,还是因为编码的方式,比如我们使用 utf-8 编码,那么汉字就占 3 个字节,而这三个字节中拆开按单字节解释的话也是可能存在特殊字符的,我们的url本质就是一个字符串,他最终肯定是要按单字节解释成字符的。

那么未来服务器收到url之后,提取参数的时候也自然需要解码,解码的规则就是相反了,检测到%之后就将%和后续的两个16进制的字符转换成十六进制的ASCII码,那么就得到了转码之前的原值。当然,他在解析的时候也会以kv的性格进行参数的提取。

当然,转码的工作我们一般不用自己做,因为我们的浏览器会自动进行转码。而解码工作的化,如果我们是从零开始写一个服务器的话,就需要我们自己进行解码的工作了。不过我们也一般不需要自己从零写一个http的服务器,一般在这之上做工作。

网上也有一些编码和解码的工具可以参考:

UrlEncode编码和UrlDecode解码-在线URL编码解码工具

http 请求与响应格式

在应用层我们一般将数据按类型分为请求与响应。

1 请求

http请求是以行为单位的。每一行的结尾都是 \r\n

http 请求一共分为四个部分

第一部分:

首先第一行分为三个部分,第一个部分表示请求的方法,第二个部分表示请求的 url ,第三个部分表示http的版本。这三个部分以空格分隔

请求方法常用的有GET,POST等,而url我们也讲过了,http的版本则有 1.0,1.1,2.0等,常用的还是1.0和1.1 。

比如: GET http//baidu.com HTTP/1.1

版本的HTTP也可以小写,但是有些服务器也无法识别。

第二部分:

第二行开始就是第二部分,也就是我们的请求属性,其中属性可能是多行的,每一行还是\r\n结尾,每一行属性的格式 是 **name: value,**注意,冒号后面需要带空格。 常见的属性有 Content-Length,Connection,Host等,后续我们用到的时候会讲

第三部分:

第三部分其实就是一个分割行, \r\n ,

第四部分:

正文。正文是可选部分,可带可不带。

那么一个完整的http请求如图:

我们把第一行称为请求行。第二部分也就是请求属性称为请求报头。而第三部分就是空行。

这个空行其实非常重要,为什么呢?

http请求是基于tcp协议的,也就是面向字节流的,那么我们就需要考虑如何拿到一个完整的 http 请求,要拿到一个完整的 http 请求,首先我们就需要将 http 报头与正文分离,而这个空行就是分离的关键。而正文长度我们这儿不需要担心,一般携带正文的话会在请求报头中将Content-Length属性带上。

2 响应

从宏观上来讲http响应的格式和请求的格式差不多。也是分为四部分。

第一部分:

第一部分局就是第一行,响应的第一行称为 状态行第一行中第一个字段为HTTP的版本,第二个字段为 状态码 ,第三个字段为状态码描述。中间还是以空格分割。

状态码是什么呢?比如我们常见的打开一个网址,出现 404 Not found ,这个404 其实就是我们的状态码,而Not found 就是我们的状态码描述。当然其实一般我们不会直接将状态码显示给用户看。状态码其实就是类似于我们的 exitcode ,我们的客户端能够根据状态码来判断他所发送的请求的状态,是文件路径错误找不到还是正在执行等。

第二部分:

第二部分还是多行构成的属性字段,也是 name: value 的格式,我们称之为响应报头。属性的类型和请求报头的差不多,不过也会有一些特殊的属性,后面会讲。

第三部分:

空行,还是用来分割响应报头与正文,很重要。

第四部分:

正文,可选。

那么响应的整体的格式如图:

那么我们就要考虑两个问题:

1 请求和响应怎么保证应用层能完整读取呢?

2 我们所谓的请求和响应其实还是结构化的数据,也就是多个string,那么发送的时候也需要序列化和反序列化,怎么做呢?

首先,完整读取的问题我们很好理解了,tcp是面向字节流的,我们读取的时候可以当作字符串来读取,那么我们就可以按行读取,不管怎么样,第一行肯定是我们的请求行或者状态行,而第二行开始就是请求或者响应报头,我们按行读取知道读到一个空行,空行就是我们的第三部分,那么读到空行之后我们就已经拿到完整的 http 报头,当然请求行和状态行也拿到了,不过他与我们读取完整报文无关,先不关心。 在请求或响应报头中就有 Content-Length属性,他会包含正文长度,这样我们就能将正文也全部读取出来。

然后就是 http 的序列化和反序列化,这其实是很简单的,我们把他的请求和响应每一行以 \r\n 分割,然后变成一个字符串就行了,反序列化也是一样的。 而真我跟的序列化和反序列化一般不用做,因为正文中一般不会是这种结构化的数据,当然如果是,也是需要自己定义该结构的序列化和反序列化的。正文中一般不用 \r\n ,将空间最大效率利用。

那么如何验证我们上面所说的 http 请求和响应的格式呢?

我们直接写代码,写一个简单的tcp服务器的逻辑,只需要将数据读取上来,并且将请求分离就行了。在这里我们不需要写客户端了,直接使用浏览器或者 telnet工具做客户端就行了。

处理逻辑代码如下:

class Request
{
public:
    void print()
    {
        cout<<_RqLine;
        for(auto&str:_RqHeader)
        cout<<str;
        cout<<_body<<endl;
        cout<<"---------------------------------------------------------------"<<endl;
    }
public:
string _RqLine;
vector<string> _RqHeader;
size_t _ConLen = 0 ; //正文长度
string _body; 
};

class Response
{
public:

public:
string _StateLine;
string _ResHeader;
string _body; 
};

#define SEP "\r\n"
#define SEPLEN strlen(SEP)
void Handler(int sock, uint16_t clientport, string clientip)
{
    string inbuffer;

    while(1)
    {
        char buffer[1024];
        bzero(buffer,sizeof buffer);
        int n = read(sock,buffer,sizeof buffer-1) ;
        if(n==0)
        {
            exit(0);
        }
        if(n<0)
        {
            cerr<<"read error"<<endl;
            exit(READ_ERR);
        }
        buffer[n] =0;
        inbuffer+=buffer;
        //首先读取请求行
        //第一行
        Request rq;
        int begin = 0 ;
        int end = inbuffer.find(SEP,begin);
        if(end==string::npos) //说明一行都没有
        continue;
        //到这里说明有一行
        rq._RqLine = inbuffer.substr(begin,end+SEPLEN); 
        begin = end+SEPLEN;//新的区间开始
        //读取请求报头
#define ConLenStr "Content-Length: "
        while(1)
        {
            end = inbuffer.find(SEP,begin); 
            if(end==begin) //说明读到空行,也就是把报头读完了
            {
                begin = end+SEPLEN;
                break;
            }
            else
            {
                rq._RqHeader.push_back(inbuffer.substr(begin,end-begin+SEPLEN)); //直接把SEPLEN也当作该行内容
                begin=end+SEPLEN;
                int x=rq._RqHeader.back().find(ConLenStr); // 判断是否读到正文长度字段
                if(x!=string::npos)
                {
                if (x != string::npos)
                {
                    int i = rq._RqHeader.back().find(" ");
                    rq._ConLen = stoi(rq._RqHeader.back().substr(i+1,rq._RqHeader.back().size()-i-1-SEPLEN));
                }
                }
                begin = end+SEPLEN;
            }
        }
        if(rq._ConLen <= inbuffer.size()-begin + 1)
        {
            rq._body = inbuffer.substr(begin,rq._ConLen);
            //读取到一个完整的请求
            begin += rq._ConLen;
            inbuffer.erase(inbuffer.begin(),inbuffer.begin()+begin);
            //打印
            rq.print();
            
        }
        else
        {
            continue;
        }

    }   

}

我们这里的处理逻辑就是从 tcp 的接收缓冲区中将一个完整的 http 请求读取出来并且打印。

这样我们就能将一个请求读取到了,

我们可以看到,当我们直接访问对应的服务器,不加资源路径的话,默认就是 / ,也就是我们的Web根目录。

难道这意味着把所有的资源都给用户吗? 不是。

一般我们访问http服务器,如果没有请求任何资源,那么他会返回一个首页。比如我们访问 www.baidu.com ,即使我们什么资源也不申请,他也会给我们返回一个百度的首页,当然是通过数据的方式返回,然后我们的浏览器将其解析出来。

然后就是我们的http 版本,目前最主流的就是 http/1.1 , http 请求交换bs(browser sever ,浏览器,服务器)通信双方的协议版本,

为什么要交换版本呢?

我们在现实生活中也会遇到这样的情况: 比如我们的浏览器更新了,但是我们不想升级更新,我们也可以继续使用老版本。 那么在 http 这里也是一样的,一定会存在多种版本的客户端。

那么就会存在一个服务器需要面对多种不同版本的客户端,而老版本的客户端使用的功能一定是新的版本的客户端的功能的一个自己,那么我们就需要通过客户端的版本来判断该客户端能够使用的功能的范围。http/1.1 和 http/1.0 和http/2.0 肯定也在功能上有一些差异,那么也需要版本号来限定。

第二部分请求报头中我们看到了几个字段

Host 字段,表示的就是我们这个请求未来发给那个服务端

Connection 字段表示的就是 长连接还是短链接 ,后续会讲到

Upgrade-Insecure-Requests 字段为1表示的就是协议升级。 http 协议是可以被升级的,http是cs或者bs模式,也就是给与请求和响应,意思就是必须由客户端主动发起请求,服务端才能构建响应。这里的升级指的是可以将 不安全的 http 协议升级为更安全的 https ,也就是告知服务器我们使用的客户端对 https 的支持很好,将来可以申请服务器的 https 资源或者说将我们的http 请求升级为 https 请求。

User-Agent 字段表示的是客户端的信息,比如操作系统版本,浏览器版本等

Accept 字段表示客户端能接受的文档格式

Accept-Encoding 字段表示客户端支持的压缩格式

Accept-Language 字段表示客户端支持的编码

那么收到请求之后,我们就可以构建一个请求返回给客户端了。

比如针对上面这种,GET方法,同时没有指定资源路径的请求 ,我们可以返回一个服务器的首页给客户端,这就要用到一些前端的知识了,我们可以在网上找一个前端的简单代码,越简单越好。

我们能发现,在服务器收到的 http 请求中,请求行中的 url 其实已经不是一个完整的 url 了,我们的服务器的ip和端口号已经被拆分到了 Host 字段中,所以 请求行中的 url 其实只剩下 端口号的后面的内容,也就是我们请求资源的路径以及参数。那么我们就可以以 ?为分隔符将请求行的url中资源目录和客户端提交的参数分离出来

那么我们可以在 Request中再加两个成员,来保存 path 和 parm ,也就是路径和参数

            int urlbegin = rq._RqLine.find(" ");                             // url 的前一个空格
            int urlend = rq._RqLine.rfind(" ");                              // url 的后一个空格
            string url = rq._RqLine.substr(urlbegin + 1, urlend - urlbegin-1); // url
            cout <<"url"<<url << endl;
            // 提取参数
            int sepindex = url.find("?");
            if (sepindex != string::npos)
            {
                rq._path = url.substr(0, sepindex);
                rq._parm = url.substr(sepindex + 1);
                // 打印测试
                // cout << rq._path << endl;
                // cout << rq._parm << endl;
            }
            else
                rq._path = url;
            responsehandler(sock, clientip, clientport, rq);

提取出来之后,处理请求,返回响应了。

响应的处理其实就是一系列的 if else 条件判断,首先要判断请求的方法是 GET还是POST,还是其他的,然后根据我们http请求中的参数或者正文来处理请求,不过我们这里不考虑方法了,一视同仁了,其次就要考虑资源所处的路径,如果为/,我们就来可以将我么的网站的首页返回给客户端,如果不是 / ,我们就要打开文件,并将文件的内容作为 相应的正文。可是这里又分为两个情况,1是文件打不开,也就是资源找不到,没有对应的文件,或者url中的path是一个目录,第二个就是文件打开,那么我们就正常将文件的内容读取。

那么我们首先就判断是不是 \ ,我们也需要有一个html 文件显示我们首页让用户看到。

<html>
<body>

<h1>网站首页</h1>

<p>这是一个网站首页</p>

</body>
</html>

那么我们的和html文件放在哪呢? 这就要回到我们之前所说的 Web根目录了。 我们的服务器在他(程序所在的目录)的目录下并不是只有他的的程序文件,一般他的Web根目录也是在这个目录下的,Web根目录一般我们取名叫做 wwwroot 。

那么我们在服务器地默认工作目录(也就是程序文件的目录)也就有了一个 Web根目录,而我们在url中拿到了一个资源路径,资源路径的根目录就是我们的Web根目录,那么最终的资源所在路径,不就是我们的Web根目录后面拼接上url中的资源路径吗?

void responsehandler(int sock, string clientip, uint16_t clientport, Request rq)
{
#define rootpath "wwwroot"             // 这里不加 / ,因为我们的 url 的资源路径中第一个字符就是 /
    string path = rootpath + rq._path; // 拼接得到资源在我们的服务器的Linux机器中的路径
#define defaultpath "wwwroot/index.html"
    if (path.back() == '/')
        path = defaultpath;
    // 返回网站首页
    Response resp;
    SetContent(resp,path); // 将文件中的数据拷贝到响应的正文中,顺带将状态行也填充进去

那么拼接完之后,我们就可以判断以下这个 path 最终是不是表示一个文件,首先,如果最后一个字符是 / ,就表示 url 中的资源路径是一个目录,那么我们也不给他报错,就给他返回我们上面的网站首页。

而如果是一个文件,那么我们就打开文件,并将其放在响应的正文中返回给客户端。

void SetContent(Response&resp, string path) //填充响应的状态行和正文
{
    int fd = open(path.c_str(), O_RDONLY);
    cout<<"fd:"<<fd<<endl;
    if (fd == -1) // 说明文件打开失败
    {
#define NotFoundPath "wwwroot/NotFound.html" // Notfound字段
        SetContent(resp, NotFoundPath);
        return;
    }
    cout<<"path:"<<path<<endl;
    char buffer[1024];
    int n = 0;
    while (n = read(fd, buffer, sizeof buffer - 1))
    {
        cout<<"n:"<<n<<" "<<"buffer"<<buffer<<endl;
        buffer[n] = 0;
        resp._body += buffer;
    }

    // 状态行我们直接采取硬编码的形式了
    if (path == NotFoundPath)
    {
        resp._StateLine = "HTTP/1.1 404 NotFound\r\n";
    }
    else
        resp._StateLine = "HTTP/1.1 200 OK\r\n";
    close(fd);
    return ;
}

填充完相应的正文和状态行之后,由于在我们这里的逻辑中,正文一定是有的,那么属性字段中的 Content-Length 我们带上。

并且完成相应的序列化以及发送数据的过程

那么完整的处理逻辑如下:

void responsehandler(int sock, string clientip, uint16_t clientport, Request rq)
{
#define rootpath "wwwroot"             // 这里不加 / ,因为我们的 url 的资源路径中第一个字符就是 /
    string path = rootpath + rq._path; // 拼接得到资源在我们的服务器的Linux机器中的路径
#define defaultpath "wwwroot/index.html"
    if (path.back() == '/')
        path = defaultpath;
    // 返回网站首页
    Response resp;
    SetContent(resp,path); // 将文件中的数据拷贝到响应的正文中,顺带将状态行也填充进去
    // 由于我们不管怎么样都填充了正文,所以在报头字段我们可以加上 Content-Length字段。
    string len = "Content-Length: ";
    int size = resp._body.size();
    len += to_string(size);
    len+=SEP;
    resp._ResHeader.push_back(len);
    //序列化
    string out = resp.Serialize();
    // cout<<"out:"<<out<<endl;
    write(sock,out.c_str(),out.size());
}

那么目前我们就能构建一个响应报文,响应报文中包含了我们的html 文件的内容作为相应的正文。

那么测试下来会不会将 html 的网页在浏览器显示出来呢?

我们发现一个很尴尬的是,就是浏览器确实是收到了我们的响应,也显示了内容,但是他并没有识别出来我们的响应是 html 类型的文件,最终导致显示的是乱码。

因为我们的 http 是超文本传输协议,可能传输任何类型的文件,但是客户端或者说浏览器却并不知道我们服务器发回去的是何种文件类型,那么其实我们需要在报头中有相应的属性来表明我们的报文的有效载荷或者说正文的内容的类型,那么就需要用到Content-Type字段

Content-Type

Content-Type 字段怎么填呢?具体有哪些类型呢?我们可以在网上找一下http的文件类型的对应表

HTTP Content-Type对照表 - MKLab在线工具

我们可以在这张表里面找到我们的正文的文件类型,将对应的字段填入到报头中。

比如我们上面传输的是 .html 文件,那么在Content-Type对应的就是

那么我们可以在这里加上该字段。

由于未来我们可能会发送任何类型的文件资源,所有我们这个字段最好就不要硬编码了,我们可以写一个配置文件,将常见的文件类型和对应的Content-Type字段保存起来,然后在加载服务器的时候将这个文件类型与Content-Type的映射关系用unordered_map保存起来,在添加报头的Content-Type字段时就可以使用map的映射.

//加载 Content-Type的映射关系
#define Contenttxt "wwwroot/ContentType.conf"
unordered_map<string,string> Con_map;

void initContentType()
{
    ifstream in(Contenttxt);
    if(!in.is_open()) exit(OPEN_ERR);
    string inbuffer;
    while(in>>inbuffer)
    {
        int sep = inbuffer.find(":"); //每行都一定能找到
        string str1 = inbuffer.substr(0,sep);
        string str2 = inbuffer.substr(sep+1);
        Con_map[str1]= str2;
    }
}

这时候如果我们的网页打印出来的还是乱码,那就是因为我们的html文件没有设置编码的格式,我们可以在html 的head 中设置html格式为 utf-8 ,比如我们将首页的html文件加上设置编码格式之后,完整的文件如下:

<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>
    <h1>网站首页</h1>
    <p>这是一个网站首页</p>
</body>

</html>

那么我们在来测试一下我们的首页和NotFound能否正确显示出来。

同时,我们也可以在首页中再放一些东西,比如一张图片,我们可以在本地中保存一个图片文件,然后将其在html中编码显示。

于此同时,我们也要将文件类型与 Content-Type的映射关系也加上

然后我们再来试一下访问网站的首页:

虽然目前我们的图片显示出了一些问题,但是我们可以通过 xshell 中我们打印出来的请求和响应看到:

我们发现,出了我们自己访问网页的http请求之外,我们的服务器还额外收到了一个 http 请求,这个多出来的请求是用来请求我们的主页所需要的图片资源的。虽然他最终图片资源还是没有成功接收,是因为我们的Content-Type字段没有正确填充,不过我们先不关心,一会再来修正,总之网页中目前显示的 alt 的内容也就是图片请求失败时显示的内容,但是我们至少看到了我们的服务器对我们第一次响应的网站的首页做了解析,浏览器看到了我们的 index.html 中需要用到服务器中的一个图片资源,于是浏览器就自动发送了一个获取该图片资源的请求。

一个用户所看到的网页的结果,可能是有多个资源组合而成的。所以要获取一个完整的网页,我们的浏览器一定会发起多次 http 请求

当然也有可能是因为我们的服务器的配置太低了,而图片资源太大了,最终导致图片拉取不成功,也可能是我们的代码中有一些bug,目前我们就不在意这个了。

总之目前我们已经能够知道从服务器拉取资源到本地的流程,最重要的就是要记住我们可能知识申请了一份资源,但是这一份资源中可能还有一些依赖的资源或者我们的资源本身就是多个资源构成的,也就是说,我们可能手动访问了一个网页,但是浏览器在底层其实可能并不止发了一个http响应,可能发了多个。

那么我们如何上传资源呢?

在http中,我们常用的上传资源的方法有两种:

一般是通过通过form表单,提交表单来构建请求。表单的两种上传方法GET/POST

我们可以在我们的 html 中加上 html 表单。 而表单中我们可以放一些 input ,text 等可以获取输入的组件,也就是类似于输入框,像我们的用户名和密码这类的输入,一般就是通过html表单进行提交的,浏览器会将表单中的内容转换为一个 GET或者POST 方法的请求。

为了方便测试,我们将上面的图片资源去掉,然后加上表单的内容。

<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>
    <h1>网站首页</h1>
    <p>这是一个网站首页</p>
    <!-- <img src="1.jpg" alt="图片显示失败"> -->
    <form method="GET" action="43.143.238.233:8080"> <!--设置提交方法为 GET-->
        First name:<br>
        <input type="text" name="firstname" value="Mickey"> //预设的一些内容
        <br>
        Last name:<br>
        <input type="text" name="lastname" value="Mouse">
        <br><br>
        <input type="submit" value="Submit">
    </form> 

</body>

</html>

然后我们就可以测试一下我们的数据是如何被提交的

首先还是获取到首页:

然后我们测试一下提交表单,也就是我们输入一些信息之后,点击 Submit提交按钮,它会自动将表单中的内容按照我们设置的方法 GET/POST 将数据封装一个请求发送给我们的服务器:

而如果我们设置为POST方法,在来测试一下:

获取首页:

Submit:

我们目前讲的是两种常见的上传的方式,当然还有其他的方法比如上传文件等,因为平常用的少,我们也就不讲解了。

那么现在有一个问题就是,我们提交的参数可能是有各种用途的,比如我们提交的是账户和密码,或者说是搜索的关键字等,服务器怎么知道我们提交的参数是用来干嘛的呢?

其实也不难,因为不管是GET还是POST抑或是其他方法,我们发送一个请求中,都有请求行,请求行中都有我们的 url 或者说我们的路径,而我们提交参数一般都是通过表单提交的,表单是可以设置构建的 http 请求的路径的。 而我们的服务器就可以通过请求中的 路径 来区分我们提交的参数的用途。在我们的服务器中,访问不同的路径时所带的参数的意义一般是不同的,当然与参数相关的资源一般也会放在这个目录下,在代码中可能有系列的 if 语句,判断要访问的路径,来得知请求中的参数是用来做什么的。如果是一些注册或者登录相关的操作,可能需要访问数据库,那么底层可能还要创建线程或者进程来完成操作,服务器继续处理请求。所以说,在服务器中,路径一般就代表了我们使用的服务,那么与路径一同传过来的参数也就是对应服务所需要的数据。底层的实现可能是以路径作为 key值,以对应的回调方法作为 value 来进行调用。

我们传参的时候使用GET好还是POST好呢?比如我们提交密码(当然实际中不会使用http来提交),还是POST好,因为如果使用GET的话,那么参数就是拼接在url中,那么在我们的浏览器中url栏就能直接看到我们提交的参数,这对于一些私密性的数据来说不是很友好,而POST提交则是放在正文中,不会这么明显的暴露我们的参数。

http 状态码

http响应行中第二列就是状态码信息,而第三列就是状态码描述。

在http中有五类状态码,状态码都是 3 位数。

1 开头的,100~199 ,表示的是信息性状态码 ,当我们收到的响应中是 1 开头的状态码,其实是服务器通知客户端,请求已经被受理,但是正在被处理,还没有处理完。因为客户端有些请求时需要一定的时间的,比如上传或者下载一些大的资源。

2 开头的,200~299 ,是成功状态码,意思就是请求已经成功完成。比如200,他的描述就是OK

3 开头的,300~399,叫做重定向状态码 ,常见的比如: 301: Moved Permanently(永久重定向) , 302: Found , 303: See Other ,307: Temporary Redirect (临时重定向)

那么在http这里,重定向是什么意思呢?

不知道大家有没有遇到过这种情况:我们在访问某些网页的时候,网页会自动跳转或者说提示我们去跳转到一些指定的网页,这就叫做重定向。重定向的本质就是我们在向服务端发送请求的时候,服务端的响应中的状态码是重定向状态码,同时会附带一个新的 url。当客户端浏览器收到这个 http 响应时,发现响应行的状态码时3开头的,就知道他要进行重定向,此时浏览器就会使用这个新的 url 去打开该网页,这种网页可能新的服务器的地址,这种行为就叫做重定向。

重定向最终是由客户端完成的,而服务器是告知客户端一个新的 url ,让客户端去访问新的 url 。

重定向分为临时重定向和永久重定向。

永久重定向就比如:我们原来使用一个服务器为客户提供服务,用户是通过域名或者ip进行访问的,由于客户越来越多,而服务器的配置可能承载不了这么大的流量或者说这个域名我们觉得不好,这时候我们就会考虑搭建一个新的网站,域名和服务器等,那么这样一来ip和域名都是新的了,而我们又不想放弃老客户,因为老客户可能每次访问我们的网站都是通过在浏览器保存的书签或者搜索记录来访问的,他们本质就是保存了我们旧有服务器的域名或者ip,即使我们有了新的网页,老客户未必能够如我们所愿去使用这个新的域名,那么这时候为了防止客户流失,我们就需要在来的服务器中设置一个永久重定向,那么未来访问这个来的服务器的时候,我们的老服务器就会发送一个重定向响应给客户端,让客户端自动跳转到我们的新的服务器和网站。这就叫做永久重定向。

永久重定向从技术角度会更新用户保存在本地的书签,至于用户用不用书签进行访问,那是用户的事情。就算用户继续访问旧的服务器,那么无非就是客户端多一次自动跳转的工作,在用户看来可能没区别。当然有的浏览器会弹出是否进行跳转的提示。

而临时重定向,顾名思义,就是临时性的跳转,比如我们有时候打开一些网页,什么都没做,我们的客户端就跳转到了某些广告页面,这就是临时重定向。

4 开头:400~499 ,表示客户端错误状态码,这种状态码一般是由于客户端的请求服务器无法完成,比如访问被拒绝(权限不够或其他原因,状态码位403),资源不存在(404)等。这种是客户端出现的错误,超出了服务器的功能范围。

5 开头:500~599,表示的是 服务端错误 ,什么叫做服务端错误呢?在我们的服务端中为客户提供服务可能需要创建线程或者进程,如果创建失败,那么服务自然就终止了,又比如申请内存失败等,这种就是服务端错误,一般是由于服务器的操作失败而导致服务未完成

长连接

前面我们就说过,用户获得一张完整网页的结果,一般不止一次http请求,可能用户请求了一个网页,但是浏览器底层却发起了多次http请求来获取网页所需要的资源

但是 http 是基于tcp协议的,而tcp是面向连接的,那么像上面这样频繁发起http请求申请资源,就会导致底层频繁创建和断开 tcp 连接 的情况。而对应的tcp服务器也需要多次创建线程或者进程来提供服务,这样就很影响效率。每请求一个资源,都要重新建立链接,请求完就断开连接,这其实就是HTTP 1.0的做法 ,因为这样设计起来很简单 ,同时由于当时网络资源还不是很大,并不会说像现在一样,一个网页中可能存在成百上千张图片,所以在当时这个设计还是可行的。

但是由于网络的飞速发展,以及终端设备的配置的提升,网络资源也在不断变大变多,那么这种频繁创建和断开连接的方式就不合适了。 于是在http 1.1 就有了长连接的技术,如果客户端和服务器都支持长连接就可以在只建立一条链接的情况下,使用这条链接来获取或者传输一大份资源(多个资源)。

客户端和服务器是否支持长连接,在http报文的属性字段中的Connection字段可以体现出来,如果支持长连接,该字段的value就是 keep-alive ,如果不支持长连接,该字段的之间就是close。

那么多份资源在一条连接上怎么分离出来呢? 因为tcp是字节流的,我们就必须要考虑如何拿到一个完整报文的问题。

其实在很简单,因为http是有自己的报头和报文格式的,他是能够将位于 tcp 接收缓冲区中的http报文每一个单独分离出来的,所以这一条链路是可以被串行使用的,那么也就减少了建立连接的成本。

会话保持

会话保持并不是http协议设计时天然具备的,http 的核心工作就是资源的传输,所以起初时并没有涉及这个功能,但是在后续的使用中,发现这个功能还是不可或缺的,于是又加了会话保持的概念。

那么这里的会话和我们之前将的系统中的会话有关联吗?没有任何关联。

在我们日常的冲浪中,比如我们登录bilibili的页面,我们登陆之后,在同一个浏览器中再打开一个b站的界面,我们会发现,我们还是处于登陆状态,而不是像第一次打开b站一样,是游客状态。甚至说,我们把浏览器关闭之后,再重新打开浏览器访问b站的界面,竟然还是登录状态。 更有甚者,我们将电脑重启,再使用该浏览器访问bilibili,我们还是处于登陆状态。或者我们在退出浏览器之前,先把b站账号退出,然后再重启浏览器访问b站,还是处于登录状态。

我们先讲一个知识点:http 协议是无状态的

也就是说,我们发送多次相同的请求,http服务器是不知道前面已经有过和他一样的请求的,他不会记录历史的请求或者资源,也不会去猜测客户端下一个请求会是什么,他只负责在客户端发送请求之后,按照请求提供对应的资源。

但是这好像和我们上面的例子有点不相符。这和http协议有关吗?其实没有直接的关系,但是也算是间接有关。

http虽然是无状态的,但是用户却需要这种功能。

在我们日常访问的很多网站,其实有很多资源或者说我们所看到的网页或者视频等,其实是需要会员的,也就是说,对于这些资源的访问,我们的http请求中必须要携带我们的身份/会员信息,然后由服务器进行认证,认证成功之后才有权限访问这些资源,这一点我们很好理解,而用户在一个网站内进行页面的跳转时很常见的,就比如使用腾讯视频,我们可能会来回切换这些需要会员的视频,但是腾讯视频却并没有要求我们每一次申请一个新的资源都要重新进行登录。按照逻辑来讲,http是无状态的,那么在外面跳转到一个新的页面时,http是不会保存我们之前的用户信息的,那么新的页面也就无法识别用户了。

而为了支持让用户已经登录,就可以在整个网站按照自己的身份进行随意访问,这就需要会话保持的技术,也就是我们要保证一个用户始终在线。

那么会话保持是怎么做到的呢?两种方法

1 老的方法:本地cookie

我们首先要搞懂,登录是在做什么?登录无非就是将我么的信息通过http请求,访问到特定的目录,然后进行用户的验证,或者说是一种鉴权行为,验证成功之后就返回对应的页面。而登陆成功之后,当我们进行页面跳转,由于http是无状态的,那么跳转到新的页面时,我们的 http 请求必须也要携带同样的用户信息,然后继续由服务器进行认证,再将页面资源返回。那么这时候就又需要用户输入登录信息,这就很扯淡了。

于是就有了第一版的方案: 当我们一旦登录成功之后,我们的服务器推送回的响应中,属性中会包含一个字段,客户端浏览器在读到这个字段之后,会为我们维持一个东西,把用户输入的信息保存起来,那么往后只要访问这一个网站内的其他的页面,浏览器就会自动携带上保存的用户信息。

其中浏览器把我们的账号信息保存起来的方式就叫做 cookie 技术。

cookie 技术又分为文件级别和内存级别

这其实很好理解,浏览器本身就是一个进程,当我们在不退出浏览器,打开来同一个网站的时候,我们依旧是登陆状态,这是因为浏览器保存了我们的 cookie 信息。而当我们将浏览器关闭之后,再去打开该网站,这时候如果需要我们重新进行登录,那么就说明当前浏览器的 cookie 是内存级别的,进程退出就自动释放了。

而如果我们浏览器退出之后,再重新打开该网站,不需要我们再次登录,或者说我们的电脑重启之后,再打开浏览器访问该网站,不需要我们再次登录,就说明当前浏览器的 cookie 是内存及别的,在进程退出的时候自动将内存中的 cookie 保存到了对应的 cookie 文件中。

当然,cookie 的级别是可以在浏览器上进行配置的。、

怎么看到呢?

我们在浏览器的url栏中

通过上面的操作,我们就能看到我们浏览器中保存的一些 cookie 文件,点开具体的cookie,我们就能看到对应 cookie 的属性信息等。

同时我们在浏览器的设置中的安全设置中,点击清楚浏览数据,可以看到这样的提示

浏览器告诉我们清除 Cookie 信息会导致我们从大多数网站退出。

这是给普通用户看到,站在程序员的角度,不就是因为将Cookie清除之后,我们在访问某些需要登录的网站时就无法在浏览器中找到对应的 cookie 信息了,那么就需要我们用户重新手动登录了。

这就是会话保持的最初的做法。

但是这种做法有一个很大的问题:

我们的用户信息是保存在客户端本地的,也就是由我们用户自己管理。 但是作为普通用户,安全意识或者防护措施可能做的不到位,导致有恶意分子向我们的主机中发送了木马病毒(蠕虫病毒以直接攻击主机为目标,木马病毒是以盗取信息为目标),如果黑客使用木马盗取了我们的 cookie 信息,然后将这些 cookie 文件通过一定的方式保存到了他的主机的浏览器的 cookie 区域中,那么这样一来,黑客的主机上也有一份你的cookie信息了,这时候黑客能使用这份 cookie 信息访问cookie 对应的网站吗? 当然可以。 也就是说,黑客以你的身份来进行访问,或者说服务器将黑客误认为了你。

这时候有两个影响:1 对社会的影响,首先不法分子会使用你的身份去传播一些违法的信息,尤其是盗取的是你的社交账号的信息的时候,这时候对社会的影响还是很大的。 2 对你个人的影响,你的账号信息泄露了,同时你不一定能及时得知,这是一个很搞心态的事情。

那么有什么解决方案呢?

要解决问题我们首先要认清出现问题的本质是什么?

出现上述问题的根本原因就是 我们的信息保存在了本地客户端 。

我们把信息存储在文件中这是没问题的,磁盘可以永久存储或者说长时间存储。但是问题出在了我们把这个如此重要的文件的维护工作交给了客户端,也就是由用户来维护,像这种重要信息,只要是交由用户来管理,就势必会出现被不法分子盗取信息的情况。

那么为了应对这个问题,于是就提出了第二种解决方案:

session id + cookie

既然用户的信息不宜保存在客户端,那么我们可以将用户信息保存在服务器端,在服务器中为每一个用户形成一个文件来保存用户信息,这个文件我们称之为 session 文件。

与此同时,我们的服务器可能有成百上千个用户,而每一个用户的信息都要形成一个 cookie 文件,那么怎么标识每个文件的在这个服务器上的服务器呢? 使用唯一的文件名就可以了 。我们要给每一个session 文件起一个唯一的名称(至少需要在服务器上唯一),一般是用一个比较大的具有唯一性的字符串来充当文件名,我们把这个具有唯一性的文件名称为 session id

那么用户在发出登录请求之后**,我们的服务器就会自动在服务器本地创建一个session文件,并生成一个唯一的 session id,然后服务器就不会让客户端直接保存这一套用户信息了,而是将这个session id 返回给客户端,让客户端保存在文件(或者内存中)中。那么这个保存session id 的文件我们也称之为 cookie 文件**。

而从此以后,我们的客户端在访问该网站的时候,请问中就会自动携带cookie文件中的 session id,那么服务器识别到之后,会在自己本地保存的session 文件中提取信息进行认证

因为用户信息是保存在服务器的,而服务器是有对应的组织或者公司维护的,他们的安全防护等级肯定是要比普通用户要高的,就算出现泄密,也会由对应的公司来进行维权。当然由于用户信息被服务器掌握,那么对应的服务器的公司也就拿到了我们的信息,当然就算我们不采取这样的方式对方也是必须有这些信息的,但是这里的性质就有点不一样了。

那么这里就又有一个问题了:我们的session id还是保存在客户端本地的,那么就依然存在泄露的风险,而黑客只要拿到我们的session id 之后,在他的本地形成 cookie 文件,那么黑客还是可以冒充我们的身份,来进行对应网站的访问。

那么这个问题有解决方案吗?从逻辑上来说是解决不了的,因为任何软件都有漏洞,服务器也无法完全避免误认的情况。但是还是那句话,有黑就有白,维护服务器的公司中肯定也会有一些白帽子工程师进行安全的相关策略,就比如我们的 qq 的登陆检测,跨地区登录是需要我们重新认证。

当服务器认为我们的 session id 出现安全问题了,那么就会立马让原来的 session文件失效。那么这时候不管是真正的用户还是黑客都会被下线,再次登录就需要用户信息才能进行登录了,重新登陆之后自然就会形成新的 session id和session 文件。

虽然这种误认的情况无法解决,但是在另一方面,我们的信息泄露的问题却大大优化了。因为信息是保存在服务器端的,黑客盗取用户本地信息最多也就只能盗取到session id,而无法直接拿到用户的信息。

测试cookie:

服务端如何在客户端写入 cookie 信息呢?

我们就直接测试在本地保存用户信息的情况了,我们可以在第一次登录的时候,在服务器回复给客户端的响应报文中,添加 Set-Cookie 字段,带上cookie信息(可能是用户信息也可能是session,我们可以就硬编码一些信息来实验了),那么客户端浏览器收到这个响应之后,检测到报头中由Set-Cookie字段,那么就会对应的位置形成 cookie 文件。

我们首先测试以下浏览器是否能保存cookie:

    //直接硬编码加上 Set-Cookie字段
    string cookie = "Set-Cookie: name=10101010&pwd=aaaa\r\n";
    resp._ResHeader.push_back(cookie);

然后我们重新访问一下该服务器,看是否会携带保存的cookie信息:

我们也就能看到,如果在本地中保存了我们要访问的网站的 cookie 文件,那么我们在访问该网站时的请求的报头中就会自动带上一个Cookie字段,来携带文件中保存的cookie信息。

当然其实上面Set-Cookie时是有一些瑕疵的,我们设置cookie时,后面的cookie信息必须是kv的,然后多个数据的时候中间是使用 ;(空格) 作为分隔,而不是使用&。反正怎么设置的,最终就是怎么提交的。

对客户端设置 cookie 之后,每次http请求都会自动携带曾经设置的所有 cookie ,帮助服务器或者说用户进行身份认证。这样的方案就支持了 http 的会话保持。

同时,我们在一开始的图片中,也能看到 cookie 也是有自己的属性的,比如有效时间,网站等。当cookie过期或者被服务器设置失效了,会被客户端自动清理。

调试 http 的一些基本工具

telnet

telnet就是在我们机器中充当一个 http 客户端,我们可以自己编辑一个请求来发送给服务器,而后telnet会将服务器的响应直接打印出来。我们可以快捷测试服务器的响应是否合理。

它的使用也很简单,首先使用

telnet ip 端口

这是在建立 tcp 链接或者说和我们的服务器建立连接,因为http是基于tcp的,面向连接。

当出现这个界面时,就说明连接成功了,目前处于 telnet 的命令行界面,我们可以输入 ctrl ] 来手动构建请求。

但是这时候先不要着急,必须先敲一下回车,在下一行进行请求的编辑。

比如我们输入一个最简单的请求

然后回车,等待响应。

最后使用完之后还是使用 ctrl ] 回到telnet命令行,然后使用 quit 断开连接关闭telnet。

最后我们在自己的 http 服务器运行期间,如果我们使用客户端连接服务器了,后续我们可能会受到一个这样的请求:

这是浏览器自动发送的,用于请求我们的网站的小图标,也就是如下的这些

一般这个文件会跟 index.html 一样放在网站的根目录下。

postman

postman不是抓包工具,也是用来模拟客户端,我们可以在postman中使用 GET/POST 等方法来向服务器发送请求,postman会自动帮我们构建请求。同时会将服务器的响应的报文抓到,对他的响应报文可能会做一些格式化的处理,方便我们的阅读。

fidller

fidller 用于抓包,针对http。

不过我们使用使用云服务器的话不好实验。因为抓包工具一般都只能抓到主机所在局域网的报文,可以抓到局域网中其他主机的报文,是因为网卡的混杂模式,后续mac帧协议中会讲到。

http协议的内容如上,接下来会火速更新出 https 协议的相关知识、

相关推荐
无所谓จุ๊บ37 分钟前
树莓派开发相关知识十 -小试服务器
服务器·网络·树莓派
TeYiToKu42 分钟前
笔记整理—linux驱动开发部分(9)framebuffer驱动框架
linux·c语言·arm开发·驱动开发·笔记·嵌入式硬件·arm
dsywws1 小时前
Linux学习笔记之时间日期和查找和解压缩指令
linux·笔记·学习
道法自然04021 小时前
Ethernet 系列(8)-- 基础学习::ARP
网络·学习·智能路由器
yeyuningzi1 小时前
Debian 12环境里部署nginx步骤记录
linux·运维·服务器
上辈子杀猪这辈子学IT1 小时前
【Zookeeper集群搭建】安装zookeeper、zookeeper集群配置、zookeeper启动与关闭、zookeeper的shell命令操作
linux·hadoop·zookeeper·centos·debian
minihuabei1 小时前
linux centos 安装redis
linux·redis·centos
EasyCVR2 小时前
萤石设备视频接入平台EasyCVR多品牌摄像机视频平台海康ehome平台(ISUP)接入EasyCVR不在线如何排查?
运维·服务器·网络·人工智能·ffmpeg·音视频
城南vision2 小时前
计算机网络——HTTP篇
网络协议·计算机网络·http
lldhsds2 小时前
书生大模型实战营第四期-入门岛-1. Linux前置基础
linux