ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring抽象类如何注入属性?原理、方式与避坑指南

Spring抽象类如何注入属性?原理、方式与避坑指南 有阵子我在重构订单侧的服务把几个Service里重复的缓存逻辑往上提抽了一个抽象父类然后把RedisTemplate、ObjectMapper这些公共依赖都放在父类字段上用Autowired注入。当时有同事提醒我抽象类又不是BeanSpring会为抽象类注入字段吗别到时候跑起来全是空指针。我说不会Spring注入的是子类实例上的字段父类字段照样被处理。后来实测也验证了。但聊的过程中我发现很多人对“抽象类注入属性”这件事的底层逻辑其实没吃透——要么不敢在父类放依赖要么在构造器里翻车要么多个子类时注入选择搞错。这篇文章就把这块讲透抽象类为什么能注入、三种注入方式有什么区别、多个子类怎么选、有哪些实际项目里的坑。1. 抽象类不会注册成Bean但父类字段确实会被注入1.1 组件扫描对抽象类的过滤逻辑Spring在启动时通过ClassPathScanningCandidateComponentProvider扫描Component及其派生注解Service、Repository、Controller等。扫到每个类之后会调用isCandidateComponent判断它是否适合注册成BeanDefinition。判断条件里有一条类不能是接口、不能是抽象类、不能是非静态内部类。换句话说一个抽象类就算标了Component也不会被Spring注册为一个Bean。这里的后果是很多人误解的起点抽象类不是Bean那抽象类里写的Autowired字段还会被处理吗答案是会但处理的主体不是抽象类自己而是它的具体子类。Spring在创建orderServiceImpl这个Bean实例之后会执行依赖注入的后置处理器这个处理器在收集注入点时把父类字段也一并收了进去。Component public abstract class AbstractOrderService { Autowired protected OrderRepository orderRepository; } Component public class OrderServiceImpl extends AbstractOrderService { }上面这段代码容器里只有orderServiceImpl这一个Bean但orderRepository会被正常注入到orderServiceImpl实例的父类字段上子类业务方法里直接orderRepository.findById()没有任何问题。1.2 父类字段找到注入点的完整路径为什么父类字段能注入关键在于AutowiredAnnotationBeanPostProcessor。它处理每个Bean时会执行buildAutowiringMetadata方法方法的逻辑大致是Class? targetClass clazz; do { // 1. 遍历 targetClass 的所有字段找出带 Autowired 的 // 2. 遍历 targetClass 的所有方法找出带 Autowired 的 targetClass targetClass.getSuperclass(); } while (targetClass ! null targetClass ! Object.class);它从子类开始一层层往父类找中间件的getDeclaredFields()能把父类的私有字段也拿回来后面统一通过反射field.setAccessible(true)强行赋值。所以父类字段无论是private还是protected只要被子类这个Bean继承了Spring都能注入。这里有一个小细节值得注意字段收集顺序是子类先、父类后。正常情况下这不影响注入结果因为每个Autowired字段都是独立解析的。但如果父类和子类在同一个Bean里定义了同名字段那就会各自注入各自的不是覆盖父类字段而是两个字段都存在子类方法的this.xxx指向子类字段。这种代码建议避免容易把人绕晕。2. 字段注入、Setter注入、构造器注入在抽象类上的真实边界2.1 字段注入最省事但protected才是正确姿势抽象类里放Autowired字段是最常见的写法。我推荐用protected而不是private原因是抽象类存在的意义就是被子类复用子类业务方法里大概率要直接访问这些依赖。如果写成private子类方法只能用父类提供的公共方法或getter去访问反而绕了一圈。举例public abstract class BaseHandler { Autowired protected RiskCalculator riskCalculator; protected void logRisk(Long bizId) { riskCalculator.evaluate(bizId); } }子类OrderHandler里可以直接调logRisk也可以在子类方法里直接读riskCalculator。如果哪天想把riskCalculator换成final不可变字段注入就无能为力了因为Spring在字段注入时通过反射给字段赋值final字段在JDK17下会被拒绝非法访问异常这点后面讲构造器注入时会说到。2.2 Setter注入父类方法上的注解同样生效Autowired不仅能标在字段上还能标在setter方法上。buildAutowiringMetadata里对方法的遍历同样包含父类方法所以抽象类的setter注入是有效的public abstract class BaseService { private AuditLogger auditLogger; Autowired public void setAuditLogger(AuditLogger auditLogger) { this.auditLogger auditLogger; } protected AuditLogger getAuditLogger() { return auditLogger; } }这种写法的好处是依赖是私有的通过方法暴露子类和外部都不能直接改字段封装性更好。坏处是子类可能不知道有这个方法调试时容易漏看。如果你所在的团队习惯用构造器注入那抽象类的setter注入更像是一种折中不用让每个子类都在构造器里传父类参数父类依赖由Spring在Bean初始化阶段通过setter指定。2.3 构造器注入抽象类构造器不是Spring管的这是最容易踩坑的地方。很多人想把构造器注入这一“最佳实践”贯彻到抽象类里于是写了public abstract class BaseService { protected final UserMapper userMapper; public BaseService(UserMapper userMapper) { this.userMapper userMapper; } }这样写本身没错但必须理解一点Spring不会去调用抽象类的构造器抽象类的构造器参数完全由子类通过super()传递。也就是说抽象类构造器里的UserMapper不是Spring注入的是子类在构造时Java层面传进去的。子类需要这样写Service public class UserQueryService extends BaseService { Autowired public UserQueryService(UserMapper userMapper) { super(userMapper); } }如果子类没写构造器Java默认会生成一个无参构造器去调父类的无参构造器。可一旦父类只有带参构造器编译直接报错逼着你给子类写构造器。从这个角度看抽象类构造器注入反而把依赖关系暴露得很清楚每个子类都要显式声明自己需要哪些父类依赖并且把依赖向上传递。代价是子类一多每个子类构造器都要重复传一遍代码啰嗦。三种注入方式在抽象类场景下的取舍可以简单对比如下注入方式抽象类里是否生效依赖不可变子类需要做的事推荐场景字段注入生效不支持final什么都不用做父子类方法内部大量直接访问依赖Setter注入生效不支持final什么都不用做希望依赖私有化、通过方法访问构造器注入不直接生效支持final必须写构造器并super传参强调依赖不可变、依赖链清晰个人经验是团队规范要求构造器注入时抽象类也没必要硬扛子类传参虽然啰嗦但明确如果只是给多个子类提供公共依赖且追求代码少字段注入在抽象类里是个合理例外毕竟它只在基类里写一次比每个子类写一个构造器清爽太多。3. 多个子类继承同一抽象类时的注入类型与选择细节3.1 用抽象类型注入父类引用当你写Autowired或者构造器参数类型是一个抽象类时Spring不是去找抽象类对应的BeanDefinition而是根据类型找所有可赋值的Bean实例。因为Bean实例的实际类型是子类所以抽象类引用是能接住的。Service public class HandlerRouter { private final AbstractHandler defaultHandler; public HandlerRouter(Qualifier(orderHandler) AbstractHandler defaultHandler) { this.defaultHandler defaultHandler; } }如果一个抽象类只有一个子类甚至不用加QualifierSpring能唯一匹配。但一旦有两个及以上的子类容器里就有多个可赋值的BeanSpring会抛NoUniqueBeanDefinitionException。这个时候的解决方案是Qualifier(beanName)指定名称或者用Resource(name beanName)。3.2 List和Map批量注入抽象类最常见的实际用途是策略模式。一个抽象类定义公共流程多个子类实现差异化逻辑然后在某个管理器里统一拿到全部策略Component public class HandlerDispatcher { private final ListAbstractHandler handlers; private final MapString, AbstractHandler handlerMap; public HandlerDispatcher(ListAbstractHandler handlers, MapString, AbstractHandler handlerMap) { this.handlers handlers; this.handlerMap handlerMap; } }Spring对ListT和MapString, T有专门的集合注入逻辑List里会按类型收集所有子类顺序可以用Order控制Map的key是Bean名称value是Bean实例。这个机制和普通类的依赖注入没有区别但放在抽象类场景下特别有用——你不用改任何配置新增一个业务子类只要把它标记成Component它会自动出现在管理器的集合里。需要注意的是被注入的ListAbstractHandler里每个元素都是独立的Bean它们各自拥有一个父类字段的副本而不是共享同一个父类字段注入结果。理解这一点很重要抽象类的字段注入不是在抽象类上执行一次然后让所有子类共享而是每个子类Bean创建时分别执行一次注入。3.3 泛型抽象类的两种玩法泛型抽象类在业务里用得多例如BaseServiceT、AbstractCacheServiceT。泛型并不影响父类字段注入只要依赖的类型是明确的public abstract class AbstractCacheServiceT { Autowired protected RedisTemplateString, Object redisTemplate; }真正会翻车的是想按泛型参数去注入比如Autowired protected T repository; // 错误T在编译后是ObjectT在运行时就是ObjectSpring会去查找Object类型的Bean基本找不到而且就算能找到也会注入一个错误的对象。正确做法是让子类构造器把泛型对应的真实类型传进来public abstract class BaseServiceT { protected final T repository; public BaseService(T repository) { this.repository repository; } } Component public class OrderService extends BaseServiceOrderRepository { Autowired public OrderService(OrderRepository repository) { super(repository); } }如果实在想在抽象类里动态拿到泛型真实类型再手动从容器取Bean也不是不行但要同时拿到ResolvableType和ApplicationContext代码复杂度直接上去不推荐为这点便利去折腾。4. 实战落地写一个带Redis缓存模板的抽象Service基类4.1 为什么这个场景非常适合抽象类缓存逻辑一旦抽出来随随便便就是十几行重复代码拼key、查缓存、类型转换、回源、写缓存、设置过期时间。如果每个Service里复制一份将来改缓存前缀格式或者加一层序列化逻辑会改到想骂人。抽象类的价值在于把公共依赖集中放在父类字段上把不变流程写死在模板方法里把可变部分留给子类通过抽象方法实现。4.2 基类代码与子类使用public abstract class AbstractCacheServiceT { Autowired protected RedisTemplateString, Object redisTemplate; Autowired protected ObjectMapper objectMapper; protected abstract String keyPrefix(); protected abstract Duration expireAfter(); public T get(String bizId, ClassT clazz, SupplierT loader) { String key keyPrefix() : bizId; Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return objectMapper.convertValue(cached, clazz); } T value loader.get(); if (value ! null) { redisTemplate.opsForValue().set(key, value, expireAfter()); } return value; } }子类只需要实现两个抽象方法依赖注入全放在父类Component public class UserCacheService extends AbstractCacheServiceUserProfile { Override protected String keyPrefix() { return user:profile; } Override protected Duration expireAfter() { return Duration.ofMinutes(30); } }使用时注入UserCacheService直接UserProfile profile userCacheService.get(uid, UserProfile.class, () - userRepository.findById(uid).orElse(null));这个例子里RedisTemplate和ObjectMapper只在父类出现一次换缓存客户端时只改基类所有子类一起生效。4.3 验证结果与注意点用SpringBootTest跑一个简单测试SpringBootTest class UserCacheServiceTest { Autowired private UserCacheService userCacheService; Test void parentFieldsAreInjected() { Assertions.assertNotNull(userCacheService.redisTemplate); Assertions.assertNotNull(userCacheService.objectMapper); } }测试类如果和子类不同包访问protected字段会受限这是Java访问控制本身的问题不是Spring的问题。这时候要么把测试类放进子类同包路径要么在实体类里加一个包内可见的getter。另外MockBean替换抽象类父类字段上的依赖也能生效因为字段注入是Bean创建期的标准流程替换的是容器里的Bean子类父类字段最终会注入到MockBean对象。5. 抽象类注入失效的五个翻车点与排查思路5.1 手工new出来的子类最常见的“抽象类注入失效”其实是子类对象压根不是Spring创建的。写单测或者临时代码时图省事直接new OrderHandler()然后访问父类字段看到的全是null。这不是Spring的问题而是对象没有经过容器初始化。排查思路很简单先用ApplicationContext.getBean(OrderHandler.class)拿对象确认是不是同一个实例如果测试就老老实实跑SpringBootTest或者手动用ReflectionTestUtils.setField给父类字段塞mock。5.2 静态方法里指望注入实例抽象类里定义静态工具方法方法内部用了Autowired字段这必然空指针因为静态方法不依赖实例而注入字段是属于实例的。抽象类加静态方法本身是合法的但静态方法要访问依赖就必须从外部拿比如通过ApplicationContextComponent public class SpringUtils implements ApplicationContextAware { private static ApplicationContext context; Override public void setApplicationContext(ApplicationContext ctx) { context ctx; } public static T T getBean(ClassT clazz) { return context.getBean(clazz); } }然后静态方法里写SpringUtils.getBean(UserCacheService.class).get(...)。注意不能用SpringUtils.getBean(AbstractCacheService.class)因为抽象类本身不是Bean会直接NoSuchBeanDefinitionException。5.3 父类构造器中调用可覆盖方法这是个隐蔽的坑。抽象类构造器执行时间点在依赖注入之前Spring创建Bean的流程是先通过构造器实例化对象再执行字段注入。如果在构造器里调用了子类或抽象方法public abstract class BaseProcessor { Autowired protected RuleEngine ruleEngine; public BaseProcessor() { initRules(); // 危险此时 ruleEngine 还是 null } protected abstract void initRules(); }实例化阶段ruleEngine尚未赋值initRules()里如果访问了ruleEngine必然NullPointerException。正确做法是用PostConstruct替代构造器里的初始化逻辑这个阶段字段注入已经完成依赖可用。5.4 代理与内部调用问题如果抽象类的方法上标了TransactionalSpring创建的是子类Bean的代理对象。外部调用代理对象事务能够生效但从同一个Bean的其他方法内部调用this方法时事务会失效因为走的不是代理public abstract class BaseService { Transactional public void save() { // 事务逻辑 } public void doSomething() { save(); // 内部调用事务注解不生效 } }这个坑在抽象类场景更容易出现因为大家都把公共逻辑放在基类很容易方法间互相调用。解决思路也很标准把事务方法拆到独立Bean里或者注入自身代理。5.5 模块系统引发的setAccessible异常Spring Boot 3配合JDK17后反射访问类的私有字段时如果类属于被封装模块可能抛InaccessibleObjectException。一般来说Spring管理的是我们自己定义的业务类不受模块系统限制父类字段注入稳稳当当。真遇到这种异常多半是试图给某个JDK内部类的字段做注入或者字节码增强工具与模块系统冲突此时优先考虑改构造器注入必要时再考虑加--add-opens启动参数不要粗暴开全局反射权限。回到文章开头的问题Spring针对抽象类注入属性底层的关键就是“抽象类不注册成Bean但子类Bean会继承父类字段注入的元数据”。我自己用下来的体会是这个能力非常适合做公共基类场景尤其是模板方法加缓存、加审计、加幂等这类横切逻辑。但使用前一定想清楚依赖要写在父类字段上子类负责任务差异构造器方案除非团队要求不可变否则日常维护成本偏大。理解原理之后它就不再是玄学而是很顺手的设计工具。
返回列表