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);
}
可自行分析