跳到正文
hello world

40. CompletableFuture 批量处理 PDF 最终版

发布于阅读量 0

40. CompletableFuture 批量处理 PDF 最终版

前面已经分别学过:

自定义线程池;
supplyAsync;
allOf;
join;
结果对象;
异常处理;
超时控制;
线程命名;
成功失败统计。

这一节把这些内容放到一起,整理出一个相对完整的 PDF 批量处理版本。

这版代码的目标是:

读取 input 目录下的全部 PDF;

每个 PDF 创建一个 CompletableFuture;

所有任务使用自定义 PDF 线程池;

同一时间最多处理 3 个 PDF;

每个 PDF 最多处理 30 秒;

单个文件失败,不影响其他文件;

等待整批任务全部结束;

统计成功、失败和超时数量;

最后关闭线程池。

这不是一套完整的生产级任务系统,但作为这个并发练习项目的最终版本,已经把主要问题串起来了。


项目结构

这一版主要使用以下类:

src/main/java
└── com/succos
    ├── completablefuture
    │   └── CompletableFuturePdfFinalDemo.java
    ├── dto
    │   ├── FileItemContext.java
    │   └── PdfTaskResult.java
    ├── service
    │   └── PdfWatermarkService.java
    └── threadpool
        └── NamedThreadFactory.java

目录仍然是:

pdf-watermark
├── input
├── output
├── pom.xml
└── src

input 用来存放原始 PDF。

output 用来存放添加水印后的 PDF。


第一步:定义 PDF 任务结果对象

批量任务不适合只返回一个字符串。

因为我不仅想知道输出路径,还需要知道:

文件名;
是否成功;
是否超时;
输出路径;
失败原因;
任务耗时。

所以先定义一个结果对象。

新建:

com.succos.dto.PdfTaskResult

代码如下:

package com.succos.dto;

public class PdfTaskResult {

    private boolean success;

    private boolean timeout;

    private String fileName;

    private String sourcePath;

    private String targetPath;

    private String message;

    private long costMillis;

    public static PdfTaskResult success(
            String fileName,
            String sourcePath,
            String targetPath,
            long costMillis
    ) {
        PdfTaskResult result = new PdfTaskResult();

        result.success = true;
        result.timeout = false;
        result.fileName = fileName;
        result.sourcePath = sourcePath;
        result.targetPath = targetPath;
        result.message = "处理成功";
        result.costMillis = costMillis;

        return result;
    }

    public static PdfTaskResult fail(
            String fileName,
            String sourcePath,
            String message,
            long costMillis
    ) {
        PdfTaskResult result = new PdfTaskResult();

        result.success = false;
        result.timeout = false;
        result.fileName = fileName;
        result.sourcePath = sourcePath;
        result.message = message;
        result.costMillis = costMillis;

        return result;
    }

    public static PdfTaskResult timeout(
            String fileName,
            String sourcePath,
            String message,
            long costMillis
    ) {
        PdfTaskResult result = new PdfTaskResult();

        result.success = false;
        result.timeout = true;
        result.fileName = fileName;
        result.sourcePath = sourcePath;
        result.message = message;
        result.costMillis = costMillis;

        return result;
    }

    public boolean isSuccess() {
        return success;
    }

    public boolean isTimeout() {
        return timeout;
    }

    public String getFileName() {
        return fileName;
    }

    public String getSourcePath() {
        return sourcePath;
    }

    public String getTargetPath() {
        return targetPath;
    }

    public String getMessage() {
        return message;
    }

    public long getCostMillis() {
        return costMillis;
    }

    @Override
    public String toString() {
        return "PdfTaskResult{" +
                "success=" + success +
                ", timeout=" + timeout +
                ", fileName='" + fileName + '\'' +
                ", sourcePath='" + sourcePath + '\'' +
                ", targetPath='" + targetPath + '\'' +
                ", message='" + message + '\'' +
                ", costMillis=" + costMillis +
                '}';
    }
}

这个类里没有直接抛异常,而是把任务状态明确表示出来。

后面汇总时就能直接判断:

result.isSuccess()
result.isTimeout()

第二步:定义通用线程工厂

为了让日志更清楚,我继续使用自定义线程名。

新建:

com.succos.threadpool.NamedThreadFactory

代码如下:

package com.succos.threadpool;

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

public class NamedThreadFactory implements ThreadFactory {

    private final String prefix;

    private final AtomicInteger threadNumber =
            new AtomicInteger(1);

    public NamedThreadFactory(String prefix) {
        this.prefix = prefix;
    }

    @Override
    public Thread newThread(Runnable runnable) {

        Thread thread = new Thread(runnable);

        thread.setName(
                prefix
                        + "-"
                        + threadNumber.getAndIncrement()
        );

        thread.setDaemon(false);

        thread.setUncaughtExceptionHandler((t, e) -> {
            System.err.println(
                    "线程发生未捕获异常,thread="
                            + t.getName()
            );

            e.printStackTrace();
        });

        return thread;
    }
}

线程名会变成:

pdf-watermark-worker-1
pdf-watermark-worker-2
pdf-watermark-worker-3

看到日志时,可以马上判断任务来自哪个线程池。


第三步:单个 PDF 处理逻辑

PdfWatermarkService 仍然只负责处理一个 PDF。

它不关心:

线程池;
CompletableFuture;
批量等待;
成功失败统计。

这些应该由外层调度代码负责。

单个文件处理方法保持类似结构:

package com.succos.service;

import com.succos.dto.FileItemContext;

public class PdfWatermarkService {

    public void addWaterMakerOfPDF(
            FileItemContext fileItem
    ) throws Exception {

        /*
         * 这里放真实的 PDFBox 水印处理代码:
         *
         * 1. 读取 sourcePath;
         * 2. 遍历 PDF 页面;
         * 3. 写入文字或图片水印;
         * 4. 保存到 targetPath;
         * 5. 关闭相关资源。
         */

        System.out.println(
                Thread.currentThread().getName()
                        + " 正在处理:"
                        + fileItem.getSourcePath()
        );

        Thread.sleep(3000);

        System.out.println(
                Thread.currentThread().getName()
                        + " 已输出:"
                        + fileItem.getTargetPath()
        );
    }
}

这里暂时仍然用 Thread.sleep(3000) 模拟处理。

如果已经有真实的 PDFBox 水印代码,直接替换方法内部即可。


第四步:完整批量处理代码

新建:

com.succos.completablefuture.CompletableFuturePdfFinalDemo

完整代码如下:

package com.succos.completablefuture;

import com.succos.dto.FileItemContext;
import com.succos.dto.PdfTaskResult;
import com.succos.service.PdfWatermarkService;
import com.succos.threadpool.NamedThreadFactory;

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;

public class CompletableFuturePdfFinalDemo {

    private static final int CORE_POOL_SIZE = 3;

    private static final int MAX_POOL_SIZE = 3;

    private static final int QUEUE_CAPACITY = 100;

    private static final long SINGLE_TASK_TIMEOUT_SECONDS = 30;

    private static final long BATCH_TIMEOUT_MINUTES = 10;

    private static final String WATERMARK_TEXT = "上下文网";

    private static final File INPUT_DIR =
            new File("input");

    private static final File OUTPUT_DIR =
            new File("output");

    public static void main(String[] args) {

        ThreadPoolExecutor pdfExecutor =
                createPdfExecutor();

        try {
            List<File> pdfFileList =
                    loadPdfFiles(INPUT_DIR);

            if (pdfFileList.isEmpty()) {
                System.out.println(
                        "input 目录下没有 PDF 文件"
                );

                return;
            }

            prepareOutputDirectory(OUTPUT_DIR);

            long batchStart =
                    System.currentTimeMillis();

            List<CompletableFuture<PdfTaskResult>>
                    futureList = submitTasks(
                            pdfFileList,
                            pdfExecutor
                    );

            waitAllTasks(
                    futureList,
                    BATCH_TIMEOUT_MINUTES,
                    TimeUnit.MINUTES
            );

            List<PdfTaskResult> resultList =
                    collectResults(futureList);

            long batchCost =
                    System.currentTimeMillis()
                            - batchStart;

            printTaskResults(resultList);

            printSummary(
                    resultList,
                    batchCost
            );

        } catch (Exception e) {
            System.err.println(
                    "批量 PDF 处理发生异常:"
                            + getErrorMessage(e)
            );

            e.printStackTrace();

        } finally {
            shutdownExecutor(pdfExecutor);
        }
    }

    private static ThreadPoolExecutor createPdfExecutor() {

        return new ThreadPoolExecutor(
                CORE_POOL_SIZE,
                MAX_POOL_SIZE,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(
                        QUEUE_CAPACITY
                ),
                new NamedThreadFactory(
                        "pdf-watermark-worker"
                ),
                new ThreadPoolExecutor.AbortPolicy()
        );
    }

    private static List<File> loadPdfFiles(
            File inputDir
    ) {
        if (!inputDir.exists()) {
            throw new IllegalStateException(
                    "input 目录不存在:"
                            + inputDir.getAbsolutePath()
            );
        }

        if (!inputDir.isDirectory()) {
            throw new IllegalStateException(
                    "input 不是目录:"
                            + inputDir.getAbsolutePath()
            );
        }

        File[] files = inputDir.listFiles(file ->
                file.isFile()
                        && file.getName()
                        .toLowerCase()
                        .endsWith(".pdf")
        );

        if (files == null) {
            return new ArrayList<>();
        }

        return Arrays.stream(files)
                .sorted(
                        Comparator.comparing(
                                File::getName
                        )
                )
                .collect(Collectors.toList());
    }

    private static void prepareOutputDirectory(
            File outputDir
    ) {
        if (outputDir.exists()) {

            if (!outputDir.isDirectory()) {
                throw new IllegalStateException(
                        "output 路径不是目录:"
                                + outputDir.getAbsolutePath()
                );
            }

            return;
        }

        boolean created = outputDir.mkdirs();

        if (!created) {
            throw new IllegalStateException(
                    "output 目录创建失败:"
                            + outputDir.getAbsolutePath()
            );
        }
    }

    private static List<CompletableFuture<PdfTaskResult>>
    submitTasks(
            List<File> pdfFileList,
            ThreadPoolExecutor pdfExecutor
    ) {
        List<CompletableFuture<PdfTaskResult>>
                futureList = new ArrayList<>();

        for (File file : pdfFileList) {

            try {
                CompletableFuture<PdfTaskResult>
                        future = createPdfTask(
                                file,
                                pdfExecutor
                        );

                futureList.add(future);

            } catch (RejectedExecutionException e) {

                PdfTaskResult result =
                        PdfTaskResult.fail(
                                file.getName(),
                                file.getAbsolutePath(),
                                "线程池任务已满,提交被拒绝",
                                0
                        );

                futureList.add(
                        CompletableFuture
                                .completedFuture(result)
                );
            }
        }

        return futureList;
    }

    private static CompletableFuture<PdfTaskResult>
    createPdfTask(
            File file,
            ThreadPoolExecutor pdfExecutor
    ) {
        long taskStart =
                System.currentTimeMillis();

        return CompletableFuture
                .supplyAsync(() -> {
                    return processSinglePdf(
                            file,
                            taskStart
                    );
                }, pdfExecutor)
                .orTimeout(
                        SINGLE_TASK_TIMEOUT_SECONDS,
                        TimeUnit.SECONDS
                )
                .whenComplete((result, ex) -> {

                    long cost =
                            System.currentTimeMillis()
                                    - taskStart;

                    if (ex != null) {
                        System.err.println(
                                file.getName()
                                        + " 执行异常,耗时:"
                                        + cost
                                        + " ms,原因:"
                                        + getErrorMessage(ex)
                        );
                    } else {
                        System.out.println(
                                file.getName()
                                        + " 执行结束,耗时:"
                                        + cost
                                        + " ms"
                        );
                    }
                })
                .exceptionally(ex -> {

                    long cost =
                            System.currentTimeMillis()
                                    - taskStart;

                    Throwable cause =
                            getRootCause(ex);

                    if (cause
                            instanceof TimeoutException) {

                        return PdfTaskResult.timeout(
                                file.getName(),
                                file.getAbsolutePath(),
                                "单个 PDF 处理超过 "
                                        + SINGLE_TASK_TIMEOUT_SECONDS
                                        + " 秒",
                                cost
                        );
                    }

                    return PdfTaskResult.fail(
                            file.getName(),
                            file.getAbsolutePath(),
                            cause.getMessage(),
                            cost
                    );
                });
    }

    private static PdfTaskResult processSinglePdf(
            File file,
            long taskStart
    ) {
        System.out.println(
                Thread.currentThread().getName()
                        + " 开始处理:"
                        + file.getName()
        );

        validateSourceFile(file);

        String targetPath =
                buildTargetPath(
                        file,
                        OUTPUT_DIR
                );

        FileItemContext context =
                new FileItemContext();

        context.setSourcePath(
                file.getAbsolutePath()
        );

        context.setTargetPath(
                targetPath
        );

        context.setWaterMakeText(
                WATERMARK_TEXT
        );

        PdfWatermarkService service =
                new PdfWatermarkService();

        try {
            service.addWaterMakerOfPDF(context);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();

            throw new CompletionException(
                    new RuntimeException(
                            "PDF 处理任务被中断",
                            e
                    )
            );

        } catch (Exception e) {
            throw new CompletionException(e);
        }

        long cost =
                System.currentTimeMillis()
                        - taskStart;

        System.out.println(
                Thread.currentThread().getName()
                        + " 处理完成:"
                        + file.getName()
        );

        return PdfTaskResult.success(
                file.getName(),
                file.getAbsolutePath(),
                targetPath,
                cost
        );
    }

    private static void validateSourceFile(
            File file
    ) {
        if (!file.exists()) {
            throw new IllegalArgumentException(
                    "源 PDF 文件不存在"
            );
        }

        if (!file.isFile()) {
            throw new IllegalArgumentException(
                    "源路径不是文件"
            );
        }

        if (!file.canRead()) {
            throw new IllegalArgumentException(
                    "源 PDF 文件不可读取"
            );
        }

        if (file.length() == 0) {
            throw new IllegalArgumentException(
                    "源 PDF 文件为空"
            );
        }
    }

    private static String buildTargetPath(
            File sourceFile,
            File outputDir
    ) {
        String sourceName =
                sourceFile.getName();

        String lowerName =
                sourceName.toLowerCase();

        String targetName;

        if (lowerName.endsWith(".pdf")) {
            targetName =
                    sourceName.substring(
                            0,
                            sourceName.length() - 4
                    )
                            + "-watermark.pdf";
        } else {
            targetName =
                    sourceName
                            + "-watermark.pdf";
        }

        return new File(
                outputDir,
                targetName
        ).getAbsolutePath();
    }

    private static void waitAllTasks(
            List<CompletableFuture<PdfTaskResult>>
                    futureList,
            long timeout,
            TimeUnit unit
    ) {
        CompletableFuture<Void> allFuture =
                CompletableFuture.allOf(
                        futureList.toArray(
                                new CompletableFuture[0]
                        )
                );

        try {
            allFuture
                    .orTimeout(
                            timeout,
                            unit
                    )
                    .join();

        } catch (CompletionException e) {

            Throwable cause =
                    getRootCause(e);

            if (cause
                    instanceof TimeoutException) {

                System.err.println(
                        "整批任务等待超时,准备取消未完成任务"
                );

                cancelUnfinishedTasks(
                        futureList
                );

                return;
            }

            throw e;
        }
    }

    private static void cancelUnfinishedTasks(
            List<CompletableFuture<PdfTaskResult>>
                    futureList
    ) {
        for (CompletableFuture<PdfTaskResult>
                future : futureList) {

            if (!future.isDone()) {
                future.cancel(true);
            }
        }
    }

    private static List<PdfTaskResult>
    collectResults(
            List<CompletableFuture<PdfTaskResult>>
                    futureList
    ) {
        List<PdfTaskResult> resultList =
                new ArrayList<>();

        for (CompletableFuture<PdfTaskResult>
                future : futureList) {

            try {
                PdfTaskResult result =
                        future.join();

                resultList.add(result);

            } catch (Exception e) {
                resultList.add(
                        PdfTaskResult.fail(
                                "未知文件",
                                null,
                                getErrorMessage(e),
                                0
                        )
                );
            }
        }

        return resultList;
    }

    private static void printTaskResults(
            List<PdfTaskResult> resultList
    ) {
        System.out.println();
        System.out.println(
                "========== 文件处理明细 =========="
        );

        for (PdfTaskResult result
                : resultList) {

            System.out.println(
                    "文件名:"
                            + result.getFileName()
            );

            System.out.println(
                    "状态:"
                            + buildStatusText(result)
            );

            System.out.println(
                    "源路径:"
                            + result.getSourcePath()
            );

            System.out.println(
                    "输出路径:"
                            + result.getTargetPath()
            );

            System.out.println(
                    "处理信息:"
                            + result.getMessage()
            );

            System.out.println(
                    "任务耗时:"
                            + result.getCostMillis()
                            + " ms"
            );

            System.out.println(
                    "--------------------------------"
            );
        }
    }

    private static String buildStatusText(
            PdfTaskResult result
    ) {
        if (result.isSuccess()) {
            return "成功";
        }

        if (result.isTimeout()) {
            return "超时";
        }

        return "失败";
    }

    private static void printSummary(
            List<PdfTaskResult> resultList,
            long batchCost
    ) {
        long successCount =
                resultList.stream()
                        .filter(
                                PdfTaskResult::isSuccess
                        )
                        .count();

        long timeoutCount =
                resultList.stream()
                        .filter(
                                PdfTaskResult::isTimeout
                        )
                        .count();

        long failCount =
                resultList.size()
                        - successCount
                        - timeoutCount;

        System.out.println();
        System.out.println(
                "========== 批量处理汇总 =========="
        );

        System.out.println(
                "总任务数:"
                        + resultList.size()
        );

        System.out.println(
                "成功数量:"
                        + successCount
        );

        System.out.println(
                "失败数量:"
                        + failCount
        );

        System.out.println(
                "超时数量:"
                        + timeoutCount
        );

        System.out.println(
                "整批耗时:"
                        + batchCost
                        + " ms"
        );

        System.out.println(
                "================================"
        );
    }

    private static Throwable getRootCause(
            Throwable throwable
    ) {
        Throwable cause = throwable;

        while (cause.getCause() != null) {
            cause = cause.getCause();
        }

        return cause;
    }

    private static String getErrorMessage(
            Throwable throwable
    ) {
        Throwable cause =
                getRootCause(throwable);

        String message =
                cause.getMessage();

        if (message == null
                || message.trim().isEmpty()) {

            return cause
                    .getClass()
                    .getSimpleName();
        }

        return message;
    }

    private static void shutdownExecutor(
            ThreadPoolExecutor executor
    ) {
        executor.shutdown();

        try {
            boolean terminated =
                    executor.awaitTermination(
                            30,
                            TimeUnit.SECONDS
                    );

            if (!terminated) {
                System.err.println(
                        "线程池未在规定时间内关闭,准备强制停止"
                );

                List<Runnable> droppedTasks =
                        executor.shutdownNow();

                System.err.println(
                        "未开始执行的任务数量:"
                                + droppedTasks.size()
                );
            }

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();

            executor.shutdownNow();
        }
    }
}

这版代码的执行流程

整个流程可以拆成几步。

1. 创建线程池

ThreadPoolExecutor pdfExecutor =
        createPdfExecutor();

线程池配置为:

核心线程数:3;

最大线程数:3;

任务队列容量:100;

线程名前缀:pdf-watermark-worker;

拒绝策略:AbortPolicy。

所以同一时间最多有 3 个 PDF 正在处理。


2. 读取 PDF 文件

List<File> pdfFileList =
        loadPdfFiles(INPUT_DIR);

这里会:

检查 input 目录是否存在;

检查 input 是否真的是目录;

过滤非 PDF 文件;

按文件名排序。

排序不是必须的,但日志和结果列表会更稳定。


3. 创建 output 目录

prepareOutputDirectory(OUTPUT_DIR);

如果目录不存在,就创建。

如果路径已经存在,但它不是目录,则直接抛异常。


4. 提交全部任务

List<CompletableFuture<PdfTaskResult>>
        futureList = submitTasks(
                pdfFileList,
                pdfExecutor
        );

每个 PDF 都对应一个:

CompletableFuture<PdfTaskResult>

任务提交阶段不会马上等待结果。

这是批量并发的关键。

如果提交一个就 join() 一个,任务又会接近顺序执行。


5. 给单个任务增加超时

每个任务都加了:

.orTimeout(
        SINGLE_TASK_TIMEOUT_SECONDS,
        TimeUnit.SECONDS
)

如果某个 PDF 超过 30 秒还没有完成,这个 CompletableFuture 会异常完成。

后面的:

.exceptionally(...)

会把超时异常转换成:

PdfTaskResult.timeout(...)

这样单个文件超时不会直接打断整批任务。


6. 记录单个任务日志

任务后面加了:

.whenComplete((result, ex) -> {
    // 记录成功或失败日志
})

它只负责观察任务状态和打印耗时。

不会改变任务最终结果。

如果任务异常,再由后面的 exceptionally() 转成失败结果对象。


7. 等待整批任务完成

waitAllTasks(
        futureList,
        BATCH_TIMEOUT_MINUTES,
        TimeUnit.MINUTES
);

内部使用:

CompletableFuture.allOf(...)

等待所有任务完成。

整批任务还设置了 10 分钟超时。

如果超过 10 分钟,会尝试取消还没有完成的任务。

不过仍然要记住:

cancel(true) 只是尝试中断;

不能保证底层 PDF 处理立即停止。

8. 统一收集结果

List<PdfTaskResult> resultList =
        collectResults(futureList);

因为每个任务已经通过 exceptionally() 转成了结果对象,所以大部分情况下 join() 都能正常返回:

成功结果;

失败结果;

超时结果。

不会因为一个 PDF 损坏,就拿不到其他 PDF 的结果。


9. 打印明细和汇总

每个文件会打印:

文件名;

处理状态;

源路径;

输出路径;

处理信息;

任务耗时。

最后汇总:

总任务数;

成功数量;

失败数量;

超时数量;

整批耗时。

这已经比较接近批量任务结果报告了。


为什么使用 AbortPolicy

这一版线程池用了:

new ThreadPoolExecutor.AbortPolicy()

队列满了以后会抛出:

RejectedExecutionException

然后在提交任务时捕获,并转换成一个失败结果:

PdfTaskResult.fail(
        file.getName(),
        file.getAbsolutePath(),
        "线程池任务已满,提交被拒绝",
        0
)

我在这一版里没有使用 CallerRunsPolicy

原因是我希望:

PDF 任务只在线程池里执行;

提交线程不要突然自己处理一个耗时 PDF;

线程池满了以后,明确记录任务提交失败。

在普通本地脚本里,CallerRunsPolicy 也能用。

但如果以后把代码放到 Web 接口中,AbortPolicy 通常更容易控制接口行为。


为什么每个任务都返回 PdfTaskResult

如果任务直接抛异常:

CompletableFuture<String>

那么一个任务失败时:

CompletableFuture.allOf(...).join();

可能直接抛出 CompletionException

虽然其他任务仍然会继续执行,但结果收集会变得麻烦。

现在每个任务最终都尽量返回:

PdfTaskResult

这样批量任务结果会更统一:

成功也是结果;

失败也是结果;

超时也是结果。

异常不再是批量处理的主要业务返回方式。


单任务超时仍然有一个现实问题

这版代码虽然用了:

orTimeout(...)

但它不能保证底层 PDF 处理马上停止。

可能出现:

CompletableFuture 已经返回超时结果;

PdfWatermarkService 还在后台继续处理;

输出文件仍然可能被写入。

所以真实项目还应该增加临时文件策略。

例如先输出到:

output/test-watermark.tmp.pdf

任务完全成功后再移动为:

output/test-watermark.pdf

如果失败或超时,就清理临时文件。

这部分属于文件任务的完整性设计,不是单靠 CompletableFuture 能解决的。


防止输出文件重名

当前代码使用:

原文件名-watermark.pdf

作为输出文件名。

如果不同目录中有同名 PDF,或者同一任务重复提交,就可能覆盖。

更稳的方式可以加入:

任务 ID;

时间戳;

UUID;

文件内容哈希。

例如:

String targetName =
        UUID.randomUUID()
                + "-"
                + sourceName;

或者:

20260716-任务ID-test-watermark.pdf

是否允许覆盖,要根据业务决定。


真实项目还应该补什么

这版已经把 Java 并发主线串起来了,但距离生产系统还有一些内容。

比如:

任务记录保存到数据库;

接口提交后立即返回任务 ID;

前端查询任务进度;

上传文件大小限制;

PDF 页数限制;

任务重试机制;

临时文件清理;

应用重启后的任务恢复;

多实例环境下的任务分配;

任务幂等;

日志框架;

监控和告警。

如果只是本地批量处理 PDF,这版已经够用了。

如果要做成 Web 服务,就需要把它进一步改造成任务系统。


这版代码里各个并发工具的职责

最后把各个部分再放在一起看。

ThreadPoolExecutor

控制线程数量;

管理任务队列;

复用线程;

提供拒绝策略。

CompletableFuture.supplyAsync

把每个 PDF 包装成有返回值的异步任务。

orTimeout

限制单个 PDF 的结果等待时间。

whenComplete

记录单个任务的执行结果和耗时。

exceptionally

把异常转换成失败或超时结果对象。

allOf

等待整批 PDF 任务全部完成。

join

在最终需要结果的位置获取任务结果。

PdfTaskResult

统一表示成功、失败和超时状态。

这些工具组合起来以后,整个结构就比较清楚了。


这一节小结

这一节我主要完成了这些内容:

1. 使用自定义 ThreadPoolExecutor 控制 PDF 并发数量;
2. 使用 CompletableFuture 为每个 PDF 创建异步任务;
3. 使用 PdfTaskResult 统一表示成功、失败和超时;
4. 使用 orTimeout 限制单个任务时间;
5. 使用 exceptionally 把异常转换成业务结果;
6. 使用 whenComplete 记录任务日志和耗时;
7. 使用 allOf 等待整批任务完成;
8. 统一统计成功、失败、超时数量和整批耗时;
9. 任务处理完成后安全关闭线程池。

用一句话总结:

CompletableFuture 批量处理的重点,不只是把任务并发跑起来,而是把线程池、结果、异常、超时和等待都管理清楚。

下一节继续对比:

Future;

ThreadPoolExecutor;

CompletableFuture。

这三个概念经常一起出现,但它们并不是同一层面的工具。下一节会整理它们分别解决什么问题,以及实际项目中应该怎么选。