Java报表导出框架
一、为什么要写这篇博客
做 ToB SaaS 报表系统,最头疼的就是“运营小姐姐今天想看团队巡检明细,明天想看激励明细,后天又想看预警明细”。
如果每个需求都 copy-paste 一个 ExportController,很快就会收获一座“代码屎山”。
去年 11 月我们团队用 模板方法 + 策略 + 工厂 三板斧重构了导出模块,至今零事故、零回归、零加班。今天把设计思路与关键代码分享出来,希望能帮大家少掉几根头发。
二、整体思路一句话
把“不变的流程”固化在模板里,把“善变的数据”交给策略,把“路由”交给工厂。
三、核心代码走读(混淆后)
模板方法:ExportTemplateService
@Slf4j
@Service
public class ExportTemplateService<T extends PageParam, R> {@Value("${export.temp.dir:/tmp/export}")private String tempDir;public <S> ApiResp<S> export(DownloadPolicy<T, R, S> policy, T req) {R resp = policy.newResp();/* 1. 统一参数校验 */policy.checkReq(req);/* 2. 查询首屏数据,顺便拿总数 */PageResult<?> page = policy.query(req);if (page.getTotal() == 0) {policy.onEmpty(resp);return ApiResp.error("EMPTY_DATA");}if (page.getTotal() > 10_000) {policy.onOverflow(resp);return ApiResp.error("TOO_MUCH_DATA");}/* 3. 真正写文件 */File tmp = null;String fileId = null;try {tmp = File.createTempFile(policy.filePrefix(req), ".xlsx", new File(tempDir));ExcelWriter writer = EasyExcel.write(tmp, policy.dataClass(req)).registerWriteHandler(new LongestMatchWidthStrategy()).build();WriteSheet sheet = EasyExcel.writerSheet(policy.sheetName(req)).build();/* 3-1 写第一页 */writer.write(page.getRows(), sheet);/* 3-2 写剩余页 */for (int i = 2; i <= page.getPages(); i++) {req.setPage(i);writer.write(policy.query(req).getRows(), sheet);}writer.finish();/* 4. 上传到对象存储 */fileId = OssClient.upload(tmp);} catch (Exception e) {log.error("export err", e);policy.onError(resp, e.getMessage());} finally {FileUtils.deleteQuietly(tmp);}/* 5. 回填下载地址 */if (StringUtils.isNotBlank(fileId)) {policy.fill(resp, fileId, OssClient.genUrl(fileId));return ApiResp.ok(policy.toFinal(resp));}return ApiResp.error("EXPORT_FAIL");}
}
亮点
全流程 try-finally 保证临时文件必删。
分页写文件,内存占用 O(1)。
泛型
<T, R, S>
兼容所有业务请求/响应对象。
策略接口:DownloadPolicy
public interface DownloadPolicy<T, R, S> {R newResp(); // 创建响应壳void checkReq(T req); // 业务校验PageResult<?> query(T req); // 查询数据Class<?> dataClass(T req); // Excel 头模型String sheetName(T req); // 工作表名String filePrefix(T req); // 临时文件名前缀/* 钩子方法 */default void onEmpty(R resp) {}default void onOverflow(R resp) {}default void onError(R resp, String msg) {}default void fill(R resp, String fileId, String url) {}S toFinal(R resp); // 适配前端格式
}
亮点
所有钩子给了默认空实现,策略类按需 Override。
接口即文档,新人一眼看懂。
工厂 + 枚举:RouterFactory
@Component
public class RouterFactory {private final Map<ExportType, DataFetcher<?>> map = new ConcurrentHashMap<>();public RouterFactory(List<DataFetcher<?>> list) {list.forEach(f -> map.put(f.type(), f));}public PageResult<?> fetch(ExportType type, QueryParam q) {DataFetcher<?> fetcher = map.get(type);if (fetcher == null) throw new BizException("UNSUPPORTED_TYPE");return fetcher.query(q);}
}
枚举示例
@AllArgsConstructor
@Getter
public enum ExportType {TEAM_INSPECTION("TI", TeamInspectionVO.class),ITEM_DETAIL("ID", ItemDetailVO.class),REWARD_DETAIL("RD", RewardDetailVO.class);private final String code;private final Class<?> clazz;
}
亮点
Spring 启动时自动收集所有
DataFetcher
Bean,零配置。枚举集中管理 code ↔ clazz ↔ desc,拒绝魔法字符串。
业务策略示例:TeamInspectionFetcher
@Slf4j
@Service
public class TeamInspectionFetcher implements DataFetcher<TeamInspectionVO> {@Resourceprivate TeamInspectionMapper mapper;@Overridepublic ExportType type() {return ExportType.TEAM_INSPECTION;}@Overridepublic PageResult<TeamInspectionVO> query(QueryParam q) {return mapper.selectByPage(q);}
}
亮点
只关心“怎么拿数据”,其余全部交给模板与工厂。
四、可扩展性实战
需求 | 改动点 | 预计工时 |
---|---|---|
新增“退货明细”导出 | ① 新建 VO ② 新建 Fetcher ③ 枚举加一行 | 10 min |
导出上限调到 50,000 | 修改 ExportTemplateService 常量 | 30 s |
增加 CSV 格式 | 在模板里加策略分支 & 实现 CsvWriter | 2 h |
五、性能 & 稳定性小贴士
临时目录挂载 tmpfs,IO 提升 30%。
分页大小动态化:小数据 5 000/页,大数据 500/页,内存更稳。
对象存储异步上传 + WebSocket 推送,体验升级。
六、FAQ
Q:为什么不用 SpringBatch?
A:Batch 重调度、重作业,报表导出多是“即时点查即走”,模板方法足矣。
Q:EasyExcel 遇到超大文件 OOM?
A:我们阈值 10 k 行 + 分页写盘,生产跑了半年单实例 2 G 堆无压力。