一、背景
2020年11月份的时候,我做过一个项目(我是中间接手的),涉及到网络文件,比如第三方接口提供一个文件的下载地址,使用java去下载,当时我全部加在到JVM内存里面,话说,单单是80M的下载单线程没问题,但是当时处于开发阶段,没注意到该问题,到了上线,同事负责测试,也没问题(主要的当时是4个人测试,也没发现内存泄漏问题,原因在于用户了少,占的内存也小),所以当时直接测试通过,并且上线。
客户那边进行验收测试,当时应该测试的人也不多,但是他们选择的文件100M以内的,而且是进行了一个,在等待是,又进行一个,也即是说类似于压测。顿时爆发问题。一查询日志显示,内存泄漏,俗称JVM:OutOfMemorry。
二、解决办法
至于针对这种情况,我提出有两种办法解决(下面会分别讲解这两种解决办法的源代码)
第一:分片下载文件、分片上传文件
第二:把文件下载到磁盘(在linux系统也是一样,指定下载到目录,再分片读取上传)
第三:另外我自行增加异步线程池来处理并发问题。也即每个文件都进行异步线程池处理(异步线程池这里不讲解,太简单了,大伙自行百度:springboot异步线程池配置(最好自己配置一下,默认的虽然不用配置,但是不太好,比如等待队列数量设置,队列满的策略怎么设置等))
三、分片下载文件、分片上传文件解决方案以及源码
1、首先分片下载地址,计算每一片的分片大小,源码如下
/** * @param fileTotalSize 文件总大小 kb * @param splice 分片单位大小 kb * 分片的结果:range=: 0-2 * 3-5 * 6-8 */ public static FileSpliceResultVo getFileSplice(Long fileTotalSize, Long splice) { //包装分片数据 Long startSpliceSize = 0L; Long endSpliceSize = 0L; List<SpliceDetail> detailList = new ArrayList<>(); //1:计算出总的分片数量 if (fileTotalSize <= 0 || splice <= 0) { return null; } if (splice >= fileTotalSize) { //如果分片大小,大于实际的文件大小: StringBuilder range = new StringBuilder() .append(0).append("-").append(fileTotalSize-1); //分片详情信息 SpliceDetail spliceDetail = SpliceDetail.builder() .range(range.toString()) .size(fileTotalSize) .build(); //把分片放进list里面 detailList.add(spliceDetail); } Integer totalSplice = Math.toIntExact(fileTotalSize / splice); //如果取模不为0,则分片数量+1; if (fileTotalSize % splice != 0) { totalSplice = totalSplice + 1; } for (int spliceIndex = 0; spliceIndex < totalSplice; spliceIndex++) { startSpliceSize = spliceIndex * splice;//分片是从0开始 endSpliceSize = spliceIndex * splice + splice - 1;//末端分片-1 if (endSpliceSize > fileTotalSize) { endSpliceSize = fileTotalSize-1; //如果最后一片大于实际文件大小,那么取文件大小 } StringBuilder range = new StringBuilder() .append(startSpliceSize).append("-").append(endSpliceSize); //分片详情信息 SpliceDetail spliceDetail = SpliceDetail.builder() .range(range.toString()) .size(endSpliceSize - startSpliceSize + 1) .build(); //把分片放进list里面 detailList.add(spliceDetail); } FileSpliceResultVo resultVo = FileSpliceResultVo.builder() .totalSplice(totalSplice) .spliceDetail(detailList) .build(); return resultVo; }
FileSpliceResultVo.java类如下定义:
//分片结果集 @Getter @Setter @AllArgsConstructor @NoArgsConstructor @Builder public class FileSpliceResultVo { //总共分片 private Integer totalSplice; private List<SpliceDetail> spliceDetail; }
SpliceDetail.java如下:
@Getter @Setter @AllArgsConstructor @NoArgsConstructor @Builder public class SpliceDetail implements Serializable { private Long size; private String range; }
2、分片计算好,那么就来分片下载(此处分片下载需要接口支持,否则不行)
//这里的自动注入是我在项目里面自己配置的,如果大家没有做配置,自行new 一个对象。也就是在spliceDownloadFile方法体里面:RestTemplate restTemplate=new RestTemplate()
@Autowired
private RestTemplate restTemplate;
//分片下载方法,主要是通过参数range来指定下载的分片,range参数在上面计算分片已经的出来,直接传进来该方法即可。至于fileName、phone参数传进来是为了日志关键字排查
public byte[] spliceDownloadFile(String fileName, String phone, String downloadUrl, String range) { //下载url转义处理 HttpHeaders headers = new HttpHeaders(); headers.set("Range", "bytes=" + range);//此处的Range的Header字段是由接口提供方定义,大家自行更改,并且如果涉及鉴权,自己在header里面添加,还有的接口会涉及其他header字段需要标识。这里不多说 HttpEntity httpEntity = new HttpEntity<>(headers); try { log.info("请求分片下载fileName={},phone={},url={}", fileName, phone, downloadUrl); ResponseEntity<byte[]> exchange = restTemplate.exchange(downloadUrl, HttpMethod.GET, httpEntity, byte[].class); return exchange.getBody(); } catch (Exception e) { log.info("请求pcDownloadFile下载阶段抛出异常fileName={},phone={},exception={}", fileName, phone, e); } return null; }
注意:这里我请求第三方文件下载接口,增加了try...catch,是为了捕获异常,有些情况下会连接超时而导致不能记录日志,而且程序直接中断
3、接下来看分片上传代码
/**
* bytes参数:文件的二进制流,如果你是File文件,转为二进制流的话,可以通过jdk自带的:FileUtils.readFileToByteArray(File)转换
*pcUploadFileVo 这里是我根据自行的业务封装的实体类,大家不必跟我的一模一样
* range 这个参数也是分片,根据第三步的分片方法计算出来上传的分片大小。
* rangeType 我的这个参数是用来识别是否分片上传完成,有的接口是这样做,有的不是。可能对大家没多大意义
* contentLength 本次上传的分片大小,有的分片上传接口也不需要,都是看业务。
* 特别注意:header请求头会根据你的不同业务,而设计不同,都是根据自己的需求而定义。我这里展示的也只是一部分,让大家好有个参考
/
public String spliceUploadFile(byte[] bytes, PcUploadFileVo pcUploadFileVo, String range, String rangeType, Long contentLength){ String fileName = URLEncoder.encode(pcUploadFileVo.getFileName(), "UTF-8"); HttpHeaders headers = new HttpHeaders(); headers.set("Range", "bytes=" + range); headers.set("contentSize", pcUploadFileVo.getFileSize()); //整个文件大小 headers.set("rangeType", rangeType); headers.set("Content-Length", String.valueOf(contentLength)); //本片文件的大小 //用HttpEntity封装整个请求报文 HttpEntity httpEntity = new HttpEntity<>(bytes, headers); try { log.info("文件分片上传:fileName={},headers={}", pcUploadFileVo.getFileName(), JSONUtil.toJsonStr(headers)); ResponseEntity<String> responseEntity = restTemplate.postForEntity(url, httpEntity, String.class); log.info("文件分片上传:{},结果:{}", pcUploadFileVo.getFileName(), JSONUtil.toJsonStr(responseEntity.getBody())); return responseEntity.getBody(); } catch (Exception e) { log.error("文件分片上传出错抛出异常:fileName={}", pcUploadFileVo.getFileName(), e); } return null; }
至此:针对网络文件,分片上传,分片下载的代码大概演示完成。接下来带大家进入方案二:把网络文件下载到磁盘(速度极快且占内存小)
四、下载网络文件到磁盘
直接上源码:
/** * 文件下载 * * @param downloadUrl 下载地址 * @param targetPath 文件保存目标路径,这里的组成是:路径+文件名,如:/opt/upload/我的报告.docx * @return 下载结果 */ public boolean downloadFile (String downloadUrl, String targetPath) { // 请求头设置为APPLICATION_OCTET_STREAM,表示以流的形式进行数据加载 RequestCallback requestCallback = request -> request.getHeaders () .setAccept (Arrays.asList (MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL)); // RequestCallback 结合File.copy保证了接收到一部分文件内容,就向磁盘写入一部分内容。而不是全部加载到内存,最后再写入磁盘文件。 // 对响应进行流式处理而不是将其全部加载到内存中 try { restTemplate.execute (downloadUrl, HttpMethod.GET, requestCallback, clientHttpResponse -> { Files.copy (clientHttpResponse.getBody (), Paths.get (targetPath)); return true; }); } catch (Exception e) { log.error ("downloadFile exception! downloadUrl={} targetPath={}", downloadUrl, targetPath, e); return false; } return true; }
对,没错,不用怀疑,就是这么简单。但是保存到磁盘,如果还需要对该文件上传,优化上传的话还需要分片处理上传,稍后会再整理怎么读取本地文件进行分片上传以及对分片的文件进行合并完整的文件
五、对分片的文件进行合并
/** * 合并文件(针对文件的分割后进行合并) * * @param srcFile * srcFile 分片文件
* fileSubfixx 文件后缀
* targetFileName 保存为目标文件的文件名 */ private static void mergeFile(File srcFile,int totalSplice,String fileSubfixx,String targetFileName) throws IOException { ArrayList<FileInputStream> al = new ArrayList<FileInputStream>(); //这里的for循环就是有多少个分片的文件,这里的变量自行控制哈,而且x变量需要根据自己分片保存的下标来决定开始变量
for (int x = 0; x <= totalSplice; x++) { // 将要合并的碎片封装成对象 al.add(new FileInputStream(new File(srcFile, x + fileSubfixx))); } Enumeration<FileInputStream> en = Collections.enumeration(al); SequenceInputStream sis = new SequenceInputStream(en); // 将合成的文件封装成一个文件对象 FileOutputStream fos = new FileOutputStream(new File(srcFile, targetFileName)); try { int len = 0; byte buf[] = new byte[1024 * 1024]; while ((len = sis.read(buf)) != -1) { fos.write(buf, 0, len); } } catch (Exception e) { } finally { fos.close(); sis.close(); } }