单测(三)Spring Boot 3.5.6 && @MockitoSpyBean / AOP / 循环依赖:静默吞错与运行期 NPE

背景:百度说@SpyBean/AOP/循环依赖冲突的场景,在Spring Boot 3.5可以用@MockitoSpyBean解决,但实际是问题并没有完全解决,而是变得更隐蔽更难查

Spring Boot 2.7 时代同样的场景(@SpyBean + AOP + 循环依赖)会在启动期直接抛 BeanCurrentlyInCreationException,快速失败;Spring Boot 3.5.6 同样的代码却"启动成功",有时没有任何问题,正常运行,但有时,在运行期 、在特定调用路径上会抛NPE------启动期的错误被吞掉运行时才抛,从而使问题变得更隐蔽、更难查。

版本:Spring Boot 3.5.6 / Spring Framework 6.2.11 / @MockitoSpyBean(Spring Boot 3.4+ 的新注解,替代 Spring Boot 2.7 的 @SpyBean)。

1 术语约定

记号 含义
order / pay beanName(小写)
raw bean 的原始实例(原对象,未经任何包装)
OrderServiceImpl / PayServiceImpl bean 对应的类
order@1 / pay@1 第一轮构造出的实例
order@2 / pay@2 第二轮重建出的实例
earlyBeanReference(x) x 未创建完成时被提前暴露给其他 bean 的引用(getEarlyBeanReference 钩子链的产出)
override(x) spy(WRAP 策略)创建的包装对象(快照副本代理),x是被override的对象
aop(x) AOP 代理,x是被aop的对象
双切 bean 同时被 @MockitoSpyBean 注解和被 AOP 切中的 bean
包装器 在 getEarlyBeanReference / postProcessAfterInitialization 两条钩子链上做"包装 + 记账"的后置处理器(本文指 WrapEarlyBeanPostProcessor 与 AbstractAutoProxyCreator)

2 先说结论

Spring Boot 3 中,存在循环依赖时,@MockitoSpyBean 在某些情况下仍会与 AOP 冲突,且与 Spring Boot 2.7 的表现不同:Spring Boot 2.7 是 fast-fail (有问题直接终止启动);Spring Boot 3.5.6 把问题留到运行期才爆,潜在危害更大。

异常处理机制差异(不管什么原因抛的异常,两版对"第一轮抛异常"的处理方式不同):

  • Spring Boot 2.7:第一次遍历抛出异常,启动直接终止
  • Spring Boot 3.5.6:第一次遍历抛出的如果是 BeanCurrentlyInCreationException,会被 try-catch 吞掉,然后遍历下一个 bean;如果是其他异常,同样直接抛出、启动终止

双切 bean 场景的差异(双切 = 同时被 spy 注解和 AOP 切中):

  • Spring Boot 2.7(@SpyBean):被提前暴露的 bean(简单环中即环起点,见下文"提前暴露规律")是双切 bean 时必定抛 BeanCurrentlyInCreationException,启动直接终止

  • Spring Boot 3.5.6(@MockitoSpyBean):被提前暴露的 bean是双切 bean 时同样必定抛 BeanCurrentlyInCreationException,但第一次异常被吞掉,从 beanNames[] 的下一个 beanName 开始下一处理轮(注意:按 beanNames[] 顺序遍历,不是按环上顺序)。假设这个bean恰好也是环上的bean,则此轮就是本文所述的同一个环第二轮,在第二轮中如果本轮被提前暴露的 bean(简单环中即第二轮环起点;复连通环中一条递归链可暴露多个 bean,判据对每个逐一适用,见"提前暴露规律")是双切 bean,那么:

    1. 启动可以成功(异常被吞);
    2. 对这些双切 bean 的插桩(stub)必定失败;
    3. 这些双切 bean 的 override 快照副本是提前暴露时刻(字段填充中途)拷贝的,尚未注入的依赖字段都是 null------测试时只要访问了这些没注入的字段,就必定抛 NPE(已注入的字段不受影响,这也正是症状"只有特定调用路径炸"的原因)。

    如果第二轮被提前暴露的 bean 都不是双切 bean → 第二轮正常重建,一切正常(详见 5.3)。

3 循环依赖与earlyBeanReference 的提前暴露

3.1 循环依赖示例

beanNames[] 是记录所有 beanName 的数组。假设有 a、b、c 三个 bean,beanNames[] = [a, c, b]。a 依赖 b,b 依赖 c。a 先启动,则 a 是环上第一个 bean,c 是环上最后一个 bean,getBean 顺序为 a -> b -> c -> earlyBeanReference(a):

  1. getBean(a):三级缓存都没有 a → 创建 a 的原始实例 → 放入三级缓存(singletonFactories,即 EarlyBeanReference 工厂)→ 属性填充时发现依赖 b → getBean(b)
  2. getBean(b):三级缓存都没有 b → 创建 b 原始实例 → 放入三级缓存 → 属性填充发现依赖 c → getBean(c)
  3. getBean(c):三级缓存都没有 c → 创建 c 原始实例 → 放入三级缓存 → 属性填充发现依赖 a → getBean(a)
  4. 再次 getBean(a):一级缓存没有,但三级缓存有 a 的工厂 → 调用工厂(如有 AOP 则提前生成代理)→ 拿到 a 的 earlyBeanReference(early reference)→ 升级放入二级缓存 → c 注入这个earlyBeanReference
  5. c 完成初始化:initializeBean(@PostConstruct 等)→ 放入一级缓存,从二、三级缓存删除 → c 是完全初始化完成的
  6. b 注入 c:此时 getBean(c) 一级缓存里已是完全体 c,注入的是最终实例,不是earlyBeanReference
  7. b 完成初始化 → 一级缓存
  8. a 注入 b:同理,拿到的是完全体 b → a 完成初始化 → 一级缓存

第一次遍历即为 a -> b -> c -> earlyBeanReference(a)。

3.2 提前暴露 earlyBeanReference 的规律(对任意环都成立的通用结论)

Spring 不识别"环"这个单位,它只沿依赖递归 getBean,环是被递归回访"顺手"解开的。因此不是"一个环一个环地创建",而是按 getBean 递归链为单位创建。通用规律:

一条递归链上,所有"别人需要它时它自己还没做完"的 bean 都会被提前暴露;被提前暴露的 bean 可以不止一个。

两种典型形态:

  • 简单环 (每个 bean 只属于一个环,本例 a -> b -> c -> earlyBeanReference(a) 即是):只有第一个开始创建的 bean(递归链起点)会被提前暴露------链上其他 bean 都是"完全做完才被注入"(走一级缓存)。此时"环起点 = 被提前暴露的 bean",两者可以互换使用。
  • 共享 bean 的复连通环 :多个环共享 bean 时,一条递归链可以同时解开多个环,每个环的起点都可能被提前暴露 。例如 a -> b、b -> c、c -> a(环 1)加上 b -> d、d -> b(环 2,b 被两个环共享):
text 复制代码
getBean(a) → getBean(b) → getBean(c) → 需要 a(a 正在创建中)→ earlyBeanReference(a)   ← 暴露 a(环 1 起点)
c 完成 → 回到 b 继续 populate → getBean(d) → 需要 b(b 还在创建中,它在等 c/d)→ earlyBeanReference(b)   ← 暴露 b(环 2 起点)
d 完成 → b 完成 → a 完成

一次递归解开了两个环,a 和 b 都被提前暴露------不是先创建完环 1 再创建环 2,而是 b 横跨两个环,创建到一半既喂了 c 又喂了 d。

本文 5.2 主线场景(order/pay 两 bean 依赖环)是简单环**,因此后文判据在简单环下可表述为"环起点是否双切";推广到任意环,判据一律表述为"被提前暴露的 bean 是否双切"(简单环中它恰好就是环起点)。

4 与 Spring Boot 2.7 的关键差异总览

维度 Spring Boot 2.7(Spring 5.3) Spring Boot 3.5.6(Spring 6.2.11)
注解 @SpyBean / @MockBean @MockitoSpyBean / @MockitoBean(spring-boot-test 内新体系,走 BeanOverride 机制,但处理流程和冲突原因与 @SpyBean 相同)
Spy 处理器 MockitoPostProcessor$SpyPostProcessor spring-test 的 MockitoBeanExecutionListener → WrapEarlyBeanPostProcessor(WRAP 策略,@MockitoSpyBean 专属;@MockitoBean 走 REPLACE 策略的 ReplaceBeanPostProcessor,与 spy 无关,见下"差异的边界")
冲突表现 preInstantiateSingletons 无 catch,异常直接抛出,refresh 失败,一轮构造 preInstantiateSingleton 新增 catch (BeanCurrentlyInCreationException)(本为并发 bootstrap 设计),异常被吞,只打一条 INFO 日志,两轮构造后"启动成功"
最终症状 启动期 BeanCurrentlyInCreationException,快速失败 启动"成功";运行期走特定路径爆 NPE(环上 bean 字段里装的是坏掉的提前暴露 override------对方 bean 在提前暴露时刻、字段还没填充时拷贝的快照副本)

两版底层机制的关键点在 spy 的生成:2.7(Mockito 2.x)默认 subclass MockMaker(SubclassByteBuddyMockMaker),ByteBuddy 运行时生成被代理类的子类实例并把 spiedInstance 的字段值逐个拷贝进去("继承 + 字段快照拷贝");3.5.6(Mockito 5.x)默认 inline MockMaker(InlineByteBuddyMockMaker),通过 Instrumentation API 重新转换原类字节码做方法插桩,spy 时 new 一个同类型新实例(Objenesis 实例化 + 字段拷贝),不再生成子类。两版 spy 生成的共同点:Mockito.spy(instance) 都不复用传入实例,而是另建新实例并逐字段拷贝快照------提前暴露时刻拷到的就是"字段还没填充"的半成品快照,这正是 3.5.6 运行期 NPE 的根源(坏快照副本里未注入字段为 null);2.7 也存在这个问题,但是 2.7 启动时就因为包装器之间的记账冲突(见 6.1)导致启动失败提前终止了,根本等不到运行时抛 NPE

WRAP / REPLACE 两种策略只对应旧版两种注解,不是 spy 的两种策略:

  • WRAP:@MockitoSpyBean 专属(对应旧版 @SpyBean)------原 bean 走完整创建流程,钩子里现场包装;
  • REPLACE:@MockitoBean 专属(对应旧版 @MockBean)------原 bean 的定义被替换、真实实例永不被创建,也就不进钩子链、不与 AOP/循环依赖冲突。

5 原理与实际启动流程

5.1 包装器记账冲突的根源(与 Spring Boot 2.7 相同的部分)

两个互不知晓的包装器在同样的两个钩子 (getEarlyBeanReference / postProcessAfterInitialization)上做"包装 + 记账",冲突由此产生。钩子链是流水线:上一个钩子的返回值是下一个钩子的入参。

getEarlyBeanReference 阶段(钩子链起始参数是 raw bean):

  1. WrapEarlyBeanPostProcessor(@MockitoSpyBean WRAP 策略,HIGHEST_PRECEDENCE 排最前):收到 bean 本身 → 记账(map.put(beanName, raw))→ Mockito.spy 用 settings.spiedInstance(instance) 创建 override(raw)------拷贝这个 bean 当时的字段值 (注意:拷贝发生在提前暴露时刻、属性填充完成之前,所以 override 拷到的是"半成品"------尚未注入的依赖字段都是 null;至于更早注入的字段(如构造器注入),则已被拷入、非 null)
  2. AbstractAutoProxyCreator(所有 AOP 的汇聚点):收到 override(raw) → 记账(map.put(beanName, override(raw)))→ 包一层 AOP → 流出 aop(override(raw))

postProcessAfterInitialization 阶段(钩子链起始参数同样是 raw bean):

  1. WrapEarlyBeanPostProcessor:收到 raw bean → 对账(map.get(beanName) == raw bean → 对账成功,认为 bean 没有被改变)→ 什么也不做,直接把 raw bean 丢给下个钩子
  2. AbstractAutoProxyCreator:收到 raw bean → 对账(map.get(beanName) != raw bean → 对账失败,认为 bean 被人改变了)→ 重新包一层 AOP → 返回 aop(raw)

doCreateBean 收尾自检:

  • earlyBeanReference(bean) = 循环依赖中其他 bean 拿到的引用 = getEarlyBeanReference 阶段钩子链产出的最终对象 aop(override(raw))
  • exposedObject(bean) = 容器里最终的引用 = postProcessAfterInitialization 阶段钩子链产出的最终对象 aop(raw) ≠ bean(裸 raw)
  • earlySingletonReference != null(本 bean 被提前暴露过,有人拿到过提前暴露产物)且收尾链交出的不是裸 bean(exposedObject != bean)→ 才进入检查分支 → 认为可能有其他 bean 拿到的引用不是最终的 → 查 dependentBeanMap,看是否有依赖当前 bean 的其他 bean:
    • 没有 → 忽略(没人依赖这个 bean,即使不一样也没问题)
    • 有 → 认为有人持有了与最终对象不一致的 earlyBeanReference → 如果没有开启 allowRawInjectionDespiteWrapping → 抛 BeanCurrentlyInCreationException

注意:自检不直接比较 earlyBeanReference 与 exposedObject 是否相等。放行条件只有 exposedObject == bean(收尾链原样放行裸 bean)这一个:满足时直接采纳 earlyBeanReference 作为最终对象(哪怕它与收尾链产物本就不同也不检查);不满足且有人依赖时才抛异常。为什么要先看 earlySingletonReference != null、再查 dependentBeanMap(而不只看前者) :提前暴露产物可能"生成了、但没人持有"------如果创建过程中 bean 因异常被销毁,销毁会连带清掉 dependentBeanMap 里的依赖账目(依赖它的 bean 也会一起销毁重建),而提前暴露产物早已交出去、不会随之回收,于是出现"earlySingletonReference != null 但账本为空"的局面;这正是本文阶段 4.4 静默放行的成因。所以 earlySingletonReference != null 只能说明"暴露过",不能说明"现在还有人持有";还要再查 dependentBeanMap,真的有 bean 依赖它才判定冲突、抛异常,没有则说明无人持有提前暴露产物,直接放行。

下面按实际启动时间线走一遍 Spring Boot 3.5.6 完整流程。场景假设:有一个测试类 TestC,注入了 order 和 pay 两个 bean,都用了 @MockitoSpyBean 注解;order 和 pay 组成依赖环(两 bean 简单环),且都是双切 bean。假设 order 先创建,则环流程为 order -> pay -> earlyBeanReference(order)。

5.2 实际启动流程:遍历到 order → 收尾自检失败报错 → 清账 → catch 吞错 → 遍历到 pay

先看源码骨架(DefaultListableBeanFactory,Spring 6.2.11;preInstantiateSingletons / preInstantiateSingleton / instantiateSingleton / getBean / doGetBean / createBean / doCreateBean 合并、省略与本事故无关的 background-init 分支,缩进表示调用层级)------后面所有剧情都发生在这个方法里:

java 复制代码
// refresh() 的 finishBeanFactoryInitialization 阶段调进来
public void preInstantiateSingletons() throws BeansException {
    List<String> beanNames = new ArrayList<>(this.beanDefinitionNames);   // 拷贝全部 bean 名
    for (String beanName : beanNames) {                                   // 按顺序逐个遍历
        RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName);
        if (!mbd.isAbstract() && mbd.isSingleton() && !mbd.isLazyInit()) {  // 非抽象 && 单例 && 非懒加载
            try {
                getBean(beanName);   // new → 三级缓存工厂 → 填充字段 → 钩子链 → 收尾冲突自检
            }
            catch (BeanCurrentlyInCreationException ex) {
                // Boot 3.4 起引入并发 bootstrap,才新增了这个 catch(以前单线程,直接向上抛)
                // 注意:这里只捕获 BeanCurrentlyInCreationException 这一种类型:
                //   吞掉它 → for 循环不中断,继续遍历下一个 bean(整场事故的舞台就在这里)
                //   其他任何异常照样向上抛出并中止启动(即 Boot 2.7 的行为,不变)
                logger.info("Bean '" + beanName + "' ... skipping it in mainline thread");
            }
        }
    }
}
阶段 1:遍历到 order(整环以 order 为起点初始化)→ 冲突检测命中 → 报错

1.1 preInstantiateSingletons 循环到 order → doCreateBean(order):

  • createBeanInstance:new 出 order@1(半成品,字段全空)
  • addSingletonFactory:三级缓存放入 getEarlyBeanReference 工厂(备着)

1.2 populateBean:order@1 需要 pay → 递归 getBean(pay) → doCreateBean(pay):

  • new 出 pay@1 → populate 反向要 order → 三级缓存命中 order 的 earlyBeanReference工厂 → 触发 order@1的getEarlyBeanReference 钩子链 (首个钩子收到的参数是 raw bean,即 order@1):
    • WrapEarlyBeanPostProcessor:收到 order@1 → 记账 map[order] = order@1 → 交出 override(order@1)
    • AnnotationAwareAspectJAutoProxyCreator:收到 override(order@1) → 记账 earlyProxyReferences[order] = override(order@1) → 交出 aop(override(order@1))
  • pay@1 到手的正是 aop(override(order@1)),填充自己的 orderService 字段
  • ★ 注意:pay@1 虽然也是双切 bean,但它没有被提前暴露 (无人请求它的 earlyBeanReference),所以只走 postProcessAfterInitialization 钩子链、不走 getEarlyBeanReference 钩子链------spy 在收尾阶段才包装它(此刻字段已填充,快照是好的),所以不会像 order@1 那样出现"提前暴露时包一次、收尾时又包一次、两次包出来的对象对不上"的问题,因此 pay@1 自己是正常的。真正潜在的问题是它的字段里装的是提前暴露的 aop(override(order@1))) 而override(order@1)里面拷贝的字段都是空的,后续调用会抛NPE
  • ★ 在pay@1 获取 order 的过程中会用到两个 Map:
    • injectionMetadataCache:记录当前 bean 需要注入哪些 bean(即当前 bean 依赖哪些 bean)。 在pay@1 获取 order 时先查 injectionMetadataCache[pay],命中则直接返回;不命中则构建并记一笔 injectionMetadataCache[pay] += order
    • dependentBeanMap:记录哪些 bean 依赖当前 bean。当injectionMetadataCache没有命中时他才会再记一笔 dependentBeanMap[order] += pay,表示pay会依赖order(后续处理order冲突时就会检查这个)

1.3 pay@1 收尾 postProcessAfterInitialization(pay@1 未被提前暴露,首钩子收到裸 pay@1 → spy 正常包装,拷贝的是已填充完的实例)→ 产出 aop(override(pay@1)) 入一级缓存 → 回溯后把 aop(override(pay@1)) 注入 order@1 → order@1 构造完成,走完 initializeBean → order@1的postProcessAfterInitialization 钩子链 (首个钩子收到的参数是 raw bean,即 order@1 本身,而不是 getEarlyBeanReference 阶段产生的 aop(override(order@1))):

  • WrapEarlyBeanPostProcessor:收到 order@1 → 对账 map[order] = order@1,与 getEarlyBeanReference 阶段收到的参数相同 → 对账成功,直接放行 order@1(不再包装)
  • AnnotationAwareAspectJAutoProxyCreator:收到 order@1 → 对账 earlyProxyReferences[order] = override(order@1) != order@1,与 getEarlyBeanReference 阶段收到的参数不同 → 对账失败 → 误判"尚未包装" → 交出重包的 aop(order@1)(不含 override)

1.4 doCreateBean 收尾自检 (AbstractAutowireCapableBeanFactory ~633 行):

  • earlyBeanReference(order@1) = 其他 bean 拿到的引用 = getEarlyBeanReference 阶段钩子链产出的最终对象 aop(override(order@1))
  • exposedObject(order@1) = 容器里最终的引用 = postProcessAfterInitialization 阶段钩子链产出的最终对象 aop(order@1) ≠ bean(裸 order@1)
  • earlySingletonReference != null(order 被提前暴露过,pay 拿到过提前暴露产物)且收尾链交出的不是裸 bean(exposedObject != bean)→ 进入检查分支 → 查 dependentBeanMap,发现有 bean 依赖 order(查到 pay)→ 冲突 → 抛 BeanCurrentlyInCreationException
阶段 2:冲突 → destroySingleton 连锁清账

2.1 清理 dependentBeanMap。doCreateBean(order) 的 finally 里 destroySingleton("order") → destroyBean(DefaultSingletonBeanRegistry:779):

  • dependentBeanMap[order] = {pay@1},表示 pay@1 依赖 order(在 getBean(pay) 时顺带登记的)
  • 先 dependentBeanMap.remove("order"),移除 order 登记的账目
  • 依赖 order 的 bean 也要一起销毁:pay@1 持有 order@1 的引用,pay@1 依赖 order@1,所以 pay@1 也要销毁(不然 pay@1 就是有问题的对象)。接着 destroySingleton(pay):移除 dependentBeanMap[pay],销毁 pay@1;返回的依赖集合 {order@1} 已销毁过,递归直接结束
  • 结果:环上所有 bean 在 dependentBeanMap 中的条目全部移除,后续 dependentBeanMap.get("order"/"pay") 都返回空;此外 order@1 / pay@1 的实例也会从一/二/三级缓存移除(removeSingleton)
  • ★ bean 销毁后清除它在 dependentBeanMap 中的账目,是理所当然的操作

2.2 但另一半账本 injectionMetadataCache 没清、也永远不会清。它只在 BeanDefinition 重注册(resetBeanDefinition)时才清,而重建轮 definition 没变:

  • injectionMetadataCache[pay] 里 pay -> order 的注入元数据仍在 → 到了后面的阶段 4(第二轮),pay@2 获取对 order 的注入元数据时直接缓存命中,也就不会再记一笔 dependentBeanMap[order] += pay。这就是 Spring Boot 3.5.6 第二轮 doCreateBean 收尾自检时不再报错的原因(此处埋下的因,在阶段 4.2 才兑现)
阶段 3:异常被 catch 吞掉(Spring Boot 2.7 与 3.5.6 的分水岭)

3.1 DefaultListableBeanFactory#preInstantiateSingleton(~1185-1193 行):

java 复制代码
private CompletableFuture<?> preInstantiateSingleton(String beanName, RootBeanDefinition mbd) {
    try {
        instantiateSingleton(beanName);
    }
    catch (BeanCurrentlyInCreationException ex) {
        logger.info("Bean '" + beanName + "' marked for pre-instantiation (not lazy-init) " +
                "but currently initialized by other thread - skipping it in mainline thread");
    }
    return null;
}

3.2 这个 catch 本为并发 bootstrap(后台启动的 bean 与主线竞争)设计,而 doCreateBean 收尾自检抛的恰好也是这个异常类型,被一起吞了。

3.3 效果:本轮 beanName 对应的 bean 被 destroy 了,但主线循环不中断,会继续遍历 beanNames[] 的下一个 bean。因为是循环依赖,本轮被 destroy 的 bean 还是会在下次遍历到环上某个 bean 时被循环创建出来,所以不用担心它不会被创建。

3.4 同时打出那条误导性的 INFO 日志("other thread")------实际全程单线程,真实原因是 spy 与 AOP 冲突。

阶段 4:遍历到 pay(整环以 pay 为起点重新初始化)→ 同样的冲突检测,这次静默放行

4.1 getBean(pay) → doCreateBean(pay),new 出 pay@2 → pay@2 populate 需要 order → 触发 order 重建 → doCreateBean(order),new 出 order@2:

  • order@2 populate 需要 pay 时,pay@2 还在创建中(它正在等 order)→ pay@2 被提前暴露 (无人请求 order@2 的 earlyBeanReference,但 order@2 请求了 pay@2 的 earlyBeanReference)→ 走 pay@2 的 getEarlyBeanReference 钩子链:WrapEarlyBeanPostProcessor 收到 pay@2 → 记账 map[pay] = pay@2 → 交出 override(pay@2);AnnotationAwareAspectJAutoProxyCreator 收到 override(pay@2) → 记账 earlyProxyReferences[pay] = override(pay@2) → 交出 aop(override(pay@2))
  • order@2 到手的正是 aop(override(pay@2))------这个 override 是 pay@2 的快照副本(拷贝时刻在提前暴露时刻,pay@2 的 order 字段还没填充,所以快照副本里该字段为 null)
  • order@2 完成注入 → 收尾自检时无人请求过 order@2 的 earlyBeanReference (三级缓存里 order 的提前暴露工厂已随第一轮销毁清掉)→ order@2 的 earlyBeanReference 为 null → 提前暴露比对跳过 → order@2 干净完成、放入一级缓存

4.2 回到 pay@2 的 populate:需要的 order@2→ ★ 直接命中 injectionMetadataCache[pay] → getBean(order)->一级缓存里拿到完全体的order@2:

  • registerDependentBeans 被 if (!this.cached) 守卫短路 → dependentBeanMap 维持为空(dependentBeanMap[order/pay] 在第一轮已被清掉,这一轮也不会补登)

4.3 pay@2 收尾 postProcessAfterInitialization:

  • WrapEarlyBeanPostProcessor:对账 map[pay] = pay@2,与 getEarlyBeanReference 阶段收到的参数相同 → 对账成功,直接放行 pay@2(不再包装)
  • AnnotationAwareAspectJAutoProxyCreator:对账 earlyProxyReferences[pay] = override(pay@2) != pay@2 → 对账失败 → 误判"尚未包装" → 交出重包的 aop(pay@2)(不含 override)→ 身份不匹配再次发生

4.4 doCreateBean 收尾自检------同一个位置,这次不抛了:

  • earlySingletonReference != null(确实被提前暴露过)、exposedObject != bean(postProcessAfterInitialization确实又包装了一次),但 hasDependentBean(pay) == false------它查的 dependentBeanMap 在阶段 1 结束时被清空、阶段 4 又因 cached 短路不会被补上。dependentBeanMap[pay] 为空表示没有 bean 依赖 pay,所以即使 earlyBeanReference 和最终对象 exposedObject 不一致也没关系------它认为没有任何 bean 使用这个 earlyBeanReference。于是跳过检测,直接把 aop(pay@2) 放进一级缓存,后续其他 bean 创建时注入的就是 aop(pay@2)
  • 但实际上 order@2 持有的是 aop(override(pay@2)),所以此时这个检测是错误的,这个放行是致命的
阶段 5:终局------启动"成功" + 运行期 NPE

5.1 所有 bean"就位",Started Application,全程无 ERROR 日志。

5.2 容器最终单例(getBean 拿到的):

  • 其他 bean(如本例的 TestC)持有的是最终的 aop(pay@2),而 order@2 持有的是 aop(override(pay@2))
  • 所以 TestC 中测试时针对 pay 插桩是没有用的
  • TestC 中的 order 为 aop(override(order@2)),这本身没问题;但当 TestC 调用 order,order 内部再调用 pay 时就会抛 NPE------因为往 order 注入 pay 的 earlyBeanReference 时,pay 的 earlyBeanReference 钩子链中 WrapEarlyBeanPostProcessor 创建了一个新的 pay 对象并拷贝原对象的字段,此时原 pay 对象还在属性填充中途:拷贝发生在填充完成之前,尚未注入的字段(本例的 order 字段)拷到的是 null;构造器注入等更早完成的字段则已被正常拷入。所以 order 内部调 pay、pay 再访问自己那部分未注入字段时,就会抛 NPE;访问已注入字段则正常

5.3 只有调用链走到坏副本(override)的未注入字段时才会抛 NPE,其他路径一切正常:

  • 方法链路:OrderService.orderA() 内部调 payService.payA(),PayService.payA() 又反过来调 this.orderService.orderB()------即 orderA() → payA() → orderB(),链路跨回了 order 自己
  • NPE 举例(从外到内逐层代理):
    1. TestC 调 this.orderService.orderA(),实际是 aop(override(order@2)).orderA()(order 的 AOP 代理)
    2. order 的 AOP 代理转发进 override(order@2).orderA()(无插桩则调底层对象)→ order@2.orderA()
    3. order@2 内部调 this.payService,它装的是阶段 4.1 注入给 order@2 的 pay earlyBeanReference:aop(override(pay@2))(pay 的 AOP 代理)
    4. pay 的 AOP 代理转发进 override(pay@2).payA()(pay 的 Mockito spy 包装)
    5. pay 的 spy 包装包的是 pay@2 提前暴露时刻拷贝出的快照副本(不是 pay@2 本身)------拷贝时 pay@2 还没注入 orderService,所以 pay 的快照副本里 this.orderService = null
    6. pay 的快照副本执行 payA(),走到 this.orderService.orderB(),此时副本的 this.orderService 为 null → 抛 NPE
  • 其他路径一切正常:比如 orderA() → payB(),调到 pay 后不再回头调 order,就不会走到 pay 的快照副本里 this.orderService 这个空字段------只有像 orderA() → payA() → orderB() 这样最终用到 pay 快照副本空字段的路径才会炸,所以症状比较隐蔽
  • 注意爆出时机可能比"测试方法执行期"更早:实测中,测试实例注入阶段(BeanOverrideTestExecutionListener.injectFields 往测试类字段注入 spy 时触发的调用链)就可能提前踩到坏副本的空字段而抛 NPE------凡是任何代码走到坏副本未注入字段的路径都会炸,不限时机

5.3 不冲突的场景:并非所有情况都会出问题

前面阶段 1-5 是出问题 的完整链路,但并不是所有"spy + AOP + 循环依赖"的场景都会走到坏结局。关键点是第二轮中被提前暴露的 bean 是不是双切 bean。如果不是,则第二轮正常重建,一切正常。

(本文 5.2 主线是简单环,"被提前暴露的 bean"即第二轮环起点;共享 bean 的复连通环中一条递归链可暴露多个 bean,判据对每个被提前暴露的 bean 逐一适用,见"提前暴露规律"。)

场景 A:环上只有一个双切 bean(如只有 order 双切,pay 仅被 AOP 切中)

第一轮与阶段 1-3 完全相同(order 双切 → 抛异常 → 清账 → 吞错),分水岭在第二轮:

A.1 遍历到 pay(非双切)为环起点开始第二轮 → doCreateBean(pay),new 出 pay@2 → populate 反向要 order → 触发 order@2 重建。

A.2 order@2 重建中 populate 要 pay → 请求 pay@2 的 earlyBeanReference → 走 pay@2 的 getEarlyBeanReference 链------★ 链上没有 spy 钩子 (pay 不被 @MockitoSpyBean 处理),只有 AnnotationAwareAspectJAutoProxyCreator:收到 pay@2 本身 → 记账 earlyProxyReferences[pay] = pay@2 → 交出 aop(pay@2)(注意:包的是 pay@2 本身,不是 override(pay@2))。

A.3 order@2 注入 aop(pay@2) 后,无人请求 order@2 的 earlyBeanReference → order@2 干净完成、放入一级缓存(同阶段 4.1)。

A.4 pay@2 收尾 postProcessAfterInitialization:AnnotationAwareAspectJAutoProxyCreator 对账 earlyProxyReferences[pay] = pay@2 == 收到的 pay@2 → 对账成功 → 不重包 → 直接放行 pay@2。

A.5 doCreateBean 收尾自检:收尾链原样放行了裸 pay@2(exposedObject == bean,未被重包)→ 直接走"采纳提前暴露产物"分支,aop(pay@2) 成为最终单例 → 检查直接通过(甚至不需要查 dependentBeanMap)。

A.6 而唯一的双切 bean order:第二轮中 order@2 没有被提前暴露 (提前暴露的只有 pay),它的 spy 包装发生在收尾 postProcessAfterInitialization 阶段------此刻 order@2 字段已填充完毕,override(order@2) 拷贝的是完整的实例 (不是阶段 5 那种坏快照------拷贝发生在填充中途,未注入字段拷到的是 null),spy 正常生效,容器最终单例 = aop(override(order@2)),所有 bean 拿到的引用一致。

结果:启动成功,spy 生效,运行期无 NPE,一切正常。

场景 B:环上有多个双切 bean,但第二轮环起点不是双切 bean

以三 bean 环 a -> b -> c -> earlyBeanReference(a) 为例,a、c 双切,b 非双切:

  • 若 beanNames[] = [a, c, b]:第一轮遍历到 a(双切,环起点)→ 抛异常 → 清账 → 吞错(同阶段 1-3);第二轮从 c 开始------c 也是双切,作为第二轮环起点被提前暴露 → 同阶段 4-5,仍会出问题
  • 若 beanNames[] = [a, b, c]:第一轮同样在 a 处抛异常;第二轮从 b(非双切)开始 → b -> c -> a -> earlyBeanReference(b),被提前暴露的只有 b(非双切)→ 同场景 A.2-A.5,对账成功、无坏副本,第二轮正常重建,一切正常

即:双切 bean 在第二轮中没有被提前暴露,提前暴露链上交出的就是原 bean 本身(AOP 包装),收尾对账必然成功;它只在收尾阶段被 spy 包装(拷贝的是字段已填充的完整实例,快照是好的)。

判据总结
场景 第一轮被提前暴露的 bean 第二轮被提前暴露的 bean 结果
1 双切 双切 启动"成功" + 插桩失败 + 运行期特定路径 NPE
2 双切 非双切 一切正常(spy 在收尾阶段正常生效)
3 非双切 \ 一切正常

通用判据:

  • 逐轮逐个检查被提前暴露的 bean:是双切 bean 就有风险(提前暴露链上交出的是坏 override,账本为空时收尾对账静默失败放行坏副本),非双切则该 bean 安全。
  • 简单环中每轮只有一个被提前暴露的 bean(即该轮环起点/递归链起点);共享 bean 的复连通环中一条递归链可暴露多个 bean,每个都按此判据逐一判断。
  • 无论环的形状如何,同一个环最多失败一次 ------失败后 dependentBeanMap 被清、injectionMetadataCache 永不清,下一轮收尾自检必然静默放行、不再触发 destroy,不会再有第三轮(除非 BeanDefinition 重注册触发 resetBeanDefinition 清掉 injectionMetadataCache)。Spring Boot 3.5.6 的 catch 对每次遍历都生效,但同一个环只会走到它一次。

6 结论与规避

6.1 问题的根源

记账冲突的本质是:多个 BeanPostProcessor 都对 bean 做包装时,靠各自记账来避免重复包装。每个 processor 第一次包装时记下"我收到的原对象";之后再收到这个 bean 时先对账:收到的对象和记账的原对象一致 → 已包过,直接放行;不一致 → 认为还没包过,再包一次。而 processor 之间彼此不知道对方的存在,两条钩子链收到的东西不一样:

  • 提前暴露链 (getEarlyBeanReference,仅循环依赖时触发):第一个钩子收到的是裸 bean → 包装 → 记账"我收到的是裸 bean" → 交出包装后的对象;第二个钩子收到第一个钩子交出的包装对象 → 包装 → 记账"我收到的是包装后的对象" → 交出双层包装对象。这条链交出的是双层包装对象。
  • 收尾链 (postProcessAfterInitialization):第一个钩子收到的又是裸 bean → 与它记账的"裸 bean"一致 → 对账成功,直接放行裸 bean;第二个钩子收到裸 bean → 与它记账的"包装后的对象"不一致 → 对账失败,误判"还没包过" → 再包一次。这条链交出的是单层包装对象。

这就解释了为什么不存在循环依赖时不会失败 :不存在循环依赖时,getEarlyBeanReference 钩子链根本不会触发,每个 processor 只在收尾链上做一次记账和一次包装,不存在对账失败和重新包装的可能,代码正常运行。而存在循环依赖时为什么会失败 :提前暴露机制使得包装和对账各发生两次------提前暴露链一次、收尾链一次;收尾链上第二个 processor 对账失败(收到的裸 bean ≠ 账上的包装对象),误判后重新包装,导致收尾链交出的不是裸 bean(exposedObject != bean),而自检的放行条件只有"收尾链原样放行裸 bean"这一个(满足时直接采纳提前暴露链产物,不比较两者是否相同),exposedObject != bean 且有人持有提前暴露产物 → 抛 BeanCurrentlyInCreationException,冲突由此产生。

@MockitoSpyBean 和 AOP 只是这个机制最典型的组合:任何"包装 + 记账"的 BeanPostProcessor 并存都会踩中。

6.2 规避

  • 根治:消除循环依赖(本 bug 的所有路径都以环为前提)
  • 缓解(要测环上 bean 的真实方法时):
    • 方案:把用例拆到新的测试类 ,类里直接 @Autowired 注入环上 bean(不挂任何 @MockitoSpyBean/@MockitoBean 注解),让容器走正常流程
    • 缺点:context 分裂------Spring TestContext 把配置类、profile、BeanOverride 注解等合起来算一个缓存 key,key 不一样就是两个 context。带 @MockitoSpyBean/@MockitoBean 的旧测试类和只用 @Autowired 的新测试类 key 不同,拿到的是两个不同的 ApplicationContext,各启动一次、内存翻倍

附录:源码示意图

7 流程示意图

text 复制代码
场景:@MockitoSpyBean(WRAP) + AOP + 循环依赖(Spring Boot 3.5 / Spring 6.2.11)
结果:两轮构造 → 异常被吞 → 启动成功 → 运行期特定路径 NPE

@SpringBootTest
│
├─ 阶段 0:Context 构建与容器定制
│  ├─ SPI:从 spring.factories 加载 ContextCustomizerFactory
│  │  └─ BeanOverrideContextCustomizerFactory#createContextCustomizer
│  └─ 注册 WrapEarlyBeanPostProcessor(提前暴露处理,排在 AnnotationAwareAspectJAutoProxyCreator 前面)
│     ★ spy 的快照语义:快照收到的 bean 本身,拷贝时刻在字段填充之前
│
└─ ApplicationContext.refresh()
   ├─ invokeBeanFactoryPostProcessors():业务 BeanDefinition 就位
   ├─ registerBeanPostProcessors():
   │  ├─ WrapEarlyBeanPostProcessor(最前)
   │  ├─ AutowiredAnnotationBeanPostProcessor
   │  └─ AbstractAutoProxyCreator(AOP)
   │
   ├─ finishBeanFactoryInitialization() → preInstantiateSingletons() 逐个 getBean:
   │
   │  ──【第一轮:遍历到 order】getBean(order) → doCreateBean(order):
   │     ├─ new order@1 → addSingletonFactory(三级缓存)
   │     ├─ populateBean → getBean(pay) → doCreateBean(pay):
   │     │  ├─ new pay@1 → populateBean 反向要 order → 三级缓存命中
   │     │  ├─ getEarlyBeanReference 钩子链(首个收到 order@1):
   │     │  │  ├─ WrapEarlyBeanPostProcessor:收到 order@1 → 记账 map[order]=order@1 → 交出 override(order@1)
   │     │  │  │   ★ spy 用 spiedInstance 拷贝 order@1 当时的字段 → order@1 的快照副本内 pay 字段=null
   │     │  │  └─ AnnotationAwareAspectJAutoProxyCreator:收到 override(order@1) → 记账 earlyProxyReferences[order]=override(order@1) → 交出 aop(override(order@1))
   │     │  ├─ pay@1 拿到需要的正是 aop(override(order@1))
   │     │     ├─ pay@1 收尾链(pay@1 未被提前暴露,首钩子收到裸 pay@1 → spy 正常包装,拷贝的是已填充完的实例,快照是好的)→ aop(override(pay@1)) 完成入一级缓存
   │     └─ order@1 注入需要的 aop(override(pay@1)),order@1 构造完成 → postProcessAfterInitialization 链(首个收到 order@1):
   │     │  ├─ WrapEarlyBeanPostProcessor:记账 map[order]=order@1 == 收到的 order@1 → 直接放行 order@1(不再包装)
   │     │  └─ AnnotationAwareAspectJAutoProxyCreator:记账 override(order@1) != 收到的 order@1 → 对账失败 → 重包 aop(order@1)
   │     └─ doCreateBean 收尾自检:order@1 收尾链最终交出的 aop(order@1) ≠ bean(裸 order@1)
   │        → 进入检查分支:earlySingletonReference != null(order@1 被提前暴露过,pay 拿到过提前暴露产物)
   │           && exposedObject != bean
   │        ★ 抛 BeanCurrentlyInCreationException
   │        → destroySingleton 连锁:整环 dependentBeanMap 清空
   │        ★ 但 injectionMetadataCache 的 cached=true 全部保留
   │
   │  ──【吞错】preInstantiateSingleton ~1185 行 catch (BeanCurrentlyInCreationException):
   │     INFO "currently initialized by other thread - skipping"(误导:全程单线程)
   │     → 主线继续,遍历下一个 bean(本例恰好是环上的 pay)触发整环重新构造
   │
   │  ──【第二轮:遍历到 pay】getBean(pay) → doCreateBean(pay):
   │     ├─ new pay@2 → addSingletonFactory(三级缓存)
   │     ├─ populateBean 反向要 order → getBean(order) → 触发重建 order:
   │     │  ├─ new order@2 → addSingletonFactory(三级缓存)
   │     │  ├─ order@2 populateBean 反向要 pay → 请求 pay 的 earlyBeanReference → 三级缓存命中
   │     │  │  ├─ getEarlyBeanReference 钩子链(首个收到 pay@2):
   │     │  │  │  ├─ WrapEarlyBeanPostProcessor:收到 pay@2 → 记账 map[pay]=pay@2 → 交出 override(pay@2)
   │     │  │  │  │   ★ override 是 pay@2 提前暴露时刻的快照副本,拷贝时 order 字段还没注入 → 副本内 order 字段=null
   │     │  │  │  └─ AnnotationAwareAspectJAutoProxyCreator:收到 override(pay@2) → 记账 → 交出 aop(override(pay@2))
   │     │  │  └─ order@2 拿到需要的正是 aop(override(pay@2))
   │     │  ├─ order@2 完成注入 → postProcessAfterInitialization:钩子链收到的又是裸 order@2
   │     │  │  ├─ WrapEarlyBeanPostProcessor:记账 map[order] 无此轮记录 → 交出 override(order@2)
   │     │  │  └─ AnnotationAwareAspectJAutoProxyCreator:收到 override(order@2) → 记账 → 交出 aop(override(order@2))
   │     │  ├─ doCreateBean 收尾自检:order@2 无人拿过earlyBeanReference → 自检通过
   │     │  └─ order@2 干净完成入一级缓存
   │     ├─ pay@2 取依赖走 injectionMetadataCache cached=true 捷径 → getBean(order) 直接拿 order@2
   │     │  ★ registerDependentBeans 被 if(!this.cached) 短路 → 账本不补登
   │     ├─ pay@2 完成注入 → postProcessAfterInitialization:钩子链收到的又是裸 pay@2
   │     │  ├─ WrapEarlyBeanPostProcessor:记账 map[pay]=pay@2 == 收到的 pay@2 → 直接放行(不再包装)
   │     │  └─ AnnotationAwareAspectJAutoProxyCreator:记账 override(pay@2) != 收到的 pay@2 → 对账失败 → 重包 aop(pay@2)(不含 override)
   │     └─ doCreateBean 收尾自检:pay@2 收尾链重包出的 aop(pay@2)(不含 override)≠ bean(裸 pay@2)
   │        → 进入检查分支:earlySingletonReference != null(pay@2 被提前暴露过,order@2 拿到过提前暴露产物)
   │           && exposedObject != bean
   │        → 异常分支跳过 → aop(pay@2)(不含 override)被静默采纳入一级缓存
   │        → 容器内外两套对象分叉:其他 bean 注入 aop(pay@2),order@2 持有的仍是坏 aop(override(pay@2))
   │
   │  ──【终局】所有 bean "就位",Started Application
   │     ├─ 容器最终单例 = 重建轮健康实例
   │     └─ 环上 bean 字段内 = 坏 override(提前暴露时刻拷贝的、未注入字段为 null 的对方快照副本)
   │
   └─ 阶段 4:运行期
      ├─ 穿环路径 orderA() → payA() → orderB():走到 pay 的 override(pay@2 提前暴露时刻的快照副本)内 orderService=null
      │  ★ NPE(三层代理嵌套堆栈:AOP → 业务 → AOP → override(提前暴露时刻快照副本) → NPE)
      └─ 其他路径(如 orderA() → payB(),调到 pay 后不再回头调 order)正常返回 → 症状隐蔽,难定位

8 Debug 断点表(IDEA,Spring Boot 3.5.6 / Spring 6.2.11,条件断点 beanName 过滤噪音)

# 断点目的 类#方法(spring-beans / spring-aop / spring-test) 断点位置/行号(6.2.11) 观察什么
1 间谍定义注册 MockitoSpyBeanOverrideMetadataHandler / spring-test BeanOverrideTestExecutionListener 体系 createMetadata / OverrideMetadata 构造处 @MockitoSpyBean 字段解析出的 definition、策略(WRAP)、目标 beanName
2 Wrap 处理器就位 WrapEarlyBeanPostProcessor(spring-test) 类构造 / postProcessBeforeInstantiation 与 AnnotationAwareAspectJAutoProxyCreator 的相对顺序(它在最前);targetBeanNames 记账
3 提前暴露包装(spy 现场) WrapEarlyBeanPostProcessor#getEarlyBeanReference 方法入口 收到 bean 本身 → 记账 → 交出 Mockito.spy 的 override;展开返回的 override:其内部 spiedInstance 字段值 = 当时的 bean(拷贝发生在属性填充中途 → 尚未注入的依赖字段为 null,已注入字段正常)
4 AOP 提前暴露记账 AbstractAutoProxyCreator#getEarlyBeanReference this.earlyProxyReferences.put(cacheKey, bean) 行 收到的是 override(bean) ★------AnnotationAwareAspectJAutoProxyCreator 记的账是 override,与后续收尾链收到的裸 bean 对不上账,正是记账冲突的根源
5 spy 收尾对账 WrapEarlyBeanPostProcessor#postProcessAfterInitialization cache.remove(key) 对账行 第一轮(order):remove == 收到的 bean 对账成功,直接放行 bean 本身(不再包装);第二轮(pay)重建时同样逻辑
6 AOP 收尾重包现场 AbstractAutoProxyCreator#postProcessAfterInitialization earlyProxyReferences.get(cacheKey) != bean 判断行 账上是 override ≠ 收到的 bean 本身 → 误判未包装 → wrapIfNecessary 重包出 aop(bean) ★
7 冲突宣判(order 首次初始化) AbstractAutowireCapableBeanFactory#doCreateBean ~633 行 hasDependentBean(beanName) 判断处 第一轮(order):收尾链交出的 aop(order@1) ≠ bean(裸 order@1)且 hasDependentBean=true → 抛异常;第二轮(pay):同位置 hasDependentBean=false → 跳过 ★ 核心对比点
8 吞错现场 DefaultListableBeanFactory#preInstantiateSingleton ~1185 行 catch (BeanCurrentlyInCreationException ex) 异常被吞、打出 "skipping it in mainline thread" INFO(实际单线程);此后循环继续 → 遍历下一个 bean(本例恰好是环上的 pay)触发整环重新构造
9 缓存短路(账本不补登) AutowiredAnnotationBeanPostProcessor$AutowiredElement#inject ~756 行 if (this.cached) 第二轮(pay)后所有取依赖恒走捷径分支;resolveFieldValue:794 的 registerDependentBeans 一次都不进
10 账本移除 DefaultSingletonBeanRegistry#destroyBean ~784 行 this.dependentBeanMap.remove(beanName) 连锁递归销毁整环;对照 dependenciesForBeanMap(反向表,单条路径不清)
11 三级缓存命中/缺失 DefaultSingletonBeanRegistry#getSingleton singletonFactories 分支 第一轮(order):环上反向取依赖命中三级缓存(触发提前暴露链);第二轮(pay)时的 order:此方法返回 null → 提前暴露检查整体跳过
12 最终症状现场 业务穿环方法(如 PayServiceImpl#payA) 调用依赖字段行 this.orderService == null NPE;调用栈里可见 $Proxy(AOP) → 业务 → $Proxy(AOP) → $Proxy(override=pay@2 提前暴露时刻的快照副本) → NPE 三层嵌套

备注:

  • 3-8 号断点全部加条件 beanName.contains("orderService") || beanName.contains("payService") 过滤噪音
  • 重点对比同一断点在第一轮(order 入口链)与第二轮(pay 入口链)的变量差异 ------7 号断点的 hasDependentBean 结果、9 号断点的 this.cached 值、11 号断点的返回值,是三个"第一轮炸、第二轮静默"的翻转开关
相关推荐
凤山老林1 小时前
Spring Boot 集成 Apache CXF 发布与消费 SOAP WebService:WSDL、WS-Security
spring boot·后端·apache·webservice·soap·apache cxf
软件技术NINI1 小时前
基于JavaWeb 汽车租聘
java·vue.js·spring boot·后端·python·毕业设计·毕设
yz_aiks1 小时前
RestTemplate 废弃后迁移 RestClient 实战:Spring Boot 4.2 升级指南
java·spring boot·后端·微服务·restclient
wuminyu1 小时前
JVM规范学习总结-JVM规范基本概念
java·linux·c语言·jvm·c++
时间静止不是简史5 小时前
生产事故排查:Redis 宕机导致补偿队列消费线程死亡,通话录音未上送业务平台
java·redis
2301_32241428048 小时前
基于SpringBoot+Vue的休闲零食超市管理系统的设计与实现59004-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·vue.js·spring boot·redis·mysql·决策树·推荐算法
金銀銅鐵10 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
福建佰胜张工11 小时前
SMC 薄型气爪 MHF2-16D1 深度解析:平行开闭、低剖面结构在自动化装配中的选型与落地
java·服务器·安全·自动化
念何架构之路11 小时前
go-grpc设计思想
开发语言·后端·golang