设计模式实战:从真实老项目中看标准模板方法与工厂的落地

老炮踩坑录 · ARC-D01 · 技术深挖系列

基于「基础档案」项目真实源码,一次 PDF 生成链路的设计复盘

关键词:模板方法 · 工厂模式 · Spring Map 注入 · Optional · finally

👋 欢迎阅读

🏠个人主页: 知守观

📘我的专栏: 老炮踩坑录

💻当前内容:模板方法的真实落地

文章目录

引言

上一篇讲了基础档案服务里一个 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 专栏「老炮踩坑录」,持续分享真实项目复盘。

相关推荐
独隅1 小时前
Claude API 实战教程:从入门到完整项目
android·java·数据库·claude·claude code
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 圈子功能实现:总结
java·开发语言·交友
SL_staff2 小时前
JVS私有化文档系统上线避坑指南:5大运维与业务协同断点及检测加固实践
java·开源·全栈
FinelyYang3 小时前
考试系统简答题 AI 阅卷:覆盖度驱动、异步评分与人机协同落地实践
java·大数据·人工智能
hhhjhl3 小时前
Yup 跨端三方库仓颉适配全流程
java·开发语言
\光辉岁月/4 小时前
3.spring5-AOP
spring·代理模式·aop·spring5
用户3126874877204 小时前
Java 对象内存布局:压缩指针、对齐填充与 JOL 实测
java
东华果汁哥4 小时前
【Spring AI 实战】从零开始调用大模型接口,构建 Java AI 应用
java·人工智能·spring
Song_RsHt4 小时前
Redis 与微服务,什么场景下无可替代
java·redis·后端·学习·微服务