阿里云HTTPDNSAndroid Webview + HttpDns最佳实践

1. 说明

本文档为Android WebView场景下接入HttpDns的参考方案,提供的相关代码也为参考代码,非线上生产环境正式代码。建议您在仔细阅读本文档,进行合理评估后再进行接入。由于Android生态碎片化严重,各厂商也进行了不同程度的定制,建议您灰度接入,并监控线上异常。有问题欢迎您随时向我们反馈,方便我们及时优化。

2. 背景

阿里云HTTPDNS是避免dns劫持的一种有效手段,在许多特殊场景如HTTPS/SNI、okhttp等都有最佳实践,但在webview场景下却一直没完美的解决方案。

但这并不代表在WebView场景下我们完全无法使用HTTPDNS,事实上很多场景依然可以通过HTTPDNS进行IP直连,本文旨在给出AndroidHTTPDNS+WebView最佳实践供用户参考。

3. 接口

  1. void setWebViewClient (WebViewClient client);

WebView提供了setWebViewClient接口对网络请求进行拦截,通过重载WebViewClient中的shouldInterceptRequest方法,我们可以拦截到所有的网络请求:

  1. public class WebViewClient{
  2. // API < 21
  3. public WebResourceResponse shouldInterceptRequest(WebView view,
  4. String url) {
  5. ...
  6. }
  7. // API >= 21
  8. public WebResourceResponse shouldInterceptRequest(WebView view,
  9. WebResourceRequest request) {
  10. ...
  11. }
  12. ......
  13. }

shouldInterceptRequest有两个版本:

  • API < 21: public WebResourceResponse shouldInterceptRequest(WebView view, String url);
  • API >= 21 public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request);

4. 实践

<a name="4.1 API 4.1 API < 21

API < 21时,shouldInterceptRequest方法的版本为:

  1. public WebResourceResponse shouldInterceptRequest(WebView view, String url)

此时仅能获取到请求URL,请求方法、头部信息以及body等均无法获取,强行拦截该请求可能无法能到正确响应。所以当API < 21时,不对请求进行拦截:

  1. public WebResourceResponse shouldInterceptRequest(WebView view,
  2. String url) {
  3. return super.shouldInterceptRequest(view, url);
  4. }

= 21″ class=”reference-link”>4.2 API >= 21

API >= 21时,shouldInterceptRequest提供了新版:

  1. public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request)

其中WebResourceRequest结构为:

  1. public interface WebResourceRequest {
  2. Uri getUrl(); // 请求URL
  3. boolean isForMainFrame(); // 是否由主MainFrame发出的请求
  4. boolean hasGesture(); // 是否是由某种行为(如点击)触发
  5. String getMethod(); // 请求方法
  6. Map<String, String> getRequestHeaders(); // 头部信息
  7. }

可以看到,在API >= 21时,在拦截请求时,可以获取到如下信息:

  • 请求URL
  • 请求方法:POST, GET…
  • 请求头
4.2.1 仅拦截GET请求

由于WebResourceRequest并没有提供请求body信息,所以只能拦截GET请求,不能拦截POST:

  1. public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
  2. String scheme = request.getUrl().getScheme().trim();
  3. String method = request.getMethod();
  4. Map<String, String> headerFields = request.getRequestHeaders();
  5. // 无法拦截body,拦截方案只能正常处理不带body的请求;
  6. if ((scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))
  7. && method.equalsIgnoreCase("get")) {
  8. ......
  9. } else {
  10. return super.shouldInterceptRequest(view, reqeust);
  11. }
4.2.2 设置头部信息
  1. public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
  2. ......
  3. URL url = new URL(request.getUrl().toString());
  4. conn = (HttpURLConnection) url.openConnection();
  5. // 接口获取IP
  6. String ip = httpdns.getIpByHostAsync(url.getHost());
  7. if (ip != null) {
  8. // 通过HTTPDNS获取IP成功,进行URL替换和HOST头设置
  9. Log.d(TAG, "Get IP: " + ip + " for host: " + url.getHost() + " from HTTPDNS successfully!");
  10. String newUrl = path.replaceFirst(url.getHost(), ip);
  11. conn = (HttpURLConnection) new URL(newUrl).openConnection();
  12. // 添加原有头部信息
  13. if (headers != null) {
  14. for (Map.Entry<String, String> field : headers.entrySet()) {
  15. conn.setRequestProperty(field.getKey(), field.getValue());
  16. }
  17. }
  18. // 设置HTTP请求头Host域
  19. conn.setRequestProperty("Host", url.getHost());
  20. }
  21. }
4.2.3 HTTPS请求证书校验

如果拦截到的请求是HTTPS请求,需要进行证书校验:

  1. if (conn instanceof HttpsURLConnection) {
  2. final HttpsURLConnection httpsURLConnection = (HttpsURLConnection)conn;
  3. // https场景,证书校验
  4. httpsURLConnection.setHostnameVerifier(new HostnameVerifier() {
  5. @Override
  6. public boolean verify(String hostname, SSLSession session) {
  7. String host = httpsURLConnection.getRequestProperty("Host");
  8. if (null == host) {
  9. host = httpsURLConnection.getURL().getHost();
  10. }
  11. return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);
  12. }
  13. });
  14. }
4.2.4 SNI场景

如果请求涉及到SNI场景,需要自定义SSLSocket,对SNI场景不熟悉的用户可以参考SNI:

  1. TlsSniSocketFactory sslSocketFactory = new TlsSniSocketFactory((HttpsURLConnection) conn);
  2. // sni场景,创建SSLScocket
  3. ((HttpsURLConnection) conn).setSSLSocketFactory(sslSocketFactory);
  4. ......
  5. class TlsSniSocketFactory extends SSLSocketFactory {
  6. private final String TAG = "TlsSniSocketFactory";
  7. HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();
  8. private HttpsURLConnection conn;
  9. public TlsSniSocketFactory(HttpsURLConnection conn) {
  10. this.conn = conn;
  11. }
  12. ......
  13. @Override
  14. public Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {
  15. String peerHost = this.conn.getRequestProperty("Host");
  16. if (peerHost == null)
  17. peerHost = host;
  18. Log.i(TAG, "customized createSocket. host: " + peerHost);
  19. InetAddress address = plainSocket.getInetAddress();
  20. if (autoClose) {
  21. // we don't need the plainSocket
  22. plainSocket.close();
  23. }
  24. // create and connect SSL socket, but don't do hostname/certificate verification yet
  25. SSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);
  26. SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);
  27. // enable TLSv1.1/1.2 if available
  28. ssl.setEnabledProtocols(ssl.getSupportedProtocols());
  29. // set up SNI before the handshake
  30. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
  31. Log.i(TAG, "Setting SNI hostname");
  32. sslSocketFactory.setHostname(ssl, peerHost);
  33. } else {
  34. Log.d(TAG, "No documented SNI support on Android <4.2, trying with reflection");
  35. try {
  36. java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);
  37. setHostnameMethod.invoke(ssl, peerHost);
  38. } catch (Exception e) {
  39. Log.w(TAG, "SNI not useable", e);
  40. }
  41. }
  42. // verify hostname and certificate
  43. SSLSession session = ssl.getSession();
  44. if (!hostnameVerifier.verify(peerHost, session))
  45. throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);
  46. Log.i(TAG, "Established " + session.getProtocol() + " connection with " + session.getPeerHost() +
  47. " using " + session.getCipherSuite());
  48. return ssl;
  49. }
  50. }
4.2.5 重定向

如果服务端返回重定向,此时需要判断原有请求中是否含有cookie:

  • 如果原有请求报头含有cookie,因为cookie是以域名为粒度进行存储的,重定向后cookie会改变,且无法获取到新请求URL下的cookie,所以放弃拦截
  • 如果不含cookie,重新发起二次请求
  1. int code = conn.getResponseCode();
  2. if (code >= 300 && code < 400) {
  3. if (请求报头中含有cookie) {
  4. // 不拦截
  5. return super.shouldInterceptRequest(view, request);
  6. }
  7. //临时重定向和永久重定向location的大小写有区分
  8. String location = conn.getHeaderField("Location");
  9. if (location == null) {
  10. location = conn.getHeaderField("location");
  11. }
  12. if (!(location.startsWith("http://") || location
  13. .startsWith("https://"))) {
  14. //某些时候会省略host,只返回后面的path,所以需要补全url
  15. URL originalUrl = new URL(path);
  16. location = originalUrl.getProtocol() + "://"
  17. + originalUrl.getHost() + location;
  18. }
  19. Log.e(TAG, "code:" + code + "; location:" + location + ";path" + path);
  20. 发起二次请求
  21. } else {
  22. // redirect finish.
  23. Log.e(TAG, "redirect finish");
  24. ......
  25. }
4.2.6 MIME&Encoding

如果拦截网络请求,需要返回一个WebResourceResponse

  1. public WebResourceResponse(String mimeType, String encoding, InputStream data) ;

创建WebResourceResponse对象需要提供:

  • 请求的MIME类型
  • 请求的编码
  • 请求的输入流

其中请求输入流可以通过URLConnection.getInputStream()获取到,而MIME类型和encoding可以通过请求的ContentType获取到,即通过URLConnection.getContentType(),如:

  1. text/html;charset=utf-8

但并不是所有的请求都能得到完整的contentType信息,此时可以参考如下策略:

  1. String contentType = conn.getContentType();
  2. String mime = getMime(contentType);
  3. String charset = getCharset(contentType);
  4. // 无MIME类型的请求不拦截
  5. if (TextUtils.isEmpty(mime)) {
  6. return super.shouldInterceptRequest(view, request);
  7. } else {
  8. if (!TextUtils.isEmpty(charset)) {
  9. // 如果同时获取到MIME和charset可以直接拦截
  10. return new WebResourceResponse(mime, charset, connection.getInputStream());
  11. } else {
  12. //获取不到编码信息
  13. // 二进制资源无需编码信息,可以进行拦截
  14. if (isBinaryRes(mime)) {
  15. Log.e(TAG, "binary resource for " + mime);
  16. return new WebResourceResponse(mime, charset, connection.getInputStream());
  17. } else {
  18. // 非二进制资源需要编码信息,不拦截
  19. Log.e(TAG, "non binary resource for " + mime);
  20. return super.shouldInterceptRequest(view, request);
  21. }
  22. }
  23. }
  24. private boolean isBinaryRes(String mime) {
  25. // 可进行扩展
  26. if (mime.startsWith("image")
  27. || mime.startsWith("audio")
  28. || mime.startsWith("video")) {
  29. return true;
  30. } else {
  31. return false;
  32. }
  33. }

5 总结

综上所述,在WebView场景下的请求拦截逻辑如下所示:

webview_httpdns_process

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://www.cloudads.cn/archives/33710.html

发表评论

登录后才能评论