6.0 KiB
| title | date | category | module | problem_type | component | severity | applies_when | tags | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @Lazy 循环依赖注入 + Mockito @InjectMocks 测试模式 | 2026-07-02 | conventions | pms-base | convention | testing_framework | medium |
|
|
@Lazy 循环依赖注入 + Mockito @InjectMocks 测试模式
Context
EtherPMS 后端微服务中,业务服务间常出现循环依赖。例如 pms-base 的 LifecycleServiceImpl 需要 ArchiveService(归档时回调),而 ArchiveServiceImpl 又需要 LifecycleService(恢复归档时推进阶段)。Spring Boot 3.x 不允许构造器循环注入,标准解法是一侧用 @Lazy 字段注入打破环。
问题出现在单元测试:使用 Mockito @InjectMocks 构造被测服务时,@Lazy 注入的字段始终为 null,导致测试 NPE。
Guidance
生产代码:一侧构造器注入(final 字段),另一侧 @Lazy 字段注入(非 final)。
// LifecycleServiceImpl — 构造器注入 ArchiveService(final 字段)
@Slf4j
@Service
@RequiredArgsConstructor
public class LifecycleServiceImpl implements LifecycleService {
private final ProjectMapper projectMapper;
private final ArchiveService archiveService; // 构造器注入
// ...
}
// ArchiveServiceImpl — @Lazy 字段注入 LifecycleService(非 final)
@Slf4j
@Service
public class ArchiveServiceImpl implements ArchiveService {
private final ArchiveFlowInstanceMapper mapper;
@Autowired
@Lazy // 打破循环依赖
private LifecycleService lifecycleService; // 非 final
}
测试代码:@InjectMocks 之后用 ReflectionTestUtils.setField 手动注入 @Lazy 字段。
@ExtendWith(MockitoExtension.class)
class ArchiveServiceTest {
@InjectMocks
private ArchiveServiceImpl archiveService; // @RequiredArgsConstructor 构造器注入 final 字段
@Mock
private ArchiveFlowInstanceMapper mapper;
@Mock
private LifecycleService lifecycleService; // @Lazy 字段的 Mock
@BeforeEach
void setUp() {
// @InjectMocks 只走构造器注入(Lombok @RequiredArgsConstructor 的 final 字段)。
// @Autowired @Lazy 非 final 字段不会被 Mockito 字段注入,必须手动 set。
ReflectionTestUtils.setField(archiveService, "lifecycleService", lifecycleService);
}
}
Why This Matters
Mockito @InjectMocks 的注入策略是 构造器注入优先:当被测类有带参构造器时(Lombok @RequiredArgsConstructor 生成的构造器),Mockito 用匹配的 Mock 填充构造器参数后即完成注入,不会再对剩余字段做字段注入。@Autowired @Lazy 标注的非 final 字段因此保持 null。
这不是 Mockito 的 bug——构造器注入成功后,Mockito 认为对象已完整构造。但 Spring 运行时通过反射 + @Lazy 代理填充了该字段,测试环境没有这一步。
如果不手动 setField,测试会在调用 lifecycleService.anyMethod() 时抛 NPE,错误指向被测方法内部而非注入缺失,排查成本高。
When to Apply
- 两个
@Service互相依赖,一侧用@Lazy private字段注入打破循环 - 被测类使用 Lombok
@RequiredArgsConstructor(构造器注入 final 字段) - 测试用 Mockito
@InjectMocks+@Mock - 测试 NPE 指向被测方法内部、但根因是
@Lazy字段未注入
不适用于:无循环依赖的正常构造器注入(Mockito 自动处理)、或被测类全字段注入(无构造器,Mockito 会做字段注入)。
Examples
典型场景:LifecycleService ↔ ArchiveService 循环依赖
本次项目生命周期管理功能中,LifecycleServiceImpl.approve() 在审批通过后回调 archiveService.markArchived(),而 ArchiveServiceImpl.archive() 在归档审批通过后回调 lifecycleService.advanceStage()。两个服务互调形成环。
生产代码用 @Lazy 打破:ArchiveServiceImpl 的 lifecycleService 字段标注 @Autowired @Lazy。
测试代码在 @BeforeEach 中补一行 ReflectionTestUtils.setField(archiveService, "lifecycleService", lifecycleService) 即可。同样的模式也应用于 LifecycleServiceTest(反向注入 archiveService mock)。
反模式:不要试图去掉 @Lazy 改用 ApplicationContextAware 或 ObjectProvider——这些虽然能避免循环依赖,但引入了不必要的复杂度。@Lazy 是 Spring 官方推荐的循环依赖解法,测试侧只需一行 setField 即可适配。
Design Rationale: 为什么不用事件总线解耦
LifecycleServiceImpl 与 ArchiveServiceImpl 的互调看似可以用 MQ 事件总线解耦(归档完成发事件 → 生命周期监听推进阶段),但当前设计选择 @Lazy 同步调用,理由:
- 事务一致性:归档恢复要求"标记归档只读解除 + 生命周期阶段推进"在同一事务内完成。若用 MQ 异步事件,归档只读已解除但阶段推进失败,项目会处于不一致态(可写但 lifecycle_stage=ARCHIVED)。
- 失败语义清晰:同步调用失败直接抛异常回滚事务;异步事件失败需额外设计重试 + 死信队列 + 幂等,复杂度远高于一行
@Lazy。 - 调用路径短:仅两个 Service 互调,无扇出场景。事件总线的优势在于一对多解耦,此处不适用。
若未来归档流程演进为多服务协作(如归档后通知计费服务结算、通知资产服务移交),应将跨服务部分拆为 MQ 事件,但 LifecycleService ↔ ArchiveService 的同事务互调保留 @Lazy。
Related
- CONCEPTS.md — 项目生命周期管理(本模式首次出现的业务场景)
- Spring Framework Reference: Circular References with @Lazy