重要声明:本文章仅仅代表了作者个人对此观点的理解和表述。读者请查阅时持自己的意见进行讨论。

一、文件上传的方式

在程序的世界里,没有什么功能的实现方式是单一的。上传文件也不例外,我们有很多种能够实现文件上传的方法。但我们最终要采用的,必然是最熟悉、最常用的方法。文件上传通常有下面的方法进行:

  1. 将内容进行base64,将base64字符串结果通过普通请求提交给后台。
  2. 直接使用浏览器的form表单进行文件上传。
  3. 使用java模拟表单(自己封装表单格式数据)进行文件上传。

这些文件上传方式,都是基于HTTP协议的基础,它并没有强制限定了我们要如何去编码或处理数据,相反协议的存在使世界上所有的程序差异化不是那么太大。HTTP协议规定了,提交大量数据时,可以使用POST请求来进行提交。是的,大多数后台程序都只会在POST请求下去获取大量上传的数据,而不会在GET请求下去获取。你要知道,如果你不规范提交,当然也是可以不规范的从GET下获取到提交的数据。但极其不建议这样做。

二、base64 上传文件

1、HTML网页内使用base64

如果你要在网页中使用base64的方式将文件提交给后台,那么你不得不面临一个很严峻的问题就是:如何在网页中奖文件内容读取并转换成base64的数据。很幸运HTML5为我们提供了解决方案。

FileReader提供了读取文件的能力。但它可不是你想象的提供一个文件路径就可以读取了。它能读取的文件需是来自下面的途径:

  1. 通过<input type="file">节点选择的文件。
  2. 通过拖放(DataTransfer)文件事件得到的文件。
  3. Canvas通过mozGetAsFile()方法返回的文件。

下面以input选择的文件为例进行讲解。

a、选择文件并转换为base64

直接在html文件中写一个input并将其type设定为file即可立即实现一个文件选择功能。现在同时给出读取文件的示列代码:

<body>
    <!-- 文件选择和上传按钮 -->
    <input type="file" id="fileSelecter"><button onclick="uploadFile()">上传</button>

    <!-- 上传逻辑 -->
    <script>
        function uploadFile() {

            // 找到文件文件选择框
            var fileInput = document.querySelector("#fileSelecter");

            // 获取选择的文件
            // (因为input是支持选择多个文件的,所以获取文件通过files字段,如果单个文件也是在这个files列表里。)
            var file = fileInput.files.item(0);

            // 判断一下
            if (file == null) {
                // 没有选择文件。就什么都不处理。
                return;
            }

            // 使用FileReader读取文件。
            var fileReader = new FileReader();

            fileReader.addEventListener("error", function (ev) {
                // 文件读取出错时,执行此方法。
                // 通过 fileReader.error 可以获取到错误信息。
            });

            fileReader.addEventListener("load", function (ev) {
                // 文件读取成功后调用此方法。
                // 通过 fileReader.result 即可获取到文件内容。
            });

            fileReader.addEventListener("loadstart", function (ev) {
                // 读取开始时此方法被调用。
            });

            fileReader.addEventListener("loadend", function (ev) {
                // 文件读取结束时执行此方法。
                // 无论读取成功,还是读取失败。
                // 总之,在结束读文件操作时,此方法都会调用。
            });

            fileReader.addEventListener("abort", function (ev) {
                // 文件读取被中断时,此方法调用。
                // 你可以通过 fileReader.abort() 方法随时中断文件的读取。
            });

            fileReader.addEventListener("progress", function (ev) {
                // 读取文件过程不是一次性读完的,会进行多次读取。
                // 没读取一次,本方法执行一次。
            });

            // 将文件内容读取为 base64 内容。通过 fileReader.result 即可返回base64的数据内容。
            fileReader.readAsDataURL(file);
        }
    </script>
</body>

上述代码中详细注释了使用FileReader如何读取文件的使用方式。通过监听load事件,即可获取到文件读取的结果数据。如果你将这个结果打印出来,你将看到类似下面的数据:

data:image/jpeg;base64,/9j/4AAQSkZJergxawffwetcwgIKFUEGNiwgefnwkef...KCAcHCg0

如果你看到了类似的数据。那么你就算是成功一半了。

注意: 其中data:image/jpeg;base64,这一段属于对base64数据体的描述。文件真实base64内容只有逗号后面部分。如果后端接口明确告知你只需要传递后面的数据,那么你还需要自行将前面的描述去除。

b、上传结果到接口

现在建立请求将得到的结果提交到服务器即可。在load监听事件里我们可以成功的获取到base64的结果,因此只需要将提交数据代码写在监听里即可。这里我直接使用JQuerypost方法进行数据提交。代码如下:

fileReader.addEventListener("load", function (ev) {
    // 文件读取成功后调用此方法。
    // 通过 fileReader.result 即可获取到文件内容。
    var result = fileReader.result;
    $.post("https://www.microanswer.cn/test/uploadBase64", {
        base64Data: result
    }, function (response) {
        // 服务器响应了我们的上传请求。
    });
});
2、Java程序内使用base64

在使用java作为后台程序的时候,有时候也经常通过http向其他服务器上传内容,那如果是使用base64方式,如何将数据以base64提交呢?其实非常简单,和上一小节里提到的BASE64Decoder有点类似,Java里还有一个类BASE64Encoder,他可以方便的将文件转为一个base64字符串,看下面的示列代码:

// 将文件转为base64字符串
public String file2Base64(String filePath) {
    // 建立文件对象
    File file = new File(filePath);
    BASE64Encoder base64Encoder = new BASE64Encoder();
    ByteArrayOutputStream out = null;
    FileInputStream fileInputStream = null;
    // 标准流读取代码模板。
    try {
        out = new ByteArrayOutputStream();
        fileInputStream = new FileInputStream(file);
        byte[] data = new byte[1024];
        int datasize = 0;
        while ((datasize = fileInputStream.read(data)) != -1) {
            out.write(data, 0, datasize);
        }
        out.flush();
    } finally {
        try {
            if (fileInputStream != null) {
                fileInputStream.close();
            }
        }catch (Exception ignore) {}
    }
    return base64Encoder.encode(out.toByteArray());
}

有了base64字符串,就可以方便的进行上传了。相信后台程序一般都会有一个封装好了的网络请求工具类,下面示列一个典型的上传代码:

String base64 = file2Base64("D:/temp/test.doc");
HashMap<String, String> param = new HashMap<>();
param.put("base64Data", base64);
String response = HttpUtil.postFormUrlEncode("https://www.microanswer.cn/test/uploadBase64", param);

此处使用的HttpUtil工具你可以通过下面的maven依赖获取:

<dependency>
    <groupId>cn.microanswer</groupId>
    <artifactId>HttpUtil</artifactId>
    <version>1.0.1</version>
</dependency>
3、后台接口

上述请求中,将数据以 base64Data 字段提交给了接口。现在只需要处理服务器响应后的事情了。先看看接口是如何获取数据的把:

@RequestMapping("/uploadBase64")
public Object uploadBase64Data(HttpServletRequest request) throws Exception {
    String base64Data = request.getParameter("base64Data");

    // 保存文件后缀名。
    String fileExtName = "";
    // 保存文件真实的base64数据
    String realBase64;

    // 判断是否有前缀
    if (base64Data.startsWith("data:") && base64Data.contains("base64,")) {
        String[] typeAndData = base64Data.split(",");

        // 截取 data:image/jpeg;base64, 为=>  jpeg;base64,
        String tempType = typeAndData[0].split("/")[1];
        // 拿到 ; 号前面的内容作为文件后缀。
        fileExtName = "." + tempType.substring(0, tempType.indexOf(";"));

        // 拿到真实base64数据。
        realBase64 = typeAndData[1];
    } else {
        // 没有前缀,则认为所有字符串都是base64实际内容。
        realBase64 = base64Data;
    }

    // 对 base64 字符串进行原数据读取
    byte[] bytes = new BASE64Decoder().decodeBuffer(realBase64);

    // 构建文件将数据保存
    File file = new File("D:/temp/" + UUID.randomUUID().toString() + fileExtName);
    FileOutputStream fo = new FileOutputStream(file);

    // 输出文件内容
    fo.write(bytes);
    fo.flush();
    fo.close();

    // 返回成功
    JSONObject object = new JSONObject();
    object.put("code", 200);
    object.put("msg", "上传成功");
    return object;
}

三、form表单上传文件

对于前端来说,这种方式无疑是最简单的上传方式,因为甚至可以做到不写一点JavaScript代码就实现了。为了对前端上传文件有更深入了解,使用form表单上传时也有两种上传方案,一种是直接使用form节点的,另一种则是通过FromData对象来完成。

1、使用html的form节点

下面直接展示示列代码:

<form method="post"
      action="https://www.microanswer.cn/test/uploadFile"
      enctype="multipart/form-data">
    请选择文件:<input type="file" name="fileData"><br>
    请输入文件描述:<input type="text" name="fileDescription"><br>
    <input type="submit" value="提交">
</form>

这样,前端就实现了文件上传了。提交给接口uploadFile要怎么去获取里面的文件,前端不用管了。

2、使用 FormData 对象

当使用FormData完成文件上传时,事情就变得稍微复杂一点了。但是有它的优势。直接在页面上使用form表单会造成页面的跳转,上传成功后就不会再停留在当前页面了。而使用 FormData 进行文件上传则可以完成异步上传,达到不跳页面的效果。

首先要清除一点,FormData对象要提交数据时,其文件数据是来自于<input type="file">选择的文件。加入现在你已经选好了文件并且拿到了file对象,那么下面示列一个如何将此数据上传到服务端的代码:

// 文件上传方法。 callback 是上传完成回调。
function uploadFile(file, callback) {
    var formData = new FormData();
    formData.append("fileData", file); // 添加文件到 formData。
    formData.append("fileDescription", "文件上传描述信息。"); // 添加一个描述字段。

    // 进行上传
    $.ajax({
        url: "https://www.microanswer.cn/test/uploadFile",
        type: 'post',
        data: formData,
        contentType: false, // 不指定contentType,这样让JQuery主动识别
        processData: false, // 不让JQuery处理上传的数据,
        dataType: 'json',   // 预期返回数据格式。
        success: function (response) {
            callback(response);
        },
        error: function (xmlHttpRequest, statusStr, exception) {
            callback(undefined, exception);
        }
    });
}

代码中可以看到,首先使用 FormData 创建了一个实例,然后将文件和描述信息放入其中,最后通过 JQuery 的 ajax 方法将数据提交给后台接口。

四、使用Java模拟表单

java里没有类似formdata的上传文件辅助类,我们需要根据表单在上传文件时数据具体是如何进行提交的流程进行自己使用java来实现。摆在我们面前的首要任务就是要搞明白表单提交的数据结构,只有详细了解了其结构,才能使用Java进行完美的模拟。

1、文件上传的数据内容格式

通过表单上传文件显然是HTTP协议内的一部分内容,因此我们不妨直接翻开HTTP协议里针对表单上传文件相关的定义文档:RFC1867。下面是一些重点内容的截取部分(博主能力有限,翻译得不好还请将就凑合):

multipart/form-data的定义

上传文件axios 上传文件怎么上传_上传文件axios

数据格式内容举例

上传文件axios 上传文件怎么上传_文件上传_02

2、Java代码实现表单

通过上述HTTP协议定义文档中的描述,相信文件表单在数据提交过程中数据传输方式和格式已经有了大致的了解。现在是时候使用Java实现这样一个功能了。

a、单个表单文件单元

根据定义,一个input可以支持选择多个文件的,而在传输时,每个文件的传输需要提供一些基本信息。因此,直接为单个文件单元实现为一个类:

/**
 * InputFile.java
 * 用于放在表单 Input 里的文件单元。
 */
public class InputFile {

    // 保存当前需要传递的文件引用。
    private File file;

    // 如果要传递的数据直接是二进制数据了,这里也提供一个变量,使支持二进制数据直接传递。
    private byte[] byteData;

    // 在不清楚上传的数据是什么,但确切知道上传的内容是一个inputstream里的内容。这里也提供一个变量,使支持输入流直接传递。
    private InputStream inputStream;

    public InputFile(File file) { this.file = file; }

    // 对于数组,不要直接引用,而是取一份其拷贝的数据。
    public InputFile(byte[] byteData) {
        this.byteData = new byte[byteData.length];
        System.arraycopy(byteData, 0, this.byteData, 0, byteData.length);
    }
    public InputFile(InputStream inputStream) { this.inputStream = inputStream; }


    // 当进行提交时,使用此方法,此方法将会把当前单元持有的[文件\数据\流]进行上传。
    public void submit(OutputStream outputStream) throws Exception{

        // 输出是,首先建立基本信息输出。
        StringBuilder baseInfo = new StringBuilder();

        if (file != null) {
            // 有文件,则输出文件。
            baseInfo.append(" filename=\"").append(file.getName()).append("\"").append(Constant.LINE_SEPARATOR);
            baseInfo.append("Content-Type: ").append(new MimetypesFileTypeMap().getContentType(file)).append(Constant.LINE_SEPARATOR);
            baseInfo.append(Constant.LINE_SEPARATOR);

            inputStream = new FileInputStream(file);

        } else {
            // 输出数据 或 流,但因为不知道名称,因此填写一个随机名称。
            baseInfo.append(" filename=\"")
                    .append(UUID.randomUUID().toString().replaceAll("-", ""))
                    .append("\"")
                    .append(Constant.LINE_SEPARATOR)
                    .append("Content-Type: application/octet-stream").append(Constant.LINE_SEPARATOR)
                    .append(Constant.LINE_SEPARATOR);
        }

        outputStream.write(baseInfo.toString().getBytes(Constant.charset));

        // 将具备的流信息输出。
        if (inputStream != null) {
            byte[] datas = new byte[1024];
            int datasize = 0;

            while ((datasize = inputStream.read(datas))!= -1) {
                outputStream.write(datas, 0, datasize);
            }

            if (file != null) {
                inputStream.close();
            }
        } else if (byteData != null && byteData.length > 0) {
            // 将数据输出。
            outputStream.write(byteData);
        }

        outputStream.write(Constant.LINE_SEPARATOR.getBytes(Constant.charset));
    }
}
b、Input单元

input单元里可以放置一个键值对,用来传递数据,同时,它还可以传递键和一系列文件,上一节的文件单元就可以放在这个Input单元中。则可以设计出 Input单元类如下:

/**
 * Input.java
 * 表单里面的一条input输入单元。
 */
public class Input {
    // 表单支持的类型枚举
    public enum Type { text,file }

    // 此 input 要提交的name值。
    private String name;

    // 此 input 要提交的value值。当type为text时才会提交此数据。
    private String value;

    // 此 input 要提交的文件。当type为file时才会提交此数据。
    private ArrayList<InputFile> inputFiles;

    // 此表单的类型,目前只有:text 和 file
    private Type type;

    // 允许直接使用键值对构造一个text的input。
    public Input(String name, String value) {
        this.name = name;
        this.value = value;
        this.type = Type.text;
    }

    // 允许使用name和文件列表构造一个file的input。
    public Input(String name, InputFile... inputFile) {
        this.name = name;
        this.inputFiles = new ArrayList<>();
        this.inputFiles.addAll(Arrays.asList(inputFile));
        this.type = Type.file;
    }

    // 在提交前,允许添加文件。
    public Input addFile(InputFile inputFile) {
        this.inputFiles.add(inputFile);
        return this;
    }

    // 在提交前,允许修改要提交的值。
    public Input setValue(String value) {
        this.value = value;
        return this;
    }


    // 将此input内的数据进行提交。
    public void submit(OutputStream outputStream) throws Exception {
        // 先构建基本信息输出。
        StringBuilder baseInfo = new StringBuilder();
        baseInfo.append("content-disposition: form-data; name=\"").append(name).append("\";");

        if (type == Type.text) {
            baseInfo.append(Constant.LINE_SEPARATOR);
            baseInfo.append(Constant.LINE_SEPARATOR);
            baseInfo.append(value);
            baseInfo.append(Constant.LINE_SEPARATOR);

            String s = baseInfo.toString();
            outputStream.write(s.getBytes(Constant.charset));
        } else {

            if (inputFiles != null && inputFiles.size() > 0) {

                if (inputFiles.size()  == 1) {

                    outputStream.write(baseInfo.toString().getBytes(Constant.charset));
                    inputFiles.get(0).submit(outputStream);
                } else {
                    String boundary = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 6);

                    // 此input有多个文件。先输出内容体的基本信息。
                    baseInfo.append(Constant.LINE_SEPARATOR);
                    baseInfo.append("Content-Type: multipart/mixed, boundary=").append(boundary);
                    baseInfo.append(Constant.LINE_SEPARATOR);
                    baseInfo.append(Constant.LINE_SEPARATOR);

                    // 循环输出文件内容
                    for (InputFile infile: inputFiles) {
                        baseInfo.append("--").append(boundary).append(Constant.LINE_SEPARATOR);
                        baseInfo.append("content-disposition: attachment;");
                        outputStream.write(baseInfo.toString().getBytes(Constant.charset));
                        infile.submit(outputStream);

                        baseInfo = new StringBuilder();
                    }

                    // 追加上最后一个 boundary。
                    baseInfo.append("--").append(boundary).append("--").append(Constant.LINE_SEPARATOR);
                    outputStream.write(baseInfo.toString().getBytes(Constant.charset));
                }
            }
        }
    }
}
c、 form 表单

实现了 Input 之后,最后一个便是form实现了,form只需要实现其基本的数据组装即可完成:

/**
 * Form.java
 * 模拟HTML页面表单行为。
 */
public class Form {

    private ArrayList<Input> inputs;

    public Form() {
        this.inputs = new ArrayList<>();
    }

    // 往表单中添加一个input数据。
    public Form addInput(Input input) {
        this.inputs.add(input);
        return this;
    }

    // 提交这个表单。传入你要提交到的目标地址。
    // 此处代码比较简单,可以按需自己实现或修复其中存在的问题。
    public String submit(String url) throws Exception {
        if (inputs == null || inputs.size() == 0) {
            throw new NullPointerException("请至少添加一个要提交的表单数据。");
        }

        URL url1 = new URL(url);
        URLConnection urlConnection = url1.openConnection();

        HttpURLConnection c = (HttpURLConnection) urlConnection;

        // 设置请求方式、
        c.setRequestMethod("POST");
        c.setDoOutput(true);
        c.setDoInput(true);

        String boundary = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 6);

        // 设置header
        c.setRequestProperty("Content-Type", "multipart/form-data, boundary=" + boundary);

        // 进行传输。
        OutputStream outputStream = c.getOutputStream();
        for (Input in : inputs) {

            outputStream.write(("--" + boundary + Constant.LINE_SEPARATOR).getBytes(Constant.charset));
            in.submit(outputStream);
        }

        outputStream.write(("--" + boundary + "--").getBytes(Constant.charset));
        outputStream.flush();

        // 获取结果
        InputStream inputStream = c.getInputStream();

        // 读取结果字符串。
        InputStreamReader reader = new InputStreamReader(inputStream, Constant.charset);
        char[] chars = new char[256];
        int charsize=  0;

        StringBuilder result = new StringBuilder();
        while ((charsize = reader.read(chars))!= -1) {
            result.append(chars, 0, charsize);
        }
        c.disconnect();

        return result.toString();
    }
}
d、测试结果

现在InputFileInputForm 类都已完成开发,不妨进行一下测试:

// 构建一个表单
Form form = new Form();

// 加入一个普通的键值对。
form.addInput(new Input("name", "Jack"));

// 加入一个包含多个文件的input。
form.addInput(new Input("fileData",
        new InputFile(new File("C:\\Users\\Micro\\Desktop\\file1.jpg")),
        new InputFile(new File("C:\\Users\\Micro\\Desktop\\file2.jpg"))
));

// 提交到指定地址。
String result = form.submit("https://www.microanswer.cn/test/uploadFile");

// 打印结果
System.out.println("提交结果:" + result);

// 输出:提交结果:{"msg":"success","code":200,"data":null}

完美通过。

e、附加类

在上述几个类中,使用了一个常量类,其代码如下:

public class Constant {

    /**
     * 表单中上传文件,使用的换行必须是 \r\n。
     */
    public static final String LINE_SEPARATOR = "\r\n";

    /**
     * 表单中字符串的编码。
     */
    public static final Charset charset = StandardCharsets.UTF_8;

}

五、后台接收表单文件

本文主要讲了Java后台如何实现获取表单内的数据,下面将分别对spring后台和原生的servlet后台进行示例。

1、Spring后台

如果你的后台使用了Spring框架,那么你就幸运了,你可以十分方便的拿到表单上传上来的文件。只需要通过一些简单的注解就可以完成,下面示列了一个典型的Controller接口方法,用于获取表单上传上来的文件:

@RequestMapping("/uploadFile")
public Object uploadFileTest(
        @RequestParam(value = "fileData") MultipartFile multipartFile,
        HttpServletRequest request
) throws Exception {
    // 即可拿到已上传的文件内容。如果你不处理,这个文件就会在本次请求结束时被删除
    InputStream in = multipartFile.getInputStream();

    return Util.buildReturnJson(WebApplication.Code.SUCCESS, "success", null);
}

这个方法针对input里只有一个文件时非常方便。如果某个input里包含了多个文件,这个方法似乎没法获取到更多的文件。(如果这个方法能的法话,还请大佬评论指正。)

当你希望获取到某个input下的所有上传的文件时。你可以用下面的方法:

@RequestMapping("/uploadFile")
public Object uploadFileTest(HttpServletRequest request) throws Exception {

    // 使用 spring 提供的内置工具
    StandardMultipartHttpServletRequest r = new StandardMultipartHttpServletRequest(request);

    // 可以直接获取到input里如果是text类型的值。
    String name = r.getParameter("name");

    // 可以通过这样的方式获取到某个input下的所有文件。
    // 这里就获取了input的name为files时,提交上来的所有文件。
    List<MultipartFile> files = r.getMultiFileMap().get("files");

    return Util.buildReturnJson(WebApplication.Code.SUCCESS, "success", null);
}
2、servlet 实现

在servlet中获取表单上传的文件相对比spring里复杂,但其实spring只是把servlet的封装了一下。咱们java后台的表单解析功能是自带就有的。下面给出一个示例:

public void doPost(HttpServletRequest request, HttpServletResponse response) throws Exception {
    // 获取表单的所有 part 内容。
    // 一个 text 的input 是一个 part
    // 一个 file 的input 里可能有多个part(因为可以多选文件嘛),这些part的name都是 input的name。
    Collection<Part> parts = request.getParts();
    Iterator<Part> iterator = parts.iterator();
    while (iterator.hasNext()) {
        // 获取到表单里的每个part。
        Part part = iterator.next();
        part.getName(); // 对应了 input 的 name 属性。
        part.getSubmittedFileName(); // 对应了 input=file 时 提交的文件的真实名字。(当input不是file类型时此方法返回的null)
        part.getInputStream();  // 对应了 input=file 是文件时的文件输入流。input=text时,其value值也通过此流读取。
        // todo 实现自己的业务。
    }
}

六、总结

无论使用什么方式进行文件上传,只要对HTTP协议有一定的了解,都可以轻松完成各种需求的开发。为什么要针对表单进行模拟来上传文件,因为这是大多数服务器的上传文件方式,许多服务端也都默认支持解析表单文件,因此客户端的上传也许迎合服务器所支持的使用表单进行文件上传。