跳到正文
hello world

2. start工程

发布于阅读量 0

我准备用一个普通的 Maven 工程来写,不准备一开始就放到 Spring Boot 里。

原因也简单:我现在主要想看清楚线程、线程池、CompletableFuture 这些东西本身的执行流程。如果直接放到 Spring Boot 里,请求线程、Controller、Service、容器生命周期这些东西都会混进来,反而不利于观察。

所以前期我就用最普通的 main 方法来跑。

这样有几个好处:

1. main 方法从哪里开始,一眼能看到;
2. 线程是在哪里创建的,也比较清楚;
3. 任务什么时候提交、什么时候结束,日志很好观察;
4. 后面再迁移到 Spring Boot,也只是把线程池做成 Bean 的问题。

并发这个东西,我觉得一开始最重要的不是写得多高级,而是先把执行顺序看明白。


工程结构

项目名我这里就叫:

pdf-watermark

大概结构是这样:

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

input 放原始 PDF,output 放处理后的 PDF。

这两个目录我没有放到 resources 里,因为它们不是程序自带的固定资源,而是运行过程中要读写的数据。

resources 更适合放配置文件、模板文件、固定资源文件。PDF 文件这种输入输出数据,放在项目根目录下更直观,也方便后面批量测试。

代码里直接这样写就可以:

File inputDir = new File("input");
File outputDir = new File("output");

这个路径在本地调试时很好理解,也方便观察文件生成结果。


引入 PDFBox

这个练习项目需要处理 PDF,所以先在 pom.xml 里加 PDFBox。

<dependencies>
    <dependency>
        <groupId>org.apache.pdfbox</groupId>
        <artifactId>pdfbox</artifactId>
        <version>3.0.3</version>
    </dependency>
</dependencies>

如果项目里想用 Lombok,也可以加上:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.36</version>
    <scope>provided</scope>
</dependency>

不过这个专辑里我不会太依赖 Lombok。因为学习并发时,代码稍微啰嗦一点反而有好处,能看清楚对象是怎么创建、参数是怎么传的。


先定义一个 PDF 任务对象

后面不管是用 Thread、线程池,还是 CompletableFuture,本质上都是在处理一个个 PDF 任务。

所以我先定义一个简单的上下文对象,用来描述单个 PDF 的处理信息。

com.succos.dto.FileItemContext

代码如下:

package com.succos.dto;

public class FileItemContext {

    private String sourcePath;

    private String targetPath;

    private String waterMakeText;

    public String getSourcePath() {
        return sourcePath;
    }

    public void setSourcePath(String sourcePath) {
        this.sourcePath = sourcePath;
    }

    public String getTargetPath() {
        return targetPath;
    }

    public void setTargetPath(String targetPath) {
        this.targetPath = targetPath;
    }

    public String getWaterMakeText() {
        return waterMakeText;
    }

    public void setWaterMakeText(String waterMakeText) {
        this.waterMakeText = waterMakeText;
    }
}

这个类没有什么特别的,就是把单个 PDF 处理所需的参数收拢起来。

后面的任务提交,其实就是不断构造 FileItemContext,然后交给水印服务处理。


PDF 水印服务只处理一个文件

我会把真正的 PDF 水印逻辑放到一个单独的 Service 里。

com.succos.service.PdfWatermarkService

这个类只负责一件事:给一个 PDF 加水印

这里我刻意不让它负责遍历目录,也不让它负责创建线程。因为如果一个类既处理文件,又管理线程,再负责批量调度,代码很快就会乱。

我希望它的职责保持简单:

public class PdfWatermarkService {

    public void addWaterMakerOfPDF(FileItemContext fileItem) throws Exception {
        // 读取源 PDF
        // 添加水印
        // 保存到目标路径
    }
}

也就是说:

PdfWatermarkService 负责单个 PDF 的业务处理;
Thread / ThreadPoolExecutor / CompletableFuture 负责批量任务调度。

这个边界分清楚以后,后面学习并发会轻松很多。

否则遇到问题时,很难判断到底是 PDFBox 出错,还是线程调度写得有问题。


先用单线程跑通

在正式写多线程之前,我会先写一个最简单的单线程测试类。

com.succos.demo.PdfWatermarkSimpleDemo

代码大概是这样:

package com.succos.demo;

import com.succos.dto.FileItemContext;
import com.succos.service.PdfWatermarkService;

public class PdfWatermarkSimpleDemo {

    public static void main(String[] args) throws Exception {

        FileItemContext fileItemContext = new FileItemContext();

        fileItemContext.setSourcePath("input/test1.pdf");
        fileItemContext.setTargetPath("output/test1-watermark.pdf");
        fileItemContext.setWaterMakeText("上下文网");

        PdfWatermarkService service = new PdfWatermarkService();

        service.addWaterMakerOfPDF(fileItemContext);

        System.out.println("PDF 水印处理完成");
    }
}

这一步看起来很普通,但我觉得很有必要。

因为并发代码最怕的问题就是:业务逻辑本身还没验证,就直接上多线程。后面一旦报错,排查成本会变高。

所以我会先确认这几件事:

1. input/test1.pdf 能正常读取;
2. output 目录下能生成新 PDF;
3. 水印确实添加成功;
4. 单个 PDF 的处理逻辑没有明显问题。

等单线程版本稳定了,再加并发。

这个顺序比较稳。


为什么先拆成“单文件处理”

这个项目后面会从 Thread 一直写到 CompletableFuture

如果一开始就把批量处理逻辑写死在 PdfWatermarkService 里,后面每换一种并发方式,都要改业务代码。

我更希望后面的结构是这样:

单个 PDF 的处理逻辑不动;
外层调度方式不断变化。

也就是说:

第一版:main 方法里直接调用;
第二版:一个 Thread 处理一个 PDF;
第三版:Semaphore 控制并发数量;
第四版:ThreadPoolExecutor 提交任务;
第五版:CompletableFuture 编排任务结果。

这样每一节变化的点都比较明确。

业务代码保持稳定,并发代码逐步演进。


当前准备好的目录

到这里,工程结构大概是这样:

pdf-watermark
├── input
│   ├── test1.pdf
│   └── test2.pdf
├── output
├── pom.xml
└── src
    └── main
        └── java
            └── com
                └── succos
                    ├── dto
                    │   └── FileItemContext.java
                    ├── service
                    │   └── PdfWatermarkService.java
                    └── demo
                        └── PdfWatermarkSimpleDemo.java

这个阶段不需要急着写线程池。

我先把单文件水印跑通,确认输入输出没问题。后面再围绕这个基础版本,一步一步加并发能力。

下一节开始,就可以进入 Thread 了。先用最原始的方式创建线程,看看多线程处理 PDF 时,程序的执行顺序到底是什么样。