从HTTP到存储——文件上传的底层原理梳理

从 HTTP 到存储:文件上传的底层原理

前端写文件上传,经常只记住三行代码:

js 复制代码
const formData = new FormData()

formData.append('file', file)

await fetch('/api/upload', { method: 'POST', body: formData })

这三行能跑,但不解释任何原理。HTTP 并没有一个叫「文件上传」的独立功能。所谓上传,不过是:客户端把一段字节序列,按双方约定的格式塞进 HTTP 请求体;服务器再按同样的约定拆出来、校验、存起来。

网络层不关心这些字节是 PNG、MP4 还是 zip。文件的含义,是客户端、请求格式、服务端业务逻辑一起赋予的。

后面无论学分片、断点续传,还是对象存储直传,都建议先把这三个区别想清楚:

  1. FormData 不等于文件本身

  2. multipart 的 boundary 不等于 HTTP 消息边界

  3. HTTP 传输完成,不等于文件已经持久化成功

还有一个更常见的混淆:Base64、Multipart、分片上传经常被放进同一张「上传方式」表里。它们看起来都像在回答「文件怎么传」,其实解决的是不同问题。本文会按三个相互独立的维度来讲:

| 维度 | 它在回答什么 | 典型概念 |

| --- | --- | --- |

| 数据表示层 | 文件内容在请求里长什么样 | 原始字节、Base64、Hex、文本 |

| 请求体组织层 | 整个 HTTP 请求体如何把文件和其它数据放在一起 | 直接请求体、JSON、multipart、自定义封装 |

| 传输策略层 | 一次传完,还是拆开传、边读边传 | 整体上传、HTTP chunked、分片、断点续传、流式上传 |

Blob、ArrayBuffer、FormData、Content-Type 和分片接口,分别落在不同层。先把层分开,后面的例子才不会互相打架。


先建立一张总图

一次典型上传,大致走这条链路:

text 复制代码
浏览器 / 前端

  选择文件、读取元信息、构造请求

        │

        ▼

HTTP 请求头 + 请求体

        │

        ▼

Web 服务器 / 后端

  接收请求、解析请求体、校验文件

        │

        ▼

本地磁盘 / 对象存储(S3、OSS、COS...)

这里还要分开三个概念:

| 层次 | 它负责什么 | 它不负责什么 |

| --- | --- | --- |

| HTTP | 如何表达请求、如何传输、如何给响应 | 不会自动把请求体存成文件 |

| 上传约定 | 例如 multipart/form-data 如何组织字段和文件 | 不会决定文件最终放哪 |

| 存储系统 | 文件最终落在磁盘还是对象存储 | 不理解业务上的「上传成功」 |

HTTP 不会自动理解文件名,也不会自动把字节写成 avatar.png。解析和存储,都是应用层自己做的。上面的三层模型,主要发生在「构造请求」到「服务端解析」这一段。


第一层:浏览器里的文件,究竟是什么?

用户点了「选择文件」之后

页面上通常是一个:

html 复制代码
<input type="file" />

用户选中 avatar.png 后,浏览器不会立刻 把整个文件读进 JavaScript 内存。它先给页面一个 File 对象,表示「这段由浏览器管理的、不可变的二进制数据」,再附上文件名等元信息。

js 复制代码
const input = document.querySelector('input[type="file"]')

input.addEventListener('change', () => {

  const file = input.files[0]

  console.log(file.name) // avatar.png

  console.log(file.size) // 字节数

  console.log(file.type) // 浏览器推断的 MIME 类型

})

File 继承自 Blob。常见属性是:

| 属性 | 含义 |

| --- | --- |

| name | 文件名,不含用户本地完整路径 |

| size | 文件字节数 |

| type | MIME 类型,例如 image/png |

| lastModified | 最后修改时间的时间戳 |

注意:file.type 不是安全校验结果。它可能为空,也可能只是根据扩展名推断出来的。不能凭它断定文件真的是 PNG。

文件如何变成字节

假设有一个极小的文本文件,内容是:

text 复制代码
Hi

在 UTF-8 下,对应字节是:

text 复制代码
48 69 0A

#  H  i  换行

图片、视频、压缩包也一样,本质都是字节序列,只是各自遵循不同的文件格式。浏览器拿到 File 之后,还没有「上传」。真正进入 HTTP 之前,先要决定:这些字节以什么形式出现在请求体里。


第二层:文件内容如何表示

这一层只关心一件事:文件数据本身,是原样放进请求,还是先变成文本。

原始字节传输

不需要文本编码。文件字节直接进入 HTTP 请求体,不必先转成 Base64 或十六进制。

| 子类型 | 说明 | 典型 Content-Type |

| --- | --- | --- |

| 通用二进制 | 告诉服务器「这是一段字节」 | application/octet-stream |

| 文件专用 MIME | 进一步声明具体格式 | image/png、application/pdf |

| 自定义二进制类型 | 由服务端定义专用格式 | 业务约定的类型 |

对 Hello 五个字节来说,请求体里就是:

text 复制代码
48 65 6C 6C 6F

典型场景是直接上传单个文件。浏览器通常也不需要先把整个文件读成 JS 字符串,可以直接使用 File / Blob 提供的二进制数据。

文本编码传输

把文件字节转换成文本字符,再放入请求体。接口如果明确要求「字符串形式的文件内容」,就会走到这一层。

| 子类型 | 做法 | 体积变化 | 示例 |

| --- | --- | --- | --- |

| Base64 | 每 3 个字节通常编码为 4 个字符 | 约增加 33% | SGVsbG8= |

| 十六进制 Hex | 每个字节写成 2 个十六进制字符 | 约翻倍 | 48656c6c6f |

| 百分号编码 | 部分字节写成 %XX | 取决于内容 | Hello 或 %XX |

| 直接传文本 | UTF-8 文本文件直接作为请求体或 JSON 字段 | 基本不膨胀 | Hello |

Base64 很常见,但不是上传的默认路径。网页表单上传通常走原始字节;只有接口把文件当成字符串字段时,才需要先编码。

原始字节和文本编码,描述的是文件数据的表示方式。它们还没有回答:整个请求体里,除了文件,还怎么放用户名、文件名和其它业务参数。


第三层:HTTP 请求体如何组织

文件数据可以是原始字节,也可以是 Base64。接下来要决定:这些数据如何装进一个 HTTP 请求。

| 类型 | 子类型 | 说明 |

| --- | --- | --- |

| 单一数据体 | 直接二进制请求体 | 请求体直接放文件字节 |

| 单一数据体 | 直接文本请求体 | 请求体直接放文本 |

| 结构化数据体 | JSON | 文件内容或文件引用放在 JSON 字段中 |

| 结构化数据体 | multipart/form-data | 多个字段通过 boundary 组织 |

| 结构化数据体 | 自定义封装 | 按业务协议组织元数据与文件数据 |

网页表单上传最常见的是 multipart/form-data。直接传二进制也可以,但服务器必须另外知道:这段原始数据怎么识别、文件名从哪来。

为什么需要 multipart?

假设一次要提交用户名 jerry、头像 avatar.png、年龄 25。服务器必须知道:哪一段字节是用户名,哪一段是文件,哪一段是年龄。multipart 的思路很直接:给每个字段加边界和描述信息。

一个示意请求大概长这样:

http 复制代码
POST /api/upload HTTP/1.1

Host: example.com

Content-Type: multipart/form-data; boundary=----UploadBoundary123

Authorization: Bearer your-token

------UploadBoundary123

Content-Disposition: form-data; name="username"

jerry

------UploadBoundary123

Content-Disposition: form-data; name="file"; filename="avatar.png"

Content-Type: image/png

<这里是 avatar.png 的原始二进制字节>

------UploadBoundary123--

真实请求里,文件部分是原始字节,不是上面这种文字占位符。注意:multipart 内部可以放原始字节,也可以放文本字段。所以它属于「请求体怎么组织」,不属于「文件是不是 Base64」。

| 请求头 | 作用 |

| --- | --- |

| Content-Type | 整个请求体采用什么格式 |

| Content-Length | 如果存在,表示整个 HTTP 消息体长度(字节) |

| Authorization | 常用于携带认证凭据 |

| Host | HTTP/1.1 中标识目标主机 |

其中最重要的是:

http 复制代码
Content-Type: multipart/form-data; boundary=----UploadBoundary123

这句话不是 在说「我正在上传图片」,而是在说:我的请求体采用 multipart/form-data 格式,各个部分由指定的 boundary 分隔。对应规范是 RFC 7578。

Boundary 到底是什么?

如果请求头是 Content-Type: multipart/form-data; boundary=abc123,请求体里实际会出现:

http 复制代码
--abc123

Content-Disposition: form-data; name="username"

jerry

--abc123

Content-Disposition: form-data; name="file"; filename="a.png"

[文件字节]

--abc123--

几个容易混的细节:

  • 请求头里的 boundary=abc123 是边界值

  • 请求体分隔符要写成 --abc123

  • 最后一个分隔符再多两个 -,表示 multipart 内容结束

  • 分隔符前后还要遵循规范里的换行规则

如果文件内容碰巧包含 --abc123 怎么办?规范要求发送方选择一个不会和封装内容冲突 的 boundary。浏览器会自动生成它,前端一般不用自己设计。服务端也不是对整个请求体做一次字符串 split(),而是按 multipart 语法解析边界、Part 头部和二进制内容。

一个非常常见的坑:不要手动写 Content-Type

下面这种写法经常把上传写坏:

js 复制代码
const formData = new FormData()

formData.append('file', file)

await fetch('/api/upload', {

  method: 'POST',

  headers: {

    'Content-Type': 'multipart/form-data',

  },

  body: formData,

})

原因是:浏览器需要在请求头里写入它实际生成的 boundary,例如:

http 复制代码
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXYZ

请求体也必须使用同一个 boundary。你手动设了 Content-Type,却没带上正确的 boundary,服务器就可能解不开请求体。正确做法是把 Content-Type 交给浏览器:

js 复制代码
await fetch('/api/upload', {

  method: 'POST',

  body: formData,

})

MDN 的 Using FormData Objects 也是这个思路。

文件字节在哪?不在请求头里

很多初学者会以为:文件被放进了 HTTP 请求头,或者 FormData 会把文件转成 JSON。实际上,文件数据主要在 **HTTP 请求体(Request Body) **。HTTP 并不要求请求体必须是文本。它可以是二进制、JSON、表单编码,也可以是 multipart。

HTTP 消息边界 ≠ multipart 边界

| 边界 | 它在回答什么 |

| --- | --- |

| HTTP 消息边界 | 整个请求体在哪里结束 |

| multipart boundary | 这一个请求体内部,各个 Part 从哪里切开 |

在 HTTP/1.1 里,消息体长度可以用 Content-Length 确定;有些请求也会用分块传输编码(Transfer-Encoding: chunked)表示「消息体传完了」。但 chunked 的传输分块,不是业务上的文件分片。它们已经进入第三维度:传输策略。一个 100 MB 文件,即使按 HTTP/1.1 chunked 发送,仍然可能只是一个完整的 multipart 文件 Part。HTTP/2、HTTP/3 有各自的帧机制,同样不会自动实现断点续传。

网络负责把字节送到对端;multipart 负责在一个请求体里切开字段;分片上传是应用自己设计的协议。


第四层:服务端如何从请求里抽出文件?

前端发出去的不是一个现成的 JavaScript File 对象。服务端拿到的是网络上传过来的 HTTP 数据,然后自己解析。常见四步:

  1. 接收 HTTP 请求:解析请求行、请求头,确定请求体怎么读

  2. 解析请求体:单一数据体就直接读字节;multipart 则按 boundary 找到各个 Part

  3. 校验文件:权限、大小、格式、业务规则

  4. 写入存储:落到磁盘、对象存储,或其他存储系统

如果请求体是 JSON + Base64,服务端要先 JSON 解析,再 Base64 解码,才能得到原始文件字节。如果是 application/pdf 的单一数据体,则请求体本身就是文件。解析方式和前面的「表示方式 + 组织方式」是对应的。

为什么后端需要 multer 这类中间件?

以 Node.js + Express 为例,前端提交:

js 复制代码
const formData = new FormData()

formData.append('username', 'jerry')

formData.append('file', file)

await fetch('/api/upload', {

  method: 'POST',

  body: formData,

})

后端可以这样接:

js 复制代码
import express from 'express'

import multer from 'multer'

const app = express()

const upload = multer({

  dest: 'uploads/',

  limits: {

    fileSize: 10 * 1024 * 1024, // 10 MB

    files: 1,

  },

})

app.post('/api/upload', upload.single('file'), (req, res) => {

  console.log(req.body.username)

  console.log(req.file)

  res.json({ message: '上传成功' })

})

app.listen(3000)

这里:

  • upload.single('file') 表示从名为 file 的 multipart 字段里提取一个文件

  • req.body.username 是解析出来的普通字段

  • req.file 是中间件整理好的文件元信息

  • dest: 'uploads/' 指定落盘目录,具体存储方式由中间件配置决定

multer 不是 HTTP 协议的一部分,只是应用层的 multipart 解析器。没有它,后端也可以自己解析,但要正确处理 boundary、二进制、编码、异常请求和资源限制,复杂度会高很多。

req.file 是什么?

上传 avatar.png 后,req.file 可能类似:

js 复制代码
{

  fieldname: 'file',

  originalname: 'avatar.png',

  encoding: '7bit',

  mimetype: 'image/png',

  destination: 'uploads/',

  filename: '8f3a...c21',

  path: 'uploads/8f3a...c21',

  size: 58231

}

这只是示意结构,具体字段取决于中间件版本和存储配置。更重要的是:req.file 通常不是完整图片的字节数组,而是一份描述对象。文件内容可能已经写入磁盘,也可能暂存在内存,或者交给了别的存储适配器。后端拿到它之后,才决定如何保存、重命名、检查内容、生成访问地址。


第五层:文件如何真正保存下来?

「上传成功」不等于「文件已经进数据库」。大多数系统会把文件内容 和业务元数据分开保存。

存到服务器本地磁盘

例如写入 /app/uploads/8f3a-c21.png。操作系统通过文件系统接口把字节写进对应文件。文件系统负责目录、文件元数据、磁盘空间和数据块。Node.js 里,概念上可以写成:

js 复制代码
import { createWriteStream } from 'node:fs'

const output = createWriteStream('/app/uploads/avatar.png')

output.write(buffer)

output.end()

生产环境还要处理流错误、背压、权限、重名和写入完成事件。这里有一个很容易被忽略的区别:

**接收完成 ≠ 存储完成。 **

请求体的全部字节都到了,不代表文件已经可靠地写进最终位置。磁盘可能满了,写入可能失败,对象存储请求也可能报错。后端应该在必要的校验和存储都成功之后,再返回业务上的「上传成功」。

存到对象存储

大量图片、视频、附件,通常不会长期依赖单台 Web 服务器的本地磁盘。更常见的是对象存储,例如 Amazon S3、阿里云 OSS、腾讯云 COS。基本模型是:

  • 一个 Bucket:存储空间

  • 一个 Object Key :对象唯一标识,例如 avatars/user123/abc.png

  • 一份 对象内容:实际文件字节

  • 一组 元数据:大小、内容类型等

两条常见路径:浏览器把文件传给后端,后端再上传到对象存储;或者后端签发临时凭证,浏览器直接把文件传到对象存储。第二种能减轻应用服务器的带宽压力,但权限、回调、一致性都要自己设计。

为什么数据库通常只存地址?

数据库里往往是这样一条记录:

json 复制代码
{

  "id": 101,

  "userId": 123,

  "fileName": "avatar.png",

  "objectKey": "avatars/123/abc.png",

  "size": 58231,

  "contentType": "image/png"

}

objectKey 指向对象存储里的文件,而不是文件本身。好处很直接:文件内容和业务数据分离;文件可以通过 CDN 或对象存储分发;数据库不必承担大量图片、视频字节的存储和传输;权限、生命周期、删除策略、访问链接可以单独控制。数据库当然也能用 BLOB 直接存二进制,只是对很多大文件场景,对象存储更容易扩展。


第六层:传输策略,以及为什么大文件要换打法

前面两层决定了「文件长什么样、请求体怎么装」。这一层决定「怎么把这些字节送过去」。

| 类型 | 子类型 | 说明 |

| --- | --- | --- |

| 整体上传 | 单次请求上传完整文件 | 适合较小文件及普通场景 |

| 分块上传 | HTTP 分块传输编码 | HTTP/1.1 的消息传输机制,不等于文件分片 |

| 分片上传 | 文件拆成多个独立请求 | 常用于大文件、并发上传 |

| 可恢复上传 | 分片上传 + 进度记录 | 网络中断后可继续上传未完成部分 |

| 流式上传 | 边读取边发送 | 不一定需要先把整个文件载入内存 |

以 HTTP/1.1 over TCP 为例,数据路径可以理解为:

text 复制代码
JavaScript / Fetch

  传入 File、Blob 或 FormData

        │

        ▼

浏览器网络实现

  编码请求头和请求体,准备发送

        │

        ▼

HTTP 协议层

  组织 HTTP 消息,确定消息体边界

        │

        ▼

TCP / IP / 网络链路

  分段、传输、重组、保证可靠

        │

        ▼

服务端网络栈和 HTTP 服务器

  接收数据,交给应用处理

这是逻辑分层,不代表浏览器一定会先在内存里拼出一个巨大数组再一次性发出去。实际实现可以用缓冲区、分块读取、流式发送。TCP 也不认识「文件」,它只负责在连接上可靠地传输字节流。

为什么大文件需要分片、断点续传、秒传?

假设要上传一个 2 GB 视频。如果用最简单的单次 HTTP 上传,整个文件会作为一个请求的主体发出去。中途失败后,能不能从 73% 接着传,取决于你有没有设计额外的续传机制。**HTTP 本身不会因为请求失败,就自动记住上传进度。 ** 这就是业务层分片上传存在的原因。

文件 100 MB,每片 5 MB,前端可以拆成 20 片。JavaScript 的 Blob.slice() 可以引用文件的指定范围:

js 复制代码
const chunkSize = 5 * 1024 * 1024

const totalChunks = Math.ceil(file.size / chunkSize)

for (let index = 0; index < totalChunks; index++) {

  const start = index * chunkSize

  const end = Math.min(start + chunkSize, file.size)

  const chunk = file.slice(start, end)

  // 把 chunk 作为独立请求上传

}

这段代码只演示如何切片,还没实现上传、重试和合并。一个 20 MB 文件按 5 MB 分片,就是:

| 分片 | 范围 |

| --- | --- |

| 0 | 0--5 MB |

| 1 | 5--10 MB |

| 2 | 10--15 MB |

| 3 | 15--20 MB |

每个分片都可以单独上传、校验、重试,最后再按正确顺序组合。slice() 通常不会立刻把整个分片复制成一个新的 JS 数组,它返回的是一个新的 Blob 视图。数据何时真正读取、复制,由浏览器实现决定。

分片上传通常需要哪些接口?

一套常见的应用层设计是:

| 接口 | 作用 |

| --- | --- |

| POST /uploads/init | 创建上传任务,返回 uploadId |

| PUT /uploads/:id/parts/:index | 上传指定分片 |

| POST /uploads/:id/complete | 校验全部分片并完成合并 |

典型流程:浏览器计算文件大小、分片数量,创建上传任务;服务端生成 uploadId,记录元信息和状态;浏览器逐片或并发上传分片;服务端保存各分片,并记录哪些已经成功;中断后,浏览器查询已完成分片,只补传缺失的;全部分片到齐后,服务端按索引合并,并做完整性校验;确认最终文件可用,再返回上传成功。这是应用自己设计的协议,不是浏览器自动提供的 HTTP 功能。

断点续传为什么能工作?

关键在于服务端保存了上传进度。例如:

text 复制代码
uploadId: abc123

已完成分片: [0, 1, 2, 4, 5]

缺失分片:   [3, 6, 7, 8, 9]

页面重新打开后,客户端可以查询这个状态,只上传缺失分片。如果服务端没有记录,客户端就无法可靠知道:哪些分片已经持久化,哪些还得重发。生产系统还要考虑:任务过期、分片重复提交、并发竞争、权限校验,以及服务端崩溃后的状态恢复。

秒传不是「什么都不传」

秒传通常不是凭空完成上传,而是服务端发现对应内容已经存在,可以直接复用。常见做法是:客户端计算文件的 SHA-256,把哈希交给服务端。

text 复制代码
文件 A → SHA-256 → abcdef...

文件 B → SHA-256 → abcdef...

如果服务端确认已有相同内容,并且当前用户有权限引用它,就可以直接创建新的文件记录,不必重复传输全部字节。但哈希相同,本身不等于「可以安全复用」。还要结合可靠的哈希算法、已有对象的完整性校验、权限和业务规则。


同一个 PDF,四种完全不同的上传

分类是否站得住,最好用同一个文件来检验。假设要上传一份 report.pdf,下面四种组合都能完成这件事,只是三层维度的取值不同。

组合 A:原始字节 + 单一数据体 + 整体上传

http 复制代码
Content-Type: application/pdf

[PDF 原始字节]

组合 B:原始字节 + Multipart + 整体上传

http 复制代码
Content-Type: multipart/form-data; boundary=...

[文件字段 + PDF 原始字节 + 其他表单字段]

组合 C:Base64 + JSON + 整体上传

json 复制代码
{

  "fileName": "report.pdf",

  "data": "JVBERi0xLjc..."

}

组合 D:原始字节 + 分片上传 + 可恢复传输

把 PDF 切成多个二进制片段,分别上传,最后由服务端合并或确认完成。

同一份文件,可以是原始字节,也可以先变成 Base64;可以单独占据整个请求体,也可以塞进 multipart 或 JSON;可以一次传完,也可以拆成多次请求。遇到新接口时,先问三句话就够了:文件在请求里是字节还是文本?请求体是单一数据还是结构化数据?这次是整体传、分片传,还是边读边传?


进度条为什么会骗人?

前端常要显示:

text 复制代码
正在上传:65%

这个百分比,通常只表示:客户端已经发送、或上传组件已经报告的字节量 ,占待上传数据量的比例。真正要区分三个状态:浏览器已经发送了多少字节;服务器已经接收了多少字节;服务器已经成功校验并持久化了多少字节。这三个数字不一定相同。客户端可能已经把数据全部发出去了,服务端还在写磁盘,或者还在做病毒扫描。此时进度条到 100%,并不代表文件已经可用。传统浏览器上传里,相对可靠的客户端进度可以用 XMLHttpRequest:

js 复制代码
const xhr = new XMLHttpRequest()

xhr.open('POST', '/api/upload')

xhr.upload.onprogress = (event) => {

  if (event.lengthComputable) {

    const percent = Math.round((event.loaded / event.total) * 100)

    console.log(`客户端上传进度:${percent}%`)

  }

}

xhr.onload = () => {

  if (xhr.status >= 200 && xhr.status < 300) {

    console.log('服务器返回成功响应')

  } else {

    console.error('上传请求失败')

  }

}

xhr.onerror = () => {

  console.error('网络错误')

}

const formData = new FormData()

formData.append('file', file)

xhr.send(formData)

这只是浏览器报告的上传进度。如果要展示服务端真实处理进度,还需要状态查询接口,或其他进度通知机制。


从原理上看,上传最容易踩的坑

理解传输和存储之后,还要理解上传接口的安全边界。

1. 不能信任客户端提供的文件名

客户端完全可以把文件名改成:

text 复制代码
../../config.json

如果服务端直接拿用户提交的文件名去拼接磁盘路径,就可能造成路径穿越。更稳妥的做法是:由服务端生成存储名(例如 UUID),原始文件名只作为经过校验的展示元数据保存。

2. 不能只检查扩展名或 MIME 类型

用户可以把任意内容命名为 avatar.png,也可以手动伪造 Content-Type: image/png。服务端需要按业务风险做格式识别、大小限制和必要的内容检查。对公开提供下载的文件,还要考虑恶意文件、浏览器内容嗅探、访问权限。RFC 7578 也明确提醒:multipart 文件可能携带任意可执行内容,必须采取安全措施。

3. 上传成功,不代表文件已经可以安全访问

一个完整的上传业务,往往要经过:身份认证与上传权限检查;请求体大小限制;文件格式及内容校验;文件持久化;数据库元信息写入;访问权限与下载地址生成。例如:文件已经写入对象存储,但数据库记录创建失败,就会留下一个没有业务引用的孤立文件。实际系统还需要失败补偿、清理机制,以及重复请求的幂等性。


把整条链路串起来

  1. 选择文件 :浏览器创建 File 对象,提供大小、名称,以及访问二进制内容的能力。

  2. 决定表示方式:原样传输字节,还是先编码成 Base64 / Hex / 文本。

  3. 组织请求体:直接把数据放进请求体,或放进 multipart、JSON、自定义封装。

  4. 选择传输策略:一次传完、流式发送,或拆成多个可恢复的分片请求。

  5. 服务端解析 :按 Content-Type 和约定格式抽出文件字节。

  6. 校验并存储:检查权限、大小和内容,再写入本地磁盘或对象存储。

  7. 返回业务结果:确认必要处理成功后返回响应。前端根据响应判断这次上传业务是否完成。

各层职责可以记成一张表:

| 层次 | 核心职责 |

| --- | --- |

| 浏览器 API | 获取并引用文件内容 |

| 数据表示 | 决定文件以原始字节还是文本进入请求 |

| 请求体组织 | 描述文件与普通字段如何放在一起 |

| 传输策略 | 决定整体上传、分片还是流式发送 |

| HTTP | 传输请求、表达消息边界与响应 |

| TCP / IP 等传输机制 | 在网络中传输字节 |

| 后端解析器 | 从请求体中提取文件 |

| 存储系统 | 持久化文件字节 |

| 业务逻辑 | 决定谁能上传、文件是否合规、何时算成功 |


写在最后

如果只记住一句,就记住这句:

FormData 不等于文件本身;multipart 的 boundary 不等于 HTTP 消息边界;HTTP 传完也不等于文件已经存好。

再补一句分类口诀:

数据怎么表示、请求体怎么组织、传输怎么拆,是三件独立的事。Base64 不是 Multipart 的替代品,HTTP chunked 也不是文件分片。

把这几条边界分清楚,再去看 Blob、ArrayBuffer、对象存储直传和进度条,就不会再把「协议细节」和「业务设计」搅在一起。

相关推荐
骑着蜗牛撵大象3278 小时前
大模型 SDK 编译就崩?用 CMakeLists 把 httplib、JSON 与 OpenSSL 装配成可复用的 ChatServer
android·java·c++·http·json·cmake·大模型sdk
张赐荣1 天前
sslx: 一个检测、查看 HTTPS 证书信息的跨平台单文件命令行工具
网络协议·http·https
m0_639906941 天前
RESTful API 接口设计规范:资源导向、HTTP 语义、统一返回格式、避坑指南
http·restful·设计规范
洋不写bug1 天前
HTTP(二):万字解析HTTP交互的请求、响应报文格式
网络协议·http·交互·postman·请求格式·响应格式·restful规范
洋不写bug1 天前
HTTP(一):Fiddler下载、抓包请求响应解析
网络协议·http·fiddler·网络通信·抓包·http请求和响应
隐擎fox1 天前
解密 HTTP/2 帧时序与流控指纹:Akamai H2 指纹逆向检测与协议重塑工程实战
网络·网络协议·tcp/ip·http
艾莉丝努力练剑1 天前
【AI大模型接入SDK】Trae生成main方法
网络·人工智能·网络协议·http·架构·1024程序员节
青瓦梦滋2 天前
WebSocket协议
linux·服务器·网络·c++·websocket·网络协议·http
GIoT80102 天前
整段 C 段 IP 不能只看 Ping:一套实用的批量检测与分析思路
http