自定义注解完善easyPoi的动态表头生成
基本思路
easyPoi的@Excel注解目前只支持静态表头(列名).
我这两天遇到的一个场景是要基于一个属性,生成很多列名。easyPoi给出的方案是让我们自己去创建 ExcelExportEntity
这个对象,再定义一组数据 List<Map>
, 然后调用它提供的下面这个方法来导出。
package cn.afterturn.easypoi.excel;
public final class ExcelExportUtil {
/**
* 根据Map创建对应的Excel
*
* @param entity 表格标题属性
* @param entityList Map对象列表
* @param dataSet Excel对象数据List
*/
public static Workbook exportExcel(ExportParams entity, List<ExcelExportEntity> entityList,
Collection<?> dataSet) {
Workbook workbook = getWorkbook(entity.getType(), dataSet.size());
;
new ExcelExportService().createSheetForMap(workbook, entity, entityList, dataSet);
return workbook;
}
}
不过在创建ExcelExportEntity
对象的过程又臭又长,很容易造就屎山。那么核心要解决的问题就是让ExcelExportEntity
对象创建的过程自动化,用户无需感知到这个过程最好。
此外,从上面的方法中看到,我们还需要一个Collection<?> dataSet
参数,这个参数的构建过程也是又臭又长的,因此也要考虑自动化。
为了迎合easyPoi使用者的习惯,就写个类似的注解来实现这项功能,话不多说,直接上代码:
自定义注解
@CustomExcel
package com.ssy.file.annotation;
import java.lang.annotation.*;
/**
* Author: SiYang
* Date: 2023/09/26 10:19
* Description:
* easyPoi并没有给出动态表头的注解方案,
* 想要使用动态表头,只能自己构建 ExcelExportEntity 这个对象,比较繁琐
* 我们可以使用自定义注解的方式,使得构建该对象的过程自动化
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomExcel {
/**
* 指定列名,有些列名是固定的,不需要生成
*/
String name() default "";
/**
* 指定生成策略,生成策略指的是数据类内部自定义的方法
* 被CustomExcel注解的属性可以定义多种生成策略
* 该方法用来指定生成策略
*/
String columnNameGenerator() default "";
/**
* 列的序号
*/
int orderNum() default 0;
/**
* 列序号生成器,需要自定义生成方法,这里的值为方法名称
* 注:顺序的体现只需要orderNum有大小关系,而不一定要连续
*/
String orderGenerator() default "";
/**
* 是否需要合并列
* 该字段映射到easyPoi的ExcelExportEntity对象中似乎无效
* easyPoi的合并功能还是一如既往地令人失望,所以合并还是得靠自己实现
*/
boolean needMerge() default false;
/**
* 标题分组
*/
String groupName() default "";
/**
* 指定生成title的方法
* 该方法如果返回的值相同,那么就表示这些列的标题需要合并
*/
String groupGenerator() default "";
int width() default 10;
int height() default 10;
}
@CustomExcelCollection
package com.ssy.file.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Author: SiYang
* Date: 2023/09/26 14:49
* Description: 处理内嵌list的注解
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomExcelCollection {
}
使用注解的工具类
用来生成 List<ExcelExportEntity> entityList
以及 Collection<?> dataSet
简单思路就是利用反射拿到注解,利用注解给到的信息执行一系列操作
package com.ssy.file.utils;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.*;
import com.ssy.file.annotation.CustomExcel;
import com.ssy.file.annotation.CustomExcelCollection;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;
/**
* Author: SiYang
* Date: 2023/09/26 10:40
* Description:
* 对于导出行为: 生成 ExcelExportEntity 对象
* 对于导入行为: 以后再说
*/
@Slf4j
@SuppressWarnings("unused")
public class ExcelEntityGenerator {
/**
* 基于数据列表生成 easyPoi 中的 ExcelExportEntity 对象列表
*
* @param dataList 数据列表: 想被导出的字段需要使用 @CustomExcel 注解
* @return ExcelExportEntity 对象列表
*/
public static List<ExcelExportEntity> generateExportEntity(List<Object> dataList) {
try {
return generateExportEntity(dataList, null, null);
} catch (Exception e) {
log.error("生成动态表头错误: ", e);
throw new RuntimeException("生成动态表头错误: " + e.getMessage());
}
}
@SuppressWarnings("rawtypes")
private static List<ExcelExportEntity> generateExportEntity(
List<Object> dataList, List<ExcelExportEntity> result, Set<String> colNameSet)
throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
colNameSet = CollectionUtils.isEmpty(colNameSet) ? new HashSet<>() : colNameSet;
result = CollectionUtils.isEmpty(result) ? new ArrayList<>() : result;
if (CollectionUtils.isEmpty(dataList)) {
return Collections.emptyList();
}
Class<?> clazz = dataList.get(0).getClass();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
CustomExcel customExcel = field.getAnnotation(CustomExcel.class);
CustomExcelCollection customExcelCollection =
field.getAnnotation(CustomExcelCollection.class);
if (Objects.nonNull(customExcel)) {
// 第一优先
String methodName = customExcel.columnNameGenerator();
// 第二优先
String colName = customExcel.name();
ExcelExportEntityBuilder builder = new ExcelExportEntityBuilder();
if (StringUtils.isNotBlank(methodName)) {
Method method = clazz.getDeclaredMethod(methodName);
method.setAccessible(true);
for (Object data : dataList) {
String dynamicColName = (String) method.invoke(data);
if (colNameSet.contains(dynamicColName)
|| StringUtils.isBlank(dynamicColName)) {
continue;
}
ExcelExportEntity entity =
builder.name(dynamicColName)
.key(dynamicColName)
.width(customExcel.width())
.height(customExcel.height())
.needMerge(customExcel.needMerge())
.orderNum(getOrderNum(clazz, data, customExcel))
.groupName(getGroupName(clazz, data, customExcel))
.build();
result.add(entity);
colNameSet.add(dynamicColName);
}
} else {
if (colNameSet.contains(colName) || StringUtils.isBlank(colName)) {
continue;
}
ExcelExportEntity entity =
builder.name(colName)
.key(field.getName())
.width(customExcel.width())
.height(customExcel.height())
.needMerge(customExcel.needMerge())
.orderNum(customExcel.orderNum())
.groupName(customExcel.groupName())
.build();
result.add(entity);
colNameSet.add(colName);
}
} else if (Objects.nonNull(customExcelCollection)) {
for (Object data : dataList) {
generateExportEntity(
new ArrayList<Object>((List) field.get(data)), result, colNameSet);
}
}
}
return result;
}
/**
* 将被 @CustomExcel 注解的属性值提取到map
*
* @param dataList 数据列表
* @return 键值对列表
*/
public static List<Map<String, Object>> generateDataCollection(List<Object> dataList) {
try {
return generateDataCollection(dataList, null, null, true);
} catch (Exception e) {
log.error("生成excel数据错误: ", e);
throw new RuntimeException("生成excel数据错误: " + e.getMessage());
}
}
@SuppressWarnings(value = {"rawtypes", "unchecked"})
private static List<Map<String, Object>> generateDataCollection(
List<Object> dataList,
List<Map<String, Object>> result,
Map<String, Object> dataMap,
boolean isRoot)
throws IllegalAccessException, NoSuchMethodException, InvocationTargetException {
result = CollectionUtils.isEmpty(result) ? new ArrayList<>() : result;
if (CollectionUtils.isEmpty(dataList)) {
return Collections.emptyList();
}
Class<?> clazz = dataList.get(0).getClass();
Field[] fields = clazz.getDeclaredFields();
for (Object data : dataList) {
if (isRoot) {
dataMap = new LinkedHashMap<>();
result.add(dataMap);
}
for (Field field : fields) {
field.setAccessible(true);
CustomExcel customExcel = field.getAnnotation(CustomExcel.class);
CustomExcelCollection customExcelCollection =
field.getAnnotation(CustomExcelCollection.class);
if (Objects.nonNull(customExcel)) {
Object val = field.get(data);
// 第一优先
String methodName = customExcel.columnNameGenerator();
// 第二优先
String colName = customExcel.name();
if (StringUtils.isNotBlank(methodName)) {
Method method = clazz.getDeclaredMethod(methodName);
method.setAccessible(true);
String dynamicColName = (String) method.invoke(data);
if (StringUtils.isBlank(dynamicColName)) {
continue;
}
dataMap.put(dynamicColName, val);
} else if (StringUtils.isNotBlank(colName)) {
dataMap.put(field.getName(), val);
}
} else if (Objects.nonNull(customExcelCollection)) {
generateDataCollection(
new ArrayList<>((List) field.get(data)), result, dataMap, false);
}
}
}
return result;
}
/**
* 优先按指定的序号生成器来获取,如果没有指定生成器则使用orderNum
*
* @param clazz 数据类字节码对象
* @param obj 当前处理的对象
* @param customExcel 注解对象
* @return 序号
*/
private static int getOrderNum(Class<?> clazz, Object obj, CustomExcel customExcel)
throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
String methodName = customExcel.orderGenerator();
if (StringUtils.isNotBlank(methodName)) {
Method method = clazz.getDeclaredMethod(methodName);
method.setAccessible(true);
return (Integer) method.invoke(obj);
} else {
return customExcel.orderNum();
}
}
/**
* 优先按照group生成策略生成groupName
*
* @param clazz 数据类字节码对象
* @param obj 当前处理的对象
* @param customExcel 注解对象
* @return groupName
*/
private static String getGroupName(Class<?> clazz, Object obj, CustomExcel customExcel)
throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
String methodName = customExcel.groupGenerator();
if (StringUtils.isNotBlank(methodName)) {
Method method = clazz.getDeclaredMethod(methodName);
method.setAccessible(true);
return (String) method.invoke(obj);
} else {
return customExcel.groupName();
}
}
@NoArgsConstructor
private static class ExcelExportEntityBuilder {
private Object key;
private String name;
private int width = 10;
private int height = 10;
private int orderNum = 0;
private boolean needMerge;
private String groupName;
public ExcelExportEntityBuilder key(Object key){
this.key = key;
return this;
}
public ExcelExportEntityBuilder name(String name){
this.name = name;
return this;
}
public ExcelExportEntityBuilder width(int width) {
if (width > 0) {
this.width = width;
}
return this;
}
public ExcelExportEntityBuilder height(int height){
if (height > 0) {
this.height = height;
}
return this;
}
public ExcelExportEntityBuilder orderNum(int orderNum){
this.orderNum = orderNum;
return this;
}
public ExcelExportEntityBuilder needMerge(boolean needMerge){
this.needMerge = needMerge;
return this;
}
public ExcelExportEntityBuilder groupName(String groupName){
this.groupName = groupName;
return this;
}
public ExcelExportEntity build() {
ExcelExportEntity entity = new ExcelExportEntity(name, key, width);
entity.setHeight(height);
entity.setOrderNum(orderNum);
entity.setNeedMerge(needMerge);
entity.setGroupName(groupName);
return entity;
}
}
}
案例分享
这里给出的案例并不完备,读者朋友只需关注大体的逻辑,不要去看细节,没有业务背景,看细节会让你迷失的。
业务数据类
package iot.thing.config.excel;
import com.alibaba.fastjson.JSONArray;
import iot.thing.common.annotation.CustomExcel;
import iot.thing.common.annotation.CustomExcelCollection;
import iot.thing.config.dto.CarbonThingEnergyDictDTO;
import iot.thing.config.dto.CarbonTimeLabelData;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.commons.lang3.tuple.MutablePair;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.poi.ss.util.CellRangeAddress;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
/**
* Author: SiYang
* Date: 2023/09/26 11:43
* Description: 物的用量属性Excel对象
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CarbonThingEnergyDictExcel {
/**
* 物名称
*/
@CustomExcel(name = "结构", width = 25, needMerge = true)
private String thingName;
/**
* 能源品种
*/
@CustomExcel(name = "能源品种", needMerge = true, orderNum = 1)
private String energyVarietyName;
/**
* 属性名称
*/
@CustomExcel(name = "类型", width = 18, orderNum = 2)
private String attrName;
/**
* 属性单位
*/
private String attrUnit;
@CustomExcel(name = "总量", width = 12, orderNum = 3)
private BigDecimal totalValue;
@CustomExcel(name = "价格", orderNum = 4)
private String price;
@CustomExcel(name = "总价", width = 12, orderNum = 5)
private BigDecimal totalCalculatedValue;
@CustomExcelCollection
private List<CarbonTimeLabelData> dataList;
public static List<CarbonThingEnergyDictExcel> convertFromDto(
List<CarbonThingEnergyDictDTO> sourceList, String timeType) {
return sourceList.stream()
.map(
source -> {
Map<String, Object> dataMap = source.getDataMap();
List<CarbonTimeLabelData> dataList =
JSONArray.parseArray(
JSONArray.toJSONString(dataMap.get("data")),
CarbonTimeLabelData.class);
dataList.forEach(data -> data.setTimeType(timeType));
return CarbonThingEnergyDictExcel.builder()
.thingName(source.getThingName())
.energyVarietyName(source.getEnergyVarietyName())
.attrName(
source.getAttrName()
+ (Objects.nonNull(source.getAttrUnit())
? "(" + source.getAttrUnit() + ")"
: ""))
.attrUnit(source.getAttrUnit())
.totalValue((BigDecimal) dataMap.get("totalValue"))
.price(
Objects.nonNull(dataMap.get("price"))
? dataMap.get("price").toString()
: "--")
.totalCalculatedValue(
(BigDecimal) dataMap.get("totalCalculatedValue"))
.dataList(dataList)
.build();
})
.collect(Collectors.toList());
}
/**
* 现在needMerge注解是无效的,不得不自定义合并方法
*
* @param dataList 数据列表
* @return 合并区域
*/
public static List<CellRangeAddress> calculateMergeRegion(List<CarbonThingEnergyDictExcel> dataList, String timeType) {
boolean isWeek = Objects.equals(timeType, "week");
List<CellRangeAddress> mergeRegionList = new ArrayList<>();
Map<String, Pair<Integer, Integer>> thingCountMap = new HashMap<>();
Map<String, Pair<Integer, Integer>> thingEnergyCountMap = new HashMap<>();
int beginRowOfThing = isWeek ? 1 : 0;
int beginRowOfEnergy = isWeek ? 1 : 0;
for (CarbonThingEnergyDictExcel currentData : dataList) {
if (!thingCountMap.containsKey(currentData.getThingName())) {
thingCountMap.put(
currentData.getThingName(),
MutablePair.of(++beginRowOfThing, beginRowOfThing));
} else {
Pair<Integer, Integer> thingCountPair =
thingCountMap.get(currentData.getThingName());
thingCountPair.setValue(++beginRowOfThing);
}
String energyKey =
currentData.getThingName() + "_" + currentData.getEnergyVarietyName();
if (!thingEnergyCountMap.containsKey(energyKey)) {
thingEnergyCountMap.put(
energyKey, MutablePair.of(++beginRowOfEnergy, beginRowOfEnergy));
} else {
Pair<Integer, Integer> energyCountPair = thingEnergyCountMap.get(energyKey);
energyCountPair.setValue(++beginRowOfEnergy);
}
}
thingCountMap.forEach(
(k, pair) ->
mergeRegionList.add(
new CellRangeAddress(pair.getLeft(), pair.getRight(), 0, 0)));
thingEnergyCountMap.forEach(
(k, pair) ->
mergeRegionList.add(
new CellRangeAddress(pair.getLeft(), pair.getRight(), 1, 1)));
return mergeRegionList;
}
}
内嵌的CarbonTimeLabelData类型
package iot.thing.config.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import iot.thing.common.annotation.CustomExcel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Author: SiYang
* Date: 2023/09/21 14:02
* Description: 时间标签数据, 给数据打上了时间与label标签
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CarbonTimeLabelData {
private static final Map<Integer, String> weekMap = new HashMap<>();
static {
weekMap.put(1, "周一");
weekMap.put(2, "周二");
weekMap.put(3, "周三");
weekMap.put(4, "周四");
weekMap.put(5, "周五");
weekMap.put(6, "周六");
weekMap.put(7, "周日");
}
/**
* 数据的日期时间
*/
@JsonIgnore
private LocalDateTime dateTime;
private Integer year;
private Integer month;
private Integer weekOfYear;
private Integer dateOfMonth;
private Integer dateOfWeek;
/**
* 属性编号
*/
private String attrCode;
/**
* 数据库值
*/
private BigDecimal value;
/**
* 通过自定义的算法计算后的值
*/
private BigDecimal calculatedValue;
/**
* 数据标签, 根据接口需求自己定义
*/
private String label;
/**
* 导出时使用的值
*/
@CustomExcel(columnNameGenerator = "generateTimeLabelTitle", width = 12, orderGenerator = "generateOrderNum", groupGenerator = "getGroupName")
private String exportValue;
/**
* 时间类型:week, month, year, any
*/
private String timeType;
/**
* 聚合之后,日的概念将会模糊,需要置空
*
* @param label 期望生成的目标数据的标签
* @return copy后的数据
*/
public CarbonTimeLabelData copy(String label) {
return CarbonTimeLabelData.builder()
.year(this.year)
.attrCode(this.attrCode)
.value(BigDecimal.ZERO)
.label(label)
.build();
}
public CarbonTimeLabelData add(CarbonTimeLabelData data) {
BigDecimal currentVal = Optional.ofNullable(this.getValue()).orElse(BigDecimal.ZERO);
BigDecimal inputVal = Optional.ofNullable(data.getValue()).orElse(BigDecimal.ZERO);
BigDecimal currentCalcVal = Optional.ofNullable(this.getCalculatedValue()).orElse(BigDecimal.ZERO);
BigDecimal inputCalcVal = Optional.ofNullable(data.getCalculatedValue()).orElse(BigDecimal.ZERO);
this.setValue(currentVal.add(inputVal));
this.setCalculatedValue(currentCalcVal.add(inputCalcVal));
return this;
}
@SuppressWarnings("unused")
private String generateTimeLabelTitle() {
StringBuilder strBuilder = new StringBuilder();
switch (timeType) {
case "week":
if (Objects.equals(label, "item")) {
strBuilder
.append(addZeroPrefix(month))
.append("-")
.append(addZeroPrefix(dateOfMonth))
.append(" ")
.append(weekMap.get(dateOfWeek));
} else if (Objects.equals(label, "weekTotal")) {
strBuilder.append(weekOfYear).append("周 总计");
}
break;
case "month":
if (Objects.equals(label, "item")) {
strBuilder.append(dateOfMonth).append("日");
}
break;
case "year":
if (Objects.equals(label, "item")) {
strBuilder.append(month).append("月");
}
break;
case "any":
break;
default:
}
return strBuilder.toString();
}
private String addZeroPrefix(Integer num) {
return (num < 10 && num > 0) ? "0" + num : num.toString();
}
@SuppressWarnings("unused")
public String getExportValue() {
return Objects.nonNull(getCalculatedValue()) ? getCalculatedValue().toString() : "--";
}
/**
* 序号生成:
* 1. 日期越大序号越小,最近的时间排在前面
* 2. 周总计排在周数据前面
*/
@SuppressWarnings("unused")
private int generateOrderNum() {
// 前面的序号已经到5了,最小不能比6小
int orderNum = 6;
switch (timeType) {
case "week":
// 算法可以自定义,这里只是从数学上给出一个粗浅的计算方法
if (Objects.equals(label, "item")) {
orderNum += (((52 - weekOfYear) << 3) + (8 - dateOfWeek));
} else if (Objects.equals(label, "weekTotal")) {
orderNum += (52 - weekOfYear) << 3;
}
break;
case "month":
orderNum += (31 - dateOfMonth);
break;
case "year":
orderNum += (12 - month);
break;
case "any":
break;
default:
}
return orderNum;
}
@SuppressWarnings("unused")
private String getGroupName() {
if (!Objects.equals(timeType, "week")) {
return null;
}
return Objects.isNull(weekOfYear) ? null : weekOfYear + "周";
}
}
导出代码
// 报表原始数据
List<CarbonThingEnergyDictDTO> originalDataList = energyPriceReport(request);
// 数据类型转换
List<CarbonThingEnergyDictExcel> excelDataList = CarbonThingEnergyDictExcel.convertFromDto(originalDataList, request.getTimeType());
// 生成表头 & 生成数据集合
List<ExcelExportEntity> excelExportEntities = ExcelEntityGenerator.generateExportEntity(new ArrayList<>(excelDataList));
List<Map<String, Object>> dataCollection = ExcelEntityGenerator.generateDataCollection(new ArrayList<>(excelDataList));
// 计算合并区域
List<CellRangeAddress> regions = CarbonThingEnergyDictExcel.calculateMergeRegion(excelDataList, request.getTimeType());
Workbook workbook = ExcelExportUtil.exportExcel(new ExportParams(null, "报表"), excelExportEntities, dataCollection);
regions.forEach(workbook.getSheetAt(0)::addMergedRegion);
// 导出
ExcelUtils.downLoadExcel("报表.xls", response, workbook);