背景
这是阳光不太明媚的一天。我接到一个需求,需要为一套第三方模块的接口做一层代理。这套第三方接口是由其他公司提供的,需要通过请求头(Header)传递鉴权信息。
碎碎念:产品经理希望将这个第三方模块直接集成在我们的系统里,接口直接通过前端页面调用。想法很好,但是通过前端调用,就意味着前端需要维护鉴权信息,并且这些鉴权信息是属于第三方的,与我们的系统毫无关系,那么通过前端直接调用接口就不太合适了。再加上该第三方只提供一个用户名给我们使用,而我们系统内部有独立的用户模块,这意味着从该第三方请求的数据需要进行基于我们系统的用户隔离。
基于上述两点,架构师的想法是通过后端程序为这套接口做代理,在代理请求转发时控制。因此我的想法是通过OpenFeign将第三方接口封装,并通过Feign的拦截器机制分别实现第三方鉴权和本系统用户隔离,很快便上手开发完成。
异常现象
测试的时候发现某一个接口的数据总是响应异常,但通过Postman直接尝试请求这个第三方接口却正常。这个异常接口使用GET方法,并同时通过query参数+header+body的方式传参。经过仔细检查,通过Feign包装的接口其配置和请求也都不存在笔误。这一定是xx的阴谋!
通过对比试验,可以认知到:Feign自动发起的请求与通过Postman发起的请求有所不同,正是这种不同导致了Feign的请求得不到正确的响应。
问题排查
通过Fiddler抓包+开启Feign的Debug日志,来查看真实的Http请求。
打开Feign的debug日志:在配置类中注入
@Bean
Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
两种方式都观测到Feign发出的GET请求被转换成了POST请求.正是这个原因导致请求无法到达正确的接口,因此响应异常。
问题定位
该问题基于一点前提:使用feign的方法是通过maven引入OpenFeign
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
除此之外没有引入其他feign相关包。在此情况下,feign在初始化client对象时使用的Client实现类型为feign.Client.Default
注意看该类的convertAndSend方法
HttpURLConnection convertAndSend(Request request, Request.Options options) throws IOException {
URL url = new URL(request.url());
HttpURLConnection connection = this.getConnection(url);
if (connection instanceof HttpsURLConnection) {
HttpsURLConnection sslCon = (HttpsURLConnection)connection;
if (this.sslContextFactory != null) {
sslCon.setSSLSocketFactory(this.sslContextFactory);
}
if (this.hostnameVerifier != null) {
sslCon.setHostnameVerifier(this.hostnameVerifier);
}
}
connection.setConnectTimeout(options.connectTimeoutMillis());
connection.setReadTimeout(options.readTimeoutMillis());
connection.setAllowUserInteraction(false);
connection.setInstanceFollowRedirects(options.isFollowRedirects());
connection.setRequestMethod(request.httpMethod().name());
Collection<String> contentEncodingValues = (Collection)request.headers().get("Content-Encoding");
boolean gzipEncodedRequest = contentEncodingValues != null && contentEncodingValues.contains("gzip");
boolean deflateEncodedRequest = contentEncodingValues != null && contentEncodingValues.contains("deflate");
boolean hasAcceptHeader = false;
Integer contentLength = null;
Iterator var10 = request.headers().keySet().iterator();
while(var10.hasNext()) {
String field = (String)var10.next();
if (field.equalsIgnoreCase("Accept")) {
hasAcceptHeader = true;
}
Iterator var12 = ((Collection)request.headers().get(field)).iterator();
while(var12.hasNext()) {
String value = (String)var12.next();
if (field.equals("Content-Length")) {
if (!gzipEncodedRequest && !deflateEncodedRequest) {
contentLength = Integer.valueOf(value);
connection.addRequestProperty(field, value);
}
} else {
connection.addRequestProperty(field, value);
}
}
}
if (!hasAcceptHeader) {
connection.addRequestProperty("Accept", "*/*");
}
if (request.body() != null) {
if (this.disableRequestBuffering) {
if (contentLength != null) {
connection.setFixedLengthStreamingMode(contentLength);
} else {
connection.setChunkedStreamingMode(8196);
}
}
connection.setDoOutput(true);
OutputStream out = connection.getOutputStream();
if (gzipEncodedRequest) {
out = new GZIPOutputStream((OutputStream)out);
} else if (deflateEncodedRequest) {
out = new DeflaterOutputStream((OutputStream)out);
}
try {
((OutputStream)out).write(request.body());
} finally {
try {
((OutputStream)out).close();
} catch (IOException var19) {
}
}
}
return connection;
}
注意条件分支if (request.body() != null) 其中有一行connection.setDoOutput(true);
connection是通过以下方法构造的
public HttpURLConnection getConnection(URL url) throws IOException {
return (HttpURLConnection)url.openConnection();
}
这里HttpURLConnection有许多具体实现,为了跟踪是哪一种,需要看url对象的构造。URL url = new URL(request.url());
再看Java.net.URL类的构造方法,可知URL的URLStreamHandlerFactory factory没有特意设置的时候,URL的handler是通过反射构造的。
// Use the factory (if any)
if (factory != null) {
handler = factory.createURLStreamHandler(protocol);
checkedWithFactory = true;
}
// Try java protocol handler
if (handler == null) {
String packagePrefixList = null;
packagePrefixList
= java.security.AccessController.doPrivileged(
new sun.security.action.GetPropertyAction(
protocolPathProp,""));
if (packagePrefixList != "") {
packagePrefixList += "|";
}
// REMIND: decide whether to allow the "null" class prefix
// or not.
packagePrefixList += "sun.net.www.protocol";
StringTokenizer packagePrefixIter =
new StringTokenizer(packagePrefixList, "|");
while (handler == null &&
packagePrefixIter.hasMoreTokens()) {
String packagePrefix =
packagePrefixIter.nextToken().trim();
try {
String clsName = packagePrefix + "." + protocol +
".Handler";
Class<?> cls = null;
try {
cls = Class.forName(clsName);
} catch (ClassNotFoundException e) {
ClassLoader cl = ClassLoader.getSystemClassLoader();
if (cl != null) {
cls = cl.loadClass(clsName);
}
}
if (cls != null) {
handler =
(URLStreamHandler)cls.newInstance();
}
} catch (Exception e) {
// any number of exceptions can get thrown here
}
}
}
这里就进到jdk的实现了,不太好找。暂时放弃这里。我们姑且认为这个connection的实现类型是sun.net.www.protocol.http.HttpURLConnection,因为似乎只有这一个看起来比较像它的单纯实现。(其实debug的时候也能通过断点看到具体类型)
那么再看Client的Default类的if (request.body() != null)条件分支,其中有
OutputStream out = connection.getOutputStream();
我们再看sun.net.www.protocol.http.HttpURLConnection的getOutputStream()方法
@Override
public synchronized OutputStream getOutputStream() throws IOException {
connecting = true;
SocketPermission p = URLtoSocketPermission(this.url);
if (p != null) {
try {
return AccessController.doPrivilegedWithCombiner(
new PrivilegedExceptionAction<OutputStream>() {
public OutputStream run() throws IOException {
return getOutputStream0();
}
}, null, p
);
} catch (PrivilegedActionException e) {
throw (IOException) e.getException();
}
} else {
return getOutputStream0();
}
}
实际返回的是getOutputStream0()方法。再看该方法(部分)
private synchronized OutputStream getOutputStream0() throws IOException {
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
}
...
惊不惊喜,意不意外?这里的实现是,如果method是GET且外部试图获取输出流,就将方法改为POST。也就是说,通过这个Client.Default的实现无论如何也不能通过GET请求传送请求体(body)。
问题修复
既然该Client的默认实现不奏效,那么我们换一种试试。经过搜索引擎一顿查询我们得知可以使用OKHttp的实现。
失败尝试
(注意,这是失败的尝试,请勿直接从此步骤开始抄代码)
通过maven引入feign的OKHttp
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.9.3</version> <!-- 请使用最新版本 -->
</dependency>
并且在spring容器中注入
@Bean
public Client feignClient() {
return new OkHttpClient();
}
修改后再验证,发现通过OKHttpClient发送的请求仍然存在问题。这次变成没有body了。
仍旧看源码 feign.okhttp.OKHttpClient
static Request toOkHttpRequest(feign.Request input) {
Request.Builder requestBuilder = new Request.Builder();
requestBuilder.url(input.url());
MediaType mediaType = null;
boolean hasAcceptHeader = false;
Iterator var4 = input.headers().keySet().iterator();
while(var4.hasNext()) {
String field = (String)var4.next();
if (field.equalsIgnoreCase("Accept")) {
hasAcceptHeader = true;
}
Iterator var6 = ((Collection)input.headers().get(field)).iterator();
while(var6.hasNext()) {
String value = (String)var6.next();
requestBuilder.addHeader(field, value);
if (field.equalsIgnoreCase("Content-Type")) {
mediaType = MediaType.parse(value);
if (input.charset() != null) {
mediaType.charset(input.charset());
}
}
}
}
if (!hasAcceptHeader) {
requestBuilder.addHeader("Accept", "*/*");
}
byte[] inputBody = input.body();
boolean isMethodWithBody = HttpMethod.POST == input.httpMethod() || HttpMethod.PUT == input.httpMethod() || HttpMethod.PATCH == input.httpMethod();
if (isMethodWithBody) {
requestBuilder.removeHeader("Content-Type");
if (inputBody == null) {
inputBody = new byte[0];
}
}
RequestBody body = inputBody != null ? RequestBody.create(mediaType, inputBody) : null;
requestBuilder.method(input.httpMethod().name(), body);
return requestBuilder.build();
}
注意这几行
byte[] inputBody = input.body();
boolean isMethodWithBody = HttpMethod.POST == input.httpMethod() || HttpMethod.PUT == input.httpMethod() || HttpMethod.PATCH == input.httpMethod();
if (isMethodWithBody) {
requestBuilder.removeHeader("Content-Type");
if (inputBody == null) {
inputBody = new byte[0];
}
}
RequestBody body = inputBody != null ? RequestBody.create(mediaType, inputBody) : null;
惊不惊喜,意不意外?这里实现的逻辑是,仅当请求方法是POST PUT PATCH时才认为有可能存在请求体,否则请求体一律为null.也就是说OKHttpClient也不支持GET请求携带请求体。
最终方案
最后调研到apache的httpclient是支持的。
通过maven引入
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-httpclient</artifactId>
<version>11.10</version>
</dependency>
并进行配置。参考这篇博客 http://www.360doc.com/content/21/1222/11/59494473_1009816207....
终于可以正常发送携带请求体的GET请求了。
太长不看 下面开始技术总结:
我们都知道Restful http协议通过区分不同的请求方法(GET/POST/PUT/DELETE...)表示不同的意图。一般大家在使用中会注意不同方法的传参,GET通常是不携带请求体的,但HTTP协议本身似乎也并未规定GET请求一定不能携带body。如果我们单纯地仅考虑HTTP协议的报文结构,什么请求方法后面都可以跟请求体,这也导致了某些公司使用奇奇怪怪的不符合习惯的接口定义。
虽然这很业余,但是当我们真的遇到这种不能修改的第三方接口时,需要注意到feign使用openjdk自带的HttpURLConnection和使用OKHttp3对不合规矩的http请求的支持是较差的。相比Postman这种纯工具,他们在实现时都添加了一些校验需求,而且没有直接将校验结果暴露到外面,导致问题很难查到。在本次debug中,我们发现HttpURLConnection和OKHttp3都对GET方法携带请求体的请求进行了特殊的静默处理。我个人觉得这种校验应该抛异常到外面,而不是直接丢弃/修改请求。
结论是:遇到不规范的GET请求携带body,且使用feign时,最好使用apache的httpclient作为客户端
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。