URL編碼問題
問題描述
使用 Tomcat 開發一個 Java Web 項目的時候,相信大多數人都遇到過url出現中文亂碼的情況,絕大多數人為了避免出現這種問題,所以設計 url 一般都會盡量設計成都是英文字符。但總避免一種情況就是當你的系統中擁有搜索功能時,你無法預料到用戶輸入的是中文還是其他符號,此時還是會存在中文亂碼的問題,那么為什么會產生中文亂碼問題,下面給大家詳細解析。
什么是 URL
URL 叫統一資源定位符,也可以說成我們平時在地址欄輸入的路徑。通過這個url(路徑)我們可以發送請求給服務器,服務器尋找具體的服務器組件然后再向用戶提供服務。
什么是 URL 編碼
url 編碼簡單來說就是對 url 的字符 按照一定的編碼規則進行轉換。
為什么需要 URL 編碼
人類的語言太多,不可能用一個通用的格式去表示這么多的字符,所以則需要編碼,按照不同的規則來表示不同的字符。
那么現在進入正題
GET 請求 和 POST請求是如何進行url編碼的
對於 GET 請求,我們都知道請求參數是直接跟在url后面,當 url 組裝好之后瀏覽器會對其進行 encode 操作。此過程主要是對 url 中一些特殊字符進行編碼以轉換成 可以用 多個 ASCII 碼字符表示。具體會以什么樣的編碼格式是由瀏覽器決定的(具體的規則可以參見 http://www.ruanyifeng.com/blog/2010/02/url_encoding.html )
進行URL encode之后,瀏覽器就會以iso-8859-1的編碼方式轉換為二進制隨着請求頭一起發送出去。
當請求發送到服務器之后,Tomcat 接收到這個請求,會對請求進行解析。具體的解析過程就不在這里詳解,可以去參看一下 Tomcat 的源碼,但在使用請求參數有中文時,我相信肯定很多人都會出現 404 的情況
下面將分別以Tomcat7、Tomcat8兩種版本來說明這其中出現404的原因
關於URL含有中文導致404
第一種情況:URL 含有中文,出現404
當前測試的 Servlet
直接訪問的結果
從測試圖可以看出當 URL 含有中文時,直接在瀏覽器訪問會出現 404,瀏覽器已經正確的發出了 HTTP 請求,所以這可以排除是瀏覽器的問題,那么問題應該是出現在服務器端,那么這個問題就應該從 Tomcat 如何解析請求着手查起。
Tomcat 解析請求時通過調用 AbstractInputBuffer.parseRequestLine 方法,這是一個抽象類,一般都將會委托org.apache.coyote.http11.InternalInputBuffer 子類來執行,那么我現在來看看 parseRequestLine 方法是如何執行的
public boolean parseRequestLine(boolean useAvailableDataOnly) throws IOException {
//前面省略,主要都是通過流的讀取字節的操作解析請求的內容
//
// Reading the URI,這段代碼主要是從流中讀取 URL 的字節到buf中,再將buf的字節set進請求中
//
boolean eol = false;
while (!space) {
// Read new bytes if needed
if (pos >= lastValid) {
if (!fill())
throw new EOFException(sm.getString("iib.eof.error"));
}
// Spec says single SP but it also says be tolerant of HT
if (buf[pos] == Constants.SP || buf[pos] == Constants.HT) {
space = true;
end = pos;
} else if ((buf[pos] == Constants.CR)
|| (buf[pos] == Constants.LF)) {
// HTTP/0.9 style request
eol = true;
space = true;
end = pos;
} else if ((buf[pos] == Constants.QUESTION)
&& (questionPos == -1)) {
questionPos = pos;
}
pos++;
}
request.unparsedURI().setBytes(buf, start, end - start);
if (questionPos >= 0) {
request.queryString().setBytes(buf, questionPos + 1,
end - questionPos - 1);
request.requestURI().setBytes(buf, start, questionPos - start);
} else {
request.requestURI().setBytes(buf, start, end - start);
}
//后面一樣省略,都是對請求流中的內容讀取字節出來,set到請求對應的內容塊
return true;
}
因為請求有很多內容,這個方法只是按照內容塊將對應的字節 set 進請求,接下來 Tomcat 會基於請求來進一步解析,下一步是調用 AbstractProcessor.prepareRequest 方法,該方法主要是檢查請求的內容是否合法,若都合法,則會將 request、response委托給 adapter 去調用service方法
public void service(org.apache.coyote.Request req,
org.apache.coyote.Response res)
throws Exception {
//省略代碼
//service會調用該方法去解析請求,並對url進行解碼
boolean postParseSuccess = postParseRequest(req, request, res, response);
//后面省略
}
protected boolean postParseRequest(org.apache.coyote.Request req,
Request request,
org.apache.coyote.Response res,
Response response)
throws Exception {
//省略
// Copy the raw URI to the decodedURI,解碼從這里開始
// 這一步只是將未解碼的 URL 字節復制給 decodedURL
MessageBytes decodedURI = req.decodedURI();
decodedURI.duplicate(req.requestURI());
// Parse the path parameters. This will:
// - strip out the path parameters
// - convert the decodedURI to bytes
parsePathParameters(req, request);
// 這一步是將 URL 中含有%的16進制數據合並
// URI decoding
// %xx decoding of the URL
try {
req.getURLDecoder().convert(decodedURI, false);
} catch (IOException ioe) {
res.setStatus(400);
res.setMessage("Invalid URI: " + ioe.getMessage());
connector.getService().getContainer().logAccess(
request, response, 0, true);
return false;
}
// 真正對 URL 解碼操作在這一步
convertURI(decodedURI, request);
protected void convertURI(MessageBytes uri, Request request)
throws Exception {
ByteChunk bc = uri.getByteChunk();
int length = bc.getLength();
CharChunk cc = uri.getCharChunk();
cc.allocate(length, -1);
// 這一步是獲取解碼使用編碼格式,從這里可以看出編碼格式與 connector 有關
// 在默認情況下,如果沒有配置Encoding,則為 null
String enc = connector.getURIEncoding();
if (enc != null) {
//根據編碼格式來對 URL 進行解碼
}
// 所以當我們沒有配置時,會直接跳下去執行,以 ISO-8859-1的編碼格式來解碼 URL
// Default encoding: fast conversion for ISO-8859-1
byte[] bbuf = bc.getBuffer();
char[] cbuf = cc.getBuffer();
int start = bc.getStart();
for (int i = 0; i < length; i++) {
cbuf[i] = (char) (bbuf[i + start] & 0xff);
}
uri.setChars(cbuf, 0, length);
}
在Tomcat 7 里面,沒有配置 connector 的編碼,它會默認使用 ISO-8859-1 的編碼格式來解碼,所以該 URL 最后解碼的結果是
可以看出解碼后的 URL 出現了中文亂碼,所以最后因為沒有匹配到對應的 Servlet ,所以出現404
那么當我們在 Tomcat 的配置文件配置編碼格式之后,再使用同樣的 URL 去訪問,這時就能成功訪問了
URL 解碼結果
測試結果
問題來了
當我們使用 Tomcat 8的時候,不管我們是否有設置 connector 的編碼,當我們使用含有中文 URL 去訪問資源,均會出現404的情況
注:Tomcat 8的默認編碼是 UTF-8,而Tomcat 7 的默認編碼是ISO-8859-1
那么既然Tomcat 8是以 UTF-8 進行解碼的,所以 URL 能夠正確解碼成功,不會出現 URL 亂碼,那么問題是出現在哪里呢?
我們知道請求最終會委托給一個請求包裝對象,如果找不到,那么就會訪問失敗,所以現在從這里請求映射開始着手找原因。
Tomcat 匹配請求的 Mapper 有多種策略,一般是使用全名匹配
- 全名匹配:根據請求的全路徑來設置對應 wrappers 對象
匹配方法如下
private final void internalMapExactWrapper
(Wrapper[] wrappers, CharChunk path, MappingData mappingData) {
Wrapper wrapper = exactFind(wrappers, path);
if (wrapper != null) {
mappingData.requestPath.setString(wrapper.name);
mappingData.wrapper = wrapper.object;
if (path.equals("/")) {
// Special handling for Context Root mapped servlet
mappingData.pathInfo.setString("/");
mappingData.wrapperPath.setString("");
// This seems wrong but it is what the spec says...
mappingData.contextPath.setString("");
} else {
mappingData.wrapperPath.setString(wrapper.name);
}
}
}
在 Tomcat 7 下 wrappers 對象集的內存快照
可以看到 wrappers 對象存在我們要訪問的資源,所以使用Tomcat 7 我們可以最終訪問到目標資源
在 Tomcat 8 下,wrapper 對象的內存快照
可以看到Mapper 對象的 name 出現亂碼
所以之所以會造成這種原因是因為不同版本的 Tomcat 在生成 Servlet 對應的 Mapper對象時,解析路徑使用的編碼格式不同,具體編碼可以去查看 Tomcat 如何解析 Servlet。
最后總結:
開發 Java Web 項目的時候,盡量避免設計含有中文字符的 URL,並且統一開發環境,比如Tomcat 版本。因為可能有些bug或問題出現原因是源於版本的不同,與自己的源程序邏輯無關,一旦出現這種問題,要找出問題的原因是需要花費很多時間的。
關於請求參數有中文亂碼問題
在 Web 開發中,我們通常會有許多帶有請求參數的請求,一般來說我們需要調用 request.setCharacterEncoding(“utf-8”); 方法來設置解析參數的編碼,但是一般情況下,該方法只對於 Post請求有用,而對於 Get 請求獲取參數仍然會出現亂碼。
測試的 Servelt
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
request.setCharacterEncoding("utf-8");
response.setCharacterEncoding("utf-8");
String name = request.getParameter("name");
System.out.println(name);
request.getRequestDispatcher("Test.jsp").forward(request, response);
}
測試結果
可以看到即使設置了編碼,但是請求參數仍然是亂碼。
那么 Tomcat 是如何解析請求參數的呢?
Tomcat 源碼如下
protected void parseParameters(){
//以上代碼省略
//獲取我們設置的編碼
String enc = getCharacterEncoding();
boolean useBodyEncodingForURI = connector.getUseBodyEncodingForURI();
if (enc != null) {
parameters.setEncoding(enc);
if (useBodyEncodingForURI) {
parameters.setQueryStringEncoding(enc);
}
} else {
parameters.setEncoding(org.apache.coyote.Constants.DEFAULT_CHARACTER_ENCODING);
if (useBodyEncodingForURI) {
parameters.setQueryStringEncoding
(org.apache.coyote.Constants.DEFAULT_CHARACTER_ENCODING);
}
}
parameters.handleQueryParameters();
}
public void handleQueryParameters() {
if( didQueryParameters ) {
return;
}
didQueryParameters=true;
if( queryMB==null || queryMB.isNull() ) {
return;
}
if(log.isDebugEnabled()) {
log.debug("Decoding query " + decodedQuery + " " +
queryStringEncoding);
}
try {
decodedQuery.duplicate( queryMB );
} catch (IOException e) {
// Can't happen, as decodedQuery can't overflow
e.printStackTrace();
}
// 解析 get 請求的參數是通過 parameter里面的 queryStringEncoding 來解碼的
processParameters( decodedQuery, queryStringEncoding );
}
從源碼可以看出 Tomcat 通過 String enc = getCharacterEncoding(); 來獲取我們設置的編碼,當前設置為 utf-8,但是當useBodyEncodingForURI 為 false 時,它只會講 enc 的值賦值給 encoding 而不會賦值給 queryStringEncoding。
在解析參數時,對於 Post 請求,Tomcat 使用 encoding 來解碼;對於 get 請求,Tomcat 使用 queryStringEncoding 來解析參數,因為此時 useBodyEncodingForURI 為 false 時,Tomcat 使用默認編碼來解析,Tomcat 7的默認編碼是 ISO-8859-1,所以解析之后參數出現亂碼;Tomcat 8 默認編碼是 UTF-8,因此解析不會出現亂碼。
對於使用 Tomcat 7 出現請求參數亂碼的解決方法:
- 在 Tomcat 的 server,xml 的配置文件中,對於 connector 的配置中,加上如下的配置,那么對於 get 請求,也能夠通過request.setCharacterEncoding(“utf-8”); 來設定編碼格式
<Connector connectionTimeout="20000" port="8080" protocol="HTTP/1.1" redirectPort="8443"
URIEncoding="UTF-8" useBodyEncodingForURI="true"/>
- 創建一個請求包裝對象,重寫請求的獲取參數方法,並通過過濾器將請求委托給包裝對象,具體代碼如下:
public class EncodingRequest extends HttpServletRequestWrapper {
private HttpServletRequest request;
private boolean hasEncode = false;
public EncodingRequest(HttpServletRequest request) {
super(request);
this.request = request;
}
@Override
public String getParameter(String name) {
String[] values = getParameterValues(name);
if (values == null) {
return null;
}
return values[0];
}
@Override
public String[] getParameterValues(String name) {
Map<String, String[]> parameterMap = getParameterMap();
String[] values = parameterMap.get(name);
return values;
}
@Override
public Map getParameterMap() {
Map<String, String[]> parameterMap = request.getParameterMap();
String method = request.getMethod();
if (method.equalsIgnoreCase("post")) {
return parameterMap;
}
if (!hasEncode) {
Set<String> keys = parameterMap.keySet();
for (String key : keys) {
String[] values = parameterMap.get(key);
if (values == null) {
continue;
}
for (int i = 0; i < values.length; i++) {
String value = values[i];
try {
value = new String(value.getBytes("ISO-8859-1"),
"utf-8");
values[i] = value;
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
hasEncode = true;
}
}
return parameterMap;
}
}
本文只是個人的測試的結果,如有錯誤,請提出,互相交流。