ether/docs/solutions/conventions/lazy-injection-reflectionte...

6.0 KiB
Raw Blame History

title date category module problem_type component severity applies_when tags
@Lazy 循环依赖注入 + Mockito @InjectMocks 测试模式 2026-07-02 conventions pms-base convention testing_framework medium
两个 Spring Service 互相依赖且用 @Lazy 打破循环时
Mockito @InjectMocks 测试中 @Lazy 字段为 null 导致 NPE 时
lazy-injection
mockito
circular-dependency
reflectiontestutils
spring-boot

@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 — 构造器注入 ArchiveServicefinal 字段)
@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 打破:ArchiveServiceImpllifecycleService 字段标注 @Autowired @Lazy

测试代码在 @BeforeEach 中补一行 ReflectionTestUtils.setField(archiveService, "lifecycleService", lifecycleService) 即可。同样的模式也应用于 LifecycleServiceTest(反向注入 archiveService mock

反模式:不要试图去掉 @Lazy 改用 ApplicationContextAwareObjectProvider——这些虽然能避免循环依赖,但引入了不必要的复杂度。@Lazy 是 Spring 官方推荐的循环依赖解法,测试侧只需一行 setField 即可适配。

Design Rationale: 为什么不用事件总线解耦

LifecycleServiceImplArchiveServiceImpl 的互调看似可以用 MQ 事件总线解耦(归档完成发事件 → 生命周期监听推进阶段),但当前设计选择 @Lazy 同步调用,理由:

  1. 事务一致性:归档恢复要求"标记归档只读解除 + 生命周期阶段推进"在同一事务内完成。若用 MQ 异步事件,归档只读已解除但阶段推进失败,项目会处于不一致态(可写但 lifecycle_stage=ARCHIVED
  2. 失败语义清晰:同步调用失败直接抛异常回滚事务;异步事件失败需额外设计重试 + 死信队列 + 幂等,复杂度远高于一行 @Lazy
  3. 调用路径短:仅两个 Service 互调,无扇出场景。事件总线的优势在于一对多解耦,此处不适用。

若未来归档流程演进为多服务协作(如归档后通知计费服务结算、通知资产服务移交),应将跨服务部分拆为 MQ 事件,但 LifecycleService ↔ ArchiveService 的同事务互调保留 @Lazy