提出问题
日常工作中,我们经常会遇到使用 Excel 批量导入的需求,正常情况下,我们会在 Controller 中接收前端传过来的文件流,并进行解析
@RequestMapping("importExcel")
public ResultDTO<List<UserExcelDTO>> importExcel(@RequestParam("file") MultipartFile file) {
try (InputStream in = file.getInputStream()) {
// TODO 解析 Excel
List<UserExcelDTO> EasyExcel.read(in)...
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
这样做看起来也挺好的,常规也是这样做的。
但是在团队中,会遇到这些问题
- 对 EasyExcel 的 API 不熟悉,容易出错
- 解析 Excel 放在 Controller 层?还是 Service 层?
- 支持 Excel 导入,又要支持在页面上的批量操作,如何复用?
做过多次类似需求的同学会发现,导入其实是一种 批量操作 的手段,而 Excel 是一种数据交互格式,就像 JSON 和 XML 一样。若直接在前端进行Excel解析成 JSON,对于后端来说,就只要提供一个批量操作的接口就可以完成批量操作和 Excel 导入两个需求了。
话外音:前端的很多限制,请不要在前端做 Excel 解析
因此,我们可以得出一个洞察:Excel 导入本质上是一种通过 Excel 这种数据交互格式进行批量操作的手段
有了这个认识,我们比较容易想到,
应该在 Controller 层将 Excel 解析成批量操作所使用的数据对象然后复用批量操作的 Service 方法即可
然而,很多人在先接到了一个 Excel 导入的需求时,没多想,把很多业务逻辑与 EasyExcel 耦合在一起,结果再来一个批量操作的需求,因为难以解耦(可能也不是同一个人做),就重起一个方法,把相同的业务逻辑再写了一遍,增加开发和维护成本。
那么,如何优雅地实现 Excel 导入呢?
既然是一种协议,那就应该像 JSON 那样,我们在写后端代码时是不需要感知到 JSON 解析的过程的
所以要看一下在 SpringMVC 中 JSON 是如何被解析的?
解决方案
经过研究,SpringMVC 是通过一系列实现 HttpMessageConverter 接口的类来支持不同三方包来解析 JSON 请求体的。
我们要实现批量操作,要接收一个 List<XXXExcelDTO>,因为是泛型,我们要实现针对泛型的接口 GenericHttpMessageConverter 来实现这个解析过程
模仿 AbstractJsonHttpMessageConverter 的写法,我们实现 canRead 和 read 两个方法:
/**
* Excel 请求解析器
*
* @author dadiyang
* @since 2022/1/29
*/
@Component // 让 Spring 扫描可以扫到
public class ExcelHttpMessageConverter implements GenericHttpMessageConverter<Object> {
private final static List<MediaType> SUPPORTED_MEDIA_TYPES = Arrays.asList(
// xlsx 格式
MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8"),
// xls 格式
MediaType.parseMediaType("application/vnd.ms-excel"),
MediaType.parseMediaType("application/vnd.ms-excel;charset=UTF-8"));
/**
* 支持 Excel 格式的媒体类型
* <p>
* 用于接收的请求体的类必须打上 ExcelBody 注解,可以用 List 接收也可以用一个包含 List 属性的类来接收
*/
@Override
public boolean canRead(Type type, Class<?> contextClass, MediaType mediaType) {
if (!SUPPORTED_MEDIA_TYPES.contains(mediaType)) {
return false;
}
if (!(type instanceof ParameterizedTypeImpl)) {
return false;
}
// 导入只能用 List 接收
if (!List.class.isAssignableFrom(((ParameterizedTypeImpl) type).getRawType())) {
return false;
}
// GenericTypeResolver 是 SpringCore 提供用来解析泛型参数的强大工具类
Type[] types = ((ParameterizedTypeImpl) GenericTypeResolver.resolveType(type, contextClass)).getActualTypeArguments();
if (types == null || types.length != 1) {
return false;
}
Class<?> clazz = (Class<?>) types[0];
ExcelBody excelBody = clazz.getAnnotation(ExcelBody.class);
return excelBody != null;
}
@Override
public Object read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
// 解析出参数的具体类型
Class<?> clazz = (Class<?>) ((ParameterizedTypeImpl) GenericTypeResolver.resolveType(type, contextClass)).getActualTypeArguments()[0];
// 获取 ExcelBody 注解
ExcelBody excelBody = clazz.getAnnotation(ExcelBody.class);
try (InputStream in = inputMessage.getBody()) {
// 通过 EasyExcel 解析输入流并返回
return EasyExcel.read(in)
.headRowNumber(excelBody.headRowNumber())
.sheet(excelBody.sheetNo() > 0 ? excelBody.sheetNo() : null, "".equals(excelBody.sheetName()) ? null : excelBody.sheetName())
.head(clazz).doReadSync();
}
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return SUPPORTED_MEDIA_TYPES;
}
// 该接口剩下的方法留空即可...
}
因为 Excel 解析需要指定一些配置参数,我们约定 用于接收 Excel 的类必须打上 ExcelBody 这个注解。
/**
* 标注一个类件用于接收 Excel 文件上传请求的
*
* @author dadiyang
* @since 2022/1/29
*/
@Inherited
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelBody {
/**
* sheet 名,不指定则取第一个 sheet
*/
String sheetName() default "";
/**
* 从 0 开始计,取第几个 sheet,不指定则取第一个 sheet
*/
int sheetNo() default -1;
/**
* 表头有几行,默认 1
* 0代表没有表头
*/
int headRowNumber() default 1;
}
用一下试试
首先,我们写一个批量更新的接口,注意在参数前加上 @RequestBody 像普通接收 JSON 格式数据的接口一样
@PostMapping("batchUpdateUser")
public List<UpdateUserByIdDTO> batchUpdateUser(@RequestBody List<UpdateUserByIdDTO> user) {
return user;
}
然后在请求体的定义中加上 ExcelBody 注解即可
@Data
@ExcelBody
public class UpdateUserByIdDTO {
@ExcelProperty("id")
private Long id;
@ExcelProperty("用户名")
private String username;
@ExcelProperty("地址")
private String address;
}
我们建一个 Excel 文件,根据 UpdateUserByIdDTO 定义的字段名称,造几条测试数据
使用 PostMan 调用一下,看看效果
可以看见,Excel 的解析对于业务代码就完全透明了
看起来这个接口跟普通的接口一样,那我完全不改代码,可以像常规的接口那样用 JSON 格式数据调用吗?试试就知道了
答案是,可以!!
通过这个办法,我们就可以用一个接口,即实现页面上普通的批量操作,又支持 Excel 导入进行批量操作了!
待优化
本文提供的演示代码只实现了最简单的解析,若要完善起来还有很多可以做的。例如:
- 数据格式校验(Excel 是用户直接上传的,无法要求他们一定按模板提交,内容填错会读取失败,生成程序抛异常,应支持数据验证并提供友好的提示给用户)
- 多 Sheet 读取。目前我们直接限定接收对象只能是 List,其实可以支持一个普通类,拥有多个 List 字段,每个 List 代表一个 Sheet
- 自定义转换器,以应对复杂的模板解析