Apache HttpClient在PUT/POST时的一个坑

结论:
Feign如果使用Apache HttpClient,PUT/POST时,传参时尽量使用RequestBody。
如果没有RequestBody,QueryString会被Apache HttpClient转换成表单中key value进行提交,这样数据接口方就会取不到

报错了
像往常一样把服务B的接口定义 copy 到服务A的FeignClient中,然后在Postman中自测期间一个接口报错了【服务A 调 服务B时出错了】。

报错信息:

Apache HttpClient在PUT/POST时的一个坑

 

 

提示信息不是很优雅,勿怪,因为正常情况下根本不可能出现这种情况。就是攻击者看到这个提示也会止步【参数校验很严格】。

数据的生产、消费情况
服务B提供的服务【生产】:

Apache HttpClient在PUT/POST时的一个坑

 

 

服务A提供给前端的服务:

Apache HttpClient在PUT/POST时的一个坑

 

 

服务A调用服务B【消费】:

Apache HttpClient在PUT/POST时的一个坑

 

 

Apache HttpClient在PUT/POST时的一个坑

当时这样写是想偷个懒:直接使用QueryString,就不用再新定义传数据用的DTO。 报错就因为不走寻常路,这是后话,下面有分析。

BugShooting:分析日志


按请求的数据流,日志依次为:服务A的日志【与期望一致】:
Apache HttpClient在PUT/POST时的一个坑

 

 

Apache HttpClient在PUT/POST时的一个坑

服务B的日志【与期望不一致:少了QueryString】:
Apache HttpClient在PUT/POST时的一个坑

 

 

Apache HttpClient在PUT/POST时的一个坑

问题已经定位:服务A调用服务B时,把QueryString参数 弄丢了
论打印日志的重要性!打印有用的日志是一门学问Apache HttpClient在PUT/POST时的一个坑

 

又check了代码,没有毛病呀,QueryString专用的标@RequestParam也已经打上了!奇怪Apache HttpClient在PUT/POST时的一个坑

BugShooting:站到巨人的肩膀上


想看看是不是有人趟过这个坑,baidu、google、bing下没找到相关的信息。只是看到有通过@Headers("Content-Type: application/json")或@PutMapping(value = "/provide/sync_strategies/{syncStrategyId}", headers = {"Content-Type:application/json"})来显式声明 Request Header的做法,试了下没用。

 

BugShooting:Debug【必杀技

会不会大家都没有这样用过Apache HttpClient在PUT/POST时的一个坑其实,直接将参数全部通过@RequestBody也可以解决,但之前给自己立了一个Flag:要把Feign的源码重新梳理一遍。
Debug时,发现Apache HttpClient在PUT或POST方法时会有这样一个逻辑:
有QueryString但没有RequestBody时,QueryString不会放到URL中,而是将QueryString转化成表单的key value结构的数据,然后按表单的方式提交:

Apache HttpClient在PUT/POST时的一个坑

org.apache.http.client.methods.RequestBuilder#build
指定使用application/x-www-form-urlencoded,并将QueryString写到RequestBody中:
Apache HttpClient在PUT/POST时的一个坑

 

 

org.apache.http.client.entity.UrlEncodedFormEntity#UrlEncodedFormEntity(java.lang.Iterable<? extends org.apache.http.NameValuePair>, java.nio.charset.Charset)

 Apache HttpClient在PUT/POST时的一个坑

 

 

org.apache.http.client.utils.URLEncodedUtils#format(java.lang.Iterable<? extends org.apache.http.NameValuePair>, char, java.nio.charset.Charset)其实将QueryString进行转换,并以表单的形式提交,也是符合Htpp协议的,但需要接收方也按这种方式来接收就可以。看上面的截图,服务B 使用了@RequestParam,即从QueryString取值,那当然就取不到了。简单地讲,就像取快递一样。平时都在南门取,但是如果快递员跑到北门后,又没告诉你这个变动。如果你还到南门,肯定是取不到的。


两种不同的数据传输方式

报错时Apache HttpClient发起的请求:

Apache HttpClient在PUT/POST时的一个坑

 

 

 

期望的方式:

 

Apache HttpClient在PUT/POST时的一个坑

 

 

问题找到了,解决办法就一目了然了:增加一个RequestBody不就可以了
我传一个冗余的RequestBody进去:
Apache HttpClient在PUT/POST时的一个坑

 

 Apache HttpClient在PUT/POST时的一个坑

 

 

可以看到ReqestBody已经值了

Apache HttpClient在PUT/POST时的一个坑

 

继续看QueryString的处理逻辑是否发生变化,可以看到与期望的一样了:
Apache HttpClient在PUT/POST时的一个坑

 

 

但这种处理方式,增加了业务不需要参数,会增加代码的维护成本,其它同学看代码时,将这个当做无效参数去掉的话,服务就不可用了。
于是,就将请求的参数封装到一个DTO中,然后在Body中传数据即可:

Apache HttpClient在PUT/POST时的一个坑

Apache HttpClient在PUT/POST时的一个坑

 

 

 

 

补充


1、Apache Http Client初始化entity【RequestBody】的代码入口:

Apache HttpClient在PUT/POST时的一个坑

feign.httpclient.ApacheHttpClient#toHttpUriRequest

 

2、踩坑的一个条件:指定Feign使用Client为Apache Http Client

       <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

        <!-- https://mvnrepository.com/artifact/io.github.openfeign/feign-httpclient -->
        <dependency>
            <groupId>io.github.openfeign</groupId>
            <artifactId>feign-httpclient</artifactId>
            <version>10.8</version>
        </dependency>

feign-httpclient的较低版本还需要添加下面这个依赖:

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
        </dependency>

 

说明:Apache HttpClient是老牌http客户端了,可以设置连接池、超时时间等对服务之间的调用调优。Spring Cloud从Brixtion.SR5版本开始就支持这种替换。
一个经典的HttpClient配置:

//httpclient 4.5.2使用连接池的经典配置
    private CloseableHttpClient getHttpClient() {
        //注册访问协议相关的Socket工厂
        Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
                .register("http", PlainConnectionSocketFactory.INSTANCE)
                .register("https", SSLConnectionSocketFactory.getSocketFactory())
                .build();

        //HttpConnectionFactory:配置写请求/解析响应处理器
        HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection> connectionFactory = new ManagedHttpClientConnectionFactory(
                DefaultHttpRequestWriterFactory.INSTANCE,
                DefaultHttpResponseParserFactory.INSTANCE
        );

        //DNS解析器
        DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE;
        //创建连接池管理器
        PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager(socketFactoryRegistry, connectionFactory, dnsResolver);
        //设置默认的socket参数
        manager.setDefaultSocketConfig(SocketConfig.custom().setTcpNoDelay(true).build());
        manager.setMaxTotal(300);//设置最大连接数。高于这个值时,新连接请求,需要阻塞,排队等待
        //路由是对MaxTotal的细分。
        // 每个路由实际最大连接数默认值是由DefaultMaxPerRoute控制。
        // MaxPerRoute设置的过小,无法支持大并发:ConnectionPoolTimeoutException:Timeout waiting for connection from pool
        manager.setDefaultMaxPerRoute(200);//每个路由的最大连接
        manager.setValidateAfterInactivity(5 * 1000);//在从连接池获取连接时,连接不活跃多长时间后需要进行一次验证,默认为2s

        //配置默认的请求参数
        RequestConfig defaultRequestConfig = RequestConfig.custom()
                .setConnectTimeout(2 * 1000)//连接超时设置为2s
                .setSocketTimeout(5 * 1000)//等待数据超时设置为5s
                .setConnectionRequestTimeout(2 * 1000)//从连接池获取连接的等待超时时间设置为2s
//                .setProxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("192.168.0.2", 1234))) //设置代理
                .build();

        CloseableHttpClient closeableHttpClient = HttpClients.custom()
                .setConnectionManager(manager)
                .setConnectionManagerShared(false)//连接池不是共享模式,这个共享是指与其它httpClient是否共享
                .evictIdleConnections(60, TimeUnit.SECONDS)//定期回收空闲连接
                .evictExpiredConnections()//回收过期连接
                .setConnectionTimeToLive(60, TimeUnit.SECONDS)//连接存活时间,如果不设置,则根据长连接信息决定
                .setDefaultRequestConfig(defaultRequestConfig)//设置默认的请求参数
                .setConnectionReuseStrategy(DefaultConnectionReuseStrategy.INSTANCE)//连接重用策略,即是否能keepAlive
                .setKeepAliveStrategy(DefaultConnectionKeepAliveStrategy.INSTANCE)//长连接配置,即获取长连接生产多长时间
                .setRetryHandler(new DefaultHttpRequestRetryHandler(0, false))//设置重试次数,默认为3次;当前是禁用掉
                .build();

        /**
         *JVM停止或重启时,关闭连接池释放掉连接
         */
        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                try {
                    closeableHttpClient.close();
                    log.info("http client closed");
                } catch (IOException e) {
                    log.error(e.getMessage(), e);
                }
            }
        });
        return closeableHttpClient;
    }

 

https://github.com/helloworldtang/spring-boot-cookbook/blob/master/learning-demo/src/main/java/com/tangcheng/learning/web/config/RestTemplateConfig.java

 

https://mp.weixin.qq.com/s/N4zqSfMAgB6b5jnUsa1z2w

 

上一篇:调用接口显示Required request body is missing


下一篇:okhttp3 上传文件