HTTP 文件上传是 Web 开发中的常见需求,涉及到特殊的请求格式和处理机制。
一、HTTP 文件上传的核心协议
1. 两种主要方式
multipart/form-data
(主流)
支持二进制文件和表单字段混合传输,由Content-Type
头部标识。application/x-www-form-urlencoded
(传统表单)
仅支持文本数据,文件会被编码为 Base64(体积增大 33%),已逐渐淘汰。
2. 关键请求头
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 12345
boundary
:分隔符,用于标记不同表单字段的边界。Content-Length
:请求体总长度(字节)。
3.请求体
请求体包含了实际要上传的数据。对于文件上传,数据被分割成多个部分,每部分由两部分组成:一部分是头部,描述了该部分的内容(如字段名和文件名),另一部分是实际的文件内容。每个部分都以--boundary开始,并以--boundary--结束
关键规则:
字段头部与内容之间必须有空行
空行由\r\n\r\n
(CRLF CRLF)组成,是协议的硬性规定。分隔符与字段头部之间
分隔符后可以紧跟字段头部(无需空行),但实际请求中可能存在一个换行符(取决于客户端实现)。结束标记
最后一个分隔符必须以--
结尾(如-----------------------------1234567890--
)。
二、请求报文结构详解
1. 基础格式
POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=---------------------------1234567890
-----------------------------1234567890
Content-Disposition: form-data; name="textField"
Hello, World!
-----------------------------1234567890
Content-Disposition: form-data; name="file"; filename="example.txt"
Content-Type: text/plain
This is the file content.
-----------------------------1234567890--
2. 核心组成部分
- 分隔符(Boundary)
由Content-Type
中的boundary
指定,用于分隔不同字段。 - 字段头部(Headers)
Content-Disposition: form-data; name="file"; filename="example.txt" Content-Type: text/plain
name
:字段名(对应表单中的name
属性)。filename
:文件名(可选,仅文件字段需要)。Content-Type
:文件 MIME 类型(默认application/octet-stream
)。
- 字段内容(Body)
文件的二进制数据或文本值。
完整示例
POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123
Content-Length: 12345
------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="username"
JohnDoe
------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain
Hello, this is the content of test.txt.
------WebKitFormBoundaryABC123--
三、服务器处理流程
1. 解析步骤
- 读取
Content-Type
中的boundary
。 - 按分隔符分割请求体。
- 解析每个字段的头部和内容。
2. Node.js 示例(原生实现.学习使用 该案例未做安全防护,未做文件分割,大文件会导致内存溢出.)
const http = require('http');
const fs = require('fs');
const path = require('path');
const server = http.createServer((req, res) => {
if (req.method === 'POST') {
// 获取 boundary
const contentType = req.headers['content-type'];
const boundary = `--${contentType.split('boundary=')[1]}`;
const boundaryBuffer = Buffer.from(boundary);
// 存储完整请求体(二进制形式)
let requestBuffer = Buffer.from('');
// 收集所有数据块(二进制形式)
req.on('data', (chunk) => {
requestBuffer = Buffer.concat([requestBuffer, chunk]);
});
req.on('end', () => {
// 按 boundary 分割(使用二进制操作)
const parts = splitBuffer(requestBuffer, boundaryBuffer);
parts.forEach(part => {
// 分离头部和内容(二进制形式)
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) return;
const headersBuffer = part.slice(0, headerEnd);
const contentBuffer = part.slice(headerEnd + 4); // +4 跳过 \r\n\r\n
// 解析头部(转换为字符串)
const headers = parseHeaders(headersBuffer.toString());
// 如果是文件,保存到磁盘
if (headers.filename) {
// 移除内容末尾的 \r\n--
const endIndex = contentBuffer.indexOf('\r\n--');
const fileContent = endIndex !== -1
? contentBuffer.slice(0, endIndex)
: contentBuffer;
// 生成安全的文件名(添加时间戳)
const ext = path.extname(headers.filename);
const safeFilename = `${Date.now()}_${Math.random().toString(36).substring(2, 10)}${ext}`;
const savePath = path.join(__dirname, 'uploads', safeFilename);
// 直接写入二进制数据
fs.writeFile(savePath, fileContent, (err) => {
if (err) {
console.error('保存文件失败:', err);
res.statusCode = 500;
res.end('服务器错误');
}
});
console.log(`文件已保存: ${savePath}`);
}
});
res.end('上传完成');
});
} else {
//设置为utf-8编码
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<form method="post" enctype="multipart/form-data">
<input type="file" name="file">
<button type="submit">上传</button>
</form>
`);
}
});
// 按 boundary 分割 Buffer
function splitBuffer(buffer, boundary) {
const parts = [];
let startIndex = 0;
while (true) {
const index = buffer.indexOf(boundary, startIndex);
if (index === -1) break;
if (startIndex > 0) {
parts.push(buffer.slice(startIndex, index));
}
startIndex = index + boundary.length;
// 检查是否到达末尾
if (buffer.slice(index + boundary.length, index + boundary.length + 2).toString() === '--') {
break;
}
}
return parts;
}
// 解析头部信息
function parseHeaders(headerText) {
const headers = {};
const lines = headerText.split('\r\n');
lines.forEach(line => {
if (!line) return;
const [key, value] = line.split(': ');
if (key === 'Content-Disposition') {
const params = value.split('; ');
params.forEach(param => {
const [name, val] = param.split('=');
if (val) headers[name] = val.replace(/"/g, '');
});
} else {
headers[key] = value;
}
});
return headers;
}
// 创建上传目录
fs.mkdirSync(path.join(__dirname, 'uploads'), { recursive: true });
server.listen(3000, () => {
console.log('服务器运行在 http://localhost:3000');
});
四、 前端实现
- 原生表单:
<form action="/upload" method="post" enctype="multipart/form-data"> <input type="file" name="file"> <input type="submit"> </form>
- AJAX 上传(使用
FormData
):const formData = new FormData(); formData.append('file', fileInput.files[0]); fetch('/upload', { method: 'POST', body: formData });
五 总结
- 协议核心:
multipart/form-data
格式通过分隔符实现多字段传输。 - 安全要点:该案例不适合生产使用. 生产使用建议使用第三方库formidable