结论:
Feign如果使用Apache HttpClient,PUT/POST时,传参时尽量使用RequestBody。
如果没有RequestBody,QueryString会被Apache HttpClient转换成表单中key value进行提交,这样数据接口方就会取不到
报错了
像往常一样把服务B的接口定义 copy 到服务A的FeignClient中,然后在Postman中自测期间一个接口报错了【服务A 调 服务B时出错了】。
报错信息:
提示信息不是很优雅,勿怪,因为正常情况下根本不可能出现这种情况。就是攻击者看到这个提示也会止步【参数校验很严格】。
数据的生产、消费情况
服务B提供的服务【生产】:
服务A提供给前端的服务:
服务A调用服务B【消费】:
当时这样写是想偷个懒:直接使用QueryString,就不用再新定义传数据用的DTO。 报错就因为不走寻常路,这是后话,下面有分析。
BugShooting:分析日志
按请求的数据流,日志依次为:服务A的日志【与期望一致】:
服务B的日志【与期望不一致:少了QueryString】:
问题已经定位:服务A调用服务B时,把QueryString参数 弄丢了
论打印日志的重要性!打印有用的日志是一门学问
又check了代码,没有毛病呀,QueryString专用的标@RequestParam也已经打上了!奇怪
BugShooting:站到巨人的肩膀上
想看看是不是有人趟过这个坑,baidu、google、bing下没找到相关的信息。只是看到有通过@Headers("Content-Type: application/json")或@PutMapping(value = "/provide/sync_strategies/{syncStrategyId}", headers = {"Content-Type:application/json"})来显式声明 Request Header的做法,试了下没用。
BugShooting:Debug【必杀技】
会不会大家都没有这样用过其实,直接将参数全部通过@RequestBody也可以解决,但之前给自己立了一个Flag:要把Feign的源码重新梳理一遍。
Debug时,发现Apache HttpClient在PUT或POST方法时会有这样一个逻辑:
有QueryString但没有RequestBody时,QueryString不会放到URL中,而是将QueryString转化成表单的key value结构的数据,然后按表单的方式提交:
org.apache.http.client.methods.RequestBuilder#build
指定使用application/x-www-form-urlencoded,并将QueryString写到RequestBody中:
org.apache.http.client.entity.UrlEncodedFormEntity#UrlEncodedFormEntity(java.lang.Iterable<? extends org.apache.http.NameValuePair>, java.nio.charset.Charset)
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发起的请求:
期望的方式:
问题找到了,解决办法就一目了然了:增加一个RequestBody不就可以了
我传一个冗余的RequestBody进去:
可以看到ReqestBody已经值了
继续看QueryString的处理逻辑是否发生变化,可以看到与期望的一样了:
但这种处理方式,增加了业务不需要参数,会增加代码的维护成本,其它同学看代码时,将这个当做无效参数去掉的话,服务就不可用了。
于是,就将请求的参数封装到一个DTO中,然后在Body中传数据即可:
补充
1、Apache Http Client初始化entity【RequestBody】的代码入口:
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