说在前面
封装的easyexcel,基于注解实现excel的导入导出,以场景来说,就是你有一个现成的分页接口或者一个list接口 ,只需要添加几个简单的注解,就可以实现excel的导出,也是为了方便有模板生成代码的情况下直接生成导出功能。 这是封装的依赖库源码:github.com/chenqi92/al... 这是这个依赖库的使用示例:github.com/chenqi92/al... 依赖库运行后在浏览器中打开:http://localhost:8080/ 即可测试各种示例,参照示例进行使用可以不用看后续的使用说明。 这是第二篇了,第一篇可以往前翻翻,后一篇是讲excel中的统计图的,没啥用可以跳过之后看最后一篇导入篇。
使用说明
添加maven依赖
xml
<dependency>
<groupId>cn.allbs</groupId>
<artifactId>allbs-excel</artifactId>
<version>3.0.0</version>
</dependency>
合并单元格
导出中不可避免遇到的情况,但是需要将需要合并的字段进行排序,以便能够识别并自动合并。 主要通过@ExportExcel的autoMerge = true属性实现,并在对应字段添加注解@ExcelMerge 代码示例:
java
@GetMapping("/merge")
@ExportExcel(
name = "部门员工列表",
sheets = @Sheet(
sheetName = "员工信息",
autoMerge = true // 启用自动合并
)
)
public List<EmployeeDTO> mergeExport(@RequestParam(defaultValue = "20") int count) {
return testDataService.generateEmployees(count);
}
java
@Data
public class EmployeeDTO {
@ExcelProperty(value = "部门", index = 0)
@ExcelMerge // 标记需要合并的字段
private String department;
@ExcelProperty(value = "姓名", index = 1)
@ExcelMerge(dependOn = "department") // 依赖部门列
private String name;
@ExcelProperty(value = "职位", index = 2)
@ExcelMerge(dependOn = "name") // 依赖姓名列
private String position;
@ExcelProperty(value = "工资", index = 3)
private BigDecimal salary;
@ExcelProperty(value = "入职日期", index = 4)
private String joinDate;
}
实际效果(数据并不合理,姓名一列专门设置了一样的名字用于测试合并): 
数据脱敏
实际导出过程中肯定无法避免遇到需要数据脱敏的情况,如果是数据库层面脱敏过这边可以忽略,但是如果数据库层面没脱敏但是又需要在最后展示进行脱敏,就是这个功能的实际应用。 主要方式依赖字段注解@Desensitize,内置提供手机号、身份证号、邮箱、银行卡号、姓名、地址、固定电话、车牌号、自定义这些脱敏方式,如果内置的脱敏达不到你的要求,可以进行自定义实现,自定义采取设置前几位和最后几位保留的脱敏方式,除了前后的指定位数,其他内容变更为*。本来还想可以自定义字符填充,想想挺傻的,用默认的*就可以了。 使用示例:
java
@GetMapping("/desensitize")
@ExportExcel(
name = "敏感信息用户列表",
sheets = @Sheet(sheetName = "用户信息")
)
public List<SensitiveUserDTO> desensitizeExport(@RequestParam(defaultValue = "10") int count) {
return testDataService.generateSensitiveUsers(count);
}
java
@Data
public class SensitiveUserDTO {
@ExcelProperty(value = "用户ID", index = 0)
private Long id;
@ExcelProperty(value = "姓名", index = 1, converter = DesensitizeConverter.class)
@Desensitize(type = DesensitizeType.NAME)
private String name;
@ExcelProperty(value = "手机号", index = 2, converter = DesensitizeConverter.class)
@Desensitize(type = DesensitizeType.MOBILE_PHONE)
private String phone;
@ExcelProperty(value = "身份证", index = 3, converter = DesensitizeConverter.class)
@Desensitize(type = DesensitizeType.ID_CARD)
private String idCard;
@ExcelProperty(value = "邮箱", index = 4, converter = DesensitizeConverter.class)
@Desensitize(type = DesensitizeType.EMAIL)
private String email;
@ExcelProperty(value = "银行卡", index = 5, converter = DesensitizeConverter.class)
@Desensitize(type = DesensitizeType.BANK_CARD)
private String bankCard;
@ExcelProperty(value = "地址", index = 6, converter = DesensitizeConverter.class)
@Desensitize(type = DesensitizeType.ADDRESS)
private String address;
@ExcelProperty(value = "性别", index = 7, converter = DictConverter.class)
@ExcelDict(dictType = "sys_user_sex")
private String sex;
@ExcelProperty(value = "状态", index = 8, converter = DictConverter.class)
@ExcelDict(dictType = "sys_user_status")
private String status;
}
实现效果: 
导出进度
如果数据量较大,提供给前端实时进度,避免用户焦躁。这是实际效果,在allbs-excel-test中有具体的实现了,主要通过接口方法注解@ExportProgress进行实现,源码中定义了一个接口ExportProgressListener,通过实现这个接口可以获取导出过程中的进度,然后通过websocket或者sse主动推送给前端即可。
代码实现,注意下面的ExportProgress:
java
@GetMapping("/with-progress")
@ExportExcel(
name = "用户列表-带进度",
sheets = @Sheet(sheetName = "用户信息")
)
@ExportProgress(
listener = SseProgressListener.class,
interval = 100 // 每 100 行触发一次进度回调
)
public List<SensitiveUserDTO> exportWithProgress(@RequestParam(defaultValue = "5000") int count,
@RequestParam(required = false) String sessionId) {
return testDataService.generateSensitiveUsers(count);
}
这是进度的实现,实现进度监听然后处理开始、进度更新、结束、错误几个方法即可:
java
@Component
public class SseProgressListener implements ExportProgressListener {
private static final Logger log = LoggerFactory.getLogger(SseProgressListener.class);
@Autowired
private ProgressService progressService;
@Override
public void onStart(int totalRows, String sheetName) {
log.info("========== 开始导出 ==========");
log.info("Sheet名称: {}", sheetName);
log.info("总行数: {}", totalRows);
String sessionId = getSessionId();
if (sessionId != null) {
progressService.sendProgress(sessionId,
ProgressService.ProgressMessage.start(totalRows, sheetName));
} }
@Override
public void onProgress(int currentRow, int totalRows, double percentage, String sheetName) {
log.info("导出进度: {}/{} ({:.2f}%) - {}",
currentRow, totalRows, percentage, sheetName);
String sessionId = getSessionId();
if (sessionId != null) {
progressService.sendProgress(sessionId,
ProgressService.ProgressMessage.progress(currentRow, totalRows, percentage, sheetName));
} }
@Override
public void onComplete(int totalRows, String sheetName) {
log.info("========== 导出完成 ==========");
log.info("Sheet名称: {}", sheetName);
log.info("总行数: {}", totalRows);
String sessionId = getSessionId();
if (sessionId != null) {
progressService.sendProgress(sessionId,
ProgressService.ProgressMessage.complete(totalRows, sheetName));
// 延迟关闭连接,给前端足够时间主动关闭(避免触发前端 onerror)
new Thread(() -> {
try {
Thread.sleep(5000); // 延长到 5 秒,让前端有足够时间主动关闭
progressService.closeEmitter(sessionId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} }).start(); } }
@Override
public void onError(Exception exception, String sheetName) {
log.error("========== 导出失败 ==========");
log.error("Sheet名称: {}", sheetName);
log.error("错误信息: {}", exception.getMessage(), exception);
String sessionId = getSessionId();
if (sessionId != null) {
progressService.sendProgress(sessionId,
ProgressService.ProgressMessage.error(sheetName, exception.getMessage()));
progressService.closeEmitter(sessionId);
} }
/**
* 从请求参数中获取 sessionId
*/ private String getSessionId() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
return attributes.getRequest().getParameter("sessionId");
} } catch (Exception e) {
log.warn("无法获取 sessionId: {}", e.getMessage());
} return null;
}}
冻结窗格和筛选
当前示例为将第一行冻结,无论你滚动到哪一行可以保证知道某列是干什么的,同时实现某一列的筛选功能。说实话这玩意完全可以导出后自己设置一下,但是如果你遇到操作excel有很大进步空间的的现场运维,那么这个功能还是有点意义的。看下方示例的ExcelSheetStyle注解。 实现代码:
java
@GetMapping("/formula")
@ExportExcel(
name = "销售统计数据",
sheets = @Sheet(sheetName = "销售统计")
)
public List<FormulaDataDTO> formulaExport(@RequestParam(defaultValue = "10") int count) {
return testDataService.generateFormulaData(count);
}
java
@Data
@NoArgsConstructor
@AllArgsConstructor
@ExcelSheetStyle(
freezeRow = 1,
autoFilter = true,
defaultColumnWidth = 15
)
public class FormulaDataDTO {
@ExcelProperty(value = "Product Name", index = 0)
private String productName;
@ExcelProperty(value = "Unit Price", index = 1)
private BigDecimal unitPrice;
@ExcelProperty(value = "Quantity", index = 2)
private Integer quantity;
@ExcelProperty(value = "Total Price", index = 3)
private BigDecimal totalPrice;
@ExcelProperty(value = "Tax (10%)", index = 4)
private BigDecimal taxAmount;
@ExcelProperty(value = "Final Amount", index = 5)
private BigDecimal finalAmount;
}
实际效果(不是2-10没了,是我往下滚动隐藏了,体现了冻结窗口之功能): 
图标集
该功能是为了让冰冷无趣的文字鲜活一些,说直白一点就是没什么卵用的功能。既然是没啥用的功能,我就不详细说了,贴个代码示例,有兴趣的去看源码好了。 实现效果:
代码示例:
java
@GetMapping("/conditional-format")
@ExportExcel(
name = "员工绩效-条件格式",
sheets = @Sheet(sheetName = "员工绩效"),
writeHandler = {ConditionalFormatWriteHandler.class}
)
public List<PerformanceDataDTO> conditionalFormatExport(@RequestParam(defaultValue = "15") int count) {
return testDataService.generatePerformanceData(count);
}
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PerformanceDataDTO {
@ExcelProperty(value = "Employee", index = 0)
private String employeeName;
@ExcelProperty(value = "Department", index = 1)
private String department;
@ExcelProperty(value = "Score", index = 2)
@ConditionalFormat(
type = ConditionalFormat.FormatType.ICON_SET,
iconSet = ConditionalFormat.IconSetType.THREE_TRAFFIC_LIGHTS_1
)
private Integer score;
@ExcelProperty(value = "Sales Amount", index = 3)
@ConditionalFormat(
type = ConditionalFormat.FormatType.ICON_SET,
iconSet = ConditionalFormat.IconSetType.THREE_ARROWS
)
private BigDecimal sales;
@ExcelProperty(value = "Completion Rate", index = 4)
@ConditionalFormat(
type = ConditionalFormat.FormatType.ICON_SET,
iconSet = ConditionalFormat.IconSetType.THREE_FLAGS
)
private Double completionRate;
}
Excel 加密
包含两种方式,一种简单的设置即可,还有一种是自定义加密算法(这种方法不支持注解形式,但是我会提供示例) 这是简单加密的示例,看到那个password属性没,加个这玩意即可。
java
GetMapping("/encrypted")
@ExportExcel(
name = "加密文件-密码password123",
sheets = @Sheet(sheetName = "敏感数据"),
password = "password123" // ✨ 使用注解方式设置密码
)
public List<SensitiveUserDTO> encryptedExport(@RequestParam(defaultValue = "20") int count) {
return testDataService.generateSensitiveUsers(count);
}
这是指定加密算法的方式,非注解,根据传入的密码加密
java
@GetMapping("/encrypted-advanced")
public void encryptedAdvancedExport(@RequestParam(defaultValue = "20") int count,
@RequestParam(defaultValue = "password123") String password,
HttpServletResponse response) throws IOException {
List<SensitiveUserDTO> data = testDataService.generateSensitiveUsers(count);
// 先导出到临时文件
File tempFile = File.createTempFile("excel_", ".xlsx");
tempFile.deleteOnExit();
EasyExcel.write(tempFile, SensitiveUserDTO.class)
.sheet("敏感数据")
.doWrite(data);
// 使用 AGILE 算法加密文件
File encryptedFile = File.createTempFile("encrypted_", ".xlsx");
encryptedFile.deleteOnExit();
ExcelEncryptionUtil.encryptFile(
tempFile, encryptedFile, password, ExcelEncryption.EncryptionAlgorithm.AGILE
);
// 设置响应头
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode("加密文件-AGILE-密码" + password, "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
// 输出加密文件
try (FileInputStream fis = new FileInputStream(encryptedFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
response.getOutputStream().write(buffer, 0, bytesRead);
} }
// 清理临时文件
tempFile.delete();
encryptedFile.delete();
}
多 Sheet 关联导出
可能会有人问,前面一篇明明有过多sheet啊,实际上并不是同一种用法。前一篇是通过返回list的list实现,这边是视图对象中有额外的对象list,所以说完全是两种截然不同的实现。看示例代码
示例代码:
java
@GetMapping("/multi-sheet")
@ExportExcel(
name = "订单及明细-多Sheet",
sheets = @Sheet(sheetName = "订单")
)
public List<MultiSheetOrderDTO> multiSheetExport(@RequestParam(defaultValue = "5") int count) {
return testDataService.generateMultiSheetOrders(count);
}
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MultiSheetOrderDTO {
/**
* 订单号
*/
@ExcelProperty(value = "订单号", index = 0)
private String orderNo;
/**
* 客户名称
*/
@ExcelProperty(value = "客户名称", index = 1)
private String customerName;
/**
* 订单金额
*/
@ExcelProperty(value = "订单金额", index = 2)
private BigDecimal totalAmount;
/**
* 订单状态
*/
@ExcelProperty(value = "订单状态", index = 3)
private String status;
/**
* 创建时间
*/
@ExcelProperty(value = "创建时间", index = 4)
private LocalDateTime createTime;
/**
* 明细数量
*/
@ExcelProperty(value = "明细数量", index = 5)
private Integer itemCount;
/**
* 订单明细(导出到关联 Sheet)
*/
@ExcelProperty(value = "操作", index = 6)
@RelatedSheet(sheetName = "订单明细", relationKey = "orderNo", dataType = MultiSheetOrderItemDTO.class, createHyperlink = true, hyperlinkText = "查看明细")
private List<MultiSheetOrderItemDTO> items;
}
嵌套对象
上面是视图对象中有对象的list,那么肯定也会存在对象中有对象的情况,对象的对象中还有对象,那么肯定就会存在某干层级下有所需字段的情况: 以下代码包含了多种不同的情况所以稍微有点复杂,包括了嵌套单个对象,嵌套基本类型的列表,嵌套Map,看代码和结果理解:
java
@GetMapping("/nested-property")
@ExportExcel(
name = "嵌套对象示例",
sheets = @Sheet(sheetName = "员工信息")
)
public List<NestedPropertyExampleDTO> nestedPropertyExport(@RequestParam(defaultValue = "10") int count) {
return testDataService.generateNestedPropertyExamples(count);
}
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class NestedPropertyExampleDTO {
@ExcelProperty("员工ID")
private Long id;
@ExcelProperty("员工姓名")
private String name;
// ==================== 单层嵌套对象 ====================
@ExcelProperty(value = "部门名称", converter = NestedObjectConverter.class)
@NestedProperty("name")
private Department department;
@ExcelProperty(value = "部门编码", converter = NestedObjectConverter.class)
@NestedProperty(value = "code", nullValue = "未分配")
private Department department2;
// ==================== 多层嵌套对象 ====================
@ExcelProperty(value = "直属领导", converter = NestedObjectConverter.class)
@NestedProperty(value = "leader.name", nullValue = "暂无")
private Department department3;
@ExcelProperty(value = "领导电话", converter = NestedObjectConverter.class)
@NestedProperty(value = "leader.phone", nullValue = "-")
private Department department4;
// ==================== 集合类型 ====================
// 技能列表(内部字段,不导出)
@ExcelIgnore
private List<String> skills;
@ExcelProperty(value = "主要技能", converter = NestedObjectConverter.class)
@NestedProperty(value = "[0]", nullValue = "无")
private List<String> mainSkill;
@ExcelProperty(value = "所有技能", converter = NestedObjectConverter.class)
@NestedProperty(value = "[*]", separator = ",", maxJoinSize = 5)
private List<String> allSkills;
// ==================== Map 类型 ====================
// 扩展属性(内部字段,不导出)
@ExcelIgnore
private Map<String, Object> properties;
@ExcelProperty(value = "工作城市", converter = NestedObjectConverter.class)
@NestedProperty(value = "[city]", nullValue = "-")
private Map<String, Object> city;
@ExcelProperty(value = "入职年份", converter = NestedObjectConverter.class)
@NestedProperty(value = "[joinYear]", nullValue = "-")
private Map<String, Object> joinYear;
}
实际导出的excel为(第一个红框是对象的对象中的内容,第二个框为对象的对象的对象中的内容,第三个箭头为list只获取第一个的情况,第四个箭头为获取所有list数据并用逗号分隔的情况,最后俩箭头就是从Map中取数据的情况,不要被最后俩map字段名称搞混,实际上上面的[city]和[joinYear]是map的key): ![[Pasted image 20251123194422.png]]
嵌套对象自动横展开
上面的一个示例讲的是从对象的对象中获取一个值的方法,这个示例讲的是讲对象中的对象所有值都带上并展示到excel中的方法。 代码示例(注意看department、parentDept、managerDept三个字段):
java
@GetMapping("/flatten-property")
@ExportExcel(
name = "对象展开示例",
sheets = @Sheet(sheetName = "员工信息")
)
public List<FlattenPropertyExampleDTO> flattenPropertyExport(@RequestParam(defaultValue = "10") int count) {
// FlattenPropertyWriteHandler 会自动检测 @FlattenProperty 注解并处理嵌套对象展开
return testDataService.generateFlattenPropertyExamples(count);
}
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FlattenPropertyExampleDTO {
@ExcelProperty("员工ID")
private Long id;
@ExcelProperty("员工姓名")
private String name;
@ExcelProperty("年龄")
private Integer age;
// 自动展开部门的所有 @ExcelProperty 字段
@FlattenProperty(prefix = "部门-")
@ExcelIgnore // 必须添加,否则 EasyExcel 会尝试直接导出 Department 对象
private Department department;
// 自动展开上级部门的所有字段,使用不同的前缀避免冲突
@FlattenProperty(prefix = "上级部门-")
@ExcelIgnore // 必须添加
private Department parentDept;
// 递归展开(会自动展开 department 内部的 leader 对象)
@FlattenProperty(prefix = "主管-", recursive = true, maxDepth = 2)
@ExcelIgnore // 必须添加
private Department managerDept;
}
实际效果: 
嵌套对象垂直展开
视图对象中嵌套对象的list,将这个list对象的内容一并输出到excel表格中。 代码示例:
java
@GetMapping("/flatten-list-student")
@ExportExcel(
name = "学生课程奖项列表",
sheets = @Sheet(sheetName = "学生信息")
)
public List<FlattenListStudentDTO> flattenListStudentExport(@RequestParam(defaultValue = "5") int count) {
return testDataService.generateFlattenListStudents(count);
}
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FlattenListStudentDTO {
@ExcelProperty("学生姓名")
private String name;
@ExcelProperty("学号")
private String studentNo;
@ExcelProperty("班级")
private String className;
// 多个 List - 使用 MAX_LENGTH 策略(默认)
@FlattenList(prefix = "课程-")
private List<Course> courses;
@FlattenList(prefix = "奖项-")
private List<Award> awards;
}
实际导出效果: 
动态表头
如果视图对象中存在Map,那么会将该Map内容动态生成表头并填充数据,注意下面的@DynamicHeaders注解的使用。
java
@GetMapping("/dynamic-header")
@ExportExcel(
name = "动态表头示例",
sheets = @Sheet(sheetName = "产品列表")
)
public List<DynamicHeaderDTO> dynamicHeaderExport(@RequestParam(defaultValue = "15") int count) {
return testDataService.generateDynamicHeaderData(count);
}
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DynamicHeaderDTO {
@ExcelProperty("产品ID")
private Long productId;
@ExcelProperty("产品名称")
private String productName;
@ExcelProperty("分类")
private String category;
/**
* 动态属性(从数据中自动提取表头)
* <p>
* 不同产品的属性可能不同,表头动态生成
* </p>
*/
@DynamicHeaders(strategy = DynamicHeaderStrategy.FROM_DATA, headerPrefix = "属性-",
order = DynamicHeaders.SortOrder.ASC)
private Map<String, Object> properties;
/**
* 扩展字段(使用预定义表头)
*/
@DynamicHeaders(strategy = DynamicHeaderStrategy.FROM_CONFIG, headers = { "备注1", "备注2", "备注3" },
headerPrefix = "扩展-")
private Map<String, Object> extFields;
}
实际效果: 