Android拾萃

手把手教你搭建应用的网络诊断模块(1)——Ping与TraceR

2021-08-08  本文已影响0人  星际码仔

「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!

前言

一个App功能的整体表现,往往与用户当前的网络状况密不可分。通过为App引入一个轻量级的网络诊断模块,收集那些能够衡量当前网络状况的重要信息,然后在征得用户同意的情况下,将信息上报到服务端进行分析,可以有针对性地对网络链路中的薄弱环节进行优化。

众所周知,Android系统基于Linux内核的,Linux本身就提供了许多可用于检测网络状况的工具,熟练地运用这些工具,可以很轻松地达到我们网络诊断的目的。今天要分享的就是其中的两个工具,Ping命令与TraceRoute命令。

网络工具介绍

Ping

声呐技术

「Ping」这个名字源于声呐技术,声呐技术是利用声波在水中的传播和反射特性,对水下目标进行探测、分类、定位和跟踪的技术。

概述

Ping命令是用于检测从源主机到目标主机是否可达的工具。

该命令基于ICMP协议,通过向目标主机发送指定个数与大小的回送请求(echo request)数据包,并要求目标主机在收到之后返回相应的回送应答(echo reply)数据包,最终结合数据包的往返时间丢包率来评估网络连接状况。

图示

ping命令模型

如果用开头提及的声呐技术来类比,就会是这样的一个对应关系:

对应关系

形式

Ping命令的基本形式如下:

ping [-c 数据包个数] [-s 数据包大小] [主机名/IP地址]

例:

ping -c 5 -s 56 developer.android.google.cn

默认情况下,假如不指定数据包个数,Ping命令就会连续发送数据包,如果仅仅是为了进行连通性测试,只需要指定3到5个即可。

而假如不指定数据包大小,则默认是56 bytes。

实现

Android支持直接使用命令行工具执行Ping命令,因此只需设定好参数,逐行读取输出内容即可:

/**
 * Ping命令
 */
class Ping(
    /** 目标主机域名/IP地址  */
    private val host: String,
    /** 数据包个数,默认连续发送  */
    private val count: Int? = null,
    /** 数据包大小,单位bytes,默认为56 bytes  */
    private val packetSize: Int? = null,
    /** 数据包生存时间 */
    private val ttl: Int? = null,
    /** 超时间隔,单位s */
    private val deadline: Int? = null
) {

    /**
     * ## 执行Ping命令
     * 请注意,ping命令在Linux系统下的参数与在Windows系统下有差异,需要区分
     * -c count ping指定次数后停止ping;
     * -s packetsize 指定每次ping发送的数据字节数,默认为“56字节”+“28字节”的ICMP头,一共是84字节;
     */
    fun execute(callback: ExecuteCallback? = null): String {
        val command = toString()
        // 回调输出执行的Ping命令
        callback?.onExecuting("% $command\n")

        val result = StringBuilder()
        var process: Process? = null
        var reader: BufferedReader? = null
        try {
            process = Runtime.getRuntime().exec(command)
            reader = BufferedReader(InputStreamReader(process.inputStream))
            // 读取首行输出内容
            var line = reader.readLine()
            while (line != null) {
                // 回调执行过程的输出内容
                callback?.onExecuting(line)
                // 记录输出行到结果字符串
                result.append(line).append("\n")
                // 读取下一行输出内容
                line = reader.readLine()
            }
            callback?.onCompleted(result.toString())
            reader.close()
            process.waitFor()
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            reader?.close()
            process?.destroy()
        }

        return result.toString()
    }

    /**
     * ## 根据构造字段将实体转换为具体的Ping命令
     * 判断各字段非
     */
    override fun toString(): String {
        val stringBuilder = StringBuilder("ping")
        if (count != null) stringBuilder.append(" -c $count")
        if (packetSize != null) stringBuilder.append(" -s $packetSize")
        if (ttl != null) stringBuilder.append(" -t $ttl")
        if (deadline != null) stringBuilder.append(" -w $deadline")
        stringBuilder.append(" $host")
        return stringBuilder.toString()
    }

}

执行

Ping命令执行结果.jpg

分析

为了方便进行说明,我们在每一个结果行前添加了一个序号。

整个示例可以分为两块区域,从第1到6行为执行过程,从第7到8行为统计信息。

执行过程

第1行表示的是向目标主机发送了5个56 bytes的数据包。

第2-6行数表示的是每个发送的回送请求数据包的执行结果,其中:

统计信息

第7行表示的是数据包的传输接收情况以及丢包率,其中:

第8行表示的是数据包往返时间的最小值/平均值/最大值,单位为毫秒(ms)。数值越大,意味着网络延迟越严重最小值与最大值之间的差值越大,意味着网络抖动越厉害

TraceRoute

路由跟踪.png

两台主机之间的通信,往往需要经过很多中间节点,如果其中某个节点出现问题,可能会导致数据无法送达,通过TraceRoute(跟踪路由)我们可以定位数据是在哪个节点丢失的。

概述

TraceRoute命令是用于定位从源主机到目标主机所经过的路由,以及到达各个路由的数据包往返时间的工具。

该命令利用的是IP报头的TTL值ICMP超时报文以及ICMP端口不可达报文,TraceRoute每次都向相同的目标主机发送三次设置了相同TTL值的数据包,利用数据包被丢弃时路由器返回ICMP超时报文获知路由器的IP地址及数据包的往返时间。

流程

  1. 首先,向目标主机发送TTL值设为1的数据包,处理该数据包的第一个路由器会将TTL值减1当TTL值变为0时,该数据包就会被丢弃,并发回一份ICMP超时报文,这样就得到了该路径中的第一个路由器的IP地址。
  2. 接着,发送TTL值设为2的数据包,该数据包在经过第二个路由器时就会被丢弃,这样就得到了第二个路由器的IP地址。
  3. 持续这个过程,直至数据包到达目标主机。
  4. 为了确认数据包是否到达目标主机,TraceRoute使用了一个一般应用程序都不会使用的端口号(30000以上)作为目标端口号。这样,当数据包到达目标主机时,目标主机就会返回一个ICMP端口不可达报文,从而让源主机可以确认数据包已经到达了目标主机。

图示

TraceRoute执行流程(1).png

形式

TraceRoute命令的基本形式如下:

traceroute [主机名/IP地址]

例:

traceroute developer.android.google.cn

实现

由于Android的非Root设备不支持直接使用命令行工具执行TraceRoute命令,因此我们改成以执行Ping命令并通过限定TTL的方式来模拟TraceRoute的过程,从而达到相等效果。缺点是模拟过程较慢,可能会频繁出现超时情况。

具体的模拟过程如下:

  1. 对目标主机执行Ping命令,发送1个TTL值为1的数据包,第一个路由器将TTL值减1变为0,数据包被路由器丢弃,输出以下结果行:
    From 10.0.168.254: icmp_seq=1 Time to live exceeded
  1. 对该结果行进行正则表达式匹配,提取其中包含的路由器IP地址,如10.0.168.254;
  2. 对路由器IP地址执行Ping命令,发送3个大小为40 bytes的数据包,数据包到达该路由器,输出以下结果行:
    48 bytes from 211.136.203.125: icmp_seq=1 ttl=251 time=28.2 ms
    48 bytes from 211.136.203.125: icmp_seq=2 ttl=251 time=75.4 ms
    48 bytes from 211.136.203.125: icmp_seq=3 ttl=251 time=33.5 ms
  1. 对该结果行进行正则表达式匹配,提取其中包含的数据包往返时间;
  2. 对目标主机再次执行Ping命令,发送1个TTL值设为2的数据包,在经过第二个路由器时被丢弃,同样从结果行中提取出路由器IP地址。
  3. 对第二个路由器的IP地址执行Ping命令,同样从结果行中提取出数据包往返时间。
  4. 持续这个过程直至数据包到达目标主机,输出以下结果行:
64 bytes from 113.108.239.226: icmp_seq=1 ttl=115 time=33.0 ms
  1. 对目标主机IP地址执行Ping命令,同样从结果行中提取出数据包往返时间,模拟结束。
  2. 如果过程中数据包超过5s没有返回,则会输出空的结果行,因而提取不出路由器IP地址,转而输出[* * *]。
  3. 当跃点数超过设立的最大30个跃点数后仍未到达目标主机,则模拟结束。

相应的流程图如下:


PingTraceRoute.png

具体代码如下:

/**
 * TraceRoute命令
 * <p>
 * 由于Android的非Root设备不支持直接使用命令行工具API执行TraceRoute命令,因此改用执行Ping命令
 * 并通过限定TTL参数(IP包被路由器丢弃之前允许通过的最大网段数)来达到相等效果
 * 路由器地址通过正则表达式匹配从Ping响应内容中截取,
 * 路由耗时通过执行Ping命令前后时间戳对比估算
 */
class TraceRoute(
    /** 目标主机域名  */
    private val host: String
) {
    var TAG = this::class.java.simpleName

    companion object {
        /** IP包被路由器丢弃之前允许通过的最大网段数  */
        const val MAX_HOP = 30

        /** 正则表达式-路由器IP地址 */
        private const val REGEX_ROUTE_IP = "(?<=From )(?:[0-9]{1,3}\\.){3}[0-9]{1,3}"
        /** 正则表达式-目标主机IP地址 */
        private const val REGEX_HOST_IP = "(?<=from ).*(?=: icmp_seq=1 ttl=)"
        /** 正则表达式-数据包往返时间 */
        private const val REGEX_RRT = "(?<=time=).*?ms"
    }

    /**
     * 执行Ping命令模拟TraceRoute流程
     * -c count ping指定次数后停止ping;
     * -t 设置TTL(Time To Live,生存时间)为指定的值。该字段指定IP包被路由器丢弃之前允许通过的最大网段数;
     */
    fun execute(callback: ExecuteCallback? = null) {
        val command = toString();
        callback?.onExecuting("% $command\n")

        callback?.onExecuting("traceroute to $host, 30 hos max, 40 byte packets\n")

        // 当前跃点数
        var hop = 1
        // 终止标识
        var done = false

        while (!done && hop <= MAX_HOP) {
            val pingResult = Ping(host, packetSize = 40, count = 1, ttl = hop, deadline = 5).execute()
            Log.d(TAG, "ping host ip: $pingResult \n\n")

            val lineBuilder = StringBuilder()
            lineBuilder.append(hop).append(".")

            // 用正则表达式匹配响应内容行
            val routerIpMatcher = matchRouterIp(pingResult)
            if (routerIpMatcher.find()) {   // 匹配到了路由器IP地址,打印路由器IP地址及到达该路由器的耗时
                val routerIp = subRouteIpString(routerIpMatcher)
                lineBuilder.append("\t\t").append(routerIp)

                val pingResult = Ping(host = routerIp, packetSize = 40, count = 3, deadline = 5).execute()
                Log.d(TAG, "ping route ip: $pingResult \n\n")
                matchAndAppendRTT(pingResult, lineBuilder)
            } else {    // 匹配不到
                val hostIpMatcher = matchHostIp(pingResult)
                if(hostIpMatcher.find()) {
                    val hostIp = hostIpMatcher.group()
                    lineBuilder.append("\t\t").append(hostIp)

                    val pingResult = Ping(host = hostIp, packetSize = 40, count = 3, deadline = 5).execute()
                    Log.d(TAG, "ping host ip: $pingResult \n\n")
                    matchAndAppendRTT(pingResult, lineBuilder)
                    done = true
                } else {
                    lineBuilder.append("\t\t *\t\t*\t\t* \t")
                }
            }

            callback?.onExecuting(lineBuilder.toString())

            hop++
        }
    }

    /**
     * 匹配并记录数据包往返时间
     */
    private fun matchAndAppendRTT(pingResult: String, lineBuilder: StringBuilder) {
        val rttMatcher = matchRTT(pingResult)
        lineBuilder.append("\t\t")
        var i = 0
        while(i < 3) {
            if(rttMatcher.find()) {
                val rtt = rttMatcher.group()
                lineBuilder.append(rtt).append("\t\t")
            } else {
                lineBuilder.append("*").append("\t\t")
            }
            i++
        }
        lineBuilder.append("\t")
    }

    /**
     * 匹配路由器IP地址
     */
    private fun matchRouterIp(input: CharSequence) = Pattern.compile(REGEX_ROUTE_IP).matcher(input)

    /**
     * 匹配数据包往返时间
     */
    private fun matchRTT(input: CharSequence) = Pattern.compile(REGEX_RRT).matcher(input)

    /**
     * 匹配目标主机IP地址
     */
    private fun matchHostIp(input: CharSequence) =  Pattern.compile(REGEX_HOST_IP).matcher(input)

    /**
     * 截取路由器IP字符串
     */
    private fun subRouteIpString(matcher: Matcher): String {
        var pingIp = matcher.group()
        val start = pingIp.indexOf('(')
        if (start >= 0) {
            pingIp = pingIp.substring(start + 1)
        }
        return pingIp
    }

    override fun toString(): String {
        return "traceroute $host"
    }
}

执行

TraceRoute命令执行结果.jpg

分析

第1行表示的是TraceRoute命令向目标主机发送最多30个跃点、40 bytes的数据包。

第2行起表示经过的路由器信息,其中:

如果目标主机可达,则会在到达某一跃点后结束,由此可知经过的路由器数量,如果目标主机不可达,则会在到达第30个跃点后结束,从而可知数据包被送到什么地方。

总结

为了有针对性地对网络进行优化,我们为App引入了一个轻量级的网络诊断模块,主要借助的是Linux本身提供的检测网络状况的工具,在本篇中介绍的是Ping命令和TraceRoute命令。

当然,网络状况的复杂度往往超过我们的想象,还有很多这两个命令不能覆盖到的故障场景,需要相应的工具才能进行排查,具体可以关注后续推出的文章。

「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!

上一篇下一篇

猜你喜欢

热点阅读