谁动了我的请求?——GET变为POST的原因探究

关键词

  1. Java
  2. OpenFeign、@QueryMap
  3. HttpURLConnection、doOutput、connection.setDoOutput(true)
  4. GET变POST

背景

相信在微服务架构下做过开发的同学对“跨服务调用”都是比较熟悉的,很多时候我们可能会选择HTTP接口调用的方式来进行服务间调用。OpenFeign作为一种开源的“声明式”HTTP客户端受到了许多开发者的青睐,毕竟只需要简单写个接口,加两个注解就能使用,何乐而不为呢?

正确地使用框架固然能极大提高我们的开发效率,但框架毕竟帮我们屏蔽了太多底层的逻辑,知道得越少,我们就越有可能犯错。

对于使用OpenFeign来说,笔者就踩过一个初看让人很摸不着头脑的坑…

先来看一个简单的测试代码:

  1. 参数类型定义
@Data
@AllArgsConstructor
@ToString
public class TestParam {
    private int id;
    private String name;
}
  1. 服务端接口
@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;
    }
}
  1. 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的原因(条件):

  1. 通过一开始对OpenFeign的分析我们知道,没有被OpenFeign指定的几个参数注解修饰的参数会被默认当作请求体放到元数据中
  2. OpenFeign底层用JDK原生的HttpURLConnection完成HTTP调用
  3. OpenFeign在判断解析出的元数据请求体部分不为空时,会调用connection.setDoOutput(true)然后通过connection.getOutputStream()获取的输出流将请求体发送出去
  4. HttpURLConnectiongetOutputStream()会“纠正”带请求体的GET为POST
  5. 最终发送出去的请求就是POST类型

总结

  1. 使用OpenFeign导致GET变POST的直接元凶是JDK的HttpURLConnection.getOutputStream0()
  2. 使用OpenFeign时,未被@QueryMap等注解修饰的参数会被默认放到请求体部分。在使用GET时需要注意,否则GET请求可能最终会变为POST
  3. 使用OpenFeign时建议养成每个参数都用对应的注解修饰的习惯,一来便于问题排查,二来可以避免一些未知的默认行为
  4. 当请求类型是GET,但却调用了HttpURLConnection.setDoOutput(true)并使用HttpURLConnection.getOutputStream0()获取输出流时,JDK会将GET自动改为POST
  • 29
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值