背景:
老后台系统的数据记录导出Excel功能被财务,运营吐槽难用有时候甚至用不了,我负责重构老后台系统代码,刚好把Excel导出功能重新优化设计一下。接下来我会分析当前问题,针对优化性能,进行代码分层解藕实现优雅封装 尽可能在提高10秒同步流返回最大的记录条数
关键字: ES滚动查询,读写分离,阻塞队列,生产消费模型,工厂模式与策略接口,抽象模板与资源控制
原代码逻辑
java
public ResponseEntity export(PrizeDto param) {
param.setPageNum(1);
param.setPageSize(10000);
ESPageEntity<ESPrizeVo> result = null;
try {
result = esUtil2.query(authCode, indexName, fuzzyQueryOrder, fuzzyQueryOrderVersion, param, ESPrizeVo.class);
} catch (Throwable ex) {
log.error(ex.getMessage(), ex);
}
ExcelUtil<ESPrizeVo> util = new ExcelUtil<>(ESPrizeVo.class);
List<ESPrizeVo> list = JSON.parseArray(JSON.toJSONString(result.getList()), ESPrizeVo.class);
return util.exportExcel(list, "数据记录", null);
}
//util.exportExcel 基本与若以框架自带的ExcelUtils一致
这一看,代码确实简单粗暴,因为数据是存在ES库中的,公司的ES库 分页搜索最多支持到第一万条数据,所以代码简单粗暴的直接一次查1w条
缺点可以说相当大,首先导出数据不支持一万条以上的数量,第二就是整个效率低下,查询与写记录是同步阻塞。第三就是内存还有待优化,一个list 最大情况下会保存一万条记录。
光1W条记录导出用了15s,效率实在太低


用户的诉求是希望能够导出2W条左右的数据,接下来开始进行优化分析
优化点:
- 首先要突破1W条记录数量限制需要使用ES的滚动查询,同时需要确定好没次滚动的数量,太长会导致单次查询较慢,同时list元素过多占用堆内存过多,滚动数量太少则会增加滚动次数,整体效率慢
2.优化整体效率,因为使用了滚动查询,那么可以进行读写分离,每次滚动的数据交给异步线程池进行异步的写Excel。
3.优化内存空间占用,这里主要涉及到读写分离时中间的缓冲池的容量大小,这个容量最好能过做到写数据不需要空等待数据进来,生产数据不会出现缓存池满了阻塞的情况。还有一个写Excel操作的配置,配置SXSSFWorkbook
的 rowAccessWindowSize
属性为每次滚动查询的大小,同时我还优化了ES的查询模板,减少了运营与财务不需要的数据字段,大幅降低了单条数据的大小

整体设计:
先上架构图:

- 可以看到我通过数据范围的大小分为了两种处理器,一种是同步流返回处理器,面对数据量较小范围的数据进行同步导出,而数据量较大的则需要完全异步后端处理,前端再轮训或者后端主动通知的方法告知其文件下载。(1.5w 其实可以调整至5w,能过实现5秒处理)
- 这两种处理器的处理逻辑其实是类似的,全部都采用了生产消费模型,实现读写异步的功能。在代码上封装统一提交任务与消费数据两个主要功能。
代码细节:
ES 滚动查询封装:
-
es滚动主要分为三个步骤,第一个步骤是 发送第一次滚动查询请求,ES内部会生成一份查询数据快照。API:
POST /<index_name>/_search?scroll=<timeout>
timeout
是这个快照保存的时间,可以设置为1m 即一分钟,请求体需要有一个size,即每次滚动的大小。请求返回一个scrollID,以及第一次滚动查询的数据!json{ "size": 100, // 每次滚动返回的文档数量 "query": { "match_all": {} // 你的查询条件,可以是任何复杂的查询 }, "sort": ["_doc"] // 【最佳实践】使用 `_doc` 排序以获得最高效的检索性能 }
-
第二个步骤是开始滚动查询数据,API:
POST /_search/scroll
你需要使用第一步返回的scrollId 作为请求体去请求,不需要携带查询模板。这个步骤会再返回一个新的scrollId,然后在用新的scrollID 重复步骤二即开始滚动数据,需要注意此步骤不可并发,即使有些获取的scrollId是相同的。json{ "scroll": "1m", // 【重要】可以重新设置 scroll 的超时时间,重置倒计时 "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ==" }
-
删除查询快照,在滚动获取数据为空的时候需要去请求ES删除这次的查询快照,如果不请求,那么会在设置的timeout超时时间后自动删除。API:
DELETE /_search/scroll
封装初始化滚动查询,以及后续的滚动,使用TransmittableThreadLocal存储scrollId
java
public class ESScrollUtil extends ESUtil implements EsSearch {
@Value("${es.xxx.order.authCode}")
private String authCode;
@Value("${es.xxx.order.indexName}")
private String indexName;
public static final TransmittableThreadLocal<String> localScrollId = new TransmittableThreadLocal<>();
// 每次滚动完进行TL清除
@Override
public void cleanLocalScrollId() {
localScrollId.remove();
}
public <R extends BaseESResult> ScrollRes<R> getFirstScrollId(Class<R> clazz, String templateName, String templateVersion, Object body) {
ParameterizedTypeImpl type = new ParameterizedTypeImpl(new Type[]{clazz}, null, ESPageEntity.class);
ScrollRes<R> baseESResultScrollRes = super.queryForScroll(authCode, indexName, templateName, templateVersion, body, type);
localScrollId.set(baseESResultScrollRes.getScrollId());
return baseESResultScrollRes;
}
public <R extends BaseESResult> ScrollRes<R> nextScrollPage(Class<R> clazz){
if (StringUtilsExt.isEmpty(localScrollId.get())) {
log.error("请先调用getFirstScrollId方法获取scrollId");
throw new CustomException("请先调用getFirstScrollId方法获取scrollId");
}
ParameterizedTypeImpl type = new ParameterizedTypeImpl(new Type[]{clazz}, null, ESPageEntity.class);
ScrollRes<R> res = super.queryForScrollNextPage(authCode, indexName, localScrollId.get(), type);
localScrollId.set(res.getScrollId());
return res;
}
}
这样在业务处理代码只需要执行 getFirstScrollId 然后 while 执行 nextScrollPage 不需要处理scrollId赋值的问题
基本Excel导出处理夫类,封装滚动任务执行以及Excel写入任务,内部使用阻塞队列实现生产消费模型
java
@Slf4j
public class BaseExcelExporter {
// clazz缓存, key为类名, value为字段名和注解的map
protected static final HashMap<Class<?>, List<Excel>> cacheExcelInfo = new HashMap<>();
// 解析Excel缓存, key为 className + "_" + 注解name, value为字段
protected static final HashMap<String,Field> cacheField = new HashMap<>();
protected static ExecutorService executorService;
static {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
// 两倍最大并发数满足了最大处理情况(单次oss处理器需要两个线程),需要使用包装Ttl线程池,继承ScrollID
threadPoolTaskExecutor.setCorePoolSize(MAX_CONCURRENCY*2);
threadPoolTaskExecutor.setMaxPoolSize(MAX_CONCURRENCY*2);
threadPoolTaskExecutor.setQueueCapacity(0);
threadPoolTaskExecutor.setThreadNamePrefix("excel-exporterThread-");
threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
threadPoolTaskExecutor.initialize();
executorService = TtlExecutors.getTtlExecutorService(threadPoolTaskExecutor.getThreadPoolExecutor());
}
/**
* 提交ES查询任务,将查询结果放入阻塞队列,异常或查询数据为空时将空数据放入队列
* @param task 滚动查询任务
* @param taskResQueue 结果阻塞队列
* @param <R> 结果类型
*/
protected <R extends BaseESResult> void commitEsQueryTask(Supplier<ScrollRes<R>> task,
BlockingQueue<List<R>> taskResQueue){
try {
executorService.submit(() -> {
try {
for (;;){
ScrollRes<R> taskResult = task.get();
List<R> res = taskResult.getRes();
if(res.isEmpty()){
log.debug("任务已完成,退出循环");
taskResQueue.put(new ArrayList<>());
return;
}
taskResQueue.put(res);
}
}catch (Exception e) {
log.error("ES滚动查询出现异常", e);
}
});
} catch (RejectedExecutionException e) {
log.error("Excel导出线程池已满,无法提交任务", e);
throw e;
}
}
protected <R extends BaseESResult> void syncWriteTaskResult(String handlerName, Class<R> clazz,
BlockingQueue<List<R>> dataQueue,
Sheet sheet, CellStyle dateCellStyle){
long start;
try {
for (;;){
List<R> taskRes = dataQueue.poll(9, TimeUnit.SECONDS);
if(taskRes == null){
log.error("【{}】获取阻塞队列数据超时! 线程退出阻塞等待", handlerName);
break;
}
log.info("主线程获取到阻塞队列数据,数据大小:{} 当前队列剩余数据批数:{}",taskRes.size(), dataQueue.size());
// 获取的list为空则结束了(可能任务执行异常了)
if(taskRes.isEmpty()){
log.info("Excel导出任务已完成,主线程退出循环");
break;
}
// 写入数据
start = System.currentTimeMillis();
writeRows(sheet, taskRes, clazz, dateCellStyle);
log.info("主线程写入数据耗时:{} ms", System.currentTimeMillis() - start);
}
} catch (InterruptedException e) {
log.error("【{}】 消费线程被中断,线程池繁忙", handlerName, e);
throw new CustomException("系统导出繁忙中,请稍后重试");
}
}
public void analysisExcelAnno(Class<?> clazz){
List<Excel> excels = cacheExcelInfo.computeIfAbsent(clazz, k -> new ArrayList<>());
//获取字段上的注解
Field[] declaredFields = clazz.getDeclaredFields();
for (Field declaredField : declaredFields) {
declaredField.setAccessible(true);
Excel excel = declaredField.getAnnotation(Excel.class);
if (excel != null) {
excels.add(excel);
cacheField.put(clazz.getSimpleName()+"_"+excel.name(), declaredField);
}
}
}
/**
* 获取excel表头, 值为 {@link Excel} 注解的 name 属性, 顺序与字段顺序一致
* 如果是第一次导出该类则会进行 {@link #analysisExcelAnno(Class)} 解析
* @param clazz 待导出的类
* @return 表头
*/
public String[] getExcelHeader(Class<?> clazz){
List<Excel> excels = cacheExcelInfo.getOrDefault(clazz, null);
if (excels == null) {
analysisExcelAnno(clazz);
excels = cacheExcelInfo.get(clazz);
}
return excels.stream().map(Excel::name).toArray(String[]::new);
}
public Field getFieldByExcelName(Class<?> clazz, String name){
Field field = cacheField.get(clazz.getSimpleName()+"_"+name);
if(field == null){
throw new CustomException("未找到对应的字段,请检查是否执行analysisExcelAnno(clazz) 过该类");
}
return field;
}
/**
* 写入数据行,需要注意写入后list数据已被清空,无法再次使用
* @param sheet 工作表
* @param data 待写入数据
* @param clazz 数据类型
* @param dateCellStyle 单元格样式
*/
public void writeRows(Sheet sheet, List<?> data, Class<?> clazz, CellStyle dateCellStyle){
String[] headers = getExcelHeader(clazz);
// 获取当前行数,判断是否需要创建表头
int currentRowNum = sheet.getPhysicalNumberOfRows();
// 如果当前行数为 0,说明表头还未创建,需要创建表头
if (currentRowNum == 0) {
Row headerRow = sheet.createRow(0); // 第 0 行作为表头行
for (int i = 0; i < headers.length; i++) {
// 创建表头单元格,值为注解的 name 属性
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
// 设置每个列的宽度
Excel excel = cacheExcelInfo.get(clazz).get(i);
sheet.setColumnWidth(i, (int) ((excel.width() + 0.72) * 256));
}
currentRowNum++; // 表头占用第 0 行,所以数据从第 1 行开始
}
// 写入数据行
for (Object item : data) {
Row dataRow = sheet.createRow(currentRowNum++); // 从当前行开始写入数据
for (int j = 0; j < headers.length; j++) {
Cell cell = dataRow.createCell(j); // 创建每列单元格
Field field = getFieldByExcelName(clazz,headers[j]); // 根据表头名称获取字段
try {
Object value = field.get(item); // 获取字段值
if (value instanceof Date) {
// 如果是日期类型,设置日期值和样式
cell.setCellValue((Date) value);
cell.setCellStyle(dateCellStyle);
} else {
// 其他类型直接转换为字符串
cell.setCellValue(value == null ? "" : value.toString());
}
} catch (IllegalAccessException e) {
log.error("获取字段值异常", e);
throw new CustomException("获取字段值异常");
}
}
}
// 显式清空辅助jvm回收
data.clear();
}
}
Excel导出接口,对Excel handler 的抽象接口
java
public interface ExcelExportHandler {
// 导出接口,执行此方法即完成了excel导出
public <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, ScrollRes<R> firstScroll, Supplier<ScrollRes<R>> nextScroll, SXSSFWorkbook workbook, CellStyle dateCellStyle);
// 判断该处理器是否支持处理该滚动查询,这里通过res的count即文档数量来选择同步返回流处理器还是Oss异步处理器
public Boolean support(ScrollRes<?> scrollRes);
}
同步流Excel导出处理器-简单导出处理器
java
/**
* 简单Excel导出处理器,支持导出数据量小于40000的数据。
* 主线程写入数据,子线程请求ES进行滚动查询,提高导出效率。
*/
@Service
@Slf4j
public class SampleExportHandler extends BaseExcelExporter implements ExcelExportHandler {
@Override
public <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, ScrollRes<R> firstScroll,
Supplier<ScrollRes<R>> nextScroll, SXSSFWorkbook workbook,
CellStyle dateCellStyle) {
ServletOutputStream outputStream = null;
try {
Sheet sheet = workbook.createSheet("Sheet1");
// 设置一个较小值,size 即为缓冲池的滚动批次大小,这里最多允许两个滚动批次结果存在队列中
BlockingQueue<List<R>> dataQueue = new LinkedBlockingQueue<>(2);
// 提交Es滚动查询任务至线程池中
commitEsQueryTask(nextScroll, dataQueue);
// 后写入第一次滚动的数据
writeRows(sheet, firstScroll.getRes(), clazz, dateCellStyle);
// 主线程消费阻塞队列的写入数据
syncWriteTaskResult("SampleExcel导出处理器",clazz, dataQueue, sheet, dateCellStyle);
HttpServletResponse response = getResponse();
// 获取输出流
outputStream = response.getOutputStream();
// 将工作簿写入输出流
workbook.write(outputStream);
// 刷新输出流
outputStream.flush();
return new ExcelHandleRes(true);
} catch (Exception e){
log.error("导出 Excel 时发生异常",e);
return new ExcelHandleRes(false);
}
finally {
if(outputStream != null){
try {
outputStream.close();
} catch (Exception e) {
log.error("outputStream 关闭时发生异常, ", e);
}
}
}
}
private HttpServletResponse getResponse() {
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
try {
response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode("export.xlsx", "UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
return response;
}
@Override
public Boolean support(ScrollRes<?> scrollRes) {
return scrollRes.getCount() <= 40000;
}
}
两个处理器的代码是相似的,这里就不重复写了。小细节是先去提交滚动查询任务至线程池中然后再去写入第一次滚动数据,如果顺序相反则第一次写入数据是阻塞读任务的。
导出工厂,通过第一次滚动查询的结果数量 调用excelExportHandler.support 找到合适的处理器
java
@Component
public class ExcelExportHandlerFactory {
@Autowired
private List<ExcelExportHandler> handlers;
public ExcelExportHandler buildHandler(ScrollRes<?> scrollRes) {
for (ExcelExportHandler handler : handlers) {
if (handler.support(scrollRes)) {
return handler;
}
}
return null;
}
}
Exccel 导出工具, 直接用于service 调用
java
public class LargeExcelUtil implements ApplicationContextAware {
private static ExcelExportHandlerFactory excelExportHandlerFactory;
public static final int MAX_CONCURRENCY = 2;
// 最大并发数量
private static final AtomicInteger atomicInteger = new AtomicInteger(MAX_CONCURRENCY);
/**
* 一定要设置分页大小!
* @param clazz 返回结果的类型,必须使用 {@link Excel} 注解
* @param esSearch es查询工具类
* @param templateName es查询模板名称
* @param templateVersion es查询模板版本
* @param body es查询参数
* @return
* @param <R>
*/
public static <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, EsSearch esSearch, String templateName, String templateVersion, Object body, Integer pageSize) {
if(atomicInteger.decrementAndGet() < 0){
atomicInteger.incrementAndGet();
throw new CustomException("系统导出繁忙中,请稍后重试");
}
// 通过第一次滚动获取scrollID以及滚动数据量再通过数据量获取处理器来执行导出
ScrollRes<R> firstScrollId = esSearch.<R>getFirstScrollId(clazz, templateName, templateVersion, body);
ExcelExportHandler excelExportHandler = excelExportHandlerFactory.buildHandler(firstScrollId);
if (excelExportHandler == null) {
throw new RuntimeException("待导出数据太多了,暂不支持的导出");
}
SXSSFWorkbook workbook = null;
try {
// 创建 SXSSFWorkbook工作簿,内存最大保存pageSize个数据, 开启压缩临时文件减少磁盘空间,但不要开启使用共享字符会提高一倍多的写入时间
workbook = new SXSSFWorkbook(null,pageSize,true,false);
CellStyle dateCellStyle = workbook.createCellStyle();
// 格式化日期数据
CreationHelper creationHelper = workbook.getCreationHelper();
dateCellStyle.setDataFormat(creationHelper.createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss"));
return excelExportHandler.export(clazz, firstScrollId, () -> esSearch.nextScrollPage(clazz), workbook, dateCellStyle);
}
finally {
// 因为使用了localStorage存储scrollID,使用完后必须清除
esSearch.cleanLocalScrollId();
atomicInteger.incrementAndGet();
try {
if(workbook != null){
// 先尝试关闭工作簿同时释放了内存与临时文件
workbook.close();
}
} catch (IOException e) {
log.error("SXSSFWorkbook close 失败,错误信息", e);
}finally {
if(workbook != null){
// 如果关闭工作簿失败则释放临时文件
workbook.dispose();
}
}
}
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
excelExportHandlerFactory = applicationContext.getBean(ExcelExportHandlerFactory.class);
}
}
实现效果
机器2C4G 每次滚动数量2000 时 写入 与 读取的耗时比较接近,下图中ES滚动查询 平均比写入多大概50ms

在导出4W条数据时,接口总耗时6911ms

总结 (偷个懒)
-
突破数据量限制 - 引入 ES 滚动查询 (Scroll API)
-
动作:替换原有的简单分页查询,采用 ES 滚动查询机制。
-
关键设计:
- 创建查询快照,保证数据一致性。
- 分批次获取数据,通过
size
参数控制每批数据量(如 2000 条),平衡单次查询速度和网络请求次数。 - 使用
TransmittableThreadLocal
在线程池间安全地传递滚动 ID (scroll_id
),优雅地封装了查询细节。
-
-
提升处理效率 - 生产消费模型 & 异步化
-
动作 :将 数据查询(生产) 和 Excel 写入(消费) 分离。
-
关键设计:
- 生产者 :一个后台线程专门负责执行滚动查询,将获取到的数据批次放入一个阻塞队列 (
BlockingQueue
)。 - 消费者:主线程(或另一个消费者线程)从队列中取出数据批次,异步写入 Excel。
- 效益:读写操作并行,消除了查询等待写入的阻塞时间,极大提升了 CPU 和 I/O 利用率。
- 生产者 :一个后台线程专门负责执行滚动查询,将获取到的数据批次放入一个阻塞队列 (
-
-
优化内存占用 - 流式处理与缓存
-
动作:避免大数据量的整体驻留。
-
关键设计:
- 使用
SXSSFWorkbook
:替换传统的XSSFWorkbook
,设置rowAccessWindowSize
(如 2000),实现了磁盘换内存的流式写入,极大降低了内存压力。 - 精简数据字段:优化 ES 查询模板,只获取导出必需的字段,从源头上减少了单条数据大小和网络传输量。
- 可控的队列容量:设置合理的阻塞队列大小(如容量为 2),作为生产者和消费者之间的缓冲,防止内存堆积。
- 使用
-
-
架构扩展性 - 策略模式与工厂模式
-
动作:根据数据量大小智能选择不同的处理策略。
-
关键设计:
-
定义
ExcelExportHandler
接口:统一导出处理器的行为。 -
实现不同处理器:
SampleExportHandler
:处理数据量较小(如 ≤ 2W 条)的场景,直接同步响应流返回文件。OssExportHandler
(文中提及):处理大数据量场景,异步导出至 OSS,前端通过轮询或通知下载。
-
工厂模式自动选择 :通过
ExcelExportHandlerFactory
根据第一次查询的结果总数,自动选择最合适的处理器。
-
-
-
资源保护 - 限流与清理
-
动作:防止系统过载和资源泄漏。
-
关键设计:
- 并发控制 :使用原子计数器 (
AtomicInteger
) 限制最大同时导出任务数,超出则友好拒绝,保护系统稳定性。 - 资源清理 : finally 块中确保关闭
SXSSFWorkbook
(释放临时文件)和清除ThreadLocal
中的scroll_id
(释放 ES 服务端资源)。
- 并发控制 :使用原子计数器 (
-
三、 优化成果
- 功能上:彻底打破了 1W 条的数据限制,可支持海量数据导出。
- 性能上 :耗时从 15 秒(1W条)降低到约 7 秒(4W条) ,性能提升超过 8 倍,且吞吐量大幅增加。
- 稳定性上:内存占用可控,无 OOM 风险;通过线程池和队列管理,系统负载更加平稳。
- 架构上:代码清晰,模块解耦,扩展性强,为未来处理更复杂的导出需求打下了良好基础。
代码设计模式与架构总结
1. BaseExcelExporter
(公共父类) - 模板方法模式 & 资源复用
-
设计意图 :抽取共性,封装流程 。将导出过程中不变的核心算法骨架 (生产-消费模型)与可变的实现细节(具体如何查询、如何写入)分离。
-
实现要点:
-
模板方法 :
commitEsQueryTask
和syncWriteTaskResult
这两个protected
方法定义了"提交查询任务"和"消费写入数据"的标准流程。子类只需调用它们即可完成核心逻辑,无需关心多线程同步和队列管理的复杂细节。 -
公共资源:
- 线程池 :集中管理所有导出任务的线程资源,避免重复创建,方便参数调优(如使用
TtlExecutors
解决线程池中ThreadLocal
传递问题)。 - 缓存机制 :
cacheExcelInfo
和cacheField
使用HashMap
缓存了类的注解信息,通过"懒加载"机制,避免了每次导出都通过反射解析注解的巨大开销,这是性能优化的关键点之一。
- 线程池 :集中管理所有导出任务的线程资源,避免重复创建,方便参数调优(如使用
-
好处:极大减少了子类的代码量,确保了所有导出器行为的一致性,并且将性能优化手段(缓存、线程池)集中在父类,便于维护。
-
2. ExcelExportHandler
(接口类) - 策略模式
-
设计意图 :定义标准,开放扩展。声明一个通用的导出契约,将不同的导出算法(如同步流、异步OSS)抽象为不同的策略,使它们可以相互替换。
-
实现要点:
export(...)
方法是策略的核心执行方法,接收所有必要的参数(如首次查询结果、工作簿、后续查询的Supplier等)。support(...)
方法是策略的选择依据,根据数据量等条件判断该处理器是否适用。
-
好处:
- 符合开闭原则 :未来如果需要增加新的导出方式(如导出为CSV、分片ZIP下载),只需实现新的
ExcelExportHandler
即可,无需修改现有任何代码。 - 解耦 :使用方(如
LargeExcelUtil
)只依赖于接口,不依赖于具体实现,降低了系统各个部分的耦合度。
- 符合开闭原则 :未来如果需要增加新的导出方式(如导出为CSV、分片ZIP下载),只需实现新的
3. ExcelExportHandlerFactory
(工厂类) - 工厂模式
-
设计意图 :对象创建与使用分离。负责根据业务规则(数据量)自动选择并创建具体的策略实现对象。
-
实现要点:
- 利用 Spring 的依赖注入(
@Autowired private List<ExcelExportHandler> handlers
)自动收集所有实现了ExcelExportHandler
的 Bean。 buildHandler
方法遍历处理器列表,通过调用每个处理器的support
方法来找到最合适的那个。
- 利用 Spring 的依赖注入(
-
好处:
- 简化客户端代码 :使用方(
LargeExcelUtil.export
)无需知道有哪些处理器,也无需写一堆if-else
来判断,只需调用工厂方法即可获得合适的处理器。代码非常简洁和清晰。 - 集中管理:所有处理器的选择逻辑都集中在工厂里,规则变化只需修改一处。
- 简化客户端代码 :使用方(
4. LargeExcelUtil
(Utils类) - 外观模式 & 资源管理
-
设计意图 :提供简洁的高层接口,整合复杂子系统 。它不再是传统的静态工具类,而是一个集成了复杂流程和资源管理的门户类(Facade) 。
-
实现要点:
-
外观模式 :对外提供一个非常简单的
export
静态方法,内部却整合了参数校验、并发控制、ES查询初始化、处理器工厂、工作簿创建、资源清理等一整套复杂流程。对调用者而言,导出功能变得非常简单。 -
资源管理:
- 并发控制 :使用
AtomicInteger
实现了一个简单的令牌桶限流器,是系统稳定性的重要保障。 - 资源清理 :在
finally
块中严格保证了workbook.close()
/dispose()
和scrollId
的清理,避免了内存泄漏和资源悬挂,体现了良好的编程习惯。
- 并发控制 :使用
-
实现了
ApplicationContextAware
:这是一个巧妙的设计,让这个静态工具类能够获取到 Spring 容器中的 Bean(ExcelExportHandlerFactory
),解决了静态方法无法直接注入 Spring Bean 的问题。
-
5. ESScrollUtil
(Utils类) - 职责单一 & 封装
-
设计意图 :封装复杂细节,提供友好API 。将ES滚动查询的三个步骤(初始化、滚动、清理)及其资源管理(
TransmittableThreadLocal
)封装起来。 -
实现要点:
- 提供了
getFirstScrollId
和nextScrollPage
两个核心方法,内部处理了scroll_id
的存储和传递,让业务代码可以像使用迭代器一样简单地进行滚动查询。 - 职责非常单一,就是管理ES滚动查询,符合单一职责原则。
总结:架构图景
- 提供了
总结:架构图景
- 调用层 (
Service
) -> 门户层 (LargeExcelUtil
):提供简单接口,负责流程编排和资源管理。 - 门户层 -> 策略工厂 (
ExcelExportHandlerFactory
):根据上下文选择策略。 - 策略工厂 -> 具体策略 (
SampleExportHandler
等):执行特定算法。 - 具体策略 -> 抽象模板 (
BaseExcelExporter
):复用基础流程和组件。 - 抽象模板 -> 工具层 (
ESScrollUtil
):调用更底层的技术组件。
其实六个月前就写完了,最近有空刚好整理发出来💦一篇(不是