《Linux网络编程》4.应用层HTTP协议

💡Yupureki:个人主页

✨个人专栏:《C++》 《算法》《Linux系统编程》《高并发内存池》《MySQL数据库》

《个人在线OJ平台》《Linux网络编程》《CMake自动化构建工具》


🌸Yupureki🌸的简介:


目录

[1. 初识HTTP协议](#1. 初识HTTP协议)

[1.1 什么是HTTP协议?](#1.1 什么是HTTP协议?)

[1.2 认识URL](#1.2 认识URL)

[1.2.1 URL 和 HTTP 的关系](#1.2.1 URL 和 HTTP 的关系)

[1.2.2 URL的结构](#1.2.2 URL的结构)

[1.2.3 URL编码](#1.2.3 URL编码)

[1.2.3.1 核心规则](#1.2.3.1 核心规则)

[1.2.3.2 哪些字符需要编码?](#1.2.3.2 哪些字符需要编码?)

[1.2.3.3 具体编码流程](#1.2.3.3 具体编码流程)

[1.3 HTTP常见方法](#1.3 HTTP常见方法)

[1.3.1 GET方法](#1.3.1 GET方法)

[1.3.2 POST方法](#1.3.2 POST方法)

[1.3.3 PUT方法](#1.3.3 PUT方法)

[1.3.4 PATCH方法](#1.3.4 PATCH方法)

[1.3.5 DELETE方法](#1.3.5 DELETE方法)

[1.4 HTTP状态码](#1.4 HTTP状态码)

[1.4.1 2xx 成功类](#1.4.1 2xx 成功类)

[1.4.2 3xx 重定向类](#1.4.2 3xx 重定向类)

[1.4.3 4xx 客户端错误](#1.4.3 4xx 客户端错误)

[1.4.4 5xx 服务器错误](#1.4.4 5xx 服务器错误)

[1.5 HTTP版本](#1.5 HTTP版本)

[1.6 HTTP报头](#1.6 HTTP报头)

[1.6.1 通用报头](#1.6.1 通用报头)

[1.6.2 请求报头(客户端 → 服务器)](#1.6.2 请求报头(客户端 → 服务器))

[1.6.3 响应报头(服务器 → 客户端)](#1.6.3 响应报头(服务器 → 客户端))

[1.7 HTTP正文](#1.7 HTTP正文)

[1.7.1 正文可以是什么内容?](#1.7.1 正文可以是什么内容?)

[1.7.2 如何设置正文?](#1.7.2 如何设置正文?)

[1.7.3 如何处理正文?](#1.7.3 如何处理正文?)

[1.7.4 常见"坑"与注意事项](#1.7.4 常见“坑”与注意事项)

[1.8 完整的HTTP传输](#1.8 完整的HTTP传输)

[第一步:尝试直接访问个人资料页(无 Cookie)](#第一步:尝试直接访问个人资料页(无 Cookie))

第二步:获取登录页面

第三步:用户提交登录表单

[第四步:重定向回个人资料页(带 Cookie)](#第四步:重定向回个人资料页(带 Cookie))

[2. 实现简单的HTTP服务器](#2. 实现简单的HTTP服务器)

[2.1 总体规划](#2.1 总体规划)

[2.2 com.hpp](#2.2 com.hpp)

[2.3 socket.hpp](#2.3 socket.hpp)

[2.4 TcpServer.hpp](#2.4 TcpServer.hpp)

[2.5 http.hpp](#2.5 http.hpp)

[2.6 测试](#2.6 测试)


1. 初识HTTP协议

1.1 什么是HTTP协议?

虽然我们说,应用层协议是我们程序猿自己定的,但实际上,已经有大佬们定义了一些现成的,又非常好用的应用层协议,供我们直接参考使用,HTTP(超文本传输协议)就是其中之一。

在互联网世界中,HTTP是一个至关重要的协议。

HTTP 的全称是 超文本传输协议 (HyperText Transfer Protocol),它是互联网上应用最广泛的一种应用层协议 。简单说,HTTP 就是规定了浏览器(客户端)与服务器之间如何"对话"、如何传输数据的一套规则

还是听不懂怎么办?没事,一图秒懂

我们手机,电脑上的浏览器,访问网页,用的就是HTTP协议

简单了解HTTP报文:

后面我们将一一介绍HTTP报文各结构的详细信息

1.2 认识URL

URL 的全称是 统一资源定位符 (Uniform Resource Locator),通俗说就是网址 。它是互联网上用来唯一标识和定位一个资源 的地址,就像每家每户的门牌号,你告诉浏览器这个"地址",它才知道去哪找并取回你想要的内容。

这就是一个URL

1.2.1 URL 和 HTTP 的关系

结合刚才的 HTTP:HTTP 负责"怎么传",而 URL 负责"找谁"和"传什么"。

当你在浏览器输入一个网址,比如 http://example.com/index.html,浏览器会:

  • 从 URL 中拆出 协议 (HTTP)、服务器地址example.com)、资源路径(/index.html)

  • 构建一个 HTTP 请求发过去,就像:GET /index.html HTTP/1.1 并带上 Host: example.com

1.2.2 URL的结构

一个完整的 URL 可以拆成以下几个部分

  1. 协议(Scheme)

    告诉客户端用什么方式访问资源。

    常见:httphttpsftpmailto 等。

    互联网上绝大部分是 httphttps

  2. 域名(Host)

    服务器的"门牌号",可以是域名(如 example.com)或 IP 地址(如 192.168.1.1)。

    域名会被 DNS 解析成 IP,才能找到物理服务器。

  3. 端口(Port)

    服务器上具体"房间"的编号,用来区分不同的服务。

    HTTP 默认端口是 80 ,HTTPS 是 443 。如果用了默认端口,浏览器会自动补上,地址栏通常不显示。一般正规的网址不会采用原始的IP:Port的方式显示在互联网上,而是将整个IP:Port替换成一个域名例如www.baidu.com,原来可能是192.168.1.1:8080

  4. 路径(Path)

    服务器上资源的具体位置,类似文件路径。

    比如 /articles/2024/hello.html。如果没有写具体文件,服务器通常会返回默认页面(如 index.html)。

  5. 查询参数(Query Parameters)

    提供给服务器的额外信息,通常用来筛选、搜索、分页等。

    ? 开头,多个参数用 & 分隔,格式为 key=value

    例子:?search=HTTP&lang=zh 代表告诉服务器"我搜索的关键词是 HTTP,语言是中文"。

  6. 片段标识符(Fragment)

    用来定位资源内部的某个位置,以 # 开头。

    比如 #section-1 能让浏览器直接滚动到页面的该锚点。

    注意:# 后面的内容不会被发送到服务器,只在浏览器本地使用。

1.2.3 URL编码

URL编码 ,也叫百分号编码 ,本质就是把不符合URL安全要求的字符转义成允许传输的形式。因为URL在设计时只能用一小部分ASCII字符 ,而你又想在地址里传中文空格保留符号 (如 ?&),就必须通过编码把它们"包裹"起来。

1.2.3.1 核心规则

规则非常直接:

  1. 将字符按一定字符编码 (几乎都是UTF-8)转换成字节序列。

  2. 每个字节前加一个百分号 %,后跟该字节的两位十六进制大写表示。

例如:

  • 空格字符:UTF-8编码为 0x20%20

  • 中文字符"中":UTF-8编码为 E4 B8 AD%E4%B8%AD

1.2.3.2 哪些字符需要编码?

URL字符分为几类,编码策略不同:

类别 包含字符 是否需要编码 说明
非保留字符 A-Z a-z 0-9 - _ . ~ 不需要 天生安全,直接写入URL
保留字符 : / ? # [ ] @ ! $ & ' ( ) * + , ; = 视位置而定 作为分隔符时不编码 (如 ?key=value&a=b 中的 ? = &);作为普通数据时必须编码
其他字符 中文、空格、emoji、" < > \ ^ ````` { ` }` 等 | 必须编码 | 要么不合法,要么易引起歧义 |

注意 :即使是保留字符,一旦它不承担"结构分隔"职能,就一定要编码。

比如你传的标题是 A & B,在查询参数里就必须写成 title=A%20%26%20B,这里的 & 被编码为 %26,否则会被误认为是参数间隔符。

1.2.3.3 具体编码流程

以在查询参数中传递一个中文标题 你好?&name=test 为例。

1. 客户端生成URL

  • 原始参数值:你好?&name=test

  • 确定字符编码(UTF-8)

  • 对每个字符判断:

    • → 非ASCII → 转UTF-8字节 E4 BD A0%E4%BD%A0

    • %E5%A5%BD

    • ?& 是保留字符,但在这里是值的一部分,必须转 → %3F%26

    • = 在被包含的 &name=test里(这里属于值的内容,但 name=test= 怎么办?其实我们需要整体看这个值的语义:原始数据就是整个字符串 你好?&name=test,其中 = 也属于字面量,必须编码为 %3D

  • 最终编码结果:%E4%BD%A0%E5%A5%BD%3F%26name%3Dtest

2. 拼接到URL中

假设参数名为 q,URL为:
https://example.com/search?q=%E4%BD%A0%E5%A5%BD%3F%26name%3Dtest

此时的 ? 和后面的 &= 都被正确编码,不会破坏URL结构。

3. 浏览器/客户端发送HTTP请求

请求行不会变,依然是这个编码后的URL。

4. 服务器接收与解码

服务器拿到查询字符串部分,会:

  1. 识别出参数 q 的原始编码值:%E4%BD%A0%E5%A5%BD%3F%26name%3Dtest

  2. 进行百分号解码 :把每个 %XX 转换回对应的字节,再用UTF-8解码为字符串。

  3. 得到原始值:你好?&name=test

1.3 HTTP常见方法

HTTP 定义了一组请求方法 (也叫"动作"或"谓词"),用来告诉服务器客户端想对资源做什么

结合URL 和编码,一个完整的请求就像:

POST /api/users HTTP/1.1
Host: example.com

这里的 POST 就是方法,它和 URL 共同决定了操作的对象和动作。

目前开发中最常接触的是这 5 个,分别对应"增删改查":

方法 语义 典型场景 是否有请求体 安全 幂等
GET 获取资源 访问网页、查询数据、请求图片/JSON 无(参数在 URL 里)
POST 创建资源 / 提交数据 提交表单、登录、上传文件、创建新订单
PUT 整体更新(替换)资源 更新用户所有信息、上传文件到固定路径
PATCH 部分修改资源 只改密码、只改昵称 ❌(通常不是幂等)
DELETE 删除资源 删除一篇文章、删除用户 一般无(也可能有)

补充概念

  • 安全:指不会改变服务器状态,纯粹读取。GET 是安全的,POST/PUT/DELETE 不是。

  • 幂等:重复执行多次,结果和执行一次相同。GET、PUT、DELETE 是幂等的;POST 不是(重复提交可能创建多个资源)。

1.3.1 GET方法

语义 :请求获取指定的资源
本质 :纯粹的查询操作,不应产生任何副作用(服务器状态不能变)。

典型请求示例

复制代码
GET /api/articles/123 HTTP/1.1
Host: blog.example.com
Accept: application/json

常见成功响应

  • 200 OK,响应体包含资源数据

  • 304 Not Modified,配合协商缓存,告诉客户端可以直接用本地缓存

1.3.2 POST方法

语义 :向指定资源提交数据 ,请求服务器进行处理(通常是创建新资源)。
本质 :它不是幂等的,意味着多次执行同一请求会产生多个资源(比如提交两次订单,就会生成两笔)。

典型请求(创建用户):

复制代码
POST /api/users HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"name": "张三", "email": "zhang@example.com"}

常见成功响应

  • 201 Created 是最符合语义的,并通常带上 Location 头指向新资源的 URL,如:
    Location: /api/users/321

  • 200 OK 也常见,特别是处理结果直接返回时。

  • 202 Accepted 表示请求已接受,但处理还没完成(异步任务)。

1.3.3 PUT方法

语义 :用请求体完整地替换指定资源 (如果不存在,则创建)。
本质幂等,你发一次和多次,最终资源状态都是一样的(因为每次都整量覆盖)。

典型请求(更新整个用户):

复制代码
PUT /api/users/123 HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"name": "李四", "email": "lisi@example.com", "age": 30}

常见响应

  • 200 OK(返回更新后的资源)

  • 204 No Content(更新成功,不返回内容)

  • 如果创建了新资源(比如不存在,服务器允许创建),返回 201 Created

1.3.4 PATCH方法

语义 :对资源进行部分更新 ,请求体中只包含要改动的内容。
本质 :通常不是幂等的(但可以设计为幂等,取决于补丁格式)。

两种常见补丁格式

  1. JSON Merge Patch (RFC 7396)

    直接发送要合并的 JSON 对象,例如只发送 {"email": "new@ex.com"},后端合并到已有对象。简单但容易混淆"设为空"与"不修改"。

  2. JSON Patch (RFC 6902)

    通过一组操作指令描述修改,更精确:

    PATCH /api/users/123 HTTP/1.1
    Content-Type: application/json-patch+json

    [
    { "op": "replace", "path": "/email", "value": "new@ex.com" },
    { "op": "remove", "path": "/age" }
    ]

常见响应200 OK204 No Content

1.3.5 DELETE方法

语义 :请求服务器删除指定资源。
本质幂等,因为删一次它就不在了,再删仍然是不在(虽然第二次返回 404,但资源状态结果一致)。

典型请求

复制代码
DELETE /api/users/123 HTTP/1.1
Host: api.example.com

常见响应

  • 200 OK(附带删除成功的信息)

  • 204 No Content(删除成功,无返回内容)

  • 202 Accepted(已接受,异步处理删除)

  • 404 Not Found(资源不存在,可视为幂等的体现)

1.4 HTTP状态码

HTTP 状态码是服务器在响应中返回的一个 三位数字 ,用来简明扼要地告诉客户端请求的结果

它配合之前了解的请求方法(GET、POST 等),构成了"请求-处理-结果"的完整闭环。不论你是打开网页、调用 API 还是抓取数据,读懂状态码是排查问题的第一步。

状态码的分类(一共五类)

类别 范围 含义 典型场景
1xx 100 -- 199 信息响应 请求已接收,继续处理
2xx 200 -- 299 成功 请求被成功接收、理解、处理
3xx 300 -- 399 重定向 需要进一步操作才能完成请求
4xx 400 -- 499 客户端错误 请求包含语法错误或无法完成(你发的不对)
5xx 500 -- 599 服务器错误 服务器在处理请求时失败了(服务器内部问题)

1.4.1 2xx 成功类

200 OK

  • 含义:请求成功,服务器返回了请求的数据。

  • 适用:GET 拿到内容,PUT 更新成功返回资源,PATCH 修改成功等。

  • 注意:POST 创建资源有时返回 201 更符合语义,但 200 也没错。

201 Created

  • 含义 :请求成功且创建了新资源

  • 典型 :POST 创建用户、文章后,响应应携带 Location 头部,指向新资源的 URL。

  • 示例
    HTTP/1.1 201 Created
    Location: /api/users/456

204 No Content

  • 含义 :请求成功,但响应主体没有内容(空 Body)。

  • 典型:DELETE 删除成功、PUT 更新成功且无需返回数据。

  • 后果:浏览器收到 204 会停留在原页面,不会跳转或刷新,常用于 AJAX 操作。

206 Partial Content

  • 含义 :服务器只返回了请求的部分内容(常用于分块下载 / 断点续传)。

  • 条件 :客户端发送 Range: bytes=1000- 头部,服务器响应 206,并提供 Content-Range 头部。

1.4.2 3xx 重定向类

301 Moved Permanently

  • 含义 :资源永久移动 到了新的 URL(响应里 Location 指明)。

  • 浏览器行为GET 方法会自动跳转,并且搜索引擎会更新索引,替换掉旧 URL;POST 转为 GET 跳转(多数浏览器如此)。

  • 场景:网站迁移、HTTP 升级 HTTPS、规范化 URL。

302 Found (之前叫临时重定向)

  • 含义 :资源临时放到另一个 URL。

  • 浏览器行为:每次仍然用原 URL 请求,但 POST 可能被改为 GET(历史上不安全的实现),容易造成链路混乱。

  • 已不推荐用 302 处理 POST 重定向,现在有更明确的 307/308。

303 See Other

  • 含义:请求的响应应该由另一个 URL 的 GET 请求获取。

  • 典型场景:PRG 模式 ------ POST 创建资源后,避免刷新时重复提交,服务器返回 303 重定向到详情页。

  • 强制行为 :不论原请求是 POST 还是 GET,客户端必须用 GET 方法请求新地址

304 Not Modified

  • 含义 :客户端已经有缓存,资源未修改,直接使用本地缓存。

  • 条件 :客户端发起了条件请求 (带 If-None-MatchIf-Modified-Since)。

  • 响应体:无(没有消息体),只返回头,节省流量。

  • 性质:归类在 3xx,但更像"成功-使用缓存"的优化指令。

307 Temporary Redirect

  • 含义 :临时重定向,且不允许更改请求方法

  • 区别 :对于 POST 请求,301/302 可能被浏览器改成 GET 跳转;307 保证方法不变,POST 仍然以 POST 跳转(但需要用户确认安全)。

  • 场景:临时维护,POST 请求严格保持。

308 Permanent Redirect

  • 含义 :永久重定向,且方法不允许更改,类似 307(但永久)。

  • 场景:PUT 更新接口迁移到新地址,仍然保持 PUT 方法跳转。

总结记忆

  • 永久:301 / 308(前者可能改方法,后者不改变方法)

  • 临时:302 / 307(同上)

  • 303 强制改成 GET

1.4.3 4xx 客户端错误

400 Bad Request

  • 含义:请求存在语法错误,服务器无法理解。

  • 常见原因:请求格式错误(如 JSON 解析失败)、参数校验不通过、URL 编码异常、头部过长等。

  • 排查:检查发出去的报文,特别是 Content-Type 和 Body 是否匹配。

401 Unauthorized

  • 含义 :当前请求需要用户认证(没登录或令牌无效)。

  • 响应头 :必须包含 WWW-Authenticate 字段,告诉客户端怎样认证。

  • 混淆:不要与 403 搞混 ------ 401 是"你没验证身份",403 是"验证过了,但没权限"。

403 Forbidden

  • 含义 :服务器理解了请求,但拒绝执行,你无权访问(即使登录了)。

  • 场景:普通用户访问管理员后台、IP 被封禁、权限不够。

404 Not Found

  • 含义 :服务器上找不到请求的资源

  • 经验:有时服务器为了安全故意返回 404 而不是 403,以隐藏资源存在性。

405 Method Not Allowed

  • 含义:请求方法对目标资源不被支持(如用 GET 调用了一个只接受 POST 的接口)。

  • 响应头Allow: GET, POST 会明确指出支持的方法。

409 Conflict

  • 含义 :请求与当前资源状态冲突,常出现在资源竞态场景。

  • 示例:多个请求同时修改同一文件产生版本冲突;创建用户时用户名已存在。

422 Unprocessable Entity (常用在 WebDAV / REST API)

  • 含义 :格式正确但语义有错,通常指数据校验失败。

  • 示例:注册时密码太短、邮箱格式错误;POST 的字段缺少必填项。

  • 与 400 区别:400 是请求本身错误(如 JSON 不合法),422 是请求内容合法但不满足业务规则。

429 Too Many Requests

  • 含义 :客户端在给定时间内发送了太多请求,触发限流

  • 响应 :可能携带 Retry-After 头,说明多少秒后可以重试。

1.4.4 5xx 服务器错误

500 Internal Server Error

  • 含义:服务器遇到了意外情况,无法完成请求。

  • 原因:代码抛出未处理异常、配置错误、权限问题等。

  • 唯一信息:这是个"万能错误",开发者必须查服务器日志才能定位。

502 Bad Gateway

  • 含义 :作为网关或代理的服务器,从上游服务器收到了无效响应

  • 场景:Nginx 反向代理的后端应用挂了,返回空内容;或者 PHP-FPM 超时无输出。

503 Service Unavailable

  • 含义:服务器暂时无法处理请求(过载或停机维护)。

  • 对比 500:503 是知道问题且是暂时的。

  • 响应头 :通常会加 Retry-After 告诉多久后恢复。

504 Gateway Timeout

  • 含义 :网关/代理等待上游服务器响应超时

  • 场景:后端请求数据库或外部 API 太慢,代理主动断开。

  • 排查方向:后端性能问题、慢查询、外部依赖超时。

但服务器即便炸了,一般也不会返回5xx错误码。

因为你告诉用户自家服务器炸了,这不是把自家丑事告诉别人了吗?并且遇到一些心怀不轨的人看见你服务器炸了,会继续攻击你的服务器,导致情况更严重

因此服务器炸了,一般也返回4xx错误码,告诉是你用户的问题

1.5 HTTP版本

HTTP 协议从诞生至今经历了多个版本的演进,每一次大版本更新都为了解决上一代的核心痛点------尤其是速度连接效率

目前共有五个主要版本:0.9、1.0、1.1、2、3(HTTP/1.1 是统治时间最长的经典版本,HTTP/2 和 HTTP/3 已是现代主流)。

一般如今使用最广泛的仍是HTTP1.1版本,我们处理最多的也是这个版本

1.6 HTTP报头

HTTP 报头(Headers)是请求和响应中的元数据,用来传递额外信息:告诉服务器你是谁、要什么格式、怎么缓存、有什么认证凭据;或者告诉浏览器返回的是什么、怎么处理。

1.6.1 通用报头

1. Connection

控制当前连接的选项,最常见:keep-alive(维持连接)和 close(不再重用)。

  • 在 HTTP/1.1 默认 keep-alive,HTTP/1.0 默认 close。

  • 还用于声明升级协议,如 Upgrade: h2c

2. Transfer-Encoding

告知消息体使用了什么传输编码。
最重要的取值chunked ------ 分块传输,常用于动态生成的响应,服务器可以先返回一部分内容。

  1. Date

表示消息生成的日期时间。

所有服务器响应都应包含,格式必须是 HTTP-date,如 Date: Tue, 15 Nov 2023 08:12:31 GMT

1.6.2 请求报头(客户端 → 服务器)

1. Host(HTTP/1.1 必须)

指定服务器的域名和端口(若为非默认)。

因为一个 IP 可托管多个站点,必须用 Host 区分。

例:Host: www.example.com:8080

2. User-Agent

标识客户端软件和版本,如浏览器、爬虫。

例:User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0

3. Accept 系列(内容协商)

  • Accept :客户端能处理的 MIME 类型,如 text/html,application/json

  • Accept-Encoding :支持的压缩算法,如 gzip, deflate, br(Brotli)。

  • Accept-Language :期望的语言,如 zh-CN,zh;q=0.9,en;q=0.8

4. Referer

来源页面的 URL。当从 A 页面点链接到 B 时,B 会收到 Referer: A 的网址

常用于防盗链、溯源分析,注意拼写错误(Referer 而非 Referrer)。

5. Authorization

向服务器传递认证信息。

  • 基础认证:Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== (Base64 编码的用户名:密码)

  • Bearer Token:Authorization: Bearer eyJhbGciOiJIUzI1...

6. Cookie(重要)

将之前服务器 Set-Cookie 保存的 Cookie 回传,用于保持会话状态。

例:Cookie: session_id=abc123; theme=dark

为什么有时候你登陆了某些网站,你退出再次打开,会保持登陆状态?这就是你的浏览器保存了Cookie

当你首次登陆时,服务器会传递一个特定的Cookie给你的浏览器,浏览器会保存下来

这个Cookie一般包含:账户和密码

你的浏览器访问服务器时,会顺便传递Cookie,服务器获取Cookie中的账户和密码,自动登陆成功

当然Cookie一般也有过期时间,过了一段时间,你会发现登陆网站时不会自动登陆,这是因为Cookie过期了

7. Cache-Control

发出缓存指令,控制缓存行为。常见取值:

  • no-cache:强制验证缓存新鲜度后才能用。

  • no-store:完全不缓存。

  • max-age=3600:缓存有效秒数。

1.6.3 响应报头(服务器 → 客户端)

1. Content-Type

正文 MIME 类型及字符集。

例如:Content-Type: application/json; charset=utf-8

绝对重要,否则浏览器可能乱码或错误解析(比如把 JSON 当文本)。

常见Mime类型:MIME 类型 | 菜鸟教程

2. Content-Length

正文的字节长度(未压缩前)。

用于响应接收方知道何时读完数据,也是 HTTP/1.1 持久连接依赖的界定方式之一(除非分块传输)。

3. Content-Encoding

报体使用的压缩编码,告知客户端需要解压。

Content-Encoding: gzip。注意与 Transfer-Encoding 的区别:前者是"内容本身的编码",后者是"传输方式的编码"。

4. Set-Cookie

服务器向客户端写入 Cookie。可携带过期时间、域、路径、安全标志等:

复制代码
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

5. Location

用于 3xx 重定向或 201 Created,指定新资源的 URL。

例:Location: /users/456

1.7 HTTP正文

HTTP 的正文(Message Body)就是请求或响应中真正传输的数据 。如果说请求行、状态行和头部像是快递信封上的地址和说明,那正文就是信封里装的货物。它可以承载任意形式的内容:网页、JSON、表单数据、图片、视频、二进制文件等。

1.7.1 正文可以是什么内容?

HTTP 正文本身只是一串字节流 ,并不限定类型。它的具体含义由 Content-Type 头部来声明,客户端/服务器根据这个头部才知道怎么去解析它。

以下是最常见的几种正文类型:

类别 Content-Type 示例 用途
纯文本 text/plain 简单文本消息
HTML text/html 网页主体
JSON application/json API 数据交互(最常见的 REST 格式)
XML application/xmltext/xml SOAP、RSS、配置数据
表单数据(URL 编码) application/x-www-form-urlencoded 传统表单提交,格式类似 URL 查询串 key1=val1&key2=val2
表单数据(多部分) multipart/form-data 文件上传,可能包含多个部分,每部分有独立头部
二进制数据 application/octet-stream 通用的二进制流,常用于文件下载
图片/视频/音频 image/png, video/mp4, audio/mpeg 媒体资源

注意:并不是所有请求/响应都有正文:

  • GET 请求没有正文(参数全在 URL)。

  • HEAD 请求的响应没有正文。

  • 204 No Content304 Not Modified 响应也没有正文。

1.7.2 如何设置正文?

处理和解析正文的关键在于理解几个重要头部。

1. Content-Type ------ 决定内容格式

它告诉接收方:"我发送的是什么类型的数据,字符集是什么。"

例如:

cpp 复制代码
Content-Type: application/json; charset=utf-8

接收方据此选择解析器:JSON 解析器、XML 解析器、HTML 渲染引擎等。

如果缺失,浏览器可能会尝试猜测(MIME sniffing),但这是不安全的,现代浏览器会尽力避免,最好明确指定。

2. Content-Length ------ 标记正文大小

声明正文的字节数 (未压缩时)。

接收方可以据此判断数据是否接收完整,尤其在持久连接中,必须知道一个响应的边界才能复用连接。

如果正文是动态生成的,长度事先未知,可以采用分块传输。

3. Transfer-Encoding: chunked ------ 分块传输

当无法提前知道总长度时,服务器会将正文分成多个"块"发送,每个块前标有本块的十六进制大小,最后以一个大小为 0 的块结束。

接收方需要边收边拼,等收到零长块才认为传输完成。这与 Content-Length 是两种互斥的界定方式。

4. Content-Encoding ------ 压缩标记

正文可能经过压缩(gzip、deflate、brotli),头会标明:

cpp 复制代码
Content-Encoding: gzip

处理流程 :先根据 Content-Encoding 解压,再根据 Content-Type 解析。

注意,这里的压缩是"内容编码",不是分块传输编码。

1.7.3 如何处理正文?

1. 客户端处理响应正文

浏览器会:

  • 根据响应的 Content-Type 决定行为:
    text/html → 渲染页面; image/png → 显示图片; application/json → 不直接显示,可能在调试工具中查看。

  • 如果响应是下载文件,浏览器会看 Content-Disposition 头(如 attachment; filename="data.pdf")来触发下载。

  • 对于 JavaScript,使用 fetchXMLHttpRequest 时,我们会调用相应方法:

    javascript 复制代码
    const response = await fetch('/api/data');
    const data = await response.json(); // 自动根据 Content-Type 解析 JSON
    const text = await response.text(); // 读取为纯文本
    const blob = await response.blob(); // 读取为二进制 Blob

2. 服务器处理请求正文

服务器接收到 POST/PUT/PATCH 等请求时,需要从请求流中读出正文,并根据 Content-Type 解析:

  • URL 编码表单 :读取字节流,按 & 拆分成键值对,再做 URL 解码。

  • JSON:合并字节流成字符串,用 JSON 解析器转换为对象。

  • 多部分表单:解析 boundary 分隔的各个部分,处理文件字段和普通字段。

  • 原始二进制:可原样保存为文件或交给进一步处理。

现代 Web 框架通常有中间件内建功能 自动解析,例如 Node.js 的 express.json()express.urlencoded(),或 Python Flask 的 request.get_json()。我们只需在代码中访问解析后的对象,不必手动处理流。

1.7.4 常见"坑"与注意事项

  1. 客户端忘记设置 Content-Type

    如果 POST 提交 JSON 而不加 Content-Type: application/json,服务器不知道如何解析,可能报 400 或 415(Unsupported Media Type)。

  2. Content-Type 与实际内容不匹配

    声称发 application/json 却送了纯文本,服务器解析 JSON 会失败,返回 400 Bad Request。

  3. 压缩链条

    某些代理或 CDN 可能会自动添加 Content-Encoding: gzip,而你读取时没解压,会导致乱码。好在浏览器和 fetch 等会自动处理透明解压。

  4. 大文件上传与超大正文

    放在内存会爆,必须使用流式处理,后端按块读写磁盘。multipart/form-data 能够有效处理文件边界。

  5. 安全考虑

    • 验证内容长度:防止恶意超大请求耗尽内存(设置请求体大小限制)。

    • 检查 Content-Type :不要仅凭扩展名或 Content-Type 信任文件,上传后应读取魔数检查真实类型。

    • 避免 XSS :如果响应 Content-Type 不当,如将用户上传的 HTML 显示为 text/html,可能导致 XSS。

1.8 完整的HTTP传输

我们用一个受保护的网站个人资料页的场景,把 HTTP 方法、状态码、头部、Cookie、正文、重定向、持久连接等串联起来,展示一次完整的、有上下文的 HTTP 交互。

假设环境 :用户使用浏览器,网站 https://example.com(已建立 TCP + TLS 连接,HTTP/1.1 持久连接)。

第一步:尝试直接访问个人资料页(无 Cookie)

用户在地址栏输入 https://example.com/profile,回车。

浏览器构造请求

javascript 复制代码
GET /profile HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 ... Chrome/120
Accept: text/html,application/xhtml+xml
Accept-Language: zh-CN,zh;q=0.9

注意:因为没登录,没有 Cookie 头部。

服务器处理:检测到用户未携带有效的会话 Cookie,需要鉴权。

服务器响应

javascript 复制代码
HTTP/1.1 302 Found
Location: /login
Set-Cookie: session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/
Date: Sun, 03 May 2026 10:00:00 GMT
Content-Length: 0
  • 302 Found:临时重定向到登录页。

  • Set-Cookie 清除了可能残留的过期 session

  • Content-Length: 0:302 通常没有正文。

浏览器收到 302,自动跟随重定向,发起新请求。


第二步:获取登录页面

javascript 复制代码
GET /login HTTP/1.1
Host: example.com
User-Agent: ... (同上)
Accept: text/html,...

由于上一步清除了 Cookie,且 /login 不需要认证,服务器直接返回登录页面 HTML。

服务器响应

javascript 复制代码
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 1524
Cache-Control: no-store
Date: Sun, 03 May 2026 10:00:01 GMT

<!DOCTYPE html>
<html lang="zh">
<head><title>登录</title></head>
<body>
  <form method="post" action="/login">
    <input name="username" type="text" />
    <input name="password" type="password" />
    <button type="submit">登录</button>
  </form>
</body>
</html>
  • Content-Type:告诉浏览器这是 HTML,用 UTF-8 解码。

  • Cache-Control: no-store:安全起见,登录页不缓存。

此时,浏览器的 TCP 连接仍保持打开(默认定 Connection: keep-alive)。


第三步:用户提交登录表单

用户输入凭据(用户名 alice,密码 secret123),点击提交。浏览器构造一个 POST 请求,注意因为一开始没有设置 Content-Type,HTML 表单默认使用 application/x-www-form-urlencoded 编码。

请求

javascript 复制代码
POST /login HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 32
Origin: https://example.com
Referer: https://example.com/login
Accept: text/html,...
Cookie: (无)

username=alice&password=secret123
  • 正文采用了 URL 编码(特殊字符已编码,这里只是示例)。

  • Content-Length: 32:标记正文长度。

  • Origin 头用于 CORS,这里同站,但会发。

服务器处理 :解析正文(自动 URL 解码),得到用户名和密码。验证通过后,服务器生成一个会话标识符(比如 sess_abc123xyz),并在存储中关联用户 alice

服务器响应

javascript 复制代码
HTTP/1.1 303 See Other
Location: /profile
Set-Cookie: session=sess_abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
Date: Sun, 03 May 2026 10:00:05 GMT
Content-Length: 0
  • 303 See Other :强制浏览器用 GET 方法请求 Location 提供的地址,避免刷新时重复提交表单(PRG 模式)。

  • Set-Cookie :将 session Cookie 写入浏览器,标记 HttpOnly(防 XSS 读取)、Secure(仅 HTTPS)、SameSite=Lax(防 CSRF)、Max-Age=3600 秒(持久 Cookie)。

  • 正文为空。


第四步:重定向回个人资料页(带 Cookie)

浏览器收到 303,自动用 GET/profile 发起请求,因为 SameSite=Lax 且它是顶级导航,浏览器会自动携带刚才写入的 Cookie。

请求

javascript 复制代码
GET /profile HTTP/1.1
Host: example.com
Cookie: session=sess_abc123xyz
Accept: text/html,...

(在请求里,只有名称和值,没有属性。)

服务器处理 :收到请求,读取 Cookie 头并找到 session=sess_abc123xyz,到会话存储中验证,确认属于用户 alice,登录有效。生成个人资料页面 HTML。

服务器响应

javascript 复制代码
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 1820
Cache-Control: private, no-cache
ETag: "h7d93jf8"
Date: Sun, 03 May 2026 10:00:05 GMT

<!DOCTYPE html>
<html lang="zh">
<head><title>个人资料</title></head>
<body>
  <h1>欢迎,alice</h1>
  <p>您的邮箱:alice@example.com</p>
  ...
</body>
</html>
  • Cache-Control: private, no-cache 表示内容仅供此用户,且每次必须验证(因为要确保登录状态)。

  • ETag 可用于后续条件请求。

  • 浏览器收到 200 OK 和 HTML,开始渲染页面,并可能继续请求图片、CSS 等静态资源,这些请求都会自动携带 Cookie,持续认证。

2. 实现简单的HTTP服务器

2.1 总体规划

一个HTTP服务器需要包含以下模块

  1. 底层TCP服务器:TCP服务器接收到浏览器发送的请求后,需要执行特定的处理,这个处理就是对HTTP协议的处理
  2. HTTP Request:TCP服务器接受到的数据,我们当作Request请求处理,一个HTTP Request包含以下结构,我们需要从字符串中提取这些结构
    1. 请求方法
    2. URL
    3. HTTP版本
    4. 报头
    5. 正文
  3. HTTP Response:服务器解析完Request后,根据请求数据做出相应的处理,并制作Response返回给客户端,一个Response包含以下结构
    1. HTTP版本
    2. 状态码
    3. 状态码描述
    4. 报头
    5. 正文

因此我们需要用C++的类来封装这些方法

2.2 com.hpp

com.hpp是一个公共头文件,其他的模块都需要包含这个头文件

cpp 复制代码
#pragma once

#include "logstrategy.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include "InetAddr.hpp"
#include <cstdlib>
#include <functional>
#include <unistd.h>
#include <string>
#include <jsoncpp/json/json.h>
#include <memory>
#include <sys/wait.h>

#define CONV(x) ((struct sockaddr*)(&x))
#define DEFAULT_BACKLOG 8
#define MAXNUM 1024
#define DEFAULT_PORT 8080
#define DEFAULT_SOCKFD -1
#define DEFAULT_IP "127.0.0.1"

enum ExitCode
{
    NORMAL = 0,
    SOCKET,
    BIND,
    LISTEN,
    ACCEPT,
    FORMAT,
    CONNECT,
    FORK
};

enum ResultCode
{
    OK = 0,
};

class nocopy{
public:
    nocopy()
    {}
    ~nocopy()
    {}
    nocopy(const nocopy&) = delete;
    const nocopy& operator=(const nocopy&) = delete;
};

2.3 socket.hpp

socket.hpp是一个封装socket套接字的头文件

cpp 复制代码
#pragma once

#include "com.hpp"

class Socket
{
public:
    Socket()
    {}
    ~Socket()
    {}
    virtual void create_socket() = 0;
    virtual void Bind(uint16_t) = 0;
    virtual void Listen(int) = 0;
    virtual std::shared_ptr<Socket> Accept(InetAddr&) = 0;
    virtual bool Connect(const std::string&,const uint16_t&) = 0;
    virtual int Recv(std::string&) = 0;
    virtual void Send(const std::string&) = 0;
    virtual int get_sockfd() = 0;
    void InitTcpServer(uint16_t port = DEFAULT_PORT,int backlog = DEFAULT_BACKLOG)
    {
        create_socket();
        Bind(port);
        Listen(backlog);
    }
    void InitTcpClient(std::string ip = DEFAULT_IP,uint16_t port = DEFAULT_PORT)
    {
        create_socket();
        Connect(ip,port);
    }
};

class TcpSocket : public Socket
{
private:
    using func_t = std::function<void()>;
public:
    TcpSocket(int sockfd = DEFAULT_SOCKFD)
    :_sockfd(sockfd)
    {}
    void create_socket() override
    {
        _sockfd = socket(AF_INET,SOCK_STREAM,0);
        
        if(_sockfd < 0)
        {
            logger(LogLevel::FATAL)<<"socket error";
            exit(ExitCode::SOCKET);
        }
        logger(LogLevel::INFO)<<"sockect create success : "<<_sockfd;
    }
    void Bind(uint16_t port)override
    {
        InetAddr addr(port);
        int n = bind(_sockfd,CONV(addr.get_addr()),sizeof(addr.get_addr()));
        if(n < 0)
        {
            logger(LogLevel::FATAL)<<"bind error";
            exit(ExitCode::BIND);
        }
        logger(LogLevel::INFO)<<"bind success";
    }
    void Listen(int backlog)override
    {
        int n = listen(_sockfd,backlog);
        if(n < 0)
        {
            logger(LogLevel::FATAL)<<"listen error";
            exit(ExitCode::LISTEN);
        }
        logger(LogLevel::INFO)<<"listen success";
    }
    std::shared_ptr<Socket> Accept(InetAddr& addr)override
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int sockfd = accept(_sockfd,CONV(peer),&len);
        if(sockfd < 0)
        {
            //logger(LogLevel::FATAL,__LINE__,__FILE__)<<"accept error";
            return nullptr;
        }
        InetAddr tmp(peer);
        addr = tmp;
        logger(LogLevel::INFO)<<"accpet success";
        std::shared_ptr<Socket> p = std::make_shared<TcpSocket>(sockfd);
        return p;
    }
    bool Connect(const std::string& ip,const uint16_t& port)override
    {
        InetAddr addr(ip,port);
        int n = connect(_sockfd,CONV(addr.get_addr()),sizeof(addr.get_addr()));
        if(n < 0)
        {
            logger(LogLevel::FATAL)<<"connect error";
            return false;
        }
        logger(LogLevel::INFO)<<"connect success";
        return true;
    }
    int Recv(std::string& out)override
    {
        char buffer[MAXNUM];
        ssize_t n = recv(_sockfd,buffer,sizeof(buffer) - 1,0);
        if(n > 0)
        {
            buffer[n] = '\0';
            out += buffer;
            return n;
        }
        else if(n == 0)
            return 0;
        else
            return -1;
    }
    void Send(const std::string& out)override
    {
        //logger(LogLevel::INFO)<<"send "<<std::to_string(_sockfd)<<" "<<out;
        send(_sockfd,out.c_str(),out.size(),0);
        //logger(LogLevel::INFO)<<"success";
    }
    void Start()
    {

    }
    int get_sockfd()override
    {
        return _sockfd;
    }
private:
    int _sockfd;
};

2.4 TcpServer.hpp

TcpServer.hpp在socket.hpp的基础上,封装成TCP服务器

cpp 复制代码
#pragma once

#include "socket.hpp"

class TcpServer : public nocopy
{
private:
    using func_t = std::function<void(std::shared_ptr<Socket>&,InetAddr&)>;
public:
    TcpServer(uint16_t port,func_t func)
    :_port(port),_ioserver(func),_listensock(std::make_shared<TcpSocket>())
    {}
    void init(int backlog = DEFAULT_BACKLOG)
    {
        _listensock->InitTcpServer(_port,backlog);
        _listensockfd = _listensock->get_sockfd();
    }
    void run()
    {
        _isrunning = true;
        while(_isrunning)
        {
            InetAddr addr;
            std::shared_ptr<Socket> sock = _listensock->Accept(addr);
            if(sock == nullptr)
                continue;
            pid_t pid = fork();
            if(pid > 0)
            {
                close(sock->get_sockfd());
                waitpid(pid,nullptr,0);
            }
            else if(pid == 0)
            {
                if(fork() > 0)
                    exit(ExitCode::NORMAL);
                close(_listensock->get_sockfd());
                _ioserver(sock,addr);
            }
            else
            {
                logger(LogLevel::FATAL)<<"fork error";
                exit(ExitCode::FORK);
            }
        }
    }

private:
    uint16_t _port;
    int _listensockfd;
    bool _isrunning = false;
    std::shared_ptr<Socket> _listensock;
    func_t _ioserver;
};

2.5 http.hpp

http.hpp是一个专门封装http处理方法的头文件,其中包含:HttpServer,HttpRequest,HttpResponse

HttpServer:

HttpServer需要提供TCP服务器接收到数据后,对数据的处理方法

具体处理方法:将数据解析成Request,经过处理后返还Response

cpp 复制代码
class HttpServer
{
public:
    HttpServer(uint16_t port)
        : _server(std::make_unique<TcpServer>(port, [this](std::shared_ptr<Socket> &sock, InetAddr &addr)
                                              { this->handler_http_request(sock, addr); }))
    {
    }
    void init()
    {
        _server->init();
    }
    void run()
    {
        _server->run();
    }
    static void handler_http_request(std::shared_ptr<Socket> &sock, InetAddr &addr)
    {
        std::string in;
        sock->Recv(in);
        logger(LogLevel::INFO)<<"get a request from "<<addr.get_string();
        HttpRequest req;
        req.Deserialize(in);
        HttpResponse rep;
        rep.MakeReponse(req.get_uri());
        std::string out = rep.Serialize();
        sock->Send(out);
    }

private:
    std::unique_ptr<TcpServer> _server;
};

HttpRequest:

HttpRequest需要将数据解析成对应的结构

cpp 复制代码
struct Util
{
    static bool ReadOneLine(std::string &line, std::string &out, const std::string &sep)
    {
        int pos = line.find(sep);
        if (pos == std::string::npos)
            return false;
        out = line.substr(0, pos);
        line.erase(0, pos + sep.size());
        return true;
    }
    static int FileSize(const std::string& file)
    {
        std::ifstream f(file, std::ios::binary);
        if(!f.is_open())
            return -1;
        f.seekg(0,f.end);
        int filesize = f.tellg();
        f.seekg(0,f.beg);
        f.close();
        return filesize;
    }
    static bool ReadFile(const std::string& file,std::string& out)
    {
        int filesize = FileSize(file);
        if(filesize > 0)
        {
            std::ifstream f(file);
            if(!f.is_open())
                return false;
            out.resize(filesize);
            f.read((char*)out.c_str(),filesize);
            f.close();
            return true;
        }
        else
            return false;
    }
};

class HttpRequest
{
public:
    bool Deserialize(std::string &reqstr)
    {
        std::string req = reqstr;
    #ifdef Debug
        logger(LogLevel::DEBUG) << "get a http request: " << req;
    #endif
        if (!prase_request_line(req) || !prase_request_head(req) || !prase_blank_line(req) || !prase_text(req))
            return false;
        return true;
    }
    std::string get_uri()
    {
        return _uri;
    }
private:
    bool prase_request_line(std::string &req)
    {
        std::string reqline;
        if (!Util::ReadOneLine(req, reqline, glinespace))
            return false;
        std::stringstream s(reqline);
        s >> _method >> _uri >> _version;
        if (_uri == "/")
            _uri = webroot + _uri + homepage;
        else
            _uri = webroot + _uri;
#ifdef Debug
        logger(LogLevel::DEBUG) << "_method: " << _method;
        logger(LogLevel::DEBUG) << "_uri: " << _uri;
        logger(LogLevel::DEBUG) << "_version: " << _version;
#endif
        return true;
    }
    bool prase_request_head(std::string &req)
    {
        std::string header;
        int pos = req.find(glinespace + glinespace);
        if (pos == std::string::npos)
            return false;
        header = req.substr(0, pos + glinespace.size());
        req.erase(0, pos + glinespace.size());
        std::string line;
        while (Util::ReadOneLine(header, line, glinespace))
        {
            int pos = line.find(glinesep);
            if (pos == std::string::npos)
                break;
            _headers[line.substr(0, pos)] = line.substr(pos + glinesep.size());
        }
#ifdef Debug
        logger(LogLevel::DEBUG) << "headers:";
        for (auto &it : _headers)
        {
            logger(LogLevel::DEBUG) << it.first << glinesep << it.second;
        }
#endif
        return true;
    }
    bool prase_blank_line(std::string &req)
    {
        int pos = req.find(glinespace);
        if (pos == std::string::npos)
            return false;
        _blankline = req.substr(0, pos);
        req.erase(0, pos + glinespace.size());
#ifdef Debug
        logger(LogLevel::INFO) << "blankline: " << _blankline;
#endif
        return true;
    }
    bool prase_text(std::string &req)
    {
        _text = req;
#ifdef Debug
        logger(LogLevel::DEBUG) << "text: " << _text;
#endif
        return true;
    }
    std::string _method;
    std::string _uri;
    std::string _version;
    std::unordered_map<std::string, std::string> _headers;
    std::string _blankline;
    std::string _text;
};

HttpResponse:

HttpResonse需要返回给客户端

cpp 复制代码
class HttpResponse
{

public:
    std::string Serialize()
    {
        std::string status_line = _version + gspace + std::to_string(_code) + gspace + _desc + glinespace;
        std::string headers;
        for(auto & it : _headers)
        {
            headers = headers + it.first + glinesep + it.second + glinespace;
        }
    #ifdef Debug
        logger(LogLevel::DEBUG)<<"response : "<< status_line + headers + _blankline + _text;
    #endif
        return status_line + headers + _blankline + _text;
    }
    void MakeReponse(std::string uri)
    {
        int filesize = 0;
        if(Util::ReadFile(uri,_text))
        {
            _targetfile = uri;
            _desc = "OK";
            set_code(200);
        }
        else
        {
            _desc = "FATAL";
            uri = webroot + "/404.html";
            _targetfile = uri;
            set_code(404);
            Util::ReadFile(uri,_text);
        }
        filesize = Util::FileSize(uri);
        set_header("Content-Type",get_suffix(uri));
        set_header("Content-Length",std::to_string(filesize));
    }

private:
    std::string get_suffix(std::string targetfile)
    {
        auto pos = targetfile.rfind(".");
        if(pos == std::string::npos)
            return "text/html";
        std::string suffix = targetfile.substr(pos);
        if(suffix == ".html" || suffix == ".htm")
            return "text/html";
        else if(suffix == ".jpg")
            return "image/jpeg";
        else if(suffix == "png")
            return "image/png";
        else
            return "";
    }
    void set_header(const std::string &key, const std::string &value)
    {
        if (_headers.find(key) != _headers.end())
            return;
        _headers[key] = value;
    }
    void set_code(int code)
    {
        _code = code;
    }
    std::string _version = "HTTP/1.0";
    int _code;
    std::string _desc;
    std::unordered_map<std::string, std::string> _headers;
    std::string _blankline = glinespace;
    std::string _text;
    std::string _targetfile;
};

index.html:

我们假设浏览器只访问wwwroot/index.html这个网页

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <p>hello world</p>
</body>
</html>

2.6 测试

启动http服务器后,在本地浏览器上输入:127.0.0.1:8080/index.html

相关推荐
m0_738120721 小时前
网路安全编程——熟悉并使用Scapy简单实现捕捉主流邮箱协议(SMTP、POP3和IMAP) 的身份凭证
网络·python·网络协议·tcp/ip·安全·网络安全
Brilliantwxx1 小时前
【C++】认识vector(概念+题目OJ)
开发语言·c++·笔记·算法
孙同学_1 小时前
【Linux篇】网络层与数据链路层详解
linux·网络·智能路由器
youngerwang1 小时前
【智能体互联网的基石:AI操作系统架构、Agent通信协议与演进路径综述】
网络·ai智能体·aios
小则又沐风a1 小时前
list模拟实现
java·服务器·list
上弦月-编程1 小时前
C语言链表详解,新手也能看懂! ——从入门到精通的完整教程
java·c语言·c++
拾光Ծ2 小时前
【Linux系统】进程信号(上)
linux·运维·服务器·面试·信号处理
咖喱o2 小时前
网络-堆叠
linux·运维·服务器·网络
Java面试题总结2 小时前
一文搞定 Linux Nginx 从安装、启动到 nginx.conf 全配置详解(新手也能看懂)
linux·运维·nginx