上传文件

用HTML表单上传文件

让我们再次通过 HTML 的例子来学习上传文件时的请求格式。先构建一个上传用的 HTML 页面:

<html>
  <head>
    <title>form upload test</title>
  </head>
  <body>
    <form
      action="http://localhost:8080/"
      method="post"
      enctype="multipart/form-data"
    >
      <div>username: <input name="username" /></div>
      <div>file1: <input type="file" name="file1" /></div>
      <div>file2: <input type="file" name="file2" /></div>
      <div><button type="submit">Submit</button></div>
    </form>
  </body>
</html>

从代码中我们可以注意到上传文件所需的要求:

  • 方法为POST
  • 编码方式multipart/form-data

表单中还增加了2个type属性为fileinput元素,其表现形式为文件选择框,它允许用户选择本地文件用于上传

再准备2个文件用于上传测试:

$ cat << EOF > /tmp/file1.txt
line 1 of file 1
line 2 of file 1
line 3 of file 1
EOF

$ cat << EOF > /tmp/file2.txt
line 1 of file 2
line 2 of file 2
line 3 of file 2
EOF

用浏览器打开HTML页面,填写文本字段,点击文件输入框选择相应文件,如下图所示:

上传文件的表单

提交后得到如下回显:

================================
Request 5
================================

POST / HTTP/1.1
Host: localhost:8080
Content-Length: 530
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjJii9Dn6GejdGMjK
(略)

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

Tom&Jerry
------WebKitFormBoundaryjJii9Dn6GejdGMjK
Content-Disposition: form-data; name="file1"; filename="file1.txt"
Content-Type: text/plain

line 1 of file 1
line 2 of file 1
line 3 of file 1

------WebKitFormBoundaryjJii9Dn6GejdGMjK
Content-Disposition: form-data; name="file2"; filename="file2.txt"
Content-Type: text/plain

line 1 of file 2
line 2 of file 2
line 3 of file 2

------WebKitFormBoundaryjJii9Dn6GejdGMjK--

从中可以发现一些特点:

  • Content-Typemultipart/form-data,与HTML表单中声明的enctype属性值一致,且跟随一个随机的boundary用于多个字段间的分割字符串。
  • 每个字段都占用一个part,part以--+boundary分隔符开始,用于和上一个part分割
  • 上一个part末尾和下一个part开始之前的换行符仅用于part之间的分割,不是字段内容的一部分
  • 最后一个part之后是--+boundary分隔符+--,代表之后没有更多part了
  • 每个part都有自己的私有头部
  • 私有头部Content-Disposition值为form-data,其后跟随name表明字段名称
  • 由于一个part仅表示一个字段,且字段名称已在私有头部体现,part的主体部分就无需额外编码了,直接以原始形式表示。
  • 文件字段的Content-Disposition头会有一个额外的filename属性用于表示用户上传时的本地文件名。在有后端逻辑的服务器上,可以把文件保存到存储设备上(丢弃文件名或按内部实现重命名),然后在UI界面上依旧显示用户之前上传时使用的文件名
  • 文件字段的值即文件的内容,所以可以是任意的二进制字节序列,并不仅限于可打印字符,通过回显服务器回显有可能会得到乱码
  • 显然文件本身的内容不能和boundary的值有冲突,否则part会意外中断,后续数据格式不合法从而导致解析失败。浏览器会通过一定的算法最大限度地保证分隔符的唯一性

用curl上传表单

手动构造multipart/form-data

根据HTML提交的上传内容,我们可以手动构造curl上传文件请求的底层数据。先将回显的主体部分(首个--+boundary分隔符到末尾)的内容保存成一个外部数据文件:

$ cat << EOF > /tmp/body.txt
------WebKitFormBoundaryjJii9Dn6GejdGMjK
Content-Disposition: form-data; name="username"

Tom&Jerry
------WebKitFormBoundaryjJii9Dn6GejdGMjK
Content-Disposition: form-data; name="file1"; filename="file1.txt"
Content-Type: text/plain

line 1 of file 1
line 2 of file 1
line 3 of file 1

------WebKitFormBoundaryjJii9Dn6GejdGMjK
Content-Disposition: form-data; name="file2"; filename="file2.txt"
Content-Type: text/plain

line 1 of file 2
line 2 of file 2
line 3 of file 2

------WebKitFormBoundaryjJii9Dn6GejdGMjK--
EOF

然后用curl提交,指定Content-Type,注意boundary要和数据文件中的保持一致。

为避免curl将数据合并成一行(模拟HTML表单提交行为),这里需要用--data-binary而不是--data以保证curl不对数据作额外处理。

$ curl \
-X POST \
-H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjJii9Dn6GejdGMjK' \
--data-binary @/tmp/body.txt \
http://localhost:8080

回显内容与之前通过HTML上传的一致。

通过curl选项自动构造multipart/form-data

直接构造底层数据太过于繁琐且不灵活,curl提供了-F--form选项来方便地构造multipart/form-data,且请求自动以POST方法发出。

注意引用外部文件的格式,字段名后跟随=,然后才是@data_file,而其他指定外部数据的选项格式中不包含=

$ curl \
-F 'username=Tom&Jerry' \
-F 'file1=@/tmp/file1.txt' \
-F 'file2=@/tmp/file2.txt;filename=second.txt' \
-F 'file3="file3 content";filename=file3.txt' \
-F 'file4="<html><body><p>hello</p></body></html>";filename=file4.html;type=text/html' \
http://localhost:8080

================================
Request 3
================================

POST / HTTP/1.1
Host: localhost:8080
Accept: */*
Content-Length: 910
Content-Type: multipart/form-data; boundary=------------------------fsssj9Z51j8F20ehccWw2K
User-Agent: curl/8.10.0

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

Tom&Jerry
--------------------------fsssj9Z51j8F20ehccWw2K
Content-Disposition: form-data; name="file1"; filename="file1.txt"
Content-Type: text/plain

line 1 of file 1
line 2 of file 1
line 3 of file 1

--------------------------fsssj9Z51j8F20ehccWw2K
Content-Disposition: form-data; name="file2"; filename="second.txt"
Content-Type: text/plain

line 1 of file 2
line 2 of file 2
line 3 of file 2

--------------------------fsssj9Z51j8F20ehccWw2K
Content-Disposition: form-data; name="file3"; filename="file3.txt"
Content-Type: text/plain

file3 content
--------------------------fsssj9Z51j8F20ehccWw2K
Content-Disposition: form-data; name="file4"; filename="file4.html"
Content-Type: text/html

<html><body><p>hello</p></body></html>
--------------------------fsssj9Z51j8F20ehccWw2K--
  • -F 'username=Tom&Jerry'构造了一个字段,字段名为username,它没有文件名属性
  • -F 'file1=@/tmp/file1.txt'构造了一个文件字段,字段名为file1,值从/tmp/file1.txt加载,文件名属性由curl自动推导,为file1.txt
  • -F 'file2=@/tmp/file2.txt;filename=second.txt'构造了一个文件字段,字段名为file2,值从/tmp/file2.txt加载,显式指定文件名属性为second.txt
  • -F 'file3="file3 content";filename=file3.txt'构造了一个文件字段,字段名为file3,值为file3 content,显式指定文件名属性为file3.txt
  • -F 'file4="<html><body><p>hello</p></body></html>";filename=file4.html;type=text/html'构造了一个文件字段,字段名为file4,值为HTML源代码,显式指定文件名属性为file4.html,且通过type属性指定了私有头部Content-Type: text/html

需要注意的是,目前网上许多网盘服务并不使用标准的multipart/form-data格式上传文件,他们一般有定制的私有协议。

上传文件到EHFS服务器

为了验证我们构造的multipart/form-data数据格式正确,这次使用第二款用于练习的服务器:EHFS接受文件上传,看看上传后的文件是否有效。

假设我们将把本地目录/tmp/upload用于共享,且允许用户上传文件:

$ mkdir /tmp/upload
$ ehfs --listen 8081 --root /tmp/upload --upload /

再打开一个新的终端,先确认目录中没有文件:

$ ls /tmp/upload/

然后使用file字段指定要上传的文件:

$ curl \
-F 'file="hello world";filename=file1.txt' \
-F 'file="foo bar";filename=file2.txt' \
'http://localhost:8081/?upload'

验证文件是否正确上传:

$ ls /tmp/upload/
file1.txt  file2.txt

$ cat /tmp/upload/file1.txt
hello world
$ cat /tmp/upload/file2.txt
foo bar

注意由于上传文件内容中没有换行符,显示文件内容会和下一行shell提示符黏连在一起。