老炮踩坑录 · ARC-F01 · 技术深挖系列
基于「基础档案」真实源码,复盘一个 PDF 功能背后的依赖构成
关键词:Aspose.Words · PDFBox · iText · FreeMarker · 静默失败
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:PDF 库的混战
引言
基础档案这个项目,需求里写着一条:档案模板支持 PDF格式导出。
就这一句话。我接手的时候以为最多两三个类,翻完之后发现 PdfUtil.java 一个文件 347 行,import 区同时出现了四个不同厂商的包名:
import com.aspose.words.Document;
import com.lowagie.text.pdf.PdfReader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
上面还有 freemarker.template.Configuration,在 FreemarkerUtil 里。
四套库,一个功能。写这段代码的人水平没问题,每一套都用在能跑的地方。问题是这四套为什么同时存在,以及它们各自承担了什么、又被什么绑在了一起。
我把这条链路从头到尾读了一遍,下面按实际代码讲解。
现场:pom 里四个和 PDF 有关的依赖
web子模块的 pom.xml 里的原文:
<dependency>
<groupId>com.aspose</groupId>
<artifactId>aspose-words</artifactId>
<version>16.8.0-SNAPSHOT</version>
</dependency>
<!-- pdfbox -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>com.lowagie</groupId>
<artifactId>itext</artifactId>
<version>2.0.8</version>
</dependency>
第四个是 FreeMarker,由 spring-boot-starter-freemarker 带进来,没写版本号。
严格说 FreeMarker 是模板引擎,不属于 PDF 库。它在这条链路里负责把模板里的 ${} 和 <#list> 填上数据,生成一段 XML,再交给 Aspose 转成 PDF。所以更准确的说法是:一个 PDF 导出功能,串起了四套技术,其中三套是 PDF 处理库。
三个版本号放在一起看,年份差着九年。Aspose.Words 16.8 发布于 2016 年,PDFBox 2.0.9 发布于 2018 年,iText 2.0.8 在 2009 年前后。iText 2.x 的坐标还是 com.lowagie,iText 5 之后才换成 com.itextpdf。一个 2023 年的项目里出现十几年前的库,通常意味着当初抄的某段示例代码没换。
四套库各自在哪一行
PdfUtil.java 里的方法职责分得很清楚,清楚到能看出是分批加进来的。
Aspose.Words 承担了格式转换和文本提取,一共四处:
public PdfResultVO stream2pdf(InputStream stream, String pdfName) // 流 → PDF
public String stream2word(InputStream stream, String pdfName) // 流 → DOCX
public String file2xml(InputStream stream, String fileType) // 名义上转 XML
protected static boolean getWordLicense() // 授权校验
PDFBox 承担页面级的操作,两处:
public List<String> splitPdf(int pageNo, InputStream in, List<String> fileNameList)
public String pdf2Image(byte[] byteArr, String imageName)
iText 只有一行,我一开始以为是看错了:
PdfReader reader = new PdfReader(byteArr);
int pages = reader.getNumberOfPages();
就为了拿一个总页数。
FreeMarker 在 AbstractPdfManager.generate() 里:
template = FreemarkerUtil.generate(pdfTemplateVO.getTemplateContent(), fileName, jsonField);
pdfTemplateVO.setTemplateContent(template);
填完的模板内容,下一行直接塞进 pdfUtil.stream2pdf()。链路的先后关系是 FreeMarker → Aspose,PDFBox 和 iText 在另一条支线上。
iText 只为了一行 getNumberOfPages
pdf2Image 是我觉得最值得单说的方法。它的目的是把 PDF 的每一页渲染成图片,再纵向拼成一张长图。
进来之后先干两件事:
PdfReader reader = new PdfReader(byteArr);
in = new ByteArrayInputStream(byteArr);
pdDocument = PDDocument.load(in);
一份字节数组,交给两个 PDF 库各自解析了一遍。iText 拿到的结果只用了一次——reader.getNumberOfPages()。PDFBox 拿到的 pdDocument 下面还要用。
而 PDFBox 自己就有这个能力:
pdDocument.getNumberOfPages()
一行,不需要额外引入 iText。所以我很难给出一个技术上的理由解释这行为什么存在。能想到的只有:写这个方法的人当时手里对照的示例代码里有 iText,就照着抄了,PDFBox 那半边也照着抄了,两段拼在一起没人回头看。
不排除另一个可能——项目里别的模块还在用 iText 做别的事,我这次只排查了 PDF 相关的调用,没有全局搜。如果是这样,删 iText 要连带处理别的地方。这一点我没有验证到底。
有个细节我没完全确认:我怀疑写这个方法的人,大概率是照着网上的示例代码抄的,因为 PDFBox 自己就有
getNumberOfPages()。有没有兄弟在重构老项目时,删掉过这种“只为了用一行 API 而引入的庞大依赖”?删完之后有没有引发过其他的连锁报错?评论区指点一下。
拆分和转图片,也要先验证 Word 授权
splitPdf 和 pdf2Image 跟 Word 文档没有关系,它们处理的是字节流形式的 PDF。但两个方法的开头都有一段一样的代码:
if (!getWordLicense()) {
return null;
}
getWordLicense() 里加载的是 license.xml,本质上是用来校验 Aspose.Words 的商业授权是否有效。
问题不在于授权校验该不该有,问题出在它把这四套库的可用性绑在了一起。
Aspose 的授权哪天失效——证书文件被替换、打包时漏了 license.xml、换了部署路径,任何一种都可能——getWordLicense() 返回 false,然后:
stream2pdf返回 nullstream2word返回 nullfile2xml返回 nullsplitPdf返回 nullpdf2Image返回 null
五个方法一起失效,包括两个完全不用 Aspose 的方法。
这种联动最常见的触发点是打包。license.xml 没进 jar,或者进了一个内容不对的同名文件,现场表现就是"PDF 拆分偶尔失败",日志里翻出来的却是 Word 授权异常。
授权失败这条路,返回值是 null
getWordLicense() 的实现:
protected static boolean getWordLicense() {
boolean result = false;
try {
InputStream is = PdfUtil.class.getClassLoader().getResourceAsStream("license.xml");
com.aspose.words.License asposeLic = new com.aspose.words.License();
asposeLic.setLicense(is);
result = true;
} catch (Exception e) {
Logs.error("get word lincense error {}", ExceptionUtils.getStackTrace(e));
}
return result;
}
失败时返回 false,调用方 return null。上层拿到 null 之后是什么反应,取决于它在哪里被调:
PdfTemplateController.generatePdf 看起来判断了 null:
PdfResultVO pdfResultVO = pdfManager.doService(pdfTemplateVO, false);
if (pdfResultVO.getUrl() == null) {
BizExceptions.throwWithCodeAndMsg("-1", "文件生成失败");
}
这个判断实际走不到。授权失败时 stream2pdf 返回 null,doService 里接下来那行 pdfTemplateInstanceDto.setDownUrl(pdfResultVO.getUrl()) 先抛了 NullPointerException,异常被 catch 记录、再抛给 Controller。调用方看到的是一句 NPE 堆栈,跟"授权"两个字没有任何关系。
另外几个方法的失败表现更不明确。splitPdf 返回的是 filePathList,里面是空的;调用方如果只检查长度,可能当成"没有页需要拆分"。
还有个细节:lincense 这个词拼错了。不影响运行,说明这段代码从写下到上线,没有人在日志里翻到过它。
三个方法的错误日志,名字都叫 word2pdf
stream2pdf、stream2word、file2xml 的 catch 块:
} catch (Exception e) {
Logs.info("word2pdf error " + ExceptionUtils.getStackTrace(e));
return null;
}
三处消息文本一样,级别都是 info。
stream2word 出错报的是 word2pdf,file2xml 出错报的也是 word2pdf。哪天这条链真的出问题,你看日志只会知道"某个跟 word2pdf 有关的东西失败了",分不清是转 PDF 失败、转 Word 失败,还是提文本失败。
级别选 info 这个事单独说一句。错误日志用 info 打,在大部分日志框架的默认配置下是能看到的,但如果线上把日志级别调到 warn 以上,这三类故障就完全不见了。
同一个文件里,splitPdf 和 pdf2Image 用的却是 Logs.error。两种级别并存,说明这个约定在文件内部都没统一过。
finally 把失败记录改成了成功
这一处不在 PdfUtil,在 AbstractPdfManager.doService,是这次读代码时我觉得最需要拿出来说的:
try {
// ……
return pdfResultVO;
} catch (Exception e) {
pdfTemplateInstanceDto.setState(false);
pdfTemplateInstanceDto.setRemark(e.getMessage());
throw e;
} finally {
pdfTemplateInstanceDto.setState(true);
pdfTemplateInstanceMapper.insertSelective(pdfTemplateInstanceConvert.convert(pdfTemplateInstanceDto));
}
catch 里把 state 设成 false,然后把异常抛出去。finally 无条件执行,第一件事就是 setState(true),紧接着插库。
结果就是:失败记录落库时,状态是成功。
remark 字段倒是留着异常信息。可如果谁查这张表的时候只看 state,看到的全是成功。想统计失败率,这张表给不出数字。
写这段代码的人大概是想保证"一定会留记录",这个意图没问题。问题在于 finally 里不该改状态。把 setState(true) 挪到 try 块的末尾,或者干脆在 finally 里不碰 state,行为就对了。
WORD_PDF 这个分支从来没有被调用过
GenerateTypeEnum 里定义了两个类型:
WORD_PDF(1, "word转pdf", "wordToPdfManager"),
XML_PDF(2, "xml转pdf", "xmlToPdfManager");
PdfFactory 按类型从 Spring 容器里取对应的 manager:
public AbstractPdfManager getOrDefault(Integer generateType){
GenerateTypeEnum generateTypeEnum = GenerateTypeEnum.getByCode(generateType).get();
if (null == generateTypeEnum) {
return null;
}
return PDF_MANAGER_MAP.get(generateTypeEnum.getClassName());
}
这里有个小问题:getByCode 返回的是 Optional,直接 .get(),传进来的 code 不存在就抛 NoSuchElementException。下面那个 null 判断永远不成立——Optional.get() 不会返回 null,取不到值就直接抛异常了。
真正的问题是 WORD_PDF 用不上。三个调用点,全是 XML_PDF:
// PdfTemplateController.generatePdf
AbstractPdfManager pdfManager = pdfFactory.getOrDefault(GenerateTypeEnum.XML_PDF.getCode());
// PdfTemplateServiceImpl.getApiPdf
AbstractPdfManager pdfManager = pdfFactory.getOrDefault(GenerateTypeEnum.XML_PDF.getCode());
// PdfDemoController.test
AbstractPdfManager pdfManager = pdfFactory.getOrDefault(GenerateTypeEnum.XML_PDF.getCode());
WordToPdfManager 里的 OSS 下载那两行,注释还留在代码里:
// OssObject ossObject = opsUtil.get(templateDto.getUpUrl());
// pdfTemplateVO.setTemplateContentStream(ossObject.getInputStream());
也就是说"word 转 pdf"这个分支,模板文件从来没有真被下载过。WordToPdfManager.getTemplate 比 XmlToPdfManager 多出来的,只是一个 upUrl 非空校验。真跑起来,它走的还是 FreeMarker 填 XML 再交 Aspose。
我推断这个分支是做了一半停下的。PdfTemplateController.save 里还有一行写死的赋值:
pdfTemplateDto.setGenerateType(GenerateTypeEnum.XML_PDF.getCode());
新增和更新两个接口都把它设成 XML_PDF,数据库里 generate_type 这一列永远是 2。也就是说,从入口开始就没给 WORD_PDF 留过机会。
如果要收敛,从哪下手
四个库都留着不是不行,但得说清楚各管什么,别互相绑。
第一步,iText 删掉。 把 getNumberOfPages() 换成 pdDocument.getNumberOfPages(),整个依赖可以移出 pom。前提是先全仓扫一遍 com.lowagie,确认别的模块没用。我这次只看了 PDF 相关的类,没做全仓确认。
第二步,把授权校验挪出 PDF 工具类。 getWordLicense() 现在的调用位置在五个方法内部,实际上是一个应用启动时就该确定的前置条件。写个 @PostConstruct 校验一次,失败就启动阶段记录明确错误;运行期不再逐方法判断。这样 PDF 拆分和转图片就不会被 Word 授权牵连。
顺带把 license 校验缓存一下。现在的写法每次调用都要 new License() 再 setLicense(),五个方法各自进来一遍,一天下来这个动作做了多少次没有统计过。
第三步,错误信息按方法区分。 三个 catch 块各自写各自的:"stream2pdf error"、"stream2word error"、"file2xml error",级别从 info 提到 error。日志这里花不了多少时间,省下的是以后排查的一整个下午。
第四步,file2xml 要么改实现,要么改名。 这个方法名字是转 XML,实际做的是:
Document doc = new Document(stream);
doc.save(os, com.aspose.words.SaveFormat.DOCX);
return doc.getText();
存了个 DOCX 到临时目录,然后返回纯文本。os 和文件都没关。调用方如果以为拿到的是 XML,后面会踩到。
第五步,WORD_PDF 分支做个决定。 要么把 OSS 下载那两行补完,让 word 模板真的能上传能用;要么把这个枚举值、WordToPdfManager 整个类一起删掉。留着一个永远进不去的分支,比没有更费解——下一个人读代码时会花时间搞清楚它为什么在,然后发现它不在也行。
第六步,finally 里别改状态。 这一条改动最小,收益最直接。
自查清单:五个可以直接搜的关键字
自查打卡:翻一下你们项目里那些 "导出 PDF"、"生成 Excel" 的工具类,数数硬编码了多少种库。评论区报个数,我看看有没有比我这个四套还多的。
另外,如果你们项目里也有
finally里改状态的写法,这个比多引几个库危险,建议先查它。
老炮点评
这四套库来自不同的时间点。从版本号能看出纵向的时间线:2009 年前后的 iText、2016 年的 Aspose、2018 年的 PDFBox。版本本身差着九年,被引进这个项目的时间线还要更长。每个接手的人遇到一个具体需求,就补一套最顺手的工具。
补的时候都不算错。要拆 PDF 页,PDFBox 确实好用;要转 Word 到 PDF,Aspose 的中文排版效果比免费方案稳;要填模板,FreeMarker 语法简单。每一步单独看都合理。
攒在一起,就成了现在这样:一个 347 行的工具类,五个方法被同一个授权判断绑住,三个 catch 报同一个错误名,一个分支从来没被调用过,一个 finally 会把失败改成成功。
我不打算说"当初应该统一选型"。那个决定不在某一个接手人手上。能说的是,这种局面到一个节点需要有人专门盘一次,把"各自能用" 整理成 "整体说得清"。盘一次的成本,比我这次读代码花的时间少。
最后问一句
你们项目里那个"导出 PDF"或者"生成报表"的工具类,现在引了几套库?
有没有遇到过授权文件或者密钥文件漏打包,导致一串不相干的功能一起失败的情况?评论区说说,我看看这种连锁失效有多常见。
下期预告:《AbstractPdfManager + PdfFactory:老项目里难得的标准模板方法落地》
下一篇还在基础档案,讲支撑这条 PDF 链路的那个抽象类。它把"取模板、校验参数、填充数据、转 PDF"四步固定成骨架,子类只实现第一步。这套写法在这个项目里算规整的一处,值得单独拿出来看它哪里做对了,以及骨架边缘还有几处没有对齐。