封装PoiExcelUtils

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

为什么要封装PoiExcelUtils

前面两篇其实基本已经完成了需求,只不过存在两个问题:

  • Controller层代码太臃肿了
  • 复用性差

特别是复用性,几乎为0,如果TeacherController也有导出需求,同样的一坨代码还要再写一遍,所以我们有必要抽取通用代码,尝试封装一个PoiExcelUtils。

通常来说,封装工具类的前提是"出现重复代码了"。为了能观察到重复代码,我们可以比对导入Teacher和导入Student的代码。

假设学生表是这样的:

而老师表是这样的:

相比学生表,有两点变化:

  • 数据不再是顶着左上角,而是各自空了一格
  • 字段变了,字段数量也不同

如果把两份导入代码都写出来,大概是这样的:

有4处地方问题要解决:

  • rowIndex、cellIndex:数据从哪行哪列开始读,每份Excel可能不同
  • 如何抽取通用代码,自动为各种POJO设置值(难点一)
  • 抽取Excel文件输入流
  • 解决单元格和POJO字段的映射顺序(难点二)

封装导入代码

第一步:抽取rowIndex、cellIndex

前两个很简单,直接抽取到形参即可,大家自己做。大概类似这样:

public void importExcel(Integer rowIndex, Integer cellIndex) throws Exception;

第二步:反射设置字段值

也就是如何抽取通用代码,自动为各种POJO设置值。

但凡通用代码,必然是对重复代码/重复操作的抽取。如果我们要抽取一段通用的代码一个POJO的不同 字段设置值,那么必然是通过一个循环语句(for循环或其他)。

这个结论怎么推导出来的?

因为Teacher可能5个字段,Student6个字段,Worker7个字段。如果要用通用代码为它们各自的字段赋值,那么必然是一个for循环,不然无法解决字段个数不同的问题。也就是5个字段的POJO循环5次,6个字段的POJO循环6次,以此类推。

但for循环本身对被迭代的对象有个隐性要求:每一次操作的对象都是相同/相似的。因为for循环的定义就是:重复N次相同操作。

也就是说,我们如果要写一个for循环对所有POJO的所有字段进行遍历,比如这样:

for (字段 : POJO){
    字段.set(getCellValue());
}

这要求所有POJO类型相同的,而且POJO内部的所有字段类型也是相同的。

于是我们得到了一个悖论:有差异的POJO们希望抽取通用代码对自己进行无差别迭代,而通用代码却要求迭代对象是无差异的。

我们分析一下为什么会出现这种悖论:

  • 首先,横向比较来看这几个POJO类型是不同的,Teacher、Student、Worker
  • 其次,纵向比较来看,同一个POJO的字段类型也是不同的,Integer age、String name

不同POJO、甚至同一个POJO的不同字段都无法用一个for搞定,似乎只能逐个手写:

Teacher字段1.set(getCellValue());
Teacher字段2.set(getCellValue());
Teacher字段3.set(getCellValue());

Student字段1.set(getCellValue());
Student字段2.set(getCellValue());
Student字段3.set(getCellValue());

Worker字段1.set(getCellValue());
Worker字段2.set(getCellValue());
Worker字段3.set(getCellValue());

那有没有可能找到POJO与POJO之间、字段与字段之间的共性内容,从而在更高的抽象层面上谋求统一呢?比如,我是中国人,他是日本人,我们不同国籍,不是同一个民族。但是如果站在更高的角度,我们都是地球人。

这给了我们启发,Teacher、Student、Worker向上抽象可以得到Class对象,而Integer age、String name向上抽象可以得到Field对象。

分析到这里,反射就呼之欲出了。

public void method(Class pojoClass) {
    Object pojo = pojoClass.newInstance();
 	Field[] fields = pojoClass.getDeclaredFields();
    for (field : fields) {
        field.set(pojo, getCellValue())
    }   
}

初步动手实践一下:

public class MyUtils {
    public static void main(String[] args) throws Exception {
        MyUtils myUtil = new MyUtils();
        // 假设我们要读取student_info.xlsx,由于标题和表头各占一行,所以RowStartIndex=2,而数据紧贴左边,所以cellStartIndex=0
        myUtil.importExcel(2, 0, Student.class);
    }


    /**
     * 第二步,抽取Class、Field
     *
     * @param RowStartIndex  从哪行开始读取(从0开始)
     * @param cellStartIndex 从那列开始读取(从0开始)
     * @param pojoClass      要操作的类型
     * @throws Exception
     */
    public void importExcel(Integer RowStartIndex, Integer cellStartIndex, Class pojoClass) throws Exception {
        // 获取工作薄
        XSSFWorkbook workbook = new XSSFWorkbook("/Users/bravo1988/Desktop/student_info.xlsx");
        // 获取工作表。一个工作薄中可能有多个工作表,比如sheet1 sheet2,可以根据下标,也可以根据sheet名称。这里根据下标即可。
        XSSFSheet sheet = workbook.getSheetAt(0);
        // 得到Pojo所有字段
        Field[] fields = pojoClass.getDeclaredFields();
        List<Object> excelDataList = new ArrayList<>();
        // 收集每一行数据,设置到Model中(跳过表头)
        for (int i = RowStartIndex; i <= sheet.getLastRowNum(); i++) {
            XSSFRow row = sheet.getRow(i);
            // 把单元格数据转为当前字段的类型,并设置
            Object pojo = pojoClass.newInstance();
            // 遍历单元格,为pojo字段赋值
            for (int j = cellStartIndex; j < row.getLastCellNum(); j++) {
                // 获取单元格的值
                XSSFCell cell = row.getCell(j);
                /**
                 * 这里假设单元格顺序和Pojo顺序一致,所以在这个for循环中,row.getCell(j)单元格对应fields[j - cellIndex]字段
                 * fields是从1开始的(id不设置),所以要j - cellIndex,这样单元格和Pojo字段才是匹配的。
                 */
                Field field = fields[j - cellStartIndex + 1];
                field.setAccessible(true);
                field.set(pojo, convertAttrType(field, cell));
            }

            excelDataList.add(pojo);
        }
        excelDataList.forEach(System.out::println);
    }

    /**
     * 类型转换 将 cell单元格数据类型 转为 Java类型
     * <p>
     * 这里其实分两步:
     * 1.通过getValue()方法得到cell对应的Java类型的字符串类型,比如Date,getValue返回的不是Date类型,而是Date的格式化字符串
     * 2.判断Pojo当前字段是什么类型,把getValue()得到的字符串往该类型转
     *
     * @param field
     * @param cell
     * @return
     * @throws Exception
     */
    private Object convertAttrType(Field field, Cell cell) throws Exception {
        Class<?> fieldType = field.getType();
        if (String.class.isAssignableFrom(fieldType)) {
            return getValue(cell);
        } else if (Date.class.isAssignableFrom(fieldType)) {
            return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(getValue(cell));
        } else if (int.class.isAssignableFrom(fieldType) || Integer.class.isAssignableFrom(fieldType)) {
            return Integer.parseInt(getValue(cell));
        } else if (double.class.isAssignableFrom(fieldType) || Double.class.isAssignableFrom(fieldType)) {
            return Double.parseDouble(getValue(cell));
        } else if (boolean.class.isAssignableFrom(fieldType) || Boolean.class.isAssignableFrom(fieldType)) {
            return Boolean.valueOf(getValue(cell));
        } else if (BigDecimal.class.isAssignableFrom(fieldType)) {
            return new BigDecimal(getValue(cell));
        } else {
            return null;
        }
    }

    /**
     * 提供POI数据类型 --> Java数据类型的转换
     * 由于本方法返回值设为String,所以不管转换后是什么Java类型,都要以String格式返回
     * 所以Date会被格式化为yyyy-MM-dd HH:mm:ss
     * 后面根据需要自己另外转换
     *
     * @param cell
     * @return
     */
    private String getValue(Cell cell) {
        if (cell == null) {
            return "";
        }
        switch (cell.getCellType()) {
            case STRING:
                return cell.getRichStringCellValue().getString().trim();
            case NUMERIC:
                if (DateUtil.isCellDateFormatted(cell)) {
                    // DateUtil是POI内部提供的日期工具类,可以把原本是日期类型的NUMERIC转为Java的Data类型
                    Date javaDate = DateUtil.getJavaDate(cell.getNumericCellValue());
                    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(javaDate);
                } else {
                    // 无论Excel中是58还是58.0,数值类型在POI中最终都被解读为double。这里的解决办法是通过BigDecimal先把Double先转成字符串,如果是.0结尾,把.0去掉
                    String strCell = "";
                    Double num = cell.getNumericCellValue();
                    BigDecimal bd = new BigDecimal(num.toString());
                    if (bd != null) {
                        strCell = bd.toPlainString();
                    }
                    // 去除 浮点型 自动加的 .0
                    if (strCell.endsWith(".0")) {
                        strCell = strCell.substring(0, strCell.indexOf("."));
                    }
                    return strCell;
                }
            case BOOLEAN:
                return String.valueOf(cell.getBooleanCellValue());
            default:
                return "";
        }
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    static class Student {
        private Long id;
        private String name;
        private Integer age;
        private String address;
        private Date birthday;
        private Double height;
        private Boolean isMainlandChina;
    }
}

输出结果:

第三步:抽取Excel文件流

我们发现,从Student换成Teacher后,我们需要进到MyUtil代码中里把Excel文件路径改成teacher_info.xlsx,这不符合开闭原则。我们已经知道WorkBook可以接收InputStream,所以第三步就是抽取Excel文件流:

public class MyUtils {

    public static void main(String[] args) throws Exception {
        MyUtils myUtil = new MyUtils();
        FileInputStream fileInputStream = new FileInputStream(new File("/Users/bravo1988/Desktop/student_info.xlsx"));
        // 通过努力,我们再次把"变化的因素"提取出去了,现在MyUtils内部的代码越来越稳定了,稳定意味着通用...
        myUtil.importExcel(fileInputStream, 2, 0, Student.class);
    }

    /**
     * 第三步,抽取Excel文件流
     *
     * @param inputStream    要导入的Excel文件流
     * @param rowStartIndex  从哪行开始读取(从0开始)
     * @param cellStartIndex 从那列开始读取(从0开始)
     * @param pojoClass      操作的类型
     * @throws Exception
     */
    public void importExcel(InputStream inputStream, Integer rowStartIndex, Integer cellStartIndex, Class<?> pojoClass) throws Exception {
        // 【本次改变】通过输入流构造工作簿,由外界传入!!!
        XSSFWorkbook workbook = new XSSFWorkbook(inputStream);
        // 获取工作表。一个工作薄中可能有多个工作表,比如sheet1 sheet2,可以根据下标,也可以根据sheet名称。这里根据下标即可。
        XSSFSheet sheet = workbook.getSheetAt(0);
        // 省略...
    }

    // 省略其他方法...

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    static class Student {
        private Long id;
        private String name;
        private Integer age;
        private String address;
        private Date birthday;
        private Double height;
        private Boolean isMainlandChina;
    }
}

第四步:引入注解标注顺序

抽取到这一步,其实已经差不多了,至于POJO字段和单元格的映射顺序问题,个人觉得其实无所谓,就默认单元格顺序和字段顺序一致也未尝不可。但客户的Excel表是五花八门的,Student表的单元格可能是按顺序来的,而Teacher表的单元格则可能是反着来的。那么我们反射的代码就不再通用,如果不更改setter的顺序,整个赋值就颠倒了。

以面向对象的思维考虑一下,单元格的哪个值应该设置给哪个字段谁最清楚呢?

答案是,POJO自己。

比如Student的name字段,它要"记得"Excel中哪个单元格的值是自己的。你可以把Excel表中单元格的数据们想象成老婆组,POJO的字段们是老公组。到时候遍历单元格数据时,比如到了朱丽叶时,就喊一句:朱丽叶的老公是谁?然后POJO就把罗密欧送过去即可。此时我们需要一个标记,把POJO字段和单元格绑定起来。这很像JPA/通用Mapper的@Column注解,所以我们也模仿一下。

自定义注解ExcelAttribute:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface ExcelAttribute {
    /**
     * 对应的列名称
     */
    String value() default "";

    /**
     * 列序号
     */
    int sort();

    // 字段类型对应的格式(大家自己可以试着扩展)
    String format() default "";
}

public class MyUtils {

    public static void main(String[] args) throws Exception {
        MyUtils myUtil = new MyUtils();
        FileInputStream fileInputStream = new FileInputStream(new File("/Users/bravo1988/Desktop/student_info.xlsx"));
        // 传入Excel文件流,从文件中读取数据并返回List
        List<Student> students = myUtil.importExcel(fileInputStream, 2, 0, Student.class);
        students.forEach(System.out::println);
    }

    /**
     * 第四步,引入注解绑定字段与单元格的映射关系
     *
     * @param inputStream    要导入的Excel文件流
     * @param rowStartIndex  从哪行开始读取(从0开始)
     * @param cellStartIndex 从那列开始读取(从0开始)
     * @param pojoClass      操作的Class
     * @param <T>            操作的类型
     * @return
     * @throws Exception
     */
    public <T> List<T> importExcel(InputStream inputStream, Integer rowStartIndex, Integer cellStartIndex, Class<T> pojoClass) throws Exception {
        // 获取工作薄
        XSSFWorkbook workbook = new XSSFWorkbook(inputStream);
        // 获取工作表。一个工作薄中可能有多个工作表,比如sheet1 sheet2,可以根据下标,也可以根据sheet名称。这里根据下标即可。
        XSSFSheet sheet = workbook.getSheetAt(0);

        // 得到Pojo所有字段
        Field[] fields = pojoClass.getDeclaredFields();

        List<T> excelDataList = new ArrayList<>();

        // 收集每一行数据,设置到Model中(跳过表头)
        for (int i = rowStartIndex; i <= sheet.getLastRowNum(); i++) {
            XSSFRow row = sheet.getRow(i);
            // 把单元格数据转为当前字段的类型,并设置
            T pojo = pojoClass.newInstance();
            // 遍历单元格(老婆组),为pojo字段赋值
            for (int j = cellStartIndex; j < row.getLastCellNum(); j++) {
                // 获取单元格的值
                XSSFCell cell = row.getCell(j);
                // 开始从老公组找出罗密欧
                for (Field field : fields) {
                    // 遍历,找到与当前单元格匹配的字段,取出单元格的值,把值设置给该字段
                    if (field.isAnnotationPresent(ExcelAttribute.class) && field.getAnnotation(ExcelAttribute.class).sort() == j) {
                        field.setAccessible(true);
                        // 把朱丽叶配给罗密欧
                        field.set(pojo, convertAttrType(field, cell));
                    }
                }

            }

            excelDataList.add(pojo);
        }

        return excelDataList;
    }

    /**
     * 类型转换 将 cell单元格数据类型 转为 Java类型
     * <p>
     * 这里其实分两步:
     * 1.通过getValue()方法得到cell对应的Java类型的字符串类型,比如Date,getValue返回的不是Date类型,而是Date的格式化字符串
     * 2.判断Pojo当前字段是什么类型,把getValue()得到的字符串往该类型转
     *
     * @param field
     * @param cell
     * @return
     * @throws Exception
     */
    private Object convertAttrType(Field field, Cell cell) throws Exception {
        Class<?> fieldType = field.getType();
        if (String.class.isAssignableFrom(fieldType)) {
            return getValue(cell);
        } else if (Date.class.isAssignableFrom(fieldType)) {
            return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(getValue(cell));
        } else if (int.class.isAssignableFrom(fieldType) || Integer.class.isAssignableFrom(fieldType)) {
            return Integer.parseInt(getValue(cell));
        } else if (double.class.isAssignableFrom(fieldType) || Double.class.isAssignableFrom(fieldType)) {
            return Double.parseDouble(getValue(cell));
        } else if (boolean.class.isAssignableFrom(fieldType) || Boolean.class.isAssignableFrom(fieldType)) {
            return Boolean.valueOf(getValue(cell));
        } else if (BigDecimal.class.isAssignableFrom(fieldType)) {
            return new BigDecimal(getValue(cell));
        } else {
            return null;
        }
    }

    /**
     * 提供POI数据类型 --> Java数据类型的转换
     * 由于本方法返回值设为String,所以不管转换后是什么Java类型,都要以String格式返回
     * 所以Date会被格式化为yyyy-MM-dd HH:mm:ss
     * 后面根据需要自己另外转换
     *
     * @param cell
     * @return
     */
    private String getValue(Cell cell) {
        if (cell == null) {
            return "";
        }

        switch (cell.getCellType()) {
            case STRING:
                return cell.getRichStringCellValue().getString().trim();
            case NUMERIC:
                if (DateUtil.isCellDateFormatted(cell)) {
                    // DateUtil是POI内部提供的日期工具类,可以把原本是日期类型的NUMERIC转为Java的Data类型
                    Date javaDate = DateUtil.getJavaDate(cell.getNumericCellValue());
                    String dateString = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(javaDate);
                    return dateString;
                } else {
                    // 无论Excel中是58还是58.0,数值类型在POI中最终都被解读为Double。这里的解决办法是通过BigDecimal先把Double先转成字符串,如果是.0结尾,把.0去掉
                    String strCell = "";
                    Double num = cell.getNumericCellValue();
                    BigDecimal bd = new BigDecimal(num.toString());
                    if (bd != null) {
                        strCell = bd.toPlainString();
                    }
                    // 去除 浮点型 自动加的 .0
                    if (strCell.endsWith(".0")) {
                        strCell = strCell.substring(0, strCell.indexOf("."));
                    }
                    return strCell;
                }
            case BOOLEAN:
                return String.valueOf(cell.getBooleanCellValue());
            default:
                return "";
        }
    }


    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    static class Student {
        private Long id;
        @ExcelAttribute(sort = 0, value = "姓名")
        private String name;
        @ExcelAttribute(sort = 1, value = "年龄")
        private Integer age;
        @ExcelAttribute(sort = 2, value = "住址")
        private String address;
        @ExcelAttribute(sort = 3, value = "生日")
        private Date birthday;
        @ExcelAttribute(sort = 4, value = "身高")
        private Double height;
        @ExcelAttribute(sort = 5, value = "是否来自大陆")
        private Boolean isMainlandChina;
    }
}

上面的代码好在哪呢?虽然Excel字段顺序变动时仍然要改变注解的顺序,但代码却不用改了,同一套setter可以复用到所有的Excel上。

封装导出代码

有了封装导入代码的经验,封装导出代码就很简单了,这里直接贴代码:

public class MyUtils {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * @param dataList            要导出的数据
     * @param pojoClass           要操作的类型
     * @param templateInputStream Excel模板输入流
     * @param response            response响应,用来导出Excel文件
     * @param excelName           指定导出文件名
     * @param rowIndex            模板数据起始行
     * @param cellIndex           模板数据起始列
     * @throws IOException
     * @throws IllegalAccessException
     */
    public void exportExcel(List<T> dataList, Class<T> pojoClass, InputStream templateInputStream, HttpServletResponse response, 
                            String excelName, Integer rowIndex, Integer cellIndex) throws IOException, IllegalAccessException {

        // 读取模板
        XSSFWorkbook workbook = new XSSFWorkbook(templateInputStream);
        // 获取模板sheet,默认第一张sheet
        XSSFSheet sheet = workbook.getSheetAt(0);
        // 从指定行收集单元格样式,方便复用,类似格式刷
        CellStyle[] templateStyles = getTemplateStyles(rowIndex, cellIndex, sheet);
        // 得到所有字段
        Field[] fields = pojoClass.getDeclaredFields();

        // 创建单元格,并设置样式和数据
        for (int i = 0; i < dataList.size(); i++) {
            Object pojo = dataList.get(i);
            XSSFRow row = sheet.createRow(i + rowIndex);
            // 为当前行创建单元格(创建老婆组)
            for (int k = cellIndex; k < templateStyles.length + cellIndex; k++) {
                // 当前新建了朱丽叶,已经就位
                XSSFCell cell = row.createCell(k);
                // 找到朱丽叶的化妆盒,给朱丽叶化妆
                cell.setCellStyle(templateStyles[k - cellIndex]);
                // 遍历字段(老公组),找到罗密欧
                for (Field field : fields) {
                    if (field.isAnnotationPresent(ExcelAttribute.class) && field.getAnnotation(ExcelAttribute.class).sort() == k - cellIndex) {
                        field.setAccessible(true);
                        // 把罗密欧给朱丽叶
                        mappingValue(field, cell, pojo);
                    }
                }

            }
        }

        // 通过response响应
        String fileName = new String(excelName.getBytes("UTF-8"), "ISO-8859-1");
        response.setContentType("application/octet-stream");
        response.setHeader("content-disposition", "attachment;filename=" + fileName);
        response.setHeader("filename", fileName);
        workbook.write(response.getOutputStream());
        workbook.close();
        logger.info("导出成功!");
    }

    /**
     * 根据字段类型强制转为字段数据,并设置给cell
     *
     * @param field
     * @param cell
     * @param pojo
     * @throws IllegalAccessException
     */
    private void mappingValue(Field field, Cell cell, Object pojo) throws IllegalAccessException {
        Class<?> fieldType = field.getType();
        if (Date.class.isAssignableFrom(fieldType)) {
            cell.setCellValue((Date) field.get(pojo));
        } else if (int.class.isAssignableFrom(fieldType) || Integer.class.isAssignableFrom(fieldType)) {
            cell.setCellValue((Integer) field.get(pojo));
        } else if (double.class.isAssignableFrom(fieldType) || Double.class.isAssignableFrom(fieldType)) {
            cell.setCellValue((Double) field.get(pojo));
        } else if (boolean.class.isAssignableFrom(fieldType) || Boolean.class.isAssignableFrom(fieldType)) {
            cell.setCellValue((Boolean) field.get(pojo));
        } else if (BigDecimal.class.isAssignableFrom(fieldType)) {
            cell.setCellValue(((BigDecimal) field.get(pojo)).doubleValue());
        } else {
            cell.setCellValue((String) field.get(pojo));
        }
    }


    /**
     * 收集Excel模板的样式,方便对新建单元格复用,相当于打造一把格式刷
     *
     * @param rowIndex
     * @param cellIndex
     * @param sheet
     * @return
     */
    private CellStyle[] getTemplateStyles(Integer rowIndex, Integer cellIndex, XSSFSheet sheet) {
        XSSFRow dataTemplateRow = sheet.getRow(rowIndex);
        CellStyle[] cellStyles = new CellStyle[dataTemplateRow.getLastCellNum() - cellIndex];
        for (int i = 0; i < cellStyles.length; i++) {
            cellStyles[i] = dataTemplateRow.getCell(i + cellIndex).getCellStyle();
        }
        return cellStyles;
    }

}

有兴趣的话,可以放在上一篇的Controller中测试一下。

代码优化

上面对POI的封装只能说基本满足要求,但还有很大的优化空间:

  • 方法参数太多
  • 无论导入还是导出,都出现了for循环三层嵌套,效率很低

把公共参数提取到构造器中

来看一下导入导出两个方法中有哪些公共的参数:

public class MyUtils {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * @param dataList            要导出的数据
     * @param pojoClass           要操作的类型
     * @param templateInputStream Excel模板输入流
     * @param response            response响应,用来导出Excel文件
     * @param excelName           指定导出文件名
     * @param rowIndex            模板数据起始行
     * @param cellIndex           模板数据起始列
     * @throws IOException
     * @throws IllegalAccessException
     */
    public void exportExcel(List<T> dataList, Class<T> pojoClass, InputStream templateInputStream, HttpServletResponse response, 
                            String excelName, Integer rowIndex, Integer cellIndex) throws IOException, IllegalAccessException {
    }
    
    /**
     * @param inputStream    要导入的Excel文件流
     * @param rowStartIndex  从哪行开始读取(从0开始)
     * @param cellStartIndex 从那列开始读取(从0开始)
     * @param pojoClass      操作的Class
     * @param <T>            操作的类型
     * @return
     * @throws Exception
     */
    public <T> List<T> importExcel(InputStream inputStream, Integer rowStartIndex, Integer cellStartIndex, Class<T> pojoClass) throws Exception {
    }

两个方法看起来相同的参数挺多的,但是含义有些不同。比如都是rowIndex,exportExcel()中指的是模板的样式位置,而importExcel()中指的是要读取的数据位置,所以从语义上不适合等同处理,所以最终我打算只抽取Class,也就是外部在new工具类时要指定Class。

用Map索引替代for遍历

Map代替for循环遍历算是一种很简单高效的方式,我们在《实用小算法》中已经讨论过,有兴趣的同学可以去看看,详细比较了几种小算法的优劣。

PoiExcelUtils完整代码

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface ExcelAttribute {
    /**
     * 对应的列名称
     */
    String value() default "";

    /**
     * 列序号
     */
    int sort();

    // 字段类型对应的格式(大家自己可以试着扩展)
    String format() default "";
}

public class PoiExcelUtils<T> {
    /**
     * 与本次导出相关的POJO类型
     */
    private Class<T> clazz;

    /**
     * POJO字段的Map形式,key是字段上ExcelAttribute注解的sort值
     */
    private Map<Integer, Field> fieldMap = new HashMap<>();

    /**
     * 构造器,明确POJO的Class类型
     *
     * @param clazz
     */
    public PoiExcelUtils(Class<T> clazz) {
        this.clazz = clazz;
        // 得到所有字段,并用Map为字段建立索引
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            if (field.isAnnotationPresent(ExcelAttribute.class)) {
                fieldMap.put(field.getAnnotation(ExcelAttribute.class).sort(), field);
            }
        }
    }

    /**
     * 按模板导出到网络
     * 输入Excel模板+数据,导出Excel表格
     *
     * @param dataList  要导出的数据
     * @param excelName 指定本次导出的Excel表名
     * @param is        Excel模板,InputStream流
     * @param rowIndex  从模板哪一行开始拷贝样式
     * @param cellIndex 从模板哪一列开始拷贝样式
     * @param response  response响应
     * @throws IOException
     * @throws IllegalAccessException
     */
    public void exportExcelWithTemplate(List<T> dataList, String excelName, InputStream is, Integer rowIndex, Integer cellIndex, HttpServletResponse response) throws IOException, IllegalAccessException {
        // 把数据封装到Excel
        XSSFWorkbook workbook = mapData2ExcelWithTemplate(dataList, is, rowIndex, cellIndex);

        //===============response响应导出Excel=================
        String fileName = new String(excelName.getBytes("UTF-8"), "ISO-8859-1");
        response.setContentType("application/octet-stream");
        response.setHeader("content-disposition", "attachment;filename=" + fileName);
        response.setHeader("filename", fileName);
        workbook.write(response.getOutputStream());
        workbook.close();
    }

    /**
     * 按模板导出到本地
     * 输入Excel模板+数据+Excel保存路径,导出Excel表格
     *
     * @param dataList  要导出的数据
     * @param is        Excel模板,InputStream流
     * @param pathName  本地输出路径(比如/users/document/student_info.xlsx)
     * @param rowIndex  从模板哪一行开始拷贝样式
     * @param cellIndex 从模板哪一列开始拷贝样式
     * @throws IOException
     * @throws IllegalAccessException
     */
    public void exportExcelToLocalWithTemplate(List<T> dataList, InputStream is, String pathName, Integer rowIndex, Integer cellIndex) throws IOException, IllegalAccessException {
        // 把数据封装到Excel
        XSSFWorkbook workbook = mapData2ExcelWithTemplate(dataList, is, rowIndex, cellIndex);

        //===============导出Excel到本地=================
        FileOutputStream fileOutputStream = new FileOutputStream(pathName);
        workbook.write(fileOutputStream);
    }

    /**
     * Excel导出到网络
     *
     * @param dataList  要导出的数据
     * @param excelName 指定本次导出的Excel表名
     * @param sheetName sheet名称
     * @param response  response响应
     * @throws IllegalAccessException
     * @throws IOException
     */
    public void exportExcel(List<T> dataList, String excelName, String sheetName, HttpServletResponse response) throws IllegalAccessException, IOException {
        XSSFWorkbook workbook = mapData2Excel(dataList, sheetName);

        //===============response响应导出Excel=================
        String fileName = new String(excelName.getBytes("UTF-8"), "ISO-8859-1");
        response.setContentType("application/octet-stream");
        response.setHeader("content-disposition", "attachment;filename=" + fileName);
        response.setHeader("filename", fileName);
        workbook.write(response.getOutputStream());
        workbook.close();
    }


    /**
     * Excel导出到本地
     *
     * @param dataList  要导出的数据
     * @param pathName  本地输出路径(比如/users/document/student_info.xlsx)
     * @param sheetName sheet名称
     * @throws IllegalAccessException
     * @throws IOException
     */
    public void exportExcelToLocal(List<T> dataList, String pathName, String sheetName) throws IllegalAccessException, IOException {
        XSSFWorkbook workbook = mapData2Excel(dataList, sheetName);

        //===============导出Excel到本地=================
        FileOutputStream fileOutputStream = new FileOutputStream(pathName);
        workbook.write(fileOutputStream);
    }

    /**
     * Excel导入
     *
     * @param inputStream 要导入的Excel表
     * @param rowIndex    从哪一行开始读取
     * @param cellIndex   从哪一列开始读取
     * @return
     * @throws Exception
     */
    public List<T> importExcel(InputStream inputStream, Integer rowIndex, Integer cellIndex) throws Exception {
        //================准备要导入的Excel=================
        XSSFWorkbook workbook = new XSSFWorkbook(inputStream);
        XSSFSheet sheet = workbook.getSheetAt(0);

        List<T> dataList = new ArrayList<>();

        //================从Excel读取数据,并设置给pojoList================
        for (int i = rowIndex; i <= sheet.getLastRowNum(); i++) {
            XSSFRow row = sheet.getRow(i);
            // 遍历单元格(老婆组)
            T pojo = (T) clazz.newInstance();
            for (int j = cellIndex; j < row.getLastCellNum(); j++) {
                // 获取单元格的值
                XSSFCell cell = row.getCell(j);
                // 当前是朱丽叶,已经就位了,罗密欧在哪?
                Object value = getValue(cell);
                // 根据索引快速从老公组找出罗密欧
                Field field = fieldMap.get(j - cellIndex);
                // 把朱丽叶配给罗密欧(把单元格数据赋值给字段)
                field.setAccessible(true);
                field.set(pojo, convertAttrType(field, cell));
            }

            dataList.add(pojo);
        }

        return dataList;
    }


    //======================== private =======================

    /**
     * 把查询得到的数据封装到Excel
     *
     * @param dataList
     * @return
     * @throws IllegalAccessException
     */
    private XSSFWorkbook mapData2Excel(List<T> dataList, String sheetName) throws IllegalAccessException {
        XSSFWorkbook workbook = new XSSFWorkbook();
        XSSFSheet sheet = workbook.createSheet(sheetName);

        //=============创建表头===============
        XSSFRow row = sheet.createRow(0);
        Field[] fields = clazz.getDeclaredFields();
        for (int i = 0; i < fields.length; i++) {
            Field field = fields[i];
            if (field.isAnnotationPresent(ExcelAttribute.class)) {
                row.createCell(i).setCellValue(field.getAnnotation(ExcelAttribute.class).value());
            }
        }

        //=========创建单元格,并设置数据(跳过表头)=======
        int size = fieldMap.keySet().size();
        for (int i = 0, length = dataList.size(); i < length; i++) {
            T pojo = dataList.get(i);
            row = sheet.createRow(i + 1);
            for (int k = 0; k < size; k++) {
                XSSFCell cell = row.createCell(k);
                Field field = fieldMap.get(k);
                field.setAccessible(true);
                mappingValue(field, cell, pojo);
            }
        }
        return workbook;
    }

    /**
     * 把查询得到的数据【按指定的模板样式】封装到Excel
     *
     * @param dataList
     * @param is
     * @param rowIndex
     * @param cellIndex
     * @return
     * @throws IOException
     * @throws IllegalAccessException
     */
    private XSSFWorkbook mapData2ExcelWithTemplate(List<T> dataList, InputStream is, Integer rowIndex, Integer cellIndex) throws IOException, IllegalAccessException {
        //================得到模板=================
        XSSFWorkbook workbook = new XSSFWorkbook(is);
        XSSFSheet sheet = workbook.getSheetAt(0);

        //=============从模板抽取样式===============
        CellStyle[] templateStyles = getTemplateStyles(rowIndex, cellIndex, sheet);

        //=========创建单元格,并设置样式和数据=======
        for (int i = 0; i < dataList.size(); i++) {
            T pojo = dataList.get(i);
            XSSFRow row = sheet.createRow(i + rowIndex);
            // 循环创建单元格(创建老婆组)
            for (int k = cellIndex; k < templateStyles.length + cellIndex; k++) {
                // 新建单元格:朱丽叶
                XSSFCell cell = row.createCell(k);
                // 为单元格设置样式:找到朱丽叶的化妆盒,给朱丽叶化妆
                cell.setCellStyle(templateStyles[k - cellIndex]);
                // 根据索引快速从老公组找出罗密欧
                Field field = fieldMap.get(k - cellIndex);
                // 把罗密欧给朱丽叶(把字段的数据赋值给单元格)
                field.setAccessible(true);
                mappingValue(field, cell, pojo);
            }
        }
        return workbook;
    }


    /**
     * 把字段Field的值设置给单元格Cell
     * Filed.get()得到的数据类型是Object,而cell.setCellValue()要求是具体的数据类
     * 因此需要判断字段类型,并把数据转换为原来的真是类型
     *
     * @param field
     * @param cell
     * @param pojo
     * @throws IllegalAccessException
     */
    private void mappingValue(Field field, Cell cell, Object pojo) throws IllegalAccessException {
        Class<?> fieldType = field.getType();
        if (Date.class.isAssignableFrom(fieldType)) {
            cell.setCellValue((Date) field.get(pojo));
        } else if (int.class.isAssignableFrom(fieldType) || Integer.class.isAssignableFrom(fieldType)) {
            cell.setCellValue((Integer) field.get(pojo));
        } else if (double.class.isAssignableFrom(fieldType) || Double.class.isAssignableFrom(fieldType)) {
            cell.setCellValue((Double) field.get(pojo));
        } else if (boolean.class.isAssignableFrom(fieldType) || Boolean.class.isAssignableFrom(fieldType)) {
            cell.setCellValue((Boolean) field.get(pojo));
        } else if (BigDecimal.class.isAssignableFrom(fieldType)) {
            cell.setCellValue(((BigDecimal) field.get(pojo)).doubleValue());
        } else {
            cell.setCellValue((String) field.get(pojo));
        }
    }


    /**
     * 收集Excel模板的样式,方便对新建单元格复用,相当于打造一把格式刷
     *
     * @param rowIndex
     * @param cellIndex
     * @param sheet
     * @return
     */
    private CellStyle[] getTemplateStyles(Integer rowIndex, Integer cellIndex, XSSFSheet sheet) {
        XSSFRow dataTemplateRow = sheet.getRow(rowIndex);
        CellStyle[] cellStyles = new CellStyle[dataTemplateRow.getLastCellNum() - cellIndex];
        for (int i = 0; i < cellStyles.length; i++) {
            cellStyles[i] = dataTemplateRow.getCell(i + cellIndex).getCellStyle();
        }
        return cellStyles;
    }

    /**
     * 类型转换 将 cell单元格数据类型 转为 Java类型
     * <p>
     * 这里其实分两步:
     * 1.通过getValue()方法得到cell对应的Java类型的字符串类型,比如Date,getValue返回的不是Date类型,而是Date的格式化字符串
     * 2.判断Pojo当前字段是什么类型,把getValue()得到的字符串往该类型转
     *
     * @param field
     * @param cell
     * @return
     * @throws Exception
     */
    private Object convertAttrType(Field field, Cell cell) throws Exception {
        Class<?> fieldType = field.getType();
        if (String.class.isAssignableFrom(fieldType)) {
            return getValue(cell);
        } else if (Date.class.isAssignableFrom(fieldType)) {
            return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(getValue(cell));
        } else if (int.class.isAssignableFrom(fieldType) || Integer.class.isAssignableFrom(fieldType)) {
            return Integer.parseInt(getValue(cell));
        } else if (double.class.isAssignableFrom(fieldType) || Double.class.isAssignableFrom(fieldType)) {
            return Double.parseDouble(getValue(cell));
        } else if (boolean.class.isAssignableFrom(fieldType) || Boolean.class.isAssignableFrom(fieldType)) {
            return Boolean.valueOf(getValue(cell));
        } else if (BigDecimal.class.isAssignableFrom(fieldType)) {
            return new BigDecimal(getValue(cell));
        } else {
            return null;
        }
    }

    /**
     * 提供POI数据类型 --> Java数据类型的转换
     * 由于本方法返回值设为String,所以不管转换后是什么Java类型,都要以String格式返回
     * 所以Date会被格式化为yyyy-MM-dd HH:mm:ss
     * 后面根据需要自己另外转换,详见{@link PoiExcelUtils#convertAttrType(Field, Cell)}
     *
     * @param cell
     * @return
     */
    private String getValue(Cell cell) {
        if (cell == null) {
            return "";
        }

        switch (cell.getCellType()) {
            case STRING:
                return cell.getRichStringCellValue().getString().trim();
            case NUMERIC:
                if (DateUtil.isCellDateFormatted(cell)) {
                    // DateUtil是POI内部提供的日期工具类,可以把原本是日期类型的NUMERIC转为Java的Data类型
                    Date javaDate = DateUtil.getJavaDate(cell.getNumericCellValue());
                    String dateString = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(javaDate);
                    return dateString;
                } else {
                    // 无论Excel中是58还是58.0,数值类型在POI中最终都被解读为Double。这里的解决办法是通过BigDecimal先把Double先转成字符串,如果是.0结尾,把.0去掉
                    String strCell = "";
                    Double num = cell.getNumericCellValue();
                    BigDecimal bd = new BigDecimal(num.toString());
                    if (bd != null) {
                        strCell = bd.toPlainString();
                    }
                    // 去除 浮点型 自动加的 .0
                    if (strCell.endsWith(".0")) {
                        strCell = strCell.substring(0, strCell.indexOf("."));
                    }
                    return strCell;
                }
            case BOOLEAN:
                return String.valueOf(cell.getBooleanCellValue());
            default:
                return "";
        }
    }

}

测试

@RestController
public class ExcelController {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @GetMapping("/exportExcel")
    public void exportExcel(HttpServletResponse response, HttpServletRequest request) throws Exception {
        // 模拟从数据库查询数据
        List<Student> studentList = new ArrayList<>();
        studentList.add(new Student(1L, "周深(web导出)", 28, "贵州", new SimpleDateFormat("yyyy-MM-dd").parse("1992-9-29"), 161.0, true));
        studentList.add(new Student(2L, "李健(web导出)", 46, "哈尔滨", new SimpleDateFormat("yyyy-MM-dd").parse("1974-9-23"), 174.5, true));
        studentList.add(new Student(3L, "周星驰(web导出)", 58, "香港", new SimpleDateFormat("yyyy-MM-dd").parse("1962-6-22"), 174.0, false));

        // 导出数据
        PoiExcelUtils<Student> poiExcelUtils = new PoiExcelUtils<>(Student.class);
        FileInputStream excelTemplateInputStream = new FileInputStream(new File("/Users/bravo1988/Desktop/student_info.xlsx"));
        poiExcelUtils.exportExcelWithTemplate(studentList, "学生信息表.xlsx", excelTemplateInputStream, 2, 0, response);
        logger.info("导出成功!");
    }

    @PostMapping("/importExcel")
    public Map<String, Object> importExcel(MultipartFile file) throws Exception {
        PoiExcelUtils<Student> poiExcelUtils = new PoiExcelUtils<>(Student.class);
        List<Student> studentList = poiExcelUtils.importExcel(file.getInputStream(), 2, 0);

        saveToDB(studentList);
        logger.info("导入{}成功!", file.getOriginalFilename());

        // 这里用Map偷懒了,实际项目中可以封装Result实体类返回
        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("data", studentList);
        result.put("msg", "success");
        return result;
    }

    private void saveToDB(List<Student> studentList) {
        if (CollectionUtils.isEmpty(studentList)) {
            return;
        }
        // 直接打印,模拟插入数据库
        studentList.forEach(System.out::println);
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Student {
        private Long id;
        @ExcelAttribute(sort = 0, value = "姓名")
        private String name;
        @ExcelAttribute(sort = 1, value = "年龄")
        private Integer age;
        @ExcelAttribute(sort = 2, value = "住址")
        private String address;
        @ExcelAttribute(sort = 3, value = "生日")
        private Date birthday;
        @ExcelAttribute(sort = 4, value = "身高")
        private Double height;
        @ExcelAttribute(sort = 5, value = "是否来自大陆")
        private Boolean isMainlandChina;
    }

}

总结

上面的工具类封装具有一定难度,尽量理解即可。

网络上还有一种封装方式,需要调用者自己在外部组装好各个字段对应的Map传入Util。但个人认为POI本身效率就不高,所以这点性能提升可有可无,用起来还麻烦。

至此,对POI的学习告一段落,请不要在生产环境使用PoiExcelUtils(可能发生OOM),推荐使用EasyExcel。

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

进群,大家一起学习,一起进步,一起对抗互联网寒冬

相关推荐
雷神乐乐几秒前
Maven学习——创建Maven的Java和Web工程,并运行在Tomcat上
java·maven
码农派大星。4 分钟前
Spring Boot 配置文件
java·spring boot·后端
顾北川_野11 分钟前
Android 手机设备的OEM-unlock解锁 和 adb push文件
android·java
江深竹静,一苇以航13 分钟前
springboot3项目整合Mybatis-plus启动项目报错:Invalid bean definition with name ‘xxxMapper‘
java·spring boot
confiself29 分钟前
大模型系列——LLAMA-O1 复刻代码解读
java·开发语言
Wlq041534 分钟前
J2EE平台
java·java-ee
XiaoLeisj41 分钟前
【JavaEE初阶 — 多线程】Thread类的方法&线程生命周期
java·开发语言·java-ee
豪宇刘1 小时前
SpringBoot+Shiro权限管理
java·spring boot·spring
Elaine2023911 小时前
02多线程基础知识
java·多线程
gorgor在码农1 小时前
Redis 热key总结
java·redis·热key