【ExcelUtil】二次封装,注解驱动,用起来不要太舒服!

前言

这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战 。话不多说,要说就是为了补上篇留下的坑 【ExcelUtil】实现文件写出到客户端下载全过程 - 掘金 (juejin.cn)

需求分析

除了最基础的表头名转换、表头和内容列宽自适应居中外,还需增加对表头顺序位置的指定,指定导出的日期数据时间日期格式,马达马达,对于枚举内容希望能够通过指定的分隔符读取写入值,此外,对于无数据的单元格可以按照需求给默认值 ......

image.png

最后,给一个是否导出数据标识用来应对需求:有时我们需要导出一份模板,这是标题需要但内容需要用户手工填写。

image.png

我:

熊猫人.gif

代码实现

自定义注解

首先,根据需求自定义一个注解,其中的每个属性对应一个功能:


/**
 * @description: 自定义导出 Excel 数据注解
 * @author: HUALEI
 * @date: 2021-11-19
 * @time: 15:37
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Excel {

    /**
     * 导出到 Excel 中的表头别名
     */
    String headerAlias() default "";

    /**
     * 导出时在 Excel 中的排序
     */
    int sort() default Integer.MAX_VALUE;

    /**
     * 日期格式,如: yyyy-MM-dd
     */
    String dateFormat() default "";

    /**
     * 根据分隔符读取内容转表达式 (如: 0=男,1=女,2=未知)
     */
    String readConverterExp() default "";

    /**
     * 分隔符(默认为 "," 逗号),读取字符串组内容(注意:有些特殊分割字符需要用 "\\sparator" 或 "[sparator]"进行转义,否则分割字符串失败)
     */
    String separator() default ",";

    /**
     * 当值为空时,字段的默认值
     */
    String defaultValue() default "";

    /**
     * 是否导出数据
     */
    boolean isExport() default true;

    enum Type {
        /** 导出导入 */
        ALL(0),
        /** 仅导出 */
        EXPORT(1),
        /** 仅导入 */
        IMPORT(2);

        private final int value;

        Type(int value) {
            this.value = value;
        }

        public int value() {
            return this.value;
        }
    }

    /**
     * 字段类型(0:导出导入;1:仅导出;2:仅导入)
     */
    Type type() default Type.ALL;
}
复制代码

注解中有一个 Type 内部枚举类,用来区分被注解标识字段是导入还是导出,虽然这里的需求只要做导出,防范于未然,帮助你立身于需求高地。

image.png

工具类封装

通过 new ExcelUtil<>(xxx.class); 来创建二次封装对象,ExcelUtil<T> 类中 包含文件名、工作表名等基本属性,还有注解字段列表用来存储通过反射获取被注解标识的 Field 字段对象和对应的注解属性,内部存储结构为:[[Field, Excel], ...]


/**
 * @description: ExcelUtil 工具类二次封装
 * @author: HUALEI
 * @date: 2021-11-20
 * @time: 17:56
 */
public class ExcelUtil<T> {

    private static final Logger logger = LoggerFactory.getLogger(ExcelUtil.class);

    /**
     * Excel 文件名
     */
    private String fileName;

    /**
     * 工作表名称
     */
    private String sheetName;

    /**
     * 导出类型
     */
    private Excel.Type type;

    /**
     * 文件名后缀
     */
    private String fileNameSuffix;

    /**
     * 导入导出数据源列表
     */
    private List<T> sourceList;

    /**
     * 注解字段列表 [[Field, Excel], ...]
     */
    private List<Object[]> fields;

    /**
     * 实体对象
     */
    public Class<T> clazz;

    /**
     * Excel 写出器
     */
    public ExcelWriter excelWriter;

    public ExcelUtil(Class<T> clazz) {
        this.clazz = clazz;
    }
    
    ......
    ......
}
复制代码

封装类中除了成员变量外,最重要的就是成员方法了,考虑到导出的文件可能有时会需要 .xls 格式,所以我重载了导出 Excel 方法,默认为 .xlsx 格式。


/**
 * 对数据源列表写入到 Excel 文件中
 *
 * @param response HttpServletResponse 对象
 * @param list 数据源列表
 * @param fileName Excel 文件名
 * @param sheetName Excel 中工作表名
 */
public void exportExcel(HttpServletResponse response,
                        List<T> list,
                        String fileName,
                        String sheetName
                        ) throws Exception {
    this.excelWriter = cn.hutool.poi.excel.ExcelUtil.getBigWriter();
    logger.info("=============== 初始化 Excel ===============");
    init(list, fileName, sheetName, Excel.Type.EXPORT);
    exportExcel(response, null);
    logger.info("=============== 导出 Excel 成功 ===============");
}

/**
 * 对数据源列表写入到 Excel 文件中
 *
 * @param response HttpServletResponse 对象
 * @param list 数据源列表
 * @param fileName Excel 文件名
 * @param fileNameSuffix Excel 文件名后缀
 * @param sheetName Excel 中工作表名
 */
public void exportExcel(HttpServletResponse response,
                        List<T> list,
                        String fileName,
                        String fileNameSuffix,
                        String sheetName
                        ) throws Exception {
    this.excelWriter = cn.hutool.poi.excel.ExcelUtil.getBigWriter();
    logger.info("=============== 初始化 Excel ===============");
    init(list, fileName, sheetName, Excel.Type.EXPORT);
    exportExcel(response, fileNameSuffix);
    logger.info("=============== 导出 Excel 成功 ===============");
}
复制代码

导出方法中,首先就是要初始化写入器,然后初始化类属性值:


/**
 * 初始化类属性
 *
 * @param list 数据源列表
 * @param fileName 导出文件名
 * @param sheetName 工作表名
 * @param type 导出类型
 */
public void init(List<T> list, String fileName, String sheetName, Excel.Type type) throws Exception {
    this.sourceList = Optional.ofNullable(list).orElseGet(ArrayList<T>::new);
    this.fileName = fileName;
    this.sheetName = sheetName;
    // 设置 Sheet 工作表名称
    this.excelWriter.renameSheet(sheetName);
    this.type = type;
    // 创建表头
    createExcelField();
    // 处理数据源
    handleDataSource();
}
复制代码

初始化部分成员变量后,创建指定顺序表头,并设置表头别名:


/**
 * 创建指定顺序表头,并设置表头别名
 */
private void createExcelField() {
    this.fields = new ArrayList<Object[]>();
    
    // 临时存储变量
    List<Field> tempFields = new ArrayList<>();
    
    // 获取目标实体对象所有声明字段列表,放入临时存储变量当中
    tempFields.addAll(Arrays.asList(clazz.getSuperclass().getDeclaredFields()));
    tempFields.addAll(Arrays.asList(clazz.getDeclaredFields()));

    // 在声明的字段列表中过滤出被 @Excel 标记的字段
    tempFields.stream()
                .filter(field -> field.isAnnotationPresent(Excel.class))
                .forEach(field -> {
                    // 获取注解属性对象
                    Excel attr = field.getAnnotation(Excel.class);
                    // 筛选目标导出类型
                    if (attr != null && (attr.type() == Excel.Type.ALL || attr.type() == this.type)) {
                        // 填充注解列表 [[Field, Excel]]
                        this.fields.add(new Object[]{ field, attr });
                    }
                });

    // 根据注解中 sort 属性值进行升序排序
    this.fields.stream()
            .sorted(Comparator.comparing( arr -> ((Excel) arr[1]).sort() ))
            .collect(Collectors.toList())
            // 按顺序设置表头别名
            .forEach(arr -> { 
                String fieldName = ((Field) arr[0]).getName();
                Excel attr = (Excel) arr[1];
                this.excelWriter.addHeaderAlias(fieldName, StrUtil.isBlank(attr.headerAlias()) ? fieldName : attr.headerAlias()); 
            });
}
复制代码

先获取目标实体对象的父类和自身所有声明字段,存入临时字段列表,然后循环遍历过滤出被 @Excel 注解标识的字段,然后通过筛选目标导出类型构建一个大小为 2 的数组放入注解字段列表 this.fields 中。

其次,根据注解中 sort 属性值进行升序排序,如果全未设置顺序值,则默认根据字段定义的先后顺序进行排序。排序好之后按顺序设置表头别名,未设置的保持默认字段名。

创建完表头后,接下来就需要根据注解字段列表 fields 中每个字段上的注解属性对象对数据源列表进行处理:


/**
 * 根据注解属性处理数据源列表
 *
 * @throws Exception 获取类属性值可能抛出的异常
 */
private void handleDataSource() throws Exception {
    for (Object[] arr : this.fields) {
        // 注解标识的字段
        Field field = (Field) arr[0];
        // 注解属性对象
        Excel attr = (Excel) arr[1];
        // 设置实体类私有属性可访问
        field.setAccessible(true);
        
        for (T object: this.sourceList) {
            // 获取当前字段的属性值
            Object value = field.get(object);
            if (attr.isExport()) {
                if (value != null) {
                    // 设置时间格式
                    if (StrUtil.isNotBlank(attr.dateFormat())) {
                        field.set(object, cn.hutool.core.convert.Convert.convert(field.getType(), DateUtil.format(new DateTime(value.toString()), attr.dateFormat())));
                    }
                    // 设置转换值
                    if (StrUtil.isNotBlank(attr.readConverterExp())) {
                        String convertResult = convertByExp(Convert.toStr(value), attr.readConverterExp(), attr.separator());
                        field.set(object, convertResult);
                    }
                } else {
                    // 设置默认值
                    if (StrUtil.isNotBlank(attr.defaultValue())) {
                        field.set(object, attr.defaultValue());
                    }
                }
            } else {
                field.set(object, null);
            }
        }
    }
}
复制代码

上述代码主要通过 Java 反射原理拿到当前对象 objectfield 字段的属性值,判断当前列数据是否需要导出,需要则进一步判断注解中的属性对应的是否有值,有值且字段属性值不为 null,就去更改原有值;有值但字段属性值为 null 的,就可以设置为指定的默认值。反之,不需要导出,则将该列所有单元格置空。

单纯理解文字可能没有一个流程图来得直观、清楚,这就给你安排上:

handleDataSource() 方法执行流程图.png

对于解析导出值方法 convertByExp(),通过分隔符分割翻译注解字符串,根据 "=" 等于号左边为键、右边为值原则进行解析,具体实现代码如下:


/**
 * 解析导出值
 *
 * @param propertyValue 参数值
 * @param converterExp 翻译注解
 * @param separator 分隔符
 * @return 解析后值
 */
public static String convertByExp(String propertyValue, String converterExp, String separator) {
    StringBuilder propertyString = new StringBuilder();
    String[] convertSource = converterExp.split(separator);
    for (String item : convertSource) {
        String[] itemArray = item.split("=");
        if (StringUtils.containsAny(separator, propertyValue)) {
            for (String value : propertyValue.split(separator)) {
                if (itemArray[0].equals(value)) {
                    propertyString.append(itemArray[1]).append(separator);
                    break;
                }
            }
        }
        else {
            if (itemArray[0].equals(propertyValue)) {
                return itemArray[1];
            }
        }
    }
    return StringUtils.stripEnd(propertyString.toString(), separator);
}
复制代码

以上就完成所有的初始化的工作了,接下来就可以愉快地往 Excel 里写数据,最后写出文件到客户端进行下载。


/**
 * 写出到客户端下载
 *
 * @param response HttpServletResponse 对象
 * @param suffix 导出 Excel 文件名后缀
 */
public void exportExcel(HttpServletResponse response, String suffix) throws IOException {
    // 输出流
    ServletOutputStream out = response.getOutputStream();

    this.excelWriter.write(this.sourceList, true);
    cellWidthSelfAdaption();

    initResponse(response, suffix);

    this.excelWriter.flush(out, true);
    // 关闭 writer,释放内存
    this.excelWriter.close();
    // 关闭输出 Servlet 流
    IoUtil.close(out);
}
复制代码
  • cellWidthSelfAdaption() 方法是用来实现中文宽度自适应的,这里就不贴代码了,详细说明和代码获取请点这里 传送门 (づ ̄3 ̄)づ╭❤~

  • initResponse() 根据导出的 Excel 文件名后缀初始化 HttpServletResponse 对象来响应体和响应类型。


/**
 * 根据导出的 Excel 文件名后缀初始化 HttpServletResponse 对象
 *
 * @param response HttpServletResponse 对象
 * @param suffix 文件名后缀
 * @throws UnsupportedEncodingException 不支持的编码异常
 */
public void initResponse(HttpServletResponse response, String suffix) throws UnsupportedEncodingException {
    // 默认导出文件名后缀
    this.fileNameSuffix = ".xlsx";
    if (suffix != null) {
        switch (suffix.toLowerCase()) {
            case "xls":
            case ".xls":
                this.fileNameSuffix = ".xls";
                response.setContentType("application/vnd.ms-excel;charset=utf-8");
                break;
            case "xlsx":
            case ".xlsx":
                response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
                break;
            default:
                response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
        }
    } else {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
    }
    // 文件名中文编码
    String encodingFilename = encodingFilename(this.fileName);
    response.setHeader("Content-Disposition","attachment;filename="+ encodingFilename);
}
复制代码

默认导出文件格式为 .xlsx ,不过也可指定为 .xls,通过设置不同的内容类型实现。至于导出的文件名加个后缀编个码拼接到响应头上即可!


/**
 * 编码文件名
 *
 * @param filename 文件名
 */
public String encodingFilename(String filename) throws UnsupportedEncodingException {
    filename = filename + this.fileNameSuffix;
    return URLEncoder.encode(filename, CharsetUtil.UTF_8);
}
复制代码

至此,整个注解 + ExcelUtil 二次封装的代码就写完了。

image.png

暴露接口

实体对象

老样子,实体对象给它套上 @Excel 注解,随便加点属性 " Buff ":

063985D5.jpg


@Data
public class ProvinceCustomAnnotationExcelVO implements Serializable {

    private static final long serialVersionUID = 877981781678377000L;

    /**
     * 省份
     */
    @Excel(headerAlias = "省份")
    private String province;

    /**
     * 省份的简称
     */
    @Excel(headerAlias = "简称")
    private String abbr;

    /**
     * 省份的面积(km²)
     */
    @Excel(headerAlias = "面积(km²)")
    private Integer area;

    /**
     * 省份的人口(万)
     */
    @Excel(headerAlias = "人口(万)")
    private BigDecimal population;

    /**
     * 省份的著名景点
     */
    @Excel(headerAlias = "著名景点")
    private String attraction;

    /**
     * 省会的邮政编码
     */
    @Excel(headerAlias = "邮政编码", readConverterExp = "100=牛逼就完事|050000=哈哈哈", separator = "\\|")
    private String postcode;

    /**
     * 省会名
     */
    @Excel(headerAlias = "省会", defaultValue = "默认值")
    private String city;

    /**
     * 省会的别名
     */
    @Excel(headerAlias = "别名", isExport = false)
    private String nickname;

    /**
     * 省会的气候类型
     */
    @Excel(headerAlias = "气候类型")
    private String climate;

    /**
     * 省会的车牌号
     */
    @Excel(headerAlias = "车牌号", defaultValue = "数据暂无")
    private String carcode;

    /**
     * 测试时间
     */
    @Excel(headerAlias = "创建时间", dateFormat = "yyyy年MM月dd日 HH时mm分ss秒")
    private String createTime;
}
复制代码

控制层

ServicegetAllProvinceDetails() 方法具体代码实现请参考 【ExcelUtil】实现文件写出到客户端下载全过程 - 掘金 (juejin.cn)


@GetMapping("provinces/custom/excel/export/{fileNameSuffix}")
public void customAnnotationExcelExport(HttpServletResponse response, @PathVariable("fileNameSuffix") String fileNameSuffix) throws Exception {
    // 获取省份详情信息
    List<ProvinceExcelVO> provinceExcelList = this.provinceService.getAllProvinceDetails();
    // Bean 对象转换拿到数据源列表
    List<ProvinceCustomAnnotationExcelVO> provinceCustomAnnotationExcelList = BeanUtil.copyToList(provinceExcelList, ProvinceCustomAnnotationExcelVO.class);
    
    // 为了测试导出时间格式化,添加点随机日期时间
    provinceCustomAnnotationExcelList.forEach(p -> p.setCreateTime(RandomUtil.randomDate(new Date(), DateField.SECOND, 0, 24*60*60).toString()));

    // 使用有参构造(必需)创建一个 ExcelUtil 对象
    ExcelUtil<ProvinceCustomAnnotationExcelVO> excelUtil = new ExcelUtil<>(ProvinceCustomAnnotationExcelVO.class);

    // 文件名(当天日期_各省份信息)
    String fileName = StrUtil.format("{}{}各省份信息", DateUtil.today(), StrUtil.UNDERLINE);
    // Sheet 工作表名
    String sheetName = "省份详情表";
    
    if (StrUtil.isBlank(fileNameSuffix)) {
        // 测试导出默认格式
        excelUtil.exportExcel(response, provinceCustomAnnotationExcelList, fileName, sheetName);
    } else {
        // 测试导出指定格式
        excelUtil.exportExcel(response, provinceCustomAnnotationExcelList, fileName, fileNameSuffix, sheetName);
    }
}
复制代码

导出的文件名后缀放在路径上主要是为了测试的方便,实际开发中 duck 不必这样!

接口测试

image.png

开始测试:

GET: http://localhost:8088/file/provinces/custom/excel/export/xls

image.png

GET: http://localhost:8088/file/provinces/custom/excel/export/.xlsx

image.png

GET: http://localhost:8088/file/provinces/custom/excel/export/""

image.png

GET: http://localhost:8088/file/provinces/custom/excel/export/HUALEI

image.png

测试全部通过,堪称完美,填坑成功!!撒花 ✿✿ヽ(°▽°)ノ✿

总结

总体实现下来并不算太难,使用注解驱动简直不要太香,用起来很方便,即便没学过编程的小白也会用,一两行代码就能完成一个数据源列表的导出。

image.png

唯一不足的就是数据导入没有集成进去,不过本文重点并不在于导入,哈哈哈,有兴趣的小伙伴可以尝试一下哦 ヾ(◍°∇°◍)ノ゙

结尾

撰文不易,欢迎大家点赞、评论,你的关注、点赞是我坚持的不懈动力,感谢大家能够看到这里!Peace & Love。

猜你喜欢

转载自juejin.im/post/7033664913373560840