在我们App开发过程中,网络是必不可少的,几乎很难想到有哪些app是不需要网络传输的,所以网络问题一般都是线下难以复现,一旦到了用户手里就会碰到很多疑难杂症,所以对于网络的监控是必不可少的,针对用户常见的问题,我们在实际的项目中也需要添加优化策略。
1 网络的基础优化
对于一些主流的网络请求框架,像OkHttp、Retrofit等,其实就是对Http协议做了封装,我们在使用的时候常见的就是POST或者GET请求,如果我们是做客户端开发,知道这些基本的内容好像也可以写代码,但是真正碰到了线上网络问题,反而摸不到头脑,其实最大的问题还是对于网络方面的知识储备不足,所以文章的开始,我们先来点基础的网络知识。
1.1 网络连接的类型
其实对于网络的连接,我们常见的就是向服务端发起请求,服务端返回对应的响应,但是在同一时刻,只能有一个方向的数据传输,这种连接方式称为半双工通信。
类型 | 描述 | 举例 |
---|---|---|
单工 | 在通信过程中,数据只能由一方发送到另一方 | 常见的例如UDP协议;Android广播 |
半双工 | 在通信过程中,数据可以由一方A发送到另一方B,也可以从一方B发送到另一方A,但是同一时刻只能存在一方的数据传输 | 常见的例如Http协议 |
全双工 | 在任意时刻,都会存在A到B和B到A的双向数据传输 | 常见的例如Socket协议,长连接通道 |
所以在Http1.0协议时,还是半双工的协议,因为默认是关闭长连接的,如果需要支持长连接,那么就需要在http头中添加字段:"Connection:Keep-Alive";在Http 1.1协议时,默认是开启了长连接,如果需要关闭长连接,那么需要添加http请求头字段:"Connection:close".
那么什么时候或者场景下,需要用到长连接呢?其实很简单,记住一点即可,如果业务场景中对于消息的即时性有要求时,就需要与服务端建立长连接,例如IM聊天,视频通话等场景。
1.2 DNS解析
如果伙伴们在项目中有对网络添加trace日志,除了net timeout这种超时错误,应该也看到过UnknowHostException这种异常,这是因为DNS解析失败,没有解析获取到服务器的ip地址。
像我们在家的时候,手机或者电脑都会连接路由器的wifi,而路由器是能够设置dns服务器地址的,
但是如果设置错误,或者被攻击篡改,就会导致DNS解析失败,那么我们app的网络请求都会出现异常,所以针对这种情况,我们需要加上自己的DNS解析策略。
首先我们先看一个例子,假设我们想要请求百度域名获取一个数据,例如:
kotlin
object HttpUtil {
private const val BASE_URL = "https://www.baidu.comxx"
fun initHttp() {
val client = OkHttpClient.Builder()
.build()
Request.Builder()
.url(BASE_URL)
.build().also {
kotlin.runCatching {
client.newCall(it).execute()
}.onFailure {
Log.e("OkHttp", "initHttp: error $it ")
}
}
}
}
很明显,百度的域名是错误的,所以在执行网络请求的时候就会报错:
java
java.net.UnknownHostException: Unable to resolve host "www.baidu.comxx": No address associated with hostname
所以一旦我们的域名被劫持修改,那么整个服务就会处于宕机的状态,用户体感就会很差,因此我们可以通过OkHttp提供的自定义DNS解析器来做一个小的优化。
java
public interface Dns {
/**
* A DNS that uses {@link InetAddress#getAllByName} to ask the underlying operating system to
* lookup IP addresses. Most custom {@link Dns} implementations should delegate to this instance.
*/
Dns SYSTEM = hostname -> {
if (hostname == null) throw new UnknownHostException("hostname == null");
try {
return Arrays.asList(InetAddress.getAllByName(hostname));
} catch (NullPointerException e) {
UnknownHostException unknownHostException =
new UnknownHostException("Broken system behaviour for dns lookup of " + hostname);
unknownHostException.initCause(e);
throw unknownHostException;
}
};
/**
* Returns the IP addresses of {@code hostname}, in the order they will be attempted by OkHttp. If
* a connection to an address fails, OkHttp will retry the connection with the next address until
* either a connection is made, the set of IP addresses is exhausted, or a limit is exceeded.
*/
List<InetAddress> lookup(String hostname) throws UnknownHostException;
}
我们看下源码,lookup方法相当于在做DNS寻址,一旦发生异常那么就会抛出UnknownHostException异常;同时内部还定义了一个SYSTEM方法,在这个方法中会通过系统提供的InetAddress类进行路由寻址,同样如果DNS解析失败,那么也会抛出UnknownHostException异常。
所以我们分两步走,首先使用系统能力进行路由寻址,如果失败,那么再走自定义的策略。
kotlin
class MyDNS : Dns {
override fun lookup(hostname: String): MutableList<InetAddress> {
val result = mutableListOf<InetAddress>()
var systemAddressList: MutableList<InetAddress>? = null
//通过系统DNS解析
kotlin.runCatching {
systemAddressList = Dns.SYSTEM.lookup(hostname)
}.onFailure {
Log.e("MyDNS", "lookup: $it")
}
if (systemAddressList != null && systemAddressList!!.isNotEmpty()) {
result.addAll(systemAddressList!!)
} else {
//系统DNS解析失败,走自定义路由
result.add(InetAddress.getByName("www.baidu.com"))
}
return result
}
}
这样在www.baidu.comxx 解析失败之后,就会使用www.baidu.com 域名替换,从而避免网络请求失败的问题。
1.3 接口数据适配策略
相信很多伙伴在和服务端调试接口的时候,经常会遇到这种情况:接口文档标明这个字段为int类型,结果返回的是字符串"";或者在某些情况下,我需要服务端返回一个空数组,但是返回的是null,对于这种情况,我们在数据解析的时候,无论是使用Gson还是Moshi,都会解析失败,如果处理不得当,严重的会造成崩溃。
所以针对这种数据格式不匹配的问题,我们可以对Gson简单做一些适配处理,例如List类型:
kotlin
class ListTypeAdapter : JsonDeserializer<List<*>> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): List<*> {
return try {
if (json?.isJsonArray == true) {
Gson().fromJson(json, typeOfT)
} else {
Collections.EMPTY_LIST
}
} catch (e: Exception) {
//
Collections.EMPTY_LIST
}
}
}
如果json是List数组类型数据,那么就正常将其转换为List数组;如果不是,那么就解析为空数组。
kotlin
class StringTypeAdapter : JsonDeserializer<String> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): String {
return try {
if (json?.isJsonPrimitive == true) {
Gson().fromJson(json, typeOfT)
} else {
""
}
} catch (e: Exception) {
""
}
}
}
对于String类型字段,首先会判断是否为基础类型(String,Number,Boolean),如果是基础类型那么就正常转换即可。
kotlin
GsonBuilder()
.registerTypeAdapter(Int::class.java, IntTypeAdapter())
.registerTypeAdapter(String::class.java, StringTypeAdapter())
.registerTypeAdapter(List::class.java, ListTypeAdapter())
.create().also {
GsonConverterFactory.create(it)
}
这样在创建GsonConverterFactory时,就可以使用我们的策略来进行数据适配,但是在测试环境下,我们不建议这样使用,因为无法发现服务端的问题,在上线之后为了规避线上问题可以使用此策略。
2 HTTPS协议
http协议与https协议的区别,就是多了一个"s",可别小看这一个"s",它能够保证http数据传输的可靠性,那么这个"s"是什么呢,就是SSL/TLS协议。
从上图中看,在进入TCP协议之前会先走SSL/TLS协议.
2.1 对称加密和非对称加密
既然Https能保证传输的可靠性,说明它对数据进行了加密,以往http协议数据的传输都是明文传输,数据极容易被窃取和冒充,因此后续优化中,对于数据进行了加密传输,才有了Https协议诞生。
常见的加密手段有两种:对称加密和非对称加密。
2.1.1 对称加密
首先对称加密,从名字就能知道具体的原理,看下图:
对称加密和解密的密钥是一把钥匙,需要双方约定好,发送方通过秘钥加密数据,接收方使用同一把秘钥解密获取传递的数据。
所以使用对称加密非常简单,解析数据很快,但是安全性比较差,因为双方需要约定同一个key,key的传输有被劫持的风险,而统一存储则同样存在被攻击的风险。
所以针对这种情况,应运而生出现了非对称加密。
2.1.2 非对称加密
非对称加密会有两把钥匙:私钥 + 公钥,对于公钥任何人都可以知道,发送方可以使用公钥加密数据,而接收方可以用只有自己知道的私钥解密拿到数据。
那么既然公钥所有人都知道,那么能够通过公钥直接推算出私钥吗?答案是目前不可能,未来可能会,得看全世界的密码学高手或者黑客能否解决这个问题。
总结一下两种加密方式的优缺点:
加密类型 | 优点 | 缺点 |
---|---|---|
对称加密 | 流程简单,解密速度快 | 不安全,秘钥管理有风险 |
非对称加密 | 私钥只有自己知道 | 流程繁琐,解密速度慢 |
2.2 公钥的安全保障
通过2.1小节对于非对称加密的介绍,虽然看起来安全性更高了一些,但是对于公钥的传递有点儿太理想化,我们看下面的场景。
如果公钥在传输的过程中被劫持,那么发送方拿到的是黑客的公钥,后续所有的数据传输都被劫持了,所以问题来了,如何保证发送方拿到的公钥一定是接收方的?
举个简单的例子:我们在马路上捡到了一张银行卡,想把里面的钱取出来,那么银行柜台其实就是接收方,银行卡就是公钥,那么银行就会直接把钱给我们了吗?肯定不可以,要么需要身份证,要么需要密码,能够证明这个银行卡是我们自己的,所以公钥的安全性保证就是CA证书(可以理解为我们的身份证)。
那么首先接收方需要办一张身份证,需要通过CA机构生成一个数字签名,具体生成的规则如下:
那么最终发送给接收方的就是如下一张数字证书,包含的内容有:数字签名 + 公钥 + 接收方的个人信息等。
那么发送方接收到数字证书之后,就会检查数字证书是否合法,检测方式如下:
如果不是办的假证,这种可能性几乎为0,因为想要伪造一个域名的数字签名,根本不可能,CA机构也不是吃干饭的,所以只要通过证书认证了,那么就能保证公钥的安全性。
2.3 Https的传输流程
其实一个Https请求,中间包含了2次Http传输,假如我们请求www.baidu.com 具体流程如下:
(1)客户端向服务端发起请求,要访问百度,那么此时与百度的服务器建立连接;
(2)此时服务端有公钥和私钥,公钥可以发送给客户端,然后给客户端发送了一个SSL证书,其中包括:CA签名、公钥、百度的一些信息,详情可见2.2小节最后的图;
(3)客户端在接收到SSL证书后,对CA签名解密,判断证书是否合法,如果不合法,那么就断开此次连接;如果合法,那么就生成一个随机数,作为数据对称加密的密钥,通过公钥加密发送到服务端。
(4)服务端接收到了客户端加密数据后,通过私钥解密,拿到了对称加密的密钥,然后将百度相关数据通过对称加密秘钥加密,发送到客户端。
(5)客户端通过解密拿到了服务端的数据,此次请求结束。
其实Https请求并不是完全是非对称加密,而是集各家之所长,因为对称加密密钥传递有风险,因此前期通过非对称加密传递对称加密密钥,后续数据传递都是通过对称加密,提高了数据解析的效率。
但是我们需要了解的是,Https保障的只是通信双方当事人的安全,像测试伙伴通过Charles抓包这种中间人攻击方式,还是会导致数据泄露的风险,因为通过伪造证书或者不受信任的CA就可以实现。