一、背景
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(); } }