把 Excel 当作一种协议来看,代码实现起来就简单多了

提出问题

日常工作中,我们经常会遇到使用 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;
}

这样做看起来也挺好的,常规也是这样做的。

但是在团队中,会遇到这些问题

  1. 对 EasyExcel 的 API 不熟悉,容易出错
  2. 解析 Excel 放在 Controller 层?还是 Service 层?
  3. 支持 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 来实现这个解析过程
JsonHttpMessageConverter
模仿 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 调用一下,看看效果
POST调用
可以看见,Excel 的解析对于业务代码就完全透明了

看起来这个接口跟普通的接口一样,那我完全不改代码,可以像常规的接口那样用 JSON 格式数据调用吗?试试就知道了

JSON 调用

答案是,可以!!

通过这个办法,我们就可以用一个接口,即实现页面上普通的批量操作,又支持 Excel 导入进行批量操作了!

待优化

本文提供的演示代码只实现了最简单的解析,若要完善起来还有很多可以做的。例如:

  1. 数据格式校验(Excel 是用户直接上传的,无法要求他们一定按模板提交,内容填错会读取失败,生成程序抛异常,应支持数据验证并提供友好的提示给用户)
  2. 多 Sheet 读取。目前我们直接限定接收对象只能是 List,其实可以支持一个普通类,拥有多个 List 字段,每个 List 代表一个 Sheet
  3. 自定义转换器,以应对复杂的模板解析
  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值