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 就可以触发浏览器的下载操作。
相关推荐
恸流失5 小时前
DJango项目
后端·python·django
Mr Aokey7 小时前
Spring MVC参数绑定终极手册:单&多参/对象/集合/JSON/文件上传精讲
java·后端·spring
14L7 小时前
互联网大厂Java面试:从Spring Cloud到Kafka的技术考察
spring boot·redis·spring cloud·kafka·jwt·oauth2·java面试
地藏Kelvin8 小时前
Spring Ai 从Demo到搭建套壳项目(二)实现deepseek+MCP client让高德生成昆明游玩4天攻略
人工智能·spring boot·后端
一个有女朋友的程序员8 小时前
Spring Boot 缓存注解详解:@Cacheable、@CachePut、@CacheEvict(超详细实战版)
spring boot·redis·缓存
菠萝018 小时前
共识算法Raft系列(1)——什么是Raft?
c++·后端·算法·区块链·共识算法
长勺8 小时前
Spring中@Primary注解的作用与使用
java·后端·spring
wh_xia_jun8 小时前
在 Spring Boot 中使用 JSP
java·前端·spring boot
yuren_xia9 小时前
在Spring Boot中集成Redis进行缓存
spring boot·redis·缓存
小奏技术9 小时前
基于 Spring AI 和 MCP:用自然语言查询 RocketMQ 消息
后端·aigc·mcp