谁动了我的请求?——GET变为POST的原因探究
关键词
- Java
- OpenFeign、@QueryMap
- HttpURLConnection、doOutput、connection.setDoOutput(true)
- GET变POST
背景
相信在微服务架构下做过开发的同学对“跨服务调用”都是比较熟悉的,很多时候我们可能会选择HTTP接口调用的方式来进行服务间调用。OpenFeign作为一种开源的“声明式”HTTP客户端受到了许多开发者的青睐,毕竟只需要简单写个接口,加两个注解就能使用,何乐而不为呢?
正确地使用框架固然能极大提高我们的开发效率,但框架毕竟帮我们屏蔽了太多底层的逻辑,知道得越少,我们就越有可能犯错。
对于使用OpenFeign来说,笔者就踩过一个初看让人很摸不着头脑的坑......
先来看一个简单的测试代码:
- 参数类型定义
@Data
@AllArgsConstructor
@ToString
public class TestParam {
private int id;
private String name;
}
- 服务端接口
@RestController
@RequestMapping("/feignTest")
public class TestController {
@GetMapping
public String feignGetTest(TestParam testParam) {
System.out.println("---- GET ----");
System.out.println("param: " + testParam);
return "response for GET request, your param is " + testParam;
}
@PostMapping
public String feignPostTest(@RequestBody TestParam param) {
System.out.println("---- POST ----");
System.out.println("param: " + param);
return "response for POST request, your param is: " + param;
}
}
- OpenFeign客户端测试
public class Test {
interface TestHttpClient {
@RequestLine("GET /feignTest")
@Headers(value = "Content-Type:application/json")
String simpleHttpGetTest(TestParam urlParam);
@RequestLine("POST /feignTest")
@Headers(value = "Content-Type:application/json")
String simpleHttpPostTest(TestParam postBody);
}
public static void main(String[] args) {
TestHttpClient testClient = Feign.builder()
.decoder(new StringDecoder())
.encoder(new JacksonEncoder())
.target(TestHttpClient.class, "http://localhost:8080");
String result = testClient.simpleHttpGetTest(new TestParam(111, "longqinx"));
//result最后是什么?
System.out.println(result);
}
}
上述服务端部署在localhost:8080地址上,此时运行OpenFeign客户端测试代码,猜猜结果输出是啥?
- A:
response for GET request, your param is: TestParam(id=111, name=longqinx)
- B:
response for POST request, your param is: TestParam(id=111, name=longqinx)
可能大家觉得这还不简单,肯定是A啊,通过请求类型是GET就直接秒掉!也有少部分同学可能觉得既然这么问,那这背后肯定不可能这么简单,所以选择B......结果就是还真选对了,正确答案就是B。
我们看到上述代码21行调用了simpleHttpGetTest()
方法,该方法上有一个@RequestLine("GET /feignTest")
注解,这里很明显写的是用GET
,从直觉的角度来说是这样没错,而且我们看看OpenFeign打印出来的日志,也证明了这是个“GET”请求:
事情到这里就越来越让人摸不着头脑了,为了让我们能摸到头脑,头发也开始一根一根掉下来了......
原因刨根
解决问题最好的方式就是直面问题,解决BUG最好的方式那当然是DEBUG。当然还有一个捷径就是先网上搜一搜有没有类似的问题,巧了,一查使用OpenFeign导致GET变POST的情况还真不少......,大家几乎都不约而同地提出了解决方案是参数上加@QeuryMap
注解,但几乎都没有顺带说一下为什么要这么做。
@RequestLine("GET /feignTest")
@Headers(value = "Content-Type:application/json")
String simpleHttpGetTest(@QueryMap TestParam urlParam);
和一开始的代码比较,simpleHttpGetTest()
的参数前加上了@QueryMap
注解,此时再运行测试用例,得到的结果就和预期一致了,即返回response for GET request, your param is: TestParam(id=111, name=longqinx)
那么问题来了,为啥呢??为啥POST请求就可以不要注解,GET请求就需要指定?又为啥GET参数不使用@QueryMap
之后实际请求变成了POST?
老规矩,跟着源码走。
既然知道是这个@QeuryMap
可以让GET请求按预期的逻辑正常工作,那么我们不妨看看OpenFeign怎么处理这个注解。通过源码中追踪这个注解的用处,可以发现在feign.Contract
这个类中有如下代码:
...
//这里注册了参数注解 @QueryMap
super.registerParameterAnnotation(QueryMap.class, (queryMap, data, paramIndex) -> {
checkState(data.queryMapIndex() == null,
"QueryMap annotation was present on multiple parameters.");
data.queryMapIndex(paramIndex);
data.queryMapEncoder(queryMap.mapEncoder().instance());
});
super.registerParameterAnnotation(HeaderMap.class, (queryMap, data, paramIndex) -> {
checkState(data.headerMapIndex() == null,
"HeaderMap annotation was present on multiple parameters.");
data.headerMapIndex(paramIndex);
});
...
从上述代码大概可以猜测出这是OpenFeign在注册各种注解对应的处理方法。来看看这个注册方法干了啥
protected <E extends Annotation> void registerParameterAnnotation(Class<E> annotation,
DeclarativeContract.ParameterAnnotationProcessor<E> processor) {
this.parameterAnnotationProcessors.put((Class) annotation,
(DeclarativeContract.ParameterAnnotationProcessor) processor);
}
这个注册方法很简单,就是将参数丢进一个名为parameterAnnotationProcessors
的HashMap中,这个字段名称正好也印证了我们的猜测——这个注册方法功能确实是注册参数注解对应的处理器。
继续顺藤摸瓜,看看都有哪些地方在用这个HashMap,通过跟踪这个字段,我们发现有个名为feign.DeclarativeContract#processAnnotationsOnParameter()
的方法用到了这个HashMap,其名称也足够直白,就是处理参数上的注解。这个方法本身又被一个名为feign.Contract.BaseContract#parseAndValidateMetadata()
的方法调用,这个方法的主要作用就是解析OpenFeign相关的各种注解来生成元数据。我们重点看处理方法参数的部分:
for (int i = 0; i < count; i++) {
boolean isHttpAnnotation = false;
if (parameterAnnotations[i] != null) {
//调用上文提到的方法处理参数上的注解
isHttpAnnotation = processAnnotationsOnParameter(data, parameterAnnotations[i], i);
}
if (isHttpAnnotation) {
data.ignoreParamater(i);
}
if ("kotlin.coroutines.Continuation".equals(parameterTypes[i].getName())) {
data.ignoreParamater(i);
}
if (parameterTypes[i] == URI.class) {
data.urlIndex(i);
} else if (!isHttpAnnotation
&& !Request.Options.class.isAssignableFrom(parameterTypes[i])) {
if (data.isAlreadyProcessed(i)) {
checkState(data.formParams().isEmpty() || data.bodyIndex() == null,
"Body parameters cannot be used with form parameters.%s", data.warnings());
} else if (!data.alwaysEncodeBody()) {
checkState(data.formParams().isEmpty(),
"Body parameters cannot be used with form parameters.%s", data.warnings());
checkState(data.bodyIndex() == null,
"Method has too many Body parameters: %s%s", method, data.warnings());
//通过调试,发现最终没有加任何注解的参数最终会走到这里被处理
data.bodyIndex(i);
data.bodyType(
Types.resolve(targetType, targetType, genericParameterTypes[i]));
}
}
}
通过逐步调试我们可以发现,没有被@QueryMap
、@Param
等任何OpenFeign相关的注解修饰的参数都会走到上述代码的30行的位置,这里的意思就是把方法的第 i 个参数当作请求体,也就是说没有被这些参数注解修饰的参数会被丢到请求体的位置.等等,可能有的同学会和我一样有疑问了,GET请求还有请求体??记住这个疑问,读到后边你会慢慢理解
再往深层跟踪,我们会发现OpenFeign的底层是直接使用JDK提供的HttpURLConnection
来进行HTTP通信的,具体逻辑可参考feign.Client.Default#convertAndSend()
方法,考虑到篇幅此处不展开详细的源码跟踪过程。
在单步调试feign.Client.Default#convertAndSend()
方法后,又出现了一个神奇的现象:
我们将断点放到将要发起HTTP实际数据传输之前看看连接参数:
此时我们能清楚地看到,这个connection
的请求类型还是GET,由此说明了OpenFeign并没有动我们的请求,我们写@RequestLine("GET /xxx")
它就真把它当GET请求,不错,这OpenFeign能处!但此时问题更难搞了,排除了OpenFeign的嫌疑,意味着这个问题更复杂了......
谁也没想到再往下走一步事情又变得奇怪但清晰了:
?!上一步的时候connection
还是GET,怎么突然就变为POST了?但好在山重水复疑无路,柳暗花明又一村!至少离真相又近了一步,此时我宣布,JDK提供的HttpURLConnection
有巨大嫌疑,毕竟我只调用了其getOutputStream()
方法我的GET请求就变为POST了
竟然查到“官方”头上了,我甚至开始怀疑我的怀疑,心想JDK咋会在背地里干这种事!?但那也没办法,只能硬着头皮继续查......
我们来看看这个getOutputStream()
干了啥。connection.getOutputStream()
最终调用了sun.net.www.protocol.http.HttpURLConnection#getOutputStream0()
方法,追到这里时我直接又惊又喜,我们来看看这个方法的前十几行代码:
private OutputStream getOutputStream0() throws IOException {
assert isLockHeldByCurrentThread();
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");
}
// if there's already an input stream open, throw an exception
if (inputStream != null) {
throw new ProtocolException("Cannot write output after reading input.");
}
//.....省略.....
}
大家没看错,这就是JDK的HttpURLConnection
源码的一部分,也正是动了我们请求的罪魁祸首......在上图的9 ~ 11 我们可以看出,我们的GET竟然被直接换成了POST!
原因再探
经过上述分析过程,我们已经洗清了OpenFeign的嫌疑,并找到了把GET变成POST的元凶——JDK的HttpURLConnection
那么造成这一步的原因又是什么呢?我的GET请求为啥会有请求体?
在查阅了HttpURLConnection
的使用方法之后,我了解到了一个名为doOutput
的参数,在上述提到的getOutputStream0()
方法中就用到了它。上述方法中如果这个参数为false,那么getOutputStream0()
就会直接抛异常,根本不会有后续GET变POST的机会
而这个doOutput
参数就是用于控制HTTP的数据传输的,可以理解为是否要打开到对方的输出流,即向对方发送数据。再理解简单一点这个参数就是用来控制要不要向对方发送HTTP请求体的,这对GET来说自然是无用的,因为按规范GET没有请求体,主要用在POST、PUT等需要通过请求体传输数据的请求。
在用HttpURLConnection
发起HTTP请求时,如果我们用POST并且需要发送请求体,就需要先调用connection.setDoOutput(true)
来将doOutput
置为true
,然后再通过connection.getOutputStream()
获取到服务端的输出流,往这个输出流写数据就相当于是发送请求体。
如果我们指定请求类型为GET,但还是调用了connection.setDoOutput(true)
和connection.getOutputStream()
,JDK内部实现就会帮我们把GET改为POST
如此便可以拉通来看一看GET变POST的原因(条件):
- 通过一开始对OpenFeign的分析我们知道,没有被OpenFeign指定的几个参数注解修饰的参数会被默认当作请求体放到元数据中
- OpenFeign底层用JDK原生的
HttpURLConnection
完成HTTP调用 - OpenFeign在判断解析出的元数据请求体部分不为空时,会调用
connection.setDoOutput(true)
然后通过connection.getOutputStream()
获取的输出流将请求体发送出去 HttpURLConnection
的getOutputStream()
会“纠正”带请求体的GET为POST- 最终发送出去的请求就是POST类型
总结
- 使用OpenFeign导致GET变POST的直接元凶是JDK的
HttpURLConnection.getOutputStream0()
- 使用OpenFeign时,未被
@QueryMap
等注解修饰的参数会被默认放到请求体部分。在使用GET时需要注意,否则GET请求可能最终会变为POST - 使用OpenFeign时建议养成每个参数都用对应的注解修饰的习惯,一来便于问题排查,二来可以避免一些未知的默认行为
- 当请求类型是GET,但却调用了
HttpURLConnection.setDoOutput(true)
并使用HttpURLConnection.getOutputStream0()
获取输出流时,JDK会将GET自动改为POST
转载自:https://juejin.cn/post/7384327143784185907