一、业务背景
请求开始 —》批量生成PDF文件(耗时较长)—》将PDF文件汇总回写给第三方—》请求结束
纵观整个业务背景,对于整个请求的耗时影响因素有很多,需要生成的PDF文件数量、需要合并的文件数量、生成与合并文件的耗时、回写逻辑与回写通信耗时等等。
使用常规的串行处理势必将整体耗时拉满,使用传统异步又无法获取各线程执行结果,于是考虑使用Future设计模式的半异步处理,也可以理解为局部异步处理。
二、设计思路
根据业务背景分析,将批量生成PDF文件部分进行异步处理可以大幅提升接口响应,其余部分仍然串行运行即可。
假设本次有十份PDF文件需要生成,平均每份生成速度为 1s 。如果串行执行十份就需要10s,如果阻塞式异步执行十份PDF生成可能只需要1.5s - 2s 之间。
请求开始—》异步生成PDF文件—》监听Futures全部执行成功开始回写第三方—》请求结束
三、代码实现
先上完整代码,文章后面进行重点部分的解释
package com.aikes.mdap.common;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.stream.Collectors;
@RestController
@RequestMapping("aikes")
@Slf4j
public class AsyncController {
private Executor asyncExecutor = getAsyncExecutor();
// 模拟生成PDF文件(返回PDF路径)
public String generatePdf(int taskId) {
try {
// 模拟耗时操作(0.5~2秒随机时间)
int sleepTime = new Random().nextInt(1500) + 500;
Thread.sleep(sleepTime);
String pdfPath = "/tmp/report_" + taskId + ".pdf";
log.info(Thread.currentThread()+"——生成PDF成功: " + pdfPath + " [耗时: " + sleepTime + "ms]");
return pdfPath;
} catch (Exception e) {
throw new RuntimeException("生成PDF失败", e);
}
}
// 模拟HTTP回写(汇总所有PDF路径)
public void callbackPdfs(List<String> pdfPaths) {
log.info(Thread.currentThread()+"——开始回写PDF: " + pdfPaths);
try {
// 模拟耗时操作(0.5~2秒随机时间)
int sleepTime = new Random().nextInt(1500) + 500;
Thread.sleep(sleepTime);
log.info(Thread.currentThread()+"——回写成功!耗时" + sleepTime);
} catch (Exception e) {
throw new RuntimeException("回写失败", e);
}
}
/**
* 自定义线程池
*/
public Executor getAsyncExecutor() {
if(asyncExecutor!=null) return asyncExecutor;
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20); //核心线程数
executor.setMaxPoolSize(50); //最大线程数
executor.setQueueCapacity(1000); //队列大小
executor.setKeepAliveSeconds(300); //线程最大空闲时间
executor.setThreadNamePrefix("pdfAsync-Executor-"); //指定用于新创建的线程名称的前缀。
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
asyncExecutor = executor;
return executor;
}
@RequestMapping(value = "/testAsyncPdf", method = RequestMethod.POST)
public Object testAsyncPdf() throws Exception{
// 1. 记录开始时间
long startTime = System.currentTimeMillis();
// 2. 创建10个异步任务(每个任务生成一个PDF)
//使用Stream流
// List<CompletableFuture<String>> futures = IntStream.range(1, 11)
// .mapToObj(taskId ->
// CompletableFuture.supplyAsync(() -> generatePdf(taskId), asyncExecutor)
// )
// .collect(Collectors.toList());
//使用常规循环
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int taskId = 1; taskId <= 10; taskId++) {
final int currentTaskId = taskId;
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> generatePdf(currentTaskId), asyncExecutor);
futures.add(future);
}
// 3. 合并所有任务结果
CompletableFuture<Void> allDone = CompletableFuture.allOf((futures.toArray(new CompletableFuture[0])));
// 4. 所有任务完成后,汇总PDF路径并回写
CompletableFuture<List<String>> result = allDone.thenApply(v ->
futures.stream()
.map(CompletableFuture::join) // 获取每个任务的结果
.collect(Collectors.toList())
);
List<String> strings = result.get();//获取返回结果
String tResult = JSON.toJSONString(strings);
// 5. 调用回写接口
result.thenAccept(this::callbackPdfs);
log.info((Thread.currentThread() + "——总耗时: " + (System.currentTimeMillis() - startTime) + "ms"));
return tResult;
}
}
执行结果:
根据日志可以看到,整个接口的响应基本是两个红框耗时的累加,一个是PDF生成的最长耗时,另一个是回写耗时,这与我们期望的效果是完全一致的,整体响应对比串行优化了很多。
接下来是关于代码中的一些重点解释:
1、多线程调用生成PDF时,我们获取到多个CompletableFuture对象,然后创建一个新的CompletableFuture将前面的多个Future整合到一起,作为后续监测一起完成时使用。
新的allDone对象的thenApply是一个回调方法,当主线程执行到此刻如果Future没有全部完成时是不会执行,主线程会继续向下,如果没有同步阻塞方法会直接执行完毕,上述示例中是因为后面有一行获取result的代码:
List<String> strings = result.get();//获取返回结果
从而导致主线程在此阻塞,等待Future全部返回才会继续执行,大家可以测试下如果接口不返回PDF路径(即:不调用result.get方法),接口响应速度甚至趋向于 0 ms,因为生成和回调都不存在阻塞,所以主线程并没有太多逻辑。
也就是说allDone.thenApply和result.thenAccept都是不阻塞的方法,只有在执行到result.join或者result.get才会阻塞等待。
2、关于lambda表达式笔者研究不深,所以有些地方更喜欢用for循环处理,总感觉这样更直观。上述代码在异步生成文件处就有两套代码,大家根据自己习惯使用就好,我们接受新鲜事物(虽然也不是很新...)但习惯这东西还真是难改~
3、关于线程池需要留意,重点看日志打印是否真的多个线程处理,平时开发过程中总是一看 Async关键线程名就觉得是异步了,实际好多时候都是 Async-1 。这种时候大概率是每次创建了新的线程池,而不是从一个线程池里取线程,后续存在线程泄露的风险。
四、总结
关于Future异步编程本文只讲述了冰山一角,这种声明式代码回调执行需要逐步适应,如果有前端开发经验可能会更好理解,类似钩子函数需要特定场景触发再执行。另外,不是所有业务都试用,还是要根据业务场景进行权衡选择,所有的技术手段都是因业务的多样性而逐步创新。