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

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

前言

一个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行数表示的是每个发送的回送请求数据包的执行结果,其中:

  • [64 bytes]是从目标主机返回的数据,之所以是64 bytes是由于加多了8 bytes的ICMP报头。
  • [icmp_seq]是ICMP报头中包含的时序号,用于确定数据包到达的顺序以及判断数据包是否重复
  • [ttl]是数据包的生存时间,是time to live的缩写,是为了防止数据包在路由选择的过程中无休止地在网络中流动而设置的。
  • [time]是数据包的往返时间,即从发送回送请求报文之后,到接收到回送应答报文之前经过的时间。
统计信息

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

  • [5 packets transmitted]表示传输了5个数据包
  • [5 packets received]表示接收了5个数据包
  • [0 packet loss]表示数据包的丢包率为0%,该数值越大,表示网络状况越不稳定,最高为100%,即目标主机不可达

第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行起表示经过的路由器信息,其中:

  • 最前面的数字表示的是跃点数,与所发送的数据包的TTL值一致
  • [172.16.88.1]表示的是经过的路由器IP地址
  • [4.69ms 9.29ms 9.24ms]表示的是所发送的三个数据包分别的往返时间
  • [*]表示的是没有应答,当所发送的三个数据包中有任意一个超过5秒没有应答时,则会以星号表示

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

总结

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

  • Ping命令用于检测到目标主机是否可达,通过结合数据包的往返时间和丢包率我们能初步地评估网络状况,包括网络延迟/网络抖动/网络稳定性等情况。
  • TraceRoute命令用于定位到目标主机所经过的路由及其耗时,以定位网络故障发生的节点。由于Android的非Root设备不支持直接使用命令行工具执行TraceRoute命令,因此我们改用执行多次Ping命令来模拟TraceRoute的执行流程。

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

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

猜你喜欢

转载自blog.csdn.net/Alfred_C/article/details/119514322