写点什么

移动端网络监控实践

用户头像
轻口味
关注
发布于: 刚刚
移动端网络监控实践

1. 背景介绍

在移动端应用开发场景下,不可避免的要与网络打交道。有时在网络请求失败时,我们想知道网络的质量;有时需要明确的告知用户当前网络质量(比如游戏场景实时显示延迟)。网络监控离不开最经典的 TCP/IP 模型,基于模型分层统计网络耗时有助于我们更清晰的了解当前网络质量。



TCP/IP 参考模型中物理层难以在网络层面统计,网络层有 Ping 工具,传输层有系统提供的 Socket 接口,应用层最常用的有 HTTP、RTMP 协议。本文我们介绍 ping 工具、DNS 解析耗时、TCP 连接耗时、HTTP 建立耗时。

2. ping

ping 是基于网络层 ICMP 协议的,发送的是 ICMP 回显请求报文。下面我们先了解下 ICMP。

2.1 ICMP(Internet 控制报文协议)简介

ICMP 是 IP 层的一个组成部分,主要传递差错报文以及其他需要注意的信息。ICMP 报文通常被 IP 层或更高层协议(TCP 或 UDP)使用。ICMP 的正式规范参见 RFC 792[Posterl 1981b],ICMP 封装在 IP 数据包内部,格式是 20 字节的 IP 首部+ICMP 报文。ICMP 报文格式如下:



所有报文的前 4 个字节都是一样的,但是剩下的其他字节则互不相同。类型字段可以有 15 个不同的值,以描述特定类型的 ICMP 报文,某些 CIMP 报文还是用代码字段的值来进一步描述不同的条件。校验和字段覆盖整个 ICMP 报文。


不同类型由报文中的类型字段和代码字段来共同决定。报文可以分为查询报文和差错报文,ICMP 差错报文有时需要做特殊处理(如在对 ICMP 差错报文进行响应时,永远不会生成另一份 ICMP 差错报文)。


对于我们今天用到的 ping 程序,使用了:


  • 类型为 0,代码为 0 的回显应答的查询报文

  • 类型为 8,代码为 0 的请求回显的查询报文

2.2 Ping 程序协议简介

ping 的工作原理很简单,一台网络设备发送请求等待另一网络设备的回复,并记录下发送时间。接收到回复之后,就可以计算报文传输时间了。只要接收到回复就表示连接是正常的。耗费的时间喻示了路径长度。重复请求响应的一致性也表明了连接质量的可靠性。因此,ping 回答了两个基本的问题:是否有连接?连接的质量如何?


我们称发送回显请求的 ping 程序为客户,称被 ping 的主机为服务器。大多数的 TCP/IP 实现都在内核中直接支持 ping 服务器,这种服务器不是一个用户进程。ICMP 回显请求和回显应答报文如下:



Unix 系统在实现 ping 程序时是把 ICMP 报文中的标识符字段置成发送进程的 ID 号。这样即使在同一台主机上同时运行了多个 ping 程序实例,ping 程序也可以识别出返回的信息。序列号从 0 开始,每发送一次新的回显请求就加 1。

2.3 ping 程序命令介绍

ping 程序的主要选项:


  1. -c:选项允许用户指定发送报文的数量,例如,ping –c10 会发送 10 个报文然后停止;

  2. -f:选项表明报文发送速率与接收主机能够处理速率相同,这一参数可用于链路压力测试或接口性能比较;

  3. -l:选项用于计数,尽可能快的发送该数量报文,然后恢复正常,该命令用于测试处理泛洪的能力,需要 root 权限执行;

  4. -i:选项用于用户在两个连续报文之间指定等待秒数。该命令对于将报文间隔开或用在脚本中非常有用。正常情况下,偶然的 ping 包对数据流的影响是很小的。但重复报文或报文泛洪影响就很大了。因此,使用该选项时需谨慎;

  5. -n:选项将输出限制为数字形式,这在碰见 DNS 问题时很有用;

  6. -v:显示更详尽输出,较少输出为-q 和-Q;

  7. -s:选项指定发送数据的大小。但如果设置的太小,小于 8,则报文中就没有空间留给时间戳了。设置报文大小能诊断有路径 MTU(Maximum Transmission Unit)设置或分段而导致的问题。如果不使用该选项,ping 默认是 64 字节。

2.4 Android 端执行 ping 程序

Android 系统提供了 ping 命令行程序,在程序中可以通过 popen 执行系统自带 ping 程序,下面是执行 ping 程序的代码:


int RunPingQuery(int _querycount, int interval/*S*/, int timeout/*S*/, const char* dest, unsigned int packetSize) {    char cmd[256] = {0};

int index = snprintf(cmd, 256, "ping -c %d -i %d -w %d", _querycount, interval, timeout);
if (index < 0 || index >= 256) { //sprintf return error return -1; }
int tempLen = 0;
if (packetSize > 0) { tempLen = snprintf((char*)&cmd[index], 256 - index, " -s %u %s", packetSize, dest); } else { tempLen = snprintf((char*)&cmd[index], 256 - index, " %s", dest); }
if (tempLen < 0 || tempLen >= 256 - index) { //sprintf return error return -1; } FILE* pp = popen(cmd, "r");
if (!pp) { //popen error return -1; }
std::string pingresult_; while (fgets(line, sizeof(line), pp) != NULL) { pingresult_.append(line, strlen(line)); }
pclose(pp);
if (pingresult_.empty()) { //m_strPingResult is empty return -1; }
struct PingStatus pingStatusTemp; //= {0};notice: cannot initial with = {0},crash GetPingStatus(pingStatusTemp); if (0 == pingStatusTemp.avgrtt && 0 == pingStatusTemp.maxrtt) { //remote host is not available return -1; } return 0;}
int GetPingStatus(struct PingStatus& _ping_status, std::string pingresult_) { if (pingresult_.empty()) return -1;
_ping_status.res = pingresult_; std::vector<std::string> vecPingRes; str_split('\n', pingresult_, vecPingRes);
std::vector<std::string>::iterator iter = vecPingRes.begin();
for (; iter != vecPingRes.end(); ++iter) { if (vecPingRes.begin() == iter) { // extract ip from the result string and assign to _ping_status.ip int index1 = iter->find_first_of("(", 0);
if (index1 > 0) { int index2 = iter->find_first_of(")", 0);
if (index2 > index1) { int size = index2 - index1 - 1; std::string ipTemp(iter->substr(index1 + 1, size)); strncpy(_ping_status.ip, ipTemp.c_str(), (size < 16 ? size : 15)); } } } // end if(vecPingRes.begin()==iter)
int num = iter->find("packet loss", 0);
if (num >= 0) { int loss_rate = 0; int i = 3;
while (iter->at(num - i) != ' ') { loss_rate += ((iter->at(num - i) - '0') * (int)pow(10.0, (double)(i - 3))); i++; } _ping_status.loss_rate = (double)loss_rate / 100; }
int num2 = iter->find("rtt min/avg/max", 0);
if (num2 >= 0) { int find_begpos = 23; int findpos = iter->find_first_of('/', find_begpos); std::string sminRTT(*iter, find_begpos, findpos - find_begpos); find_begpos = findpos + 1; findpos = iter->find_first_of('/', find_begpos); std::string savgRTT(*iter, find_begpos, findpos - find_begpos); find_begpos = findpos + 1; findpos = iter->find_first_of('/', find_begpos); std::string smaxRTT(*iter, find_begpos, findpos - find_begpos); _ping_status.minrtt = atof(sminRTT.c_str()); _ping_status.avgrtt = atof(savgRTT.c_str()); _ping_status.maxrtt = atof(smaxRTT.c_str()); } } return 0;}
复制代码

2.5 iOS 端发送 ping 指令

iOS 端主要通过创建 socket 发送 ICMP 执行,主要思路如下:


  1. 如果设置的是域名,需要将 DNS 转换为 IP;

  2. 创建 socketn = socket(family, type, protocol),family 为 AF_INET, type 为 SOCK_DGRAM, protocol 为 IPPROTO_ICMP;

  3. 构造 ICMP 包:


struct icmp {    u_char    icmp_type;        /* type of message, see below */    u_char    icmp_code;        /* type sub code */    u_short    icmp_cksum;        /* ones complement cksum of struct */    union {        u_char ih_pptr;            /* ICMP_PARAMPROB */        struct in_addr ih_gwaddr;    /* ICMP_REDIRECT */        struct ih_idseq {            n_short    icd_id;            n_short    icd_seq;        } ih_idseq;        int ih_void;
/* ICMP_UNREACH_NEEDFRAG -- Path MTU Discovery (RFC1191) */ struct ih_pmtu { n_short ipm_void; n_short ipm_nextmtu; } ih_pmtu;
struct ih_rtradv { u_char irt_num_addrs; u_char irt_wpa; u_int16_t irt_lifetime; } ih_rtradv; } icmp_hun;#define icmp_pptr icmp_hun.ih_pptr#define icmp_gwaddr icmp_hun.ih_gwaddr#define icmp_id icmp_hun.ih_idseq.icd_id#define icmp_seq icmp_hun.ih_idseq.icd_seq#define icmp_void icmp_hun.ih_void#define icmp_pmvoid icmp_hun.ih_pmtu.ipm_void#define icmp_nextmtu icmp_hun.ih_pmtu.ipm_nextmtu#define icmp_num_addrs icmp_hun.ih_rtradv.irt_num_addrs#define icmp_wpa icmp_hun.ih_rtradv.irt_wpa#define icmp_lifetime icmp_hun.ih_rtradv.irt_lifetime union { struct id_ts { n_time its_otime; n_time its_rtime; n_time its_ttime; } id_ts; struct id_ip { struct ip idi_ip; /* options and then 64 bits of data */ } id_ip; struct icmp_ra_addr id_radv; u_int32_t id_mask; char id_data[1]; } icmp_dun;#define icmp_otime icmp_dun.id_ts.its_otime#define icmp_rtime icmp_dun.id_ts.its_rtime#define icmp_ttime icmp_dun.id_ts.its_ttime#define icmp_ip icmp_dun.id_ip.idi_ip#define icmp_radv icmp_dun.id_radv#define icmp_mask icmp_dun.id_mask#define icmp_data icmp_dun.id_data};
void __preparePacket(char* _sendbuffer, int& _len) { char sendbuf[MAXBUFSIZE]; memset(sendbuf, 0, MAXBUFSIZE); struct icmp* icmp; icmp = (struct icmp*) sendbuf; icmp->icmp_type = ICMP_ECHO; icmp->icmp_code = 0; icmp->icmp_id = getpid() & 0xffff;/* ICMP ID field is 16 bits */ icmp->icmp_seq = htons(nsent_++); memset(&sendbuf[ICMP_MINLEN], 0xa5, DATALEN); /* fill with pattern */
struct timeval now; (void)gettimeofday(&now, NULL); now.tv_usec = htonl(now.tv_usec); now.tv_sec = htonl(now.tv_sec); bcopy((void*)&now, (void*)&sendbuf[ICMP_MINLEN], sizeof(now)); _len = ICMP_MINLEN + DATALEN; /* checksum ICMP header and data */ icmp->icmp_cksum = 0; icmp->icmp_cksum = in_cksum((u_short*) icmp, _len); memcpy(_sendbuffer, sendbuf, _len);}
复制代码


  1. 接收 ICMP 包:


int PingQuery::__recv() {    char            recvbuf[MAXBUFSIZE];    char            controlbuf[MAXBUFSIZE];    memset(recvbuf, 0, MAXBUFSIZE);    memset(controlbuf, 0, MAXBUFSIZE);
struct msghdr msg = {0}; struct iovec iov = {0}; iov.iov_base = recvbuf; iov.iov_len = sizeof(recvbuf); msg.msg_name = &recvaddr_; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = controlbuf;
msg.msg_namelen = sizeof(recvaddr_); msg.msg_controllen = sizeof(controlbuf);
int n = (int)recvmsg(sockfd_, &msg, 0);
if (n < 0) { return -1; } //解析消息结构 return n;}
复制代码

2.6 ping 网络延迟的相关参考

ping 外网小于 50ms,网络的延迟就算良好,是正常的。


一般来说,网络的延迟 PING 值越低,速度会越快;但是网络的速度与网络延迟这二者之间没有必然的联系,以下是 ping 网络延迟的相关参考数据:


  • ping 网络:1 到 30ms:速度极快,几乎察觉不出有延迟,玩任何游戏速度都特别顺畅;~

  • ping 网络:31 到 50ms:速度良好,可以正常游戏浏览网页,没有明显的延迟情况;~

  • ping 网络:51 到 100ms:速度普通,对抗类游戏在一定水平以上能感觉出延迟,偶尔感觉到停顿;

  • ping 网络:100ms 到 200ms:速度较差,无法正常游玩对抗类游戏,有明显的卡顿现象,偶尔出现丢包和掉线现象。

3. dns 解析耗时

在我们创建 socket 前,会有一个域名转 IP 的解析过程。域名系统是一种用于 TCP/IP 应用程序的分布式数据库,提供了域名和 IP 地址之间的转换及有关电子邮件的选路信息。在 Unix 主机中,通过两个库函数 gethostbyname 和 gethostbyaddr 来访问的。前者接收主机名字返回 IP 地址,后者接收 IP 地址来寻找主机名字。


我们知道域名解析要访问域名服务,连接域名服务是基于 UDP 还是 TCP 呢?DNS 名字服务器使用的熟知端口号是 53,通过 tcpdump 观察到所有例子都是采用 UDP,为什么采用的是 UDP 呢?


当名字解析器发出一个查询请求,并且返回响应中的 TC(删减标志)比特被设置为 1 时,它就意味着响应的长度超过了 512 个字节,而仅返回前 512 个字节。在遇到这种情况时,名字解析器通过使用 TCP 重发原来的查询请求,它将允许返回的响应超过 512 个字节。TCP 能将用户的数据流分为一些报文段,它就能用多个报文段来传送任意长度的用户数据。


我们要统计 DNS 解析延时就需要自己创建 socket,发送 DNS 报文并获取响应计算耗时。创建 socket 需要知道 DNS 服务地址,怎么获取 DNS 地址呢?


一种常见的方法通过获取手机配置文件获取:


char buf1[PROP_VALUE_MAX];char buf2[PROP_VALUE_MAX];__system_property_get("net.dns1", buf1);__system_property_get("net.dns2", buf2);
复制代码


这种方式高版本获取不到 DNS 服务地址,部分高版本手机可通过下面方法获取:


    char buf3[1024];    __system_property_get("ro.config.dnscure_ipcfg", buf3);    std::string dnsCureIPCfgStr(buf3);    if (!dnsCureIPCfgStr.empty()) {        const std::vector<std::string> &kVector = splitstr(dnsCureIPCfgStr, '|');        if (kVector.size() > 2) {            const std::vector<std::string> &kVector2 = splitstr(dnsCureIPCfgStr, ';');            if (kVector2.size() > 2) {                _dns_servers.push_back(kVector2[0]);  // 主DNS                _dns_servers.push_back(kVector2[1]);  // 备DNS                return;            }        }    }
复制代码


该方法获取到 DNS 列表,以逗号分隔地址列表,内网外网通过|区分。


通过 ConnectivityManager 获取:


private static String[] getDnsFromConnectionManager(Context context) {    LinkedList<String> dnsServers = new LinkedList<>();    if (Build.VERSION.SDK_INT >= 21 && context != null) {      ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(context.CONNECTIVITY_SERVICE);      if (connectivityManager != null) {        NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();        if (activeNetworkInfo != null) {          for (Network network : connectivityManager.getAllNetworks()) {            NetworkInfo networkInfo = connectivityManager.getNetworkInfo(network);            if (networkInfo != null && networkInfo.getType() == activeNetworkInfo.getType()) {              LinkProperties lp = connectivityManager.getLinkProperties(network);              for (InetAddress addr : lp.getDnsServers()) {                dnsServers.add(addr.getHostAddress());              }            }          }        }      }    }    return dnsServers.isEmpty() ? new String[0] : dnsServers.toArray(new String[dnsServers.size()]);  }
复制代码


获取到的是内网 DNS 地址。在 Android 端获取 DNS 服务地址需要考虑到 Android 品牌及系统的兼容性。

4. tcp 连接耗时统计

TCP 耗时从 socket 创建到连接、收发消息耗时:


  1. 创建 socketsocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

  2. 建立连接:int connectRet = connect(fsocket, (sockaddr*)&_addr, sizeof(_addr));

  3. 发送测试指令:send

  4. 接收消息:recv

5. 总结

本文讨论了统计移动端网络耗时网络质量的主要方法:ping 耗时、DNS 耗时、TCP 连接耗时等。在移动端要考虑到获取 DNS 服务地址的兼容性、tcp socket 读写次数等策略,以及简要介绍了网络质量评估方法。

发布于: 刚刚阅读数: 3
用户头像

轻口味

关注

🏆2021年InfoQ写作平台-签约作者 🏆 2017.10.17 加入

Android音视频、AI相关领域从业者,开源RTMP播放器:https://github.com/qingkouwei/oarplayer

评论

发布
暂无评论
移动端网络监控实践