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 时,程序的执行顺序到底是什么样。