Administrator
发布于 2018-12-07 / 2483 阅读
11

@Autowired 注入失败的各种原因与排查清单

注入的 service 是 null,我对着空指针查了一下午

十二月初写了个定时任务,跑起来第一行就 NPE:

@Component
public class OrderTimeoutJob {
    @Autowired
    private OrderService orderService;

    public void execute() {
        List<Order> list = orderService.findTimeoutOrders();     // 这行 NPE
        // ...
    }
}

异常栈很简单:

java.lang.NullPointerException
    at com.xxx.job.OrderTimeoutJob.execute(OrderTimeoutJob.java:24)

orderService 是 null。但同一个 OrderService 在 Controller 里注入得好好的。我一开始怀疑是 OrderService 本身有问题,查了半天注解,最后发现是 job 类根本没被 Spring 管理。

原因一:类不在包扫描范围内

启动类的位置:

package com.xxx;

@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

而我的 job 类在 com.xxx.job 下,看着是子包,应该被扫到才对。问题出在我后来为了整理代码,把 job 挪到了 com.job(少了 xxx 这一层),而 @SpringBootApplication 默认只扫描启动类所在包及其子包

验证方法,启动时加一行打印:

@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        ConfigurableApplicationContext ctx = SpringApplication.run(OrderApplication.class, args);
        System.out.println("是否包含 OrderTimeoutJob: " + ctx.containsBean("orderTimeoutJob"));
    }
}

输出 false,确认没被注册。

解决办法有两种,一是把包挪回去(我选了这个),二是显式指定扫描路径:

@SpringBootApplication(scanBasePackages = {"com.xxx", "com.job"})

关于 @SpringBootApplication 还有个容易忽略的点:它是三个注解的组合:

@SpringBootConfiguration      // 本质是 @Configuration
@EnableAutoConfiguration
@ComponentScan                // 默认扫当前包及子包

所以启动类放的位置决定了整个项目的扫描根路径。我们项目的规范是启动类必须放在最外层的 com.xxx 下。

原因二:这个对象是我自己 new 出来的

第二次遇到注入为 null,是在一个工具类里:

public class PriceCalculator {
    @Autowired
    private SkuService skuService;      // null

    public BigDecimal calc(Long skuId) {
        Sku sku = skuService.getById(skuId);     // NPE
        // ...
    }
}

// 调用方
PriceCalculator calculator = new PriceCalculator();     // 自己 new 的

这是我当时最不理解的一条:Spring 只负责管理由它创建的对象。你 new 出来的对象,Spring 根本不知道它存在,自然不会给它注入依赖。

改法是把工具类也交给 Spring:

@Component
public class PriceCalculator {
    private final SkuService skuService;

    @Autowired
    public PriceCalculator(SkuService skuService) {
        this.skuService = skuService;
    }
    // ...
}

// 调用方也注入
@Autowired
private PriceCalculator calculator;

顺带说下我为什么改成构造器注入。用 @Autowired 标注字段的话,下面这种情况编译能过、运行时 NPE:

@Component
public class PriceCalculator {
    @Autowired
    private SkuService skuService;

    public PriceCalculator() {
        skuService.getById(1L);      // 构造器执行时,字段还没被注入!
    }
}

字段注入发生在对象实例化之后,所以构造器里用不了注入的字段。改成构造器注入之后,依赖关系在构造时就确定了,而且字段可以声明成 final,避免后续被改掉。Spring 4.3 之后,如果类只有一个构造器,连 @Autowired 都可以省。

原因三:静态字段注入

第三次,我写了个工具类想做成静态的:

@Component
public class SmsUtil {
    @Autowired
    private static SmsClient smsClient;     // 注入失败,永远是 null

    public static void send(String phone, String content) {
        smsClient.send(phone, content);     // NPE
    }
}

Spring 不支持静态字段的依赖注入,它扫描的是实例字段。而且就算注入成功了,静态字段属于类,所有实例共享,跟 Spring 的单例/原型作用域设计冲突。

三种绕法,各有取舍:

// 方式一:@PostConstruct 里赋值给静态字段(能跑,但别扭)
@Component
public class SmsUtil {
    private static SmsClient CLIENT;

    @Autowired
    private SmsClient smsClient;

    @PostConstruct
    public void init() {
        CLIENT = smsClient;
    }

    public static void send(String phone, String content) {
        CLIENT.send(phone, content);
    }
}
// 方式二:实现 ApplicationContextAware 拿容器(我以前用过,不推荐)
@Component
public class SpringContextHolder implements ApplicationContextAware {
    private static ApplicationContext ctx;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        ctx = applicationContext;
    }

    public static <T> T getBean(Class<T> clazz) {
        return ctx.getBean(clazz);
    }
}
// 方式三:别用静态,改成正常注入(最终方案)
@Component
public class SmsSender {
    private final SmsClient smsClient;

    @Autowired
    public SmsSender(SmsClient smsClient) {
        this.smsClient = smsClient;
    }

    public void send(String phone, String content) {
        smsClient.send(phone, content);
    }
}

师傅的说法是:"想用静态方法本质上是你想少写一行注入,代价是把自己绕进 Spring 的生命周期外面去了。别这么干。"我后来把项目里所有 ApplicationContextAware 的 holder 都删了。

原因四:多个实现类,Spring 不知道选哪个

第四次的报错信息不一样,是启动失败:

org.springframework.beans.factory.NoUniqueBeanDefinitionException:
No qualifying bean of type 'com.xxx.PaymentService' available:
expected single matching bean but found 2: alipayService, wechatPayService

代码:

public interface PaymentService {
    void pay(Order order);
}

@Service
public class AlipayServiceImpl implements PaymentService { ... }

@Service
public class WechatPayServiceImpl implements PaymentService { ... }

@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;      // 两个实现,选谁?
}

三种解法:

// 1. @Qualifier 指定 bean 名
@Autowired
@Qualifier("alipayService")
private PaymentService paymentService;

// 2. 字段名和 bean 名一致(Spring 的 byName 兜底策略)
@Autowired
private PaymentService alipayService;      // 变量名就是 bean 名

// 3. 其中一个加 @Primary
@Service
@Primary
public class AlipayServiceImpl implements PaymentService { ... }

我们最后是这么做的——用 Map 把所有实现收集起来,按类型分发:

@Service
public class PaymentRouter {
    private final Map<String, PaymentService> handlers = new ConcurrentHashMap<>();

    @Autowired
    public PaymentRouter(List<PaymentService> services) {
        for (PaymentService s : services) {
            handlers.put(s.supportType(), s);
        }
    }

    public void pay(String payType, Order order) {
        handlers.get(payType).pay(order);
    }
}

Spring 会把某个接口的所有实现类注入到一个 ListMap 里(Map 的 key 是 bean 名)。这算是策略模式在 Spring 下的标准写法,加新的支付方式只要新增实现类,不用改动路由代码。

另外几个容易忽略的情况

注解用错了。 我见过有人把 @Autowired 写在接口上,或者把 @Service 写成 @Component 导致 AOP 不生效(其实这两个都能注入,但语义不对)。注解的语义要分清:@Component 是通用的,@Service/@Repository/@Controller 是它的特化,除了语义清晰,@Repository 还额外提供了异常转换(把 SQLException 转成 Spring 的 DataAccessException)。

作用域不匹配。 把一个 prototype 的 bean 注入到 singleton 里,只会注入一次,后续拿到的还是同一个实例:

@Scope("prototype")
@Component
public class TaskContext { ... }

@Service
public class TaskService {
    @Autowired
    private TaskContext context;      // 只注入一次,不是每次都新的
}

这个坑很隐蔽。要每次拿新实例,得用 ObjectProvider 或者 @Lookup 方法。

循环依赖。 Spring 能解决字段注入方式的循环依赖(靠三级缓存和提前暴露对象),但解决不了构造器注入的循环依赖:

Description:
The dependencies of some of the beans in the application context form a cycle:
   orderService defined in file [OrderService.class]
      ↓
   stockService defined in file [StockService.class]
      ↓
   orderService
Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'orderService':
Requested bean is currently in creation: Is there an unresolvable circular reference?

遇到这个,正确的做法不是改回字段注入,而是重新设计依赖关系。我们那次是把两个 service 共同依赖的逻辑抽成了第三个 service。

排查清单

注入为 null 或者注入失败,按这个顺序查:

  1. 目标类上有没有 @Component/@Service 等注解;
  2. 它所在的包在不在启动类的扫描范围(用 ctx.containsBean() 验证);
  3. 使用方是不是自己 new 出来的对象(包括在各种线程池的 Runnable 里 new 的);
  4. 字段是不是 static
  5. 是不是有多个实现类导致歧义(看启动日志里的 NoUniqueBeanDefinitionException);
  6. 有没有循环依赖导致 bean 创建不完整。

还有个调试小技巧,把所有注册的 bean 名字打印出来看看:

@Component
public class BeanPrinter implements CommandLineRunner {
    @Autowired
    private ApplicationContext ctx;

    @Override
    public void run(String... args) {
        String[] names = ctx.getBeanDefinitionNames();
        Arrays.sort(names);
        for (String name : names) {
            System.out.println(name + " -> " + ctx.getType(name).getSimpleName());
        }
    }
}
orderService -> OrderServiceImpl
stockService -> StockServiceImpl
...

我排查那次 job 注入失败时,就靠这个脚本一眼看出 orderTimeoutJob 不在列表里,前后不到五分钟。要是早知道这个办法,能省掉一下午。

参考