头图

背景

这是阳光不太明媚的一天。我接到一个需求,需要为一套第三方模块的接口做一层代理。这套第三方接口是由其他公司提供的,需要通过请求头(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作为客户端


风觅椒塘考曲棋
210 声望41 粉丝

爱学习的小白一枚呀