HttpClient 4.5 重定向到中文URL出現亂碼的解決方案
一、問題描述:
遇到某個 URL A,請求時發現會重定向到某個包含了中文字元的 URL B。原以為只要 HttpClient 開啟了自動重定向的功能,下載 A 指向的頁面輕而易舉,結果卻出乎意料。HttpClient 在獲取重定向後的 URL B 時出現了中文亂碼,導致下載失敗,具體報錯資訊見下圖:
二、解決方案
問題的核心在於 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 的值就已經是亂碼了。
這時候我們可以確定,問題不是出現在 response
的 getFirstHeader(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 屬性位於響應頭中,所以我們進入到 DefaultBHttpClientConnection
的 receiveResponseHeader()
方法,看看裡面有什麼門道:
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
的存在。經過跟蹤,我們發現 responseParser
的 parse()
方法是由抽象類 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");
}
這個方法顯得比較長,但是我們需要關注的只有兩個變數,分別是 inbuffer
和 current
。前者是 SessionInputBuffer
物件,物件中 instream
變數儲存的資料實際上就是我們的響應流;後者實際上就是一個字元陣列。
看到這裡,我們基本可以確定,亂碼出現在響應流轉換為字元陣列的過程中。
我們進入到 SessionInputBuffer
實現類 SessionInputBufferImpl
中,發現該類有一個 CharsetDecoder
變數,跟蹤發現預設情況下該變數為空。這時候,我們只需按照文章開頭的方法,為該實現類賦予一個封裝了 UTF-8
編碼格式的 CharsetDecoder
例項,就可以解決中文亂碼的問題。