最近公司项目用到的某个域名在北京地区出现大量无法访问的情况,用户最直观的感受就是,打开 WebView 页面时,出现了他们看不懂的提示:

WebView ERR_CONNECTION_REFUSED

其实 net::ERR_CONNECTION_REFUSED 这个提示太虚,不只是用户,我们作为开发看到的时候也不一定能马上反应过来。

技术开会后得出是 DNS 被劫持了,这得联系当地运营商解决,我们临时的解决方案就是替换域名(一般企业都应该有备用域名),重新出包给用户替换。

等运营商解决全凭运气,重新出包也是临时解决,我们应该利用备用域名考虑一套解决方案。

复现

首先要尝试复现场景,因为我不在北京,自然无法直接的复现这个问题,但我们可以思考这个问题的本质。

之前在『一文带你简单了解 Hosts』中提到,域名和 IP 有映射关系,DNS 服务器与 Hosts 一样,充当着解析这层映射的角色,也就是说,当解析出错的时候,就会产生上面的问题。

那么所谓的「解析出错」应当如何判断?

也很简单,既然域名和 IP 有映射关系,出错则意味着域名无法解析为正确的 IP,我们可以利用 Hosts 去实现这一点,具体操作参考『Android 修改 Hosts』一文。

接下来写个 WebView 加载一个网页,我这里就拿阿里云官网来写个 Demo,效果如下:

WebView 正常加载

然后,通过『IPAddress.com』查一下百度官网的 IP,修改 Hosts 文件,将阿里云官网域名映射到百度的 IP 上:

14.215.177.38   www.aliyun.com

再次运行刚刚的 Demo,可以看到页面没有加载出来:

WebView 加载地址指向其他网络 IP

这似乎跟我一开始的情况不太一样,将域名指向本地主机试试看:

127.0.0.1   www.aliyun.com

情况复现:

WebView 加载地址指向本地 IP

复现之后就能够针对性的解决了。

WebView 处理方案

测试发现,以上两种情况会分别回调 WebViewClient 中的 onReceivedSslError()onReceivedError() 接口,我们可以在这两个回调中去做切换域名的操作,来规避 DNS 被劫持产生的错误页面:

webView.setWebViewClient(new WebViewClient() {
    @Override
    public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
        super.onReceivedSslError(view, handler, error);
        // 切换域名
    }
    
    @Override
    public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
        super.onReceivedError(view, errorCode, description, failingUrl);
        // 切换域名
    }

    @Override
    public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
        super.onReceivedError(view, request, error);
        // 切换域名
    }
});

需要注意的是,尽管页面有提示 net::ERR_CONNECTION_REFUSED 的内容,但不要尝试抓取相应的描述内容去进行处理,因为不同的错误可能会产生不同的描述,你无法预测到所有的情况,还是在回调中统一处理比较好。

网络请求处理方案

大多数项目中使用的都是同一套域名,这也就意味着,WebView 不能正常打开网页的情况下,同域名的普通网络请求也会失败。

网络请求下判断劫持似乎有点麻烦,因为它不像 WebView 那样直接走对应的回调,而且每个网络请求框架的封装也不尽相同。

域名被劫持时进行网络请求,得到的响应理论上是为空的,但假如指向的 IP 拥有相同的目录也可能出现不为空的情况。

如果尝试根据响应的 HTTP CODE 判断,网络请求框架一般会返回 0,当然也会有返回如 404 或者 500 的,这种方法更加不靠谱。

而且当域名被劫持时,意味着该域名下的所有接口都不能正常访问,假若每个接口都做重复判断,会造成代码冗余以及耗时增加。

我们最终的处理方案是,单独提供一个判断域名是否正常的接口,在初始化阶段调用,如果出现问题就替换为备用域名,只需做一次判断操作,后续请求时直接将接口切到备用域名中。

代码大致如下:

public enum HostChecker {

    INSTANCE;
    
    private int retryIndex = -1;
    
    private String HOST_NAME = BASE_DOMAIN;
    
    public void checkHost() {
        String[] domains = DomainConfig.getDomains();
        if (domains == null || domains.length <= 0) {
            return;
        }
        checkHostConnection(domains);
    }
    
    public String replaceHost(String url) {
        if (url.contains(BASE_DOMAIN)) {
            return url.replace(BASE_DOMAIN, HOST_NAME);
        }
    }
    
    private void checkHostConnection(final String[] domains) {
        RequestManager.checkHost(retryIndex, new RequestCallback() {
            @Override
            public void onRequestSuccess(String content) {
                try {
                    JSONObject jsonObject = new JSONObject(content);
                    if (jsonObject.optBoolean("status")) {
                        HOST_NAME = retryIndex == -1 ? BASE_DOMAIN : domains[retryIndex];
                    } else {
                        retryIndex++;
                        checkHostConnection(domains);
                    }
                } catch (JSONException e) {
                    retryIndex++;
                    checkHostConnection(domains);
                }
            }
    
            @Override
            public void onRequestError(String error) {
                retryIndex++;
                checkHostConnection(domains);
            }
    
            @Override
            public void onRequestTimeout(String msg) {
                retryIndex++;
                checkHostConnection(domains);
            }
        });
    }
}

封装一个单例的检查器,暴露两个方法,其中 checkHost() 在初始化的时候调用,用于判断域名是否正常运作,如果不能,则替换为可用的备用域名,replaceHost() 在网络请求的时候调用,这里无需再做判断,任何请求的接口都会切为可用的域名。

public class RequestManager {

    ...
    
    public void doRequest(final String requestUrl, final RequestParams params, final RequestCallback callback) {
        final String url = HostChecker.INSTANCE.replaceHost(requestUrl);
        HttpUtil.doRequest(url, params, callback);
    }
    
    public void checkHost(int retryIndex, final RequestCallback callback) {
        String[] domains = DomainConfig.getDomains();
        if (domains == null || domains.length <= 0) {
            return;
        }
        if (retryIndex < -1 || retryIndex >= domains.length) {
            return;
        }
        final String url;
        if (retryIndex >= 0) {
            url = CHECK_HOST_URL.replace(BASE_DOMAIN, domains[retryIndex]);
        } else {    // -1
            url = CHECK_HOST_URL;
        }
        HttpUtil.doRequest(url, null, callback);
    }
}

我这里将备用域名存放在数组中,这样如果备用域名有多个的话,可以通过索引直接访问到对应域名,我们知道数组的索引是从 0 开始的,我在这里将初始索引标记为 -1,可以理解为该索引对应的是默认的域名(实际上并不是),假如默认域名不能正常工作,只需将索引的值加 1 就能直接从备用域名数组的首个值开始了,索引自增操作在数组内可以访问下一个值,于是便能形成递归复用。

小结

实际上,假如 WebView 并不在应用打开时立即显示的话,我更建议直接复用网络请求的全局替换方案,因为这个方案只在初始化阶段执行一次,即可在当次会话中直接使用,否则 WebView 如果多次回调到失败接口会造成较长时间的白屏,影响体验。而如果首页即为 WebView 的话,使用网络请求全局替换方案由于请求异步,可能会造成 WebView 加载失败,所以应当结合项目实际情况合理选择方案。

虽然示例依然是 Android 客户端的 Java 代码,但标题我说的是「客户端」,也就意味着 iOS 等其他客户端平台开发也可以采用相似的策略,后来我还想了一下其他方案,靠 ping 来判断是否被劫持理论上也是可行的。

在网上搜索相关内容,基本都是无解的,实际上也正常,因为这本来就不是客户端的锅,但同时也不正常,毕竟在移动客户端发展的十余年间,相信有许多开发者都遇到过这种问题,理应有一个解决的思路。

标题中我用的是「思考」这个词,因为没有前辈们的方案做参考,所以我也不确切上面的方案是否正确,只能根据自己的思路来写,希望对后来者有所帮助,也希望有踩过坑的开发者能提供更好的思路。