Bootstrap

自定义注解完善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);
;