前言
Java解析、生成Excel比較有名的框架有Apache poi、jxl。但他們都存在一個嚴重的問題就是非常的耗內存,poi有一套SAX模式的API可以一定程度的解決一些內存溢出的問題,但POI還是有一些缺陷,比如07版Excel解壓縮以及解壓后存儲都是在內存中完成的,內存消耗依然很大。easyexcel重寫了poi對07版Excel的解析,能夠原本一個3M的excel用POI sax依然需要100M左右內存降低到幾M,並且再大的excel不會出現內存溢出,03版依賴POI的sax模式。在上層做了模型轉換的封裝,讓使用者更加簡單方便。
起步
- maven or gradle
- springboot
- api or blog
快速上手
簡單需求demo
- demo地址
喜歡直接看項目的可以直接 >> demo-easy-excel
-
內容大致瀏覽
-
引入easyexcel
引入easyexcel (maven為例),引入easyexcel
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>2.1.4</version>
</dependency>
- 自定義注解
/**
* @author quaint
* @since 17 February 2020
*/
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExcelPropertyNotNull {
/**
* @return 開啟校驗
*/
boolean open() default true;
/**
* @return 提示消息
*/
String message() default "";
/**
* @return 列號
*/
int col() default -1;
}
- 創建對應Excel的Dto
業務中有各種類型,這里基於java8常用的類型進行測試。
/**
* 父類 可能業務需要繼承
* @author quaint
* @date 2020-01-14 11:23
*/
@Data
public class DemoParentDto {
@ExcelProperty(index = 0,value = {"序號"})
private Integer num;
}
/**
* 子類 一般業務一個子類即可
* @author quaint
* @date 2020-01-14 11:20
*/
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
@NoArgsConstructor
@Data
public class DemoUserDto extends DemoParentDto{
@ExcelProperty(value = {"姓名"})
private String name;
@ExcelProperty(value = {"性別"})
private String sex;
/**
* @see LocalDateConverter (時間格式轉換器)LocalDateTime同理,代碼也會貼出來
*/
@ExcelProperty(value = "生日",converter = LocalDateConverter.class)
@DateTimeFormat("yyyy-MM-dd")
private LocalDate birthday;
@ExcelProperty(value = {"存款"})
@ExcelPropertyNotNull(message = "不可為空", col = 4)
private BigDecimal money;
/**
* 獲取6個測試數據
* @return 6個
*/
public static List<DemoUserDto> getUserDtoTest6(String search){
List<DemoUserDto> list = new ArrayList<>();
list.add(new DemoUserDto("quaint","男",LocalDate.of(2011,11,11),BigDecimal.ONE));
list.add(new DemoUserDto("quaint2","女",LocalDate.of(2001,11,1),BigDecimal.TEN));
list.add(new DemoUserDto("quaint3","男",LocalDate.of(2010,2,7),new BigDecimal(11.11)));
list.add(new DemoUserDto("quaint4","男",LocalDate.of(2011,1,11),new BigDecimal(10.24)));
list.add(new DemoUserDto("quaint5","女",LocalDate.of(2021,5,12),BigDecimal.ZERO));
list.add(new DemoUserDto(search,"男",LocalDate.of(2010,7,11),BigDecimal.TEN));
return list;
}
}
- 創建converter(導入導出時自定義轉換對應字段)
/**
* LocalDate and string converter
* @author quait
*/
public class LocalDateConverter implements Converter<LocalDate> {
@Override
public Class supportJavaTypeKey() {
return LocalDate.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return CellDataTypeEnum.STRING;
}
@Override
public LocalDate convertToJavaData(CellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration){
// 將excel 中的 數據 轉換為 LocalDate
if (contentProperty == null || contentProperty.getDateTimeFormatProperty() == null) {
return LocalDate.parse(cellData.getStringValue(), DateTimeFormatter.ISO_LOCAL_DATE);
} else {
// 獲取注解的 format 注意,注解需要導入這個 excel.annotation.format.DateTimeFormat;
return LocalDate.parse(cellData.getStringValue(),
DateTimeFormatter.ofPattern(contentProperty.getDateTimeFormatProperty().getFormat()));
}
}
@Override
public CellData convertToExcelData(LocalDate value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
// 將 LocalDateTime 轉換為 String
if (contentProperty == null || contentProperty.getDateTimeFormatProperty() == null) {
return new CellData(value.toString());
} else {
return new CellData(value.format(DateTimeFormatter.ofPattern(contentProperty.getDateTimeFormatProperty().getFormat())));
}
}
}
/**
* LocalDateTime and string converter
*
* @author quait
*/
public class LocalDateTimeConverter implements Converter<LocalDateTime> {
@Override
public Class supportJavaTypeKey() {
return LocalDateTime.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return CellDataTypeEnum.STRING;
}
@Override
public LocalDateTime convertToJavaData(CellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration){
// 將excel 中的 數據 轉換為 LocalDateTime
if (contentProperty == null || contentProperty.getDateTimeFormatProperty() == null) {
return LocalDateTime.parse(cellData.getStringValue(), DateTimeFormatter.ISO_LOCAL_DATE_TIME);
} else {
// 獲取注解的 format 注意,注解需要導入這個 excel.annotation.format.DateTimeFormat;
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(contentProperty.getDateTimeFormatProperty().getFormat());
return LocalDateTime.parse(cellData.getStringValue(), formatter);
}
}
@Override
public CellData convertToExcelData(LocalDateTime value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
// 將 LocalDateTime 轉換為 String
if (contentProperty == null || contentProperty.getDateTimeFormatProperty() == null) {
return new CellData(value.toString());
} else {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(contentProperty.getDateTimeFormatProperty().getFormat());
return new CellData(value.format(formatter));
}
}
}
- 創建Listener(監聽Excel導入)
/**
* 官方提示:有個很重要的點 DemoDataListener 不能被spring管理,要每次讀取excel都要new,然后里面用到spring可以構造方法傳進去
*
* 如果想被spring 管理的話, 改為原型模式, Controller 以 getBean 形式獲取 本博客展示被spring管理
* @author quaint
*/
@EqualsAndHashCode(callSuper = true)
@Slf4j
@Data
@Scope(SCOPE_PROTOTYPE)
@Component
public class DemoUserListener extends AnalysisEventListener<DemoUserDto> {
/**
* 每隔5條存儲數據庫,實際使用中可以3000條,然后清理list ,方便內存回收
*/
private static final int BATCH_COUNT = 5;
private List<DemoUserDto> list = new ArrayList<>();
/**
* 方式一
* 可以換成 @Autowired 注入 service 或者mapper
* 不被spring管理的話 使用構造函數 接收外面被spring管理的mapper -->constructor
* @Autowired
* DemoUserMapper demoUserMapper;
*/
private List<DemoUserDto> virtualDataBase = new ArrayList<>();
/**
* 方式二
* 假設 virtualDataBase 是 mapper, 這里就在外面new該類的時候傳進來 調用方注入過得mapper
* 並且 把Scope、Component注解去掉
*/
// public DemoUserListener(List<DemoUserDto> virtualDataBase) {
// this.virtualDataBase = virtualDataBase;
// }
/**
* 這個每一條數據解析都會來調用
*/
@Override
public void invoke(DemoUserDto data, AnalysisContext context) {
log.info("解析到一條數據:{}", JSONObject.toJSONString(data));
// 校驗非空
Field[] fields = DemoUserDto.class.getDeclaredFields();
for (Field f: fields) {
f.setAccessible(true);
ExcelPropertyNotNull ann = f.getAnnotation(ExcelPropertyNotNull.class);
if (null != ann && ann.open()){
try {
if(null == f.get(data)){
log.info("有一條數據未通過校驗,message[{}]",ann.message());
log.info("列號:[{}]",ann.col());
return;
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
list.add(data);
// 達到BATCH_COUNT了,需要去存儲一次數據庫,防止數據幾萬條數據在內存,容易OOM
if (list.size() >= BATCH_COUNT) {
saveData();
// 存儲完成清理 list
list.clear();
}
}
/**
* 所有數據解析完成了 會來調用
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 這里也要保存數據,確保最后遺留的數據也存儲到數據庫
saveData();
log.info("所有數據解析完成!");
}
/**
* 在轉換異常 獲取其他異常下會調用本接口。拋出異常則停止讀取。如果這里不拋出異常則 繼續讀取下一行。
* @param exception exception
* @param context context
* @throws Exception e
*/
@Override
public void onException(Exception exception, AnalysisContext context) {
log.error("解析失敗,但是繼續解析下一行:{}", exception.getMessage());
// 如果是某一個單元格的轉換異常 能獲取到具體行號
// 如果要獲取頭的信息 配合invokeHeadMap使用
if (exception instanceof ExcelDataConvertException) {
ExcelDataConvertException excelDataConvertException = (ExcelDataConvertException)exception;
log.error("第{}行,第{}列解析異常", excelDataConvertException.getRowIndex(),
excelDataConvertException.getColumnIndex());
}
}
/**
* 加上存儲數據庫
*/
private void saveData() {
log.info("{}條數據,開始存儲數據庫!", list.size());
virtualDataBase.addAll(list);
log.info("存儲數據庫成功!");
}
}
- 創建Handler
/**
* 自定義攔截器。對第一行第一列的頭超鏈接到:https://github.com/alibaba/easyexcel
* 這里沒有采用 spring 管理
* @author Jiaju Zhuang
*/
@Slf4j
public class CustomCellWriteHandler implements CellWriteHandler {
@Override
public void beforeCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row,
Head head, Integer columnIndex, Integer relativeRowIndex, Boolean isHead) {
log.info("cell 創建之前");
}
@Override
public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell,
Head head, Integer relativeRowIndex, Boolean isHead) {
log.info("cell 創建后");
}
@Override
public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder,
List<CellData> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) {
// 這里可以對cell進行任何操作
log.info("第{}行,第{}列寫入完成。", cell.getRowIndex(), cell.getColumnIndex());
}
}
- 創建handler控制單元格樣式
/**
* 自定義寫Excel handler 實現style 策略。
* @author quaint
* @date 14 February 2020
* @since 1.30
*/
public class ProductWriteErrHandler extends AbstractCellStyleStrategy {
/**
* 存儲解析失敗的行號和列號
*/
private Map<Integer, Integer> failureRowCol;
/**
* 可以這么理解: 外部定義樣式
*/
private WriteCellStyle writeErrCellStyle;
/**
* 單元格樣式
*/
private CellStyle errCellStyle;
/**
* 在這里自定義樣式, 或者在外面定義樣式
*/
public ProductWriteErrHandler(WriteCellStyle writeCellStyle,Map<Integer, Integer> failureRowCol) {
this.writeErrCellStyle = writeCellStyle;
this.failureRowCol = failureRowCol;
}
/**
* 單元格樣式初始化方法
* @param workbook
*/
@Override
protected void initCellStyle(Workbook workbook) {
// 初始化
if (writeErrCellStyle!=null){
errCellStyle = StyleUtil.buildContentCellStyle(workbook, writeErrCellStyle);
}
}
/**
* 寫頭部樣式
* @param cell
* @param head
* @param relativeRowIndex
*/
@Override
protected void setHeadCellStyle(Cell cell, Head head, Integer relativeRowIndex) {
}
/**
* 寫內容樣式
* @param cell
* @param head
* @param relativeRowIndex
*/
@Override
protected void setContentCellStyle(Cell cell, Head head, Integer relativeRowIndex) {
// 判斷 是否傳入 錯誤的 map
if (!CollectionUtils.isEmpty(failureRowCol)){
// 如果錯誤 的行 和列 對應成功 --> 染色
if (failureRowCol.containsKey(cell.getRowIndex())
&& failureRowCol.get(cell.getRowIndex()).equals(cell.getColumnIndex())){
cell.setCellStyle(errCellStyle);
}
}
}
}
- 控制層Controller
/**
* @author quaint
* @date 2020-01-14 11:13
*/
@Controller
@Slf4j
public class DemoEasyExcelSpi implements ApplicationContextAware {
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@PostMapping("/in/excel")
public String inExcel(@RequestParam("inExcel") MultipartFile inExcel, Model model){
DemoUserListener demoUserListener = applicationContext.getBean(DemoUserListener.class);
log.info("demoUserListener 在 spi 調用之前 hashCode為 [{}]", demoUserListener.hashCode());
if (inExcel.isEmpty()){
// 讀取 local 指定文件
List<DemoUserDto> demoUserList;
String filePath = System.getProperty("user.dir")+"/demo-easy-excel/src/main/resources/ExcelTest.xlsx";
try {
// 這里 需要指定讀用哪個class去讀,然后讀取第一個sheet 文件流會自動關閉
EasyExcel.read(filePath, DemoUserDto.class, demoUserListener).sheet().doRead();
demoUserList = demoUserListener.getVirtualDataBase();
} catch (Exception e) {
e.printStackTrace();
return null;
}
model.addAttribute("users", demoUserList);
} else {
// 讀取 web 上傳的文件
List<DemoUserDto> demoUserList;
try {
EasyExcel.read(inExcel.getInputStream(), DemoUserDto.class, demoUserListener).sheet().doRead();
demoUserList = demoUserListener.getVirtualDataBase();
} catch (IOException e) {
e.printStackTrace();
return null;
}
model.addAttribute("users", demoUserList);
}
log.info("demoUserListener 在 spi 調用之后 hashCode為 [{}]", demoUserListener.hashCode());
return "index";
}
@PostMapping("/out/excel")
public void export(HttpServletResponse response){
String search = "@RequestBody Object search";
// 根據前端傳入的查詢條件 去庫里查到要導出的dto
List<DemoUserDto> userDto = DemoUserDto.getUserDtoTest6(search);
// 要忽略的 字段
List<String> ignoreIndices = Collections.singletonList("性別");
// 根據類型獲取要反射的對象
Class clazz = DemoUserDto.class;
// 遍歷所有字段, 找到忽略的字段
Set<String> excludeFiledNames = new HashSet<>();
while (clazz != Object.class){
Arrays.stream(clazz.getDeclaredFields()).forEach(field -> {
ExcelProperty ann = field.getAnnotation(ExcelProperty.class);
if (ann!=null && ignoreIndices.contains(ann.value()[0])){
// 忽略 該字段
excludeFiledNames.add(field.getName());
}
});
clazz = clazz.getSuperclass();
}
// 設置序號
AtomicInteger i = new AtomicInteger(1);
userDto.forEach(u-> u.setNum(i.getAndIncrement()));
// 創建本地文件
EasyExcelUtils.exportLocalExcel(userDto,DemoUserDto.class,"ExcelTest",excludeFiledNames);
// 創建web文件
EasyExcelUtils.exportWebExcel(response,userDto,DemoUserDto.class,"ExcelTest",null);
}
}
- 導出工具類
/**
* EasyExcelUtils
* @author quaint
* @date 2020-01-14 14:26
*/
public abstract class EasyExcelUtils {
/**
* 導出excel
* @param response http下載
* @param dataList 導出的數據
* @param clazz 導出的模板類
* @param fileName 導出的文件名
* @param excludeFiledNames 要排除的filed
* @param <T> 模板
*/
public static <T> void exportWebExcel(HttpServletResponse response, List<T> dataList, Class<T> clazz,
String fileName, Set<String> excludeFiledNames) {
// 這里注意 有同學反應使用swagger 會導致各種問題,請直接用瀏覽器或者用postman
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx");
// 單元格樣式策略 定義
WriteCellStyle style = new WriteCellStyle();
// 這里需要指定 FillPatternType 為FillPatternType.SOLID_FOREGROUND 不然無法顯示背景顏色.頭默認了 FillPatternType所以可以不指定
style.setFillPatternType(FillPatternType.SOLID_FOREGROUND);
style.setFillForegroundColor(IndexedColors.RED.getIndex());
Map<Integer,Integer> errRecord = new HashMap<>();
errRecord.put(1,1);
errRecord.put(2,2);
ProductWriteErrHandler handler = new ProductWriteErrHandler(style,errRecord);
try {
// 導出excel
EasyExcel.write(response.getOutputStream(), clazz)
// 設置過濾字段策略
.excludeColumnFiledNames(excludeFiledNames)
// 選擇導入時的 handler
.registerWriteHandler(handler)
.sheet("fileName")
.doWrite(dataList);
} catch (IOException e) {
System.err.println("創建文件異常!");
}
}
/**
* 導出excel
* @param dataList 導出的數據
* @param clazz 導出的模板類
* @param fileName 導出的文件名
* @param excludeFiledNames 要排除的filed
* @param <T> 模板
*/
public static <T> void exportLocalExcel(List<T> dataList, Class<T> clazz, String fileName,
Set<String> excludeFiledNames){
//創建本地文件 test 使用
String filePath = System.getProperty("user.dir")+"/demo-easy-excel/src/main/resources/"+fileName+".xlsx";
File dbfFile = new File(filePath);
if (!dbfFile.exists() || dbfFile.isDirectory()) {
try {
dbfFile.createNewFile();
} catch (IOException e) {
System.err.println("創建文件異常!");
return;
}
}
// 導出excel
EasyExcel.write(filePath, clazz)
.registerWriteHandler(new CustomCellWriteHandler())
.excludeColumnFiledNames(excludeFiledNames)
.sheet("SheetName").doWrite(dataList);
}
}
- 前端代碼
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<style>
.data-local{
border: 1px black;
}
</style>
<body>
<form th:action="@{/in/excel}" method="post" enctype="multipart/form-data">
<input name="inExcel" type="file" value="上傳文件"/>
<input type="submit" value="導入excel"/>
</form>
<h2>導入的數據展示位置:</h2>
<div class="data-local" th:each="user : ${users}">
<span th:text="${user}"></span>
</div>
<form th:action="@{/out/excel}" method="post">
<input type="submit" value="導出下載文件"/>
</form>
</body>
</html>
- 導入效果圖
- 導出效果圖
總結
Listener和Handler的自定義寫法可以滿足絕大多數需求,大佬設計的代碼用起來就是舒服。就是@ExcelProperty注解的index屬性的排序混合使用,還需要看源碼是如何排序的。這里知識匱乏,望以后可以補充。