You need to enable JavaScript to run this app.
导航
HttpURLConnection
最近更新时间:2024.03.29 17:41:10首次发布时间:2023.03.22 20:49:16

方案描述

如果您的 app 使用 HttpURLConnection 发送和接收网络请求,您需要按照以下步骤手动将请求改写成 IP 直连请求。

  1. 手动将 URL 请求改写成 IP 直连请求。同时,您需要为请求添加 header。
  2. 如果您的请求带有 SNI 信息,您需要重新设置 SNI。
  3. 处理 Cookie 和重定向。
  4. 发送请求。

说明

HTTPS 请求使用 SSL/TLS 协议。SNI(Server Name Indication) 是 SSL/TLS 协议的扩展,在 RFC 6066 中定义。SNI 可以解决一个服务端 IP 地址对应多个主机名时,SSL 证书无法正常认证的问题。发送 SNI 请求时,您需要通过 SNI 将服务端的主机名传递到 SSL/TLS 握手进程。这样,SSL/TLS 握手进程可以生成正确的 SSL/TLS 证书。

注意

App 开启代理时,如果代理无法读取 Host header,您无法将请求改写成 IP 直连请求,这样会导致请求无法正常发送。因此,我们建议您针对这种情况增加一层异常处理逻辑。如果请求无法改写成 IP 直连请求,您可以直接通过 HttpURLConnection 发送请求。

前提条件

警告

对于没有在控制台添加的域名,HTTPDNS 服务端的解析会失败,您只能获得 Local DNS 服务器的解析结果。参见 添加需要解析的域名了解如何添加域名。

实现步骤

注意

为了演示需要,示例代码仅提供了集成方案中最基本的逻辑。移动解析 HTTPDNS 仅保证 HTTPDNS SDK 本身的 可用性。在生产环境下,您需要自行保证集成方案的健壮性。

  1. 通过 getHttpDnsResult 方法获取目标域名对应的 IP 地址。然后,您需要将 URL 请求改写为 IP 直连请求。

    说明

    SDK 提供以下类型的 getHttpDnsResult 方法。示例代码中使用了 getHttpDnsResultForHostSyncBlock 方法。该方法会阻塞后续代码的运行,直到 SDK 获取到域名解析结果。您也可以根据需求使用其他类型的 getHttpDnsResult 方法。

    // 调用 getHttpDnsResultForHostSyncBlock 获取目标域名对应的 IP 地址
    long beforeResolve = System.currentTimeMillis();
    DnsResult dnsResult = HttpDns.getService().getHttpDnsResultForHostSyncBlock(url.getHost());
    long cost = System.currentTimeMillis() - beforeResolve;
    if (dnsResult != null && (!dnsResult.ipv4List.isEmpty() || !dnsResult.ipv6List.isEmpty())) {
        connection = constructHttpdnsBasedConnection(url, dnsResult);
        setCookieHeader(connection, cookieMap);
    } else {
               ......
    }
    

    获取目标域名对应的 IP 地址之后,您可以将 URL 中的域名改写为目标域名对应的 IP 地址。

    // 将 URL中的域名改写为 IP 地址。
    String requestip = null;
    String newUrl = rawUrl.toString();
    if (!dnsResult.ipv6List.isEmpty()) {
        requestip = dnsResult.ipv6List.get(0);
        requestip = "[" + requestip + "]";
    } else if (!dnsResult.ipv4List.isEmpty()) {
        requestip = dnsResult.ipv4List.get(0);
    }
    if (!TextUtils.isEmpty(requestip)) {
        newUrl = newUrl.replaceFirst(rawUrl.getHost(), requestip);
        Log.d(TAG, "request url is " + newUrl + ", host is " + rawUrl.getHost());
    }
    

    同时,您需要调用 setRequestProperty为请求添加 header。这样可以把域名通过 host header 传递给接收请求的服务端。

    connection.setRequestProperty("Host", rawUrl.getHost());
    
  2. 如果您的请求带有 SNI 信息,您需要重新设置 SNI,并进行主机名验证。您可以按照以下步骤设置 SNI:
    由于本集成方案把 URL 中的域名更改成了 IP 地址,HTTPS 请求中的 SNI(Server Name Indication)信息是不正确的。因此,您需要重新设置 SNI。

    1. 继承 SSLSocketFactory 类。

      public class SniSocketFactory extends SSLSocketFactory {
      ...
      }
      
    2. 重写 createSocket 方法。

      1. 设置 SNI。按照不同的 Android API level,选择不同的设置方式:

        • 对于 Android API level 24 或以上版本,创建 SNIServerName 对象和 SNIHostName 对象为 TLS 握手设置 SNI。同时,您需要向 SNIHostName 对象传入正确的主机名(HostName)。
        • 对于 Android API level 17 或以上版本,使用 SSLCertificateSocketFactory 类的 setHostname 方法设置 SNI。
        • 对于 Android API level 17 以下的版本,尝试使用 java.lang.reflect.Method 类设置 SNI。在这里,您需要增加异常处理逻辑,处理 SNI 失败时的情况。
      2. 使用 HostnameVerifier 接口在 TLS 握手过程中验证主机名。如果验证成功,您可以返回 HttpsURLConnection 对象用于后续网络连接。

        @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;
            InetAddress address = plainSocket.getInetAddress();
            if (autoClose) {
                plainSocket.close();
            }
            // create and connect SSL socket, but don't do hostname/certificate verification yet
            SSLSocketFactory sslSocketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
            SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(address, port);
            // enable TLSv1.1/1.2 if available
            sslSocket.setEnabledProtocols(sslSocket.getSupportedProtocols());
        
            // 通过 SNIServerName 类和 SNIHostName 类为 TLS 握手设置 SNI。
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                SNIHostName sniHostName = new SNIHostName(peerHost);
                SSLParameters sslParameters = sslSocket.getSSLParameters();
                List<SNIServerName> sniHostNameList = new ArrayList<>(1);
                sniHostNameList.add(sniHostName);
                sslParameters.setServerNames(sniHostNameList);
                sslSocket.setSSLParameters(sslParameters);
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                ((SSLCertificateSocketFactory) sslSocketFactory).setHostname(sslSocket, peerHost);
            } else {
                Log.d(TAG, "No documented SNI support on Android <4.2, trying with reflection");
                try {
                    java.lang.reflect.Method setHostnameMethod = sslSocket.getClass().getMethod("setHostname", String.class);
                    setHostnameMethod.invoke(sslSocket, peerHost);
                } catch (Exception e) {
                    Log.d(TAG, "SNI not useable", e);
                }
            }
            // 验证主机名
            SSLSession session = sslSocket.getSession();
            if (!hostnameVerifier.verify(peerHost, session)) {
                throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);
            }
            Log.d(TAG, "Established " + session.getProtocol() + " connection with " + session.getPeerHost() +
                    " using " + session.getCipherSuite());
            return sslSocket;
        }
        
      3. HttpURLConnection 对象配置 SNI 信息。

        if ("https".equals(rawUrl.getProtocol())) {
             HttpsURLConnection connection = (HttpsURLConnection) new URL(newUrl).openConnection();
            if (connection != null) {
                 connection.setRequestProperty("Host", rawUrl.getHost());
                 SniSocketFactory sniSocketFactory = new SniSocketFactory(connection);
                 connection.setSSLSocketFactory(sniSocketFactory);
                 ((HttpsURLConnection) connection).setHostnameVerifier(new HostnameVerifier() {
                     @Override
                     public boolean verify(String s, SSLSession sslSession) {
                         String host = connection.getRequestProperty("Host");
                         if (host == null) {
                             host = connection.getURL().getHost();
                         }
                         return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, sslSession);
                     }
                });
                 return connection;
             }
        } else {
             return (HttpURLConnection) new URL(newUrl).openConnection();
        }
        
  3. 使用 CookieManager 管理 Cookie。同时,您还要处理重定向。如果原先的请求含有 Cookie,则不进行重定向。

    // 管理 Cookie
    // 创建 HttpURlConnection 对象后,设置 CookieMap 和 CookieHeader
    // requestUrl 是改写之前的请求 URL
    Map<String, String> cookieMap = SsCookieManager.inst().getCookieMap(requestUrl, null);   
    setCookieHeader(connection, cookieMap);
    
    // 处理重定向
    if (needRedirect(code)) {
        // 原来的请求中含有 cookie,则不拦截
        if (containCookie(headers)) {
            return null;
        }
        // 得到重定向的地址
        String location = connection.getHeaderField("Location");
        if (location == null) {
            location = connection.getHeaderField("location");
        }
    
        if (location != null) {
            if (!(location.startsWith("http://") || location
                    .startsWith("https://"))) {
                // 某些时候 Location 会省略 host,只返回后面的 path,所以需要补全 url
                URL originalUrl = new URL(requestUrl);
                location = originalUrl.getProtocol() + "://"
                        + originalUrl.getHost() + location;
            }
            Log.d(TAG, "redirect code is " + code + ", and redirect url is " + location);
            return recursiveRequest(location, headers, context);
        } else {
            return null;
        }
    } 
    
    else {
       // 重定向结束
       Log.d(TAG, "redirect finish, and responsecode is " + code);
       return connection;
       }
    
  4. 使用 HttpURLConnection 对象发送请求。

    int responseCode = connection.getResponseCode();
        InputStream in = null;
        if (responseCode == 200) {
            in = connection.getInputStream();
        } else {
            in = connection.getErrorStream();
        }
    

相关信息

如果您需要了解 HTTPDNS Android SDK 的详细信息,参见 Android SDK 参考