EasyExcel在项目中的应用-在web中导出带下拉框和批注的excel文件

5,293 阅读11分钟

前言

​ 好长一段时间没有更新博客了,最近刚刚找到实习工作,接触了企业中的项目,在这段时间的实习过程中,终于知道了企业级项目的体量和业务难度跟之前的小项目是完全不同的。10多天的适应期也逐渐让我找到了状态,初步完成了学生到职场新人的转变。

​ 我入职的公司在面试时还提出了一个让我掉进坑里的问题,之前写过一篇文章专门介绍了该问题,有兴趣的同学可以移步查看:面试官问我存储金额应该用哪种数据类型,我竟这样回答

​ 言归正传,最近几天,老大找了几个小功能来让我练手,做了几个小功能模块,基本都是与excel打交道。大概需求如下:导出excel模板,让用户在excel中填写数据做批量添加功能。

​ 因为部分数据是需要从下拉框选取固定的数据,所以需要在导出excel时在对应的单元格列生成下拉框,同时为了让用户明确每个单元格中需要填写的数据格式,在每列的头部也引入了批注。

技术选型

​ 首先让我们来看一下easyExcel中文文档中对这门技术的描述:快速、简单避免OOM的java处理Excel工具

​ 常见的poi和jxl这两种java解析、生成excel的框架都存在一个巨大的缺陷,就是非常的耗内存!而且如果使用poi或者jxl的话需要写大量的底层代码,操作比较繁琐;easypoi虽然对poi进行了解析和封装,简便了读和写的操作。但是由于easypoi对poi的解析方式是dom解析,把结果一次都读入内存操作,这样的操作平时是不会有问题的,但是并发量上来的时候就会出现OOM(OutOfMemory)即内存用完了的问题。而easyExcel对poi进行了封装,运用的SAX的解析方式,明显降低了内存,在简便了操作的同时,也使得java对excel的读或写操作的内存消耗大大降低!

​ 所以综合对比之后选择了阿里开源的easyExcel框架来完成需求。

操作步骤

​ 这里通过一个用户的demo来模拟实际场景。

导入maven依赖

​ 首先当然是要导入maven依赖

<!--easyExcel工具-->
<dependency>
   <groupId>com.alibaba</groupId>
   <artifactId>easyexcel</artifactId>
   <version>2.2.3</version>
</dependency>

这里需要注意的是因为easyExcel已经导入了poi的依赖,所以不要再导入poi的依赖了,如果要导入的话要注意一下版本冲突的问题。

web中的写

​ 第一次上手的同学,建议可以直接参照easyExcel的官方中文文档,文档里对操作的描述和代码都比较详细,也有入门的demo,可以根据官网给出的代码先照猫画虎。easyExcel的文档地址我也会在结尾处贴出来。

​ 先看一下web中最基本的写出excel代码:

	@GetMapping("/download")
    public void download(HttpServletResponse response, String fileName) throws IOException {
        //设置返回值类型为
        response.setContentType("application/vnd.ms-excel");
        response.setCharacterEncoding("utf-8");
        //编码 防止中文乱码
        fileName = URLEncoder.encode(fileName,"UTF-8");
        response.setHeader("Content-disposition","attachment;filename="+fileName+".xlsx");
        List<User> userList = userService.getUser();
        //easyExcel.write第一个参数是输出流 第二个是依照哪个模板写出
        //sheet是指设置工作簿名
        //doWrite就是写出哪些数据
        EasyExcel.write(response.getOutputStream(), User.class)
                .sheet("用户测试")
                .doWrite(userList);
    }

​ 这段代码就是把从数据库中查询出来的用户列表依照用户的模板写出到excel,那个用户模板又是啥呢?

​ 我们来看一下代码:

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("tb_user")
public class User {
    @ExcelProperty(value = "id",index = 0)
    @TableId(type = IdType.AUTO)
    private Long id;
    @ExcelProperty(value = "用户名",index = 1)
    private String name;
    @ExcelProperty(value = "年龄",index = 2)
    private Integer age;
}

​ 其实就是一个正常的pojo,但是pojo的每个字段上都加上了一个注解@ExcelProperty(),那么这个注解是什么意思呢?其实就是把JavaBean的属性当做是excel表中的一个列,而一条数据就是一行。而value中设置的其实就是该列的表头名称,index就是设置该列在表中的位置,从0开始往后排列。

​ 读到这里可能有同学会发现这不就是数据库吗?是的!java对对象的写出excel其实就可以理解为数据库中的一条条记录,不过是将数据实体化成了一个excel表。

​ 前端可以直接使用a标签来访问下载方法来下载该excel表格。

​ 下载后excel如下:

​ 根据图片可以看出在javaBean中设置了@ExcelProperty()注解的字段,别当做了表头,数据就是通过该注解设置的value属性值,而顺序也是通过index属性来确定的。而下面工作簿的名称则是在写出时通过.sheet()来设置的。

​ 以上就是web中最简单的写,那个复杂一点的业务呢?比如年龄这一列的数据我希望从数据库查出一个列表以供用户选择,这个要怎么实现呢?

下拉框实现

​ 我们可以自定义一个拦截器来完成该需求:

​ 自定义一个拦截器实现SheetWriteHandler接口,重新接口的方法,在afterSheetCreate写逻辑代码即可。

public class SpinnerWriteHandler implements SheetWriteHandler {

    @Override
    public void beforeSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
    }

    @Override
    public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
        //定义一个map key是需要添加下拉框的列的index value是下拉框数据
        Map<Integer,String[]> mapDropDown = new HashMap<>(1);
        //定义下拉框中的数据 并添加到map中 这里的key是写死的
        String[] ageArray = {18,19,20,21};
        mapDropDown.put(2,ageArray);
        //获取工作簿
        Sheet sheet = writeSheetHolder.getSheet();
        ///开始设置下拉框
        DataValidationHelper helper = sheet.getDataValidationHelper();
        //设置下拉框
        for (Map.Entry<Integer, String[]> entry : mapDropDown.entrySet()) {
            /*起始行、终止行、起始列、终止列  起始行为1即表示表头不设置**/
            CellRangeAddressList addressList = new CellRangeAddressList(1, 65535, entry.getKey(), entry.getKey());
            /*设置下拉框数据**/
            DataValidationConstraint constraint = helper.createExplicitListConstraint(entry.getValue());
            DataValidation dataValidation = helper.createValidation(constraint, addressList);
            sheet.addValidationData(dataValidation);
        }
    }
}

​ 该拦截器会在工作簿创建完成后被执行,思想类似于springAop,在代码动态的插入到源代码的执行中。现在就已经实现了表中某一列的下拉框的设置。因为使用了map来存储数据,所以可以给多个列设置,只需要添加数据到map中即可,key为需要添加列的index,value为一个String[]即下拉框的数据。

​ 在controller层的写出excel代码加一行即可引用该handler使功能实现:

@GetMapping("/download")
    public void download(HttpServletResponse response, String fileName) throws IOException {
        response.setContentType("application/vnd.ms-excel");
        response.setCharacterEncoding("utf-8");
        //编码 防止中文乱码
        fileName = URLEncoder.encode(fileName,"UTF-8");
        response.setHeader("Content-disposition","attachment;filename="+fileName+".xlsx");
        List<User> userList = userService.getUser();
        EasyExcel.write(response.getOutputStream(), User.class)
                .sheet("用户测试")
            	//registerWriteHandler 即为注册拦截器
                .registerWriteHandler(new SpinnerWriteHandler())
                .doWrite(userList);
    }

​ 至此就可以实现excel中的下拉框:

​ 但是这里出现了一个问题,那就是下拉框的数据是写死的,我们希望的应该是从数据库中查出数据,那么应该怎么做呢?

​ 有同学应该就想到了,把这个类加上@Component注解交给Spring管理,再把service实现类注入到该类中,在执行业务时先从数据库查出数据,在对数据做封装即可,代码如下:

@Component
public class SpinnerWriteHandler implements SheetWriteHandler {
    @Resource
    private AgeService ageService;

    @Override
    public void beforeSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
    }

    @Override
    public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
        List<Age> ageList = ageService.getAge();
        String[] ageArray = new String[ageList.size()];
        for (int i = 0; i < ageList.size(); i++) {
            ageArray[i] = ageList.get(i).getAge().toString();
        }
        Map<Integer,String[]> mapDropDown = new HashMap<>();
        mapDropDown.put(2,ageArray);
        Sheet sheet = writeSheetHolder.getSheet();
        ///开始设置下拉框
        DataValidationHelper helper = sheet.getDataValidationHelper();//设置下拉框
        for (Map.Entry<Integer, String[]> entry : mapDropDown.entrySet()) {
            /*起始行、终止行、起始列、终止列**/
            CellRangeAddressList addressList = new CellRangeAddressList(1, 65535, entry.getKey(), entry.getKey());
            /*设置下拉框数据**/
            DataValidationConstraint constraint = helper.createExplicitListConstraint(entry.getValue());
            DataValidation dataValidation = helper.createValidation(constraint, addressList);
            /*处理Excel兼容性问题**/
            if (dataValidation instanceof XSSFDataValidation) {
                dataValidation.setSuppressDropDownArrow(true);
                dataValidation.setShowErrorBox(true);
            } else {
                dataValidation.setSuppressDropDownArrow(false);
            }
            sheet.addValidationData(dataValidation);
        }
    }
}

​ 但是这条路是行不通的!在代码执行时就会抛出空指针异常,表示ageService是空的!这就证明ageService并未如我们所愿正确注入数据,该问题的具体原因我也没有找到,希望有大佬找到问题原因之后可以在评论区不吝赐教。

​ 既然spring的方法行不通,那么我们可以尝试一下java的办法。以下实现方式是我的实现思路,但是这种实现方式应该存在很大问题,是实打实的笨办法。如果有大佬有更好的实现思路可以在评论区讨论交流。

​ 既然不能通过依赖注入在该代码中做查询,那么可不可以先查出数据再把数据传进来呢?我们可以给这个拦截器设置一个成员变量,提供set方法或带参构造,在controller代码执行时先从数据库查出数据,再把数据传递到该拦截器中,在拦截器中只需要封装数据即可。

​ 该思路的代码实现如下:

​ 拦截器:

public class SpinnerWriteHandler implements SheetWriteHandler {


    private List<Age> ageList;

    public SpinnerWriteHandler(List<Age> ageList) {
        this.ageList = ageList;
    }

    public SpinnerWriteHandler() {
    }

    @Override
    public void beforeSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
    }

    @Override
    public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
        String[] ageArray = new String[ageList.size()];
        for (int i = 0; i < ageList.size(); i++) {
            ageArray[i] = ageList.get(i).getAge().toString();
        }
        Map<Integer,String[]> mapDropDown = new HashMap<>();
        mapDropDown.put(2,ageArray);
        Sheet sheet = writeSheetHolder.getSheet();
        ///开始设置下拉框
        DataValidationHelper helper = sheet.getDataValidationHelper();
        //设置下拉框
        for (Map.Entry<Integer, String[]> entry : mapDropDown.entrySet()) {
            /*起始行、终止行、起始列、终止列**/
            CellRangeAddressList addressList = new CellRangeAddressList(1, 65535, entry.getKey(), entry.getKey());
            /*设置下拉框数据**/
            DataValidationConstraint constraint = helper.createExplicitListConstraint(entry.getValue());
            DataValidation dataValidation = helper.createValidation(constraint, addressList);
            sheet.addValidationData(dataValidation);
        }
    }
}

​ controller:

 @GetMapping("/download")
    public void download(HttpServletResponse response, String fileName) throws IOException {
        response.setContentType("application/vnd.ms-excel");
        response.setCharacterEncoding("utf-8");
        //编码 防止中文乱码
        fileName = URLEncoder.encode(fileName,"UTF-8");
        response.setHeader("Content-disposition","attachment;filename="+fileName+".xlsx");
        List<User> userList = userService.getUser();
        SpinnerWriteHandler spinnerWriteHandler = new SpinnerWriteHandler(ageService.getAge());
        EasyExcel.write(response.getOutputStream(), User.class)
                .sheet("用户测试")
                .registerWriteHandler(spinnerWriteHandler)
                .doWrite(userList);
    }

​ 至此可以实现下拉框的数据从数据库查询需求,如果要实现多个列的下拉框可以通过map传递数据,或者封装一个BO对象,再通过set方法或带参构造传递数据接口。

批注实现

​ 批注的实现跟拦截器思路相近,通过一个拦截器即可实现。

​ 自定义拦截器继承AbstractRowWriteHandler类,重写afterRowDispose方法,在方法中写逻辑代码即可

public class CommentWriteHandler extends AbstractRowWriteHandler {

    @Override
    public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) {
        if (isHead) {
            Sheet sheet = writeSheetHolder.getSheet();
            Drawing<?> drawingPatriarch = sheet.createDrawingPatriarch();
            // 在第一行 第二列创建一个批注
            Comment comment =
                    drawingPatriarch.createCellComment(new XSSFClientAnchor(0, 0, 0, 0, (short)1, 0, (short)2, 1));
            // 输入批注信息
            comment.setString(new XSSFRichTextString("哈哈哈哈"));
            // 将批注添加到单元格对象中 从0开始计算 第1行第2列
            sheet.getRow(0).getCell(1).setCellComment(comment);
        }
    }
}

​ 这个拦截器是在行处置之后做业务处理的

​ 需要注意的是:XSSFClientAnchor对象的几个参数有必要说明一下:

XSSFClientAnchor(int dx1, int dy1, int dx2, int dy2, int col1, int row1, int col2, int row2)
dx1:起始单元格的x偏移量,填0即可;
dy1:起始单元格的y偏移量,填0即可;
dx2:终止单元格的x偏移量,填0即可;
dy2:终止单元格的y偏移量,填0即可;
col1:起始单元格列序号,从0开始计算;
row1:起始单元格行序号,从0开始计算;
col2:终止单元格列序号,从0开始计算,如例子中col1=1,col2=2就表示单元格列为第二列;
row2:终止单元格行序号,从0开始计算,如例子中row1=0,row2=1就表示单元行为第一行;

对于我们来说,前面4个参数无需关注,只需要关注后面四个参数确定单元格坐标即可,这个位置填写的坐标要与最后一行代码中填写的坐标相对应。

最后在controller中用跟下拉框拦截器同样的方式注册批注拦截器即可:

@GetMapping("/download")
    public void download(HttpServletResponse response, String fileName) throws IOException {
        response.setContentType("application/vnd.ms-excel");
        response.setCharacterEncoding("utf-8");
        //编码 防止中文乱码
        fileName = URLEncoder.encode(fileName,"UTF-8");
        response.setHeader("Content-disposition","attachment;filename="+fileName+".xlsx");
        List<User> userList = userService.getUser();
        SpinnerWriteHandler spinnerWriteHandler = new SpinnerWriteHandler(ageService.getAge());

        EasyExcel.write(response.getOutputStream(), User.class)
                .sheet("用户测试")
                .registerWriteHandler(spinnerWriteHandler)
            	//注册批注拦截器
                .registerWriteHandler(new CommentWriteHandler())
                .doWrite(userList);
    }

现在关于web中写出带下拉框和批注的excel表格就已经实现了。

总结

​ 使用easyExcel可以方便快捷的完成java对excel表格的操作,其他更多的操作和代码请参见easyExcel的官方文档:easyExcel官方中文文档

​ 如果针对web中easyExcel操作有更好的建议的话,请在评论区讨论交流,不吝赐教。

本文已在CSDN同步上传:EasyExcel在项目中的应用-在web中导出带下拉框和批注的excel文件

补充

经过掘金社区评论区大佬的指点,发现了ageService未能成功注入的原因:SpinnerWriteHandler类虽然加了@Component注解交给了spring容器管理,但是在controller层使用的时候,直接去new SpinnerWriteHandler() 调用无参构造去创建了对象,这个对象不是spring的bean 而是一个普通的类 不走spring bean的生命周期 并不会自动注入ageservice

在这里,我要感谢这位大佬的指点,还是我对于springBean的生命周期没有理解透彻,基础不够扎实!要去恶补一下这一块的知识了。