拒绝重复造轮子:利用自定义注解封装POI,实现Java通用Excel解析

基于 SSM + Vue 的 POI Excel 导入导出全流程实现

本文详细讲解基于 SSM(Spring + SpringMVC + MyBatis)后端与 Vue + Element UI 前端的 Excel 导入导出功能实现。通过自定义注解的方式,实现通用的 POI 操作流程。


1. 环境准备与依赖引入

首先需要在项目的 pom.xml 中引入 Apache POI 及相关工具类的依赖。

xml 复制代码
<!-- 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 表格的映射关系,实现通用解析。

  1. @ExcelField

    • 作用 :标注在实体类的字段上。
    • 功能:定义字段在 Excel 表头中对应的中文名称、排序号、是否参与导入/导出等。
    • 元注解@Target(ElementType.FIELD)
  2. @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

xml 复制代码
<!-- 配置文件上传解析器 -->  
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">  
    <!-- 最大上传文件大小:10MB (10 * 1024 * 1024) -->  
    <property name="maxUploadSize" value="10485760"/>  
    <!-- 默认编码格式,防止文件名乱码 -->  
    <property name="defaultEncoding" value="UTF-8"/>  
</bean>

4.2 Controller 编写

定义导入和导出两个接口。


5. 前端实现 (Vue + Element UI)

5.1 界面组件

使用 Element UI 的按钮组件触发操作。

5.2 导出功能实现 (Blob 流处理)

核心逻辑

  1. 调用后端接口,设置响应类型为 blob
  2. 接收二进制流,创建 Blob 对象。
  3. 创建临时的 <a> 标签触发下载。
  4. 释放 URL 对象。

Axios 封装

业务代码

javascript 复制代码
handleExport() {
    this.$message.info('正在导出数据,请稍等...');
    exportUserExcel()
        .then(response => {
            // 1. 校验响应是否为有效 blob
            if (!response.data || response.data.size === 0) {
                this.$message.error('导出失败:无数据可导出');
                return;
            }
            
            // 2. 创建 Blob 对象,指定 MIME 类型为 Excel
            const blob = new Blob([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 上传)

核心逻辑

  1. 点击按钮触发隐藏的 <input type="file">
  2. handleFileChange 捕获文件。
  3. 前端校验文件格式(.xls/.xlsx)和大小(<10MB)。
  4. 使用 FormData 封装文件并发送请求。

业务代码

javascript 复制代码
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

用于解析注解,建立映射关系。

java 复制代码
/**  
 * @className: ExcelAnnotationUtil  
 * @description: Excel注解解析工具类:解析实体类注解,生成表头/字段映射  
 */  
public class ExcelAnnotationUtil {  
  
    /**  
     * 解析实体类注解,生成导出用的表头和字段数组  
     * @param clazz 实体类Class  
     * @return 返回数组:[0]headers(表头名称数组),[1]fields(实体类字段名数组)  
     */  
    public static <T> String[][] parseExportAnnotation(Class<T> clazz) {  
        return parseAnnotation(clazz, true);  
    }

    /**  
     * 解析实体类注解,生成导入用的字段数组  
     * @param clazz 实体类Class  
     * @return 返回数组:[0]headers(表头用于校验),[1]fields(实体类字段名用于赋值)  
     */  
    public static <T> String[][] parseImportAnnotation(Class<T> clazz) {  
        return parseAnnotation(clazz, false);  
    }  
  
    /**  
     * 通用注解解析逻辑  
     * @param isExport true为导出模式,false为导入模式(根据注解中的 isExport/isImport 属性过滤)
     */    
    private static <T> String[][] parseAnnotation(Class<T> clazz, boolean isExport){  
        // 获取所有声明的字段
        Field[] declaredFields = clazz.getDeclaredFields();  
        
        // 使用 TreeMap 存储字段信息,key 为 sort 值,实现自动按 sort 升序排列
        Map<Integer, Map<String, String>> fieldMap = new TreeMap<>(); 
        
        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 = new TreeMap<>();  
            fieldInfo.put("fieldName", field.getName());  
            fieldInfo.put("headerName", excelField.name());  
            
            // 存入 Map 进行排序
            fieldMap.put(excelField.sort(), fieldInfo);  
        }        
        
        // 将 Map 转换为数组返回
        List<String> headers = new ArrayList<>();  
        List<String> fields = new ArrayList<>();  
        for (Map<String, String> info : fieldMap.values()){  
            headers.add(info.get("headerName"));  
            fields.add(info.get("fieldName"));  
        }        
        
        return new String[][]{  
                headers.toArray(new String[0]),  
                fields.toArray(new String[0])  
        };    
    }  
    
    /**  
     * 获取字段的注解类型(用于导入时的类型转换)  
     * 例如:Excel 中是文本,但实体类是 Integer,需要获取该类型进行转换
     */  
    public static <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();  
            return String.class;  
        }  
    }
}

8.2 ExcelUtil.java

POI 操作核心类,包含样式设置、数据读写、类型转换。

java 复制代码
/**  
 * 适配注解的 POI 工具类(支持 .xlsx 和类型转换)  
 */  
public class ExcelUtil {  
  
    // ====================== 导出逻辑 ======================    
    public static <T> void exportExcel(HttpServletResponse response, List<T> dataList,  
                                       String[] headers, String[] fields, String fileName) {  
        // 1. 创建工作簿 (XSSFWorkbook 支持 .xlsx)
        Workbook workbook = new XSSFWorkbook();  
        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();  
        }    
    }  
    
    // ====================== 导入逻辑 ======================    
    public static <T> List<T> importExcel(MultipartFile file, Class<T> clazz) {  
        List<T> dataList = new ArrayList<>();  
        if (file.isEmpty()) {  
            return dataList;  
        }  
        
        // 1. 解析导入注解,获取表头和字段映射  
        String[][] annotationData = ExcelAnnotationUtil.parseImportAnnotation(clazz);  
        String[] headers = annotationData[0]; // Excel表头(用于校验)  
        String[] fields = annotationData[1]; // 实体类字段名  
  
        try {  
            // WorkbookFactory 自动识别 xls 或 xlsx
            Workbook 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) {  
                throw new RuntimeException("Excel无表头行");  
            }            
            for (int i = 0; i < headers.length; i++) {  
                Cell cell = headerRow.getCell(i);  
                String cellValue = getCellValue(cell);  
                if (!headers[i].equals(cellValue)) {  
                    throw new RuntimeException("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();  
            throw new RuntimeException("导入失败:" + e.getMessage());  
        }        
        return dataList;  
    }  
    
    // ====================== 私有工具方法 (样式与单元格读取) ======================    
    
    /** 获取表头样式(加粗、居中、浅灰背景、边框) */  
    private static CellStyle getHeaderStyle(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;  
    }  
    
    /** 获取内容样式(居中、边框) */  
    private static CellStyle getContentStyle(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) */  
    private static String getCellValue(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 {  
                    // 防止数字变成科学计数法,这里简单转 String
                    return String.valueOf(cell.getNumericCellValue()).trim();  
                }            
            case BOOLEAN:  
                return String.valueOf(cell.getBooleanCellValue());  
            case FORMULA:  
                // 递归获取公式计算后的值
                return getCellValue(cell.getCachedFormulaResultType(), cell);  
            default:  
                return "";  
        }    
    }  
    
    /** 处理公式单元格的结果 */  
    private static String getCellValue(CellType cellType, Cell cell) {  
        if (cellType == CellType.NUMERIC) {  
            return String.valueOf(cell.getNumericCellValue()).trim();  
        } else if (cellType == CellType.STRING) {  
            return cell.getStringCellValue().trim();  
        } else {  
            return "";  
        }    
    }
}
相关推荐
入门级前端开发2 小时前
vue集成xlsl实现前端表格导入导出
前端·javascript·vue.js
gAlAxy...2 小时前
Spring Boot 详细学习指南(上篇):核心概念 + 环境搭建 + HelloWorld 实战
java·spring boot·后端
私人珍藏库2 小时前
[吾爱大神原创工具] Word图片批量导出并插入Excel对应单元格
word·excel
2501_944521592 小时前
Flutter for OpenHarmony 微动漫App实战:列表项组件实现
android·开发语言·javascript·flutter·ecmascript
一人の梅雨2 小时前
中国制造网商品详情接口进阶实战:跨境场景下的差异化适配与问题攻坚
java·前端·javascript
无心水2 小时前
8、吃透Go语言container包:链表(List)与环(Ring)的核心原理+避坑指南
java·开发语言·链表·微服务·架构·golang·list
沛沛老爹2 小时前
Web开发者转型AI安全核心:Agent金融数据处理Skill合规架构实战
java·人工智能·rag·企业转型·合规
步步为营DotNet2 小时前
深度钻研.NET 中Task.Run:异步任务执行的便捷入口
java·服务器·.net
Hello.Reader2 小时前
Spring 新声明式 HTTP 客户端:HTTP Interface + RestClient,把“调用外部 API”写成接口
java·spring·http