• App线上网络问题优化策略


    在我们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解析策略。

    首先我们先看一个例子,假设我们想要请求百度域名获取一个数据,例如:

    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 ")
                    }
    
                }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    很明显,百度的域名是错误的,所以在执行网络请求的时候就会报错:

    java.net.UnknownHostException: Unable to resolve host "www.baidu.comxx": No address associated with hostname
    
    • 1

    所以一旦我们的域名被劫持修改,那么整个服务就会处于宕机的状态,用户体感就会很差,因此我们可以通过OkHttp提供的自定义DNS解析器来做一个小的优化。

    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 lookup(String hostname) throws UnknownHostException;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    我们看下源码,lookup方法相当于在做DNS寻址,一旦发生异常那么就会抛出UnknownHostException异常;同时内部还定义了一个SYSTEM方法,在这个方法中会通过系统提供的InetAddress类进行路由寻址,同样如果DNS解析失败,那么也会抛出UnknownHostException异常。

    所以我们分两步走,首先使用系统能力进行路由寻址,如果失败,那么再走自定义的策略。

    class MyDNS : Dns {
    
    
        override fun lookup(hostname: String): MutableList {
    
            val result = mutableListOf()
            var systemAddressList: MutableList? = 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
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    这样在www.baidu.comxx 解析失败之后,就会使用www.baidu.com 域名替换,从而避免网络请求失败的问题。

    1.3 接口数据适配策略

    相信很多伙伴在和服务端调试接口的时候,经常会遇到这种情况:接口文档标明这个字段为int类型,结果返回的是字符串“”;或者在某些情况下,我需要服务端返回一个空数组,但是返回的是null,对于这种情况,我们在数据解析的时候,无论是使用Gson还是Moshi,都会解析失败,如果处理不得当,严重的会造成崩溃。

    所以针对这种数据格式不匹配的问题,我们可以对Gson简单做一些适配处理,例如List类型:

    class ListTypeAdapter : JsonDeserializer> {
        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
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    如果json是List数组类型数据,那么就正常将其转换为List数组;如果不是,那么就解析为空数组。

    class StringTypeAdapter : JsonDeserializer {
    
        override fun deserialize(
            json: JsonElement?,
            typeOfT: Type?,
            context: JsonDeserializationContext?
        ): String {
            return try {
                if (json?.isJsonPrimitive == true) {
                    Gson().fromJson(json, typeOfT)
                } else {
                    ""
                }
            } catch (e: Exception) {
                ""
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    对于String类型字段,首先会判断是否为基础类型(String,Number,Boolean),如果是基础类型那么就正常转换即可。

    GsonBuilder()
        .registerTypeAdapter(Int::class.java, IntTypeAdapter())
        .registerTypeAdapter(String::class.java, StringTypeAdapter())
        .registerTypeAdapter(List::class.java, ListTypeAdapter())
        .create().also {
            GsonConverterFactory.create(it)
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这样在创建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就可以实现。

    Android 学习笔录

    OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
    Android 性能优化篇:https://qr18.cn/FVlo89
    Android 车载篇:https://qr18.cn/F05ZCM
    Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
    Android Framework底层原理篇:https://qr18.cn/AQpN4J
    Android 音视频篇:https://qr18.cn/Ei3VPD
    Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
    Kotlin 篇:https://qr18.cn/CdjtAF
    Gradle 篇:https://qr18.cn/DzrmMB
    Flutter 篇:https://qr18.cn/DIvKma
    Android 八大知识体:https://qr18.cn/CyxarU
    Android 核心笔记:https://qr21.cn/CaZQLo
    Android 往年面试题锦:https://qr18.cn/CKV8OZ
    2023年最新Android 面试题集:https://qr18.cn/CgxrRy
    Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
    音视频面试题锦:https://qr18.cn/AcV6Ap

  • 相关阅读:
    【前端】实现时钟网页
    华为云云耀云服务器L实例评测 | 实例评测使用之硬件参数评测:华为云云耀云服务器下的 Linux 网络监控神器 bmon
    AcWing:第56场周赛
    html+css 带图片的搜索框
    Semantic Kernel 学习笔记1
    来瞧瞧,WPF 炫酷走马灯!
    拼凑硬币问题
    如何在Nginx上启用HTTPS
    Cent OS 7下部署zabbix5.0
    echarts根据x轴数据长度判断是否倾斜展示/柱状图上方显示数字
  • 原文地址:https://blog.csdn.net/weixin_61845324/article/details/132782128