Feign类上RequestMapping注解的处理

背景

将项目分模块,API独立为API模块,内部主要分为三个package:

  1. api-接口类

  2. request-请求体

  3. 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

spring-cloud-openfeign/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java at v3.0.4 · spring-cloud/spring-cloud-openfeign

commit

Block clas-level request mapping on Feign clients. · spring-cloud/spring-cloud-openfeign@d6783a6

3.0.5

spring-cloud-openfeign/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java at v3.0.5 · spring-cloud/spring-cloud-openfeign

二、解决方案

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请求方式

https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-http-interface

https://juejin.cn/post/7172014407247986696

如果升级了SpringBoot3后,可以使用这种方式提供API Client。