Day | 12 【苍穹外卖 :导出Excel数据表】

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!

前言:经过差不多一个月的学习,我们这个项目已经接近尾声,今天是最后一张的内容,关于商家端的excel数据表的导出,后面我会挤出几天的时间对这个项目进行全面的总结,如果对你有帮助,请关注我。

一、为什么选择Apache POI?

在苍穹外卖项目中,虽然EasyExcel性能更好,但POI仍然是主流选择,原因如下:

  • 功能最全面:支持复杂的Excel操作(公式、图表、宏等)

  • 生态成熟:文档丰富,问题解决方案多

  • 无需额外依赖:很多项目已包含POI依赖

  • 精细控制:可以精确控制每个单元格的样式

二、技术依赖配置

Maven依赖

XML 复制代码
xml

<!-- POI核心依赖 -->
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>4.1.2</version>
</dependency>

<!-- POI处理.xlsx格式 -->
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>4.1.2</version>
</dependency>

<!-- 日期处理工具(可选) -->
<dependency>
    <groupId>joda-time</groupId>
    <artifactId>joda-time</artifactId>
    <version>2.10.10</version>
</dependency>

三、完整实现步骤

第1步:定义导出VO类

java 复制代码
java

package com.sky.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDate;

/**
 * 营业额报表VO
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TurnoverReportVO {
    private LocalDate date;          // 日期
    private BigDecimal turnover;     // 营业额
    private Integer orderCount;      // 订单总数
    private Integer validOrderCount; // 有效订单数
    private String completionRate;   // 完成率
}

/**
 * 菜品销量VO
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DishSalesVO {
    private String dishName;         // 菜品名称
    private Integer salesCount;      // 销量
    private BigDecimal amount;       // 销售额
    private String category;         // 分类
}

第2步:Service层实现

java 复制代码
java

package com.sky.service.impl;

import com.sky.mapper.OrderMapper;
import com.sky.mapper.DishMapper;
import com.sky.vo.TurnoverReportVO;
import com.sky.vo.DishSalesVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;

@Service
@Slf4j
public class ReportServiceImpl implements ReportService {

    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private DishMapper dishMapper;

    @Override
    public void exportTurnoverReport(LocalDate start, LocalDate end, 
                                     HttpServletResponse response) throws IOException {
        
        // 1. 查询数据
        List<TurnoverReportVO> turnoverData = getTurnoverData(start, end);
        
        // 2. 创建工作簿
        Workbook workbook = new XSSFWorkbook();
        
        // 3. 创建样式
        Map<String, CellStyle> styles = createStyles(workbook);
        
        try {
            // 4. 创建营业额报表Sheet
            Sheet turnoverSheet = workbook.createSheet("营业额报表");
            createTurnoverSheet(turnoverSheet, turnoverData, styles);
            
            // 5. 设置响应头
            String fileName = URLEncoder.encode(
                String.format("营业额报表_%s至%s.xlsx", start, end), 
                "UTF-8"
            );
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("utf-8");
            response.setHeader("Content-disposition", 
                "attachment;filename=" + fileName);
            
            // 6. 输出到响应流
            workbook.write(response.getOutputStream());
            workbook.close();
            
            log.info("导出营业额报表成功,时间范围:{} 至 {}", start, end);
            
        } catch (Exception e) {
            log.error("导出营业额报表失败", e);
            throw new RuntimeException("导出失败:" + e.getMessage());
        } finally {
            workbook.close();
        }
    }
    
    /**
     * 查询营业额数据
     */
    private List<TurnoverReportVO> getTurnoverData(LocalDate start, LocalDate end) {
        List<TurnoverReportVO> list = new ArrayList<>();
        LocalDate currentDate = start;
        
        while (!currentDate.isAfter(end)) {
            // 查询当日数据
            BigDecimal turnover = orderMapper.getTurnoverByDate(currentDate);
            Integer orderCount = orderMapper.getOrderCountByDate(currentDate);
            Integer validOrderCount = orderMapper.getValidOrderCountByDate(currentDate);
            
            // 计算完成率
            String completionRate = orderCount == 0 ? "0%" :
                String.format("%.2f%%", (validOrderCount * 100.0 / orderCount));
            
            TurnoverReportVO vo = TurnoverReportVO.builder()
                .date(currentDate)
                .turnover(turnover != null ? turnover : BigDecimal.ZERO)
                .orderCount(orderCount != null ? orderCount : 0)
                .validOrderCount(validOrderCount != null ? validOrderCount : 0)
                .completionRate(completionRate)
                .build();
            
            list.add(vo);
            currentDate = currentDate.plusDays(1);
        }
        
        return list;
    }
    
    /**
     * 创建样式集合
     */
    private Map<String, CellStyle> createStyles(Workbook workbook) {
        Map<String, CellStyle> styles = new HashMap<>();
        
        // 标题样式(加粗、居中、背景色)
        CellStyle titleStyle = workbook.createCellStyle();
        Font titleFont = workbook.createFont();
        titleFont.setBold(true);
        titleFont.setFontHeightInPoints((short) 14);
        titleStyle.setFont(titleFont);
        titleStyle.setAlignment(HorizontalAlignment.CENTER);
        titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);
        titleStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
        titleStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        styles.put("title", titleStyle);
        
        // 表头样式(加粗、居中、边框)
        CellStyle headerStyle = workbook.createCellStyle();
        Font headerFont = workbook.createFont();
        headerFont.setBold(true);
        headerStyle.setFont(headerFont);
        headerStyle.setAlignment(HorizontalAlignment.CENTER);
        headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
        headerStyle.setBorderTop(BorderStyle.THIN);
        headerStyle.setBorderBottom(BorderStyle.THIN);
        headerStyle.setBorderLeft(BorderStyle.THIN);
        headerStyle.setBorderRight(BorderStyle.THIN);
        headerStyle.setFillForegroundColor(IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex());
        headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        styles.put("header", headerStyle);
        
        // 数据样式(右对齐、边框)
        CellStyle dataStyle = workbook.createCellStyle();
        dataStyle.setAlignment(HorizontalAlignment.RIGHT);
        dataStyle.setVerticalAlignment(VerticalAlignment.CENTER);
        dataStyle.setBorderTop(BorderStyle.THIN);
        dataStyle.setBorderBottom(BorderStyle.THIN);
        dataStyle.setBorderLeft(BorderStyle.THIN);
        dataStyle.setBorderRight(BorderStyle.THIN);
        styles.put("data", dataStyle);
        
        // 货币样式
        CellStyle currencyStyle = workbook.createCellStyle();
        currencyStyle.cloneStyleFrom(dataStyle);
        DataFormat format = workbook.createDataFormat();
        currencyStyle.setDataFormat(format.getFormat("#,##0.00"));
        styles.put("currency", currencyStyle);
        
        // 百分比样式
        CellStyle percentStyle = workbook.createCellStyle();
        percentStyle.cloneStyleFrom(dataStyle);
        percentStyle.setDataFormat(format.getFormat("0.00%"));
        styles.put("percent", percentStyle);
        
        return styles;
    }
    
    /**
     * 创建营业额报表Sheet
     */
    private void createTurnoverSheet(Sheet sheet, List<TurnoverReportVO> data, 
                                     Map<String, CellStyle> styles) {
        
        // 1. 创建标题行
        Row titleRow = sheet.createRow(0);
        Cell titleCell = titleRow.createCell(0);
        titleCell.setCellValue("苍穹外卖营业额统计报表");
        titleCell.setCellStyle(styles.get("title"));
        sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 4));
        titleRow.setHeightInPoints(30);
        
        // 2. 创建表头
        String[] headers = {"日期", "营业额(元)", "订单总数", "有效订单数", "完成率"};
        Row headerRow = sheet.createRow(1);
        for (int i = 0; i < headers.length; i++) {
            Cell cell = headerRow.createCell(i);
            cell.setCellValue(headers[i]);
            cell.setCellStyle(styles.get("header"));
            // 设置列宽
            sheet.setColumnWidth(i, 4500);
        }
        
        // 3. 填充数据
        DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        int rowNum = 2;
        for (TurnoverReportVO vo : data) {
            Row row = sheet.createRow(rowNum);
            
            // 日期
            Cell dateCell = row.createCell(0);
            dateCell.setCellValue(vo.getDate().format(dateFormatter));
            dateCell.setCellStyle(styles.get("data"));
            
            // 营业额
            Cell turnoverCell = row.createCell(1);
            turnoverCell.setCellValue(vo.getTurnover().doubleValue());
            turnoverCell.setCellStyle(styles.get("currency"));
            
            // 订单总数
            Cell orderCountCell = row.createCell(2);
            orderCountCell.setCellValue(vo.getOrderCount());
            orderCountCell.setCellStyle(styles.get("data"));
            
            // 有效订单数
            Cell validCountCell = row.createCell(3);
            validCountCell.setCellValue(vo.getValidOrderCount());
            validCountCell.setCellStyle(styles.get("data"));
            
            // 完成率
            Cell rateCell = row.createCell(4);
            rateCell.setCellValue(vo.getCompletionRate());
            rateCell.setCellStyle(styles.get("data"));
            
            rowNum++;
        }
        
        // 4. 添加统计行
        Row summaryRow = sheet.createRow(rowNum);
        Cell summaryLabel = summaryRow.createCell(0);
        summaryLabel.setCellValue("合计");
        summaryLabel.setCellStyle(styles.get("header"));
        
        // 计算总营业额
        BigDecimal totalTurnover = data.stream()
            .map(TurnoverReportVO::getTurnover)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
        Cell totalTurnoverCell = summaryRow.createCell(1);
        totalTurnoverCell.setCellValue(totalTurnover.doubleValue());
        totalTurnoverCell.setCellStyle(styles.get("currency"));
        
        // 计算总订单数
        int totalOrders = data.stream()
            .mapToInt(TurnoverReportVO::getOrderCount)
            .sum();
        Cell totalOrdersCell = summaryRow.createCell(2);
        totalOrdersCell.setCellValue(totalOrders);
        totalOrdersCell.setCellStyle(styles.get("header"));
        
        // 计算总有效订单
        int totalValidOrders = data.stream()
            .mapToInt(TurnoverReportVO::getValidOrderCount)
            .sum();
        Cell totalValidCell = summaryRow.createCell(3);
        totalValidCell.setCellValue(totalValidOrders);
        totalValidCell.setCellStyle(styles.get("header"));
        
        // 整体完成率
        String totalRate = totalOrders == 0 ? "0%" :
            String.format("%.2f%%", (totalValidOrders * 100.0 / totalOrders));
        Cell totalRateCell = summaryRow.createCell(4);
        totalRateCell.setCellValue(totalRate);
        totalRateCell.setCellStyle(styles.get("header"));
    }
}

第3步:导出菜品销量排行

java 复制代码
java

/**
 * 导出菜品销量排行
 */
public void exportTopDishes(LocalDate start, LocalDate end, 
                            Integer topN, HttpServletResponse response) throws IOException {
    
    // 查询Top N菜品
    List<DishSalesVO> dishList = dishMapper.getTopDishes(start, end, topN);
    
    Workbook workbook = new XSSFWorkbook();
    Sheet sheet = workbook.createSheet("热销菜品排行");
    
    // 创建样式
    Map<String, CellStyle> styles = createStyles(workbook);
    
    // 创建表头
    Row headerRow = sheet.createRow(0);
    String[] headers = {"排名", "菜品名称", "分类", "销量", "销售额(元)"};
    for (int i = 0; i < headers.length; i++) {
        Cell cell = headerRow.createCell(i);
        cell.setCellValue(headers[i]);
        cell.setCellStyle(styles.get("header"));
        sheet.setColumnWidth(i, i == 1 ? 6000 : 4000);
    }
    
    // 填充数据
    int rank = 1;
    for (int i = 0; i < dishList.size(); i++) {
        DishSalesVO vo = dishList.get(i);
        Row row = sheet.createRow(i + 1);
        
        // 排名
        Cell rankCell = row.createCell(0);
        rankCell.setCellValue(rank++);
        rankCell.setCellStyle(styles.get("data"));
        
        // 菜品名称
        Cell nameCell = row.createCell(1);
        nameCell.setCellValue(vo.getDishName());
        nameCell.setCellStyle(styles.get("data"));
        
        // 分类
        Cell categoryCell = row.createCell(2);
        categoryCell.setCellValue(vo.getCategory());
        categoryCell.setCellStyle(styles.get("data"));
        
        // 销量
        Cell salesCell = row.createCell(3);
        salesCell.setCellValue(vo.getSalesCount());
        salesCell.setCellStyle(styles.get("data"));
        
        // 销售额
        Cell amountCell = row.createCell(4);
        amountCell.setCellValue(vo.getAmount().doubleValue());
        amountCell.setCellStyle(styles.get("currency"));
    }
    
    // 响应输出
    String fileName = URLEncoder.encode(
        String.format("热销菜品排行_%s至%s.xlsx", start, end), "UTF-8");
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setHeader("Content-disposition", "attachment;filename=" + fileName);
    workbook.write(response.getOutputStream());
    workbook.close();
}

第4步:Mapper层SQL实现

java 复制代码
java

@Mapper
public interface OrderMapper {
    
    /**
     * 查询指定日期营业额
     */
    @Select("SELECT COALESCE(SUM(amount), 0) FROM orders " +
            "WHERE DATE(create_time) = #{date} " +
            "AND status = 5")
    BigDecimal getTurnoverByDate(LocalDate date);
    
    /**
     * 查询指定日期订单总数
     */
    @Select("SELECT COUNT(*) FROM orders " +
            "WHERE DATE(create_time) = #{date}")
    Integer getOrderCountByDate(LocalDate date);
    
    /**
     * 查询指定日期有效订单数
     */
    @Select("SELECT COUNT(*) FROM orders " +
            "WHERE DATE(create_time) = #{date} " +
            "AND status IN (5, 6)")
    Integer getValidOrderCountByDate(LocalDate date);
}

@Mapper
public interface DishMapper {
    
    /**
     * 查询Top N热销菜品
     */
    @Select("SELECT d.name as dish_name, " +
            "SUM(od.number) as sales_count, " +
            "SUM(od.amount) as amount, " +
            "c.name as category " +
            "FROM order_detail od " +
            "LEFT JOIN dish d ON od.dish_id = d.id " +
            "LEFT JOIN category c ON d.category_id = c.id " +
            "LEFT JOIN orders o ON od.order_id = o.id " +
            "WHERE DATE(o.create_time) BETWEEN #{start} AND #{end} " +
            "AND o.status = 5 " +
            "GROUP BY d.id, d.name, c.name " +
            "ORDER BY sales_count DESC " +
            "LIMIT #{topN}")
    List<DishSalesVO> getTopDishes(LocalDate start, LocalDate end, Integer topN);
}

第5步:Controller层接口

java 复制代码
java

@RestController
@RequestMapping("/admin/report")
@Api(tags = "报表管理")
@Slf4j
public class ReportController {
    
    @Autowired
    private ReportService reportService;
    
    @GetMapping("/export/turnover")
    @ApiOperation("导出营业额报表")
    public void exportTurnoverReport(
            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate start,
            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end,
            HttpServletResponse response) {
        try {
            reportService.exportTurnoverReport(start, end, response);
        } catch (IOException e) {
            log.error("导出失败", e);
            throw new RuntimeException("导出失败");
        }
    }
    
    @GetMapping("/export/top-dishes")
    @ApiOperation("导出热销菜品排行")
    public void exportTopDishes(
            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate start,
            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end,
            @RequestParam(defaultValue = "10") Integer topN,
            HttpServletResponse response) {
        try {
            reportService.exportTopDishes(start, end, topN, response);
        } catch (IOException e) {
            log.error("导出失败", e);
            throw new RuntimeException("导出失败");
        }
    }
}

四、POI核心技术点

1. Workbook的选择

java 复制代码
java

// HSSFWorkbook: 处理.xls格式(Excel 97-2003),最大行数65536
Workbook hssfWorkbook = new HSSFWorkbook();

// XSSFWorkbook: 处理.xlsx格式(Excel 2007+),最大行数1048576
Workbook xssfWorkbook = new XSSFWorkbook();

// SXSSFWorkbook: 处理大数据量,内存友好
Workbook sxssfWorkbook = new SXSSFWorkbook(100); // 内存中保留100行

2. 单元格样式详解

java 复制代码
java

// 创建字体
Font font = workbook.createFont();
font.setFontName("微软雅黑");
font.setFontHeightInPoints((short) 12);
font.setBold(true);
font.setColor(IndexedColors.RED.getIndex());
font.setItalic(true);
font.setUnderline(Font.U_SINGLE);

// 创建样式
CellStyle style = workbook.createCellStyle();
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);  // 水平居中
style.setVerticalAlignment(VerticalAlignment.CENTER); // 垂直居中
style.setBorderTop(BorderStyle.THIN);  // 上边框
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setFillForegroundColor(IndexedColors.YELLOW.getIndex());

// 设置数据格式
DataFormat format = workbook.createDataFormat();
style.setDataFormat(format.getFormat("yyyy-mm-dd"));  // 日期格式
style.setDataFormat(format.getFormat("#,##0.00"));    // 货币格式
style.setDataFormat(format.getFormat("0.00%"));      // 百分比格式

3. 合并单元格

java 复制代码
java

// 合并行:起始行,结束行,起始列,结束列
CellRangeAddress region = new CellRangeAddress(0, 0, 0, 4);
sheet.addMergedRegion(region);

// 判断单元格是否合并
boolean isMerged = sheet.isMergedRegion(rowNum, colNum);

4. 公式计算

java 复制代码
java

// 添加Excel公式
Cell formulaCell = row.createCell(5);
formulaCell.setCellFormula("SUM(B2:B10)");

// 求和公式
formulaCell.setCellFormula("SUMIF(A2:A10, \"已完成\", C2:C10)");

// 平均值
formulaCell.setCellFormula("AVERAGE(B2:B10)");

// 注意:需要设置公式后刷新
workbook.setForceFormulaRecalculation(true);

五、难点与解决方案

难点1:大数据量导出内存溢出

java 复制代码
java

@Service
public class LargeDataExportService {
    
    /**
     * 使用SXSSFWorkbook解决大数据量问题
     */
    public void exportLargeData(HttpServletResponse response) throws IOException {
        // SXSSFWorkbook: 内存中只保留100行,其余写入磁盘
        SXSSFWorkbook workbook = new SXSSFWorkbook(100);
        Sheet sheet = workbook.createSheet("大数据报表");
        
        // 分页查询数据
        int pageSize = 2000;
        int pageNum = 1;
        int rowNum = 0;
        
        while (true) {
            // 分页查询
            List<DataVO> pageData = queryData(pageNum, pageSize);
            if (pageData.isEmpty()) {
                break;
            }
            
            // 写入数据
            for (DataVO data : pageData) {
                Row row = sheet.createRow(rowNum++);
                // 填充数据...
            }
            
            pageNum++;
            
            // 定期清理临时文件
            if (pageNum % 10 == 0) {
                workbook.dispose();
            }
        }
        
        // 响应输出
        workbook.write(response.getOutputStream());
        workbook.dispose();  // 清理临时文件
        workbook.close();
    }
}

难点2:复杂表头(多级表头)

java 复制代码
java

public void createComplexHeader(Sheet sheet) {
    // 创建第一级表头
    Row row1 = sheet.createRow(0);
    row1.createCell(0).setCellValue("基本信息");
    row1.createCell(2).setCellValue("订单信息");
    
    // 合并单元格
    sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 1));
    sheet.addMergedRegion(new CellRangeAddress(0, 0, 2, 4));
    
    // 创建第二级表头
    Row row2 = sheet.createRow(1);
    String[] headers = {"姓名", "年龄", "订单号", "金额", "状态"};
    for (int i = 0; i < headers.length; i++) {
        row2.createCell(i).setCellValue(headers[i]);
    }
}

难点3:单元格样式性能优化

java 复制代码
java

// 错误做法:每个单元格都创建新样式(内存浪费)
Cell cell = row.createCell(0);
CellStyle style = workbook.createCellStyle();
style.setAlignment(HorizontalAlignment.CENTER);
cell.setCellStyle(style);

// 正确做法:复用样式
public class StyleCache {
    private static Map<String, CellStyle> styleCache = new HashMap<>();
    
    public static CellStyle getStyle(Workbook workbook, String styleName) {
        return styleCache.computeIfAbsent(styleName, k -> {
            CellStyle style = workbook.createCellStyle();
            // 配置样式...
            return style;
        });
    }
}

// 使用
CellStyle style = StyleCache.getStyle(workbook, "header");
cell.setCellStyle(style);

难点4:导出进度监控

java 复制代码
java

@Component
public class ExportProgressMonitor {
    private final Map<String, ExportTask> taskMap = new ConcurrentHashMap<>();
    
    @Data
    public static class ExportTask {
        private String taskId;
        private int totalCount;
        private int processedCount;
        private String status; // RUNNING, SUCCESS, FAILED
        private String filePath;
    }
    
    public String startExport(ExportParams params) {
        String taskId = UUID.randomUUID().toString();
        ExportTask task = new ExportTask();
        task.setTaskId(taskId);
        task.setStatus("RUNNING");
        taskMap.put(taskId, task);
        
        // 异步执行导出
        CompletableFuture.runAsync(() -> {
            try {
                String filePath = doExport(params, task);
                task.setFilePath(filePath);
                task.setStatus("SUCCESS");
            } catch (Exception e) {
                task.setStatus("FAILED");
            }
        });
        
        return taskId;
    }
    
    public ExportTask getProgress(String taskId) {
        return taskMap.get(taskId);
    }
}

难点5:并发导出限流

java 复制代码
java

@Component
public class ExportRateLimiter {
    private final Semaphore semaphore = new Semaphore(3); // 最多3个并发
    
    public void executeWithLimit(Runnable exportTask) {
        if (!semaphore.tryAcquire(5, TimeUnit.SECONDS)) {
            throw new RuntimeException("系统繁忙,请稍后重试");
        }
        try {
            exportTask.run();
        } finally {
            semaphore.release();
        }
    }
}

// 使用
@Autowired
private ExportRateLimiter rateLimiter;

public void export(HttpServletResponse response) {
    rateLimiter.executeWithLimit(() -> {
        doExport(response);
    });
}

六、最佳实践总结

1. 性能优化

  • 大数据量使用SXSSFWorkbook

  • 复用CellStyle对象

  • 分页查询,分批写入

  • 使用try-with-resources确保资源释放

2. 内存管理

java 复制代码
java

// 及时清理临时文件
SXSSFWorkbook workbook = new SXSSFWorkbook(100);
try {
    // 导出操作
} finally {
    workbook.dispose();  // 清理磁盘临时文件
    workbook.close();    // 关闭工作簿
}

3. 异常处理

java 复制代码
java

try {
    // 导出逻辑
} catch (IOException e) {
    log.error("IO异常", e);
    response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
    response.getWriter().write("导出失败:" + e.getMessage());
} catch (Exception e) {
    log.error("导出异常", e);
    throw new BusinessException("导出失败");
}

4. 编码规范

  • 文件名使用URLEncoder.encode()防止中文乱码

  • 设置正确的ContentType

  • 添加导出日志记录

  • 权限控制(只有管理员可导出)

通过以上实现,苍穹外卖系统可以稳定、高效地处理各种Excel导出需求,既保证了功能完整性,又解决了POI常见的内存问题。

值得注意的是,在实际的项目中,我们不需要在后端自己创建表格,设置字体,大小,标题,分行等等功能,我们通常是实现有一个模板文件,通过IO流把数据写入这个模板文件中。

补充:

HttpServletResponse 详解

HttpServletResponse 是 Java Web 开发中的一个核心接口,用于封装服务器对客户端的响应。简单来说,它就是服务器给浏览器(或其他客户端)回复信息的"信使"。

一、核心作用

1. 设置响应状态码

告诉客户端请求处理的结果状态

java 复制代码
java

// 成功
response.setStatus(HttpServletResponse.SC_OK); // 200

// 资源未找到
response.setStatus(HttpServletResponse.SC_NOT_FOUND); // 404

// 服务器错误
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); // 500

// 重定向
response.sendRedirect("/login.html"); // 自动设置302状态码

2. 设置响应头(Header)

告诉浏览器如何处理返回的内容

java 复制代码
java

// 告诉浏览器返回的是图片
response.setHeader("Content-Type", "image/jpeg");

// 告诉浏览器下载文件(苍穹外卖导出Excel场景)
response.setHeader("Content-Disposition", "attachment;filename=report.xlsx");

// 禁用缓存(验证码场景)
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);

3. 设置响应内容类型和编码

告诉浏览器内容的格式和字符集

java 复制代码
java

// 设置返回HTML页面
response.setContentType("text/html;charset=UTF-8");

// 设置返回JSON数据
response.setContentType("application/json;charset=UTF-8");

// 设置返回Excel文件(苍穹外卖常用)
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

4. 获取输出流

向客户端写入实际的数据内容

java 复制代码
java

// 字符流 - 用于返回文本(HTML、JSON等)
PrintWriter writer = response.getWriter();
writer.write("<html><body>Hello</body></html>");

// 字节流 - 用于返回二进制数据(图片、Excel、PDF等)
ServletOutputStream outputStream = response.getOutputStream();
outputStream.write(excelData);

二、在苍穹外卖导出Excel中的完整应用

java 复制代码
java

@GetMapping("/export/turnover")
public void exportTurnoverReport(HttpServletResponse response) {
    try {
        // 1. 设置响应类型(告诉浏览器返回的是Excel文件)
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        
        // 2. 设置字符编码
        response.setCharacterEncoding("UTF-8");
        
        // 3. 设置响应头(告诉浏览器下载该文件,而不是直接打开)
        String fileName = URLEncoder.encode("营业额报表.xlsx", "UTF-8");
        response.setHeader("Content-Disposition", 
                          "attachment;filename=" + fileName);
        
        // 4. 禁用缓存(防止浏览器缓存文件)
        response.setHeader("Cache-Control", "no-cache");
        response.setHeader("Pragma", "no-cache");
        response.setDateHeader("Expires", 0);
        
        // 5. 获取输出流并写入Excel数据
        Workbook workbook = new XSSFWorkbook();
        // ... 创建Excel内容 ...
        
        // 将Excel写入响应流
        workbook.write(response.getOutputStream());
        workbook.close();
        
    } catch (IOException e) {
        // 异常处理
        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
    }
}

三、常见使用场景

场景1:返回JSON数据(API接口)

java 复制代码
java

@GetMapping("/user/{id}")
public void getUser(@PathVariable Long id, HttpServletResponse response) {
    User user = userService.getById(id);
    
    response.setContentType("application/json;charset=UTF-8");
    
    try {
        PrintWriter out = response.getWriter();
        out.write(JSON.toJSONString(user));
        out.flush();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

// 更优雅的方式:使用@ResponseBody
@GetMapping("/user/{id}")
@ResponseBody
public User getUser(@PathVariable Long id) {
    return userService.getById(id); // Spring自动处理响应
}

场景2:文件下载

java 复制代码
java

@GetMapping("/download/image")
public void downloadImage(@RequestParam String path, HttpServletResponse response) {
    try {
        // 读取文件
        File file = new File(path);
        
        // 设置响应头
        response.setContentType("image/jpeg");
        response.setContentLengthLong(file.length());
        response.setHeader("Content-Disposition", 
                          "attachment;filename=" + file.getName());
        
        // 写入输出流
        FileInputStream fis = new FileInputStream(file);
        ServletOutputStream os = response.getOutputStream();
        
        byte[] buffer = new byte[1024];
        int len;
        while ((len = fis.read(buffer)) != -1) {
            os.write(buffer, 0, len);
        }
        
        fis.close();
        os.flush();
        
    } catch (IOException e) {
        e.printStackTrace();
    }
}

场景3:生成验证码图片

java 复制代码
java

@GetMapping("/captcha")
public void getCaptcha(HttpServletResponse response) {
    // 禁用缓存
    response.setHeader("Cache-Control", "no-cache");
    response.setContentType("image/jpeg");
    
    // 生成验证码图片
    BufferedImage image = new BufferedImage(100, 40, BufferedImage.TYPE_INT_RGB);
    Graphics g = image.getGraphics();
    // ... 绘制验证码 ...
    
    try {
        // 输出图片
        ImageIO.write(image, "jpg", response.getOutputStream());
    } catch (IOException e) {
        e.printStackTrace();
    }
}

场景4:重定向

java 复制代码
java

@GetMapping("/old-url")
public void redirectOldUrl(HttpServletResponse response) {
    try {
        // 重定向到新地址
        response.sendRedirect("/new-url");
        
        // 或使用状态码+Header方式
        // response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
        // response.setHeader("Location", "/new-url");
        
    } catch (IOException e) {
        e.printStackTrace();
    }
}

四、核心方法总结

方法分类 方法名 作用
状态码 setStatus(int sc) 设置HTTP状态码
sendError(int sc) 发送错误状态码
sendRedirect(String location) 重定向(自动设置302)
响应头 setHeader(String name, String value) 设置响应头
addHeader(String name, String value) 添加响应头(可重复)
setContentType(String type) 设置内容类型
setCharacterEncoding(String charset) 设置字符编码
setContentLength(int len) 设置内容长度
setDateHeader(String name, long date) 设置日期类型响应头
输出流 getWriter() 获取字符输出流(返回文本)
getOutputStream() 获取字节输出流(返回二进制)
Cookie addCookie(Cookie cookie) 添加Cookie
其他 encodeURL(String url) URL编码(会话追踪)
containsHeader(String name) 是否包含指定响应头

五、注意事项

1. getWriter() 和 getOutputStream() 互斥

java 复制代码
java

// 错误:不能同时使用
response.getWriter();
response.getOutputStream(); // 抛出IllegalStateException

// 正确:只能选择其一
// 返回文本用getWriter()
PrintWriter writer = response.getWriter();

// 返回二进制文件用getOutputStream()
ServletOutputStream os = response.getOutputStream();

2. 必须在提交响应前设置Header

java 复制代码
java

// 正确顺序
response.setContentType("application/json");
response.setHeader("X-Custom", "value");
response.getWriter().write("data"); // 提交响应

// 错误:提交后设置无效
response.getWriter().write("data");
response.setHeader("X-Custom", "value"); // 无效

3. 文件下载时文件名中文乱码处理

java 复制代码
java

// 不同浏览器兼容处理
String fileName = "报表.xlsx";
String userAgent = request.getHeader("User-Agent");

if (userAgent.contains("MSIE") || userAgent.contains("Trident")) {
    // IE浏览器
    fileName = URLEncoder.encode(fileName, "UTF-8");
} else {
    // 其他浏览器
    fileName = new String(fileName.getBytes("UTF-8"), "ISO-8859-1");
}

response.setHeader("Content-Disposition", "attachment;filename=" + fileName);

4. 大文件下载注意内存

java 复制代码
java

// 错误:一次性读取到内存
byte[] data = Files.readAllBytes(Paths.get("large.zip"));
response.getOutputStream().write(data); // 内存溢出风险

// 正确:使用缓冲流分批写入
try (InputStream is = new FileInputStream("large.zip");
     OutputStream os = response.getOutputStream()) {
    byte[] buffer = new byte[8192];
    int len;
    while ((len = is.read(buffer)) != -1) {
        os.write(buffer, 0, len);
    }
    os.flush();
}

六、Spring MVC 中的简化

Spring MVC 提供了更简洁的方式,通常不需要直接操作 HttpServletResponse

java 复制代码
java

// 1. 返回JSON - 使用@ResponseBody
@GetMapping("/user")
@ResponseBody
public User getUser() {
    return userService.getUser();
}

// 2. 返回文件 - 使用ResponseEntity
@GetMapping("/download")
public ResponseEntity<byte[]> download() {
    byte[] data = generateExcel();
    
    return ResponseEntity.ok()
        .header("Content-Disposition", "attachment;filename=report.xlsx")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(data);
}

// 3. 返回视图 - 自动渲染 @GetMapping("/page") public String page() { return "index"; // 返回视图名称 }

七、总结

HttpServletResponse 是服务器向客户端发送响应的核心对象,主要功能:

  1. 告诉浏览器响应的状态(成功、失败、重定向等)

  2. 告诉浏览器返回内容的类型(HTML、JSON、Excel、图片等)

  3. 告诉浏览器如何处理返回内容(下载、直接显示、缓存等)

  4. 实际传输数据内容(通过输出流)

在苍穹外卖项目中,它主要用于:

  • 导出Excel报表(设置下载响应头,输出Excel文件)

  • 返回API数据(设置JSON格式,输出数据)

  • 文件上传后的预览(返回图片文件)

  • 登录验证码(输出图片流)

结语:

如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

相关推荐
A小码哥2 小时前
向cluade学习如何在实际项目中配置AI规则
前端·后端
毕设源码-朱学姐2 小时前
【开题答辩全过程】以 校园超市购物系统为例,包含答辩的问题和答案
java
zs宝来了2 小时前
Spring IoC 容器初始化全链路深度解析:从 BeanFactory 到 refresh() 的底层真相
java·后端·spring·ioc·源码解析·java后端
芒果披萨2 小时前
sql实操
数据库·sql·mysql
不剪发的Tony老师2 小时前
FlowScope:一款注重隐私的SQL数据血缘分析工具
数据库·sql·数据血缘
愤豆2 小时前
10-Java语言核心-JVM原理--字节码与执行引擎详解
java·jvm·python
番茄去哪了2 小时前
Retrofit框架调用第三方api
java·服务器·retrofit
后端不背锅2 小时前
Elasticsearch 实战指南:从入门到生产
后端