結論:
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
