博客主頁html
Android WebView場景下接入HttpDns的參考方案,提供的相關代碼也爲參考代碼,非線上生產環境正式代碼。因爲Android生態碎片化嚴重,各廠商也進行了不一樣程度的定製,建議您灰度接入,並監控線上異常。java
阿里雲HTTPDNS是避免dns劫持的一種有效手段,在許多特殊場景如HTTPS/SNI、okhttp等都有最佳實踐,但在webview場景下卻一直沒完美的解決方案。android
但這並不表明在WebView場景下咱們徹底沒法使用HTTPDNS,事實上不少場景依然能夠經過HTTPDNS進行IP直連,本文旨在給出Android端HTTPDNS+WebView最佳實踐供用戶參考。git
void setWebViewClient(WebViewClient client)
WebView提供了 setWebViewClient 接口對網絡請求進行攔截,經過重載WebViewClient中的shouldInterceptRequest方法,咱們能夠攔截到全部的網絡請求:github
public class WebViewClient { // API < 21 @Deprecated public WebResourceResponse shouldInterceptRequest(WebView view, String url) { return null; } // API >= 21 public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { return shouldInterceptRequest(view, request.getUrl().toString()); } }
shouldInterceptRequest有兩個版本:web
當API < 21時,shouldInterceptRequest方法的版本爲:segmentfault
public WebResourceResponse shouldInterceptRequest(WebView view, String url)
此時僅能獲取到請求URL,請求方法、頭部信息以及body等均沒法獲取,強行攔截該請求可能沒法能到正確響應。因此當API < 21時,不對請求進行攔截:瀏覽器
public WebResourceResponse shouldInterceptRequest(WebView view, String url) { return super.shouldInterceptRequest(view, url); }
當API >= 21時,shouldInterceptRequest提供了新版:cookie
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request)
其中WebResourceRequest結構爲:網絡
public interface WebResourceRequest { Uri getUrl(); // 請求URL boolean isForMainFrame(); // 是否由主MainFrame發出的請求 boolean isRedirect(); boolean hasGesture(); // 是不是由某種行爲(如點擊)觸發 String getMethod(); // 請求方法 Map<String, String> getRequestHeaders(); // 頭部信息 }
能夠看到,在API >= 21時,在攔截請求時,能夠獲取到以下信息:
因爲WebResourceRequest並無提供請求body信息,因此只能攔截GET請求,不能攔截POST:
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { String scheme = request.getUrl().getScheme().trim(); String method = request.getMethod(); Map<String, String> headerFields = request.getRequestHeaders(); String url = request.getUrl().toString(); Log.e(TAG, "url:" + url); // 沒法攔截body,攔截方案只能正常處理不帶body的請求; if ((scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) && method.equalsIgnoreCase("get")) { // ... } else { return super.shouldInterceptRequest(view, request); } }
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { // ... URL url = new URL(request.getUrl().toString()); conn = (HttpURLConnection) url.openConnection(); // 接口獲取IP String ip = httpdns.getIpByHostAsync(url.getHost()); if (ip != null) { // 經過HTTPDNS獲取IP成功,進行URL替換和HOST頭設置 Log.d(TAG, "Get IP: " + ip + " for host: " + url.getHost() + " from HTTPDNS successfully!"); String newUrl = path.replaceFirst(url.getHost(), ip); conn = (HttpURLConnection) new URL(newUrl).openConnection(); if (headers != null) { for (Map.Entry<String, String> field : headers.entrySet()) { conn.setRequestProperty(field.getKey(), field.getValue()); } } // 設置HTTP請求頭Host域 conn.setRequestProperty("Host", url.getHost()); } }
若是攔截到的請求是HTTPS請求,須要進行證書校驗:
if (conn instanceof HttpsURLConnection) { final HttpsURLConnection httpsURLConnection = (HttpsURLConnection)conn; // https場景,證書校驗 httpsURLConnection.setHostnameVerifier(new HostnameVerifier() { @Override public boolean verify(String hostname, SSLSession session) { String host = httpsURLConnection.getRequestProperty("Host"); if (null == host) { host = httpsURLConnection.getURL().getHost(); } return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session); } }); }
若是請求涉及到SNI場景,須要自定義SSLSocket,對SNI場景不熟悉的用戶能夠參考SNI:
WebviewTlsSniSocketFactory sslSocketFactory = new WebviewTlsSniSocketFactory((HttpsURLConnection) conn); // sni場景,建立SSLScocket httpsURLConnection.setSSLSocketFactory(sslSocketFactory); class WebviewTlsSniSocketFactory extends SSLSocketFactory { private final String TAG = WebviewTlsSniSocketFactory.class.getSimpleName(); HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); private HttpsURLConnection conn; public WebviewTlsSniSocketFactory(HttpsURLConnection conn) { this.conn = conn; } @Override public Socket createSocket() throws IOException { return null; } @Override public Socket createSocket(String host, int port) throws IOException, UnknownHostException { return null; } @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException { return null; } @Override public Socket createSocket(InetAddress host, int port) throws IOException { return null; } @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return null; } // TLS layer @Override public String[] getDefaultCipherSuites() { return new String[0]; } @Override public String[] getSupportedCipherSuites() { return new String[0]; } @Override public Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException { String peerHost = this.conn.getRequestProperty("Host"); if (peerHost == null) peerHost = host; Log.i(TAG, "customized createSocket. host: " + peerHost); InetAddress address = plainSocket.getInetAddress(); if (autoClose) { // we don't need the plainSocket plainSocket.close(); } // create and connect SSL socket, but don't do hostname/certificate verification yet SSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0); SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port); // enable TLSv1.1/1.2 if available ssl.setEnabledProtocols(ssl.getSupportedProtocols()); // set up SNI before the handshake if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { Log.i(TAG, "Setting SNI hostname"); sslSocketFactory.setHostname(ssl, peerHost); } else { Log.d(TAG, "No documented SNI support on Android <4.2, trying with reflection"); try { java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class); setHostnameMethod.invoke(ssl, peerHost); } catch (Exception e) { Log.w(TAG, "SNI not useable", e); } } // verify hostname and certificate SSLSession session = ssl.getSession(); if (!hostnameVerifier.verify(peerHost, session)) throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost); Log.i(TAG, "Established " + session.getProtocol() + " connection with " + session.getPeerHost() + " using " + session.getCipherSuite()); return ssl; } }
若是服務端返回重定向,此時須要判斷原有請求中是否含有cookie:
private boolean needRedirect(int code) { return code >= 300 && code < 400; } /** * header中是否含有cookie * @param headers */ private boolean containCookie(Map<String, String> headers) { for (Map.Entry<String, String> headerField : headers.entrySet()) { if (headerField.getKey().contains("Cookie")) { return true; } } return false; } int code = conn.getResponseCode();// Network block if (needRedirect(code)) { // 原有報頭中含有cookie,放棄攔截 if (containCookie(headers)) { return null; } // 臨時重定向和永久重定向location的大小寫有區分 String location = conn.getHeaderField("Location"); if (location == null) { location = conn.getHeaderField("location"); } if (location != null) { if (!(location.startsWith("http://") || location .startsWith("https://"))) { //某些時候會省略host,只返回後面的path,因此須要補全url URL originalUrl = new URL(path); location = originalUrl.getProtocol() + "://" + originalUrl.getHost() + location; } Log.e(TAG, "code:" + code + "; location:" + location + "; path" + path); // 從新發起二次請求 return recursiveRequest(location, headers, path); } else { // 沒法獲取location信息,讓瀏覽器獲取 return null; } } else { // redirect finish. Log.e(TAG, "redirect finish"); return conn; }
若是攔截網絡請求,須要返回一個WebResourceResponse:
public WebResourceResponse(String mimeType, String encoding, InputStream data)
建立WebResourceResponse對象須要提供:
其中請求輸入流能夠經過URLConnection.getInputStream()獲取到,而MIME類型和encoding能夠經過請求的ContentType獲取到,即經過URLConnection.getContentType(),如:
text/html;charset=utf-8
但並非全部的請求都能獲得完整的contentType信息,此時能夠參考以下策略
// 注*:對於POST請求的Body數據,WebResourceRequest接口中並無提供,這裏沒法處理 String contentType = connection.getContentType(); String mime = getMime(contentType); String charset = getCharset(contentType); HttpURLConnection httpURLConnection = (HttpURLConnection)connection; int statusCode = httpURLConnection.getResponseCode(); String response = httpURLConnection.getResponseMessage(); Map<String, List<String>> headers = httpURLConnection.getHeaderFields(); Set<String> headerKeySet = headers.keySet(); Log.e(TAG, "code:" + httpURLConnection.getResponseCode()); Log.e(TAG, "mime:" + mime + "; charset:" + charset); // 無mime類型的請求不攔截 if (TextUtils.isEmpty(mime)) { Log.e(TAG, "no MIME"); return super.shouldInterceptRequest(view, request); } else { // 二進制資源無需編碼信息 if (!TextUtils.isEmpty(charset) || (isBinaryRes(mime))) { WebResourceResponse resourceResponse = new WebResourceResponse(mime, charset, httpURLConnection.getInputStream()); resourceResponse.setStatusCodeAndReasonPhrase(statusCode, response); Map<String, String> responseHeader = new HashMap<String, String>(); for (String key: headerKeySet) { // HttpUrlConnection可能包含key爲null的報頭,指向該http請求狀態碼 responseHeader.put(key, httpURLConnection.getHeaderField(key)); } resourceResponse.setResponseHeaders(responseHeader); return resourceResponse; } else { Log.e(TAG, "non binary resource for " + mime); return super.shouldInterceptRequest(view, request); } } /** * 從contentType中獲取MIME類型 * @param contentType * @return */ private String getMime(String contentType) { if (contentType == null) { return null; } return contentType.split(";")[0]; } /** * 從contentType中獲取編碼信息 * @param contentType * @return */ private String getCharset(String contentType) { if (contentType == null) { return null; } String[] fields = contentType.split(";"); if (fields.length <= 1) { return null; } String charset = fields[1]; if (!charset.contains("=")) { return null; } charset = charset.substring(charset.indexOf("=") + 1); return charset; } /** * 是不是二進制資源,二進制資源能夠不須要編碼信息 * @param mime * @return */ private boolean isBinaryRes(String mime) { if (mime.startsWith("image") || mime.startsWith("audio") || mime.startsWith("video")) { return true; } else { return false; } }
前提條件:
可用場景:
HTTPDNS+WebView最佳實踐完整代碼請參考:GithubDemo
若是個人文章對您有幫助,不妨點個贊鼓勵一下(^_^)