1.11 HTTP 文件上传的核心协议

发布于:2025-06-13 ⋅ 阅读:(15) ⋅ 点赞:(0)

 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--结束

关键规则

  1. 字段头部与内容之间必须有空行
    空行由 \r\n\r\n(CRLF CRLF)组成,是协议的硬性规定。

  2. 分隔符与字段头部之间
    分隔符后可以紧跟字段头部(无需空行),但实际请求中可能存在一个换行符(取决于客户端实现)。

  3. 结束标记
    最后一个分隔符必须以 -- 结尾(如 -----------------------------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. 解析步骤
  1. 读取 Content-Type 中的 boundary
  2. 按分隔符分割请求体。
  3. 解析每个字段的头部和内容。
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