Java-从FeignClient的Ambiguous mapping报错,重温RequestMapping原理

1. 微服务的公共API模块

微服务之间调用进程会出现DTO实体类的重复定义。比如服务A的接口返回User实体,服务B接收的时候,也需要定义一个同样的User实体。
在引入了Feign后,就有了一个避免项目间重复定义实体类的简单方案:我们可以在服务A开发的时候专门抽出来一个API模块。

API公共模块

这个API模块可以包含接口方法定义,URI以及和对外实体类定义(DTO),可以认为是A和B之间互通的约定。
一个最简单的API模块代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DemoDto implements Serializable {
private String text;
}

@RequestMapping("/demo")
public interface DemoApiService {
@GetMapping("/hello")
DemoDto hello();
}

服务A的Controller负责对接口定义进行实现:

1
2
3
4
5
6
7
@RestController
public class DemoProducerController implements DemoApiService {
@Override
public DemoDto hello() {
return new DemoDto("hello");
}
}

服务A项目将API模块发布到Maven私服上。服务B项目只需要对API模块添加依赖:

1
2
3
4
5
<dependency>
<groupId>com.galaxy.demo</groupId>
<artifactId>feign-demo-api</artifactId>
<version>1.0.0</version>
</dependency>

并且扩展一下该接口并添加@FeignClient注解:

1
2
3
@FeignClient(name = "demo", contextId = "demoSpiService", url = "http://localhost:8080/")
public interface DemoSpiService extends DemoApiService {
}

就可以很轻松地像调用本地方法一样调用A应用的接口了。

1
2
3
4
5
6
7
@Resource
private DemoSpiService demoSpiService;

@GetMapping("/hello")
public String hello() {
return demoSpiService.hello().toString();
}

2. Ambiguous mapping报错

如果你像我上面描述的那样实现,就会在消费者服务B启动的时候遇到如下的报错信息:

1
2
3
Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'com.galaxy.demo.feign.consumer.spi.DemoSpiService' method 
com.galaxy.demo.feign.consumer.spi.DemoSpiService#hello()
to {GET /demo/hello}: There is already 'demoConsumerController' bean method

报错信息很直白:同一个URI被重复映射了两次。一次是在DemoConsumerController,一次是在DemoSpiService。
But Why? DemoSpiService里只是一个FeignClient,不是RestController啊?

3. @RestController,@Controller,@RequestMapping原理重温

我们通过这个问题,正好来重温一下@RestController,@Controller和@RequestMapping几个Spring中的经典概念。

3.1 @RestController

我们先来看一下@RestController的原代码:

1
2
3
4
5
6
7
8
9
10
11
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
@AliasFor(
annotation = Controller.class
)
String value() default "";
}

可以看到@RestController=@Controller+@ResponseBody

Spring MVC流
上图是一个Spring MVC从接收请求到返回响应的完整流程。我理解对于SpringBoot的RestController来说,在第四步没有返回ModelAndView,而是直接返回了Json,并通过@ResponseBody将Json直接写到了响应Body,略过了第5步和第6步。

3.2 @Controller和@RequestMapping

如果只从@Controller的源代码来看,@Controller只是@Component的一个别名。

1
2
3
4
5
6
7
8
9
10
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Controller {
@AliasFor(
annotation = Component.class
)
String value() default "";
}

但注解怎么用不是看定义的。从Spring的AbstractHandlerMethodMapping.java的源代码,我们可以看到Spring会根据一个名为isHandler方法的判断结果,对Handler处理器里的方法进行扫描,获得URL映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
		if (beanType != null && isHandler(beanType)) {
detectHandlerMethods(beanName);
}
// 省略部分
protected void detectHandlerMethods(Object handler) {
Class<?> handlerType = (handler instanceof String ?
obtainApplicationContext().getType((String) handler) : handler.getClass());

if (handlerType != null) {
Class<?> userType = ClassUtils.getUserClass(handlerType);
Map<Method, T> methods = MethodIntrospector.selectMethods(userType,
(MethodIntrospector.MetadataLookup<T>) method -> {
try {
return getMappingForMethod(method, userType);

而isHandler的逻辑很简单,就是看Bean上是否有@Controller注解或@RequestMapping注解。参见源代码

1
2
3
4
5
@Override
protected boolean isHandler(Class<?> beanType) {
return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) ||
AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class));
}

4. Ambiguous mapping报错原因总结和解决方案

归根到底,Ambiguous mapping报错原因在于上面的那个逻辑中使用的是“或”(||),而不是“和”(&&)。
由于我们的DemoSpiService扩展了DemoApiService,而DemoApiService的接口定义上有@RequestMapping注解,于是DemoSpiService也被Spring MVC扫描Handler了。而偏生对于DemoSpiService和DemoConsumerController的URL路径都是“/demo”,于是就产生了冲突。
知道了原因后,解决方案也就很简单了:修改一下DemoConsumerController的@RequestMapping的URL,例如改为@RequestMapping("/consumer/demo"),就可以成功启动了。

你可能会担心@FeignClient+API模块是否会暴露不该暴露的接口?直接访问的话会返回404:

1
2
3
4
5
6
7
{
"timestamp": "2019-12-23T12:51:05.376+0000",
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/demo/hello"
}

也很容易理解:请求在找RequestMapping对应的View:”/demo/hello”。但View不存在,就只能返回404了。只有在DemoSpiService上主动添加@ResponseBody注解,才能对外暴露。

5. 参考资料

这篇是主要参考资料。作者认为这是Spring MVC的锅。我理解指的是“或”的那个逻辑。但我觉得当初Spring这么写肯定是有原因的。。。虽然我没找到相关文章。
FeignClient 出现 Ambiguous mapping 重复映射 | Japari Park

另外是两篇Spring原理解析参考
SpringMVC在@RequestMapping配置两个相同路径 - Text_Dexter - 博客园

Spring MVC — @RequestMapping原理讲解-1 - 小小默:进无止境

本文永久链接 [ https://galaxyyao.github.io/2019/12/23/Java-从FeignClient的Ambiguous-mapping报错-重温RequestMapping原理/ ]