SpringBoot读取Excel文件,一场与“表格怪兽”的搏斗记

大家好,我是小悟。

前情提要:Excel------那个伪装成表格的数据怪兽

想象一下,你正悠闲地喝着咖啡,产品经理突然拍着你的肩膀说:"嘿,这是客户发来的Excel文件,里面有十万条数据,明天上线前要导入系统哦!"

这时你才发现,你面对的不仅是一个.xlsx文件,而是一个披着网格外衣的"数据怪兽"。它有隐藏的工作表、合并的单元格、还有那些任性格式化的日期字段!但别怕,拿起SpringBoot这把"圣剑",我们一起来驯服这个怪兽!

战斗准备:装备你的SpringBoot武器库

第一步:添加神奇药剂(依赖)

xml 复制代码
<dependencies>
    <!-- 主武器:SpringBoot基础装备 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- 对付Excel的专属神器:Apache POI -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi</artifactId>
        <version>5.2.3</version>
    </dependency>
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
        <version>5.2.3</version>
    </dependency>
    
    <!-- 辅助装备:简化代码的Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

第二步:创建数据模型------给怪兽分类

typescript 复制代码
import lombok.Data;

@Data
public class User {
    private String name;        // 姓名列
    private Integer age;        // 年龄列
    private String email;       // 邮箱列
    private Date birthDate;     // 生日列(Excel最擅长把日期搞乱)
    private Double salary;      // 工资列(希望这个数字让你开心)
    
    // 可选:给怪兽的数据加个验证
    public boolean isValid() {
        return name != null && !name.trim().isEmpty() 
            && email != null && email.contains("@");
    }
}

第三步:创建Excel读取服务------我们的"怪兽翻译官"

java 复制代码
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Service
public class ExcelReaderService {
    
    /**
     * 读取Excel文件的主要方法
     * @param file 上传的Excel文件
     * @return 用户列表
     * @throws Exception 如果Excel怪兽太难对付
     */
    public List<User> readExcelFile(MultipartFile file) throws Exception {
        // 安全检查:先确认这不是个空文件陷阱
        if (file.isEmpty()) {
            throw new RuntimeException("文件是空的!Excel怪兽使用了隐身术!");
        }
        
        // 检查文件类型:.xlsx还是.xls?
        String fileName = file.getOriginalFilename();
        if (fileName == null || (!fileName.endsWith(".xlsx") && !fileName.endsWith(".xls"))) {
            throw new RuntimeException("这不是一个合法的Excel文件!可能是伪装的怪兽!");
        }
        
        List<User> userList = new ArrayList<>();
        
        // 尝试打开Excel文件(打开怪兽的嘴)
        try (InputStream inputStream = file.getInputStream();
             Workbook workbook = WorkbookFactory.create(inputStream)) {
            
            // 获取第一个工作表(Excel怪兽可能有多个头)
            Sheet sheet = workbook.getSheetAt(0);
            
            // 遍历每一行(像翻阅怪兽的日记)
            for (int i = 1; i <= sheet.getLastRowNum(); i++) { // 从1开始,跳过表头
                Row row = sheet.getRow(i);
                
                // 跳过空行(怪兽的空白记忆)
                if (row == null) {
                    continue;
                }
                
                User user = convertRowToUser(row);
                if (user.isValid()) {
                    userList.add(user);
                } else {
                    System.out.println("第 " + (i+1) + " 行数据不完整,已跳过");
                }
            }
        }
        
        System.out.println("成功从Excel怪兽手中解救了 " + userList.size() + " 个用户!");
        return userList;
    }
    
    /**
     * 把一行数据转换成User对象
     * 注意:这个方法要和Excel的结构对应上!
     */
    private User convertRowToUser(Row row) {
        User user = new User();
        
        try {
            // 第一列:姓名(字符串)
            Cell nameCell = row.getCell(0);
            if (nameCell != null) {
                user.setName(getCellValueAsString(nameCell));
            }
            
            // 第二列:年龄(数字)
            Cell ageCell = row.getCell(1);
            if (ageCell != null) {
                if (ageCell.getCellType() == CellType.NUMERIC) {
                    user.setAge((int) ageCell.getNumericCellValue());
                } else {
                    // 尝试从字符串解析
                    String ageStr = getCellValueAsString(ageCell);
                    if (ageStr.matches("\\d+")) {
                        user.setAge(Integer.parseInt(ageStr));
                    }
                }
            }
            
            // 第三列:邮箱(字符串)
            Cell emailCell = row.getCell(2);
            if (emailCell != null) {
                user.setEmail(getCellValueAsString(emailCell));
            }
            
            // 第四列:生日(日期)
            Cell birthCell = row.getCell(3);
            if (birthCell != null) {
                if (birthCell.getCellType() == CellType.NUMERIC && 
                    DateUtil.isCellDateFormatted(birthCell)) {
                    user.setBirthDate(birthCell.getDateCellValue());
                }
            }
            
            // 第五列:工资(数字)
            Cell salaryCell = row.getCell(4);
            if (salaryCell != null) {
                if (salaryCell.getCellType() == CellType.NUMERIC) {
                    user.setSalary(salaryCell.getNumericCellValue());
                }
            }
            
        } catch (Exception e) {
            System.err.println("处理第 " + (row.getRowNum()+1) + " 行时遇到问题: " + e.getMessage());
        }
        
        return user;
    }
    
    /**
     * 智能获取单元格值作为字符串
     * Excel怪兽喜欢把数据打扮成各种类型
     */
    private String getCellValueAsString(Cell cell) {
        if (cell == null) {
            return "";
        }
        
        switch (cell.getCellType()) {
            case STRING:
                return cell.getStringCellValue().trim();
            case NUMERIC:
                if (DateUtil.isCellDateFormatted(cell)) {
                    return cell.getDateCellValue().toString();
                } else {
                    // 避免科学计数法,也避免不必要的.0
                    double num = cell.getNumericCellValue();
                    if (num == Math.floor(num)) {
                        return String.valueOf((int) num);
                    } else {
                        return String.valueOf(num);
                    }
                }
            case BOOLEAN:
                return String.valueOf(cell.getBooleanCellValue());
            case FORMULA:
                try {
                    return cell.getStringCellValue();
                } catch (Exception e) {
                    try {
                        return String.valueOf(cell.getNumericCellValue());
                    } catch (Exception ex) {
                        return cell.getCellFormula();
                    }
                }
            default:
                return "";
        }
    }
    
    /**
     * 高级功能:读取多个工作表
     */
    public Map<String, List<User>> readMultipleSheets(MultipartFile file) throws Exception {
        Map<String, List<User>> result = new HashMap<>();
        
        try (InputStream inputStream = file.getInputStream();
             Workbook workbook = new XSSFWorkbook(inputStream)) {
            
            // 遍历所有工作表
            for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
                Sheet sheet = workbook.getSheetAt(i);
                String sheetName = sheet.getSheetName();
                List<User> users = new ArrayList<>();
                
                for (Row row : sheet) {
                    if (row.getRowNum() == 0) continue; // 跳过表头
                    
                    User user = convertRowToUser(row);
                    if (user.isValid()) {
                        users.add(user);
                    }
                }
                
                result.put(sheetName, users);
                System.out.println("工作表 '" + sheetName + "' 中读取到 " + users.size() + " 个用户");
            }
        }
        
        return result;
    }
}

第四步:创建控制器------与前端通信的"传令兵"

typescript 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/excel")
public class ExcelController {
    
    @Autowired
    private ExcelReaderService excelReaderService;
    
    /**
     * 上传并读取Excel文件
     * POST /api/excel/upload
     */
    @PostMapping("/upload")
    public ResponseEntity<Map<String, Object>> uploadExcel(
            @RequestParam("file") MultipartFile file) {
        
        Map<String, Object> response = new HashMap<>();
        
        try {
            // 调用服务读取Excel
            List<User> users = excelReaderService.readExcelFile(file);
            
            // 构建响应
            response.put("success", true);
            response.put("message", "文件读取成功!");
            response.put("totalRecords", users.size());
            response.put("data", users);
            response.put("suggestions", generateSuggestions(users));
            
            // 这里可以添加数据库保存逻辑
            // userRepository.saveAll(users);
            
            return ResponseEntity.ok(response);
            
        } catch (Exception e) {
            response.put("success", false);
            response.put("message", "读取文件时出错: " + e.getMessage());
            response.put("error", e.toString());
            return ResponseEntity.badRequest().body(response);
        }
    }
    
    /**
     * 生成一些有趣的统计数据
     */
    private Map<String, Object> generateSuggestions(List<User> users) {
        Map<String, Object> suggestions = new HashMap<>();
        
        if (users.isEmpty()) {
            suggestions.put("note", "Excel文件是空的,或者没有找到有效数据");
            return suggestions;
        }
        
        // 统计平均年龄
        double avgAge = users.stream()
                .filter(u -> u.getAge() != null)
                .mapToInt(User::getAge)
                .average()
                .orElse(0);
        suggestions.put("averageAge", String.format("%.1f 岁", avgAge));
        
        // 统计最年长和最年轻
        users.stream()
                .filter(u -> u.getAge() != null)
                .max((u1, u2) -> u1.getAge() - u2.getAge())
                .ifPresent(oldest -> 
                    suggestions.put("oldest", oldest.getName() + " (" + oldest.getAge() + "岁)"));
        
        // 邮箱域名统计
        Map<String, Long> emailDomains = users.stream()
                .filter(u -> u.getEmail() != null && u.getEmail().contains("@"))
                .map(u -> u.getEmail().split("@")[1])
                .collect(Collectors.groupingBy(domain -> domain, Collectors.counting()));
        
        if (!emailDomains.isEmpty()) {
            suggestions.put("emailDomains", emailDomains);
        }
        
        return suggestions;
    }
    
    /**
     * 下载Excel模板
     * GET /api/excel/template
     */
    @GetMapping("/template")
    public ResponseEntity<byte[]> downloadTemplate() {
        // 这里可以创建一个模板Excel文件并返回
        // 为了简洁,这里省略具体实现
        return ResponseEntity.ok().body("请创建自己的模板文件".getBytes());
    }
}

第五步:前端HTML页面------我们的"战斗指挥台"

xml 复制代码
<!DOCTYPE html>
<html>
<head>
    <title>Excel文件读取器 - 怪兽驯服界面</title>
    <style>
        body {
            font-family: 'Arial', sans-serif;
            max-width: 800px;
            margin: 40px auto;
            padding: 20px;
            background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
        }
        .container {
            background: white;
            padding: 30px;
            border-radius: 15px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.1);
        }
        h1 {
            color: #333;
            text-align: center;
            margin-bottom: 30px;
        }
        .upload-area {
            border: 3px dashed #4CAF50;
            border-radius: 10px;
            padding: 40px;
            text-align: center;
            margin: 20px 0;
            transition: all 0.3s;
            cursor: pointer;
        }
        .upload-area:hover {
            background-color: #f0fff0;
            border-color: #45a049;
        }
        .upload-area.dragover {
            background-color: #e8f5e9;
            border-color: #2e7d32;
        }
        #fileInput {
            display: none;
        }
        .btn {
            background-color: #4CAF50;
            color: white;
            padding: 12px 24px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
            margin: 10px;
            transition: background-color 0.3s;
        }
        .btn:hover {
            background-color: #45a049;
        }
        .result {
            margin-top: 30px;
            padding: 20px;
            border-radius: 8px;
            background-color: #f8f9fa;
            display: none;
        }
        .result.success {
            display: block;
            border-left: 5px solid #4CAF50;
        }
        .result.error {
            display: block;
            border-left: 5px solid #f44336;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 20px;
        }
        th, td {
            border: 1px solid #ddd;
            padding: 12px;
            text-align: left;
        }
        th {
            background-color: #4CAF50;
            color: white;
        }
        tr:nth-child(even) {
            background-color: #f2f2f2;
        }
        .loading {
            text-align: center;
            padding: 20px;
            display: none;
        }
        .loading.show {
            display: block;
        }
        .stats {
            background-color: #e8f5e9;
            padding: 15px;
            border-radius: 8px;
            margin: 15px 0;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Excel文件读取器</h1>
        <p>上传你的Excel文件,让我们一起驯服这个"数据怪兽"!</p>
        
        <div class="upload-area" id="dropArea">
            <h2>拖放文件到这里</h2>
            <p>或者</p>
            <button class="btn" onclick="document.getElementById('fileInput').click()">
                选择Excel文件
            </button>
            <input type="file" id="fileInput" accept=".xlsx,.xls" onchange="handleFileSelect()">
            <p style="margin-top: 15px; color: #666;">
                支持 .xlsx 和 .xls 格式,请确保第一行是表头
            </p>
        </div>
        
        <div class="loading" id="loading">
            <h3>正在读取Excel文件...</h3>
            <p>正在与Excel怪兽搏斗中,请稍候!</p>
            <div style="margin: 20px;">
                <div style="width: 100%; background-color: #ddd; border-radius: 5px;">
                    <div id="progressBar" style="width: 0%; height: 20px; background-color: #4CAF50; border-radius: 5px; transition: width 0.3s;"></div>
                </div>
            </div>
        </div>
        
        <div class="result" id="result"></div>
        
        <div style="margin-top: 30px; text-align: center;">
            <button class="btn" onclick="downloadTemplate()">下载模板文件</button>
            <button class="btn" onclick="clearResults()">清除结果</button>
        </div>
    </div>

    <script>
        const dropArea = document.getElementById('dropArea');
        const fileInput = document.getElementById('fileInput');
        const loading = document.getElementById('loading');
        const resultDiv = document.getElementById('result');
        const progressBar = document.getElementById('progressBar');
        
        // 拖放功能
        ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
            dropArea.addEventListener(eventName, preventDefaults, false);
        });
        
        function preventDefaults(e) {
            e.preventDefault();
            e.stopPropagation();
        }
        
        ['dragenter', 'dragover'].forEach(eventName => {
            dropArea.addEventListener(eventName, highlight, false);
        });
        
        ['dragleave', 'drop'].forEach(eventName => {
            dropArea.addEventListener(eventName, unhighlight, false);
        });
        
        function highlight() {
            dropArea.classList.add('dragover');
        }
        
        function unhighlight() {
            dropArea.classList.remove('dragover');
        }
        
        dropArea.addEventListener('drop', handleDrop, false);
        
        function handleDrop(e) {
            const dt = e.dataTransfer;
            const files = dt.files;
            fileInput.files = files;
            handleFileSelect();
        }
        
        // 处理文件选择
        async function handleFileSelect() {
            const file = fileInput.files[0];
            if (!file) return;
            
            if (!file.name.match(/\.(xlsx|xls)$/i)) {
                showError('请选择Excel文件 (.xlsx 或 .xls)');
                return;
            }
            
            // 显示加载动画
            loading.classList.add('show');
            resultDiv.className = 'result';
            progressBar.style.width = '30%';
            
            const formData = new FormData();
            formData.append('file', file);
            
            try {
                progressBar.style.width = '60%';
                
                const response = await fetch('/api/excel/upload', {
                    method: 'POST',
                    body: formData
                });
                
                progressBar.style.width = '90%';
                
                const data = await response.json();
                
                progressBar.style.width = '100%';
                
                if (data.success) {
                    showSuccess(data);
                } else {
                    showError(data.message);
                }
                
            } catch (error) {
                showError('上传失败: ' + error.message);
            } finally {
                setTimeout(() => {
                    loading.classList.remove('show');
                    progressBar.style.width = '0%';
                }, 500);
            }
        }
        
        // 显示成功结果
        function showSuccess(data) {
            resultDiv.className = 'result success';
            
            let html = `<h3>文件读取成功!</h3>`;
            html += `<p>共读取 <strong>${data.totalRecords}</strong> 条记录</p>`;
            
            // 显示统计信息
            if (data.suggestions) {
                html += `<div class="stats">`;
                html += `<h4>统计信息:</h4>`;
                if (data.suggestions.averageAge) {
                    html += `<p>平均年龄: ${data.suggestions.averageAge}</p>`;
                }
                if (data.suggestions.oldest) {
                    html += `<p>最年长: ${data.suggestions.oldest}</p>`;
                }
                html += `</div>`;
            }
            
            // 显示数据表格
            if (data.data && data.data.length > 0) {
                html += `<h4>数据预览(前10条):</h4>`;
                html += `<table>`;
                html += `<tr><th>姓名</th><th>年龄</th><th>邮箱</th><th>生日</th><th>工资</th></tr>`;
                
                data.data.slice(0, 10).forEach(user => {
                    html += `<tr>`;
                    html += `<td>${user.name || ''}</td>`;
                    html += `<td>${user.age || ''}</td>`;
                    html += `<td>${user.email || ''}</td>`;
                    html += `<td>${user.birthDate || ''}</td>`;
                    html += `<td>${user.salary || ''}</td>`;
                    html += `</tr>`;
                });
                
                html += `</table>`;
                
                if (data.data.length > 10) {
                    html += `<p>... 还有 ${data.data.length - 10} 条记录未显示</p>`;
                }
            }
            
            resultDiv.innerHTML = html;
        }
        
        // 显示错误
        function showError(message) {
            resultDiv.className = 'result error';
            resultDiv.innerHTML = `
                <h3>读取失败!</h3>
                <p>${message}</p>
                <p>请检查:</p>
                <ul>
                    <li>文件是否为Excel格式</li>
                    <li>文件是否损坏</li>
                    <li>文件结构是否符合要求</li>
                </ul>
            `;
        }
        
        // 下载模板
        async function downloadTemplate() {
            alert('模板下载功能需要后端实现!');
            // 实际实现应该调用后端的模板下载接口
            // window.location.href = '/api/excel/template';
        }
        
        // 清除结果
        function clearResults() {
            resultDiv.className = 'result';
            resultDiv.innerHTML = '';
            fileInput.value = '';
        }
    </script>
</body>
</html>

第六步:配置文件------设置战斗参数

yaml 复制代码
spring:
  servlet:
    multipart:
      max-file-size: 10MB      # 最大文件大小
      max-request-size: 10MB   # 最大请求大小
      
  # 如果你要保存到数据库
  datasource:
    url: jdbc:mysql://localhost:3306/excel_db
    username: root
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver
    
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

# 自定义配置
excel:
  upload:
    max-size: 10485760  # 10MB
    allowed-extensions: .xlsx,.xls
  batch:
    insert-size: 1000   # 批量插入大小

第七步:高级功能------添加批量处理

scss 复制代码
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ExcelBatchService {
    
    @Autowired
    private UserRepository userRepository;
    
    /**
     * 批量保存用户,提高性能
     */
    @Transactional
    public void batchSaveUsers(List<User> users, int batchSize) {
        List<User> batch = new ArrayList<>(batchSize);
        
        for (int i = 0; i < users.size(); i++) {
            batch.add(users.get(i));
            
            if (batch.size() == batchSize || i == users.size() - 1) {
                userRepository.saveAll(batch);
                batch.clear();
                
                // 显示进度
                System.out.printf("已保存 %d/%d 条记录 (%.1f%%)%n", 
                    i + 1, users.size(), (i + 1) * 100.0 / users.size());
            }
        }
    }
    
    /**
     * 异步处理大文件
     */
    @Async
    public CompletableFuture<List<User>> processLargeFileAsync(MultipartFile file) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                // 这里处理大文件,可以使用SAX模式读取
                return readLargeExcelFile(file);
            } catch (Exception e) {
                throw new RuntimeException("处理文件失败", e);
            }
        });
    }
}

战斗总结:我们是如何驯服Excel怪兽的?

经过这场与Excel怪兽的搏斗,我们学到了不少宝贵经验:

我们的战利品:

  1. Apache POI:我们的主力武器,专门对付各种Excel格式
  2. 智能单元格处理:Excel怪兽喜欢把数据伪装成各种类型,我们都能识别
  3. 批量处理能力:即使面对十万大军(数据),我们也能有序处理
  4. 用户友好界面:让非技术人员也能轻松使用

注意事项:

  1. 内存管理:大文件就像大怪兽,小心它吃光你的内存!考虑使用SAX模式
  2. 数据类型:Excel的日期格式是个狡猾的敌人,总想迷惑我们
  3. 空值处理:Excel怪兽喜欢留空白,我们要妥善处理
  4. 性能优化:批量操作和异步处理是我们的秘密武器

可以继续增强的功能:

  1. 数据验证:添加更严格的业务规则验证
  2. 模板验证:检查上传文件是否符合预定模板
  3. 错误报告:生成详细的错误报告,告诉用户哪里出错了
  4. 进度反馈:对于大文件,提供实时进度反馈
  5. 数据转换:更复杂的数据转换和清洗逻辑

建议:

每个Excel文件都是一个独特的"怪兽",可能有自己的小脾气(奇怪的格式、隐藏的工作表等)。我们的代码要足够健壮,既能处理规整的数据,也能应对意外情况。

现在,当产品经理再给你Excel文件时,你可以微笑着说:"放马过来吧,我有SpringBoot这个神器!"

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

相关推荐
糖果店的幽灵3 分钟前
langgraph分支之 - 动态分支(Dynamic Branch)
java·前端·javascript·人工智能·langgraph
唐青枫44 分钟前
Java Spring Security 实战详解:从登录认证到 JWT 权限控制
java
曹牧1 小时前
文档格式:OFD
java
豆角焖肉1 小时前
Maven进阶与搭建私服
java·maven
大不点wow1 小时前
Java序列化与反序列化:让对象走出JVM
java·开发语言·jvm
止语Lab2 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活2 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
噢,我明白了2 小时前
Java中日期和字符串的处理
java·开发语言·日期
程序员天天困2 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn2 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端