OpenFeign简介

Feign是Netflix开发的声明式、模板化的HTTP客户端, Feign可以帮助我们更快捷、优雅地调用HTTP API。

Spring Cloud对Feign进行了增强,使Feign支持了Spring MVC注解,并整合了Ribbon和Hystrix

Spring Cloud Feign具备可插拔的注解支持,支持Feign注解、JAX-RS注解和Spring MVC的注解。

以上是OpenFeign的一个简洁,我们可以用它更优雅的项目中发起HTTP请求。但是由于本身是基于各种http客户端进行封装的,所以不免损失了些许的灵活性。也正是因为这一点,在一些实际的使用中就遇到不小的问题。

遇到问题

业务方有一个GET方法的接口,但是接口参数需要在body内通过application/json的方式传递进去,如果在springmvc中实现这个接口的话,大概会是如下模式

1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/demo")
public String demo(@RequestBody Req req) {
return req.getId();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
class Req {
String id;
Integer score;
}

按照正常思路编写feign接口

1
2
3
4
5
6
@FeignClient(value = "server",url = "http://localhost:8080")
public interface Server {

@GetMapping("/api/demo")
String demo(@RequestBody Req req);
}

两边同时使用RequestBody,理论上应该是会如预期的通过GET方法携带json body请求服务端。

但实际上效果并未如我们愿,请求报错

1
2
3
4
5
6
7
8
9
10
11
12
feign.FeignException$MethodNotAllowed: [405] during [GET] to [http://localhost:8080/api/demo] [Server#demo(Req)]: [{"timestamp":"2021-11-27T09:00:28.096+00:00","status":405,"error":"Method Not Allowed","message":"","path":"/api/demo"}]

at feign.FeignException.clientErrorStatus(FeignException.java:203)
at feign.FeignException.errorStatus(FeignException.java:177)
at feign.FeignException.errorStatus(FeignException.java:169)
at feign.codec.ErrorDecoder$Default.decode(ErrorDecoder.java:92)
at feign.AsyncResponseHandler.handleResponse(AsyncResponseHandler.java:96)
at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:138)
at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:89)
at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:100)
at com.sun.proxy.$Proxy80.demo(Unknown Source)
at com.huya.Client.test(Client.java:23)

开始排查

查日志

这是一个非常反直觉的事情,于是乎就开始在谷歌百度各种寻找和尝试,但是并未获得有效信息,那就只能从源头开始排查。

看日志描述,理论上我们发出的是GET请求,为什么服务器端还会返回Method Not Allowed这种信息呢?

是不是有可能这是一个假日志?代码并未真正发出GET请求?

我们尝试打开java net debug日志,只需要在执行参数后面添加-Djavax.net.debug=all即可,如果是包含日志插件的项目,可以把日志插件的级别调整到debug,否则有可能看不到debug日志。

打开之后果然在debug日志中发现了端倪

1
2021-11-27 17:13:19.104 DEBUG 22264 --- [           main] s.n.www.protocol.http.HttpURLConnection  : sun.net.www.MessageHeader@27358a197 pairs: {POST /api/demo HTTP/1.1: null}{Content-Type: application/json}{Accept: */*}{User-Agent: Java/11.0.11}{Host: localhost:8080}{Connection: keep-alive}{Content-Length: 23}

这里可以看到它最终通过HttpURLConnection发出了POST请求,但是Feign的日志中却打印的是GET请求。

看源码

既然两边不一样,那肯定有人说了谎,看看到底是谁说的谎。

Feign的代码看起来,找到最终调用HttpURLConnection的地方feign.client类内部convertAndSend方法内

1
2
3
4
HttpURLConnection convertAndSend(Request request, Options options) throws IOException{
……
OutputStream out = connection.getOutputStream();//这一行就是开始调用HttpURLConnection的地方
}

在这行打一个断点,查看当前的request变量内容

1
2
3
GET http://localhost:8080/api/demo HTTP/1.1
Content-Length: 23
Content-Type: application/json

可以发现feign本身是没有问题的,它如实将GET方法传入了HttpURLConnection内部。

解决问题

那么现在思路就清晰了HttpURLConnection的某些机制导致了方法被改掉,如果要先解决问题的话,这里就可以先不深入探究了,我们选择feign的其他实现来替换HttpURLConnection

那么常见的可选内容包括:OkhttpApache httpclient等,这里经过尝试Okhttp也是不支持的,它会直接报错

1
method GET must not have a request body

Apache httpclient可以完美适配,事到如此,其实问题已经解决。但是种种尝试结果让人感觉这里面并不简单,让我们深入探究一番。

思考

HttpURLConnection为什么会将GET请求转换成POST请求?

从刚刚的getOutputStream方法进入到HttpURLConnection内部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
 @Override
public synchronized OutputStream getOutputStream() throws IOException {
connecting = true;
SocketPermission p = URLtoSocketPermission(this.url);

if (p != null) {
try {
return AccessController.doPrivilegedWithCombiner(
new PrivilegedExceptionAction<>() {
public OutputStream run() throws IOException {
return getOutputStream0();
}
}, null, p
);
} catch (PrivilegedActionException e) {
throw (IOException) e.getException();
}
} else {
return getOutputStream0();
}
}

private synchronized OutputStream getOutputStream0() throws IOException {
try {
if (!doOutput) {
throw new ProtocolException("cannot write to a URLConnection"
+ " if doOutput=false - call setDoOutput(true)");
}

if (method.equals("GET")) {
method = "POST"; // Backward compatibility
}
if ("TRACE".equals(method) && "http".equals(url.getProtocol())) {
throw new ProtocolException("HTTP method TRACE" +
" doesn't support output");
……
}

这里的getOutputStream0方法内部检测到个当前如果是get方法,则会被转换成post方法的请求

所以总结来说,HttpURLConnection在遇到包含body请求体的请求时,会将当前的GET方法自动转换成POST方法进行请求。

像我们经常听到的一个面试题中,HTTPGET方法和POST方法有什么区别?大家可能会毫不犹豫的回答到这么一点,get方法也可以携带body体进行请求。那么为什么JDK会做这么一个不太符合逻辑的事情呢?

这里我们看看Stack Overflow上的一个回答

The RFC2616 referenced as “HTTP/1.1 spec” is now obsolete. In 2014 it was replaced by RFCs 7230-7237. Quote “the message-body SHOULD be ignored when handling the request” has been deleted. It’s now just “Request message framing is independent of method semantics, even if the method doesn’t define any use for a message body” The 2nd quote “The GET method means retrieve whatever information … is identified by the Request-URI” was deleted. - From a comment

大意是早期的HTTP协议是不允许GET请求携带body,后面删除了这项规定,所以慢慢的也变成了现在大家所熟知的GET方法。

为什么OkHttp会直接对这种行为报错呢?

这里详细的讨论可以看github社区内对Okhttp关于GET方法的支持的讨论。我们在文中对讨论区做一个简单的总结。

支持方观点:

  • elasticsearch的查询API中是需要通过在GET方法中传入body实现查询的,这一点相比写过es query的同学都深有体会吧
  • RFC7230-7237规范中已经明确删除了web服务器对GET方法禁止请求body的限制

OkHttp官方团队则是明确反对的:

  • 携带请求体会使缓存的行为不可预测
  • 可能致使拦截器发生资源泄露或者崩溃
  • 可能会干扰HTTP/1的连接池,导致被攻击

同时官方团队也认为ES提供的Api是错误的用法,除过elastic外,并没有其他令人心动的案例支持。

总结

综上可见,虽然RFC规范中恢复了对GET方法请求体的支持,但是可能仍有现存服务器不支持这一类型的请求模式。

很多客户端实现的过程中,也为了轻量、简单、安全等方面的考虑放弃了对它的支持。

所以我们在实现业务接口的时候,也可以考虑到这方面的缘故,尽可能少的使用这些有历史遗留问题的规范模式。


本文为王尔玉先生投稿