文件導出
官方文檔:寫Excel | Easy Excel (alibaba.com)
引言
當使用 EasyExcel 進行 Excel 文件導出時,我最近在工作中遇到了一個需求。因此,我決定寫這篇文章來分享我的經驗和解決方案。如果你對這個話題感興趣,那么我希望這篇文章對你有所幫助。
本文的目標是介紹 EasyExcel 的基本概念、使用方法以及解決特定問題的技巧。通過使用 EasyExcel,我們可以提高文件導出的效率,簡化代碼,并實現更靈活的數據導出。
在閱讀完本文后,你將能夠了解 EasyExcel 的核心功能和常用操作,掌握如何根據實際需求進行配置和定制。此外,我還將分享一些實用的技巧和最佳實踐,幫助你更好地利用 EasyExcel 完成文件導出任務。
最后,我要再次感謝你的關注和支持。如果你覺得這篇文章對你有所幫助,請不要吝嗇點贊、收藏和關注我的其他文章或資源。這將是對我最大的鼓勵和支持!😘
文章內容會持續更新!!!
為何選擇 EasyExcel 而不是 POI?
選擇使用 EasyExcel 而不是 POI 的原因主要有以下幾點:
- EasyExcel 在盡可能節約內存的情況下支持讀寫大型 Excel 文件。具體來說,它通過一行一返回的方式解決了 POI 解析 Excel 非常耗費內存的問題。
- EasyExcel 是開源的,代碼放在 GitHub 上,如果遇到問題,可以隨時提出 issue。
- EasyExcel 社區活躍,網上的相關文檔也比較多,這對于使用者來說是一個很大的優勢。
- 雖然 POI 是目前使用最多的用來做 excel 解析的框架,但其 userModel 模式在處理大文件時存在明顯的缺陷,比如內存消耗大和有并發問題等。而 EasyExcel 則很好地解決了這些問題。
- EasyExcel 底層對象其實還是使用 poi 包的那一套,只是將 poi 包的一部分抽了出來,摒棄掉了大部分業務相關的屬性。
總的來說,EasyExcel 在處理大數據量的 Excel 文件導出方面,相比 POI 具有明顯的優勢,這也是為什么越來越多人選擇使用 EasyExcel 的原因。
簡單來說就是,因為 EasyExcel 性能更好。
EasyExcel 簡介
EasyExcel 是一個基于 Java 的開源庫,用于簡化和優化 Excel 文件的讀寫操作。它提供了一種簡單而高效的方式來處理大量數據的導入和導出,特別適用于大數據量的處理。
EasyExcel 具有以下特點:
- 高性能:通過使用高效的數據模型和批量寫入技術,EasyExcel 能夠快速地處理大量數據,提高文件導出的效率。
- 靈活性:EasyExcel 支持多種數據類型和格式,可以方便地導出各種類型的數據,包括文本、數字、日期等。
- 簡潔的 API:EasyExcel 提供了簡潔易用的 API,使得開發者可以快速上手并實現文件導出功能。
Date 字段問題
報錯
{"code": "1","message": "導出文件失敗:java.lang.NoSuchMethodError: org.apache.poi.ss.usermodel.Cell.setCellValue(Ljava/time/LocalDateTime;)V"
}
這是因為要導出的列里面有 Date
類型,EasyExcel 識別不了
解決方法
1、編寫 Date 轉換器
public class DateConverter implements Converter<Date> {private static final String PATTERN_YYYY_MM_DD = "yyyy-MM-dd";@Overridepublic Class<Date> supportJavaTypeKey() {return Date.class;}/*** easyExcel導出數據類型轉換* @param cellData* @param contentProperty* @param globalConfiguration* @return* @throws Exception*/@Overridepublic Date convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {String value = cellData.getStringValue();SimpleDateFormat sdf = new SimpleDateFormat(PATTERN_YYYY_MM_DD);return sdf.parse(value);}/*** easyExcel導入Date數據類型轉換* @param context* @return* @throws Exception*/@Overridepublic WriteCellData<String> convertToExcelData(WriteConverterContext<Date> context) throws Exception {Date date = context.getValue();if (date == null) {return null;}SimpleDateFormat sdf = new SimpleDateFormat(PATTERN_YYYY_MM_DD);return new WriteCellData<>(sdf.format(date));}}
然后,修改導出視圖類中的 Date 類型字段,加入 converter = DateConverterUtil.class
轉換屬性。
@ExcelProperty(value = "最新維保時間", index = 3, converter = DateConverter.class)@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")private Date time;
2、用官方的注解
使用 EasyExcel 的 @DateTimeFormat
注解:
// 字段類型為String,否則注解可能會無效
@DateTimeFormat(value = "yyyy-MM-dd HH:mm:ss")
private String alarmTime;
對象轉換使用案例:
1、在需要使用時,轉換時間值
// 1、獲取導出列表List<Alarm> list = alarmService.list();List<AlarmExportVO> list2 = list.stream().map(item -> {AlarmExportVO exportVO = new AlarmExportVO();exportVO.setEnterpriseTown(item.getEnterpriseTown());exportVO.setMarketSupervision(item.getMarketSupervision());exportVO.setDeviceName(item.getDeviceName());exportVO.setAlarmType(item.getAlarmType());exportVO.setAlarmLevel(item.getAlarmLevel());// item.getAlarmTime() 返回的是一個 Date 對象Date alarmTime = item.getAlarmTime(); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");String formattedTime = sdf.format(alarmTime);exportVO.setAlarmTime(formattedTime);
2、在實體類中進行修改,重寫 get 方法
public String getAlarmTime() {SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");return sdf.format(alarmTime);}
自定義字典映射轉換器
反例:
直接用 @Resource
注入 bean 使用,會報錯,不被 Spring IoC 管理。
List<DictDTO> dictList = documentMapper.getDict(CERTIFICATE);
報錯:
注入的 bean
,查數據庫的時候,報 空指針異常
,導致轉換數據的時候第一條字典映射轉換就失敗
原因猜測:
- 在 EasyExcel 中,轉換器通常是默認通過構造函數
new
出來的,而不是由 Spring 容器管理的Bean
。 - 因此,在轉換器中,如果你需要使用 Spring 容器中的其他
Bean
,你需要手動獲取這些 Bean,而不是通過 Spring 的依賴注入。
💡 Spring 管理的 bean 通常是由 Spring 容器負責創建、配置和管理的。當你使用
new
運算符直接實例化一個對象時,這個對象不會由 Spring 容器來管理,因此 Spring 不會介入該對象的生命周期和依賴注入。
解決方法
通過 Spring 容器提供的方法來獲取已經由 Spring 管理的 bean。
DocumentMapper bean = SpringUtil.getBean(DocumentMapper.class);List<DictDTO> dictList = bean.getDict(CERTIFICATE);
參考:
EasyExcel 使用Converter 轉換注入時報nullPoint異常_converter null入參_地平線上的新曙光的博客-CSDN博客
完整代碼:
public class DocumentDictConverter implements Converter<String> {@Overridepublic Class<?> supportJavaTypeKey() {return String.class;}@Overridepublic CellDataTypeEnum supportExcelTypeKey() {return CellDataTypeEnum.STRING;}@Overridepublic String convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {return cellData.getStringValue();}/*** 這里是寫的時候會調用** @return*/@Overridepublic WriteCellData<?> convertToExcelData(WriteConverterContext<String> context) {// 獲取字典列表DocumentMapper bean = SpringUtil.getBean(DocumentMapper.class);List<DictDTO> dictList = bean.getDict(CERTIFICATE);HashMap<String, String> dictMap = new HashMap<>(16);for (DictDTO dictDTO : dictList) {dictMap.put(dictDTO.getDictValue(), dictDTO.getDictName());}// 根據字典映射進行值轉換String convertedValue = dictMap.get(context.getValue());if (convertedValue != null) {return new WriteCellData<>(convertedValue);} else {return new WriteCellData<>(context.getValue());}}}
性能問題
每次做字段值映射轉換的時候都需要查數據庫,太影響性能了。
- 考慮引入緩存,達到只需查詢一次數據即可。(使用 Spring 框架的緩存注解
@Cacheable
) - 將字典數據在服務啟動時加載到內存中,并在轉換器中直接使用內存中的字典數據而不是每次都查詢數據庫。
- 聲明靜態變量解決。(推薦)
解決方案
一、使用 Spring 框架的緩存注解
參考筆者這篇文章:Cacheable注解小記 | DreamRain
使用說明:
- 該方法使用
@Cacheable("dictionaryCache")
注解來定義緩存區域為 “dictionaryCache”,并且可以根據dictionaryType
參數來查詢字典數據。 - 當方法第一次被調用時,數據將被查詢并放入緩存中,以后的調用將直接從緩存中獲取數據。
- 緩存找不到時會報錯,轉換失敗。
二、將字典數據在服務啟動時加載到內存中
- 創建一個單例的字典數據加載類,該類在應用啟動時加載字典數據到內存中。你可以使用
@PostConstruct
注解來標記一個初始化方法,該方法在 Spring 容器加載完所有 bean 后執行。
@Service
public class DictionaryDataService {private Map<String, String> certificateDict = new HashMap<>();@Autowiredprivate DocumentExpiredMapper documentExpiredMapper;// 這個方法會在 bean 初始化時被自動調用@PostConstructpublic void init() {// 在 bean 初始化時執行一些初始化操作List<DictDTO> dictList = documentExpiredMapper.getDict(CERTIFICATE);for (DictDTO dictDTO : dictList) {certificateDict.put(dictDTO.getDictValue(), dictDTO.getDictName());}}public String getCertificateDictValue(String key) {return certificateDict.get(key);}
}
2、修改轉換器類,使用內存中的字典數據進行轉換:
@Overridepublic WriteCellData<?> convertToExcelData(WriteConverterContext<String> context) {DictionaryDataService bean = SpringUtil.getBean(DictionaryDataService.class);String convertedValue = bean.getCertificateDictValue(context.getValue());if (convertedValue != null) {return new WriteCellData<>(convertedValue);} else {return new WriteCellData<>(context.getValue());}}
通過這種方式,字典數據在應用啟動時加載到內存中,以后的值轉換操作都會使用內存中的數據,避免了重復查詢數據庫的性能開銷。這是一種常見的性能優化方法。
三、聲明靜態變量解決(推薦)
- 在每次調用導出接口時都查一次數據庫,并只需查詢一次。
- 從而減輕了數據值映射轉換時查詢數據庫的壓力,并且確保數據為最新數據,還無需考慮刪除緩存問題。
@Service
public class DictionaryService {// 聲明靜態變量存儲字典數據public static HashMap<String, HashMap<String, String>> hashMap = new HashMap<>();@Resourceprivate DocumentExpiredMapper documentExpiredMapper;/*** 將字典數據存入靜態變量* @param dictionaryType* @return*/public HashMap<String, String> getDict(String dictionaryType) {if (hashMap.containsKey(dictionaryType)){return hashMap.get(dictionaryType);}List<DictDTO> dictList = documentExpiredMapper.getDict(dictionaryType);HashMap<String, String> dictMap = new HashMap<>(dictList.size());for (DictDTO dictDTO : dictList) {dictMap.put(dictDTO.getDictValue(), dictDTO.getDictName());}hashMap.put(dictionaryType, dictMap);return dictMap;}}
前兩者優劣分析
第一種方法(DictionaryService
使用緩存):
優點:
- 使用了
@Cacheable
注解,Spring 會自動處理緩存相關邏輯,包括緩存的清除、存儲、失效等,減輕了你的工作負擔。 - 緩存數據在運行時動態從數據庫中獲取,因此數據保持最新,不需要手動更新。
- 可以靈活地在其他地方使用
DictionaryService
服務,而不需要關心緩存細節。
缺點:
- 需要依賴 Spring 緩存機制,可能需要較多配置和依賴,不如手動控制靈活。
- 當有多個不同字典類型需要緩存時,可能需要創建多個不同的緩存,增加了管理復雜度。
第二種方法(DictionaryDataService
使用內存緩存):
優點:
- 簡單明了,不依賴 Spring 緩存機制,適用于小規模應用或特定場景。
- 在應用啟動時加載字典數據到內存中,查詢字典數據的速度非常快,適用于頻繁查詢字典數據的場景。
缺點:
- 手動加載字典數據到內存,如果數據庫中的數據發生變化,需要手動同步內存數據,容易出現數據不一致的問題。
- 不支持自動過期和失效處理,需要自己編寫邏輯來處理緩存的更新和失效。
- 在大規模應用中,如果內存占用較多,可能會影響應用性能。
場景分析:
- 如果你的應用要求字典數據保持實時性,能夠自動過期和更新,使用第一種方法更為合適。
- 如果應用規模較小、字典數據變化不頻繁,或者希望簡化配置,第二種方法也是一個不錯的選擇。
- 理論上來說,第一種用的更為廣泛
@PostConstruct 注解
@PostConstruct
是 Java EE(Enterprise Edition)的注解之一,它標識在類實例化后,但在類投入使用之前要執行的方法。通常在使用 Spring 框架或其他依賴注入框架時,@PostConstruct
注解用于在 bean 的初始化過程中執行一些額外的初始化操作。以下是關于 @PostConstruct
注解的一些重要信息:
- 生命周期回調方法:
@PostConstruct
用于定義在 bean 的生命周期中何時應該執行的初始化方法。它提供了一個方便的方式來執行一些準備工作,如數據加載、資源初始化等。 - 執行時機:
@PostConstruct
注解的方法會在 Spring 容器創建 bean 實例后,依賴注入之前執行。這意味著它是在 bean 的構造函數之后,依賴注入之前執行的,用于初始化 bean 的各種屬性。 - 方法簽名:被
@PostConstruct
注解的方法沒有參數。方法名可以隨意命名,但通常為init
、initialize
、postConstruct
等。 - 依賴注入和容器管理:
@PostConstruct
注解通常與依賴注入和容器管理框架(如 Spring、Java EE 容器等)一起使用。容器會在執行構造函數和依賴注入后,自動調用被@PostConstruct
注解的初始化方法。 - 異常處理:如果
@PostConstruct
注解的方法拋出異常,容器會將異常捕獲并處理,通常會導致 bean 創建失敗。這可以用于在初始化階段檢測配置錯誤或其他問題。 - 多次調用:
@PostConstruct
注解的方法只會被調用一次,即使 bean 在容器中被多次注入也是如此。 - 典型用途:
@PostConstruct
常用于執行一些需要在 bean 初始化時進行的操作,例如數據庫連接的建立、資源初始化、數據加載等。
內存與緩存
以下是 “加載到內存” 和 “放入緩存” 的相關概念。
-
加載到內存:
- 加載到內存通常指將數據、資源或對象從持久存儲(如硬盤或數據庫)加載到【計算機的內存】中,以便在應用程序中使用。
- 這是一個通用操作,常見于應用程序的啟動過程或在需要訪問數據時。
-
放入緩存:
- 放入緩存是一種特定的加載到內存操作,它指的是將數據或計算結果存儲在一個【臨時存儲區域】中,通常在內存中,以提高后續訪問的性能。
- 緩存通常包括緩存鍵(用于檢索數據)和緩存值(實際數據或計算結果)。
-
內存加載的情況:
-
內存加載可能是一次性的,例如在應用程序啟動時加載配置文件。(一次性初始化)
-
內存加載也可能是動態的,例如從數據庫中加載實時數據或通過用戶請求加載。
- 如果數據的變化頻率較低,可以使用定時刷新。
- 如果數據變化頻繁,懶加載或異步加載可能更合適。
- 緩存加載器適用于需要復雜邏輯來獲取數據的情況。
-
-
緩存的情況:
- 緩存是一種性能優化技術,通過將頻繁訪問的數據存儲在內存中,以減少重復訪問持久存儲的開銷。
- 緩存通常采用一定策略,例如緩存過期時間或根據內存大小來管理緩存。
綜上所述:
- 加載到內存是一種廣泛的操作,它可以用于不同的用途,
- 而緩存是一種內存加載的具體應用,它的主要目的是提高數據訪問的性能。緩存通常包括一些管理策略,以確保緩存數據的有效性和一致性。
使用案例
參考官方文檔案例:web中的寫并且失敗的時候返回json
@Overridepublic void exportDocument(DocumentDTO documentDTO, HttpServletResponse response) throws IOException {// 1、獲取證件臨期告警列表List<DocumentVO> documentList = getDocumentList(documentDTO);// 2、 Excel文件標題String title = "Excel文件標題";// 3、告警類型值替換List<DocumentExportVO> exportVOList = BeanUtil.copy(documentList, DocumentExportVO.class);// 4、EasyExcel導出try {// 設置內容類型response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");// 設置字符編碼response.setCharacterEncoding("utf-8");// 這里URLEncoder.encode可以防止中文亂碼 當然和easy excel沒有關系String fileName = URLEncoder.encode(title, "UTF-8").replaceAll("\\+", "%20");// 設置響應頭response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");// 這里需要設置不關閉流EasyExcel.write(response.getOutputStream(), DocumentExportVO.class).autoCloseStream(Boolean.FALSE).sheet("模板").doWrite(exportVOList);} catch (Exception e) {// 重置responseresponse.reset();String errorMessage = "導出文件失敗:" + e.getMessage();returnResult(response, errorMessage);// e.printStackTrace();}}
代碼規范問題:
// 將錯誤信息全部打印在控制臺
e.printStackTrace();
- 全部錯誤信息打印出來,有助于排查
轉換器類
的問題(不會打印到日志文件中,但會一直刷控制臺) - 但是一般生產情況不能打印出來,因為可能會引發事故
導出圖片并壓縮
代碼倉庫:excel-demo
要壓縮圖片,您可以使用 Java 中的圖像處理庫,例如 ImageIO 或 Thumbnails 庫。
下面是使用 Thumbnails 庫壓縮圖片的示例講述。
1、添加依賴
首先,確保您已將 Thumbnails 庫添加到您的項目依賴項中。在 Maven 項目中,您可以在 pom.xml
文件中添加以下依賴項:
<dependency><groupId>net.coobird</groupId><artifactId>thumbnailator</artifactId><version>0.4.14</version>
</dependency>
2、使用 Thumbnails 庫內部方法
-
使用
File.createTempFile()
方法創建一個臨時文件,然后通過toFile()
方法將壓縮后的圖片保存到臨時文件中。最后,將臨時文件的路徑設置到ExcelExportVO
對象的file
屬性中。臨時文件的生命周期由操作系統管理,通常在程序退出后會自動刪除。(可以設置手動刪除)
-
使用
Thumbnails.of()
方法加載原始圖片,然后通過scale()
方法設置壓縮比例。
try {// 1.1 壓縮圖片并保存到臨時文件File compressedFile = File.createTempFile("compressed_image", ".jpg");// 1.2 壓縮圖片Thumbnails.of(new URL(item.getFile())).scale(0.5) // 設置壓縮比例.toFile(compressedFile);// 1.3 將臨時文件路徑設置到ExcelExportVO對象中exportVO.setFile(compressedFile.toURI().toURL());} catch (MalformedURLException e) {throw new RuntimeException(e);} catch (IOException e) {throw new RuntimeException("壓縮圖片失敗!!!", e);}
壓縮比例講解
選擇壓縮比例的大小取決于您的需求和偏好,以及圖像的具體情況。
- 較高的壓縮比例會導致圖像更大程度地被壓縮,文件大小更小,但可能會損失一些圖像細節和質量。
- 較低的壓縮比例可以保留更多的圖像細節和質量,但文件大小會相對較大。
總結
- 一般來說,如果您更關注圖像的質量和細節保留,可以選擇較低的壓縮比例,如 0.8。這樣可以在一定程度上減小文件大小,同時保持圖像的視覺質量。
- 如果您更關注文件大小的減小,可以選擇較高的壓縮比例,如 0.5,以獲得更小的文件大小,但可能會犧牲一些圖像細節和質量。
- 壓縮比例值越小,文件大小就越小。
3、完整代碼示例
手動刪除資源的寫法
這種壓縮的效果會更好,但是需要手動刪除資源。
@Testvoid export() {// 1、獲取導出列表 List<TblExcel> list = excelService.list();List<ExcelExportVO> list2 = list.stream().map(item -> {ExcelExportVO exportVO = new ExcelExportVO();exportVO.setName(item.getName());try {// 1.1 壓縮圖片并保存到臨時文件File compressedFile = File.createTempFile("compressed_image", ".jpg");// 1.2 壓縮圖片Thumbnails.of(new URL(item.getFile())).scale(0.5) // 設置壓縮比例.toFile(compressedFile);// 1.3 將臨時文件路徑設置到ExcelExportVO對象中exportVO.setFile(compressedFile.toURI().toURL());} catch (MalformedURLException e) {throw new RuntimeException(e);} catch (IOException e) {throw new RuntimeException("壓縮圖片失敗!!!", e);}return exportVO;}).collect(Collectors.toList());// 2、導出EasyExcel.write("D:\\TblExcel.xls").sheet("模板").head(ExcelExportVO.class).doWrite(list2);// 3、使用完畢后手動刪除臨時文件for (ExcelExportVO exportVO : list2) {try {File compressedFile = new File(exportVO.getFile().toURI());if (!compressedFile.delete()) {// 刪除操作失敗,記錄日志或進行其他錯誤處理log.error("刪除臨時文件失敗: " + compressedFile.getAbsolutePath());}} catch (Exception e) {// 處理刪除臨時文件的異常e.printStackTrace();}}}
自動刪除資源的寫法
- 在使用
try-with-resources
來壓縮圖像并保存到臨時文件后,該臨時文件會在try-with-resources
塊結束時自動關閉。因此,我們無需另外手動操作刪除臨時文件。 - 但是這種方法壓縮的效果沒有上面那種方法好。
代碼如下:
/*** Thumbnails 壓縮圖片導出 -- 使用 try-with-resources 自動關閉資源版* 壓縮效果較差*/@Testvoid export11() {// 1、獲取導出列表List<TblExcel> list = excelService.list();List<ExcelExportVO> list2 = list.stream().map(item -> {ExcelExportVO exportVO = new ExcelExportVO();exportVO.setName(item.getName());try {// 壓縮圖像并保存到臨時文件File compressedFile;try (OutputStream outputStream = new FileOutputStream(compressedFile = File.createTempFile("compressed_image", ".jpg"))) {Thumbnails.of(new URL(item.getFile())).scale(0.5) // 設置壓縮比例.toOutputStream(outputStream);}// 將臨時文件路徑設置到ExcelExportVO對象中exportVO.setFile(compressedFile.toURI().toURL());} catch (IOException e) {throw new RuntimeException("壓縮圖片失敗!!!", e);}return exportVO;}).collect(Collectors.toList());// 2、導出EasyExcel.write("D:\\TblExcel.xls").sheet("模板").head(ExcelExportVO.class).doWrite(list2);}
原因分析
-
在第一個方法中,我使用了
Thumbnails.of(new URL(item.getFile())).scale(0.5).toFile(compressedFile);
這一行代碼來壓縮圖片。這個方法將壓縮后的圖片直接保存到了文件系統中,然后在 ExcelExportVO 對象中設置的是臨時文件的路徑。 -
而在第二個方法中,我使用了
Thumbnails.of(new URL(item.getFile())).scale(0.5).toOutputStream(outputStream);
這一行代碼來壓縮圖片。這個方法將壓縮后的圖片數據寫入到了一個輸出流(OutputStream)中,而沒有將其直接保存到文件系統。這意味著,雖然壓縮后的圖片數據被存儲在了內存中的字節數組中,但并沒有實際地創建一個新的臨時文件。因此,在第二個方法中,ExcelExportVO 對象中的文件路徑實際上指向的是一個尚未存在的臨時文件。
當 EasyExcel 將這些對象寫入到 Excel 文件時,它會嘗試打開每個 ExcelExportVO 對象中的文件路徑。在第一個方法中,因為臨時文件已經存在,所以 EasyExcel 可以成功地打開并讀取這些文件。但在第二個方法中,由于臨時文件并未實際創建,所以 EasyExcel 無法打開這些文件。
因此,盡管兩個方法都實現了壓縮圖片的功能,但由于第二個方法沒有將壓縮后的圖片數據實際保存到文件系統中,所以在導出的 Excel 文件中可能不會包含這些圖片數據,從而導致導出的文件更小。
總結
- 第一種,需要手寫代碼刪除臨時文件,但是壓縮效果好
- 第二種,不需要手寫代碼刪除臨時文件,但是壓縮效果較差。
圖片壓縮效果說明
測試數據:100 條記錄,每條記錄包含一張【圖片URL】。
- 壓縮前:Excel 文件大小為 67.8M
- 壓縮后:
- (手動刪除資源版)Excel 文件大小為 2.68M
- (自動關閉資源版)Excel 文件大小為 23.7M
- 選擇壓縮比例為 0.8 時,文件大小為 6.01M
指定字段導出
代碼如下:
// 根據用戶傳入字段 假設我們只要導出 fileSet<String> includeColumnFiledNames = new HashSet<String>();includeColumnFiledNames.add("file");// 2、導出EasyExcel.write("D:\\TblExcel.xls").sheet("模板").head(ExcelExportVO.class).includeColumnFieldNames(includeColumnFiledNames)// .includeColumnIndexes(Collections.singleton(1)).doWrite(list2);
具體說明:
-
includeColumnFieldNames 是根據字段名指定導出列(建議導出視圖 VO 不要指定 index 屬性,否則會有導出會有空列)
-
includeColumnIndexes 是根據索引指定導出列,即使實體類中沒有指定
index
屬性一樣可以使用-
List<Integer> columnList = Arrays.asList(0, 1, 2, 3, 4);
-
自動列寬
使用官方自帶的處理器:
// 自動列寬.registerWriteHandler((new LongestMatchColumnWidthStyleStrategy()))
具體說明:
- 可以自己根據官方的父類繼承,重寫處理器來使用;
- 也可結合【自動列寬處理器 + 實體類注解】來一起使用。
自定義自適應列寬處理策略:
- 使用動態表頭時,官方的自動列寬策略不夠好用,所以重寫了一下方法
- 以下內容是指定第四列的列寬,其余列自定義
public class CustomColumnWidthStyleStrategy extends AbstractColumnWidthStyleStrategy {public CustomColumnWidthStyleStrategy() {}@Overrideprotected void setColumnWidth(WriteSheetHolder writeSheetHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) {Sheet sheet = writeSheetHolder.getSheet();sheet.setColumnWidth(3, 8000);}}
合并單元格導出
1、自定義Excel合并表格策略
- 需要實現 CellWriteHandler,Cell 是列,ROW 是行
- 這里針對的是列合并處理
public class CustomMergeStrategy implements CellWriteHandler {/*** 合并列索引。*/private final List<Integer> mergeColumnIndexes;/*** 構造函數。** @param mergeColumnIndexes 合并列索引集合。*/public CustomMergeStrategy(List<Integer> mergeColumnIndexes) {this.mergeColumnIndexes = mergeColumnIndexes;}@Overridepublic void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) {// 校驗:如果當前是表頭,則不處理。if (isHead) {return;}// 校驗:如果當前是第一行,則不處理。if (relativeRowIndex == 0) {return;}// 校驗:如果當前列索引不在合并列索引列表中,則不處理。Integer columnIndex = cellDataList.get(0).getColumnIndex();if (!this.mergeColumnIndexes.contains(columnIndex)) {return;}// 獲取:當前表格、當前行下標、上一行下標、上一行對象、上一列對象。Sheet sheet = cell.getSheet();int rowIndexCurrent = cell.getRowIndex();int rowIndexPrev = rowIndexCurrent - 1;Row rowPrev = sheet.getRow(rowIndexPrev);Cell cellPrev = rowPrev.getCell(cell.getColumnIndex());// 獲取:當前單元格值、上一單元格值。Object cellValueCurrent = cell.getCellTypeEnum() == CellType.STRING ? cell.getStringCellValue() : cell.getNumericCellValue();Object cellValuePrev = cellPrev.getCellTypeEnum() == CellType.STRING ? cellPrev.getStringCellValue() : cellPrev.getNumericCellValue();// 校驗:如果當前單元格值與上一單元格值不相等,則不處理。if (!cellValueCurrent.equals(cellValuePrev)) {return;}List<CellRangeAddress> mergedRegions = sheet.getMergedRegions();boolean merged = false;for (int i = 0; i < mergedRegions.size(); i++) {CellRangeAddress cellRangeAddress = mergedRegions.get(i);if (cellRangeAddress.isInRange(rowIndexPrev, cell.getColumnIndex())) {// 移除合并單元格。sheet.removeMergedRegion(i);// 設置合并單元格的結束行。cellRangeAddress.setLastRow(rowIndexCurrent);// 重新添加合并單元格。sheet.addMergedRegion(cellRangeAddress);merged = true;break;}}if (!merged) {CellRangeAddress cellRangeAddress = new CellRangeAddress(rowIndexPrev, rowIndexCurrent, cell.getColumnIndex(), cell.getColumnIndex());sheet.addMergedRegion(cellRangeAddress);}}}
2、實際使用
// ......// 導出EasyExcel.write(response.getOutputStream()).head(headList)// 自動列寬.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())// .registerWriteHandler(new CustomMergeStrategy(Arrays.asList(1, 2))).registerWriteHandler(new CustomMergeStrategy(Collections.singletonList(1))).autoCloseStream(Boolean.FALSE).sheet("模板").doWrite(resultList);// ......// 獲取表格頭列表private List<List<String>> getHeadList(SettleDTO settleDTO) {List<List<String>> headList = new ArrayList<>();Date startDate = DateUtil.parse(settleDTO.getStartDay(), "yyyy-MM-dd");Date endDate = DateUtil.parse(settleDTO.getEndDay(), "yyyy-MM-dd");SimpleDateFormat sdf = new SimpleDateFormat("MM月dd日");String startDay = sdf.format(startDate);String endDay = sdf.format(endDate);String day = startDay + "-" + endDay;ArrayList<String> headColumn1 = new ArrayList<>();headColumn1.add("第一列");headList.add(headColumn1);ArrayList<String> headColumn2 = new ArrayList<>();headColumn2.add("第二列");headList.add(headColumn2);ArrayList<String> headColumn3 = new ArrayList<>();headColumn3.add(day);headColumn3.add("第三列");headList.add(headColumn3);return headList;}
代碼解析
拓展性說明:
- 做了一個拓展性處理,可根據列索引來指定需要執行合并的列。
以下是 afterCellDispose
方法的各參數的含義:
-
WriteSheetHolder writeSheetHolder
:當前正在寫入的 Sheet 的持有者。它包含有關當前 Sheet 的信息,例如 Sheet 的索引、當前行的索引以及其他相關詳細信息。 -
WriteTableHolder writeTableHolder
:當前正在寫入的表格的持有者。它包含有關當前表格的信息,例如表格的名稱、起始行的索引以及其他相關詳細信息。 -
List<WriteCellData<?>> cellDataList
:這是一個WriteCellData
對象的列表,表示要寫入當前單元格的數據。如果單元格跨越多個列或行,該列表可能包含多個WriteCellData
對象。 -
Cell cell
:表示當前正在處理的單元格。它提供有關單元格位置、樣式和其他屬性的信息。 -
Head head
:表示當前單元格的頭部(標題)。它包含有關頭部的信息,如字段名、類類型以及其他相關詳細信息。-
如果傳入的不是一個
class
文件(實體類)時,head 可能會為 null,例如上面示例(.head(headList)
),傳入的是個列表集合,此時 head 為 null
-
-
Integer relativeRowIndex
:表示當前行在當前 Sheet 中的相對索引。一開始就是表頭之下的第一行。 -
Boolean isHead
:這是一個布爾標志,指示當前單元格是否為表頭單元格。如果isHead
為true
,則表示當前單元格是表頭單元格;否則,它是數據單元格。
總的來說,這些參數提供了有關當前 Excel 寫入過程狀態的上下文信息。它們允許您基于正在處理的 Sheet、表格、單元格和頭部,以及當前行在 Sheet 中的位置執行自定義邏輯。
打包成壓縮包導出
壓縮包導出有以下兩種情況:
- 指定本地路徑導出
- 寫入響應流導出
指定路徑導出
一、可以使用 hutool 的 ZipUtil 工具類
/*** 壓縮包導出 -- hutool*/@Testvoid export6() throws IOException {List<TblExcel> list = new ArrayList<>();list.add(new TblExcel("張三", "abc"));List<ByteArrayInputStream> ins = new ArrayList<>();// 導出第一個ExcelByteArrayOutputStream out1 = new ByteArrayOutputStream();EasyExcel.write(out1, TblExcel.class).sheet("第一個").doWrite(list);ins.add(new ByteArrayInputStream(out1.toByteArray()));// 導出第二個ExcelByteArrayOutputStream out2 = new ByteArrayOutputStream();EasyExcel.write(out2, TblExcel.class).sheet("第二個").doWrite(list);ins.add(new ByteArrayInputStream(out2.toByteArray()));// 將多個 InputStream 壓縮到一個 zip 文件File zipFile = new File("C:\\Users\\喬\\Desktop\\noModelWrite.zip");String[] fileNames = {"1.xlsx", "2.xlsx"};InputStream[] inputStreams = ins.toArray(new InputStream[0]);cn.hutool.core.util.ZipUtil.zip(zipFile, fileNames, inputStreams);}
二、手寫一個 ZipUtil 工具類
public class ZipUtil {/*** 默認編碼,使用平臺相關編碼*/private static final Charset DEFAULT_CHARSET = Charset.defaultCharset();/*** 將文件流壓縮到目標流中** @param out 目標流,壓縮完成自動關閉* @param fileNames 流數據在壓縮文件中的路徑或文件名* @param ins 要壓縮的源,添加完成后自動關閉流*/public static void zip(OutputStream out, List<String> fileNames, List<InputStream> ins) throws IOException {zip(out, fileNames.toArray(new String[0]), ins.toArray(new InputStream[0]));}/*** 將文件流壓縮到目標流中** @param out 目標流,壓縮完成自動關閉* @param fileNames 流數據在壓縮文件中的路徑或文件名* @param ins 要壓縮的源,添加完成后自動關閉流*/public static void zip(File out, List<String> fileNames, List<InputStream> ins) throws IOException {FileOutputStream outputStream = new FileOutputStream(out);zip(outputStream, fileNames.toArray(new String[0]), ins.toArray(new InputStream[0]));outputStream.flush();}/*** 將文件流壓縮到目標流中** @param out 目標流,壓縮完成自動關閉* @param fileNames 流數據在壓縮文件中的路徑或文件名* @param ins 要壓縮的源,添加完成后自動關閉流*/public static void zip(OutputStream out, String[] fileNames, InputStream[] ins) throws IOException {ZipOutputStream zipOutputStream = null;try {zipOutputStream = getZipOutputStream(out, DEFAULT_CHARSET);zip(zipOutputStream, fileNames, ins);} catch (IOException e) {throw new IOException("壓縮包導出失敗!", e);} finally {IOUtils.closeQuietly(zipOutputStream);}}/*** 將文件流壓縮到目標流中** @param zipOutputStream 目標流,壓縮完成不關閉* @param fileNames 流數據在壓縮文件中的路徑或文件名* @param ins 要壓縮的源,添加完成后自動關閉流* @throws IOException IO異常*/public static void zip(ZipOutputStream zipOutputStream, String[] fileNames, InputStream[] ins) throws IOException {if (ArrayUtils.isEmpty(fileNames) || ArrayUtils.isEmpty(ins)) {throw new IllegalArgumentException("文件名不能為空!");}if (fileNames.length != ins.length) {throw new IllegalArgumentException("文件名長度與輸入流長度不一致!");}for (int i = 0; i < fileNames.length; i++) {add(ins[i], fileNames[i], zipOutputStream);}}/*** 添加文件流到壓縮包,添加后關閉流** @param in 需要壓縮的輸入流,使用完后自動關閉* @param fileName 壓縮的路徑* @param out 壓縮文件存儲對象* @throws IOException IO異常*/private static void add(InputStream in, String fileName, ZipOutputStream out) throws IOException {if (null == in) {return;}try {out.putNextEntry(new ZipEntry(fileName));IOUtils.copy(in, out);} catch (IOException e) {throw new IOException(e);} finally {IOUtils.closeQuietly(in);closeEntry(out);}}/*** 獲得 {@link ZipOutputStream}** @param out 壓縮文件流* @param charset 編碼* @return {@link ZipOutputStream}*/private static ZipOutputStream getZipOutputStream(OutputStream out, Charset charset) {if (out instanceof ZipOutputStream) {return (ZipOutputStream) out;}return new ZipOutputStream(out, DEFAULT_CHARSET);}/*** 關閉當前Entry,繼續下一個Entry** @param out ZipOutputStream*/private static void closeEntry(ZipOutputStream out) {try {out.closeEntry();} catch (IOException e) {// ignore}}
}
測試代碼如下:
@Testvoid export5() throws IOException {List<TblExcel> list = new ArrayList<>();list.add(new TblExcel("張三", "abc"));List<InputStream> ins = new ArrayList<>();OutputStream out1 = new ByteArrayOutputStream();OutputStream out2 = new ByteArrayOutputStream();// 2、導出EasyExcel.write(out1).sheet("第一個").head(ExcelExportVO.class).doWrite(list2);ins.add(outputStream2InputStream(out1)); // 寫法可參考上一個 hutool 的示例EasyExcel.write(out2).sheet("第二個").head(ExcelExportVO.class).doWrite(list2);ins.add(outputStream2InputStream(out2));File zipFile = new File("C:\\Users\\喬\\Desktop\\noModelWrite.zip");// 壓縮包內流的文件名List<String> paths = Arrays.asList("1.xlsx", "2.xlsx");ZipUtil.zip(zipFile, paths, ins); // 工具類使用}/*** 輸出流轉輸入流;數據量過大請使用其他方法** @param out* @return*/private ByteArrayInputStream outputStream2InputStream(OutputStream out) {Objects.requireNonNull(out);ByteArrayOutputStream bos;bos = (ByteArrayOutputStream) out;return new ByteArrayInputStream(bos.toByteArray());}
寫入響應流導出
@Overridepublic void exportSettleZip(TestDTO dto, HttpServletResponse response) throws IOException {// 1、參數校驗if (StringUtils.isEmpty(dto.getStartDay()) || StringUtils.isEmpty(dto.getEndDay())) {throw new IllegalArgumentException("日期不能為空!!");}// 2、獲取所有入駐企業的行業類型List<String> filedTypes = FiledTypeEnum.getValues();// 3、所有Excel導出并壓縮ByteArrayOutputStream zipStream = new ByteArrayOutputStream();try (ZipOutputStream zipOut = new ZipOutputStream(zipStream)) { // zipOutfor (String filedType : filedTypes) {// 獲取當前行業類型的入駐企業數據List<TestVO> entSettleList = enterpriseMapper.getEntSettleList(dto, filedType);// 創建一個字節流,用于存儲當前行業類型的 Excel 數據ByteArrayOutputStream excelStream = new ByteArrayOutputStream();// 使用 EasyExcel 導出 Excel 數據EasyExcel.write(excelStream).head(getHeadList(dto)) // 獲取表頭.registerWriteHandler(new CustomColumnWidthStyleStrategy()) // 自適應列寬策略.registerWriteHandler(new CustomMergeStrategy(Collections.singletonList(1))) // 單元格合并策略.sheet(filedType + "模板") // 設置 Excel 表格名.doWrite(entSettleList); // 寫入 Excel 數據// 將 Excel 寫入 ZipOutputStream -- 這三行代碼用于將一個 Excel 文件的數據寫入到 ZIP 文件中ZipEntry zipEntry = new ZipEntry(filedType + "信息導出.xlsx"); // 表示 ZIP 文件中的一個文件名稱zipOut.putNextEntry(zipEntry); // 將剛剛創建的 ZipEntry 對象添加到 ZipOutputStream 中,表示開始寫入 ZIP 文件的一個新文件。zipOut.write(excelStream.toByteArray()); // 將之前在內存中生成的 Excel 文件數據寫入到 ZIP 文件中的當前條目// 關閉當前 ZipEntryzipOut.closeEntry();// 關閉當前 Excel 字節流excelStream.close();}}// 4、將壓縮包寫入響應流String start = dto.getStartDay().replaceAll("-", "");String end = dto.getEndDay().replaceAll("-", "");String zipName = "模板數據" + start + "-" + end + ".zip";zipName = URLEncoder.encode(zipName, "UTF-8");try {response.setContentType("application/zip");response.setHeader("Content-Disposition", "attachment;filename=" + zipName);response.getOutputStream().write(zipStream.toByteArray()); // 重點關注} finally {// 關閉 ByteArrayOutputStreamzipStream.close();}}
代碼用途
- 這段代碼實例,實現了根據不同的行業類型導出對應的 Excel 文件,并將這些 Excel 文件壓縮成一個 ZIP 文件。
- 主要用于在 Web 環境下導出 Excel 數據并進行壓縮,方便用戶一次性下載多個行業類型的數據。
以下是對代碼的詳細解釋
- **參數校驗:**檢查傳入的日期參數是否為空,若為空則拋出異常。
- **獲取所有的行業類型:**通過
FiledTypeEnum.getValues()
獲取所有的行業類型。(此方法在枚舉類里面定義) - **所有 Excel 導出并壓縮:**使用
ZipOutputStream
創建一個 ZIP 文件,然后遍歷所有行業類型,為每個行業類型生成對應的 Excel 文件,并將其寫入 ZIP 文件中。- 在每個行業類型的循環中,獲取當前行業類型的入駐企業數據。
- 創建一個
ByteArrayOutputStream
用于存儲當前行業類型的 Excel 數據。 - 使用 EasyExcel 導出 Excel 數據,包括設置表頭、列寬策略和單元格合并策略。
- 將當前行業類型的 Excel 數據寫入 ZIP 文件,并關閉當前 ZipEntry。
- 關閉當前 Excel 字節流。
- **將壓縮包寫入響應流:**將生成的 ZIP 文件寫入響應流,實現下載功能。設置響應頭的文件名,并使用
URLEncoder.encode
處理中文文件名。最后,關閉ByteArrayOutputStream
。
以下是一些關于流的概念說明
流(Stream)是用于在程序之間傳輸數據的抽象。流可以是輸入流(Input Stream),用于從某個源讀取數據,也可以是輸出流(Output Stream),用于將數據寫入某個目標。
現在來解釋一下這段代碼中各個流的用法:
- ByteArrayOutputStream: 這是一個字節數組輸出流,它會在內存中創建一個字節數組緩沖區,所有寫入到這個流的數據都會被保存在這個緩沖區中。在這段代碼中,用于將每個行業類型的 Excel 數據保存在內存中。
- 為什么需要它: 因為我們需要在內存中生成 Excel 數據,而不是將其寫入到硬盤。
EasyExcel.write
方法的參數是一個輸出流,而ByteArrayOutputStream
就是一個方便在內存中存儲字節數據的流。 - 為什么需要關閉: 關閉流是為了釋放占用的系統資源。在這里,通過
ByteArrayOutputStream
的close
方法,確保所有關聯的資源被釋放,尤其是關閉底層的字節數組。
- 為什么需要它: 因為我們需要在內存中生成 Excel 數據,而不是將其寫入到硬盤。
- ZipOutputStream: 這是一個用于寫入 ZIP 文件的輸出流。ZIP 文件是一種存檔文件,可以包含多個文件或目錄,通過壓縮來減小文件大小。
- 為什么需要它: 在這段代碼中,我們希望將每個行業類型的 Excel 數據寫入一個 ZIP 文件中,以便用戶可以一次性下載多個文件。
- 為什么需要關閉: 關閉
ZipOutputStream
將確保 ZIP 文件的完整性。在這里,通過zipOut.closeEntry()
來關閉當前 ZIP 文件的條目(即一個文件),并準備開始下一個 ZIP 條目。
- ZipEntry: 在 ZIP 文件中,每個文件或目錄都對應一個條目,這個條目就是
ZipEntry
。在這段代碼中,用于表示 ZIP 文件中的每個 Excel 文件。- 為什么需要它: 我們希望 ZIP 文件中有多個文件,每個文件對應一個行業類型的 Excel 數據。
ZipEntry
就是用于表示 ZIP 文件中的每個文件。 - 為什么需要關閉: 在
ZipOutputStream
中,每次調用putNextEntry
方法都會創建一個新的ZipEntry
,表示一個新的文件。通過zipOut.closeEntry()
來關閉當前 ZIP 條目,以確保下一次寫入時不會影響到前一個 ZIP 條目。
- 為什么需要它: 我們希望 ZIP 文件中有多個文件,每個文件對應一個行業類型的 Excel 數據。
總體來說,這些流的使用是為了在【內存】中生成多個 Excel 文件,并將這些文件寫入一個 ZIP 文件中,最終提供給用戶進行下載。關閉流是為了釋放資源,確保數據完整性。
學習參考
-
使用easyExcel導入導出Date類型的轉換問題 (mfbz.cn)
-
代碼規范:禁用e.printStackTrace()打印異常_e.printstacktrace()禁用-CSDN博客
-
代碼規范之e.printStackTrace()-CSDN博客
-
【溫情提醒】工作中要少用e.printStackTrace()的致命原因之一_printtrace問題-CSDN博客
-
視頻:Easy Excel 13:導出圖片內容
-
使用 easyExcel 生成多個 excel 并打包成zip壓縮包-CSDN博客