为什么要面向接口编程——从一次依赖注入的困惑说起
在学习 Spring 项目时,一个常见的困惑是:Controller 里注入的 IProductFacade 是个接口,里面没有任何方法实现,为什么可以直接调用它的方法?如果多个类都实现了这个接口,Spring 又怎么知道注入哪一个?更根本的问题是——为什么不直接注入实现类,非要绕一层接口?
这篇文章以一个真实的商城项目(fullstack-mall)为例,把这三个问题一次讲透。
一、接口没有实现,为什么能直接调用?
答案:运行时注入进去的对象是有实现的。背后是 Java 的多态 + Spring 的依赖注入(DI)。
项目里有三个角色:
1. 接口(契约) —— 位于 contract 模块:
public interface IProductFacade {
ProductDetailResponse getPublishedProduct(Long productId);
PageResponse<ProductSummaryResponse> queryPublishedProducts(ProductQueryRequest request);
// ...
}只声明方法,没有实现。它定义"商品模块能做什么",不关心"怎么做"。
2. 实现类 —— 位于 service 模块:
@Service
public class ProductFacade implements IProductFacade {
@Override
public PageResponse<ProductSummaryResponse> queryPublishedProducts(ProductQueryRequest request) {
return toPageResponse(productDbService.queryPublishedPage(request));
}
// ...
}implements IProductFacade 实现了接口的全部方法,@Service 告诉 Spring:"启动时帮我创建一个 ProductFacade 实例,放进容器管理"。
3. 注入点 —— Controller:
@Resource
private IProductFacade productFacade;Spring 启动流程:
- 扫描到
@Service ProductFacade,实例化它,存入容器,登记为"IProductFacade 的实现"; - 创建
ProductController时,看到@Resource IProductFacade,就去容器里找这个接口的实现,把那个ProductFacade实例赋值给productFacade字段。
运行时内存里的关系:
productFacade (编译期类型: IProductFacade)
│
└──> 实际指向的对象 (运行时类型: ProductFacade)
└── 里面是真正的实现代码调用 productFacade.queryPublishedProducts(request) 时,JVM 按多态规则分发到 ProductFacade 里 @Override 的那个方法。字段声明的类型是接口,对象实际的类型是实现类——这就是"面向接口编程"。
和前端的区别
TypeScript 的 interface 编译后被擦除,运行时不存在;Java 的 interface 在运行期是真实存在的类型,只是不能直接 new,必须有一个实现类配合。
二、多个实现类时,Spring 怎么知道注入哪一个?
1. 什么都不做 → 直接启动失败
如果有两个类都 implements IProductFacade 且都注册为 Bean,Spring 按类型注入时发现两个候选,启动时直接抛异常:
NoUniqueBeanDefinitionException: expected single matching bean but found 2: productFacade1, productFacade2Spring 不会帮你随便选一个,而是强迫你表态。
2. 用 Bean 名字指定(最常用)
每个 Bean 都有名字,@Service 不指定时默认是类名首字母小写:
@Service("facadeV1") // 或者不写,默认 "productFacade1"
public class ProductFacade1 implements IProductFacade { ... }
@Service("facadeV2")
public class ProductFacade2 implements IProductFacade { ... }注入时按名字拿。@Resource(JSR-250)默认先按字段名找,再按类型找:
@Resource(name = "facadeV1") // 显式指定
private IProductFacade productFacade;
// 或者利用 @Resource 的默认规则:字段名 = Bean 名
@Resource
private IProductFacade facadeV1; // 字段名匹配到 facadeV1 这个 Bean如果用 @Autowired(Spring 自带),则搭配 @Qualifier:
@Autowired
@Qualifier("facadeV2")
private IProductFacade productFacade;3. 用 @Primary 指定"默认选项"
@Service
@Primary
public class ProductFacade1 implements IProductFacade { ... }有多个候选时优先注入它。但建议至少配合名字使用,光靠 @Primary 时间长了容易忘了还有另一个实现存在。
4. 按环境/配置条件注册(只加载一个)
很多时候"多个实现"不是要同时存在,而是不同环境用不同的:
@Service
@ConditionalOnProperty(name = "product.facade", havingValue = "v2")
public class ProductFacade2 implements IProductFacade { ... }配置里写 product.facade=v2,容器里就只有这一个 Bean,注入处完全不用改。@Profile("dev") 同理。
5. 全都要 → 策略模式
如果业务上确实需要同时用多个实现(比如不同支付渠道、不同推荐算法),直接注入集合:
@Resource
private Map<String, IProductFacade> facadeMap; // key 是 Bean 名
@Resource
private List<IProductFacade> facadeList; // 所有实现按顺序注入再用一个工厂/路由类按 key 分发:
public IProductFacade route(String channel) {
return Optional.ofNullable(facadeMap.get(channel))
.orElseThrow(() -> new BusinessException(ApiCode.XXX, "未知渠道"));
}小结
| 场景 | 方案 |
|---|---|
| 只有一个实现 | 按类型注入即可 |
| 多个实现、固定选一个 | @Resource(name=...) / @Qualifier 按名字 |
| 多个实现、有一个是默认 | @Primary |
| 按环境切换 | @Profile / @ConditionalOnProperty |
| 运行时动态选择 | 注入 Map<String, IProductFacade>,策略模式 |
核心原则:类型只解决"有哪些能力",名字/条件/主次才解决"具体是谁"。多个实现并不可怕,Spring 只是要求你必须给出选择依据。
三、为什么不直接注入 ProductFacade,非要绕一层接口?
先看项目的模块结构:
backend/
├── contract/ ← 纯契约模块(只有 Lombok + jakarta.validation 依赖)
│ └── product/IProductFacade.java + 各种 Request/Response
└── service/ ← 实现模块(依赖 contract)
└── product/ProductFacade.java关键在 contract/pom.xml 的依赖列表——它只有 Lombok 和 jakarta.validation,没有 Spring Web、没有 MyBatis、没有 Redis。两个 Maven 模块之间的依赖是单向的:
service ──依赖──> contract ✅ 合法
contract ──依赖──> service ❌ 编译都过不了1. 依赖倒置:控制的是"依赖的方向"
如果 Controller 直接注入 ProductFacade:
// 反例
import com.example.fullstackmall.service.product.ProductFacade; // 拖进实现
@Resource
private ProductFacade productFacade;看似只是多 import 了一个类,但依赖会传染。ProductFacade 内部 import 了:
import ...service.category.entity.CategoryEntity; // 数据库实体
import ...service.product.cache.ProductDetailCacheService; // Redis 缓存
import ...service.inventory.service.SkuDbService; // SKU 库存Web 层一旦引用了实现类,IDE 的自动补全、循环引用检查、重构工具都会认为"Controller 可以合法接触这些细节"。慢慢地就会有人写出 productFacade.productDbService.xxx 这种代码——边界是靠纪律守不住的,得靠结构。
倒置之后:
ProductController ──┐
├──> IProductFacade(抽象,contract 模块)
ProductFacade ──────┘注意箭头方向:实现类反过来依赖接口,而不是 Controller 依赖实现类。这就是"倒置"二字的含义——传统的 thinking 是"我要用谁就 import 谁",倒置后是"我定义我需要什么,让实现来满足我"。Controller 端只看得见:入参 DTO、出参 DTO、方法名和 Javadoc,其他一概不知。
2. 契约可独立发布:一份 jar,多处使用
IProductFacade 和它的 Request/Response 都在 contract 模块里,这个模块轻到可以被任何人依赖:
- 其他后端服务:拆微服务时,订单服务想调商品服务,只需依赖
fullstack-mall-contract这个几 KB 的 jar,不用把 MyBatis、Redis 客户端拖进来; - 公司的二方库:contract 打包发布到私服,消费方拿着接口 + DTO 就能开发,甚至可以先用一个内存假实现跑通流程;
- 前端联调:contract 里的 DTO 结构就是接口文档的事实来源,可以直接生成 TypeScript 类型,保证前后端字段定义一致。
如果接口和实现混在一起,上面每一条都得"顺带"打包整个 service 模块及其全部依赖。
3. 可测试性:日常收益最大的一条
假设要测 ProductController 的参数校验、状态码、TraceId 透传。直接注入实现类的话,ProductFacade 内部有 ProductDbService(MyBatis)、ProductDetailCacheService(Redis)、CurrentUserService……得连着 MySQL 和 Redis 才能跑起来。
依赖接口后:
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private IProductFacade productFacade; // 只需要个假对象
@Test
void 商品不存在时返回404() throws Exception {
given(productFacade.getPublishedProduct(99L))
.willThrow(new BusinessException(ApiCode.PRODUCT_NOT_FOUND, "商品不存在", HttpStatus.NOT_FOUND));
mockMvc.perform(get("/api/products/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ApiCode.PRODUCT_NOT_FOUND.getCode()));
}
}不需要数据库、不需要 Redis,毫秒级跑完。而且测试意图非常清楚:这一层只测 Web 层的职责(路由、校验、错误映射),业务逻辑对不对是 ProductFacade 自己的单测该管的事。
4. 可替换与可包装:实现的演进不动契约
接口稳定后,实现层获得了自由。
整体替换——新写 ProductFacadeV2 implements IProductFacade 挂 @Primary,Controller 一行不改。
装饰器包装——更实用。比如想给商品详情加限流,不改原实现,新写一个:
@Service
@Primary
public class RateLimitedProductFacade implements IProductFacade {
@Resource
private ProductFacade delegate; // 原实现
@Override
public ProductDetailResponse getPublishedProduct(Long id) {
// 先过限流器,再委托给原实现
rateLimiter.acquire();
return delegate.getPublishedProduct(id);
}
// 其他方法同样委托...
}Controller 完全无感知。这在直接注入具体类的写法里做不到——你没有"同一形状的另一个东西"可以插进去。
5. 语义层面:接口是一份"用例说明书"
回看 IProductFacade 的 Javadoc,写的全是业务规则:
/** 按后端状态机变更商品状态;上架前还会校验分类和可售 SKU。 */
/** 分页查询公开商品;服务端强制只返回 ON_SALE。 */而 ProductFacade 里写的是技术手段:先查分类、再校验 SKU、写库后删缓存。这两层信息的受众不同:
- 接口 = 这个模块对外承诺什么(改它 = 改合同,要慎重、要通知所有消费方);
- 实现 = 我们怎么兑现承诺(随便重构,缓存方案调整、DB 换分库,消费方都不知道)。
新人接手项目时,读一遍 contract 包就知道了全部业务用例;排查问题时,Controller → 接口 → 实现的调用链也让"入口在哪里、逻辑在哪里"一目了然。
6. 诚实地说:什么时候不需要这层接口
避免教条化。如果满足以下全部条件,直接注入实现类也没问题:
- 单模块小项目,没有拆分/复用的可能;
- 永远只会有一个实现;
- 没有"Web 层禁止接触实现细节"这种团队规约。
但示例项目不满足第 1 条——它已经物理上拆成了 contract / service 两个 Maven 模块。既然划了模块边界,接口就是两个模块之间的合法通道:contract 里只有接口和 DTO,service 里实现它,Controller 只依赖通道。这不是为了设计模式而设计模式,而是让已经存在的模块边界真正发挥作用。
总结
- 接口为什么能被调用:多态 + Spring DI。字段声明的是接口类型,运行时注入的是实现类对象;
- 多个实现怎么选:名字(
@Resource/@Qualifier)、主次(@Primary)、条件(@Profile/@ConditionalOnProperty)、全要(注入 Map/List,策略模式); - 为什么要绕一层接口:把"模块承诺做什么"(稳定的契约)和"模块怎么做"(易变的实现)拆开,并用类型系统强制执行这个拆分——这是任何注释和 code review 都做不到的。
一句话:接口是"插座标准",实现类是"插头",Spring 是负责把插头插进插座的电工。