复用为王:四个 Excel 导出端点的快速搭建

为广东水文项目的一站一档与地下水模块新增 4 个 Excel 导出端点,覆盖墒情、咸情、地下水统计与地下水监测四种数据。整个过程没引入任何新依赖,完全复用项目已有的 @Excel 注解 + ExcelUtil.exportExcelToBytes() 模式 —— 写得越像现有代码,维护成本就越低。

🎧 文章导读

🎵 背景音乐

前言

水文监测系统的前端页面早就支持站点数据的在线查询,但用户一直没法把这些数据下载下来。本次迭代要补的就是这个短板 —— 在一站一档和地下水两个模块各加几个导出接口,让墒情、咸情、地下水统计和地下水监测的数据能直接落地成 Excel 文件。

Excel 导出功能整体架构
图1:四个导出端点的模块归属与数据流向

需求背景

监测页面上的数据列表只能在线浏览,有两个痛点一直没解决:

  • 现场调查没网络,工程师到野外站点检查时,需要带一份离线 Excel
  • 上级汇报要数据,把表格直接贴进 Word 或 PPT,远比”打开页面截图”高效

需求来源是前端组提出来的,优先级中等。四个端点分别对应四种数据:

端点 归属模块 导出字段
GET /basic/station/archive/soil/{stcd}/export 一站一档 时间、10cm/20cm/40cm 含水量
GET /basic/station/archive/salinity/{stcd}/export 一站一档 时间、含氯度
GET /api/groundwater/stations/{stcd}/statistics/export 地下水 时间、深埋(支持 day/month/year)
GET /api/groundwater/stations/{stcd}/waterLevel/export 地下水 时间、深埋

设计思考

项目里已经存在一套成熟的 Excel 导出模式:

  1. 创建带 @Excel 注解的 VO 类,声明列名、宽度、排序
  2. 调用业务层已有方法拿数据
  3. 转换为 VO 列表,用 ExcelUtil.exportExcelToBytes() 生成字节流
  4. 通过 FileUtils.setAttachmentResponseHeader() 写 HTTP 响应

水情监测模块就是这套范式,跑得稳稳的。所以本次决策很明确 —— 完全复用,不引入任何新东西

[!tip] 一致性带来的红利
风格统一、维护成本低,团队成员看一眼就知道在做什么。代价是如果将来需求复杂化(模板导出、大数据量流式写入),可能要换方案 —— 但当前没必要为这种未来需求买单。

在 VO 的设计上做了一个取舍:**地下水统计导出和监测导出共享同一个 GwStatsExportVO**。语义上它们是两种不同的数据,但导出字段都是”时间 + 深埋”两列,完全一样。为了”概念纯粹”维护两个一模一样的类是 over-engineering。反过来,墒情(三层土壤含水量)和咸情(含氯度)字段差异明显,各自独立 VO 才合理。

接口归属上,墒情/咸情放在 StationArchiveController,地下水两个放在 GroundwaterController,遵循”谁的数据谁负责“原则,而不是把所有导出集中到一个 Controller。

VO 设计与字段映射
图2:三个导出 VO 的字段映射关系

实现方案

导出 VO 设计

VO 只包含需要进入 Excel 的字段,通过 @Excel 注解描述列。以最简洁的咸情导出 VO 为例:

1
2
3
4
5
6
7
8
9
10
@Data
public class SalinityExportVO implements Serializable {
private static final long serialVersionUID = 1L;

@Excel(name = "时间", width = 20, sort = 1)
private String time;

@Excel(name = "含氯度(mg/L)", width = 14, sort = 2)
private BigDecimal salinity;
}

两个细节值得记一下:

  • time 用 String 而不是 Date —— 上游业务方法返回的就是格式化后的字符串,直接透传,避免二次格式化引入的时区问题
  • sort 显式声明列顺序 —— 不依赖字段声明顺序,无论代码怎么改,输出列顺序都稳定

端点核心逻辑

四个端点的代码结构几乎完全一致,以咸情导出为代表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void exportSalinity(@PathVariable String stcd,
MonitoringDataQueryDTO query, HttpServletResponse response) {
query.setPaginated(false); // 关闭分页,导出全部数据
SalinityResponseDTO data = stationArchiveBiz.getSalinity(stcd, query);

if (data.getDataList() == null || data.getDataList().isEmpty()) {
writeErrorResponse(response, "未查询到可导出的数据",
HttpServletResponse.SC_NOT_FOUND);
return;
}
// 转换为 SalinityExportVO 列表
byte[] excelBytes = util.exportExcelToBytes(exportList, "咸情数据");
writeExcelResponse(response, excelBytes, fileName);
}

关键就在 query.setPaginated(false) 这一行。复用的业务方法本身是支持分页的,通过这个标志位告诉它”全量返回”,既复用了查询逻辑,又避免了导出被分页截断。这是最便宜的复用方式 —— 改一个参数,而不是重写一套查询。

错误处理三层结构

场景 HTTP 状态 处理位置
查询无数据 404 Controller 显式判断
参数非法(如 type 不属于 day/month/year) 400 Controller 显式校验
服务端异常 500 全局兜底

所有错误响应统一为 JSON 格式 {"msg":"...","code":...},前端可以一套逻辑处理全部错误场景。

遇到的几个小坑

[!bug] writeExcelResponse 参数顺序写反
第一次写墒情导出时,把参数传成了 (response, fileName, excelBytes),实际签名是 (response, excelBytes, fileName)。方法名暗示”往 response 里写 excel”,字节流应该在前。编译直接报错,所以定位很快 —— 类型不同的参数,放反了至少编译会救你一命

[!warning] mvn clean compile 在 Windows 下偶尔卡死
target 目录下的 banner.txt 有时被进程占着文件锁,clean 删不掉就报错。解决办法:mvn compile(不 clean),因为编译本身是增量的,class 文件能正常更新,不需要先删整个 target。

[!info] 编译完必须重启应用
看似常识,但快速迭代时容易忘:改完代码必须重启 JVM 才能让新逻辑生效,否则会陷入”代码改了,行为没变”的困惑里反复怀疑人生。

测试验证流程
图3:四个端点的测试覆盖矩阵

测试与验证

用 curl 打本地接口(端口 8086),覆盖正常和异常两类场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 墒情导出 - 站 80100600
curl -O "http://localhost:8086/basic/station/archive/soil/80100600/export"
# → 200, Excel 6.8KB, 三层含水量数据完整

# 咸情导出 - 站 81200250(大盛站,数据量大)
curl -O "http://localhost:8086/basic/station/archive/salinity/81200250/export"
# → 200, Excel 44KB

# 地下水日统计 - 站 81761001
curl -O "http://localhost:8086/api/groundwater/stations/81761001/statistics/export?type=day"
# → 200, Excel 4.2KB(月、年统计也分别测了)

# 无数据场景 - 不存在的站编码
curl "http://localhost:8086/basic/station/archive/soil/99999999/export"
# → 404, {"msg":"未查询到可导出的数据","code":404}

经验总结

复盘下来,这次开发的几个收获:

  1. 找到已有模式比创造新模式更值得 —— 复用 @Excel + ExcelUtil 让 4 个端点的实现成本变得几乎线性可加,出问题的排查方式都一样
  2. 共享 VO 的边界要看导出字段而不是业务语义 —— GwStatsExportVO 同时承担统计和监测两种场景,因为它们的字段完全一致;而墒情和咸情字段不同,就老老实实写两个 VO
  3. 复用业务方法的开关参数(如 paginated)是最便宜的扩展点 —— 不写新方法,只切换参数,就能把分页查询变成全量导出
  4. 错误响应格式统一(都是 {msg, code})能极大降低前端处理成本

后续可以考虑的方向

  • 数据量上升到几万条时,引入 SXSSF 流式写入,避免内存溢出
  • 前端加导出进度提示,虽然现在数据量不大,但体验更友好
  • 把通用导出逻辑抽到一个 ExportSupport 工具类里,减少 controller 里的样板代码

结语

这次迭代的核心不是”造轮子”,而是”在正确的位置上重用已有的轮子”。四个端点的代码结构高度一致,这种一致性本身就是项目长期可维护性的一部分。