☰
Java后端消除重复代码:工厂模式+模板方法+反射+Bean拷贝实践
2026/10/6 5:59:21 网站建设 项目流程

在Java后端待久了,天天和处理重复代码的人打交道。新渠道接入,来了个和旧渠道几乎一模一样的流程;新接口校验,又是复制粘贴的那一套;DO转VO,80行getter/setter抄来抄去。针对这三类问题,我的解法就是:工厂模式+模板方法固定流程,注解+反射收拢横切逻辑,Bean拷贝接管对象属性搬运。这套组合拳在多个项目里跑过,代码量下降明显,新增渠道从两天压缩到半天。

这篇博客就按这三招拆开讲,最后附上一个支付渠道对接的重构实例和踩坑记录。适合谁看?如果你是写业务逻辑的Java工程师,被if-else和重复代码磨得没脾气;如果你在面试前想梳理设计模式和反射的实际应用;如果你接手老旧项目准备做一次低成本重构。这三招都能直接落地,不需要引入重量级框架,实测下来反而比某些“高级”方案更稳。

1. 前言:三个上线前夜的“紧急救火”

先说三个我真实经历过的上线前夜。第一个:凌晨一点,产品跑过来说新渠道明天必须上线,你打开代码一看,新渠道和上个月做的渠道几乎一模一样,只是签名算法、字段命名、回调地址不同。怎么办?复制粘贴,改吧。于是Controller、Service、DTO、Utils各多出一份“孪生代码”。第二个:全项目每个Controller的入参都有一坨手写的非空校验、日志打印、参数转换,横竖看不顺眼,但没人敢动,因为一动就牵连十几个类。第三个:从数据库实体DO转VO,整整80行的Getter/Setter,复制过来复制过去,一个字段新增忘改,线上直接报空指针。

这三个场景,对应标题里的三招,刚好是一一对应的:工厂模式+模板方法管“流程重复”,注解+反射管“横切逻辑重复”,Bean拷贝管“属性搬运重复”。我最初是分开用的,后来在一个支付渠道项目里把这套组合拳打完整,效果比我预想中好很多。下面拆开讲。

2. 绝招一:工厂模式+模板方法,把“新增场景”变成“填空”

2.1 先看模板方法:把流程固定,把变化留给子类

先说原理。模板方法模式的核心,是把一套业务流程的骨架固化在父类里,把每一步具体做什么交给子类覆盖。生活里最典型的例子是煮泡面:烧水、放面、加料包、等三分钟,这是骨架;加什么料包、放不放蛋、熟度多久,这是子类。你不需要每次重新发明煮面流程,只需要按“填空”的方式完成差异部分。

在Java业务代码里,最常见的模板场景是:参数校验、业务处理、结果组装、日志记录。这四个步骤几乎每个接口都有。我见过不少代码把校验、日志、组装散落在每一个方法里,第101个接口又来一遍。用模板方法改造后,父类里定义一个public final的execute方法,统一安排顺序,子类只实现doValidate、doProcess、doAssemble这些钩子方法。顺序固定了,重复逻辑自然没地方藏身。

2.2 工厂模式:用Map代替一长串if-else

有了模板,还需要知道“按什么条件选哪个实现”。工厂模式在这里的职责是:把创建对象的逻辑收拢到一个类里,调用方不再写switch/if-else判断类型,只管通过一个key或枚举去取。很多Java项目里的渠道接入、优惠策略、通知类型,都是典型的多分支场景。

我最常用的是“Map+初始化注册”的写法:在工厂类里维护一个Map,key是渠道编码或业务类型,value是模板子类实例。Spring环境下,把子类注入到List里,启动时自动注册;非Spring环境,也可以用静态代码块手动注册。实际效果非常直观:原来A渠道、B渠道、C渠道三套if-else,现在new一个渠道实现类加一行注册就完事。业务逻辑不再有分支,要加渠道就像在菜单上添一道菜。

补充一下选型理由:为什么不用策略模式?策略模式解决的是“同一目标的不同算法”,模板方法解决的是“同一流程的不同步骤”,两者可以叠加使用,但我更建议业务接入场景先上模板。因为大多数接入类需求的差异,不仅仅是算法不同,而是校验、组装、回写各环节都有差异,单用策略模式还得在自己方法里重新编排一遍流程。

2.3 一个能直接抄的示例:通用渠道处理器

用一个支付渠道的例子来说。先定义一个处理器父类:

public abstract class AbstractChannelHandler<T, R> { // execute方法固定流程,子类不要重写 public final R execute(T request) { logRequest(request); validate(request); R result = doProcess(request); assembleResult(request, result); logResult(request, result); return result; } protected abstract void validate(T request); protected abstract R doProcess(T request); protected void assembleResult(T request, R result) { // 默认空实现,子类按需覆盖 } private void logRequest(T request) { // 统一请求摘要日志 } private void logResult(T request, R result) { // 统一结果摘要日志 } }

接着是工厂。为了兼容Spring和纯Java两种用法,我用一个双重注册的设计:

@Component public class ChannelHandlerFactory implements ApplicationContextAware { private static final Map<String, AbstractChannelHandler> HANDLERS = new ConcurrentHashMap<>(); private static ApplicationContext context; // 提供静态方法,供非Spring组件调用 public static AbstractChannelHandler getHandler(String channel) { return HANDLERS.get(channel); } // 子类在构造器/初始化时调用此方法注册 public static void register(String channel, AbstractChannelHandler handler) { HANDLERS.put(channel, handler); } @PostConstruct public void init() { // 从Spring容器里取出所有AbstractChannelHandler类型实例 Map<String, AbstractChannelHandler> beans = context.getBeansOfType(AbstractChannelHandler.class); beans.forEach((name, handler) -> { // 这里约定子类上标注@ChannelType注解 ChannelType type = handler.getClass().getAnnotation(ChannelType.class); if (type != null) { register(type.value(), handler); } }); } @Override public void setApplicationContext(ApplicationContext applicationContext) { context = applicationContext; } }

这里有个我踩过的坑:如果用getBeansOfType自动注册,子类一旦实现接口被代理,getAnnotation可能拿不到注解。解决办法是让注册逻辑和实现类解耦,比如在实现类里实现一个getChannel()方法,工厂直接调用这个方法做key。或者更稳妥一点,放弃自动注册,在工厂的init里显式注册所有实现。代码略长,但排查问题非常顺畅。

调用侧就干净了:

ChannelHandler handler = ChannelHandlerFactory.getHandler(order.getChannel()); if (handler == null) { throw new BizException("不支持的渠道编码"); } handler.execute(new ChannelRequest(order));

2.4 使用模板+工厂的注意事项

第一,模板父类里execute方法必须设计为final,否则子类一旦重写,流程骨架就散了,“固定流程”就成了摆设。第二,不要在父类构造器里调用子类方法,Java对象初始化顺序会导致子类字段尚未赋值就执行了钩子方法,出现空指针的风险极高。第三,工厂里的Map要控制并发初始化,我用ConcurrentHashMap,注册动作放在启动阶段完成,避免运行期动态注册引发可见性问题。第四,抽象出钩子方法时,不要贪多,五六个钩子以上的流程,子类理解成本反而上升。

还有一个容易被忽视的点:模板方法会带来类数量增加。一个渠道一个子类,十个渠道就是十个类。如果渠道差异极小,只是参数不同,直接用工厂返回配好参数的同一个子类反而更好。类数量不是越多越优秀,合适的抽象边界才是关键。

3. 绝招二:注解+反射,一句注解干掉一坨重复逻辑

3.1 注解的本质:元数据,不是魔法

先讲清楚一个常见误解:注解本身不包含任何执行逻辑,它只是一段结构化元数据,附着在类、方法、字段上。真正干活的是“读到这个注解并执行逻辑”的那个处理器。就像快递单上的“易碎”标签,标签本身不会搬运箱子,是搬运工看到了标签才轻拿轻放。所以用注解+反射的第一步,不是写注解,而是想清楚:谁来读?读到了做什么?什么时候执行?

我给团队定过一个选择题标准:如果一份重复逻辑只是为了“标注某类字段/方法需要额外处理”,那就用注解;如果是运行时动态找实现类、动态操作对象,那就用反射;如果两者都要,就用自定义注解搭配反射处理器。项目里落地最多的三类:字段脱敏、参数非空校验、操作日志。下面拿字段脱敏讲透,其他案例按同一套思路套就行。

3.2 手写注解+反射处理器:从定义到执行

定义注解很直接:

@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Sensitive { SensitiveType type() default SensitiveType.MOBILE; }

注意RetentionPolicy一定要选RUNTIME,因为我们要在运行时用反射读取。CLASS级别在源码编译后仍存在于class文件,但JVM运行时不保证加载,反射拿不到。

然后写一个处理器。以手机号脱敏为例:

public final class SensitiveFieldProcessor { private SensitiveFieldProcessor() {} public static void process(Object obj) { if (obj == null) { return; } Class<?> clazz = obj.getClass(); for (Field field : clazz.getDeclaredFields()) { Sensitive sensitive = field.getAnnotation(Sensitive.class); if (sensitive == null) { continue; } field.setAccessible(true); try { String value = (String) field.get(obj); if (value == null || value.length() < 7) { continue; } field.set(obj, mask(value)); } catch (IllegalAccessException e) { // 记日志,不要随便吞 } } } private static String mask(String value) { return value.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"); } }

写好之后,在返回VO的字段上直接标注:

public class UserVO { @Sensitive(type = SensitiveType.MOBILE) private String mobile; private String email; }

调用侧一行搞定:

UserVO vo = userService.getById(id); SensitiveFieldProcessor.process(vo);

这里有几个容易翻车的细节。第一,getDeclaredFields只能拿到当前类声明的字段,父类字段得递归或配合getFields注意可见性区别。第二,字段类型不是String时,直接强转会ClassCastException,可以在注解里加expectedType或在处理器里统一校验。第三,频繁反射同一个Class会有一定性能损耗,但业务系统里一次出参处理几十个字段,毫秒级以下,基本无感,不需要在这个场景里过度优化。

3.3 与Spring AOP结合,让零侵入变成常态

光有反射处理器还不够,因为每个方法都要手动调用process,还是有重复。我的做法是把处理逻辑放AOP切面里,彻底消灭调用点。举个例子,写一个切面,专门负责将Controller返回值做脱敏:

@Aspect @Component public class SensitiveAspect { @Around("@annotation(com.example.annotation.SensitiveResponse)") public Object process(ProceedingJoinPoint pjp) throws Throwable { Object result = pjp.proceed(); if (result == null) { return null; } if (result instanceof Collection<?>) { ((Collection<?>) result).forEach(SensitiveFieldProcessor::process); } else { SensitiveFieldProcessor.process(result); } return result; } }

然后在Controller方法或类上标注@SensitiveResponse,返回的VO字段再标@Sensitive,一行注解都不用在业务代码里出现。这个方案在项目里跑了半年,很稳。新的业务开发人员接入成本也低,只需知道“出参对象要脱敏就加注解”这一条规则。

这里要提醒一个实践教训:AOP只对通过Spring代理调用的方法生效,同类内部this调用不走代理,注解不生效。解决方法是把逻辑拆到另外一个被Spring管理的Bean里,或者用AopContext.currentProxy。我之前在基类里写了一个public方法然后子类内部直接调用,注解一直不触发,排查很久才发现是自调用问题。

3.4 注解+反射的边界与性能

很多人一听到反射就觉得慢。我的体感是:在业务系统中,反射主要用在启动期的注解扫描、框架初始化和少量数据的对象操作,真正高频的循环反射要让位给缓存和编译期方案。如果你确实有每秒上万次的反射调用需求,建议用两种手段加速:第一,做Field/Class缓存,把getDeclaredFields、getAnnotation的结果缓存到本地Map,避免每次反射查询;第二,用MethodHandle或者直接上MapStruct这类编译期代码生成。

还有一种特殊情况值得单独说:非public字段。setAccessible(true)在Java 17以上模块系统里不再是无脑可用,要追加--add-opens参数,否则抛InaccessibleObjectException。如果项目是JDK 17+,建议设计时尽量用public方法代替直接改私有字段,或者就把目标字段定义为包内可见,减少模块限制的烦恼。

关于注解丢失,我经历过一个非常诡异的场景:同一个注解类,在Spring Boot 3.x + Gradle增量编译时,偶尔出现运行时reflect拿不到注解的情况。后来定位是增量编译把class文件覆盖得不够完整,注解元数据丢了,解决方案是clean后重新build,并把构建进程关闭或使用稳定的增量编译配置。这个坑在很多讨论里都有提及,实际就是编译期和运行期字节码不一致导致的,建议遇到先clean verify,再怀疑代码问题。

4. 绝招三:Bean拷贝,请个“搬运工”来复制属性

4.1 别再手写Getter/Setter搬运工

先说一个最朴素的问题:DO转VO、VO转DTO、第三方接口的Request转我们内部的Model,这些场景里手写赋值代码到底好不好?短期看,IDE自动生成很爽;长期看,字段一旦增加,所有拷贝点都要同步改,漏改一个就是运行时数据缺失。更麻烦的是,多个系统间字段名不同(比如cmdno和commandNo),纯手写代码就会变成一场找字段的捉迷藏。

Bean拷贝工具的本质,就是“按名字把源对象的属性值搬到目标对象”。你不需要关心每个字段的Getter/Setter怎么写,只需要告诉它“把a拷到b”,剩下的交给反射或编译期代码。用生活类比:搬家的时候,你不会自己把每个抽屉里的东西一个个搬上楼,而是让搬运工根据贴好的标签把东西归位。这个搬运工,就是Bean拷贝库。

4.2 三个主流拷贝方案的对比与选型

我在实际项目中用过三种方式,简单列个对比:

方案实现原理性能适用场景风险点
Spring BeanUtils.copyProperties运行时反射中等同名字段多、项目里已依赖spring-beans类型不同会忽略与否需仔细看版本;source为null会NPE
Apache Commons BeanUtils.copyProperties运行时反射较慢老项目历史遗留有类型转换开销,性能差,避免新用
MapStruct编译期生成Mapper实现很高字段多、性能敏感的大型项目需要额外插件;不如反射“零配置”

Spring的BeanUtils是我们日常最常用的,因为它不需要引入额外依赖,只要项目是Spring生态,直接把org.springframework.beans.BeanUtils引进来就能用。Apache那个我建议新项目慎用:它的copyProperties会把源Bean转成Map再设值,性能差不少,而且遇到不同类型的字段转换还会悄悄出错。

用的时候有一个非常实用的点:忽略不需要拷贝的字段,例如从DO转VO时不想带出createTime。Spring版本提供了入参忽略字段的重载:

BeanUtils.copyProperties(source, target, "createTime", "updateTime");

但要注意,忽略逻辑按字段名来,不是按类型。如果两个对象字段名相同但语义不同(比如source里的amount单位是分,target里的amount单位是元),千万别用BeanUtils,老老实实手写转换或在工具里做单位换算。

4.3 MapStruct:编译期生成的“最优解”

如果项目字段流转非常多,对性能也有要求,我强烈建议考虑MapStruct。它的原理是在编译期自动生成一个Mapper实现类,调用它的方法本质是直接赋值,不经过反射,所以性能接近手写。

@Mapper(componentModel = "spring") public interface UserConvertor { UserVO toVO(UserDO userDO); @Mappings({ @Mapping(source = "cmdno", target = "commandNo"), @Mapping(target = "createTime", ignore = true) }) ChannelRequest toRequest(ChannelConfigDO configDO); }

使用时的注意点:第一,需要在pom里引入mapstruct-processor,并和lombok同时使用时注意annotationProcessorPaths的顺序,否则会出现“找不到getter/setter”的诡异报错。第二,多个源对象合成一个目标时,@Mapping参数要写完整对象名,例如toVO(UserDO userDO, RoleDO roleDO),当多个源里有同名字段时,需要显式指定。第三,List拷贝同样是一行方法声明,MapStruct自动生成遍历逻辑,这个体验非常舒服。

4.4 Bean拷贝容易踩的坑

我整理四个高频坑。

第一,空指针与null策略。Spring BeanUtils拷贝时,如果源对象某个字段为null,默认会把null覆盖到目标字段,目标里已有的值会被清空。做更新操作时,我通常先查DB得到已有对象target,再拷贝入参到target,如果入参某个字段没传,DB老数据就被覆盖成null了。解决办法是写一个工具方法,用PropertyDescriptor遍历目标字段,只拷贝源对象里非null的字段。

第二,字段类型不一致。source是Integer,target是Long,Spring BeanUtils的默认行为是直接抛异常还是忽略,不同版本不一致。我在Spring 5.3.x上实测过,会尝试类型转换,转换失败抛异常;转换成功则正常赋值。所以涉及类型不一致时,不要依赖默认行为,建议显式转换或用MapStruct指定转换方法。

第三,集合与嵌套对象。BeanUtils只做浅拷贝,target里的嵌套List或Map引用会直接指向source的同一份引用。一旦你改了target里的嵌套对象,source也会被改。想深拷贝就得自己处理,或者用序列化方式,但序列化方案性能差,一般业务场景不推荐。

第四,字段名别看错。doghouse和dogHouse,idea和Id,这类大小写差异会让“按名拷贝”直接失败。排查方法很简单:打开两个类,用IDE的Compare功能过一遍字段名,或者干脆写单元测试把所有字段对齐关系断言一遍,比上线后发现问题强得多。

5. 三招的边界:什么时候不适合用

5.1 过度设计的危险信号

没有银弹,三招也各有代价。工厂+模板会增加类层次,注解+反射会引入元编程的调试难度,Bean拷贝会把字段映射关系“藏”到底层。用它们的最终目的只有一个:用可维护性换掉重复代码。如果系统里只有两个固定渠道、满足十年不变量,你和我说要用工厂和模板方法,我会劝退你。类多、调用链深、新人理解成本高,就是过度设计。

我见过一个极端案例:一个只有三个字段的内部状态枚举,硬被套了策略工厂,每个枚举值一个类,还配了注解扫描。结果后续扩展没有发生,维护的人每次都得翻七个类才能改一个数字。这类场景最合适的做法就是一组HashMap初始化或一个switch,最多加上枚举的自身方法。

5.2 适用范围速查表

场景推荐技术理由
多渠道接入、多类型处理工厂+模板方法业务分支收敛、流程统一
参数校验、脱敏、日志注解+反射/AOP横切逻辑与业务解耦
DO/VO/DTO转换、字段复制Bean拷贝减少样板代码、字段变化集中处理
单一简单判断分支if-else不要为了模式而模式
字段有业务语义转换(分转元等)手写转换器显式表达业务规则
高频热点路径的拷贝MapStruct编译期生成、性能接近手写

还有一个容易被忽略的维度:团队习惯。如果团队里没有人熟悉反射和AOP,强行上注解+反射,出现问题的时候可能没人能接手。我通常建议先在代码评审和小范围模块试点,写出配套的《使用规范》,再逐步推广,而不是一把梭推全量。

5.3 三招如何配合使用

这三招不是孤立的。实战里最常见的组合是:工厂负责拿到正确的处理器,模板方法固定处理流程,注解+反射处理横切关注点(脱敏、校验),Bean拷贝在入参出参转换中消除样板代码。一个典型的接口链路长这样:

  1. Controller接收外部请求。
  2. 用Bean拷贝把外部DTO转成内部Command。
  3. 工厂根据业务类型取到对应处理器。
  4. 处理器走模板方法:校验、业务处理、结果组装。
  5. 返回前,注解+反射切面对结果做统一脱敏或字段补充。

这样一来,重复代码不是被“删掉”了,而是被“收纳”到固定的结构里。对新人来说,看代码的路径从“翻十个if分支”变成了“看一个工厂、看一个抽象类、看自己的子类”,效率完全不一样。

6. 实战复盘:一次支付渠道对接的重构实录

6.1 重构前的痛点

标题里说的“彻底告别冗余代码”,如果只是理论肯定没说服力。所以我复盘一个真实项目:我们要新增一个支付渠道,此时老代码里已经有四家渠道,每个渠道都以“渠道名+Service”类存在。随便打开一个WechatPayService,里面是几百行的流程,签名字段拼装、请求对象创建、回调验签、结果落库全部揉在一起。新增第五家时,代码评审的人叹了口气说要复制改半小时。

不只是类内重复,类间更严重。wechatPayService和aliPayService里都有60%的逻辑长得一样:请求摘要日志、异常重试、结果状态更新、回调幂等表写入。这些逻辑用复制粘贴实现,导致改支付结果状态机时,四份代码改了三个,差点线上出事故。这个事件让我下决心做一次结构性重构。

6.2 重构设计:模板+工厂落实支付流程

我先抽出抽象父类AbstractPayHandler,流程固定为:构造请求参数、调用上游、解析响应、更新本地订单、写入回调流水。五个步骤里,前三步由子类差异实现,后两步在父类统一提供。这样一来,状态机和幂等逻辑只出现在父类里,不会再出现四份拷贝。

工厂侧,我用渠道编码作为key,把五个子类注册到Map。注册后调用代码变成:

AbstractPayHandler handler = PayHandlerFactory.get(channel); PayResponse resp = handler.execute(new PayRequest(order));

新增渠道时,新写一个子类,实现构造请求和解析响应,注册渠道编码。最核心的业务参数处理从四份变成一份,逻辑归属非常清楚。

6.3 注解+反射处理渠道参数差异

渠道间的参数差异比想象中多:有的渠道要商户号,有的要子商户号,有的要terminalId,有的还要额外的扩展字段。我最初想把差异写进子类里,结果每个子类构造请求时都有一大段if-else。后来优化成注解驱动:定义一个渠道参数注解@ChannelField,标注在渠道专用DTO响应字段上,处理器根据渠道号自动把对应字段填充进统一请求对象。

实现思路不复杂:渠道子类里维护一个Map,key是字段名,value是值;处理器反射遍历统一请求对象,遇到标了@ChannelField的字段,就从Map里取值并设置。这个方案真正起到了“一注解代替一坨if-else”的效果。而且接入新渠道时,渠道对接人员只需要在DTO里标注解,不用动处理器的调度逻辑。

6.4 Bean拷贝在接入层的使用

请求和响应的对象转换是另一个样板代码温床。每个渠道的回调请求参数都长得很像,却各有特殊字段。我先用Spring BeanUtils做公共字段拷贝,再用MapStruct生成一个Adapter,把渠道特有字段显式映射到统一对象。

这里特别强调一个细节:我当时把统一对象的公共字段抽象成BaseCallback,每个渠道回调DTO继承它。BeanUtils只负责拷贝BaseCallback的公共字段,特殊字段走MapStruct的@Mapping。改造后,新增渠道回调接入只需写一个Adapter接口、标几个@Mapping注解,不需要手工拼接对象,回调解析代码量下降了约40%。

6.5 重构收益与验证

重构完成后我统计了一下收益:

  • 代码行数:四家渠道支付核心流程整体减少约35%。
  • 新增渠道耗时:从2天压缩到半天以内。
  • 状态机问题:后续又调整过两次支付状态流转,只改了父类,测试覆盖一遍全渠道,没有出现回归遗漏。
  • 事故率:重构后半年内,渠道接入相关线上故障为0。

验证方式是每个渠道都跑了集成测试:正常支付、超时重试、回调签名错误、重复回调四种场景。因为父类统一了状态机,测试用例也收敛成一套基础用例加各渠道差异用例,维护成本比原来大大降低。

7. 常见问题与避坑清单

7.1 高频Q&A

问:工厂+模板方法会不会让类爆炸?
答:会,所以要先确认分支是否真的会持续增长。如果确定渠道数长期小于5,可以只做模板,工厂用Map简单注册就行。类爆不爆炸,取决于你有没有扛住“每个渠道一个子类”的诱惑。差异极小的时候,让一个子类持有参数配置,远比多个子类更合适。

问:注解+反射在微服务里怎么传递?
答:注解是编译期附着在类上的,服务间远程调用时不会传递,只有本地反射才能读到。微服务场景通常是把需要脱敏的字段在出参DTO上标注解,在网关或服务端处理后再返回,不存在跨服务传注解的问题。

问:BeanUtils在JDK 17和Spring Boot 3.x下还能用吗?
答:能用。Spring 6的BeanUtils底层已经适配模块系统,通过构造器和属性描述符操作。但如果字段是private且模块未开放,仍可能有权限问题,建议配合--add-opens或者改用MapStruct。

问:三招适合在现有老项目里直接推广吗?
答:不建议一把梭。老项目里优先找新增模块或变更频繁的渠道做试点,把试用规范写好,代码评审盯着用,跑稳了再扩大范围。硬改已有稳定模块,风险和收益不成正比。

还有问反射缓存怎么做的?我给一个简单公式:用一个静态ConcurrentHashMap,key是Class,value是Field数组或Method数组,查一次class后缓存起来。注意缓存的是元数据,不是对象实例,避免内存泄漏。

7.2 踩坑清单(按严重程度排序)

  • 模板父类execute不是final:子类一改顺序,全乱。
  • 子类构造器里初始化状态:由于初始化顺序,父类调用钩子时状态还没准备好。
  • Spring AOP自调用导致注解不生效:同一个类内部方法互调,绕过了代理。
  • 反射拿到父类字段失败:没有做递归遍历。
  • BeanUtils更新操作覆盖null:没有做非空判断,老数据被清空。
  • MapStruct与Lombok一起使用时注解处理器顺序混乱:报错找不到getter。
  • 工厂Map用HashMap在并发环境下被并发写:要用ConcurrentHashMap。
  • 注解Retention选错为SOURCE或CLASS:运行时反射读不到。

7.3 团队推广的小建议

我建议把这三招写进团队的开发规范里,但规则写得克制些:优先推荐,不强制。可以给三条硬性要求:新增渠道/多类型分支必须用工厂或Map统一管理,不允许新增if-else链超过三层;对外返回对象涉及手机号、身份证等敏感字段必须用注解脱敏;DO转VO禁止手写超过十个Setter。这三条可Review、可自动化扫描,推行阻力小。

8. 最后想说的:我的体感

这三招用下来,我最大的体会不是“代码变少了”,而是“改代码的胆子变大了”。以前改支付状态机要小心翼翼地把四个类打开对照,怕漏改;现在改父类一个方法,再来一轮集成测试,就能比较放心地发布。这种安全感,才是消灭重复代码真正的红利。

最后再分享一个小技巧:重构时别一步到位。我通常先把复制粘贴最狠的一段代码抽成模板,跑通测试;再把分支案件收进工厂,再跑通;最后再引入注解或Bean拷贝。每一步都是独立的、可回滚的,这样即使中途出问题,也能快速退回到上一步,不会把项目拖入“大规模重构翻车”的窘境。

如果有机会,你可以找一个渠道类型比较多的旧模块,按这个顺序试一次。先用模板方法把重复流程固定下来,再看哪些地方能收成注解,最后把对象转换全部交给Bean拷贝。实践一次,比读十篇设计模式文章都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询