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

愿天堂没有BUG

共 14061字,需浏览 29分钟

 · 2021-11-30

需求分析

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

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

我:

代码实现

自定义注解

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


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

工具类封装

通过 new ExcelUtil<>(xxx.class); 来创建二次封装对象,ExcelUtil 类中 包含文件名、工作表名等基本属性,还有注解字段列表用来存储通过反射获取被注解标识的 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 sourceList;

/**
* 注解字段列表 [[Field, Excel], ...]
*/

private List fields;

/**
* 实体对象
*/

public Class clazz;

/**
* Excel 写出器
*/

public ExcelWriter excelWriter;

public ExcelUtil(Class 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 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 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 list, String fileName, String sheetName, Excel.Type type) throws Exception {
this.sourceList = Optional.ofNullable(list).orElseGet(ArrayList::new);
this.fileName = fileName;
this.sheetName = sheetName;
// 设置 Sheet 工作表名称
this.excelWriter.renameSheet(sheetName);
this.type = type;
// 创建表头
createExcelField();
// 处理数据源
handleDataSource();
}
复制代码

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


/**
* 创建指定顺序表头,并设置表头别名
*/

private void createExcelField() {
this.fields = new ArrayList();

// 临时存储变量
List 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 的,就可以设置为指定的默认值。反之,不需要导出,则将该列所有单元格置空。

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

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

暴露接口

实体对象

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


@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 provinceExcelList = this.provinceService.getAllProvinceDetails();
// Bean 对象转换拿到数据源列表
List provinceCustomAnnotationExcelList = BeanUtil.copyToList(provinceExcelList, ProvinceCustomAnnotationExcelVO.class);

// 为了测试导出时间格式化,添加点随机日期时间
provinceCustomAnnotationExcelList.forEach(p -> p.setCreateTime(RandomUtil.randomDate(new Date(), DateField.SECOND, 0, 24*60*60).toString()));

// 使用有参构造(必需)创建一个 ExcelUtil 对象
ExcelUtil 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 不必这样!

接口测试

开始测试:

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

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

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

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

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

总结

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

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


作者:HUALEI
链接:https://juejin.cn/post/7033664913373560840
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。



浏览 89
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报