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

前言

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

需求分析

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

image.png

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

image.png

我:

熊猫人.gif

代码实现

自定义注解

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
java复制代码
/**
* @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], …]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
java复制代码
/**
* @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 格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
java复制代码
/**
* 对数据源列表写入到 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 成功 ===============");
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码
/**
* 初始化类属性
*
* @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();
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
java复制代码
/**
* 创建指定顺序表头,并设置表头别名
*/
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 中每个字段上的注解属性对象对数据源列表进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
java复制代码
/**
* 根据注解属性处理数据源列表
*
* @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(),通过分隔符分割翻译注解字符串,根据 “=“ 等于号左边为键、右边为值原则进行解析,具体实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码
/**
* 解析导出值
*
* @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 里写数据,最后写出文件到客户端进行下载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码
/**
* 写出到客户端下载
*
* @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 对象来响应体和响应类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
java复制代码
/**
* 根据导出的 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,通过设置不同的内容类型实现。至于导出的文件名加个后缀编个码拼接到响应头上即可!

1
2
3
4
5
6
7
8
9
10
java复制代码
/**
* 编码文件名
*
* @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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
java复制代码
@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)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码
@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。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%