作者简介:大家好,我是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
进群,大家一起学习,一起进步,一起对抗互联网寒冬