最近公司项目用到的某个域名在北京地区出现大量无法访问的情况,用户最直观的感受就是,打开 WebView
页面时,出现了他们看不懂的提示:
其实 net::ERR_CONNECTION_REFUSED
这个提示太虚,不只是用户,我们作为开发看到的时候也不一定能马上反应过来。
技术开会后得出是 DNS 被劫持了,这得联系当地运营商解决,我们临时的解决方案就是替换域名(一般企业都应该有备用域名),重新出包给用户替换。
等运营商解决全凭运气,重新出包也是临时解决,我们应该利用备用域名考虑一套解决方案。
复现
首先要尝试复现场景,因为我不在北京,自然无法直接的复现这个问题,但我们可以思考这个问题的本质。
之前在『一文带你简单了解 Hosts』中提到,域名和 IP 有映射关系,DNS 服务器与 Hosts 一样,充当着解析这层映射的角色,也就是说,当解析出错的时候,就会产生上面的问题。
那么所谓的「解析出错」应当如何判断?
也很简单,既然域名和 IP 有映射关系,出错则意味着域名无法解析为正确的 IP,我们可以利用 Hosts 去实现这一点,具体操作参考『Android 修改 Hosts』一文。
接下来写个 WebView
加载一个网页,我这里就拿阿里云官网来写个 Demo,效果如下:
然后,通过『IPAddress.com』查一下百度官网的 IP,修改 Hosts 文件,将阿里云官网域名映射到百度的 IP 上:
14.215.177.38 www.aliyun.com
再次运行刚刚的 Demo,可以看到页面没有加载出来:
这似乎跟我一开始的情况不太一样,将域名指向本地主机试试看:
127.0.0.1 www.aliyun.com
情况复现:
复现之后就能够针对性的解决了。
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
来判断是否被劫持理论上也是可行的。
在网上搜索相关内容,基本都是无解的,实际上也正常,因为这本来就不是客户端的锅,但同时也不正常,毕竟在移动客户端发展的十余年间,相信有许多开发者都遇到过这种问题,理应有一个解决的思路。
标题中我用的是「思考」这个词,因为没有前辈们的方案做参考,所以我也不确切上面的方案是否正确,只能根据自己的思路来写,希望对后来者有所帮助,也希望有踩过坑的开发者能提供更好的思路。