Feign-Get請求自動轉成Post問題分析


1 現象

Feign使用中有一個小小的細節之處,在明明我們使用Get配置的時候,我們會發現Feign會將Get請求轉成Post調用。

直接上示例:
接口提供者:

  	@GetMapping(value = "/provider")
    public String test(@RequestParam(value = "name", defaultValue = "zcswl7961") String name) {

        List<String> services = discoveryClient.getServices();
        /**
         * test code
         */
        for (String serviceId : services) {
            List<ServiceInstance> instances = discoveryClient.getInstances(serviceId);

            for (ServiceInstance serviceInstance : instances) {
                URI uri = serviceInstance.getUri();
            }
        }

        return "this port is:" + port + " name is:"+name;
    }


    /**
     * test feign Get Post
     */
    @PostMapping(value = "provider")
    public String postTest(@RequestBody String name) {

        System.out.println(name);

        return "this method is Post port is:" + port + "     name is:" +name;


    }

該 服務模塊命名爲service-hi,我們在服務中定義了兩個接口,接口路徑相同,都是/provider 只是一個爲Get 一個爲Post請求,

Feign接口定義:

@FeignClient(value = "service-hi",fallback = ScheduleServiceHiHystric.class)
public interface ScheduleServiceHi {

    /**
     * 接口調用
     * @param name 名稱
     * @return 返回結果
     */
    @RequestMapping(value = "/provider",method = RequestMethod.GET)
    String sayProvider(String name);
}

Feign接口中,我們定義了對應Get請求的接口示例,

Feign接口調用:

@RestController
@AllArgsConstructor
public class HiController {

    private final ScheduleServiceHi scheduleServiceHi;

    @GetMapping(value = "/hi")
    public String sayHi(@RequestParam String name) {
        return scheduleServiceHi.sayProvider( name );
    }
}

我們在服務模塊:service-feign(端口:8766)中 定義了一個/hi接口,使用了Feign接口調用,通過調用測試/hi接口之後,我們會發現,feign調用了Post接口的請求數據

curl :
127.0.0.1:8766/hi?name=zhoucg
Response:
this method is Post port is:8763 name is:zhoucg

2 解決

對於這個現象,我們需要聲明參數請求的註解形式
例如,我們可以在Feign的接口定義的參數中加上@RequestParam(“name”)方式

@FeignClient(value = "service-hi",fallback = ScheduleServiceHiHystric.class)
public interface ScheduleServiceHi {

    /**
     * 接口調用
     * @param name 名稱
     * @return 返回結果
     */
    @RequestMapping(value = "/provider",method = RequestMethod.GET)
    String sayProvider(@RequestParam("name") String name);

}

另一種方法是更換Apache的HttpClient
在Feign的配置項中加入

feign:
  httpclient:
    enabled: true

同時使用下面兩個依賴:

		<dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.9</version>
        </dependency>
        <dependency>
            <groupId>io.github.openfeign</groupId>
            <artifactId>feign-httpclient</artifactId>
            <version>10.2.3</version>
        </dependency>

3 源碼分析

這個問題的主要的原因就是Feign默認使用的連接工具實現類,發現只要你有對應的body體對象,就會強制把GET請求轉換成POST請求

這句話說的很籠統,下面,就是帶着源碼細節,我們慢慢的分析,具體的Feign源碼的實現細節

針對第一個解決方式:
我們只是簡單的在Feign的接口中的調用參數中增加了一個@RequestParam(“name”) 註解就解決了問題,這主要是因爲,Feign源碼在解析含有@FeignClient註解的接口的時候,在創建代理對象的時候,代理對象在去解析含有@RequestParam註解的參數的時候,會將該參數增強到url上,而不是作爲body傳遞

我們在使用Feign的時候,模塊啓動類會存在 @EnableFeignClients註解
該註解會通過@Import註解向Spring注入 FeignClientsRegistrar

FeignClientsRegister是一個ImportBeanDefinitionRegistrar 類型的類 ,系統會默認調用registerBeanDefinitions(AnnotationMetadata metadata) 方法

	@Override
	public void registerBeanDefinitions(AnnotationMetadata metadata,
			BeanDefinitionRegistry registry) {
		registerDefaultConfiguration(metadata, registry);
		registerFeignClients(metadata, registry);
	}

該方法中,首先是會根據@EnableFeignClients 配置的Configuration配置環境實體類去向Spring容器中注入BeanDefinition,
BeanDefinition中的name爲 default. + @EnableFeignClients註解的類的全名
BeanDefinition中的class爲:FeignClientSpecification
在這裏插入圖片描述
然後會去解析指定包下的@FeignClient註解,並創建對應接口的代理類,註冊到spring 容器中,

默認情況下,解析的包會從@EnableFeignClient註解的basePackages配置和basePackageClasses配置屬性中查詢,如果沒有配置,會去解析當前包以及子包下含有的@FeignClient註解
在這裏插入圖片描述
然後在遍歷每一個包路徑,並且解析包中含有@FeignClient註解的BeanDefinition類,進行處理,處理步驟

  • 1,首先是校驗該類是接口定義
  • 2,獲取@FeignClient註解的配置屬性信息,存放到attributes中
  • 3,解析name,根據@FeignClient配置的contextId -> value -> name ->
    serviceId優先級依次獲取
  • 4,註冊步驟3中的name名稱的BeanDefinition,class類型爲:FeignClientSpecification
  • 5,註冊FeignClient,解析@FeignClient中的配置屬性,BeanDefinition的別名爲:contextId +"FeignClient"
  • class類型爲:FeignClientFactoryBean
    是一個ObjectFactory類型的類

在這裏插入圖片描述

在解析@FeignClient之後,會通過ObjectFacotry的getObject()方法獲取到其代理對象

	<T> T getTarget() {
		FeignContext context = this.applicationContext.getBean(FeignContext.class);
		Feign.Builder builder = feign(context);

		if (!StringUtils.hasText(this.url)) {
			if (!this.name.startsWith("http")) {
				this.url = "http://" + this.name;
			}
			else {
				this.url = this.name;
			}
			this.url += cleanPath();
			return (T) loadBalance(builder, context,
					new HardCodedTarget<>(this.type, this.name, this.url));
		}
		if (StringUtils.hasText(this.url) && !this.url.startsWith("http")) {
			this.url = "http://" + this.url;
		}
		String url = this.url + cleanPath();
		Client client = getOptional(context, Client.class);
		if (client != null) {
			if (client instanceof LoadBalancerFeignClient) {
				// not load balancing because we have a url,
				// but ribbon is on the classpath, so unwrap
				client = ((LoadBalancerFeignClient) client).getDelegate();
			}
			builder.client(client);
		}
		Targeter targeter = get(context, Targeter.class);
		return (T) targeter.target(this, builder, context,
				new HardCodedTarget<>(this.type, this.name, url));
	}

我們重點看一下loadBalance方法中的最終會調用ReflectiveFeign#newInstance(Target target) 方法進行獲取
在這裏插入圖片描述
首先是通過ParseHandlersByName類apply去獲取解析目標接口,並返回方法元數據對象
在這裏插入圖片描述
1,避免檢測從Object類繼承的方法,剔除檢測@FeignClient註解的接口中的靜態方法,和抽象方法,
2,進入parseAndValidateMetadata方法,解析對應存在方法中的feign的註解參數

	protected MethodMetadata parseAndValidateMetadata(Class<?> targetType, Method method) {
      MethodMetadata data = new MethodMetadata();
      data.returnType(Types.resolve(targetType, targetType, method.getGenericReturnType()));
      data.configKey(Feign.configKey(targetType, method));

      if (targetType.getInterfaces().length == 1) {
        processAnnotationOnClass(data, targetType.getInterfaces()[0]);
      }
      processAnnotationOnClass(data, targetType);


      for (Annotation methodAnnotation : method.getAnnotations()) {
        processAnnotationOnMethod(data, methodAnnotation, method);
      }
      checkState(data.template().method() != null,
          "Method %s not annotated with HTTP method type (ex. GET, POST)",
          method.getName());
      Class<?>[] parameterTypes = method.getParameterTypes();
      Type[] genericParameterTypes = method.getGenericParameterTypes();

      Annotation[][] parameterAnnotations = method.getParameterAnnotations();
      int count = parameterAnnotations.length;
      for (int i = 0; i < count; i++) {
        boolean isHttpAnnotation = false;
        if (parameterAnnotations[i] != null) {
          isHttpAnnotation = processAnnotationsOnParameter(data, parameterAnnotations[i], i);
        }
        if (parameterTypes[i] == URI.class) {
          data.urlIndex(i);
        } else if (!isHttpAnnotation) {
          checkState(data.formParams().isEmpty(),
              "Body parameters cannot be used with form parameters.");
          checkState(data.bodyIndex() == null, "Method has too many Body parameters: %s", method);
          data.bodyIndex(i);
          data.bodyType(Types.resolve(targetType, targetType, genericParameterTypes[i]));
        }
      }

      if (data.headerMapIndex() != null) {
        checkMapString("HeaderMap", parameterTypes[data.headerMapIndex()],
            genericParameterTypes[data.headerMapIndex()]);
      }

      if (data.queryMapIndex() != null) {
        if (Map.class.isAssignableFrom(parameterTypes[data.queryMapIndex()])) {
          checkMapKeys("QueryMap", genericParameterTypes[data.queryMapIndex()]);
        }
      }

      return data;
    }

1,該方法中,首先是獲取對應feign配置方法中的returnType和configKey
2,解析配置的@RequestMapping註解以及對應的Param註解,最終我們會發現,會將含有@RequestParam註解存儲到 indexToNameurl進行封裝

	protected MethodMetadata parseAndValidateMetadata(Class<?> targetType, Method method) {
      MethodMetadata data = new MethodMetadata();
      data.returnType(Types.resolve(targetType, targetType, method.getGenericReturnType()));
      data.configKey(Feign.configKey(targetType, method));

      if (targetType.getInterfaces().length == 1) {
        processAnnotationOnClass(data, targetType.getInterfaces()[0]);
      }
      processAnnotationOnClass(data, targetType);


      for (Annotation methodAnnotation : method.getAnnotations()) {
        processAnnotationOnMethod(data, methodAnnotation, method);
      }
      checkState(data.template().method() != null,
          "Method %s not annotated with HTTP method type (ex. GET, POST)",
          method.getName());
      Class<?>[] parameterTypes = method.getParameterTypes();
      Type[] genericParameterTypes = method.getGenericParameterTypes();

      Annotation[][] parameterAnnotations = method.getParameterAnnotations();
      int count = parameterAnnotations.length;
      for (int i = 0; i < count; i++) {
        boolean isHttpAnnotation = false;
        if (parameterAnnotations[i] != null) {
          isHttpAnnotation = processAnnotationsOnParameter(data, parameterAnnotations[i], i);
        }
        if (parameterTypes[i] == URI.class) {
          data.urlIndex(i);
        } else if (!isHttpAnnotation) {
          checkState(data.formParams().isEmpty(),
              "Body parameters cannot be used with form parameters.");
          checkState(data.bodyIndex() == null, "Method has too many Body parameters: %s", method);
          data.bodyIndex(i);
          data.bodyType(Types.resolve(targetType, targetType, genericParameterTypes[i]));
        }
      }

      if (data.headerMapIndex() != null) {
        checkMapString("HeaderMap", parameterTypes[data.headerMapIndex()],
            genericParameterTypes[data.headerMapIndex()]);
      }

      if (data.queryMapIndex() != null) {
        if (Map.class.isAssignableFrom(parameterTypes[data.queryMapIndex()])) {
          checkMapKeys("QueryMap", genericParameterTypes[data.queryMapIndex()]);
        }
      }

      return data;
    }

這也是爲什麼增加了@RequestParam時,Feign會將其追加到url中,而不是作爲body傳遞

針對第二個解決方法:

第二個解決方式是我們直接替換Feign中的HttpClient調用jar包,
Feign中,默認的Http連接工具是在feign-core.jar包中的Client方法

	HttpURLConnection convertAndSend(Request request, Options options) throws IOException {
      final HttpURLConnection connection =
          (HttpURLConnection) new URL(request.url()).openConnection();
      if (connection instanceof HttpsURLConnection) {
        HttpsURLConnection sslCon = (HttpsURLConnection) connection;
        if (sslContextFactory != null) {
          sslCon.setSSLSocketFactory(sslContextFactory);
        }
        if (hostnameVerifier != null) {
          sslCon.setHostnameVerifier(hostnameVerifier);
        }
      }
      connection.setConnectTimeout(options.connectTimeoutMillis());
      connection.setReadTimeout(options.readTimeoutMillis());
      connection.setAllowUserInteraction(false);
      connection.setInstanceFollowRedirects(options.isFollowRedirects());
      connection.setRequestMethod(request.httpMethod().name());

      Collection<String> contentEncodingValues = request.headers().get(CONTENT_ENCODING);
      boolean gzipEncodedRequest =
          contentEncodingValues != null && contentEncodingValues.contains(ENCODING_GZIP);
      boolean deflateEncodedRequest =
          contentEncodingValues != null && contentEncodingValues.contains(ENCODING_DEFLATE);

      boolean hasAcceptHeader = false;
      Integer contentLength = null;
      for (String field : request.headers().keySet()) {
        if (field.equalsIgnoreCase("Accept")) {
          hasAcceptHeader = true;
        }
        for (String value : request.headers().get(field)) {
          if (field.equals(CONTENT_LENGTH)) {
            if (!gzipEncodedRequest && !deflateEncodedRequest) {
              contentLength = Integer.valueOf(value);
              connection.addRequestProperty(field, value);
            }
          } else {
            connection.addRequestProperty(field, value);
          }
        }
      }
      // Some servers choke on the default accept string.
      if (!hasAcceptHeader) {
        connection.addRequestProperty("Accept", "*/*");
      }

      if (request.requestBody().asBytes() != null) {
        if (contentLength != null) {
          connection.setFixedLengthStreamingMode(contentLength);
        } else {
          connection.setChunkedStreamingMode(8196);
        }
        connection.setDoOutput(true);
        OutputStream out = connection.getOutputStream();
        if (gzipEncodedRequest) {
          out = new GZIPOutputStream(out);
        } else if (deflateEncodedRequest) {
          out = new DeflaterOutputStream(out);
        }
        try {
          out.write(request.requestBody().asBytes());
        } finally {
          try {
            out.close();
          } catch (IOException suppressed) { // NOPMD
          }
        }
      }
      return connection;
    }

Feign原生的連接工具使用了jdk中的rt.jar包的HttpURLConnection 類 進行實現,

其中,對應HttpURLConnection 的連接對象,Feign默認的實現是設置了doOutput爲true

        connection.setDoOutput(true);

這個設置也正是解釋了爲什麼Feign只要發現你存在body體對象就會將Get請求轉成Post

關於這個HttpURLConnection的配置的解釋,我們可以參考strackoverflow這個鏈接:https://stackoverflow.com/questions/8587913/what-exactly-does-urlconnection-setdooutput-affect

因此,我們可以替換原始的feign中的httpclient的實現,來解決這個問題。

在Netflix的官方github上,同樣是針對這個問題提出了一個issue,鏈接:
https://github.com/spring-cloud/spring-cloud-netflix/issues/1253

其中,有一位打個也同樣給出了一個解決方案:通過實現RequestInterceptor 來自定義Feign配置的解析
代碼如下:

public class YryzRequestInterceptor implements RequestInterceptor {
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void apply(RequestTemplate template) {
        // feign 不支持 GET 方法傳 POJO, json body轉query
        if (template.method().equals("GET") && template.body() != null) {
            try {
                JsonNode jsonNode = objectMapper.readTree(template.body());
                template.body(null);

                Map<String, Collection<String>> queries = new HashMap<>();
                buildQuery(jsonNode, "", queries);
                template.queries(queries);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private void buildQuery(JsonNode jsonNode, String path, Map<String, Collection<String>> queries) {
        if (!jsonNode.isContainerNode()) {   // 葉子節點
            if (jsonNode.isNull()) {
                return;
            }
            Collection<String> values = queries.get(path);
            if (null == values) {
                values = new ArrayList<>();
                queries.put(path, values);
            }
            values.add(jsonNode.asText());
            return;
        }
        if (jsonNode.isArray()) {   // 數組節點
            Iterator<JsonNode> it = jsonNode.elements();
            while (it.hasNext()) {
                buildQuery(it.next(), path, queries);
            }
        } else {
            Iterator<Map.Entry<String, JsonNode>> it = jsonNode.fields();
            while (it.hasNext()) {
                Map.Entry<String, JsonNode> entry = it.next();
                if (StringUtils.hasText(path)) {
                    buildQuery(entry.getValue(), path + "." + entry.getKey(), queries);
                } else {  // 根節點
                    buildQuery(entry.getValue(), entry.getKey(), queries);
                }
            }
        }
    }
}

關於RequestInterceptor的解析,Feign的源碼是在SynchronousMethodHandler類中的targetRequest(RequestTemplate template) 方法實現

  Request targetRequest(RequestTemplate template) {
    for (RequestInterceptor interceptor : requestInterceptors) {
      interceptor.apply(template);
    }
    return target.apply(template);
  }

可自行分析

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章