HTTP协议(Linux视角)

发布时间:2026/7/27 23:25:04
HTTP协议(Linux视角) 目录urlencode/urldecodeHTTP请求/响应请求响应HTTP服务端实现服务端和网页分离HTTP报头常见字段HTTP请求方法GET/POST的区别HTTP状态码重定向状态码长连接会话保持旧方案Cookie技术新方案Session技术Http工具PostmanFidler对于应用层的协议目前已经有很多大佬定制的成熟、好用的协议例如HTTP超文本传输协议就是其中一个每个网站都有网址URL下图简单认识URL的各部分主机之间的通信需要通过唯一的IP因此DNS服务器中存储着域名到IP的映射关系以此找到对应的IP这里的文件路径并不是从Linux的根目录开始而是从指定的web根目录可以是Linux中的任意一个目录当做根目录实际上URL中还需要传入端口号只不过http统一规定为80https统一规定为443因此不指定也没关系早期URLurlencode/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进制序列再转回原本字符的过程就是urldecodeHTTP请求/响应请求既然是协议就一定会有请求与响应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为ok404为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_iteratorchar(ifs), std::istreambuf_iteratorchar()}; // 响应正文 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 functionvoid (std::string) func_t;//回调函数类型 // typedef std::functionvoid(const Request , Response ) func_t; using func_t std::functionvoid(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绑定ipport 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-Lengthstd::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 methodGET div classform-group label forusername用户名/label input typetext idusername nameusername placeholder请输入用户名 required /div div classform-group label forpassword密码/label input typepassword idpassword namepassword placeholder请输入密码 required /div button typesubmit classlogin-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和POSTHTTP状态码2开头的例如200,201,204等都表示请求已成功被服务器接收理解并接受4开头的例如400,403,404等都表示请求包含语法错误或无法实现问题出在客户端浏览器/App5开头的例如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夹在请求中每次访问网站时服务端也会有身份验证此时读取请求中的CookieCookie分为文件级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: tmpcookie1145141919810aaaaaa; Max-Age120\r\n;在服务器中也可以看到后续浏览器的每次请求都会附带CookieHttp工具PostmanPostman是一个接口调试工具用于模拟浏览器发送请求并查看响应由于在服务端发送响应时通常都会对html进行压缩去掉无意义的\r\n\t等用这类工具就可以查看美化重新为html加上格式符后的效果当我们请求自己的服务端时获取的Cookie信息服务端的请求显示若用GET方式自己添加参数向服务端发请求服务端会收到若用POST方式自己添加参数向服务端发请求服务端可以正常收到FidlerFidler是一个专门用于HTTP的本地抓包工具它充当中间人劫持浏览器的请求转发给目标服务器若浏览器向服务端发送POST请求依旧可以通过抓包软件抓到正文中的参数Fidler作为中间人浏览器需要告知它要将请求发送到哪个服务器上因此在Fidler中显示的请求url部分是带上了IP地址的而服务端接收到的请求只有后面的路径部分