Android Webview + HttpDns最佳實踐

博客主頁html

1. 說明

Android WebView場景下接入HttpDns的參考方案,提供的相關代碼也爲參考代碼,非線上生產環境正式代碼。因爲Android生態碎片化嚴重,各廠商也進行了不一樣程度的定製,建議您灰度接入,並監控線上異常。java

2. 背景

阿里雲HTTPDNS是避免dns劫持的一種有效手段,在許多特殊場景如HTTPS/SNIokhttp等都有最佳實踐,但在webview場景下卻一直沒完美的解決方案。android

但這並不表明在WebView場景下咱們徹底沒法使用HTTPDNS,事實上不少場景依然能夠經過HTTPDNS進行IP直連,本文旨在給出Android端HTTPDNS+WebView最佳實踐供用戶參考。git

3. 接口

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: public WebResourceResponse shouldInterceptRequest(WebView view, String url);
  • API >= 21 public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request);

4. 實踐

4.1 API < 21

當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);
}

4.2 API >= 21

當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時,在攔截請求時,能夠獲取到以下信息:

  • 請求URL
  • 請求方法:POST, GET…
  • 請求頭
4.2.1 僅攔截GET請求

因爲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);
    }  
}
4.2.2 設置頭部信息
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());
    } 
}
4.2.3 HTTPS請求證書校驗

若是攔截到的請求是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);
        }
    });
}
4.2.4 SNI場景

若是請求涉及到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;
    }
}
4.2.5 重定向

若是服務端返回重定向,此時須要判斷原有請求中是否含有cookie:

  • 若是原有請求報頭含有cookie,由於cookie是以域名爲粒度進行存儲的,重定向後cookie會改變,且沒法獲取到新請求URL下的cookie,因此放棄攔截
  • 若是不含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;
}
4.2.6 MIME&Encoding

若是攔截網絡請求,須要返回一個WebResourceResponse:

public WebResourceResponse(String mimeType, String encoding, InputStream data)

建立WebResourceResponse對象須要提供:

  • 請求的MIME類型
  • 請求的編碼
  • 請求的輸入流

其中請求輸入流能夠經過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;
    }
}
5 總結

綜上所述,在WebView場景下的請求攔截邏輯以下所示:

5.1 【不可用場景】
  • API Level < 21的設備
  • POST請求
  • 沒法獲取到MIME類型的請求
  • 沒法獲取到編碼的非二進制文件請求
5.2【可用場景】

前提條件:

  • API Level >= 21
  • GET請求
  • 能夠獲取到MIME類型以及編碼信息請求或是能夠獲取到MIME類型的二進制文件請求

可用場景:

  • 普通HTTP請求
  • HTTPS請求
  • SNI請求
  • HTTP報頭中不含cookie的重定向請求
5.3 完整代碼

HTTPDNS+WebView最佳實踐完整代碼請參考:GithubDemo

若是個人文章對您有幫助,不妨點個贊鼓勵一下(^_^)

相關文章
相關標籤/搜索