Java实现HTTP的上传与下载

发布时间 2023-12-13 10:08:53作者: Xproer-松鼠

相信很多人对于java文件下载的过程都存在一些疑惑,比如下载上传文件会不会占用vm内存,上传/下载大文件会不会导致oom。下面从字节流的角度看下载/上传的实现,可以更加深入理解文件的上传和下载功能。

文件下载
首先明确,文件下载不仅仅只有下载方,还有服务端也就是返回文件的服务器
那么看一个简易文件服务器返回下载的文件。

服务端
这里是使用springMvc实现

@GetMapping("download")
public void downFile(HttpServletResponse response) throws IOException {
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename=" + "test.jhprof");
File file = new File("D:\\heap\\heapDump.hprof");
InputStream in = new FileInputStream(file);
OutputStream out = response.getOutputStream();
byte[] buffer = new byte[1024];
int len = 0;
while ((len = in.read(buffer)) > 0) {
out.write(buffer, 0, len);
}
in.close();
out.close();
}

这里每次从文件流中读取1024个字节输出,是因为如果读取太多字节会给内存造成压力,我们这里使用的是java的堆内存。如果直接完整读取整个文件,那么可以导致oom。

java.lang.OutOfMemoryError: Java heap space

客户端
URL url = new URL("http://localhost:8062/fallback/download");
URLConnection conn = url.openConnection();
InputStream in = conn.getInputStream();
FileOutputStream fileOutputStream = new FileOutputStream("D:\\tmp\\a.hrpof");

byte[] buffer = new byte[1024];
int len = 0;
while ((len = in.read(buffer)) > 0) {
fileOutputStream.write(buffer, 0, len);
}

in.close();
fileOutputStream.close();

同样,在下载文件时也需要注意,不要一次读取特别多的字节数
测试过程发现由于TCP发送缓冲区和接受缓冲区有限,当缓冲区满之后就会阻塞,例如下载方的速度满,服务端的文件不断写到缓冲区,缓冲区满了,就无法继续写入,那么就会导致在执行write方法时暂时阻塞。等到接收端接受到数据了,才能继续写入。

文件上传
服务端
http是支持多个文件进行上传的,文件数据都在请求体中,多个文件之间可以通过分隔符区分
例如上传两个文本文件
请求大概长这样

1.HTTP上传method=post,enctype=multipart/form-data;
2.计算出所有上传文件的总的字节数作为Content-Length的值
3.设置
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryJ9RUA0QCk13RaoAp
4.多个文件数据:请求体
------WebKitFormBoundaryJ9RUA0QCk13RaoAp
Content-Disposition: form-data; name="pic"; filename="thunder.gif"
Content-Type: image/gif
这中间是文件的二进制数据

------WebKitFormBoundaryJ9RUA0QCk13RaoAp
Content-Disposition: form-data; name="pic"; filename="uuu.gif"
Content-Type: image/gif
这中间是文件的二进制数据
------WebKitFormBoundaryJ9RUA0QCk13RaoAp--


服务端使用springMvc接收上传文件的写法(多个文件)

@RequestMapping(
method = RequestMethod.POST,
value = "/uploadModel"
)
public void uploadModel(@RequestPart(value = "file", required = true) List<MultipartFile> file, @RequestParam Integer type) {
··········
}

这样就可以直接获取到上传的文件。
具体接受过程还要看springMvc的实现
在doDispatch方法中会是否按照文件进行处理
判断方式也很简单检查请求头的multipart

@Override
public boolean isMultipart(HttpServletRequest request) {
return StringUtils.startsWithIgnoreCase(request.getContentType(), "multipart/");
}

如果是文件类型,那么就要通过IO流将文件下载到本地
springMvc的大致实现如下
原代码位置org.apache.tomcat.util.http.fileupload.FileUploadBase#parseRequest

FileItemIterator iter = getItemIterator(ctx);
FileItemFactory fileItemFactory = Objects.requireNonNull(getFileItemFactory(), "No FileItemFactory has been set.");
final byte[] buffer = new byte[Streams.DEFAULT_BUFFER_SIZE];//8KB的字节数组用于读取字节流
while (iter.hasNext()) {
final FileItemStream item = iter.next();
// Don't use getName() here to prevent an InvalidFileNameException.
final String fileName = ((FileItemStreamImpl) item).getName();
FileItem fileItem = fileItemFactory.createItem(item.getFieldName(), item.getContentType(),
item.isFormField(), fileName);
items.add(fileItem);
try {
Streams.copy(item.openStream(), fileItem.getOutputStream(), true, buffer);
} catch (FileUploadIOException e) {
throw (FileUploadException) e.getCause();
} catch (IOException e) {
throw new IOFileUploadException(String.format("Processing of %s request failed. %s",
MULTIPART_FORM_DATA, e.getMessage()), e);
}
final FileItemHeaders fih = item.getHeaders();
fileItem.setHeaders(fih);
}
successful = true;
return items;


根据分隔符找出上传的多个文件进行读取字节流,同时创建本地文件,写入到本地文件,这里循环通过8kb数组读取到内存,再写到文件,是为了防止文件过大造成占用内存大。

public static long copy(InputStream inputStream,
OutputStream outputStream, boolean closeOutputStream,
byte[] buffer)
throws IOException {
OutputStream out = outputStream;
InputStream in = inputStream;
try {
long total = 0;
for (;;) {
int res = in.read(buffer);
if (res == -1) {
break;
}
if (res > 0) {
total += res;
if (out != null) {
out.write(buffer, 0, res);
}
}
}
if (out != null) {
if (closeOutputStream) {
out.close();
} else {
out.flush();
}
out = null;
}
in.close();
in = null;
return total;
} finally {
IOUtils.closeQuietly(in);
if (closeOutputStream) {
IOUtils.closeQuietly(out);
}
}
}


从这里我们也能看出来,http请求不并不是tomcat服务器接收进完全接收的,而是先接收请求头进行就开始进行处理了,至于后面要不要读取请求提,如何读取,就要看程序员的代码了,这也是程序员可以控制的。

客户端
文件上传的客户端逻辑比较复杂

package javaio;

import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

/**
* @author liuxishan 2023/9/3
*/

public class FileUpload {

public static void main(String[] args) {
String reslut = null;
Map<String, File> files = new HashMap() {
{
put("0.png", new File("C:\\Users\\lxs\\Desktop\\0.png"));
put("1.jpg", new File("C:\\Users\\lxs\\Desktop\\1.png"));
put("big.herof", new File("D:\\heap\\heapDump.hprof"));
}
};
try {
String BOUNDARY = java.util.UUID.randomUUID().toString();
String PREFIX = "--", LINEND = "\r\n";
String MULTIPART_FROM_DATA = "multipart/form-data";
String CHARSET = "UTF-8";
URL uri = new URL("http://localhost:8062/fallback/uploadModel?type=1");
HttpURLConnection conn = (HttpURLConnection) uri.openConnection();
// conn.setChunkedStreamingMode(0);
conn.setReadTimeout(100 * 1000);
conn.setDoInput(true);// 允许输入
conn.setDoOutput(true);// 允许输出
conn.setUseCaches(false);
conn.setRequestMethod("POST"); // Post方式
conn.setRequestProperty("connection", "keep-alive");
conn.setRequestProperty("Charsert", "UTF-8");
conn.setRequestProperty("Content-Type", MULTIPART_FROM_DATA + ";boundary=" + BOUNDARY);
conn.connect();
// 首先组拼文本类型的参数
StringBuilder sb = new StringBuilder();
OutputStream outStream = conn.getOutputStream();
outStream.flush();
// 发送文件数据
if (files != null)
// for (Map.Entry<String, File> file : files.entrySet()) {
for (String key : files.keySet()) {
StringBuilder sb1 = new StringBuilder();
sb1.append(PREFIX);
sb1.append(BOUNDARY);
sb1.append(LINEND);
sb1.append("Content-Disposition: form-data; name=\"file\"; filename=\"" + key + "\"" + LINEND);
sb1.append("Content-Type: multipart/form-data; charset=" + CHARSET + LINEND);
sb1.append(LINEND);
outStream.write(sb1.toString().getBytes());
File valuefile = files.get(key);
InputStream is = new FileInputStream(valuefile);
byte[] buffer = new byte[1024];
int len = 0;
while ((len = is.read(buffer)) != -1) {
outStream.write(buffer, 0, len);
}
is.close();
outStream.write(LINEND.getBytes());
}
// 请求结束标志
byte[] end_data = (PREFIX + BOUNDARY + PREFIX + LINEND).getBytes();
outStream.write(end_data);
outStream.flush();
// 得到响应码
// success = conn.getResponseCode()==200;
InputStream in = conn.getInputStream();
InputStreamReader isReader = new InputStreamReader(in);
BufferedReader bufReader = new BufferedReader(isReader);
String line = null;
reslut = "";
while ((line = bufReader.readLine()) != null)
reslut += line;
outStream.close();
conn.disconnect();
} catch (Exception e) {
e.printStackTrace();
}
}

}


这里需要注意的是在使用HttpURLConnection 上传大文件时,出现内存溢出的错误,这让我产生了错觉,输入和输出流咋会暂用内存,不就是一个数据传送的管道么,都没有把数据读取到内存中,为撒会报错。。。然后就纠结了。。。
不过实在与原来的经验相违背,然后写了一个示例直接从file中读出然后写入到输出流中,发现并没有问题
查HttpURLConnection api发现其有缓存机制,数据并没有实时发送到网络,而是先缓存再发送,导致内存溢出。
解决办法:
httpConnection.setChunkedStreamingMode(0);
//不使用HttpURLConnection的缓存机制,直接将流提交到服务器上。
需要注意的是我们经常使用的hutool的http也存在这个问题
如果不指定chunkedStreamingMode也会出现oom的问题
对于大文件可以这么进行指定。

HttpResponse response = HttpUtil.createPost("http://localhost:8062/fallback/uploadModel?type=1")
.setChunkedStreamingMode(0)
.form("file", new File("D:\\heap\\heapDump.hprof"))
.execute();

 

参考文章:http://blog.ncmem.com/wordpress/2023/12/13/java%e5%ae%9e%e7%8e%b0http%e7%9a%84%e4%b8%8a%e4%bc%a0%e4%b8%8e%e4%b8%8b%e8%bd%bd/

欢迎入群一起讨论