Android Webview + HttpDns最佳实践
1. 说明
Android WebView场景下接入HttpDns的参考方案,提供的相关代码也为参考代码,非线上生产环境正式代码。由于Android生态碎片化严重,各厂商也进行了不同程度的定制,建议您灰度接入,并监控线上异常。
2. 背景
阿里云HTTPDNS是避免dns劫持的一种有效手段,在许多特殊场景如HTTPS/SNI、okhttp等都有最佳实践,但在webview场景下却一直没完美的解决方案。
但这并不代表在WebView场景下我们完全无法使用HTTPDNS,事实上很多场景依然可以通过HTTPDNS进行IP直连,本文旨在给出Android端HTTPDNS+WebView最佳实践供用户参考。
3. 接口
void setWebViewClient(WebViewClient client)
WebView提供了 setWebViewClient 接口对网络请求进行拦截,通过重载WebViewClient中的shouldInterceptRequest方法,我们可以拦截到所有的网络请求:
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有两个版本:
- 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方法的版本为:
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提供了新版:
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 总结
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
如果我的文章对您有帮助,不妨点个赞鼓励一下(^_^)
原文地址:https://segmentfault.com/a/1190000021508905
相关推荐
-
Kotlin + Spring Boot (Gradle) + React.js (Nowa) 集成 Web 开发 Java基础
2019-8-22
-
每个Java 程序员都应该要掌握的 Nginx 实战应用 Java基础
2019-9-9
-
[Kotlin] 操作符重载及中缀调用 Java基础
2019-8-22
-
Java执行JavaScript脚本破解encodeInp()加密 Java基础
2019-5-5
-
Android P(9.0) 行为变更 适配WebView Java基础
2019-8-22
-
全文搜索引擎 ElasticSearch 还是 Solr? Java基础
2019-6-14
-
基于角色的权限控制在springMVC框架中的实现 Java基础
2020-5-30
-
Mysql中事务ACID实现原理 Java基础
2019-5-10
-
Spring Cloud 学习推荐 Java基础
2020-6-14
-
Java 在PDF中添加条形码 Java基础
2020-6-13