SpringBoot异步导出文件的实现步骤
作者:月生_
序言
在工作当中经常会碰到文件下载的功能,当文件较大时,如果使用原来的下载方式会导致下载进度慢,甚至有可能存在请求超时的情况,所以封装异步下载文件的功能是非常有必要的。我私下尝试了,可以实现,代码已经提交到我仓库。
思路
既然是要实现异步下载,那么在发送下载文件的请求,页面就不应该处于等待状态,而是给一个提示,比如 开始导出文件 之类的,然后后台开始执行下载文件的代码,下载完成后上传到 Minio 中,然后将这访问路径存储到数据库表中,前段再去请求这个下载路径就可以实现了,
代码实现
前端发送文件下载的请求时,后端返回一个 processId 给前端,然后前端根据这个id一直轮询查询这个流程的状态,直到这个流程的状态为完成时结束轮询,然后就可以根据后端返回的访问 url 地址来下载文件。
上面是一个大概的思路,下面来看实现:
自定义注解
首先需要定义两个注解 : ProcessRunner
和 ProcessHandle
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ProcessRunner { String processName() default ""; ProcessType processType() default ProcessType.EXCEL_TYPE; String description() default ""; }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ProcessHandle { String value() default ""; ProcessType processType() default ProcessType.EXCEL_TYPE; }
ProcessType
定义如下:
public enum ProcessType { EXCEL_TYPE("Excel类型", ProcessTypeEnum.EXCEL_TYPE); private final String value; private final ProcessTypeEnum type; ProcessType(String value, ProcessTypeEnum type) { this.value = value; this.type = type; } public String getValue() { return value; } public ProcessTypeEnum getType() { return type; } public enum ProcessTypeEnum { /** * 进程类型 */ EXCEL_TYPE("Excel类型", "EXCEL_TYPE"); private final String desc; private final String type; ProcessTypeEnum(String desc, String type) { this.desc = desc; this.type = type; } public String getDesc() { return desc; } public String getType() { return type; } } }
这里的两个注解主要用来做Aop切面和扫描使用的,具体如下:
AOP 切面
@Aspect @Component @Order(1) @Slf4j public class ProcessRunnerAop { @Resource private ProcessService processService; @Around("@annotation(processRunner)") public Object processRunner(ProceedingJoinPoint proceedingJoinPoint, ProcessRunner processRunner) throws Throwable { // 插入流程到数据库表 Object proceed = proceedingJoinPoint.proceed(); if (proceed instanceof ProcessDTO processDTO) { Process process = new Process(); process.setProcessId(processDTO.getProcessId()); process.setProcessName(processRunner.processName()); process.setDescription(processRunner.description()); process.setProcessType(processRunner.processType().name()); process.setStartTime(new Date()); process.setStatus((byte) 0); process.setParams(JSONUtil.toJsonStr(processDTO)); processService.insert(process); } return proceed; } }
注解扫描
@Component @Slf4j public class ProcessManager implements BeanPostProcessor, PriorityOrdered { private static final Map<String, Invoker> INVOKER_MAP = new ConcurrentHashMap<>(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); public static final Pattern SERVICE_IMPL_BEAN_NAME_PATTERN = Pattern.compile("(?i)[.a-z]+ServiceImpl"); @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { Class<?> beanClass = AopUtils.isAopProxy(bean) ? AopUtils.getTargetClass(bean) : bean.getClass(); if (!SERVICE_IMPL_BEAN_NAME_PATTERN.matcher(beanClass.getName()).matches()) { return bean; } log.info("beanName:{}, beanType:{}", beanName, beanClass.getName()); Method[] declaredMethods = ClassUtil.getDeclaredMethods(beanClass); for (Method declaredMethod : declaredMethods) { if (declaredMethod.isAnnotationPresent(ProcessHandle.class)) { ProcessHandle processHandle = declaredMethod.getAnnotation(ProcessHandle.class); if (INVOKER_MAP.containsKey(processHandle.processType().toString())) { throw new RuntimeException("processType 重复" + processHandle.processType().toString()); } Parameter[] parameters = declaredMethod.getParameters(); // 校验processId参数是否存在 boolean processIdExist = false; for (Parameter parameter : parameters) { String name = parameter.getName(); if (StrUtil.equalsIgnoreCase("processId", name)) { processIdExist = true; break; } } if (!processIdExist) { throw new RuntimeException("processId 参数不存在," + processHandle.processType().toString()); } if (declaredMethod.getReturnType() != String.class) { throw new RuntimeException("返回值必须为String," + processHandle.processType().toString()); } INVOKER_MAP.put(processHandle.processType().name(), new Invoker(bean, declaredMethod, parameters)); } } return bean; } public static void handleProcess(Long processId, boolean isEnd) { log.info("processId:{} isEnd:{}", processId, isEnd); ProcessService processService = SpringUtil.getBean(ProcessService.class); Process process = processService.queryById(processId); if (process == null) { throw new RuntimeException("processId 不存在" + processId); } if (isEnd) { process.setStatus((byte) 2); } else { process.setStatus((byte) 1); } processService.update(process); log.info("任务更新状态成功"); } public void startProcess(Long processId) { ProcessService processService = SpringUtil.getBean(ProcessService.class); Process process = processService.queryById(processId); if (process == null) { return; } String params = process.getParams(); try { JsonNode jsonNode = OBJECT_MAPPER.readTree(params); Invoker invoker = INVOKER_MAP.get(process.getProcessType()); Method method = invoker.getMethod(); Parameter[] parameters = method.getParameters(); Object[] args = new Object[parameters.length]; for (int i = 0; i < parameters.length; i++) { Parameter parameter = parameters[i]; Class<?> type = parameter.getType(); String name = parameter.getName(); if (Long.class.isAssignableFrom(type) && StrUtil.equalsIgnoreCase("processId", name)) { args[i] = jsonNode.get(name).asLong(); } else if (Map.class.isAssignableFrom(type)) { JsonNode valueNode = jsonNode.get(name); args[i] = OBJECT_MAPPER.convertValue(valueNode, type); } } method.setAccessible(true); Object url = method.invoke(INVOKER_MAP.get(process.getProcessType()).getBean(), args); if (url != null) { process.setStatus((byte) 2); process.setUrl(url.toString()); processService.update(process); } } catch (JsonProcessingException | IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } } @Override public int getOrder() { return 0; } @Data @AllArgsConstructor @NoArgsConstructor static class Invoker { private Object bean; private Method method; private Object[] args; } }
这段代码的大体思路就是扫描所有以 ServiceImpl
来结尾的 Bean 对象,然后找到带有 ProcessHandle
注解的方法,构建成 Invoker
对象存到 INVOKER_MAP
集合中。在这个里面不可以使用 Resource
这类依赖注入的注解,即便使用了 Spring
也无法注入对象,因为 ProcessManager
实现了 PriorityOrdered
接口(实现这个接口是为了保证最先被初始化),这个 Bean 被实例化的时候,其他的 Bean 还没被创建,注入一直都是空对象。
服务层代码实现
@Override @ProcessRunner(processName = "异步导出excel", processType = ProcessType.EXCEL_TYPE) public ProcessDTO startProcess() { HashMap<String, Object> hashMap = new HashMap<>(); hashMap.put("userId", 1L); Process process = new Process(); process.setProcessName("异步导出excel"); hashMap.put("bean", process); return ProcessDTO.createProcessDTO(IdUtil.getSnowflakeNextId(), hashMap); } @Override @ProcessHandle(value = "异步导出excel", processType = ProcessType.EXCEL_TYPE) public String downloadExcel(Long processId, Map<String, Object> data) { // 这里模拟下载excel文件,我就直接找一个本地excel文件,不使用poi来生成excel文件了 log.info("userId:{}", data.get("userId")); log.info("bean:{}", data.get("bean")); File file = FileUtil.file("C:\Users\Administrator\Downloads\1.xlsx"); ProcessManager.handleProcess(processId, true); try { minioUtils.putObject("test", file.getName(), FileUtil.getInputStream(file), FileUtil.size(file), FileUtil.getType(file)); } catch (Exception e) { throw new RuntimeException(e); } return minioUtils.getObjectUrl("test", file.getName()); }
定时任务
服务层的代码实现完毕,我们需要定义一个定时任务去后台执行文件的下载操作,后台下载完成后,会把 Minio 的可访问 url 同步到数据库中,前端根据这个 url 下载就可以了,具体代码如下:
/** * 定时任务 * 表示上一次执行完毕后,间隔2秒执行下一次 */ @Scheduled(fixedDelay = 2000) public void run() { log.info("开始执行定时任务"); processService.run(); }
定时使用 HttpUtil 工具类去请求
@Override public void run() { // 查询出之前未开始的任务 Process process = processMapper.listByStatus(0); if (process == null) { return; } // 开始执行任务 HashMap<String, Object> map = new HashMap<>(); map.put("processId", process.getProcessId()); HttpUtil.get("http://127.0.0.1:8080/processManager/startProcess?processId=" + process.getProcessId()); }
请求的具体方法如下
@RestController @RequestMapping("/processManager") public class ProcessManagerController { @Resource private ProcessManager processManager; @GetMapping("/startProcess") public void startProcess(@RequestParam(value = "processId") Long processId) { processManager.startProcess(processId); } }
public void startProcess(Long processId) { ProcessService processService = SpringUtil.getBean(ProcessService.class); Process process = processService.queryById(processId); if (process == null) { return; } String params = process.getParams(); try { JsonNode jsonNode = OBJECT_MAPPER.readTree(params); Invoker invoker = INVOKER_MAP.get(process.getProcessType()); Method method = invoker.getMethod(); Parameter[] parameters = method.getParameters(); Object[] args = new Object[parameters.length]; for (int i = 0; i < parameters.length; i++) { Parameter parameter = parameters[i]; Class<?> type = parameter.getType(); String name = parameter.getName(); if (Long.class.isAssignableFrom(type) && StrUtil.equalsIgnoreCase("processId", name)) { args[i] = jsonNode.get(name).asLong(); } else if (Map.class.isAssignableFrom(type)) { JsonNode valueNode = jsonNode.get(name); args[i] = OBJECT_MAPPER.convertValue(valueNode, type); } } method.setAccessible(true); Object url = method.invoke(INVOKER_MAP.get(process.getProcessType()).getBean(), args); if (url != null) { process.setStatus((byte) 2); process.setUrl(url.toString()); processService.update(process); } } catch (JsonProcessingException | IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } }
这里下载完成后,数据库表中任务的状态和 url 地址就会更新
总结
我这里并没有写前端代码,最后和大家口述一下大概的流程:
- 前端发送一个下载文件的请求,后端结构返回一个 processId 值,并提示 “开始下载文件”
- 使用 AOP 切面,将这个 processId 存储到数据库,并记录开始时间和任务状态
- 后台维护定时任务去定时查询数据库中未开始的下载任务,根据时间升序排序
- 如果存在未完成的定时任务,则请求 processManager 去触发任务的下载,下载完成后会将任务的状态标记为已完成,同时更新文件的可访问 url
- 前端触发下载的请求后,根据返回的 processId 一直走接口去查询任务的状态吗,如果返回的数据状态为已完成,则直接访问 url 就可以触发浏览器的下载操作。
到此这篇关于SpringBoot异步导出文件的实现步骤的文章就介绍到这了,更多相关SpringBoot异步导出 内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!