需求分析

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

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

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

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

我:

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

代码实现

自定义注解

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

/**
* @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​​ 内部枚举类,用来区分被注解标识字段是导入还是导出,虽然这里的需求只要做导出,防范于未然,帮助你立身于需求高地。

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

工具类封装

通过 ​​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​​​ 反射原理拿到当前对象 ​​object​​​ 下 ​​field​​​ 字段的属性值,判断当前列数据是否需要导出,需要则进一步判断注解中的属性对应的是否有值,有值且字段属性值不为 ​​null​​​,就去更改原有值;有值但字段属性值为 ​​null​​ 的,就可以设置为指定的默认值。反之,不需要导出,则将该列所有单元格置空。

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

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

对于解析导出值方法 ​​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​​ 二次封装的代码就写完了。

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

暴露接口

实体对象

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

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

@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;
}

控制层

​Service​​​ 层 ​​getAllProvinceDetails()​​ 方法具体代码实现请参考 【ExcelUtil】实现文件写出到客户端下载全过程

@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 不必这样!

接口测试

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

开始测试:

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

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

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

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

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

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

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

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

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

总结

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

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

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


作者:HUALEI
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。