Skip to content

Spring MVC 文档视图:PDF 和 Excel 生成完全指南 📄

在现代 Web 应用开发中,我们经常需要为用户提供多种格式的数据输出。虽然 HTML 页面是最常见的展示方式,但有时用户需要下载 PDF 报告或 Excel 表格来进行离线查看或进一步处理。Spring MVC 为我们提供了优雅的解决方案来动态生成这些文档格式。

为什么需要文档视图? 🤔

NOTE

想象一下,如果没有文档视图功能,我们需要手动处理文档生成、HTTP 响应头设置、内容类型配置等繁琐工作。Spring MVC 的文档视图将这些复杂性封装起来,让我们专注于业务逻辑。

常见业务场景

  • 财务报表:生成 PDF 格式的月度/年度财务报告
  • 数据导出:将数据库查询结果导出为 Excel 文件
  • 发票生成:为电商订单生成 PDF 发票
  • 统计报告:生成包含图表的综合分析报告

核心原理与设计哲学 💡

Spring MVC 的文档视图基于模板方法模式设计,核心思想是:

  1. 统一的视图接口:所有文档视图都实现相同的 View 接口
  2. 内容类型自动处理:框架自动设置正确的 Content-Type 响应头
  3. 流式输出:直接将生成的文档流式传输给客户端
  4. 模板方法抽象:提供抽象基类,开发者只需实现文档构建逻辑

PDF 视图实现 📋

依赖配置

首先,我们需要添加 OpenPDF 依赖:

kotlin
dependencies {
    implementation("com.github.librepdf:openpdf:1.3.30") 
    implementation("org.springframework.boot:spring-boot-starter-web")
}
xml
<dependency>
    <groupId>com.github.librepdf</groupId>
    <artifactId>openpdf</artifactId>
    <version>1.3.30</version> 
</dependency>

IMPORTANT

强烈推荐使用 OpenPDF 而不是过时的 iText 2.1.7,因为 OpenPDF 积极维护并修复了重要的安全漏洞。

创建 PDF 视图类

kotlin
import org.springframework.web.servlet.view.document.AbstractPdfView
import com.lowagie.text.Document
import com.lowagie.text.Paragraph
import com.lowagie.text.pdf.PdfWriter
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

/**
 * 销售报告 PDF 视图
 * 将销售数据渲染为 PDF 格式
 */
class SalesReportPdfView : AbstractPdfView() {

    override fun buildPdfDocument(
        model: Map<String, Any>, 
        doc: Document,           
        writer: PdfWriter,
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        // 从模型中获取销售数据
        val salesData = model["salesData"] as List<SaleRecord>
        val reportTitle = model["reportTitle"] as String
        
        // 添加标题
        doc.add(Paragraph(reportTitle).apply {
            alignment = Paragraph.ALIGN_CENTER
        })
        
        // 添加销售记录
        salesData.forEach { record ->
            doc.add(Paragraph("${record.productName}: ${record.amount}")) 
        }
        
        // 添加总计
        val total = salesData.sumOf { it.amount }
        doc.add(Paragraph("总计: $total").apply {
            alignment = Paragraph.ALIGN_RIGHT
        })
    }
}

// 数据类定义
data class SaleRecord(
    val productName: String,
    val amount: Double,
    val date: String
)

Controller 实现

kotlin
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.servlet.ModelAndView

@Controller
class ReportController {

    @GetMapping("/reports/sales.pdf")
    fun generateSalesReportPdf(): ModelAndView {
        // 模拟从数据库获取销售数据
        val salesData = listOf( 
            SaleRecord("笔记本电脑", 8999.0, "2024-01-15"),
            SaleRecord("无线鼠标", 199.0, "2024-01-16"),
            SaleRecord("机械键盘", 599.0, "2024-01-17")
        )
        
        // 准备模型数据
        val model = mapOf(
            "salesData" to salesData, 
            "reportTitle" to "2024年1月销售报告"
        )
        
        // 返回 PDF 视图
        return ModelAndView(SalesReportPdfView(), model) 
    }
}

TIP

你也可以通过视图名称来引用 PDF 视图,只需在配置中注册视图解析器即可。

Excel 视图实现 📊

依赖配置

kotlin
dependencies {
    implementation("org.apache.poi:poi:5.2.4") 
    implementation("org.apache.poi:poi-ooxml:5.2.4") 
}

创建 Excel 视图类

kotlin
import org.springframework.web.servlet.view.document.AbstractXlsxView
import org.apache.poi.ss.usermodel.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

/**
 * 用户数据 Excel 导出视图
 * 支持 .xlsx 格式,具有更好的兼容性
 */
class UserDataExcelView : AbstractXlsxView() {

    override fun buildExcelDocument(
        model: Map<String, Any>,
        workbook: Workbook, 
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        // 设置文件名
        response.setHeader("Content-Disposition", "attachment; filename=user_data.xlsx") 
        
        // 获取用户数据
        val users = model["users"] as List<User>
        
        // 创建工作表
        val sheet = workbook.createSheet("用户数据") 
        
        // 创建标题样式
        val headerStyle = workbook.createCellStyle().apply {
            fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
            fillPattern = FillPatternType.SOLID_FOREGROUND
            setBorderBottom(BorderStyle.THIN)
        }
        
        // 创建标题行
        val headerRow = sheet.createRow(0) 
        val headers = arrayOf("ID", "姓名", "邮箱", "注册日期")
        headers.forEachIndexed { index, header ->
            headerRow.createCell(index).apply {
                setCellValue(header)
                cellStyle = headerStyle
            }
        }
        
        // 填充数据行
        users.forEachIndexed { rowIndex, user ->
            val row = sheet.createRow(rowIndex + 1) 
            row.createCell(0).setCellValue(user.id.toDouble())
            row.createCell(1).setCellValue(user.name)
            row.createCell(2).setCellValue(user.email)
            row.createCell(3).setCellValue(user.registrationDate)
        }
        
        // 自动调整列宽
        for (i in 0 until headers.size) {
            sheet.autoSizeColumn(i) 
        }
    }
}

data class User(
    val id: Long,
    val name: String,
    val email: String,
    val registrationDate: String
)

流式 Excel 视图(大数据量优化)

当需要处理大量数据时,可以使用流式视图来优化内存使用:

kotlin
import org.springframework.web.servlet.view.document.AbstractXlsxStreamingView

/**
 * 大数据量 Excel 导出视图
 * 使用流式处理,适合大量数据导出
 */
class LargeDataExcelView : AbstractXlsxStreamingView() {

    override fun buildExcelDocument(
        model: Map<String, Any>,
        workbook: Workbook,
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        response.setHeader("Content-Disposition", "attachment; filename=large_data.xlsx")
        
        val sheet = workbook.createSheet("大数据")
        val dataProvider = model["dataProvider"] as DataProvider 
        
        // 创建标题行
        val headerRow = sheet.createRow(0)
        dataProvider.getHeaders().forEachIndexed { index, header ->
            headerRow.createCell(index).setCellValue(header)
        }
        
        // 流式处理数据
        var rowIndex = 1
        dataProvider.processData { record ->
            val row = sheet.createRow(rowIndex++)
            record.forEachIndexed { cellIndex, value ->
                row.createCell(cellIndex).setCellValue(value.toString())
            }
        }
    }
}

interface DataProvider {
    fun getHeaders(): List<String>
    fun processData(processor: (List<Any>) -> Unit) 
}

实际业务场景示例 🏢

电商订单发票生成

完整的订单发票 PDF 生成示例
kotlin
/**
 * 订单发票 PDF 视图
 * 生成包含订单详情、客户信息和计费明细的发票
 */
class OrderInvoicePdfView : AbstractPdfView() {

    override fun buildPdfDocument(
        model: Map<String, Any>,
        doc: Document,
        writer: PdfWriter,
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        val order = model["order"] as Order
        val customer = model["customer"] as Customer
        
        // 设置文件名
        response.setHeader("Content-Disposition", 
            "attachment; filename=invoice_${order.orderNumber}.pdf")
        
        // 发票标题
        doc.add(Paragraph("发票", Font(Font.HELVETICA, 18, Font.BOLD)).apply {
            alignment = Paragraph.ALIGN_CENTER
        })
        
        // 客户信息
        doc.add(Paragraph("\n客户信息:"))
        doc.add(Paragraph("姓名: ${customer.name}"))
        doc.add(Paragraph("地址: ${customer.address}"))
        doc.add(Paragraph("电话: ${customer.phone}"))
        
        // 订单信息
        doc.add(Paragraph("\n订单信息:"))
        doc.add(Paragraph("订单号: ${order.orderNumber}"))
        doc.add(Paragraph("订单日期: ${order.orderDate}"))
        
        // 创建商品明细表格
        val table = Table(4)
        table.addCell("商品名称")
        table.addCell("数量")
        table.addCell("单价")
        table.addCell("小计")
        
        order.items.forEach { item ->
            table.addCell(item.productName)
            table.addCell(item.quantity.toString())
            table.addCell("¥${item.price}")
            table.addCell("¥${item.quantity * item.price}")
        }
        
        doc.add(table)
        
        // 总计
        doc.add(Paragraph("\n总计: ¥${order.totalAmount}", 
            Font(Font.HELVETICA, 12, Font.BOLD)))
    }
}

data class Order(
    val orderNumber: String,
    val orderDate: String,
    val items: List<OrderItem>,
    val totalAmount: Double
)

data class OrderItem(
    val productName: String,
    val quantity: Int,
    val price: Double
)

data class Customer(
    val name: String,
    val address: String,
    val phone: String
)

数据分析报表导出

kotlin
@RestController
class AnalyticsController {

    @GetMapping("/analytics/export")
    fun exportAnalytics(
        @RequestParam startDate: String,
        @RequestParam endDate: String,
        @RequestParam format: String
    ): ModelAndView {
        
        // 查询分析数据
        val analyticsData = analyticsService.getAnalytics(startDate, endDate) 
        
        val model = mapOf(
            "analyticsData" to analyticsData,
            "dateRange" to "$startDate$endDate",
            "generatedAt" to LocalDateTime.now().toString()
        )
        
        return when (format.lowercase()) {
            "pdf" -> ModelAndView(AnalyticsPdfView(), model) 
            "excel" -> ModelAndView(AnalyticsExcelView(), model) 
            else -> throw IllegalArgumentException("不支持的格式: $format") 
        }
    }
}

最佳实践与优化建议 ⚡

1. 性能优化

WARNING

生成大型文档可能消耗大量内存和CPU资源,需要合理优化。

kotlin
/**
 * 优化的文档视图基类
 * 包含通用的性能优化措施
 */
abstract class OptimizedDocumentView : AbstractPdfView() {
    
    companion object {
        private val logger = LoggerFactory.getLogger(OptimizedDocumentView::class.java)
    }
    
    override fun buildPdfDocument(
        model: Map<String, Any>,
        doc: Document,
        writer: PdfWriter,
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        val startTime = System.currentTimeMillis()
        
        try {
            // 设置缓存控制
            response.setHeader("Cache-Control", "private, max-age=3600") 
            
            // 执行具体的文档构建
            buildDocument(model, doc, writer, request, response)
            
        } finally {
            val duration = System.currentTimeMillis() - startTime
            logger.info("文档生成耗时: ${duration}ms") 
        }
    }
    
    abstract fun buildDocument(
        model: Map<String, Any>,
        doc: Document,
        writer: PdfWriter,
        request: HttpServletRequest,
        response: HttpServletResponse
    )
}

2. 错误处理

kotlin
@ControllerAdvice
class DocumentExceptionHandler {

    @ExceptionHandler(DocumentException::class)
    fun handleDocumentException(ex: DocumentException): ResponseEntity<String> {
        logger.error("文档生成失败", ex) 
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body("文档生成失败,请稍后重试")
    }
}

3. 配置视图解析器

kotlin
@Configuration
class ViewConfiguration {

    @Bean
    fun pdfViewResolver(): BeanNameViewResolver {
        return BeanNameViewResolver().apply {
            order = 1
        }
    }
    
    @Bean("salesReportPdf")
    fun salesReportPdfView(): SalesReportPdfView {
        return SalesReportPdfView() 
    }
    
    @Bean("userDataExcel")
    fun userDataExcelView(): UserDataExcelView {
        return UserDataExcelView() 
    }
}

总结 🎯

Spring MVC 的文档视图功能为我们提供了强大而灵活的文档生成能力:

核心优势

  • 统一抽象:通过抽象基类简化文档生成逻辑
  • 自动处理:框架自动处理 HTTP 响应头和内容类型
  • 灵活扩展:可以轻松支持新的文档格式
  • 性能优化:支持流式处理大量数据

通过合理使用这些功能,我们可以轻松为用户提供丰富的文档导出体验,满足各种业务场景的需求。记住,选择合适的文档库版本和优化策略对于生产环境的性能至关重要! 🚀