拒绝重复造轮子:利用自定义注解封装POI,实现Java通用Excel解析
基于 SSM + Vue 的 POI Excel 导入导出全流程实现
本文详细讲解基于 SSM(Spring + SpringMVC + MyBatis)后端与 Vue + Element UI 前端的 Excel 导入导出功能实现。通过自定义注解的方式,实现通用的 POI 操作流程。
1. 环境准备与依赖引入
首先需要在项目的 pom.xml 中引入 Apache POI 及相关工具类的依赖。
<!-- POI 核心依赖(支持 .xls 格式 - Office 2003) --><dependency><groupId>org.apache.poi</groupId><artifactId>poi</artifactId><version>4.1.2</version></dependency><!-- POI OOXML(支持 .xlsx 格式 - Office 2007+,目前主流) --><dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>4.1.2</version></dependency><!-- Commons BeanUtils:用于简化实体类属性的反射操作 --><dependency><groupId>commons-beanutils</groupId><artifactId>commons-beanutils</artifactId><version>1.9.4</version></dependency><!-- Commons FileUpload:SpringMVC 处理文件上传的核心依赖 --><dependency><groupId>commons-fileupload</groupId><artifactId>commons-fileupload</artifactId><version>1.4</version></dependency>2. 后端核心架构设计
2.1 自定义注解
通过注解标记实体类与 Excel 表格的映射关系,实现通用解析。
@ExcelField:- 作用:标注在实体类的字段上。
- 功能:定义字段在 Excel 表头中对应的中文名称、排序号、是否参与导入/导出等。
- 元注解:
@Target(ElementType.FIELD)。
@ExcelEntity:- 作用:标注在实体类上。
- 功能:定义实体类对应的 Excel 表名(Sheet 名)。
- 元注解:
@Target(ElementType.TYPE)。




2.2 工具类设计
ExcelAnnotationUtil:负责解析实体类上的注解,生成“表头-字段”的映射关系。ExcelUtil:核心工具类,负责创建 Workbook、填充数据(导出)以及读取 Workbook、封装实体(导入)。- 注:完整工具类代码见文末附录。
3. 业务逻辑层 (Service) 实现
3.1 导入逻辑
思路:Controller 接收文件 -> 调用工具类解析 Excel 为 List<User> -> 调用 Mapper 批量插入数据库。

3.2 导出逻辑
思路:Mapper 查询所有数据 -> 调用工具类解析注解(获取表头和Sheet名) -> 生成 Excel 二进制流 -> 通过 HttpServletResponse 输出。

4. SpringMVC 配置与 Controller
4.1 配置文件上传解析器
在 SpringMVC 配置文件中添加 MultipartResolver,否则后端无法接收 MultipartFile。
<!-- 配置文件上传解析器 --><beanid="multipartResolver"class="org.springframework.web.multipart.commons.CommonsMultipartResolver"><!-- 最大上传文件大小:10MB (10 * 1024 * 1024) --><propertyname="maxUploadSize"value="10485760"/><!-- 默认编码格式,防止文件名乱码 --><propertyname="defaultEncoding"value="UTF-8"/></bean>4.2 Controller 编写
定义导入和导出两个接口。

5. 前端实现 (Vue + Element UI)
5.1 界面组件
使用 Element UI 的按钮组件触发操作。

5.2 导出功能实现 (Blob 流处理)
核心逻辑:
- 调用后端接口,设置响应类型为
blob。 - 接收二进制流,创建
Blob对象。 - 创建临时的
<a>标签触发下载。 - 释放 URL 对象。
Axios 封装:

业务代码:
handleExport(){this.$message.info('正在导出数据,请稍等...');exportUserExcel().then(response=>{// 1. 校验响应是否为有效 blobif(!response.data || response.data.size ===0){this.$message.error('导出失败:无数据可导出');return;}// 2. 创建 Blob 对象,指定 MIME 类型为 Excelconst blob =newBlob([response.data],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});// 3. 创建下载链接const link = document.createElement('a');// 使用 encodeURIComponent 解决中文文件名乱码const fileName =encodeURIComponent('用户列表.xlsx'); link.href = window.URL.createObjectURL(blob); link.download =decodeURIComponent(fileName);// 下载属性设为解码后的文件名// 4. 触发点击并清理 link.click(); window.URL.revokeObjectURL(link.href);// 释放内存this.$message.success('导出成功');}).catch(err=>{// 处理错误信息(注意:Blob 报错时可能需要将 Blob 转回 JSON 才能读取 msg)this.$message.error('导出失败:'+(err.response?.data?.msg || err.message)); console.error(err);});},5.3 导入功能实现 (FormData 上传)
核心逻辑:
- 点击按钮触发隐藏的
<input type="file">。 handleFileChange捕获文件。- 前端校验文件格式(.xls/.xlsx)和大小(<10MB)。
- 使用
FormData封装文件并发送请求。

业务代码:
handleImport(){// 触发隐藏的文件输入框this.$refs.fileInput.click();},handleFileChange(event){const file = event.target.files[0];if(!file)return;// 1. 前端校验文件类型const isExcel = file.type ==='application/vnd.ms-excel'|| file.type ==='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';// 2. 前端校验文件大小 (10MB)const isLt10M = file.size /1024/1024<10;if(!isExcel){this.$message.error('只能上传xls/xlsx格式的Excel文件!'); event.target.value ='';// 清空选择,允许重复选择同名文件return;}if(!isLt10M){this.$message.error('文件大小不能超过10MB!'); event.target.value ='';return;}// 3. 调用导入接口importUserExcel(file).then(()=>{this.$message.success('导入成功');this.fetchUsers();// 刷新列表 event.target.value ='';}).catch(err=>{this.$message.error('导入失败:'+(err.response?.data?.msg || err.message)); console.error(err); event.target.value ='';});},API 调用:

6. 效果展示
导入成功:


导出成功:


7. 常见问题与解决方案
7.1 导出的 Excel 无法打开 (文件损坏)

| 可能原因 | 解决方案 |
|---|---|
| 前端未设置 Blob | 前端 Axios 请求必须添加 responseType: 'blob',否则二进制流会被当做字符串/JSON 解析导致乱码。 |
| 后端流未关闭 | 后端 ExcelUtil 中必须执行 os.flush() 和 os.close(),确保缓冲区数据完全写入。 |
| 拦截器干扰 | 项目中的全局响应拦截器(Response Interceptor)可能强制解析 JSON。需添加判断:如果是 Blob 类型则直接返回,不进行 JSON 解析。 |
7.2 导入 Excel 报 500 错误
| 可能原因 | 解决方案 |
|---|---|
| 未配置解析器 | 检查 SpringMVC 配置文件是否包含 CommonsMultipartResolver Bean。 |
| 请求头错误 | 前端必须使用 FormData 对象封装文件,浏览器会自动设置 Content-Type: multipart/form-data。 |
| 参数名不一致 | 后端 @RequestParam("file") 中的名称必须与前端 formData.append('file', file) 中的 key 保持一致。 |
8. 附录:核心工具类源码
8.1 ExcelAnnotationUtil.java
用于解析注解,建立映射关系。
/** * @className: ExcelAnnotationUtil * @description: Excel注解解析工具类:解析实体类注解,生成表头/字段映射 */publicclassExcelAnnotationUtil{/** * 解析实体类注解,生成导出用的表头和字段数组 * @param clazz 实体类Class * @return 返回数组:[0]headers(表头名称数组),[1]fields(实体类字段名数组) */publicstatic<T>String[][]parseExportAnnotation(Class<T> clazz){returnparseAnnotation(clazz,true);}/** * 解析实体类注解,生成导入用的字段数组 * @param clazz 实体类Class * @return 返回数组:[0]headers(表头用于校验),[1]fields(实体类字段名用于赋值) */publicstatic<T>String[][]parseImportAnnotation(Class<T> clazz){returnparseAnnotation(clazz,false);}/** * 通用注解解析逻辑 * @param isExport true为导出模式,false为导入模式(根据注解中的 isExport/isImport 属性过滤) */privatestatic<T>String[][]parseAnnotation(Class<T> clazz,boolean isExport){// 获取所有声明的字段Field[] declaredFields = clazz.getDeclaredFields();// 使用 TreeMap 存储字段信息,key 为 sort 值,实现自动按 sort 升序排列Map<Integer,Map<String,String>> fieldMap =newTreeMap<>();for(Field field : declaredFields){ExcelField excelField = field.getAnnotation(ExcelField.class);if(excelField ==null){continue;// 跳过无注解的字段 }// 根据模式过滤字段if(isExport &&!excelField.isExport()){continue;}if(!isExport &&!excelField.isImport()){continue;}// 封装字段名、表头名Map<String,String> fieldInfo =newTreeMap<>(); fieldInfo.put("fieldName", field.getName()); fieldInfo.put("headerName", excelField.name());// 存入 Map 进行排序 fieldMap.put(excelField.sort(), fieldInfo);}// 将 Map 转换为数组返回List<String> headers =newArrayList<>();List<String> fields =newArrayList<>();for(Map<String,String> info : fieldMap.values()){ headers.add(info.get("headerName")); fields.add(info.get("fieldName"));}returnnewString[][]{ headers.toArray(newString[0]), fields.toArray(newString[0])};}/** * 获取字段的注解类型(用于导入时的类型转换) * 例如:Excel 中是文本,但实体类是 Integer,需要获取该类型进行转换 */publicstatic<T>Class<?>getFieldType(Class<T> clazz,String fieldName){try{Field field = clazz.getDeclaredField(fieldName);ExcelField excelField = field.getAnnotation(ExcelField.class);// 默认为 String 类型return excelField ==null?String.class: excelField.type();}catch(NoSuchFieldException e){ e.printStackTrace();returnString.class;}}}8.2 ExcelUtil.java
POI 操作核心类,包含样式设置、数据读写、类型转换。
/** * 适配注解的 POI 工具类(支持 .xlsx 和类型转换) */publicclassExcelUtil{// ====================== 导出逻辑 ====================== publicstatic<T>voidexportExcel(HttpServletResponse response,List<T> dataList,String[] headers,String[] fields,String fileName){// 1. 创建工作簿 (XSSFWorkbook 支持 .xlsx)Workbook workbook =newXSSFWorkbook();Sheet sheet = workbook.createSheet("数据列表");// 2. 创建表头行并设置样式 Row headerRow = sheet.createRow(0);CellStyle headerStyle =getHeaderStyle(workbook);for(int i =0; i < headers.length; i++){Cell cell = headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); sheet.autoSizeColumn(i);// 简单的自适应列宽}// 3. 写入数据行 if(dataList !=null&&!dataList.isEmpty()){CellStyle contentStyle =getContentStyle(workbook);for(int rowNum =0; rowNum < dataList.size(); rowNum++){Row dataRow = sheet.createRow(rowNum +1);T data = dataList.get(rowNum);// 遍历字段数组,通过反射获取值for(int colNum =0; colNum < fields.length; colNum++){Cell cell = dataRow.createCell(colNum); cell.setCellStyle(contentStyle);try{// 使用 BeanUtils 获取属性值String value =BeanUtils.getProperty(data, fields[colNum]); cell.setCellValue(value ==null?"": value);}catch(Exception e){ cell.setCellValue(""); e.printStackTrace();}}}}// 4. 写入响应流 try{ response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");// 防止中文文件名乱码 response.setHeader("Content-Disposition","attachment;filename="+URLEncoder.encode(fileName,"UTF-8"));OutputStream os = response.getOutputStream(); workbook.write(os); os.flush();// 关键:刷新缓冲区 os.close(); workbook.close();}catch(Exception e){ e.printStackTrace();}}// ====================== 导入逻辑 ====================== publicstatic<T>List<T>importExcel(MultipartFile file,Class<T> clazz){List<T> dataList =newArrayList<>();if(file.isEmpty()){return dataList;}// 1. 解析导入注解,获取表头和字段映射 String[][] annotationData =ExcelAnnotationUtil.parseImportAnnotation(clazz);String[] headers = annotationData[0];// Excel表头(用于校验) String[] fields = annotationData[1];// 实体类字段名 try{// WorkbookFactory 自动识别 xls 或 xlsxWorkbook workbook =WorkbookFactory.create(file.getInputStream());Sheet sheet = workbook.getSheetAt(0);if(sheet ==null){return dataList;}// 2. 校验 Excel 表头(可选,确保导入文件格式正确) Row headerRow = sheet.getRow(0);if(headerRow ==null){thrownewRuntimeException("Excel无表头行");}for(int i =0; i < headers.length; i++){Cell cell = headerRow.getCell(i);String cellValue =getCellValue(cell);if(!headers[i].equals(cellValue)){thrownewRuntimeException("Excel表头错误:第"+(i+1)+"列应为【"+ headers[i]+"】,实际为【"+ cellValue +"】");}}// 3. 逐行解析数据 + 类型转换 int lastRowNum = sheet.getLastRowNum();for(int rowNum =1; rowNum <= lastRowNum; rowNum++){Row row = sheet.getRow(rowNum);if(row ==null)continue;T data = clazz.newInstance();// 实例化对象for(int colNum =0; colNum < fields.length; colNum++){Cell cell = row.getCell(colNum);String cellValue =getCellValue(cell);if(cellValue.isEmpty())continue;// 空值跳过 // 核心:根据注解指定的类型转换值 String fieldName = fields[colNum];Class<?> fieldType =ExcelAnnotationUtil.getFieldType(clazz, fieldName);// 使用 ConvertUtils 进行类型转换 (String -> Integer/Date等)Object convertValue =ConvertUtils.convert(cellValue, fieldType);// 赋值给实体类 BeanUtils.setProperty(data, fieldName, convertValue);} dataList.add(data);} workbook.close();}catch(Exception e){ e.printStackTrace();thrownewRuntimeException("导入失败:"+ e.getMessage());}return dataList;}// ====================== 私有工具方法 (样式与单元格读取) ====================== /** 获取表头样式(加粗、居中、浅灰背景、边框) */privatestaticCellStylegetHeaderStyle(Workbook workbook){CellStyle style = workbook.createCellStyle(); style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN);Font font = workbook.createFont(); font.setBold(true); font.setFontName("微软雅黑"); font.setFontHeightInPoints((short)12); style.setFont(font); style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND);return style;}/** 获取内容样式(居中、边框) */privatestaticCellStylegetContentStyle(Workbook workbook){CellStyle style = workbook.createCellStyle(); style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN);Font font = workbook.createFont(); font.setFontName("微软雅黑"); font.setFontHeightInPoints((short)11); style.setFont(font);return style;}/** 统一获取单元格值(适配 String, Numeric, Boolean, Formula) */privatestaticStringgetCellValue(Cell cell){if(cell ==null)return"";CellType cellType = cell.getCellType();switch(cellType){case STRING:return cell.getStringCellValue().trim();case NUMERIC:// 处理日期格式if(DateUtil.isCellDateFormatted(cell)){return cell.getDateCellValue().toString();}else{// 防止数字变成科学计数法,这里简单转 StringreturnString.valueOf(cell.getNumericCellValue()).trim();}case BOOLEAN:returnString.valueOf(cell.getBooleanCellValue());case FORMULA:// 递归获取公式计算后的值returngetCellValue(cell.getCachedFormulaResultType(), cell);default:return"";}}/** 处理公式单元格的结果 */privatestaticStringgetCellValue(CellType cellType,Cell cell){if(cellType ==CellType.NUMERIC){returnString.valueOf(cell.getNumericCellValue()).trim();}elseif(cellType ==CellType.STRING){return cell.getStringCellValue().trim();}else{return"";}}}