Feign类上RequestMapping注解的处理
背景
将项目分模块,API独立为API模块,内部主要分为三个package:
- api-接口类
- request-请求体
- response-响应体
接口类如下:
@Tag(name = "聊天接口", description = "聊天接口")
@FeignClient(value = "aigc", contextId = "aigc-chat")
@RequestMapping("/chat")
public interface ChatApi {
@Operation(summary = "简单聊天对话", description = "简单聊天对话")
@PostMapping("/completions")
ResponseResult<ChatCompleteResponse> simpleChatCompletions(@RequestBody @Validated ChatCompleteRequest chatCompleteRequest);
@Operation(summary = "流式聊天对话", description = "流式聊天对话")
@PostMapping("/stream-completions")
SseEmitter streamChatCompletions(@RequestBody @Validated ChatCompleteRequest chatCompleteRequest);
@Operation(summary = "具有提示工程的聊天对话-非流式", description = "具有提示工程的聊天对话-非流式")
@PostMapping("/prompt-completions")
ResponseResult<PromptCompleteResponse> promptCompletions(@RequestBody @Validated PromptCompleteRequest promptCompleteRequest);
}
API模块的设计是为了让其它服务可以直接引入依赖,直接使用OpenFeign调用接口,而本服务则需要在业务模块引入API模块,并实现对应接口。这样直接使用的话会出现Feign客户端实例无法创建和路由映射的问题,并且在不同OpenFeign的版本解决方式不相同。
本文中的OpenFeign指如下依赖,版本也是指spring-cloud-starter-openfeign的version
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
一、原因
1.1、路由映射问题
实现了Api接口和Controller都会被RequestMappingHandlerMapping
判断为属于handler:
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
@Override
protected boolean isHandler(Class<?> beanType) {
return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) ||
AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class));
}
这里可以看出,具有RequestMapping
注解或者具有Controller
注解都会被去HandlerMapping(具体可以查看SpringMVC的原理和流程)。但是由于Controller是实现的FeignClient接口,所以二者的路由是一一对应的,一模一样,这样会出现重复路由,所以SpringMVC会抛出异常。
1.2、Feign代理客户端创建失败原因
在OpenFeign中,对于FeignClient
上使用RequestMapping
注解的行为,主要在以下方法中处理:
org.springframework.cloud.openfeign.support.SpringMvcContract#processAnnotationOnClass
在OpenFeign-3.0.1版本中是这样处理的:
@Override
protected void processAnnotationOnClass(MethodMetadata data, Class<?> clz) {
if (clz.getInterfaces().length == 0) {
RequestMapping classAnnotation = findMergedAnnotation(clz, RequestMapping.class);
if (classAnnotation != null) {
// Prepend path from class annotation if specified
if (classAnnotation.value().length > 0) {
String pathValue = emptyToNull(classAnnotation.value()[0]);
pathValue = resolve(pathValue);
if (!pathValue.startsWith("/")) {
pathValue = "/" + pathValue;
}
data.template().uri(pathValue);
if (data.template().decodeSlash() != decodeSlash) {
data.template().decodeSlash(decodeSlash);
}
}
}
}
}
这里会正常处理RequestMapping
中的Path。但在3.1版本时,这里的处理方式会变为发现RequestMapping
注解直接抛出异常:
@Override
protected void processAnnotationOnClass(MethodMetadata data, Class<?> clz) {
RequestMapping classAnnotation = findMergedAnnotation(clz, RequestMapping.class);
if (classAnnotation != null) {
LOG.error("Cannot process class: " + clz.getName()
+ ". @RequestMapping annotation is not allowed on @FeignClient interfaces.");
throw new IllegalArgumentException("@RequestMapping annotation not allowed on @FeignClient interfaces");
}
CollectionFormat collectionFormat = findMergedAnnotation(clz, CollectionFormat.class);
if (collectionFormat != null) {
data.template().collectionFormat(collectionFormat.value());
}
}
实际上该方法变更发生在3.0.5版本,以下该项目在github3.0.4版本、3.0.5版本和提交记录
3.0.4
commit
Block clas-level request mapping on Feign clients. · spring-cloud/spring-cloud-openfeign@d6783a6
3.0.5
二、解决方案
2.1、路由映射问题
由于是Controller和Api的路由地址重复导致的,那么我们只需要在判断handler时选取其中一个就可以了,而且一般来说习惯于使用具体的Controller类来做路由映射,那么可以如下处理:
package xx.xx.config;
import cn.hutool.core.collection.CollectionUtil;
import feign.Feign;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import javax.servlet.http.HttpServletRequest;
import java.util.Enumeration;
import java.util.Objects;
@Slf4j
@ConditionalOnClass({Feign.class})
@Configuration
public class FeignConfig implements WebMvcRegistrations, RequestInterceptor {
private final RequestMappingHandlerMapping requestMappingHandlerMapping = new FeignRequestMappingHandlerMapping();
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return requestMappingHandlerMapping;
}
private static class FeignRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(@NonNull Class<?> beanType) {
return super.isHandler(beanType) &&
beanType.getAnnotation(FeignClient.class) == null;
}
}
/**
* 将原请求中Header的所有参数,原样传递至Feign请求中。
*/
@Override
public void apply(RequestTemplate template) {
HttpServletRequest request = getHttpServletRequest();
if (Objects.isNull(request)) {
return;
}
Enumeration<String> headerNames = request.getHeaderNames();
if (CollectionUtil.isEmpty(headerNames)) {
return;
}
while (headerNames.hasMoreElements()) {
String key = headerNames.nextElement();
// 跳过content-length,因为feign会自动设置content-length
// <https://github.com/spring-cloud/spring-cloud-openfeign/issues/390>
if ("content-length".equalsIgnoreCase(key)) {
continue;
}
String value = request.getHeader(key);
template.header(key, value);
}
}
private HttpServletRequest getHttpServletRequest() {
try {
// 这种方式获取的HttpServletRequest是线程安全的
return ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
} catch (Exception e) {
return null;
}
}
}
这里顺便处理一下feign请求头的透传问题,如果只想处理映射问题,那么只需要WebMvcRegistrations
,然后重写getRequestMappingHandlerMapping
即可。
我们创建了了一个内部类继承RequestMappingHandlerMapping
,重写了isHandler
方法,注意该方法的另一个条件:
beanType.getAnnotation(FeignClient.class) == null
Controller
类本身没有FeignClient
注解,所以表达式结果为true
,即Controller
类才会参与路由映射。
2.2、FeignClient代理类创建问题
这里我们需要找到Contract是如何注入的,在org.springframework.cloud.openfeign.FeignClientsConfiguration类中可以看到:
@Bean
@ConditionalOnMissingBean
public Contract feignContract(ConversionService feignConversionService) {
boolean decodeSlash = feignClientProperties == null || feignClientProperties.isDecodeSlash();
return new SpringMvcContract(parameterProcessors, feignConversionService, decodeSlash);
}
这里有条件,所以如果我们自己注册一个Contract就可以阻止自动装配。观察一下SpringMvcContract
的实现,该类并非final类型,所以我们可以继承它,重写其中的processAnnotationOnClass
方法:
package xx.xx.config.feign;
import feign.MethodMetadata;
import org.springframework.cloud.openfeign.AnnotatedParameterProcessor;
import org.springframework.cloud.openfeign.support.SpringMvcContract;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
import static feign.Util.emptyToNull;
import static org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation;
/**
* @author fcw
* @date 2023/5/15
* @description 自定义feign的contract,解决feign调用时不允许使用@RequestMapping的问题。
*/
public class CustomSpringMvcContract extends SpringMvcContract {
private final boolean decodeSlash;
private final ResourceLoader resourceLoader = new DefaultResourceLoader();
public CustomSpringMvcContract(List<AnnotatedParameterProcessor> annotatedParameterProcessors,
ConversionService conversionService,
boolean decodeSlash) {
super(annotatedParameterProcessors, conversionService, decodeSlash);
this.decodeSlash = decodeSlash;
}
@Override
protected void processAnnotationOnClass(MethodMetadata data, Class<?> clz) {
if (clz.getInterfaces().length == 0) {
RequestMapping classAnnotation = findMergedAnnotation(clz, RequestMapping.class);
if (classAnnotation != null) {
// Prepend path from class annotation if specified
if (classAnnotation.value().length > 0) {
String pathValue = emptyToNull(classAnnotation.value()[0]);
pathValue = resolve(pathValue);
if (!pathValue.startsWith("/")) {
pathValue = "/" + pathValue;
}
data.template().uri(pathValue);
if (data.template().decodeSlash() != decodeSlash) {
data.template().decodeSlash(decodeSlash);
}
}
}
}
}
private String resolve(String value) {
if (StringUtils.hasText(value) && resourceLoader instanceof ConfigurableApplicationContext) {
return ((ConfigurableApplicationContext) resourceLoader).getEnvironment().resolvePlaceholders(value);
}
return value;
}
}
具体实现可以直接使用3.0.4版本的逻辑,我在处理时在单独的Config类中通过@Bean
注解生成Contract
的SpringBean:
package xx.xx.config;
import feign.Contract;
import feign.Feign;
import feign.Logger;
import feign.RequestInterceptor;
import feign.codec.Decoder;
import feign.codec.Encoder;
import feign.form.spring.SpringFormEncoder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.cloud.openfeign.AnnotatedParameterProcessor;
import org.springframework.cloud.openfeign.FeignAutoConfiguration;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.cloud.openfeign.FeignClientProperties;
import org.springframework.cloud.openfeign.support.SpringDecoder;
import org.springframework.cloud.openfeign.support.SpringEncoder;
import org.springframework.cloud.openfeign.support.SpringMvcContract;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Scope;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.convert.ConversionService;
import org.springframework.lang.NonNull;
@ConditionalOnClass(Feign.class)
@AutoConfigureBefore(FeignAutoConfiguration.class)
@Slf4j
@Configuration
public class FeignConfig {
@Autowired(required = false)
private FeignClientProperties feignClientProperties;
@Autowired(required = false)
private List<AnnotatedParameterProcessor> parameterProcessors = new ArrayList<>();
/**
* 解决@RequestMapping annotation not allowed on @FeignClient interfaces
*/
@Bean
public Contract feignContract(ConversionService feignConversionService) {
boolean decodeSlash = feignClientProperties == null || feignClientProperties.isDecodeSlash();
return new CustomSpringMvcContract(parameterProcessors, feignConversionService, decodeSlash);
}
}
三、总结
通过接口API模块定义接口及其数据类,业务模块实现具体逻辑这种架构方式是很有必要的。配合Maven仓库,想要调用其它微服务,只需要引入对应依赖,无需自行维护内部的数据类和手动编写路由Path。减少了BUG出现的可能性,提升了本服务代码结构整洁度,提高了开发效率。在SpringBoot3中,内置了一个新的通过注解声明的Http请求方式
如果升级了SpringBoot3后,可以使用这种方式提供API Client。