老炮踩坑录 · ARC-D01 · 技术深挖系列
基于「基础档案」项目真实源码,一次 PDF 生成链路的设计复盘
关键词:模板方法 · 工厂模式 · Spring Map 注入 · Optional · finally
👋 欢迎阅读

🏠个人主页: 知守观
📘我的专栏: 老炮踩坑录
💻当前内容:模板方法的真实落地
文章目录
-
- 引言
- [doService 把四步流程固定在基类,子类只实现取模板](#doService 把四步流程固定在基类,子类只实现取模板)
- [PdfFactory 注入整张 Map,枚举的第三个字段是 bean 名](#PdfFactory 注入整张 Map,枚举的第三个字段是 bean 名)
- 参数校验放在填充之前,报错带具体字段名
- [文件名用 Redis 按天自增,补位方法的边界差了一位](#文件名用 Redis 按天自增,补位方法的边界差了一位)
- 骨架边缘还有三处没跟上
- 速查表
- 老炮点评
引言
上一篇讲了基础档案服务里一个 PDF 功能引了四套生成库的事,Aspose、PDFBox、iText、FreeMarker 挤在同一个仓库里。发出去之后有读者留言:这个项目是不是所有代码都写得那么随意。
其实不是。这四套库里负责 FreeMarker 那条链路的两个类,AbstractPdfManager 和 PdfFactory,写法跟仓库里其他代码不在一个水平线上:骨架固定在抽象类,差异留给子类,工厂按类型路由。GoF 对模板方法的定义是把算法骨架固定在一个操作里、让子类填补可变步骤,这两个类是照着定义写的。我当年维护这个项目时读过很多遍,每次读到这段都会停下来多看两眼。
这个项目是我 2024 年前后维护的,离职后把代码拉回本地复盘。老项目没有完美的事,骨架搭得正,边缘还有几处没跟上。这篇把做对的四处和没跟上的三处都摆出来,代码使用全部原文。
doService 把四步流程固定在基类,子类只实现取模板
java
public PdfResultVO doService(PdfTemplateVO pdfTemplateVO, Boolean checkTemplate) {
PdfTemplateInstanceDto pdfTemplateInstanceDto = new PdfTemplateInstanceDto();
try {
LoginUserDTO loginUser = ClientIdentUtil.getLoginUser();
pdfTemplateInstanceDto.setAppCode(pdfTemplateVO.getAppCode());
pdfTemplateInstanceDto.setTemplateCode(pdfTemplateVO.getTemplateCode());
pdfTemplateInstanceDto.setInstanceParams(pdfTemplateVO.getTemplateParams());
pdfTemplateInstanceDto.setDownBucketName(kosProperties.getBucket());
pdfTemplateInstanceDto.setCreateTime(new Date());
pdfTemplateInstanceDto.setCreateUser(loginUser == null ? "sys" : loginUser.getName());
// 获取模板内容
PdfTemplateVO template = getTemplate(pdfTemplateVO, checkTemplate);
// 校验模板参数
preprocesXml(template);
// 填充xml数据
generate(template);
// 生成文件路径
PdfResultVO pdfResultVO = toPDF(template);
pdfTemplateInstanceDto.setDownUrl(pdfResultVO.getUrl());
return pdfResultVO;
} catch (Exception e) {
pdfTemplateInstanceDto.setState(false);
pdfTemplateInstanceDto.setRemark(e.getMessage());
throw e;
} finally {
pdfTemplateInstanceDto.setState(true);
pdfTemplateInstanceMapper.insertSelective(pdfTemplateInstanceConvert.convert(pdfTemplateInstanceDto));
}
}
流程四步:取模板、校验参数、填充数据、转 PDF 上传。顺序固定在基类,生成记录的填充、异常时记下失败原因、finally 里把记录落库,也都在基类。
子类要做的只有一件事:
java
abstract PdfTemplateVO getTemplate(PdfTemplateVO pdfTemplateVO, Boolean checkTemplate);
Word 模板和 XML 模板取法不一样,各自实现各自的取法。取完之后的三步和落库,子类一行不碰。
这个结构的收益在扩展时才兑现。假设要加一种 Excel 转 PDF,新建一个子类继承 AbstractPdfManager,实现 getTemplate,完事。校验、填充、转换、上传、落库全部继承,新增成本只有取模板这一个差异点。
模板方法走样的项目我见得多:基类只声明一个抽象方法,流程散在各个子类里复制粘贴,改一处要跟着改 N 处。这段代码读到的时候我是有点意外的。
PdfFactory 注入整张 Map,枚举的第三个字段是 bean 名
骨架有了,谁决定用哪个子类:
java
@Component
public class PdfFactory {
@Autowired
private Map<String, AbstractPdfManager> PDF_MANAGER_MAP;
public AbstractPdfManager getOrDefault(Integer generateType){
GenerateTypeEnum generateTypeEnum = GenerateTypeEnum.getByCode(generateType).get();
if (null == generateTypeEnum) {
return null;
}
return PDF_MANAGER_MAP.get(generateTypeEnum.getClassName());
}
}
第一个值得说的点 :@Autowired 注的是一张 Map<String, AbstractPdfManager>。Spring 对这种写法有专门支持,容器里所有这个类型的 bean 被收集起来,bean 名做 key,实例做 value,整张 Map 一次注入。两个子类标了 @Component,默认 bean 名就是首字母小写的类名:wordToPdfManager、xmlToPdfManager。
第二个值得说的点在枚举:
java
public enum GenerateTypeEnum {
WORD_PDF(1, "word转pdf", "wordToPdfManager"),
XML_PDF(2, "xml转pdf", "xmlToPdfManager");
}
第三个字段 className 存的就是 bean 名。工厂拿类型编码查枚举,取 className,从 Map 里 get 出子类实例。
路由表因此被拆成两份,枚举管编码到 bean 名的映射,Spring 容器管 bean 名到实例的映射。新增一种生成类型,加一个枚举值、加一个 @Component 子类,工厂零改动。我见过的多数工厂是一串 if (type == 1) return wordManager; else if (type == 2) return xmlManager;,每加一种类型就要动一次工厂类。两种写法的差别在第三次、第四次扩展时才真正体现出来。
参数校验放在填充之前,报错带具体字段名
模板填充有个常见事故:模板里写了 ${order.buyerName},调用方传的 JSON 里没这个字段,FreeMarker 渲染时抛异常,堆栈拉得很长,调用方看完还是不知道少传了什么。
基类的 preprocesXml 把校验挪到了填充之前。它做三件事:用正则从模板里提取全部 ${...} 占位符,用 Jackson 把入参 JSON 解析出全部字段路径,再处理 <#list> 的别名映射,列表元素的字段会还原成列表路径。然后逐个比对:
java
if (realPath == null) {
BizExceptions.throwWithCodeAndMsg("-1", "模板参数中缺少字段:" + item);
}
if (!hasNonNullValue(rootNode, realPath)) {
BizExceptions.throwWithCodeAndMsg("-1", "模板参数中字段" + item + "的值不能为null");
}
缺哪个字段,字段名直接写进报错。调用方拿到消息不用翻模板,照着补参数就行。
fail fast 的价值在联调阶段最明显。这个接口对内提供给其他微服务调用,报错信息的可读性直接决定联调要扯几轮皮。放它进 FreeMarker 再炸,排查成本全都转嫁给了调用方。
文件名用 Redis 按天自增,补位方法的边界差了一位
生成 PDF 要落文件名,并发下不能重名。基类的写法:
java
protected String genBatchNo() {
// 按天自增的序列号
String now = DateUtil.formateDate(new Date(), "yyyyMMdd");
String redisKey = BATCH_NO_SEQ_KEY + now;
Long seq = redisUtil.getSeq(redisKey, SECONDS_PER_YEAR);
return now + toFixStr(seq, LENGTH_OF_BATCH_NO);
}
日期做前缀,Redis increment 做后缀,202610240001 这种格式。getSeq 就是对 increment(key, 1) 的封装,新 key 从 1 开始,原子操作,并发安全由 Redis 保证。key 按天滚动,业务上从文件名能直接看出生成日期。
补位没用 String.format,自己写了 toFixStr:算 10^length,加序号,去掉首位。4 位长度下序号 1 变成 0001。它还处理了溢出,序号撑爆位数时抛异常,不会悄悄生成一个超位数的名字。
不过实读时发现边界差了一位。判断写的是 if (max < n),length 为 4 时 max 是 10000,n 等于 10000 这天,max < n 不成立,不抛异常,返回 String.valueOf(10000 + 10000).substring(1),也就是 0000。正确的拦法应该是 n >= max。这一天当天的第 10000 个文件会拿到一个形如 202610240000 的名字,从第 10001 个起才开始抛。影响面不大,但边界就是边界。
另外这段代码的注释写「后缀暂定3位」,常量 LENGTH_OF_BATCH_NO 实际是 4,注释和代码对不上;SECONDS_PER_YEAR 声明是 public static int,没加 final,项目里任何一处都能改掉这个过期时间。
骨架边缘还有三处没跟上
夸完了,说没跟上的地方。
第一处:finally 里无条件 setState(true),把失败标记压掉了。 回看 doService,catch 里 setState(false) 记了失败原因,finally 里紧跟一句 setState(true)。无论成功失败,落库的记录状态都是成功。生成记录无论成败都落库,这个意图是对的,但状态字段被写死了,按状态根本筛不出失败记录,remark 里存的失败原因等于没人看。这个坑在 ARC-F01 里展开过,这里点一句。
第二处:getOrDefault 的 Optional.get() 让 null 判断成了死代码。 getByCode 返回 Optional,空 Optional 调 .get() 直接抛 NoSuchElementException。执行到第二行时 generateTypeEnum 不可能是 null,那个 if (null == generateTypeEnum) 永远进不去。方法名叫 getOrDefault,实际没有任何 default 路径。传一个非法的类型编码进来,调用方拿到的是 NoSuchElementException,不是业务异常。方法签名承诺的行为和实际行为对不上。
第三处:两个子类的 getTemplate 有八成重复,checkTemplate 参数只有一个子类在用。 差异对比:
java
// XmlToPdfManager:checkTemplate 决定模板缺失时抛不抛
PdfTemplateDto templateDto = pdfTemplateService.getByCode(pdfTemplateVO.getTemplateCode());
if (checkTemplate && templateDto == null) {
BizExceptions.throwWithCodeAndMsg("-1", "模板不存在");
} else if (templateDto == null){
return pdfTemplateVO;
}
// WordToPdfManager:无条件抛,checkTemplate 没出现
PdfTemplateDto templateDto = pdfTemplateService.getByCode(pdfTemplateVO.getTemplateCode());
if (templateDto == null) {
BizExceptions.throwWithCodeAndMsg("-1", "模板不存在");
}
if (GenerateTypeEnum.WORD_PDF.getCode().equals(templateDto.getGenerateType())
&& StringUtils.isEmpty(templateDto.getUpUrl())) {
BizExceptions.throwWithCodeAndMsg("-1", "模板上传文件不存在");
}
两个方法剩下的部分------取模板、前端没传内容就用默认内容、没传参数就用默认参数、setGenerateType------逐行一样。共同部分本可以上移到基类,子类只留校验差异。checkTemplate 写在抽象方法的签名里,Word 版一次都没引用,调用方传这个参数时无法预期行为。WordToPdfManager 里还留着两行注释掉的 Oss 取文件代码,没人知道是暂时的还是废弃的。
还有一件要标记的事:generate() 最终调 FreemarkerUtil.generate,那个工具类每次调用都在静态共享的 Configuration 上 setTemplateLoader,两个线程同时生成,后一个的 loader 会盖掉前一个的,存在串模板的可能。这个问题不在模板方法这条线上,单独值得一篇,这里先标记。
有个细节我没完全确认 :finally 里那句
setState(true),我倾向认为作者的本意是"记录无论如何要落库",覆盖状态字段是写顺手了。但我接手时这段代码就在,没找到当时的提交说明,这个判断只是推测。知道内情的前同事看到了,评论区补一句。
速查表

自查打卡:搜一下你项目里的 finally 块,有几处给入参或字段重新赋了值?数量打在评论区,我看看谁的项目 finally 最干净。
老炮点评
模板方法模式本身不难,难的是在老项目里见到一次不走样的落地。这两个类证明仓库作者里至少有一位真懂这个模式:骨架固定、差异下沉、工厂免改、校验前置、文件名去重,每一处都有明确的意图。
边缘那三处没跟上的,说明的是另一件事:模式管得住结构,管不住每一行的实现质量。finally 里的一个 set、Optional 上的一个 .get()、边界判断里差的一位,模式都拦不住,能拦的只有 review 和速查表这种东西。
我 review 这个项目时也没拦住。finally 那句我当时读到了,脑子里过了一遍「finally 一定会执行」,没往下想它会压掉 catch 里的 false。结构好的代码容易让人放松对单行的警惕,这是我这次复盘最愿意承认的一点。
最后问一句:你项目里的工厂,加一种新类型要改几个文件?一个还是一串?评论区聊聊。
如果本文对你有帮助,欢迎:👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言。
欢迎留言探讨。你的每一次互动,都是我继续更新的动力🚀
下期预告:《JSch 0.1.55 写 SFTP:连接管理、目录创建与上传校验的边界》
下一篇还在基础档案。这个项目的文件传输靠 JSch,版本停在 0.1.55,一个 2018 年就不再更新的库。连接怎么管、远端目录不存在时怎么建、上传完怎么校验,每一步都有边界情况。我把 SftpUtils 逐行过了一遍,下期讲。
我是老炮,18 年 Java 老兵,仍在一线。如果你也面临老项目重构、遗留代码排雷的困扰,欢迎关注我的 CSDN 专栏「老炮踩坑录」,持续分享真实项目复盘。