[实验室系列]-文件上传/下载

学习实践前后端交互中文件上传与下载的知识点

文件上传

json上传

json虽然不属于文件,但在前后端数据交互中json是比较重要的一种格式,而且也算一种比较简单的格式
1.前端html中,使用jQuery以及集成的ajax进行请求发送,

<script>
    $(function () {
        var button1 = $("#button1");
        button1.click(function () {
            $.get("upload/jsonServlet",{"userName":"user","password":"123456"},function (data) {
                alert(data)
            })
        })
    })
</script>

2.后端控制层代码借助springMVC框架完成参数获取

@GetMapping("/jsonServlet")
public @ResponseBody String jsonUpload(User user){
    System.out.println(user);
    //User(userName=user, password=123456)
    return "json上传";
}

表单文件上传(Multipart的格式)

就是将图片,文件等包装成multipart的形式进行传输,而没有直接当成二进制流处理

  • 起初http并不支持传输文件,rfc1867为http 协议添加了这个功能,要求使用这个功能时,表单中的enctype属性更改为multipart/form-data,默认的application/x-www-form-urlencoded不适合用于传输大型二进制数据或者包含非ASCII字符的数据;表单method选择post;同时提供一个file类型的input作为文件选择域
  • multipart/form-data会把发送请求中不同字段的内容之间用随机生成的分隔符(boundary)分开,以二进制流的形式发送,比如文件跟文本的内容就会被分开,这样文件才能正常被解析
    multipart发送请求的http协议内容
    此时由于数据是以二进制流的形式发送的,所以在后端代码中通过request.getParameter()是获取不到对应的数据的,应该相对应地用流的方式接收:使用request.getInputStream()获取请求输入流,此时这个流表示的内容就是请求体的内容,可将流读取转化为字符串的形式即可发现:
ServletInputStream inputStream = req.getInputStream();
int firstByte = inputStream.read();//网络流在调用available()之前应该先调用一次read()
byte[] bytes = new byte[inputStream.available() + 1];
bytes[0] = (byte)firstByte;
int read = inputStream.read(bytes,1,inputStream.available());
System.out.println(new String(bytes,0,read + 1));

再对流进行处理,解析(配合commons-fileupload和commons-io两个组件),就可以得到上传的内容

  • 当我们在form表单中设置了enctype为"multipart/form-data"后请求中contentType就默认是multipart/form-data了。所以如果有使用jQuery配合Ajax,在$.ajax()中应该设置contentType=false避免jQuery对contentType进行操作,影响分隔符以及服务端文件的解析,使用jQuery配合ajax进行传输的话,他会根据所传数据($.ajax中的data属性)自己设定contentType
    如果出现了以下报错:
Current request is not a multipart request

可能就是没有改content-type导致的
如果是以下报错:

the request was rejected because no multipart boundary was found

可能就是有改contenttype但是在jQuery中再次对content-type进行了操作导致没有分隔符
所以要处理的有:
1.将form表单的enctype取值改为multipart/form-data,请求正文就改成每一部分都是mime类型描述的(默认是application/x-www-form-urlencoded,请求正文是k=v&k=v&…的形式),enctype是表单请求正文的类型
2.使用post方式
3.提供一个文件选择域<input type=“file” name=“”/>,注意这里的name必须设置,它就是这个input选择的文件对应的参数名,后端也是通过这个参数名获取上传的文件的

不使用springMVC的方式

不使用springMVC以及springBoot,在后端对multipart/form-data格式下的请求体解析时就要使用commons-fileupload和commons-io两个组件

关于request流的解析

这里有一个很重要的点上面说到multipart/form-data方式下数据是以二进制流进行传输,所以后端应该用request.getInputStream()才能获取到输入的请求体内容;解析时可以使用组件进行解析。不使用springMVC框架的前提下是可以的。但如果我们使用springMVC或springBoot,从request.getInputStream()返回的流中是读不到东西的,而且使用commons-fileupload组件获取磁盘文件项FileItem也是获取不到内容的。只有不使用框架,使用原生的servlet(即部署tomcat工件,让控制器继承HttpServlet重写doGet()跟doPost()方法的方式),才能完成前面说的两个操作。一开始我认为应该是因为springMVC以及springBoot已经对request以及上传的文件解析完毕了,当我们拿到request时已经不是原来的request了,前端上传的文件也已经被框架解析获取了,所以不能用原生的方式进行处理,后来了解到available()这个方法:
InputStream().available可以获取request流中还有多少字节可以读取,便于根据该数值创建固定大小的byte数组,从而读取输入流的信息。在文件输入流中,这种操作是没有问题的,但是在网络流(socket) 中,调用available方法得到的返回值是0,原因是(参考博客):

网络通讯往往是间断性的,一串字节往往分几批进行发送。例如对方发来字节长度100的数据,本地程序调用available()方法有时得到0,有时得到50,有时能得到100,大多数情况下是0。这可能是对方还没有响应,也可能是对方已经响应了,但是数据还没有送达本地。也许分3批到达,也许分两批,也许一次性到达。
OSI网络七层结构
我们进行的数据接收只是基于应用层,网络传输的最上层,数据从一端到另一端传输的时候,会在传输层分解成适合的数据包。传输层(Transport Layer)是OSI 模型中最重要的一层。传输协议同时进行流量控制或是基于接收方可接收数据的快慢程度规定适当的发送速率。除此之外,传输层按照网络能处理的最大尺寸将较长的数据包进行强制分割。例如,以太网无法接收大于1500 字节的数据包。发送方节点的传输层将数据分割成较小的数据片,同时对每一数据片安排一序列号,以便数据到达接收方节点的传输层时,能以正确的顺序重组。该过程即被称为排序。工作在传输层的一种服务是 TCP/IP 协议套中的TCP(传输控制协议),另一项传输层服务是 IPX/SPX 协议集的 SPX(序列包交换)。
InputStream的available()方法的作用是返回此输入流在不受阻塞情况下能读取的字节数。网络流与文件流不同的关键就在于是否“受阻”二字,网络socket流在读取时如果没有内容read()方法是会受阻的,所以从socket初始化的输入流的available也是为零的,所以要read一字节后再使用,这样可用的字节数就等于 available + 1。但文件读取时read()一般是不会受阻的,因为文件流的可用字节数 available = file.length(),而文件的内容长度在创建File对象时就已知了。

所以调用网络流(socket)的available()方法前,一定记得要先调用read()方法,这样才能避免获取为0的不正确情况:

int firstByte = inputStream.read();
int length = inputStream.available();
byte[] bytes = new byte[length+1];
bytes[0] = (byte)firstByte;
inputStream.read(bytes,1,length);

但这种方式,不适用于contentType为application/x-www-form-urlencoded的时候,这种情况下read()返回-1,available()返回0
不过在springboot项目中这么做还是无法打印出request的内容,所以我还是觉得是MVC框架的问题
总结:如果获取request流中的内容获取不到,可以尝试在调用available()方法前先调用read()读取第一个字符;也可能contnetType为application/x-www-form-urlencoded,尝试改为其他形式如text/plain,application/json

springMVC方式

前端代码:

<form enctype="multipart/form-data" action="upload/formServlet" method="post" id="file1">
    <input type="file"  id="file" name="file"/>
    <input type="submit" />提交
</form>

后端代码:
由于request以及上传的文件项已经被框架解析完毕,只需要我们在控制器对应方法上加上一个MultipartFile类的参数,直接就可以接收了。注意控制器类的@Controller@RestController注解必须有,否则会报404错误

@PostMapping("/formServlet")
//这里用RequestParam注解将参数与前端传送的“file”绑定,也可以不使用注解,直接让参数名为file也行
public String formUpload(HttpServletRequest request, @RequestParam("file")MultipartFile multipartFile) throws Exception{
    //获取/upload/目录在项目中的路径
    String path = request.getSession().getServletContext().getRealPath("/upload/");
    File file = new File(path);
    if(!file.exists()) {
        file.mkdirs();
    }
    //上传到项目中的/upload/目录,就以上传时的参数名作为文件名(没有指定后缀也就是没有文件类型,要自己指定)
    multipartFile.transferTo(new File(path,multipartFile.getName()));
    return "success";
}

如果请求发送后出现以下提示:
如果出现下面这个提示

Required request part 'file' is not present

就是文件选择域input中的name属性没有指定

动态创建表单提交数据

<body>
	<button id="button1">上传json数据</button>
	<input type="number" name="age" id="age"/>年龄
</body>
button1.click(function () {
         var age = $("#age");
         alert(age.val());
         var form = $('<form></form>')
         var input1 = $('<input />')
         input1.attr('type',"text")
         input1.attr("name","year")
         input1.attr("value",(2021 - age.val()));
         form.attr('action',"upload/yearServlet")
         form.attr("method","get")
         form.append(input1);
         $(document.body).append(form)
         form.submit()
})

注意$(document.body).append(form)要有,否则会出现以下报错:

Form submission canceled because the form is not connected

blob格式上传

首先要知道如何使用blob格式的数据:在js代码中可以创建一个Blob对象,构造函数可以传入两个参数:一个是要转化为blob的数据,一个是含下面两个属性的对象:

type: MIME的类型,
endings: 决定第一个参数的数据格式。默认值为"transparent",用于指定包含行结束符n的字符串如何被写入。 它是以下两个值中的一个: “native”,表示行结束符会被更改为适合宿主操作系统文件系统的换行符; “transparent”,表示会保持blob中保存的结束符不变

详细的构造方法请看下文的示例。通过这种方式,可以将数据转化为blob对象,以二进制的形式发送出去

代码示例

前端代码:

<input id="fileUpload" type="file" name="fileUpload.jpg" >
<button id="but">upload</button>
<script>
    $(function () {
    	//使用DOM选择input标签以及选择标签中选中的文件内容
        var fileUpload = document.getElementById("fileUpload");
        var but = $("#but");
        but.click(function () {
        	//blob的构造:取出input标签中的内容作为构造函数的第一个参数
        	//构造函数中的第一个参数是数组形式
        	//type指定为要要被转为blob对象的数据的类型(也就是第一个参数的类型,这里以图片为例)
            var file1 = new Blob([fileUpload.files[0]],{type : 'image/jpeg'})//
            //将构造出来的blob对象放入formData然后使用formdata发送
            var form = new FormData();
            form.append("file3",file1)
            $.ajax({
                contentType:false,
                processData: false,
                type:"post",
                url:"upload/blobServlet",
                data:form,
                success:function (data) {
                }
            })
        })
    })
</script>

后端代码:

@PostMapping("/blobServlet")
public String blobUpload(HttpServletRequest request,@RequestParam("file3") MultipartFile multipartFile) throws Exception{
    String path = request.getSession().getServletContext().getRealPath("/upload/");
    System.out.println(path);
    File file = new File(path);
    if(!file.exists()){
        file.mkdirs();
    }
    multipartFile.transferTo(new File(path,"11" + multipartFile.getName() + ".jpg"));
    return "success";
}

如果浏览器中检查出现报错:

Failed to execute 'arrayBuffer' on 'Blob': Illegal invocation

是应该将请求头中的属性processData改为false:

processData,布尔值,默认为true。默认情况下,通过data选项传递进来的数据,如果是一个对象(技术上讲只要不是字符串),都会处理转化成一个查询字符串,以配合默认内容类型 “application/x-www-form-urlencoded”。如果要发送 DOM 树信息或其它不希望转换的信息,就要设置为 false。

对于我们要上传的文件,blob格式的也一样,他们不同于json数据字符串,文件不希望经过转化成为字符串,所以发送不希望转换的信息时就把processData属性更改为false,由于大多数的情况下我们传输的都是字符串形式的数据,所以一般不会去管processData这个参数
在这个示例中,虽然没有明确地设置contentType为multipart/form-data,但请求发送后可以看到它确实是multipart/form-data的,此时就像上面在表单文件上传部分讲的那样,在控制器代码中是打印不出request的内容的,也无法通过commons-io和commons-upload组件获取上传文件,只能通过参数绑定的方式

文件下载

整体代码框架

文件下载的后端代码总结起来都是如下这几步:
1.获取文件在项目或服务器中的绝对路径
2.通过文件名(带后缀)以及ServletContext对象获取mimetype类型
3.设置响应头中的content-type以及content-disposition,一个是设置mimetype,一个是设置文件为附件形式展示和展示时的文件名
4.获取文件输入流和response输出流,将文件读到输入流中即可。最后关闭输入流

@GetMapping("download")
public void download(@RequestParam("fileRecordId")int fileRecordId, HttpServletRequest httpServletRequest,HttpServletResponse response) throws Exception{
	//1.获取文件路径,不同项目获取方式不同,仅供参考
    String path = fileRecordService.findFilePath(fileRecordId);
    File file = new File(path);
    if(!file.exists()){
        response.getWriter().println("file does not exist");
        return;
    }
    //2.根据文件名(带后缀才能)获取对应的文件mimetype
    String fileName = path.substring(path.lastIndexOf("/") + 1);
    String mimeType = httpServletRequest.getSession().getServletContext().getMimeType(fileName);
    //3.设置响应头
    response.setHeader("content-type",mimeType);
    response.setHeader("content-disposition","attachment;filename=" + fileName);//filename是前端页面展示的文件名
    //4.获取文件的流和response的流,用流输出
    FileInputStream fileInputStream = new FileInputStream(path);
    ServletOutputStream outputStream = response.getOutputStream();
    byte[] buff = new byte[1024*4];
    int len = 0;
    //read(buff)表示将数据独代buff数组中,buff数组有多大一次就读多少字节
    //当然读到最后可能不够读满一个buff数组。返回值len表示每次read实际read到的字节数
    //当len为-1时表示没有数据可以读了
    while ((len = fileInputStream.read(buff)) != -1){
    	//指定从buffer数组中读len个字节
        outputStream.write(buff,0,len);
    }
    fileInputStream.close();
}

前端请求一开始我是使用ajax,然后点击button触发ajax请求,发现这么做前端点击文件下载后没有任何反应,即没有弹出询问是否下载文件的窗口,搜索之后发现ajax好像不支持这种二进制流形式的文件上传,所以用了超链接发送文件下载的请求不使用ajax,成功了:

div1.append("<a href='file/download?fileRecordId=" + datum['fileRecordId'] + "' >下载</a>")

如果需要接口返回文件流但是浏览器不弹出窗口提示,就可以尝试使用ajax,直接接收返回的响应体

防止任意下载文件的操作

应对用户自己修改请求链接的问题

注意上面这个超链接,对于fileRecordId的参数设定是FileRecord这个实体类的主键id,然后通过这个id找到对应的文件然后返回前端进行文件下载。这样的话可能有人就借助这个超链接,只需要修改一下这个fileRecordId参数就能把数据库中所有文件都下载下来,所以这种请求路径的写法是不安全的,应该在展示到前端页面前,后台对要显示的文件信息做处理,在指定文件被选择要下载后再把相应的文件信息转回实际的信息,然后进入到获取文件路径这一步。
项目给出的处理方案是维护一个map,其中的键为字符串,对应文件的某一项真实信息,值为一个包含键的内容经过处理后的内容和该键值对生成的时间戳这两个内容的对象,当要展示文件名或其它作为文件下载的请求路径中的参数的信息在前端页面上时(如上面的fileRecordId)后台先对真实信息进行处理得到对应的虚拟信息,然后生成一个键值对放到map中,把虚拟信息传给前端,当某个文件的下载函数被触发时,前端将对应的虚拟信息返回后台,后台再借助map得到对应的真实信息,然后通过这个真实信息得到文件路径,继续接下来的文件下载操作。
对于同一个文件的信息,每一次处理时得到的结果应该是不同的,所以map中越早生成的键值对应该被移除。采取的做法是另外开一个线程,每60s遍历一遍map,移除其中生成时间戳距当前时间达到120s的键值对。
另外一个没有采取的做法是初始为map设置一个容量,当map存储键值对的数量达到容量大小时就进行对map中过期的键值对的移除操作,这个做法的问题在于一开始map的容量大小不知道要设为多少,有可能在短时间内有多个文件下载的操作产生,如果容量设置太小,早生成的键值对被移除,但它对应的文件被点击下载了,此时后端就无法得到文件的真实信息了

token

系统应对请求接口加入token的认证防止用户随意的下载操作

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值