HTTP协议(Linux视角)
目录
urlencode/urldecode
HTTP请求/响应
请求
响应
HTTP服务端实现
服务端和网页分离
HTTP报头常见字段
HTTP请求方法
GET/POST的区别
HTTP状态码
重定向状态码
长连接
会话保持
旧方案:Cookie技术
新方案:Session技术
Http工具
Postman
Fidler
对于应用层的协议,目前已经有很多大佬定制的成熟、好用的协议,例如HTTP(超文本传输协议)就是其中一个
每个网站都有网址(URL),下图简单认识URL的各部分
主机之间的通信需要通过唯一的IP,因此DNS服务器中存储着域名到IP的映射关系,以此找到对应的IP
这里的文件路径并不是从Linux的根目录开始,而是从指定的web根目录(可以是Linux中的任意一个目录)当做根目录
实际上URL中还需要传入端口号,只不过http统一规定为80,https统一规定为443,因此不指定也没关系
早期URL:
urlencode/urldecode
在URL中,如'/',':','@','?','#'等等特殊字符,都有特殊含义,如果URL的参数中出现了这种字符,就需要对其进行转义
例如,在b站中搜索 “CSDN” 的URL,其keyword参数就为CSDN
但如果搜索带有特殊字符或中文:
英文字母会原样输出,但特殊字符或中文就会被转移成%xx的格式
对于一个特殊字符,用%XX表示
对于一个汉字(以UTF-8编码为例),常用汉字用3个%XX(%XX%XX%XX)表示,生僻字用4个%XX(%XX%XX%XX%XX)表示
拿上面C++举例,由于'+'的ASCII为43,十六进制为2B,因此它的urlencode就是%2B
若是汉字,例如'中',它的UTF-8字节序列为E4 B8 AD,将每个字节前带上%,即得%E4%B8%AD
将特殊字符/汉字变为这种带%的十六进制序列的过程,就叫urlencode
而将这种16进制序列再转回原本字符的过程,就是urldecode
HTTP请求/响应
请求
既然是协议,就一定会有请求与响应
HTTP的请求分为四大部分:请求行、请求报头、空行、请求正文
- 请求行:以空格为分隔的三列,分别表示请求方法(例如GET为获取数据,POST为提交数据)、URL(不包含http://和域名的非完整URL,只有路径和查询参数)、HTTP版本(例如http/1.1代表1.1版本,现已退役,主流网站/APP都为h2或h3,也就是http2或http3版本)
- 请求报头:每行为以":"分隔的KV键值对(分隔符为':'+' '),即为要告诉服务器的元数据,当读到空行时,代表请求报头部分结束
- 请求正文:承载要告诉服务端的核心数据,例如在登录时提交的用户名与密码。
但并不是所有的HTTP请求都有正文,例如当请求方法为GET,即获取资源时,例如百度的搜索,参数通常拼接在 URL 后面,而不是放在正文里。
响应
当客户端的请求通过TCP连接传输过去后,当处理完后,服务端也会再发送回响应,大体格式和请求类似:
- 响应行:以空格为分隔的三列,分别表示HTTP版本、状态码(类似于程序的退出码,告诉对方成功与否。例如200代表成功,404代表找不到指定资源)、状态码描述(类似于strerror(),每个状态码都有对应的描述,例如200为ok,404为Not Found)
- 响应报头:响应报头中的KV键值对属性决定了要如何接收/解压/解析/展示响应正文。
- 响应正文:服务端返回给客户端的数据,若报头的Content-Type为text/html,就代表响应正文要当作HTML来解析,其他同理
HTTP怎么保证应用层读到的是一个完整的请求/响应呢?
请求/响应行和请求/响应报头可以通过while(行不为空)读取完,当跳出while时,就代表现在的光标在空行开头。
在请求/响应报头中,Content-Length字段的值为正文长度,通过读取该字段就可以正好读完请求/响应正文
HTTP是怎么序列化/反序列化的?
HTTP自己的序列化只是将这四部分作为字符串拼接起来再发送....嗯没错就这么简单(HTTP/1.1标准)
在Linux云服务器中写好服务端后,就可以用浏览器充当客户端进行通讯了:
只要在浏览器的URL处输入公网IP:端口号即可向服务端发送请求
请求行的 / 代表请求web根目录,http请求若没有请求指定的资源,web server会有默认的首页(例如index.html)
请求/响应都会发送http版本,这就交换了通信双方的版本。使用客户端的用户,因为更新 or 不更新的问题,客户端会有很多http版本,只要交换了通信双方版本,服务端就可以知道哪些功能是对方客户端协议有的
又或者说:HTTP 版本号的交换,就是通信双方在对齐“协议规范”。服务端通过版本号,就能精准掌握对方在“协议层面”支持哪些功能集合(如多路复用、分块传输等),从而决定采用哪种底层通信规则。而在该协议框架内具体“用不用”某个功能,则交由 Header 来灵活协商
User-Agent为客户端的信息,包括系统,安卓/WIn/Mac都可以显示出来,服务端就可以根据不同的系统返回不同的结果
HTTP服务端实现
当客户端向服务端发送请求后,服务端可以返回响应,这里以HTML为例
例如,可以用一个现成的HTML,利用wget命令下载资源,这里以哔哩哔哩首页为例
此时只需在服务端中构建响应并发送给客户端即可
//HttpServer.cpp: #include <iostream> #include <fstream> #include "HttpServer.hpp" #include "log.hpp" #include "protocol.hpp" using namespace std; using namespace Server; void usage(string proc) // 使用手册 { cout << GREEN << "\nUsage: \n\t" << ED << RED << proc << " [port]\n\n" << ED; } void Get(const Request &request, Response &response) { cout << "----------http start------------------\n"; cout << request.inbuffer << endl; // 这里采用硬编码,仅供测试用 std::string resp_line = "HTTP/1.1 200 OK\r\n"; // 响应行 // 响应报头 std::string resp_header = "Content-Type: text/html\r\n"; // 告诉客户端,正文为html std::string resp_blank = "\r\n"; // 空行 ifstream ifs("index.html"); if (!ifs.is_open()) { LogMessage(ERROR, (char *)"打开文件失败"); return; } // std::string resp_body; std::string resp_body{std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>()}; // 响应正文 response.outbuffer += resp_line += resp_blank += resp_body; cout << "---------- http end ------------------\n"; } int main(int argc, char *argv[]) { if (argc != 2) { usage(argv[0]); exit(USAGE_ERR); } uint16_t port = atoi(argv[1]); // 字符串port转整数 HttpServer server(Get, port); server.init(); server.start(); return 0; } //HttpServer.hpp: #include <iostream> #include <functional> #include <string> #include <cstring> #include <cerrno> #include <csignal> #include <unistd.h> #include <sys/wait.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> #include "log.hpp" #include "protocol.hpp" namespace Server { // typedef function<void (std::string)> func_t;//回调函数类型 // typedef std::function<void(const Request &, Response &)> func_t; using func_t = std::function<void(const Request &, Response &)>; // 将请求处理为响应 const int gbacklog = 5; // 全局的全连接队列长度 void handlerEnter(int sockfd, func_t func) { // 读取到完整的Http请求 Request request; Response response; char buffer[1024]; size_t n = recv(sockfd, buffer, sizeof(buffer), 0); // 假设一次读取完整请求 if (n > 0) { buffer[n] = 0; request.inbuffer = buffer; // func(Request, Response); 传入请求,传出响应 func(request, response); // 循环发送,确保所有数据都发出去 // send一次性发送的话,内核缓冲区可能遭不住,因此循环发送 const std::string &data = response.outbuffer; size_t total_sent = 0; while (total_sent < data.size()) { ssize_t sent = send(sockfd, data.c_str() + total_sent, data.size() - total_sent, 0); if (sent <= 0) { LogMessage(ERROR, (char *)"发送数据失败"); break; } total_sent += sent; } // send(sockfd, response.outbuffer.c_str(), response.outbuffer.size(), 0); } else { LogMessage(ERROR, (char *)"客户端退出..."); exit(0); } // exit(0); } class HttpServer { public: HttpServer(func_t func, uint16_t port) : _func(func), _port(port) {} HttpServer(func_t func, std::string ip, uint16_t port) : _func(func), _ip(ip), _port(port) { } void init() { // 创建监听套接字 _ListenSock = socket(AF_INET, SOCK_STREAM, 0); if (_ListenSock == -1) { LogMessage(FATAL, (char *)"socket创建监听套接字失败, 错误码: %d, 错误描述:%s", errno, strerror(errno)); exit(SOCKET_ERR); } LogMessage(DEBUG, (char *)"socket创建监听套接字成功"); // bind绑定ip+port struct sockaddr_in ServerAddr; memset(&ServerAddr, 0, sizeof(ServerAddr)); ServerAddr.sin_family = AF_INET; ServerAddr.sin_port = htons(_port); if (inet_pton(AF_INET, _ip.c_str(), &ServerAddr.sin_addr) != 1) { LogMessage(FATAL, (char *)"点分十进制ip转网络序列失败, 错误码: %d, 错误描述:%s", errno, strerror(errno)); exit(INETPN_ERR); } LogMessage(DEBUG, (char *)"点分十进制ip转网络序列成功"); if (bind(_ListenSock, (struct sockaddr *)&ServerAddr, sizeof(ServerAddr)) != 0) { LogMessage(FATAL, (char *)"bind绑定失败, 错误码: %d, 错误描述:%s", errno, strerror(errno)); exit(BIND_ERR); } LogMessage(DEBUG, (char *)"bind绑定成功"); // 开启监听状态 if (listen(_ListenSock, gbacklog) != 0) { LogMessage(FATAL, (char *)"listen监听状态开启失败, 错误码: %d, 错误描述:%s", errno, strerror(errno)); exit(LISTEN_ERR); } LogMessage(DEBUG, (char *)"listen监听状态开启成功"); } void start() { signal(SIGCHLD, SIG_IGN); // OS自动回收子进程资源 while (true) { // 建立连接 struct sockaddr_in ClientAddr; memset(&ClientAddr, 0, sizeof(ClientAddr)); socklen_t socklen = sizeof(ClientAddr); int sockfd = accept(_ListenSock, (struct sockaddr *)&ClientAddr, &socklen); if (sockfd == -1) { LogMessage(FATAL, (char *)"accept建立新连接失败, 错误码: %d, 错误描述:%s", errno, strerror(errno)); exit(ACCEPT_ERR); } LogMessage(DEBUG, (char *)"accept建立新连接成功,sockfd = %d", sockfd); pid_t pid = fork(); if (pid == 0) // 子进程 { close(_ListenSock); // 关掉无用文件描述符 if (fork() > 0) // 子进程本身退出 exit(0); // 孙子进程,被OS领养,不等待也不会变成僵尸进程 handlerEnter(sockfd, _func); } close(sockfd); // 父进程关掉该文件描述符,防止文件描述符被用完 } } private: int _ListenSock; // listen监听套接字 std::string _ip = "0.0.0.0"; // 默认接收所有ip uint16_t _port; // 服务器端口号 func_t _func; // // func_t _callback; }; }在浏览器URL处ip:port的方式访问,就可以看到服务端返回的html了(由于图片资源在本服务器没有,所以加载不出来)
服务端和网页分离
若不想将html放在服务端的内存中,也就是让html和服务端分离,通过请求行的URL字段决定服务端返回给客户端的响应
需要让服务端通过请求的资源路径,将指定资源读取
//HttpServer.cpp: std::string resp_body; if (!Util::readfile(request._path, resp_body)) Util::readfile(html_404, resp_body); //Util.hpp: static bool readfile(const std::string file, std::string &outbuffer) // 将文件内容读取到outbuffer中 { std::ifstream ifs(file, std::ios::binary); if (!ifs.is_open()) // 不存在该文件(资源) return false; // 一次性全部读取 std::ostringstream oss; oss << ifs.rdbuf(); outbuffer = oss.str(); ifs.close(); return true; }一个用户看到的网页结果,可能是由多个资源整合而成,因此要获取一张完整的网页效果,浏览器需要发起多次http请求。
所以正文不一定是html,也有可能是图片/视频等等,因此需要根据不同的后缀名填写报头字段Content-Type,再根据资源的大小填充Content-Length
std::string ContDesc(std::string suffix) // 根据后缀返回Content-Type的值 { std::string ct = "Content-Type: "; if (suffix == ".html") ct += "text/html"; else if (suffix == ".jpg" || suffix == ".jpeg") ct += "image/jpeg"; else if (suffix == ".gif") ct += "image/gif"; else if (suffix == ".ico") ct += "image/x-icon"; // [TODO] 每个类型都写一个if ct += "\r\n"; return ct; } // 响应报头 // std::string resp_header = "Content-Type: text/html\r\n"; // 告诉客户端,正文为html 硬编码 std::string resp_header = ContDesc(request._suffix); if(request._size > 0) //若正文大小大于0,则填充Content-Length { resp_header += "Content-Length: " + std::to_string(request._size) + "\r\n"; } //protocol.hpp: class Request { public: void parse() // 从请求中解析请求行 { std::string req_line = Util::getOneLine(_inbuffer, sep_line); if (req_line.empty()) return; std::istringstream ist(req_line); ist >> _method >> _url >> _version; // 提取出请求行的三个字段 _path = wwwroot + _url; if (_path[_path.size() - 1] == '/') // 说明没有指定访问的资源,返回默认首页 _path += default_page; // 将请求的资源的后缀提取出来 // ./wwwroot/index.html 提取 .html // ./wwwroot/image/1.jpg 提取 .jpg auto pos = _path.rfind("."); if (pos == std::string::npos) _suffix = ".html"; // 缺省值为.html else _suffix = _path.substr(pos); // 将请求的资源的大小放到_size中 struct stat st; int n = stat(_path.c_str(), &st); if (n == 0) _size = st.st_size; else _size = -1; } public: std::string _inbuffer; // 整个请求 std::string _method; // 请求方法 std::string _url; // 文件路径 std::string _version; // http版本 std::string _path; // 真正的服务器目录 std::string _suffix; // 请求资源的后缀名(Content-Type) int _size; // 正文大小(Content-Length的值) }; class Response { public: std::string _outbuffer; };HTTP报头常见字段
- Content-Type: 数据类型(text/html等)
- Content-Length: Body的长度
- Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
- User-Agent: 声明用户的操作系统和浏览器版本信息;
- referer: 当前页面是从哪个页面跳转过来的;
- location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问(重定向);
- Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能;
HTTP请求方法
在上面服务端中,请求都是GET,表示获取资源,而还有POST,表示上传资源,这两种方法是最常用的
例如此时要再网页中通过表单输入用户名&密码,点击登录
<form action="/login" method="GET"> <div class="form-group"> <label for="username">用户名</label> <input type="text" id="username" name="username" placeholder="请输入用户名" required> </div> <div class="form-group"> <label for="password">密码</label> <input type="password" id="password" name="password" placeholder="请输入密码" required> </div> <button type="submit" class="login-btn">登 录</button> </form>这段表单的请求资源为/login,请求方法为GET,点击登录后,会自动发送http请求,该请求以?隔开url和参数
由于我们现在的服务器没有做相关处理,因此访问/login资源会被替换成404界面
服务端也会收到该请求:
若将请求方法改为POST,点击登录后,发送的http请求就不会附带参数
参数在该请求的正文中
GET/POST的区别
- GET通过url传递参数,POST通过http请求的正文提交参数
- POST方法的参数一般来说用户看不到,私密性好(私密性 != 安全性)
无论是GET还是POST都不安全,要安全就需要https加密 - 若GET方法,参数就不能太大,否则url会非常冗余
但POST方法就无需担心参数长度,即使是超文本也没问题
当我们提交了指定路径(例如/login)后,服务器会通过例如if (path = "\login")这样的判断,来引导程序走向专门用于登录的代码,而不是默认的获取资源(例如fork后执行execl程序替换,交给指定程序执行)
请求方法有很多,但一般来说只会用到GET和POST
HTTP状态码
- 2开头的,例如200,201,204等,都表示请求已成功被服务器接收,理解并接受
- 4开头的,例如400,403,404等,都表示请求包含语法错误或无法实现,问题出在客户端(浏览器/App)
- 5开头的,例如500,502,503等,表示服务器在处理请求的过程中发生了错误,问题出在服务端。
- 1开头的,例如100,101等,这类状态码很少在日常开发中直接接触,主要用于协议层的通信。
- 3开头的,例如301,302,304,表示需要客户端采取进一步的操作才能完成请求(重定向)
重定向状态码
下面详细介绍一下3号开头的重定向状态码
当在浏览器登录时,登录完后通常要跳转到首页,这个操作其中就有重定向的参与:服务端向浏览器发送重定向响应,浏览器识别到后再请求新的url
而重定向又分为永久重定向(301/308)和临时重定向(302/303/307)
永久重定向会让浏览器记住这个跳转,在下次访问旧url时,不会问服务器,而是直接在本地跳转到新url;还会自动将用户保存的旧书签更新为新url
而临时重定向就不会
永久重定向一般用于永久更换域名、HTTP强制跳转HTTPS等等,其他情况都用临时重定向
若要将上面实现的服务端改为临时重定向到指定网站,只需修改如下两行即可
std::string resp_line = "HTTP/1.1 302 Found\r\n"; // 响应行 resp_header += "Location: https://www.bilibili.com/\r\n";启动服务端后,当用telnet测试时,会发现它的Location字段和302状态码
现在再从浏览器进入时,就会自动跳转到bilibili.com
长连接
Http网页中可能包含多个元素,需要向服务端发送多个请求,而Http是基于TCP的,若每个元素都要建立一条TCP链接,开销会很大。
因此引入了长连接优化机制,长连接让一个 TCP 连接可以复用,连续发送多个 HTTP 请求/响应,避免频繁建立和断开连接
长连接需要客户端与服务端都支持才可以启用:
- 若是HTTP/1.0,需要请求和响应中都包含Connection: keep alive报头字段
- 若是HTTP/1.1,默认双方都开启了长连接,若想关闭,就显示声明Connection: close
会话保持
会话保持不是HTTP协议具备的,而是为了弥补HTTP缺陷而在应用层(浏览器)引入的“补丁”
HTTP协议本身是无状态的,即协议本身不记录前一次请求和后一次请求之间的任何关联。但这样用户在浏览器中登录了某个网站后,自动重定向到指定url,此时这个新url也不认识用户,还需要再一次登录
因此需要会话保持:用户登录一次某网站后,该网站会一直记住登录信息,后续再访问该网站时无需重复登录
旧方案:Cookie技术
在用户第一次访问该网站时,会要求注册/登录,当注册/登录成功后,浏览器会加密保存用户信息(账号密码等等),保存的信息称之为Cookie,并在后续访问同一网站时自动推送(将Cookie夹在请求中),每次访问网站时服务端也会有身份验证,此时读取请求中的Cookie
Cookie分为文件级Cookie和内存级Cookie,文件级在浏览器被关闭后仍然有效,内存级仅在当前浏览器中有效,关闭即失效
而Cookie技术的安全风险太大:Cookie存储在本地,容易被木马病毒窃取、当黑客获取到Cookie文件后,就可以伪装成合法用户访问服务,并且Cookie中的账号密码也容易被获取
新方案:Session技术
为了提高安全性,现在用户的私密数据不存储在本地,而是服务器上。在登录成功后,服务端创建唯一的Session文件(包含了用户的认证信息、浏览痕迹等私密信息)并返回给用户一个Session ID(作为Cookie返回),后续请求只需携带该Session ID,服务端会验证ID合法性
由于攻击者只能获取到Session ID,而无法直接获取账号密码等信息,这在一定程度上改善了用户信息泄露的问题。同时,大型公司会投入资源维护服务器的安全性,包括防漏洞、防攻击等措施,进一步降低信息泄露的风险。
虽然攻击者拿到Session ID后依然可以伪装成合法用户,但当前已有许多成熟的技术防范:
- 异常行为检测:IP地址突变、长期未用账号突然活跃、短时大量联系历史好友等等
- 主动防御机制:强制Session失效、多因素认证触发、蜜罐漏洞诱捕黑客
要想在我们实现的服务端上推送给浏览器Cookie,需要在响应报头中添加Set-Cookie行
真正生成的Cookie应该是通过算法实现的,但这里仅为测试所以手动写一个:
resp_header += "Set-Cookie: 1145141919810aaaaaa\r\n";之后在浏览器访问该网站时:
默认的Cookie到期时间是会话结束,要手动设置就在Set-Cookie字段后加Max-Age属性(当有两个以上属性时,Cookie本身也需要有属性名):
resp_header += "Set-Cookie: tmpcookie=1145141919810aaaaaa; Max-Age=120\r\n";在服务器中也可以看到,后续浏览器的每次请求都会附带Cookie:
Http工具
Postman
Postman是一个接口调试工具,用于模拟浏览器发送请求并查看响应
由于在服务端发送响应时通常都会对html进行压缩(去掉无意义的\r\n\t等),用这类工具就可以查看美化(重新为html加上格式符)后的效果
当我们请求自己的服务端时,获取的Cookie信息:
服务端的请求显示:
若用GET方式自己添加参数向服务端发请求,服务端会收到:
若用POST方式自己添加参数向服务端发请求,服务端可以正常收到:
Fidler
Fidler是一个专门用于HTTP的本地抓包工具,它充当中间人劫持浏览器的请求转发给目标服务器
若浏览器向服务端发送POST请求,依旧可以通过抓包软件抓到正文中的参数:
Fidler作为中间人,浏览器需要告知它要将请求发送到哪个服务器上,因此在Fidler中显示的请求,url部分是带上了IP地址的,而服务端接收到的请求只有后面的路径部分