小Cの已经记不起来的博客

从零实现一个简易的文件上传

前两天折腾一个小工具,有个特别朴素的需求:网页上选个文件,传到服务器存下来就行。按理说这事儿不大,但真动手的时候发现,网上的教程要么上来就是 Express 加 multer 一整套端上来,要么直接塞给你一个现成组件,为了传个文件引一堆依赖,总觉得有点杀鸡用牛刀。索性从零手写一个,顺便把浏览器上传文件时到底发了些什么东西搞清楚,免得下次再踩上传相关的坑,还是两眼一抹黑。

先看看浏览器到底发了啥

写代码之前,先看看这玩意儿长什么样,不然基本就是闭着眼睛写。最省事的办法是用 nc 起一个监听:

nc -l 3000

Mac 和 Linux 一般都自带,Windows 没准儿得自己装一下,不想折腾的话用浏览器 F12 的 Network 面板看也行,就是没那么直观。

然后随便找个地方建一个 index.html,内容就一个表单:

<form action="http://localhost:3000/upload" method="post" enctype="multipart/form-data">
  <input type="file" name="file" />
  <button type="submit">上传</button>
</form>

注意 enctype="multipart/form-data" 必须带上,表单默认的编码是 application/x-www-form-urlencoded,那种格式是装不下文件内容的。我第一次写的时候就把它忘了,后端死活收不到文件,盯着代码看了半天,结果问题出在表单上...

用浏览器打开这个页面,选一个小的文本文件(千万别选大文件,终端会给你刷出一堆乱码),点上传,nc 那边就能看到浏览器发出来的原始请求了,页面上报错不用管,反正服务器还没写,咱只是来偷看请求的。大概是这个样子(无关紧要的头我删掉了):

POST /upload HTTP/1.1
Host: localhost:3000
Content-Length: 208
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryX3xJ6j2Z8fQ7nNpT

------WebKitFormBoundaryX3xJ6j2Z8fQ7nNpT
Content-Disposition: form-data; name="file"; filename="hello.txt"
Content-Type: text/plain

hello upload
------WebKitFormBoundaryX3xJ6j2Z8fQ7nNpT--

看明白这个结构,后面就简单了。整个请求体是靠 boundary 切开的:Content-Type 里声明了 boundary 的值,但正文里每条分隔线前面都要多加两个 -,最后一条还要在结尾再补一个 -- 表示结束,第一次看很容易在这里犯迷糊。两条分隔线中间就是「一部分头部 + 空行 + 文件内容」,文件名就藏在 Content-Disposition 那一行里。

手写服务端

后端我用 Node 写,不引任何第三方库,就用官方的 http、fs、path 这仨模块,直接看代码:

const http = require('http');
const fs = require('fs');
const path = require('path');

const UPLOAD_DIR = path.join(__dirname, 'uploads');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR);

// Buffer 没有 split,只能自己写一个
function splitBuffer(buf, sep) {
  const parts = [];
  let start = 0;
  let i;
  while ((i = buf.indexOf(sep, start)) !== -1) {
    parts.push(buf.slice(start, i));
    start = i + sep.length;
  }
  parts.push(buf.slice(start));
  return parts;
}

const server = http.createServer(async (req, res) => {
  if (req.method === 'GET') {
    // GET 就直接把页面返回,一个端口全搞定
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    fs.createReadStream(path.join(__dirname, 'index.html')).pipe(res);
    return;
  }

  if (req.method === 'POST' && req.url === '/upload') {
    // 把请求体完整收下来
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const body = Buffer.concat(chunks);

    // 从 Content-Type 里把 boundary 抠出来,正文里的分隔线前面还要多两个 -
    const boundary = '--' + req.headers['content-type'].split('boundary=')[1];
    // 第一段是第一条分隔线前面的空内容,最后一段是结尾的 --,都不用管
    const parts = splitBuffer(body, Buffer.from(boundary)).slice(1, -1);

    for (const part of parts) {
      const i = part.indexOf('\r\n\r\n');
      const header = part.slice(2, i).toString();  // 掐掉开头的 \r\n
      const content = part.slice(i + 4, -2);       // 掐掉结尾的 \r\n,剩下的才是文件内容
      const matched = /filename="(.*)"/.exec(header);
      if (!matched || !matched[1]) continue;       // 没选文件或者不是文件字段,跳过
      const name = Date.now() + '-' + path.basename(matched[1]); // 时间戳防覆盖,basename 防路径穿越
      fs.writeFileSync(path.join(UPLOAD_DIR, name), content);
      console.log('已保存:', name, content.length + '字节');
    }

    res.end('上传成功');
    return;
  }

  res.statusCode = 404;
  res.end('Not Found');
});

server.listen(3000, () => console.log('listening on http://localhost:3000'));

代码不多,挑几个地方说一下。for await (const chunk of req) 是把请求体一点一点收下来,最后用 Buffer.concat 拼成完整的 Buffer,这是 Node 里收请求体最朴素的写法。

重点是解析那几行。splitBuffer 是因为 Buffer 没有 split 方法,只好自己写一个,逻辑很简单:不停用 indexOf 找分隔线的位置,切一段,接着找。切完之后的数组里,第一段是第一条分隔线前面的空内容,最后一段是结尾的 --,都用不上,所以 slice(1, -1) 直接掐头去尾。

剩下的每一段,去掉分隔线之后长这样:

\r\n
Content-Disposition: form-data; name="file"; filename="hello.txt"\r\n
Content-Type: text/plain\r\n
\r\n
(这里是文件的二进制内容)\r\n

结构就是「开头一个 \r\n + 头部 + 空行 + 内容 + 结尾一个 \r\n」。所以代码里 slice(2, i) 是掐掉开头的 \r\n,slice(i + 4, -2) 是掐掉结尾的 \r\n,中间那块才是真正的文件内容。头部和内容的分界是一个空行,也就是 \r\n\r\n,跟 HTTP 报文里头和身体的分隔方式一个套路。

没有匹配到 filename 的是普通表单字段,直接跳过;有 filename 的就是文件,取出名字写盘完事。写盘前用 path.basename 过一道,防止有人构造一个带 ../ 的文件名,把文件写到 uploads 目录外面去。

几个不能省的细节

功能到上面就已经能跑了,但有几个细节还是得说一嘴。

一个是同名覆盖。连着传两次同一个文件,后一次会直接把前一次盖掉,所以我在代码里给文件名加了个 Date.now() 前缀,粗暴但管用。

一个是内存。这套方案是把整个请求体一口气读进内存再解析,传头像、传几兆的附件没问题,但你要是拿它传几个 G 的大文件,服务器内存直接就交代了。大文件得流式处理,边收边往磁盘上写,那就是另一个话题了。

还有一个是 boundary 撞车。理论上文件内容里也可能出现一模一样的 boundary 字符串,真出现了解析就乱了。不过 boundary 是浏览器随机生成的,正常情况撞不上,我就不操这个心了。要做到严谨,得用状态机一边读一边解析,那些现成的库干的主要就是这个活。

另外整段代码基本没什么防御,比如没校验 Content-Type 是不是 multipart,没限制文件大小和类型,自己玩无所谓,真要用记得补上。

跑起来看看

把 index.html 和 server.js 放同一个目录,然后:

node server.js

打开 http://localhost:3000,选个文件,点上传。不出问题的话,页面会显示「上传成功」,终端里打一行日志,uploads 目录下就能看到刚传的文件了。

整套东西两个文件加起来几十行,一个依赖都没引,传传头像、传传几兆的附件完全够用。当然,真要放到生产环境里,还是老老实实上 multer 这类轮子吧,各种边边角角人家都替你踩过了。不过自己动手拆一遍之后再去看那些库的文档,至少能知道每个配置项到底在替你干什么,这波不亏。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放