SpringBoot异步导出文件

序言

在工作当中经常会碰到文件下载的功能,当文件较大时,如果使用原来的下载方式会导致下载进度慢,甚至有可能存在请求超时的情况,所以封装异步下载文件的功能是非常有必要的。我私下尝试了,可以实现,代码已经提交到我仓库

思路

既然是要实现异步下载,那么在发送下载文件的请求,页面就不应该处于等待状态,而是给一个提示,比如 开始导出文件 之类的,然后后台开始执行下载文件的代码,下载完成后上传到 Minio 中,然后将这访问路径存储到数据库表中,前段再去请求这个下载路径就可以实现了,

代码实现

前端发送文件下载的请求时,后端返回一个 processId 给前端,然后前端根据这个id一直轮询查询这个流程的状态,直到这个流程的状态为完成时结束轮询,然后就可以根据后端返回的访问 url 地址来下载文件。

上面是一个大概的思路,下面来看实现:

自定义注解

首先需要定义两个注解 : ProcessRunnerProcessHandle

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ProcessRunner {

    String processName() default "";

    ProcessType processType() default ProcessType.EXCEL_TYPE;

    String description() default "";
}
java 复制代码
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ProcessHandle {

    String value() default "";

    ProcessType processType() default ProcessType.EXCEL_TYPE;
}

ProcessType 定义如下:

java 复制代码
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 切面

java 复制代码
@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;
    }
}

注解扫描

java 复制代码
@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 还没被创建,注入一直都是空对象。

服务层代码实现

java 复制代码
@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 下载就可以了,具体代码如下:

java 复制代码
/**
 * 定时任务
 * 表示上一次执行完毕后,间隔2秒执行下一次
 */
@Scheduled(fixedDelay = 2000)
public void run() {
    log.info("开始执行定时任务");
    processService.run();
}

定时使用 HttpUtil 工具类去请求

java 复制代码
@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());
}

请求的具体方法如下

java 复制代码
@RestController
@RequestMapping("/processManager")
public class ProcessManagerController {


    @Resource
    private ProcessManager processManager;

    @GetMapping("/startProcess")
    public void  startProcess(@RequestParam(value = "processId") Long processId) {
        processManager.startProcess(processId);
    }
}
java 复制代码
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 地址就会更新

总结

我这里并没有写前端代码,最后和大家口述一下大概的流程:

  1. 前端发送一个下载文件的请求,后端结构返回一个 processId 值,并提示 "开始下载文件"
  2. 使用 AOP 切面,将这个 processId 存储到数据库,并记录开始时间和任务状态
  3. 后台维护定时任务去定时查询数据库中未开始的下载任务,根据时间升序排序
  4. 如果存在未完成的定时任务,则请求 processManager 去触发任务的下载,下载完成后会将任务的状态标记为已完成,同时更新文件的可访问 url
  5. 前端触发下载的请求后,根据返回的 processId 一直走接口去查询任务的状态吗,如果返回的数据状态为已完成,则直接访问 url 就可以触发浏览器的下载操作。
相关推荐
Albert Edison2 小时前
【最新版】IntelliJ IDEA 2025 创建 SpringBoot 项目
java·spring boot·intellij-idea
Piper蛋窝3 小时前
深入 Go 语言垃圾回收:从原理到内建类型 Slice、Map 的陷阱以及为何需要 strings.Builder
后端·go
六毛的毛6 小时前
Springboot开发常见注解一览
java·spring boot·后端
AntBlack6 小时前
拖了五个月 ,不当韭菜体验版算是正式发布了
前端·后端·python
31535669136 小时前
一个简单的脚本,让pdf开启夜间模式
前端·后端
uzong6 小时前
curl案例讲解
后端
开开心心就好7 小时前
免费PDF处理软件,支持多种操作
运维·服务器·前端·spring boot·智能手机·pdf·电脑
一只叫煤球的猫7 小时前
真实事故复盘:Redis分布式锁居然失效了?公司十年老程序员踩的坑
java·redis·后端
猴哥源码7 小时前
基于Java+SpringBoot的农事管理系统
java·spring boot
大鸡腿同学8 小时前
身弱武修法:玄之又玄,奇妙之门
后端