封装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

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

相关推荐
前行的小黑炭27 分钟前
设计模式:为什么使用模板设计模式(不相同的步骤进行抽取,使用不同的子类实现)减少重复代码,让代码更好维护。
android·java·kotlin
Java技术小馆33 分钟前
如何设计一个本地缓存
java·面试·架构
XuanXu1 小时前
Java AQS原理以及应用
java
风象南4 小时前
SpringBoot中6种自定义starter开发方法
java·spring boot·后端
mghio13 小时前
Dubbo 中的集群容错
java·微服务·dubbo
咖啡教室18 小时前
java日常开发笔记和开发问题记录
java
咖啡教室18 小时前
java练习项目记录笔记
java
鱼樱前端19 小时前
maven的基础安装和使用--mac/window版本
java·后端
RainbowSea19 小时前
6. RabbitMQ 死信队列的详细操作编写
java·消息队列·rabbitmq