夜雨聆风学习资料网

ARTICLE · 1238323

一个导出 PDF 的功能,我居然在仓库里翻出了四套技术栈

一个导出 PDF 的功能,我居然在仓库里翻出了四套技术栈

老炮踩坑录 · ARC-F01 · 技术深挖系列基于「基础档案」真实源码,复盘一个 PDF 功能背后的依赖构成关键词:Aspose.Words · PDFBox · iText · FreeMarker · 静默失败

👋 欢迎阅读

引言

基础档案这个项目,需求里写着一条:档案模板支持 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)   // 流 → PDFpublic String stream2word(InputStream stream, String pdfName)       // 流 → DOCXpublic String file2xml(InputStream stream, String fileType)         // 名义上转 XMLprotected 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 返回 null
  • • stream2word 返回 null
  • • file2xml 返回 null
  • • splitPdf 返回 null
  • • pdf2Image 返回 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.generatePdfAbstractPdfManager pdfManager = pdfFactory.getOrDefault(GenerateTypeEnum.XML_PDF.getCode());// PdfTemplateServiceImpl.getApiPdfAbstractPdfManager pdfManager = pdfFactory.getOrDefault(GenerateTypeEnum.XML_PDF.getCode());// PdfDemoController.testAbstractPdfManager 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"四步固定成骨架,子类只实现第一步。这套写法在这个项目里算规整的一处,值得单独拿出来看它哪里做对了,以及骨架边缘还有几处没有对齐。

相关学习资料