java下載網路大檔案之記憶體不夠的解決辦法(包含分片上傳分片下載)

QH.Thomas發表於2022-02-08

一、背景

        2020年11月份的時候,我做過一個專案,涉及到網路檔案,比如第三方介面提供一個檔案的下載地址,使用java去下載,當時我全部加在到JVM記憶體裡面,話說,單單是80M的下載單執行緒沒問題,但是當時處於開發階段,沒注意到該問題,到了上線,同事負責測試,也沒問題(主要的當時是4個人測試,也沒發現記憶體洩漏問題,原因在於使用者了少,佔的記憶體也小),所以當時直接測試通過,並且上線。

 

       客戶那邊進行驗收測試,當時應該測試的人也不多,但是他們選擇的檔案100M以內的,而且是進行了一個,在等待是,又進行一個,也即是說類似於壓測。頓時爆發問題。一查詢日誌顯示,記憶體洩漏,俗稱JVM:OutOfMemorry。

 

二、解決辦法

至於針對這種情況,專案經理說有兩種辦法解決(下面會分別講解這兩種解決辦法的原始碼)

第一:分片下載檔案、分片上傳檔案

第二:把檔案下載到磁碟(在linux系統也是一樣,指定下載到目錄,再分片讀取上傳)

 

三、分片下載檔案、分片上傳檔案解決方案以及原始碼

    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(); } }

 



相關文章