HTTP文件上传是如何工作的?

733

当我提交一个带有附件的简单表单时,例如:

<form enctype="multipart/form-data" action="http://localhost:3000/upload?upload_progress_id=12344" method="POST">
<input type="hidden" name="MAX_FILE_SIZE" value="100000" />
Choose a file to upload: <input name="uploadedfile" type="file" /><br />
<input type="submit" value="Upload File" />
</form>

它是如何内部发送文件的?文件是否作为数据作为HTTP正文的一部分发送?在此请求的标头中,我没有看到任何与文件名有关的内容。

我只是想了解在发送文件时,HTTP的内部工作原理。


我已经有一段时间没有使用过嗅探器了,但如果你想查看请求中发送了什么(因为它是发送到服务器的请求),可以使用嗅探器。这个问题太广泛了。SO更适合于具体的编程问题。 - paparazzo
就嗅探工具而言,Fiddler 是我首选的武器。你甚至可以构建自己的测试请求来查看它们的发布方式。 - Phil Cooper
对于那些感兴趣的人,也可以在https://dev59.com/Y3M_5IYBdhLWcg3wcCnc上查看有关PHP中“MAX_FILE_SIZE”的内容 - 这有什么意义。 - Pacerier
我觉得MAX_FILE_SIZE很奇怪。因为我可以在Chrome中将我的HTML修改为100000000,然后再发布它,这样就可以获得更好的值。要么1.使用带有安全哈希的cookie(通过盐),以便如果修改了cookie,服务器可以验证并抛出异常(像Webpieces或Playframework一样),要么使用某种表单验证来确保事情没有改变。@0xSina - Dean Hiller
5个回答

409

让我们看一下当您选择一个文件并提交表单时会发生什么(为了简洁起见,我已经截断了标头):

POST /upload?upload_progress_id=12344 HTTP/1.1
Host: localhost:3000
Content-Length: 1325
Origin: http://localhost:3000
... other headers ...
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryePkpFF7tjBAqx29L

------WebKitFormBoundaryePkpFF7tjBAqx29L
Content-Disposition: form-data; name="MAX_FILE_SIZE"

100000
------WebKitFormBoundaryePkpFF7tjBAqx29L
Content-Disposition: form-data; name="uploadedfile"; filename="hello.o"
Content-Type: application/x-object

... contents of file goes here ...
------WebKitFormBoundaryePkpFF7tjBAqx29L--
注意:每个分界字符串都必须以额外的--为前缀,就像在上一个分界字符串的末尾一样。上面的示例已经包含了这一点,但很容易被忽略。请参见@Andreas以下的评论。

与其将表单参数进行URL编码,表单参数(包括文件数据)作为多部分文档中的各个部分发送到请求正文中。

在上面的示例中,您可以看到输入的MAX_FILE_SIZE,其值设置在表单中,以及包含文件数据的部分。文件名是Content-Disposition标题的一部分。

详细信息请点击此处


8
@source.rar:不。网络服务器(几乎?)总是线程化的,以便能够处理并发连接。实质上,监听端口80的守护进程会立即将服务任务交给另一个线程/进程,以便它可以返回继续监听其他连接;即使两个入站连接恰好在同一时刻到达,它们也只会坐在网络缓冲区中,直到守护进程准备好读取它们。 - eggyal
15
线程解释有点不正确,因为有些高性能服务器设计为单线程,并使用状态机快速轮流下载连接的数据包。而在TCP/IP中,端口80是一个监听端口,而不是数据传输的端口。 - slebetman
10
当一个IP监听套接字(端口80)接收到一个连接时,会创建另一个套接字在另一个端口上,通常是在1000以上的随机数字端口。然后,该套接字被连接到远程套接字,使端口80空闲以便继续监听新的连接。 - slebetman
12
首先,这是关于HTTP的内容。FTP主动模式不适用于此。其次,监听套接字并不会在每个连接上被阻塞。您可以有多个连接到一个端口,只要对方有端口来绑定自己的端点就可以了。 - Slotos
47
请注意,作为 Content-Type 标头字段的一部分传递的边界字符串要比下面各个部分的边界字符串短 2 个字符。我刚刚花了一个小时试图弄清楚为什么我的上传程序不起作用,因为很难注意到第一个边界字符串实际上只有 4 条破折号,而其他边界字符串则有 6 条。换句话说:在使用边界字符串分隔各个表单数据时,它必须以两条破折号 -- 为前缀。当然,在 RFC1867 中已经有描述,但我认为这里也应该指出。 - Andreas
显示剩余5条评论

382
它是如何在内部发送文件的?
该格式称为 multipart/form-data,如所问:enctype='multipart/form-data'的含义是什么? 我将:
  • 添加更多HTML5参考
  • 解释为什么他使用表单提交示例是正确的

HTML5参考

enctype三种可能性

如何生成示例

一旦您看到每种方法的示例,就会明显它们的工作原理以及何时应该使用每一种方法。

您可以使用以下方式生成示例:

将表单保存为一个简化的.html文件:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8"/>
  <title>upload</title>
</head>
<body>
  <form action="http://localhost:8000" method="post" enctype="multipart/form-data">
  <p><input type="text" name="text1" value="text default">
  <p><input type="text" name="text2" value="a&#x03C9;b">
  <p><input type="file" name="file1">
  <p><input type="file" name="file2">
  <p><input type="file" name="file3">
  <p><button type="submit">Submit</button>
</form>
</body>
</html>

我们将默认文本值设置为a&#x03C9;b,这意味着aωb,因为ωU+03C9,在UTF-8中是61 CF 89 62字节。
创建要上传的文件:
echo 'Content of a.txt.' > a.txt

echo '<!DOCTYPE html><title>Content of a.html.</title>' > a.html

# Binary file containing 4 bytes: 'a', 1, 2 and 'b'.
printf 'a\xCF\x89b' > binary

运行我们的小回声服务器:

while true; do printf '' | nc -l localhost 8000; done

在浏览器中打开HTML,选择文件并单击提交,然后检查终端。

nc 打印接收到的请求。

测试环境:Ubuntu 14.04.3,nc BSD 1.105,Firefox 40。

multipart/form-data

Firefox 发送:

POST / HTTP/1.1
[[ Less interesting headers ... ]]
Content-Type: multipart/form-data; boundary=---------------------------735323031399963166993862150
Content-Length: 834

-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="text1"

text default
-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="text2"

aωb
-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="file1"; filename="a.txt"
Content-Type: text/plain

Content of a.txt.

-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="file2"; filename="a.html"
Content-Type: text/html

<!DOCTYPE html><title>Content of a.html.</title>

-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="file3"; filename="binary"
Content-Type: application/octet-stream

aωb
-----------------------------735323031399963166993862150--

对于二进制文件和文本字段,字节61 CF 89 62(在UTF-8中为aωb)会被直接发送。您可以使用nc -l localhost 8000 | hd进行验证,该命令会显示以下字节:

61 CF 89 62

被发送的内容为 (61 == 'a' and 62 == 'b')。

因此很明显:

  • Content-Type: multipart/form-data; boundary=---------------------------735323031399963166993862150 将内容类型设置为 multipart/form-data 并指定字段由给定的 boundary 字符串分隔。

    但请注意:

    boundary=---------------------------735323031399963166993862150
    

    比实际障碍物少两个破折号 --

    -----------------------------735323031399963166993862150
    

    这是因为标准要求边界以两个破折号 -- 开始。其他破折号似乎只是 Firefox 选择实现任意边界的方式。RFC 7578 明确提到需要这两个前导破折号 --

4.1. multipart/form-data的"Boundary"参数
与其他multipart类型一样,使用CRLF、"--"和"boundary"参数的值构造边界分隔符来分隔各个部分。
每个字段在其数据之前都会有一些子标题:Content-Disposition: form-data;,字段namefilename,然后是数据。
服务器读取数据直到下一个边界字符串。浏览器必须选择一个不会出现在任何字段中的边界,因此边界可能因请求而异。
由于我们有唯一的边界,因此不需要对数据进行编码:二进制数据按原样发送。
待办事项:最佳边界大小(我打赌是log(N)),以及找到它的算法的名称/运行时间? 在https://cs.stackexchange.com/questions/39687/find-the-shortest-sequence-that-is-not-a-sub-sequence-of-a-set-of-sequences上提问。 Content-Type由浏览器自动确定。
如何确定确切地被问到了:How is mime type of an uploaded file determined by browser?

应用程序/x-www-form-urlencoded

现在将enctype更改为application/x-www-form-urlencoded,重新加载浏览器并重新提交。

Firefox发送了:

POST / HTTP/1.1
[[ Less interesting headers ... ]]
Content-Type: application/x-www-form-urlencoded
Content-Length: 51

text1=text+default&text2=a%CF%89b&file1=a.txt&file2=a.html&file3=binary

很明显,文件数据没有被发送,只有基本名称。因此,这不能用于文件。

至于文本字段,我们可以看到通常的可打印字符如ab在一个字节中发送,而不可打印的字符如0xCF0x89每个字符占用了3个字节%CF%89

比较

文件上传通常包含大量的非打印字符(例如图像),而文本表单几乎从不包含。

从我们所见的例子中,我们可以得出以下结论:

  • multipart/form-data:将一些字节的边界开销添加到消息中,并必须花费一些时间计算它,但是将每个字节发送为一个字节。

  • application/x-www-form-urlencoded:每个字段具有单个字节边界(&),但对于每个非打印字符,会添加一个线性开销因子3倍

因此,即使我们可以使用application/x-www-form-urlencoded发送文件,我们也不希望这样做,因为这样会非常低效。
但对于在文本字段中找到的可打印字符,它并不重要且生成的开销较小,因此我们只需使用它。

2
你如何添加一个二进制附件?(例如小图片)- 我可以看到更改“Content-Disposition”和“Content-Type”属性的值,但如何处理“content”? - blurfus
3
在发送请求之前,浏览器会自动执行此操作。我不知道它使用了哪些启发式算法,但最有可能的是文件扩展名是其中之一。这可能回答了这个问题:https://dev59.com/oHM_5IYBdhLWcg3w2XBg - Ciro Santilli OurBigBook.com
关于浏览器如何确定“内容类型”,引用自“rfc1867”的一些引语:“如果已知媒体类型(例如,从文件扩展名或操作系统键入信息推断),则每个部分应标记为适当的内容类型,否则应标记为application/octet-stream。” - smwikipedia
3
谢谢@smwikipedia引用rfc并喜欢这个回答!关于用户名:对我来说,SO的精神在于每个人都应该随时拥有最好的信息。让我们将讨论限制在Twitter或元中。和平。 - Ciro Santilli OurBigBook.com
1
@KumarHarsh提供的信息不够详细,我认为无法回答。请提出一个新的超级详细问题。 - Ciro Santilli OurBigBook.com
显示剩余6条评论

86

以二进制形式发送文件(无需表单或FormData上传)

在给出的答案/示例中,文件(很可能)是通过HTML表单或使用FormData API上传的。文件只是请求中发送的数据的一部分,因此需要使用multipart/form-dataContent-Type头。

如果您想将文件作为唯一内容发送,则可以直接将其添加为请求正文,并将Content-Type标头设置为要发送的文件的MIME类型。文件名可以添加到Content-Disposition标头中。您可以像这样上传:

var xmlHttpRequest = new XMLHttpRequest();

var file = ...file handle...
var fileName = ...file name...
var target = ...target...
var mimeType = ...mime type...

xmlHttpRequest.open('POST', target, true);
xmlHttpRequest.setRequestHeader('Content-Type', mimeType);
xmlHttpRequest.setRequestHeader('Content-Disposition', 'attachment; filename="' + fileName + '"');
xmlHttpRequest.send(file);

如果您不想使用表单,并且只希望上传单个文件,则这是将文件包含在请求中的最简单方法。

更新:

在所有现代浏览器中,您现在还可以使用Fetch API进行(二进制)上传。与上面示例中提到的相同,代码如下:

const promise = fetch(target, { 
  method: 'POST', 
  body: file, 
  headers: {
    'Content-Type': mimeType,
    'Content-Disposition', `attachment; filename="${fileName}"`,
  },
});

promise.then(
  (response) => { /*...do something with response*/ },
  (error) => { /*...handle error*/ },
);

1
@AsleG 不,它只能发送单个文件作为请求内容。我不是 Asp.Net 专家,但你应该简单地从请求中提取内容(一个 blob),并使用头部的 Content-Type 将其保存到文件中。 - Wilt
@wilt 如果我不使用表单,但是我想使用formdata API,我可以这样做吗? - angry kiwi
你不需要使用表单来使用FormData API。你可以在你的JavaScript代码中附加不同的部分。但我不明白这与我在二进制上传方面的答案有什么关系... - Wilt
1
@AnkitKhettry 听起来像是通过表单或使用表单 API 上传的。你所提到的这些“奇怪的字符串”通常用于在服务器上将表单数据分成几个部分的表单边界。 - Wilt
1
@AsleG,您可以始终在URL中使用GET参数来获取附加数据。例如,如果您的上传URL为./upload.aspx,请使用GET参数将所需的任何数据添加到您的URL中,如./upload.aspx?user=userid&filetype=jpg,并在服务器上读取它。文件将使用POST方法发送,并且在同一请求中使用GET的其他参数。我知道这是旧评论,但也许将来有人会发现这很有用。 - Nicolo
显示剩余6条评论

18

我有这段示例Java代码:

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;

public class TestClass {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(8081);
        Socket accept = socket.accept();
        InputStream inputStream = accept.getInputStream();

        InputStreamReader inputStreamReader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
        char readChar;
        while ((readChar = (char) inputStreamReader.read()) != -1) {
            System.out.print(readChar);
        }

        inputStream.close();
        accept.close();
        System.exit(1);
    }
}

我有这个test.html文件:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>File Upload!</title>
</head>
<body>
<form method="post" action="http://localhost:8081" enctype="multipart/form-data">
    <input type="file" name="file" id="file">
    <input type="submit">
</form>
</body>
</html>

最后,我将用于测试的文件名为a.dat,其内容如下:

0x39 0x69 0x65

如果你将上面的字节解释为ASCII或UTF-8字符,它们实际上表示的是:

9ie

让我们运行Java代码,在我们最喜爱的浏览器中打开test.html,上传a.dat并提交表单,看看服务器收到了什么:

POST / HTTP/1.1
Host: localhost:8081
Connection: keep-alive
Content-Length: 196
Cache-Control: max-age=0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Origin: null
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.97 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary06f6g54NVbSieT6y
DNT: 1
Accept-Encoding: gzip, deflate
Accept-Language: en,en-US;q=0.8,tr;q=0.6
Cookie: JSESSIONID=27D0A0637A0449CF65B3CB20F40048AF

------WebKitFormBoundary06f6g54NVbSieT6y
Content-Disposition: form-data; name="file"; filename="a.dat"
Content-Type: application/octet-stream

9ie
------WebKitFormBoundary06f6g54NVbSieT6y--

看到9ie这些字符,我并不感到惊讶,因为我们让Java将它们作为UTF-8字符打印出来。您也可以选择将它们读取为原始字节。

Cookie: JSESSIONID=27D0A0637A0449CF65B3CB20F40048AF 

这实际上是HTTP头的最后一部分。在此之后是HTTP正文,我们上传的文件的元数据和内容实际上可以在其中看到。


你好,上面程序中的代码行 while ((readChar = (char) inputStreamReader.read()) != -1) { 是正确的吗?(int)(char)-1 实际上是 65535 - Daniele
@Daniele 应该是正确的。 - Koray Tugay

7
一个HTTP消息可能在头部之后有数据体。在响应中,这是返回请求的资源给客户端的地方(消息体最常见的用法),或者如果出现错误的话,可能是解释性文本。在请求中,这是用户输入的数据或上传的文件发送到服务器的地方。

http://www.tutorialspoint.com/http/http_messages.htm


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接