IT技术博客大学习 共学习 共进步

模拟HTML表单上传文件(RFC 1867)

老赵点滴 2011-04-27 23:49:47 浏览 2,983 次

如今使用HTTP协议定制API已经是十分常见的事情,在普通的GET和POST请求中传递些参数估计人人都会,但是如果我们需要上传文件呢?如果只是传递单个文件,那么将数据流POST给服务器端即可。但如果需要上传多个文件,或是在文件之外需要附带一些信息,那么又该怎么做呢?之前我遇到过一些朋友是这么打算的,他们说,不如就把文件流转化为文本,然后把它当作一个普通的字段传递。这么做自然可以“实现功能”,但缺点也很多。首先,将二进制流转化为文本会增大体积(例如最常见的BASE64编码会增大1/3的数据量);其次,既然互联网上存在相关的协议,又为何要自定义一套规则呢?其实这便是《RFC 1867 - Form-based File Upload in HTML》,它是我们用HTML表单上传文件时使用的传输协议,虽然十分常用,但似乎了解它的人并不多。

普通POST操作

说起HTML表单,大家绝对不会陌生。例如下面这样的HTML表单:

<form action="http://www.baidu.com/" method="post">
    <input type="text" name="myText1" /><br />
    <input type="text" name="myText2" /><br />
    <input type="submit" />
</form>

提交时会向服务器端发出这样的数据(已经去除部分不相关的头信息):

POST http://www.baidu.com/ HTTP/1.1
Host: www.baidu.com
Content-Length: 74
Content-Type: application/x-www-form-urlencoded

myText1=hello+world&myText2=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C

对于普通的HTML POST表单,它会在头信息里使用Content-Length注明内容长度。头信息每行一条,空行之后便是Body,即“内容”。此外,我们可以发现它的Content-Type是application/x-www-form-urlencoded,这意味着消息内容会经过URL编码,就像在GET请求时URL里的Query String那样。在上面的例子中,myText1里的空格被编码为加号,而myText2,您看得出这是“你好世界”这四个汉字吗?

使用POST上传文件

不过之前的HTML表单是无法上传文件的,因此RFC 1867应运而生,它的目的便是让HTML表单可以提交文件。它对HTML表单的扩展主要是:

  • 为input标记的type属性增加一个file选项。
  • 在POST情况下,为form标记的enctype属性定义默认值为application/x-www-form-urlencoded。
  • 为form标记的enctype属性增加multipart/form-data选项。

于是,如果我们要使用HTML表单提交文件,则可以使用如下定义:

<form action="http://www.baidu.com/" method="post" enctype="multipart/form-data">
    <input type="text" name="myText" /><br />
    <input type="file" name="upload1" /><br />
    <input type="file" name="upload2" /><br />
    <input type="submit" />
</form>

为了实验所需,我们创建两个文件file1.txt和file2.txt,内容分别为“This is file1.”及“This is file2, it's bigger.”。在文本框里写上“hello world”,并选择这两个文件,提交,则会看到浏览器传递了如下数据:

POST http://www.baidu.com/ HTTP/1.1
Host: www.baidu.com
Content-Length: 495
Content-Type: multipart/form-data; boundary=---------------------------7db2d1bcc50e6e

-----------------------------7db2d1bcc50e6e
Content-Disposition: form-data; name="myText"

hello world
-----------------------------7db2d1bcc50e6e
Content-Disposition: form-data; name="upload1"; filename="C:\file1.txt"
Content-Type: text/plain

This is file1.
-----------------------------7db2d1bcc50e6e
Content-Disposition: form-data; name="upload2"; filename="C:\file2.txt"
Content-Type: text/plain

This is file2, it's longer.
-----------------------------7db2d1bcc50e6e--

这段内容比较有趣,值得细细观察。首先,第一个空行之前自然还是HTTP头,之后则是Body,而此时的Body也比之前要复杂一些。根据RFC 1867定义,我们需要选择一段数据作为“分割边界”,这个“边界数据”不能在内容其他地方出现,一般来说使用一段从概率上说“几乎不可能”的数据即可。例如,上面这段数据使用的是IE 9,而我在Chrome下则是这样的:

POST http://www.baidu.com/ HTTP/1.1
Host: www.baidu.com
Content-Length: 473
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryW49oa00LU29E4c5U

------WebKitFormBoundaryW49oa00LU29E4c5U
Content-Disposition: form-data; name="myText"

hello world
------WebKitFormBoundaryW49oa00LU29E4c5U
Content-Disposition: form-data; name="upload1"; filename="file1.txt"
Content-Type: text/plain

This is file1.
------WebKitFormBoundaryW49oa00LU29E4c5U
Content-Disposition: form-data; name="upload2"; filename="file2.txt"
Content-Type: text/plain

This is file2, it's bigger.
------WebKitFormBoundaryW49oa00LU29E4c5U--

很显然它们两个选择了不同的数据“模式”作为边界――事实上,浏览器提交两次数据时,使用的边界也可能不会相同,这都没有问题。

选择了边界之后,便会将它放在头部的Content-Type里传递给服务器端,实际需要传递的数据便可以分割为“段”,每段便是“一项”数据。从上面的内容中大家应该都能看出数据传输的规范,因此便不做细谈了。只强调几点:

  • 数据均无需额外编码,直接传递即可,例如您可以看出上面的示例中的“空格”均没有变成加号。至于这里您可以看到清晰地文字内容,是因为我们上传了仅仅包含可视ASCII码的文本文件,如果您上传一个普通的文件,例如图片,捕获到的数据则几乎完全不可读了。
  • IE和Chrome在filename的选择策略上有所不同,前者是文件的完整路径,而后者则仅仅是文件名。
  • 数据内容以两条横线结尾,并同样以一个换行结束。在网络协议中一般都以连续的CR、LF(即\r、\n,或0x0D、Ox0A)字符作为换行,这与Windows的标准一致。如果您使用其他操作系统,则需要考虑它们的换行符

实现

了解上述策略之后,使用编程来实现文件上传也是顺理成章的事情,例如我这里便编写了一段简单的代码实现这一功能。

首先,我们定义一个Part类,表示每“段”,它的Write方法会写入整段数据。每段数据分为Header和Body两部分,使用WriteHeader和WriteBody两个抽象方法写入:

public abstract class Part
{
    protected abstract void WriteHeader(StreamWriter writer);
    protected abstract void WriteBody(StreamWriter writer);

    public void Write(StreamWriter writer)
    {
        this.WriteHeader(writer);
        writer.WriteLine();
        this.WriteBody(writer);
    }
}

接着便是表示普通字段的NormalPart和文件上传得FilePart:

public class NormalPart : Part
{
    public string Name { get; set; }
    public string Value { get; set; }

    protected override void WriteHeader(StreamWriter writer)
    {
        writer.WriteLine("Content-Disposition: form-data; name=\"{0}\"", this.Name);
    }

    protected override void WriteBody(StreamWriter writer)
    {
        writer.WriteLine(this.Value);
    }
}

public class FilePart : Part
{
    public string Name { get; set; }
    public string FilePath { get; set; }

    protected override void WriteHeader(StreamWriter writer)
    {
        writer.WriteLine(
            "Content-Disposition: form-data; name=\"{0}\"; filename=\"{1}\"",
            this.Name,
            Path.GetFileName(this.FilePath));

        writer.WriteLine("Content-Type: application/octet-stream");
    }

    protected override void WriteBody(StreamWriter writer)
    {
        var data = File.ReadAllBytes(this.FilePath);
        writer.Flush();
        writer.BaseStream.Write(data, 0, data.Length);
        writer.WriteLine();
    }
}

最后便是统一写入各段的Write方法,我在这里使用新建的GUID作为“边界”:

static void Write(StreamWriter writer, IEnumerable<Part> parts)
{
    var guidBytes = Guid.NewGuid().ToByteArray();
    var boundary = "----------------" + Convert.ToBase64String(guidBytes);

    foreach (var p in parts)
    {
        writer.WriteLine(boundary);
        p.Write(writer);
    }

    writer.WriteLine(boundary + "--");
}

其实就是这么简单。不过在实际情况中可能会复杂一些。例如,由于HTTP协议需要先发送头信息,因此我们需要提前计算出Content-Length再传输所有内容,不过我相信这对您来说也不会是件难事。

其他

世界上已经有了足够多的协议,在我看来在绝大部分情况下都无所谓使用自定义的协议。协议在制定时,往往也会考虑到安全、性能等诸多方面,有时候我们自己所谓的“顾虑”其理由也并不充分。更重要的是,使用现成的协议,我们往往都有现成的实现,对于开发和测试都会有很大帮助。

RFC 1867是一个很简单的协议,当然再简单也不是我这短短一篇文章可以完整描述的,其中很多细节(例如在同一个“段”中上传多个文件)就要靠您自己去挖掘了。

建议继续学习

  1. QQ上传大文件为什么这么快 (阅读 13,083)
  2. Web表单设计之注册表单 (阅读 8,722)
  3. 10个强大的Ajax jQuery文件上传程序 (阅读 8,725)
  4. 解决securecrt rz 上传rar,gif文件不正确问题 (阅读 7,782)
  5. PHP上传进度条深度解析 (阅读 5,925)
  6. Codeigniter里的无刷新上传 (阅读 5,423)
  7. 网页表单设计摘要 (阅读 5,321)
  8. jquery.form.js,JQuery表单插件 (阅读 4,963)
  9. PHP上传文件类型彻底判断方案及PHP+nginx上传大小彻底控制方案 (阅读 4,942)
  10. Web表单设计:表单结构 (阅读 4,843)