解析HTTP, 实现文件上传


本文的目的是简要说明如何编写一个文件上传组件,使他的功能类似 commons-fileupload, 并在结尾处提供了完整代码的获取方式。

HTTP

本文讨论的是基于 HTTP 协议的文件上传,下面先来看看 HTTP 请求的真面目。

首先,用 JavaSe 类库中的 Socket 搭建一个超简单的服务器,这个服务器只有一个功能,就是完整地打印整个 HTTP 请求体。

public class Server {

    private ServerSocket serverSocket;

    public Server() throws  IOException{
        serverSocket = new ServerSocket(8080);
    }

    public void show() throws IOException{
        while(true){
            Socket socket = serverSocket.accept();
            byte[] buf = new byte[1024];
            InputStream is =  socket.getInputStream();
            OutputStream os = new ByteArrayOutputStream();
            int n = 0;
            while ((n = is.read(buf)) > -1){
                os.write(buf,0,n);
            }
            os.close();
            is.close();
            socket.close();

            System.out.println(os);
        }
    }

    public static void main(String[] args) throws IOException {
        new Server().show();
    }

}

将服务器运行起来之后,在浏览器中输入地址:http://localhost:8080

在我的机器上,显示如下内容,可以看到,这个一个get请求

http-get

下面利用一个 html 的 form表单提交 post 请求

    <form action="http://localhost:8080" method="post" enctype="multipart/form-data">
       <input type="text" name="time" value="1970-01-01"/>
        <input type="file" name="file"/>
        <input type="submit"/>
    </form>

在我的机器上,显示如下内容

http-post

注意图中被红色框起来的部分,第一个红框指示了本次请求中,用来分隔不同元素的分隔线。

每个元素将以此分隔线作为第一行,后面紧跟对元素的描述,描述与内容用空行分隔。

分隔线的后面加两个小短横代表整个请求体结束,即EOF

我们需要做的工作,就是利用分隔线,从请求体中分离出每个元素,分析HTTP请求头的工作可以交给Servlet。

分析

那么,如何分离呢?

java中的 InputStream 只能读取一次,所以我们想要方便地分析一个流,最直接的办法就是将其缓存下来。

RandomAccessFile 或许能够满足需求,RandomAccessFile 可以提供一个指针用于在文件中的随意移动,然而需要读写本地文件的方案不会是最优方案。

先将整个流读一遍将内容缓存到内存中? 这种方案在多个客户端同时提交大文件时一定是不可靠的。

最理想的方案可能是,我只需要读一遍 InputStream , 读完后将得到一个有序列表,列表中存放每个元素对象。

很明显,JavaSe的流没有提供这个功能

我们知道从 InputStreeam 中获取内容需要使用 read 方法,返回 -1 表示读到了流的末尾,如果我们增强一下read的功能,让其在读到每个元素末尾的时候返回 -1,这样不就可以分离出每个元素了吗,至于判断是否到了整个流的末尾,自有办法。

设计

如何增强read方法呢?

read方法要在读到元素末尾时返回-1 , 一定需要先对已读取的内容进行分析,判断是否元素末尾。

我的做法是,内部维护一个buffer,read方法在读取时先将字节写入到这个buffer中,然后分析其中是否存在分隔线,然后将buffer中可用的元素复制到客户端提供的buffer。

这个内部维护的buffer并不总是满的,其中的字节来自read方法的原始功能,所以我们需要一个变量来记录buffer中有效字节的末尾位置 tail

我们还需要一个变量 pos 来标记buffer中是否存在分隔线,pos的值即为分隔线的开头在buffer中的位置,如果buffer中不存在分隔线pos的值将为-1。

但是问题没这个简单,分隔线在buffer中存在状态有两种情况:

情况A,分隔线完好地存在于buffer中,图中的bundary即为分隔线

boundary-A

情况B,分隔线的一部分存在于buffer中

boundary-B

在B情况下,boundary有多少字节存在于buffer中是不确定的,而且依靠这些不完整的字节根本无法判断他是否属于boundary开头。

例如,buffer中没有发现boundary,但是buffer末尾的3个字节与boundary开头相同,这种情况可能只是巧合,boundary并没有被截断。

对于这个问题,有一个解决办法,我们不必检查到buffer末尾,而是在buffer末尾留一个关健区pad

这个关健区中很有可能存在被截断boundary,每次检查到pad开头时立即收手,此位置之前的数据可以确保没有boundary,在下次填充buffer时,将这个关健区中的数据复制到buffer开头再处理。很显然,关健区pad长度应该等于boundary,如图:

pad

关键代码

在buffer中检查boundary

private int findSeparator() {
    int first;
    int match = 0;
    //若buffer中head至tail之间的字节数小于boundaryLength,那么maxpos将小于head,循环将不会运行,返回值为-1
    int maxpos = tail - boundaryLength;
    for (first = head; first <= maxpos && match != boundaryLength; first++) {
        first = findByte(boundary[0], first);
        if (first == -1 || first > maxpos) {
            return -1;
        }
        for (match = 1; match < boundaryLength; match++) {
            if (buffer[first + match] != boundary[match]) {
                break;
            }
        }
    }
    if (match == boundaryLength) {
        return first - 1;
    }
    return -1;
}

填充buffer

private int makeAvailable() throws IOException {
    //该方法在available返回0时才会被调用,若pos!=-1那pos==head,表示boundary处于head位,可用字节数为0
    if (pos != -1) {
        return 0;
    }

    // 将pad位之后的数据移动到buffer开头
    total += tail - head - pad;
    System.arraycopy(buffer, tail - pad, buffer, 0, pad);

    // 将buffer填满
    head = 0;
    tail = pad;
    //循环读取数据,直至将buffer填满,在此过程中,每次读取都将检索buffer中是否存在boundary,无论存在与否,都将即时返回可用数据量
    for (;;) {
        int bytesRead = input.read(buffer, tail, bufSize - tail);
        if (bytesRead == -1) {
            //理论上因为会对buffer不断进行检索,读到boundary时就会return 0,read方法将返回 -1,
            //所以不会读到input末尾,如果运行到了这里,表示发生了错误.
            final String msg = "Stream ended unexpectedly";
            throw new RuntimeException(msg);
        }
        if (notifier != null) {
            notifier.noteBytesRead(bytesRead);
        }
        tail += bytesRead;
        findSeparator();
        //若buffer中的数据量小于keepRegion(boundaryLength),av将必定等于0,循环将继续,直至数据量大于或等于keepRegion(boundaryLength).
        //此时将检索buffer中是否包含boundary,若包含,将返回boundary所在位置pos之前的数据量,若不包含,将返回pad位之前的数据量
        int av = available();

        if (av > 0 || pos != -1) {
            return av;
        }
    }
}

强化后的read方法

@Override
public int read(byte[] b, int off, int len) throws IOException {
    if (closed) {
        throw new RuntimeException("the stream is closed");
    }
    if (len == 0) {
        return 0;
    }
    int res = available();
    if (res == 0) {
        res = makeAvailable();
        if (res == 0) {
            return -1;
        }
    }
    res = Math.min(res, len);
    System.arraycopy(buffer, head, b, off, res);
    head += res;
    total += res;
    return res;
}

源码获取

我已经按照这套想法完整地实现了文件上传组件

有兴趣的朋友可以从我的Gighub获取源码 点我获取

使用方法:点我查看