一个接口多个实现,Spring 怎么"适配多场景"?

35 阅读6分钟

一个接口多个实现,Spring 怎么”适配多场景”?

你正给后台加个报表导出:用户点一下,PDF、Excel、CSV 随便选。三个实现你全备齐了:

public interface ReportExporter {
    boolean supports(String format);   // 我支持啥格式
    byte[] export(List rows);          // 导出
}

@Service
public class PdfExporter implements ReportExporter { ... }    // PDF

@Service
public class ExcelExporter implements ReportExporter { ... }  // Excel

@Service
public class CsvExporter implements ReportExporter { ... }    // CSV

然后随手一注:

@Service
public class ReportService {
    @Autowired
    private ReportExporter exporter;   // 容器里仨,要哪个?
}

启动,啪——

NoUniqueBeanDefinitionException: No qualifying bean of type 'ReportExporter' available:
expected single matching bean but found 3: csvExporter, excelExporter, pdfExporter

一个接口仨实现,Spring 懵了:你到底要哪个?

18 篇聊注入的”方式”(用哪种),这篇聊注入的”匹配”(注入哪个)

  1. 一个接口多个实现,Spring 凭什么选?
  2. 能不能一次把所有实现都拿到?
  3. 有些依赖”可能有也可能没有”,怎么不报错?

一、一个接口多个实现,Spring 听谁的

容器里有三个 ReportExporter,Spring 不知道选哪个。三个解法,各有脾气。

解法一:@Primary——”有多个时,默认选我”

@Service
@Primary
public class ExcelExporter implements ReportExporter { ... }

@Primary 标在某个实现上,意思是”有多个候选时,默认选我”。这下 @Autowired ReportExporter 不报错了,注入的是 ExcelExporter

适合场景:大多数情况都用某一个实现,偶尔切换。比如默认导出 Excel,个别要 PDF 的再单独点名。

解法二:@Qualifier——”我点名要这个”

@Service
public class ReportService {
    @Autowired
    @Qualifier("pdfExporter")
    private ReportExporter exporter;
}

@Qualifier 跟在 @Autowired 后面,按 Bean 名字精确指定。比 @Primary 更明确——你要谁就是谁。代价是耦合到了具体的 Bean 名字上,Bean 一改名,注入也得跟着改。

适合场景:这个字段就是要那个特定的实现,没得商量。

解法三:@Resource——”我按名字找”

@Service
public class ReportService {
    @Resource(name = "pdfExporter")
    private ReportExporter exporter;
}

@Resource 是 JSR-250 标准注解(不是 Spring 的),匹配规则是先按名字,再按类型。你可以理解成”加强版的 @Autowired + @Qualifier“。

那它和 @Autowired 到底差在哪?

对比维度@Autowired@Resource
来源Spring 自带JSR-250 标准(Java 规范)
默认匹配按类型按名字
找不到时报错(除非 required=false)回退按类型
能用在构造器上吗不能(Spring 只支持字段/setter 上的 @Resource)
推荐场景构造器注入配它,或字段/setter字段/setter 注入按名字找

Spring 到底怎么选的?

候选有多个时,Spring 按下面这个顺序逐级往下试,命中哪级就用哪级:

试到哪级Spring 问自己命中就
你标了 @Qualifier 点名?注入它指定的
有 @Primary 实现吗?注入那个
字段名恰好等于某个 Bean 名?按名字注入
都不中报 NoUniqueBeanDefinitionException

@Qualifier 居首,@Primary 次之,”凑名字”殿后。

二、一次注入全部实现:List 和 Map

换个思路——多个实现,干嘛非得选一个?全都要。

还记得第 14 篇《用 SpringBean 实现多态、告别 if-else》吗?那里用到一个技巧:把多个实现一次性注入成集合。这里正式讲清楚。

注入 List:拿到所有实现

@Service
public class ReportService {

    private final List<ReportExporter> exporters;

    public ReportService(List<ReportExporter> exporters) {
        this.exporters = exporters;
    }

    public byte[] export(String format, List rows) {
        for (ReportExporter e : exporters) {
            if (e.supports(format)) {
                return e.export(rows);
            }
        }
        throw new IllegalArgumentException("不支持的格式:" + format);
    }
}

Spring 看到你要 List<ReportExporter>,会把容器里所有 ReportExporter 类型的 Bean 收集起来,按顺序塞进 List。你哪天加个 HtmlExporter,啥都不用改,自动进 List。

这是策略模式最优雅的 Spring 实现——注册即生效,零配置

注入 Map:按 Bean 名字索引

更进一步的玩法:

@Service
public class ReportService {

    private final Map<String, ReportExporter> exporterMap;

    public ReportService(Map<String, ReportExporter> exporterMap) {
        this.exporterMap = exporterMap;
    }

    public byte[] export(String format, List rows) {
        ReportExporter e = exporterMap.get(format + "Exporter");   // "pdf" -> pdfExporter
        if (e == null) {
            throw new IllegalArgumentException("不支持的格式:" + format);
        }
        return e.export(rows);
    }
}

Map<String, ReportExporter> 的 key 是 Bean 名字(默认类名首字母小写),value 是 Bean 实例。这样就能按名字 O(1) 直接查找,不用循环——用户传 format=pdfget("pdfExporter") 直接拿到。

注入方式key 是什么适合场景
List<接口>顺序索引需要遍历所有实现、按条件分发
Map<String, 接口>Bean 名字需要按名字精准定位某个实现

这一招结合第 14 篇的多态玩法,能干掉业务代码里几乎所有的 if-else 分支。

三、兜底三件套:@Lazy、ObjectProvider、Optional

前面都是”依赖一定存在”的情况。可现实里有些依赖可能有、也可能没有——比如通知服务,配了就发、没配就跳过。这时候硬注入会报错。三个兜底工具,挑一个用。

@Lazy:循环依赖的创可贴

第 17 篇聊循环依赖时说过,构造器循环依赖是无解的——除非上 @Lazy

@Service
public class AService {
    private final BService bService;

    public AService(@Lazy BService bService) {
        this.bService = bService;
    }
}

@Lazy 的作用:Spring 不真注入 BService,而是注入它的代理对象。  等 AService 真正调用 bService 的方法时,代理才去容器里找真正的 Bean。

这就打破了”构造 A 需要 B、构造 B 又需要 A”的死循环——A 构造时拿到的是 B 的代理(占位),B 可以照常构造。

但记住: @Lazy 是创可贴,不是治本的药。  循环依赖本身是设计问题,最好的解法是第 17 篇说的——重新设计、用事件解耦。@Lazy 留给实在改不动的老代码。

ObjectProvider:延迟 + 可选,Spring 原生

ObjectProvider<T> 是 Spring 提供的延迟获取容器:

@Service
public class OrderService {

    private final ObjectProvider<NotificationService> notificationProvider;

    public OrderService(ObjectProvider<NotificationService> notificationProvider) {
        this.notificationProvider = notificationProvider;
    }

    public void placeOrder(Order order) {
        saveOrder(order);
        // 容器里没有 NotificationService 也不报错
        notificationProvider.ifAvailable(ns -> ns.notify(order));
    }
}

它一手解决两件事:

  1. 延迟获取——构造时只拿到一个 Provider,真正用时才查容器
  2. 可选依赖——用 ifAvailable / getIfAvailable,找不到也不报错

比 @Autowired(required = false) 优雅的地方在于:它把”可选”这件事显式表达在类型上——光看构造器签名,你就知道这个依赖是 optional 的,不用翻注解。

Optional:Java 8 风格的可选注入

从 Spring 4.3 起,构造器里还能直接注入 Optional

@Service
public class OrderService {

    private final Optional<NotificationService> notificationService;

    public OrderService(Optional<NotificationService> notificationService) {
        this.notificationService = notificationService;
    }
}

语义和 ObjectProvider 类似,但用的是 JDK 自带的 java.util.Optional。哪个顺眼,看个人喜好——语义上都清楚。

三个怎么选

工具解决什么典型场景
@Lazy延迟到使用时才真正创建/查找打破构造器循环依赖
ObjectProvider延迟 + 可选,Spring 原生可选依赖、延迟获取
Optional可选,JDK 原生可选依赖,语义清晰

一句话体会:这三个都是兜底,不是日常。

四、总结

如果系统永远只导出一种格式,给那个实现标个 @Primary 就完事了。但对产品来说,固定一种格式太单一——今天要 Excel,明天加 PDF,后天又要 CSV,每加一种都得改代码、重新部署,扩展性太差。

真正”适配多场景”的正解,落回第二章那个 Map 注入:按前端传进来的 format 参数,直接从 Map 里取对应的导出器——

ReportExporter e = exporterMap.get(format + "Exporter");   // format=pdf -> pdfExporter

哪天产品要加 HTML 导出?写个 HtmlExporter 扔进容器,ReportService 一行都不用改,自动生效。注册即生效、按需路由——这才是”适配多场景”该有的样子。

回过头看,一个接口多个实现从来不是 Spring 的 bug,而是它给你留的多场景适配口子。至于 @LazyObjectProvider 这些兜底工具,留给真撞墙的时候;日常依赖老老实实写在构造器上、让它”必须存在”,是便宜得多的保险。