1. 程式人生 > >HttpClient 4.5 重定向到中文URL出現亂碼的解決方案

HttpClient 4.5 重定向到中文URL出現亂碼的解決方案

一、問題描述:

遇到某個 URL A,請求時發現會重定向到某個包含了中文字元的 URL B。原以為只要 HttpClient 開啟了自動重定向的功能,下載 A 指向的頁面輕而易舉,結果卻出乎意料。HttpClient 在獲取重定向後的 URL B 時出現了中文亂碼,導致下載失敗,具體報錯資訊見下圖:

image

二、解決方案

問題的核心在於 ConnectionConfig 物件的 Charset 變數。如果你有使用到連線池,請參照如下方法:

PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setDefaultConnectionConfig(ConnectionConfig.custom().setCharset(Charset.forName("UTF-8")).build());

如果你只是使用到 HttpClient 物件,那麼可以參考以下方法:

CloseableHttpClient httpClient = HttpClients.custom()
            .setDefaultConnectionConfig(ConnectionConfig.custom().setCharset(Charset.forName("UTF-8")).build
()) .build();

三、過程分析

上面我直接給出瞭解決方案,有興趣的話可以一起分析一下這個過程。

首先,我們要了解 HttpClient 在重定向的這個過程中做了什麼。

預設情況下,HttpClient 的重定向策略依賴於 DefaultRedirectStrategy 這個類。該類的 getLocationURI(...) 方法用於獲取重定向後的 URL,具體程式碼如下所示:

public URI getLocationURI(HttpRequest request, HttpResponse response, HttpContext context) throws
ProtocolException { Args.notNull(request, "HTTP request"); Args.notNull(response, "HTTP response"); Args.notNull(context, "HTTP context"); HttpClientContext clientContext = HttpClientContext.adapt(context); Header locationHeader = response.getFirstHeader("location"); if(locationHeader == null) { throw new ProtocolException("Received redirect response " + response.getStatusLine() + " but no location header"); } else { String location = locationHeader.getValue(); if(this.log.isDebugEnabled()) { this.log.debug("Redirect requested to location \'" + location + "\'"); } RequestConfig config = clientContext.getRequestConfig(); URI uri = this.createLocationURI(location); try { if(!uri.isAbsolute()) { if(!config.isRelativeRedirectsAllowed()) { throw new ProtocolException("Relative redirect location \'" + uri + "\' not allowed"); } HttpHost redirectLocations = clientContext.getTargetHost(); Asserts.notNull(redirectLocations, "Target host"); URI requestURI = new URI(request.getRequestLine().getUri()); URI absoluteRequestURI = URIUtils.rewriteURI(requestURI, redirectLocations, false); uri = URIUtils.resolve(absoluteRequestURI, uri); } } catch (URISyntaxException var12) { throw new ProtocolException(var12.getMessage(), var12); } RedirectLocations redirectLocations1 = (RedirectLocations)clientContext.getAttribute("http.protocol.redirect-locations"); if(redirectLocations1 == null) { redirectLocations1 = new RedirectLocations(); context.setAttribute("http.protocol.redirect-locations", redirectLocations1); } if(!config.isCircularRedirectsAllowed() && redirectLocations1.contains(uri)) { throw new CircularRedirectException("Circular redirect to \'" + uri + "\'"); } else { redirectLocations1.add(uri); return uri; } } }

注意其中的核心點:

Header locationHeader = response.getFirstHeader("location");

可以看到,在遇到需要重定向的 URL 時,HttpClient 會先獲取響應頭的 location 屬性,然後將其封裝成 URI 物件後重新請求。

瞭解這一點後,我們先 debug 到這個位置,看看實際獲取到的 location 屬性是怎樣的。結果發現,在這個地方獲取到的 location 的值就已經是亂碼了。

這時候我們可以確定,問題不是出現在 responsegetFirstHeader(String name) 方法,而是出現在 response 本身。就是說,在我們發出請求後,獲取到的 HttpResponse 例項本身就已經是出現問題的了。

那麼,我們繼續往底層跟蹤,看看返回 HttpResponse 物件的 HttpRequestExecutor 在做什麼。

protected HttpResponse doSendRequest(HttpRequest request, HttpClientConnection conn, HttpContext context) throws IOException, HttpException {
    Args.notNull(request, "HTTP request");
    Args.notNull(conn, "Client connection");
    Args.notNull(context, "HTTP context");
    HttpResponse response = null;
    context.setAttribute("http.connection", conn);
    context.setAttribute("http.request_sent", Boolean.FALSE);
    conn.sendRequestHeader(request);
    if(request instanceof HttpEntityEnclosingRequest) {
        boolean sendentity = true;
        ProtocolVersion ver = request.getRequestLine().getProtocolVersion();
        if(((HttpEntityEnclosingRequest)request).expectContinue() && !ver.lessEquals(HttpVersion.HTTP_1_0)) {
            conn.flush();
            if(conn.isResponseAvailable(this.waitForContinue)) {
                response = conn.receiveResponseHeader();
                if(this.canResponseHaveBody(request, response)) {
                    conn.receiveResponseEntity(response);
                }

                int status = response.getStatusLine().getStatusCode();
                if(status < 200) {
                    if(status != 100) {
                        throw new ProtocolException("Unexpected response: " + response.getStatusLine());
                    }

                    response = null;
                } else {
                    sendentity = false;
                }
            }
        }

        if(sendentity) {
            conn.sendRequestEntity((HttpEntityEnclosingRequest)request);
        }
    }

    conn.flush();
    context.setAttribute("http.request_sent", Boolean.TRUE);
    return response;
}

我們發現,真正發出請求和獲取響應的是以下兩段程式碼:

conn.sendRequestHeader(request);
response = conn.receiveResponseHeader();
if(this.canResponseHaveBody(request, response)) {  
    conn.receiveResponseEntity(response);
}

其中,在預設情況下,conn 的實現類是 DefaultBHttpClientConnection

由於負責重定向的 location 屬性位於響應頭中,所以我們進入到 DefaultBHttpClientConnectionreceiveResponseHeader() 方法,看看裡面有什麼門道:

public HttpResponse receiveResponseHeader() throws HttpException, IOException {
    this.ensureOpen();
    HttpResponse response = (HttpResponse)this.responseParser.parse();
    this.onResponseReceived(response);
    if(response.getStatusLine().getStatusCode() >= 200) {
        this.incrementResponseCount();
    }

    return response;
}

結果發現在這裡還是沒法看到響應頭的具體獲取過程,但是發現了 responseParser 的存在。經過跟蹤,我們發現 responseParserparse() 方法是由抽象類 AbstractMessageParser 實現的:

public T parse() throws IOException, HttpException {
    int st = this.state;
    switch(st) {
    case 0:
        try {
            this.message = this.parseHead(this.sessionBuffer);
        } catch (ParseException var4) {
            throw new ProtocolException(var4.getMessage(), var4);
        }

        this.state = 1;
    case 1:
        Header[] headers = parseHeaders(this.sessionBuffer, this.messageConstraints.getMaxHeaderCount(), this.messageConstraints.getMaxLineLength(), this.lineParser, this.headerLines);
        this.message.setHeaders(headers);
        HttpMessage result = this.message;
        this.message = null;
        this.headerLines.clear();
        this.state = 0;
        return result;
    default:
        throw new IllegalStateException("Inconsistent parser state");
    }
}

注意到程式碼中的 Header[] 陣列,可以明顯地感覺到離目標已經非常接近了,所以我們繼續深入到 parseHeaders(...) 方法中:

public static Header[] parseHeaders(SessionInputBuffer inbuffer, int maxHeaderCount, int maxLineLen, LineParser parser, List<CharArrayBuffer> headerLines) throws HttpException, IOException {
    Args.notNull(inbuffer, "Session input buffer");
    Args.notNull(parser, "Line parser");
    Args.notNull(headerLines, "Header line list");
    CharArrayBuffer current = null;
    CharArrayBuffer previous = null;

    do {
        if(current == null) {
            current = new CharArrayBuffer(64);
        } else {
            current.clear();
        }

        int headers = inbuffer.readLine(current);
        int i;
        if(headers == -1 || current.length() < 1) {
            Header[] var12 = new Header[headerLines.size()];

            for(i = 0; i < headerLines.size(); ++i) {
                CharArrayBuffer var13 = (CharArrayBuffer)headerLines.get(i);

                try {
                    var12[i] = parser.parseHeader(var13);
                } catch (ParseException var11) {
                    throw new ProtocolException(var11.getMessage());
                }
            }

            return var12;
        }

        if((current.charAt(0) == 32 || current.charAt(0) == 9) && previous != null) {
            for(i = 0; i < current.length(); ++i) {
                char buffer = current.charAt(i);
                if(buffer != 32 && buffer != 9) {
                    break;
                }
            }

            if(maxLineLen > 0 && previous.length() + 1 + current.length() - i > maxLineLen) {
                throw new MessageConstraintException("Maximum line length limit exceeded");
            }

            previous.append(' ');
            previous.append(current, i, current.length() - i);
        } else {
            headerLines.add(current);
            previous = current;
            current = null;
        }
    } while(maxHeaderCount <= 0 || headerLines.size() < maxHeaderCount);

    throw new MessageConstraintException("Maximum header count exceeded");
}

這個方法顯得比較長,但是我們需要關注的只有兩個變數,分別是 inbuffercurrent。前者是 SessionInputBuffer 物件,物件中 instream 變數儲存的資料實際上就是我們的響應流;後者實際上就是一個字元陣列。

看到這裡,我們基本可以確定,亂碼出現在響應流轉換為字元陣列的過程中

我們進入到 SessionInputBuffer 實現類 SessionInputBufferImpl 中,發現該類有一個 CharsetDecoder 變數,跟蹤發現預設情況下該變數為空。這時候,我們只需按照文章開頭的方法,為該實現類賦予一個封裝了 UTF-8 編碼格式的 CharsetDecoder 例項,就可以解決中文亂碼的問題。