☰
为什么@Mapper标在接口而@Service标在实现类?底层机制解析
2026/10/12 21:25:05 网站建设 项目流程

如果你写 Java 后端的时间稍微长一点,大概率产生过这个困惑:同样都是“告诉框架这是一个需要管理的组件”,为什么@Service、@Controller总是放在实现类上,而@Mapper却堂而皇之地放在接口上?尤其是当项目里把接口和实现类拆成分层模块时,现象就更奇怪了——Service 接口通篇没有实现类,却能照常注入;Mapper 接口连一个MapperImpl都不存在,Spring 启动也能稳稳当当。这套看似矛盾的做法,背后涉及 Spring 组件扫描和 MyBatis 动态代理两条完全不同的机制。

如果把结论压缩成一句话:@Mapper标注的是 MyBatis 要为它生成代理对象的“接口”,@Service/@Controller标注的是 Spring 容器要实例化的“具体类”。一个是代理的起点,一个是实例化的对象,两个框架对注解目标的期待天然就不一样。但这句结论背下来没有用,换个场景还是会踩坑。我打算把两边机制拆开讲清楚,顺便把项目里常见的几个“接口注解混乱”引发的启动失败和注入问题也一并排掉。

1. 先回答最表面的那一层:谁在被注册成 Bean

要把这个问题理清,首先得接受一个事实:@Service和@Mapper虽然长得像“同类注解”,但它们服务的框架和生命周期的阶段完全不同。

@Service属于 Spring 构造型注解,它和@Component、@Controller、@Repository是一家人。Spring 在启动时通过@ComponentScan扫描类路径,找到带这类注解的具体类,然后由AutowireCapableBeanFactory反射构造一个实例。这里的关键点是:Spring 需要的是一个“能 new 出来的类”。如果一个类没有明确的构造方法、抽象方法占位、或者它干脆是一个接口,Spring 的常规路径是无能为力的。

@Mapper则属于 MyBatis 的注解,作用是告诉 MyBatis:这个接口需要被扫描,并由 MyBatis 在运行时生成一个代理实现。真正的 Bean 并不是接口本身,而是MapperFactoryBean创建的 JDK 动态代理对象。也就是说,接口在这个机制里不是“待实例化的目标”,而是“代理生成的模板”。

我把两个注解的关键差异放在一起对比一下,看完这个问题基本就通了一半:

对比维度@Service / @Controller@Mapper
出处Spring 框架自身MyBatis 及 MyBatis-Spring 集成
标注对象具体实现类接口
注册方式组件扫描直接注册类,反射实例化扫描接口后注册MapperFactoryBean,Bean 本体是动态代理
依赖注入时声明的类型实现类本身,或它实现的接口接口本身
标错位置的后果接口标了通常不会被注册,运行期报没有可用 Bean标在实现类上没有意义,不会被识别

这里要说一个容易被忽略的点:@Mapper接口在注入时,接口类型就是 Bean 的类型。你可以在 Controller 里直接@Autowired private UserMapper userMapper;,Spring 通过类型匹配到MapperFactoryBean生成的代理,当你调用userMapper.selectById时,实际上走的是代理对象内部的SqlSession。而@Service实现类在注入时,Spring 按类型匹配到的通常是实现类本身,只不过因为你声明的是接口类型,它会把实现类的实例赋上去。

这两个机制的分工一旦理清,题目里的“为什么”就只剩最后一层窗户纸:因为框架要的东西不一样,所以注解待的位置自然不一样。

2. 从 Spring 的组件扫描看:接口上的 @Service 为什么默认失灵

很多人不服气:我偏要在接口上加@Service,实现类也加了@Service,项目照样能跑,你怎么说?能跑是因为脚手架够宽容,而不是因为 Spring 真的接纳了接口上的注解。这里得把 Spring 扫描候选组件的流程翻出来看。

2.1 Spring 扫描组件时的两层判断逻辑

@ComponentScan的扫描不是“看一眼有没有注解”就完事,它由ClassPathScanningCandidateComponentProvider执行。大致流程是:

  1. 根据配置的包路径,遍历 classpath 下所有.class文件,生成MetadataReader;
  2. 用 include/exclude 过滤器初步筛选。默认的注解过滤器AnnotationTypeFilter会匹配带有@Component及其元注解(也就是@Service、@Controller、@Repository)的类;
  3. 对初步匹配到的候选对象调用isCandidateComponent(AnnotatedBeanDefinition)做二次校验;
  4. 只有通过二次校验的 BeanDefinition 才会被注册到容器。

问题就出在第三步。isCandidateComponent的实际逻辑可以理解成:

  • 这个类必须是“独立的”(顶层类或静态内部类);
  • 并且它要么是“具体的”普通类,要么是“携带@Lookup方法的抽象类”。

关键词是“具体类”。接口永远不是一个可实例化的具体类,它没有构造函数,也没有任何方法体,Spring 拿它去BeanUtils.instantiateClass根本无从下手。所以接口即便在第二步被注解过滤器匹配到了,在第三步也会被直接筛掉,压根不会注册成 BeanDefinition。

2.2 接口加 @Service 后到底会发生什么

我实际见过最典型的场景是这样的:某同学在 Service 接口上标了@Service,实现类UserServiceImpl上没标,启动时 Spring 报No qualifying bean of type 'UserService'。他当时百思不解,觉得注解不是已经标在接口上了吗?

原因就在这里。接口没有被注册,实现类又没有@Service,容器里完全没有UserService类型对应的 Bean,注入自然失败。

还有一种更隐蔽的场景:接口和实现类都标了@Service。这种情况下项目能跑,因为实现类被正常扫描注册,接口上的@Service被静默忽略。但别高兴得太早,这种写法最容易埋雷。假设后续有两个实现类,比如UserServiceImpl和LdapUserServiceImpl,类型匹配时容易产生歧义;或者你想在接口上加 JDK 动态代理切面时,切点表达式写的接口全限定名,结果发现接口根本没有被注册为 Bean,AOP 行为整个是错位的。

2.3 唯一沾点边的例外:@Lookup 不是给人这么用的

有朋友会问,抽象类可以吗?确实可以。Spring 的isCandidateComponent里留了一个特殊通道:如果一个类是抽象类,但只要它包含了标注@Lookup的方法,Spring 依然会留下它的 BeanDefinition,并通过 CGLIB 生成一个子类代理注入。但这是给“抽象类 + 方法注入”的场景设计的,接口完全不适用。接口没有构造器,即便是@Lookup也救不了它。

所以实践结论很干脆:Spring 构造型注解的默认工作目标就是具体类,接口上加@Service属于无效标注。与其跟框架拧着来,不如老老实实把@Service放在实现类。

3. 从 MyBatis 的代理机制看:@Mapper 为什么必须放在接口上

理解了@Service那一侧之后,再看@Mapper就会明白:不是 MyBatis 故意玩特立独行,而是它的整套设计本来就构建在接口之上。

3.1 MapperFactoryBean 是如何“凭空”制造 Bean 的

MyBatis 扫描接口后,注册的并不是接口本身,而是一个MapperFactoryBean。这个工厂 Bean 实现了 Spring 的FactoryBean<T>接口,当你从容器中获取UserMapper时,它会调用getObject()方法,最终执行到这样一条链路:

SqlSessionTemplate.getMapper(UserMapper.class); // 内部进一步调用 Configuration.getMapper // 委托给 MapperRegistry,由 MapperProxyFactory 创建动态代理

这意味着容器里真正存在的 Bean 是一个 JDK 动态代理,它的接口是UserMapper。这个代理在调用方法时,会把每个接口方法包装成一个MapperMethod,再交给SqlSession去执行真正的 SQL 操作。整个过程中,MyBatis 从来不关心你有没有写实现类,它只关心能不能从接口拿到足够信息来拼出一个代理。

3.2 SQL 与接口方法的绑定关系

接口在这里不只是“形状”,它还是 SQL 映射的锚点。MyBatis 有两种方式把 SQL 绑到方法上:

  • XML 方式:mapper命名空间必须等于接口的全限定名,statement id等于方法名。比如接口是com.example.mapper.UserMapper,XML 里的namespace就得写成一致;
  • 注解方式:在接口方法上直接标@Select、@Insert、@Update、@Delete,MyBatis 从注解里提取 SQL。

这两种方式都以接口方法作为唯一入参。如果没有接口,MyBatis 根本不知道 SQL 应该挂在哪里。这就是@Mapper必须待在接口上的根本原因:在这个框架里,接口就是“实现类”的角色,只是这个“实现类”不是手写的,而是代理工厂在运行期生成的。

3.3 @Mapper 和 @MapperScan 同时使用的重复注册问题

聊到@Mapper,一定会扯出@MapperScan。很多项目会在启动类上加@MapperScan("com.example.mapper"),又在每个 Mapper 接口上加@Mapper,结果两边同时扫描,导致同一个接口被注册了两份 BeanDefinition。

@MapperScan通过MapperScannerConfigurer扫描指定包下被@Mapper注解标记的接口;而 MyBatis-Spring 的自动配置里也有一个AutoConfiguredMapperScannerRegistrar。如果你两个通道都开着,就可能出现重复的MapperFactoryBean注册。日志里常见的提示是“Incompatible attribute”——严重时直接启动报错,轻微时 Spring 靠 BeanDefinition 覆盖规则取其中一个,行为不可控。

我平时的做法是在一个项目内明确二选一:要么用@MapperScan统一扫包,接口上不写@Mapper;要么每个接口加@Mapper,启动类上不写@MapperScan。两者混用短期没事,但一旦加了多数据源或者定制 Mapper 插件的场景,坑立刻就会出现。

4. 接口与实现类分离时,注解该怎样分配

既然题目提到了分层结构,那我重点说说最常见的工程形态:Service 拆成接口和实现类,Mapper 只保留接口。这种结构下注解的分布其实非常规律。

4.1 一个标准分层模块的注解落点

我拿一个典型的用户模块举例:

// Mapper 层:只有接口,由 MyBatis 动态代理接管 @Mapper public interface UserMapper { UserDO selectById(@Param("id") Long id); } // Service 层接口:只定义业务契约,不标注任何构造型注解 public interface UserService { UserDTO getUserById(Long id); } // Service 实现:@Service 标在具体实现上 @Service public class UserServiceImpl implements UserService { private final UserMapper userMapper; public UserServiceImpl(UserMapper userMapper) { this.userMapper = userMapper; } @Override public UserDTO getUserById(Long id) { UserDO user = userMapper.selectById(id); // 业务组装... return userDTO; } }

这套结构干净的原因在于:接口负责定义契约,@Mapper负责告诉 MyBatis “给我生成这个接口的代理”,@Service负责告诉 Spring “这个具体类要被我实例化并管理”。每层干的活不一样,注解待的位置也跟着分工。

4.2 为什么不在 Service 接口上写 @Service 更符合体系

有一种反向思潮是:所有接口都加@Service,实现类不加,让框架“自动识别”实现类。这种想法源自对注解的误读。Spring 的组件扫描机制面向具体的 class 文件,它没有能力也没有义务在接口和实现类之间自动建立“配对关系”。如果一个接口标注了@Service,Spring 要么忽略它,要么因为它找不到实现类而报错。你不写@Service在接口上,反而省去了一层困惑。

有人可能会问,那接口上是不是永远不该出现构造型注解?对常规业务代码,是的。接口上出现的应该只有@Mapper、@FeignClient、@Repository这类由专门框架自己识别和消费的注解。Spring 自己的构造型注解,统一放实现类。

4.3 顺带一提:Spring Data JPA 的接口注解为什么能成立

说到“接口加注解”,很多人会拿 Spring Data JPA 的@Repository接口反问:为什么 JPA 可以把@Repository放在接口上?因为 JPA 走的也不是常规组件扫描路径,而是@EnableJpaRepositories触发的仓库扫描,类似 MyBatis 的@MapperScan。框架会给每个接口生成一个SimpleJpaRepository代理实现。你看到的“接口被注入了”只是表象,真正生效的是仓库代理工厂。

所以判断一个接口上的注解是否有效,别只看注解本身,要看背后有没有对应的代理工厂机制在消费它。没有,就白标。

5. 接口和实现类注解错位时,常见的三个排错现场

实际项目里因为这套机制踩坑的案例我见过不少,挑三个最典型的说一下排查思路。

5.1 接口写 @Service,实现类没写,启动报“No qualifying bean”

这是最直白的错误。启动时的异常通常长这样:

Parameter 0 of constructor in com.example.controller.UserController required a bean of type 'com.example.service.UserService' that could not be found.

排查链路很简单:

  1. 先看UserService是不是接口;
  2. 如果接口上写了@Service,几乎可以断定它被扫描链路过滤掉了;
  3. 再去实现类上看有没有@Service或@Component;
  4. 确认@ComponentScan的包路径覆盖了实现类。

这类问题的修复永远是“在实现类上标注构造型注解”,而不是“把接口上的注解删掉再等等看”。

5.2 Mapper 接口忘记加 @Mapper,也没有 MapperScan,注入时报找不到 Bean

这属于 MyBatis 侧的常见遗漏。假设代码是这样的:

@Mapper public interface OrderMapper { }

删掉@Mapper后,如果项目没有@MapperScan,启动时OrderMapper根本不会出现在容器里,注入同样会报找不到 Bean。排查时注意一点:有些项目里的@MapperScan写在别的配置类上,你只盯着启动类看是看不出来的。正确做法是全局搜一下@MapperScan,确认它的扫描范围是否覆盖了这个接口所在的包。

另外,如果@Mapper和@MapperScan同时存在且出现了重复注册相关的启动杂音,先决定留哪一个,把另一个清掉,不要留着隐患然后等某天换多数据源时突然炸。

5.3 同一接口出现多个实现类,注入提示类型不唯一

这个问题和@Service的位置关系更大。假设UserService有两个实现类都标了@Service,注入UserService时会提示:

No qualifying bean of type 'UserService' available: expected single matching bean but found 2

这不是注解位置的问题,而是类型匹配产生歧义。解法是在实现类上加@Primary,或者在注入处用@Qualifier指定 bean 名称。但这类歧义在“接口上加 @Service 再配多个实现”的组合里特别容易让人晕。因为接口上那个无效@Service会让人误以为接口本身是一个 Bean,导致排查时跑到错误的思路上。这也侧面说明,接口上保留无效注解只会增加维护噪声。

6. 我这些年总结的三条判断经验

最后分享几条压箱底的判断法则,是我在实际项目中反复被教育出来的。

第一,看注解被谁消费。@Component家族注解的作用对象是 Spring 容器,容器需要实例化一个具体类,所以它应该待在能被 new 出来的类上。@Mapper的作用对象是 MyBatis 的接口代理工厂,工厂需要接口来生成代理,所以它待在接口上。某一个注解有效无效,取决于背后有没有框架在消费它,而不是它本身叫什么叫什么。

第二,分层项目的注解分布可以当模板用。Mapper 接口只写@Mapper,Service 接口不写任何 Spring 构造型注解,ServiceImpl 写@Service,Controller 写@Controller。这套分布配合项目里的分层结构,半年后你再回来看代码,一眼就能判断出每一层的职责边界。

第三,遇到接口自动注入却没有任何实现类的现象,第一个念头应该是“这里存在代理工厂”,而不是“反射黑魔法”。MyBatis 的@Mapper、Spring Data JPA 的 Repository、OpenFeign 的 FeignClient,都是这个模式。只要你摸清了代理工厂的注册链路,这类问题在脑子里立刻就有了排查方向。

写到这里基本把两个注解的底层逻辑聊透了。这个题目看起来像个入门问题,但能把接口代理和类扫描两条链路分清楚的人,在很多复杂场景里反而不容易迷路。最后再提醒一句:启动日志里看到No qualifying bean先别急着加@Service,先想清楚你要注入的东西,是应该由容器实例化的类,还是应该由代理工厂生成的接口。

这个区别,就是那层窗户纸。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询