likes
comments
collection
share

【KT】PC端收集路由器信息以保障应用稳定性实践

作者站长头像
站长
· 阅读数 58

前言

📢 博客首发 : 阿宽的博客

这篇文章已经是 2021.12.02 的库存了,今天扫了一眼草稿箱,发现了该文章,思前想后,还是发出来吧~ 内容较多,感兴趣的小伙伴可耐心去看

文章将循序渐进,围绕“为什么、做什么、怎么做、最后收益”给大家进行分享。

1. 场景

先给大家说明一下我们的场景,在电脑A上,A会安装我们用 Electron 开发的 PC 应用,其他人用设备连用同一个 WIFI,在同一个局域网内进行愉快的冲浪; 但是 A 是可以控制所有人设备的哦~

以上是场景的介绍,如果对场景不太了解,可以在评论区提问。

2. 背景

抓住核心重点:Window 电脑 A 与其他人的设备都连入同一个 WiFi,在同一个局域网下,管理员通过在 window 电脑上的 PC 端下发一些指令进行控制

但我们收到一些反馈,一部分用户在使用我们的 PC 应用时,管理员下发功能指令后,普通用户的设备端无法触发相应功能的情况,比如说:

  • 无法锁屏、解屏(不进行管控的话,在开会时你丫的给我用设备在冲浪摸鱼?)
  • 无法回答问题、抢答(比如年总时,主持人游戏环节,下发题目,每个普通用户的设备都会收到,此时你可以作答、抢答等)
  • 设备掉线(比如正在开会,一个会议室 100 个人,就 87 个人连上了,13 个人掉线,你说是逻辑代码有问题?还是怎么回事?)
  • ......

以开会场景为例,在1小时的会议中,在有限的时长内,出现功能不稳定的情况会极大影响体验及质量,但不稳定性的因素较多,例如:

  1. 路由器(比如我们的路由器合作方表明,当路由器内存达到 100%时,此时再接收到的包,可能会直接丢掉)
  2. 环境信号干扰(路由器旁边有电磁炉、微波炉等,它们工作时产生的磁场会对路由器发射的无线信号造成干扰)
  3. 代码写得烂(虽然说程序员不承认自己代码写的烂,但不可否认,确实有可能是软件代码写的不够好)
  4. 硬件问题、系统较低(也有可能是这个原因)
  5. ......

从表面上看不出具体问题,比如连接状态、数据包是否真实到达都无从可知。同时,在不同的区域地方,环境千差万别,影响稳定性的原因也可能各不相同,现阶段没有很好的技术手段能帮助我们分析具体原因。

所以我们需要开发一款工具,从技术层面收集影响稳定性的数据,来辅助分析不同环境下的真实原因。

需要明确的是:该工具是为了在出现问题的时候,能通过数据进行可靠性的分析,排查非程序代码导致的问题,进行可行性方案的探讨,最终保证整个 PC 应用的稳定可靠。

3. 目标

我们所希望的是,在一次有效规定时间内使用我们的应用(例如开会、上课、月总、年总等),使用工具记录下影响稳定性权重较高的不同维度数据,结合真实的不稳定情况,加上不同维度数据的辅助分析,找到问题所在,所以我们从各个端进行分析。

  • 管理设备 PC 端(本章不提)
  • 普通用户设备 Pad 端 (本章不提)
  • 路由器端 : CPU、Memory、工作信道、WiFi 相关信息、接收包/转发包等

4. 主流程

管理员一台电脑 A,多个普通人员设备 N 台,连入同一个 WiFi

大家可能对这个工具还有一丝丝的疑惑,下面是一个基本流程:

管理员在电脑上打开 PC 应用 -> 创建一个房间 -> 多台设备连入 -> 打开“数据收集”按钮 -> 数据收集(路由器端) -> 关闭“数据收集”按钮 -> 数据上报

接下来所有的事情,都在我们点击 <Button>网络稳定性数据采集</Button>这个按钮之后发生。

意味着点了这个按钮之后,我们要做的事情有:

  1. 远端登陆 KK 路由器,
  2. 登陆成功之后,执行脚本命令得到数据
  3. 数据写入本地文件系统,并经过一些正则处理
  4. 正则处理后的数据上报数据监控平台。

大家了解整个过程之后,接下来我们开始一步步实现。

5. 动手实现

在整个 PC 端进行课堂授课的局域网通信链路中,所有的数据包都需要通过路由器进行转发,从而达到目标设备,所以我们需要对路由器相关性的数据收集。

合作方的路由器(以 KK 路由器代替)提供了几个获取数据信息的脚本命令:

  • show route client -i through cpu-usage 获取 cpu 信息
  • show route client -i through memory 获取内存信息
  • show route client -i through interface 获取网口信息
  • show route client -i through wlan client 获取 wifi 相关具体信息
  • show route client -i through channel-loading 获取全量信道信息
  • show route client -i through channel-loading work-channel 获取当前工作信道信息

5.1 路由器收集实现的前置条件

  • 条件一:一台 KK 路由器设备,并保证路由器正常工作

从测试那边薅过来的,差点没跟测试打起来

【KT】PC端收集路由器信息以保障应用稳定性实践
  • 条件二:终端设备连入 KK_WiFi__5G(WiFi)
  • 条件三:远端登陆路由器设备的 IP 正确

现在条件一、条件二已经满足,我们只需要实现条件三即可,下面开始进入正文~

5.2 路由器 UML 图

“面向接口开发,依赖于抽象类”,定义一个 BaseRoute 类,该类依赖 RouteEventType 接口,由于我们目前合作的厂商是 KK 路由器,也许之后会与其他的路由器厂商合作,所以路由器的具体类是继承于基类。

【KT】PC端收集路由器信息以保障应用稳定性实践

5.3 路由器远端登陆

回到代码实现上,现况是,KK 路由器给我们提供了脚本命令,我们只需要登录到路由器,执行对应命令得到数据即可。所以第一个问题出现了:如何远端登陆 KK 路由器?

在 KK 路由器提供的默认账号、密码下,我们可通过 telnet 命令进行远程登陆,telnet 协议是 TCP/IP 协议族中的一员,它能为用户提供在本地计算机上完成远程主机工作的能力。

整体的一个流程题是怎样的呢?

【KT】PC端收集路由器信息以保障应用稳定性实践

流程图直观明了,那么在代码中如何实现?我们可以在 Node 中可通过 child_process 模块,以 exec 为例子,它会衍生 shell,然后在该 shell 中执行 command

const { exec } = require('child_process');

class KK_RouteController extends BaseRoute {
  constructor() {
    super();
  }
  login() {
    const { encoding, decode } = getExecDecode();
    exec('telnet 192.xxx.xxx.xxx', { encoding }, async (err, chunk) => {
      const data = decode(chunk).toString();
      console.log(data);
    });
  }
}

问题来了,telnet 是需要输入登陆的账号和密码。也就是说控制台打印的 data 的数据是 admin:,然后就等待你输入账号。

【KT】PC端收集路由器信息以保障应用稳定性实践

这不是我想要的效果,毕竟我们这不是终端,我想要的是:事先写好一份配置,启动时读取配置信息,完成登录逻辑

一般这种情况下,直接找轮子,看看有没有可用的,果不其然,我还真找到了一个库,node-telnet-client ,所以登陆伪代码为:

const Telent = require('telnet-client');
const config = require('./config'); // 该文件为配置文件

async function routeClientEstablishConnect() {
  let telnetClient = new Telent();
  let params = {
    port: config.port,
    host: config.host,
    username: config.username,
    password: config.password,
    timeout: config.timeout,
    execTimeout: config.execTimeout,
    negotiationMandatory: config.negotiationMandatory,
    pageSeparator: '--More--',
    shellPrompt: /CST-.*>/,
  };

  telnetClient.on('ready', function () {
    console.log('connection ready !');
    console.log('已远程连接 KK 路由器 !');
  });

  telnetClient.on('failedlogin', function () {
    console.log('failed login!');
    console.log('登陆 KK 路由器失败,请检查当前连接的 WiFi !');
  });

  telnetClient.on('timeout', function () {
    console.log('socket timeout!');
    telnetClient.end();
  });

  try {
    await telnetClient.connect(params);
    return telnetClient;
  } catch (err) {
    console.log(err);
    return null;
  }
}

路由器登陆模块代码可以写成这样:

const BaseRoute = require('./classes/BaseRoute');
const routeClientEstablishConnect = require('./client/connect');

class KKRouteController extends BaseRoute {
  constructor() {
    super();
    this.telnetClient = null;
  }

  login() {
    return new Promise((resolve, reject) => {
      routeClientEstablishConnect()
        .then((telnetClient) => {
          if (telnetClient) {
            this.telnetClient = telnetClient;
            resolve(telnetClient);
          } else {
            resolve(null);
          }
        })
        .catch((err) => {
          reject(err);
        });
    });
  }

  /**
   * @description 获取路由器CPU信息
   */
  async getCpu() {
    if (this.telnetClient) {
      let res = await this.telnetClient.exec('show route client -i through cpu-usage');
      this.saveFile(`router_cpu_file_${new Date().valueOf()}`, res);
    }
  }
  /**
   * @description 获取路由器内存信息
   */
  async getMemory() {}

  // 更多实现就不写了
  async getInterfacePort() {}
  async getWifiDetail() {}
  async scanWorkChannel() {}
  async scanTotalChannel() {}
}

5.4 路由器数据收集时序图

当我们可以远端登陆路由器之后,通过 KK 路由器提供的命令获取数据,接着经过一系列的特定解析,写入本地文件。

我们以 10s 为一轮,换言之:每 10s 跑命令获取一下路由器的数据信息,路由器信息包含:

  • cpu
  • memory
  • interface-port
  • wifi-detail
  • work-channel

10s 之后,进行新一轮的数据收集,以此类推,为此我给大家画了一个时序图,如下

【KT】PC端收集路由器信息以保障应用稳定性实践

5.5 路由器数据收集实现

“梦想很美好 现实很骨感“ ,当我点击“开始收集”按钮时,进入到数据收集阶段,此时开始收集数据。我们所期望的是,在一定的时间内(默认 10s),获取路由器的相关信息。

来看看代码如何实现的

const KKRouteController = require('./container/route/KKRouteController');

class RouteModel {
  constructor() {
    this.timer = -1; // 定时器运行命令
    this.client = 0; // 连接登陆的实例
    this.route = new KKRouteController();

    this.route.login().then((telnetClient) => {
      this.client = telnetClient;
      if (telnetClient) {
        telnetClient.on('close', () => {
          console.log('失去 KK 路由器主机的连接,当前连接的不是 KK 路由器 WiFi');
        });
      }
    });
  }

  async start() {
    console.log('开始收集KK路由器指标数据');
    this.timer = setInterval(() => {
      this.route.getCpu();
      this.route.getMemory();
      // this.route.getInterfacePort();
      // this.route.getWifiDetail();
      // this.route.scanWorkChannel();
    }, 10000);
  }
}

如上,我们添加定时器,每 10s 就获取路由器的 cpu 和 memory 数据。执行的命令各不同,但实际上,预期与我们有些偏差。最终我们写入文件的内容居然都是一致的。

【KT】PC端收集路由器信息以保障应用稳定性实践

这就很离谱了,讲道理打印的数据不应该一致,怎么回事???

【KT】PC端收集路由器信息以保障应用稳定性实践

5.5.1 并行脚本 vs 串行脚本?

我在想,有没有可能是同一时间刻,只能执行一条脚本命令?于是我将并行执行的代码改写成串行,来看看效果

class RouteModel {
  // ...

  async start() {
    console.log('开始收集KK路由器指标数据');
    this.timer = setInterval(async () => {
      let cpuData = await this.route.getCpu();
      if (cpuData) {
        let memortyData = await this.route.getMemory();
        if (memortyData) {
          // ...
        }
      }
    }, 10000);
  }
}

改成这样是否可行呢?来看看结果,仍是每 10s 执行一次,最终写入的数据是不同的,符合预期。

【KT】PC端收集路由器信息以保障应用稳定性实践

事实证明:必须得串行执行脚本命令,才不会出现问题! 那么并行执行为什么会出现问题呢?接着往下看

5.5.2 多定时器踩坑?

讲道理我们全改成串行执行脚本命令,就能正确获取路由器相关的数据,但在 KK 路由器提供的命令中,show route client -i through channel-loading(获取全量信道信息) 这条命令是比较耗时耗性能,KK 那边的相关人员建议不要太频繁执行,为此,该命令的执行频率与常规命令的执行频率存在差异。

  • 常规命令:每 10s 执行一次
  • 耗时命令:每 5 分钟执行一次

必然需要两个定时器去处理,作为过来人,一般有定时器出现我都觉得是个坑,于是我写了一段代码,来模拟一个场景:第一个定时器 3s,第二个定时器 6s,我在到达 10s 之后,关闭按钮,此时我们看看数据

class RouteModel {
  // ...

  async start() {
    console.log('开始收集KK路由器指标数据');
    this.timer = setInterval(async () => {
      let cpuData = await this.route.getCpu();
      if (cpuData) {
        let memortyData = await this.route.getMemory();
        if (memortyData) {
          // ...
        }
      }
    }, 3000);

    // 6s 执行一次全信道的检测
    this.channelTimer = setInterval(async () => {
      await this.route.scanTotalChannel();
    }, 6000);
  }
}

期望值:打印的数据数据均正确,同时文件数量应该是对的

  • 3 个 cpu 文件(3s 执行一次,10s 会执行 3 次,应该是 3 个文件)
  • 3 个 memory 文件 (3s 执行一次,10s 会执行 3 次,应该是 3 个文件)
  • 1 个 channel_total 文件

大家猜一下,是不是如我所期望的一样?一般这么问,那肯定并不是了。来看看结果如何,看看具体的打印数据及顺序

【KT】PC端收集路由器信息以保障应用稳定性实践

最终文件:3 个 cpu,2 个 memory,1 个 channel_total

【KT】PC端收集路由器信息以保障应用稳定性实践

回到上一张图片,在控制台可以看到打印的数据和报错的信息,一共有三个问题:

  1. 在第 6s 时,同时跑两个定时器,数据错乱?(应该写入 cpu 数据变成 channels 的数据)
  2. Error: response not received ?
  3. 第二轮时的 scanTotalChannel() 比 getMemory() 优先打印?(看第一个红框)

接下来一个个问题解决吧。

问题一:同时跑两个定时器,数据错乱

命令执行的结果数据是通过 node-telnet-client 库提供的 exec 方法,通过阅读源码,我们可以看到原因

module.exports = class Telnet extends events.EventEmitter {
  constructor(){
    this.socket = null
  }

  // 连接
  connect(ots) {
    this.socket.on('data', (data) => {
      // ...
      this._parseData(data, () => {
         // ...
      })
    )
  }

  // 解析数据
  _parseData(chunk, callback) {
    // ...
    let response = this.inputBuffer.split(this.irs);
    // ...
    this.response = response;
  }

  // 执行命令
  exec (cmd, opts, callback) {
    return new Promise((resolve, reject) => {
      // ...
      if (this.response !== 'undefined') {
        resolve(this.response.join('\n'))
      }
    })
  }
}

我们登录 KK 路由器之后,new Telnet(config) 的是一个实例,每个命令均使用该实例的 exec 执行,这就意味着,如果同时执行多个 exec,执行的结果都会写到 inputBuffer 缓冲区中,从缓冲区中取数据,挂在 this.response 上。

我猜测:如果同一时刻出现多个 exec,打印的永远是最后一个 exec 的数据。这也就是为什么要串行而不能并行的原因?

问题二:Error: response not received

该错误也是 node-telnet-client 抛出,通过阅读源码,可以知道报错的主要原因是:execTimeout 是指执行 exec() 后服务响应数据的等待时长,如果在执行时间内未响应,则抛出异常错误。

this.socket.write(cmd, () => {
  let execTimeout = null;
  // ...

  if (this.execTimeout) {
    execTimeout = setTimeout(() => {
      execTimeout = null;
      // ...
      reject(new Error('response not received'));
    }, this.execTimeout);
  }
});

问题三:第二轮的时候为什么 scanTotalChannel() 比 getMemory() 优先打印?

这就是 event loop 的原因了。再回顾一下:“第一个定时器 3s,第二个定时器 6s,我在到达 10s 之后,关闭按钮”,所以我的理解为:

首先 JS 是单线程执行,也就是说所有的任务都会被推到执行栈中,当 JS 主线程空闲之后,从执行栈中取出 fn 进行处理。如果 fn 中存在异步,那么会将其放到 callback queue (event loop)中,等待 JS 主线程的召唤。当执行栈为空,由宿主环境——浏览器提供的 event loop 会将 callback queue 中的方法添加到执行栈中。依次执行。

【KT】PC端收集路由器信息以保障应用稳定性实践

class RouteModel {
  async start() {
    // 3s 执行一次常规命令
    this.timer = setInterval(async () => {
      let cpuData = await this.route.getCpu();
      if (cpuData) {
        let memortyData = await this.route.getMemory();
        if (memortyData) {
          // ...
        }
      }
    }, 3000);

    // 6s 执行一次全信道的检测
    this.channelTimer = setInterval(async () => {
      await this.route.scanTotalChannel();
    }, 6000);
  }
}

结合上面代码和图片,我们可以看到,从代码上看,好似入栈的顺序是:getCPU → getMemory → scanTotalChannel,但实际上,必须得等 getCPU 异步执行完成之后,将 getMemory 放到 callback queue 中,因为 getCPU 是异步的,此时的同步任务定时器 scanTotalChannel 已经被添加到执行栈中了,真正的入栈顺序是 getCPU → scanTotalChannel → getMemory。这也就是红方框中,先打印 total_channel 的数据了。

所以结论为:不能并行执行脚本命令,同时不能以写死的时间为定时时长,而是以一轮中的所有 await 都执行完,再给间隔执行下一轮,下一轮执行必须等上一轮执行完毕!

最终我的代码修改为:

class KKRouteController extends BaseRoute {
  // ...
  
  promiseDispatch(cmd) {
    return new Promise(async (resolve, reject) => {
      if (this.telnetClient) {
        const { encoding, decode } = getExecDecode();
        let res = await this.telnetClient.exec(cmd, { encoding });
        if (res) resolve(decode(res));
        else reject(`get [${cmd}] fail !`);
      } else reject(new Error('telnet client connect fail !'));
    });
  }

  getCPU() {
    return new Promise((resolve, reject) => {
      this.promiseDispatch('show route client -i through cpu-usage')
        .then((res) => {
          this.saveFile(`cpu_${new Date().valueOf()}`, res);
          resolve(1);
        })
        .catch((err) => {
          reject(err);
        });
    });
  }

  getMemory() {
    return new Promise((resolve, reject) => {
      this.promiseDispatch('show route client -i through memory')
        .then((res) => {
          this.saveFile(`memory_${new Date().valueOf()}`, res);
          resolve(1);
        })
        .catch((err) => {
          reject(err);
        });
    });
  }
  // ...
}

定时器逻辑修改成这样

// 串行调用
class RouteModel {
  //...

  async start() {
    if (this.client) {
      /**
       * 由于 login 是一个异步过程,需要等待异步执行完毕,再执行同步的 _run
       * 否则会出现 login 还未 connect,就执行同步的 _run 导致 telnet-client 连接失败的BUG
       */
      setTimeout(() => {
        this._run();
      }, 3000);
    }
  }

  // 不管命令执行成功失败,在上一条执行完毕,就会执行下一条
  _run() {
    this.route.getCPU().finally(() => {
      this.route.getMemory().finally(() => {
        this.route.getInterfacePort().finally(() => {
          this.route.getWifiDetail().finally(() => {
            this.route.scanWorkChannel().finally(() => {
              this.runCount++;
              if (this.runCount > 0 && this.runCount % this.scanTotalDiff === 0) {
                this.route.scanTotalChannel().finally(() => {
                  if (this.execTimer) clearTimeout(this.execTimer);
                  this.execTimer = setTimeout(() => {
                    this._run(startRunTime);
                  }, 10000);
                });
              } else {
                if (this.execTimer) clearTimeout(this.execTimer);
                this.execTimer = setTimeout(() => {
                  this._run(startRunTime);
                }, 10000);
              }
            });
          });
        });
      });
    });
  }

  end() {
    if (this.timer) clearInterval(this.execTimer);
  }
}

5.6 数据本地化存储 + 上报 kibana 平台

命令执行完毕,我做了两件事:

  1. 对原始数据进行解析,提取有效数据,构造 json 上报至 kibana
  2. 将原始数据写入到了本地文件系统(说白了 txt 格式存储于本地)

有小伙伴就有疑惑了,为什么要上报到 kibana ? 为什么要保留原始数据 txt?

这里我的考虑是:

  • 上报 kibana 是为了能在监控平台能够可视化图表看到相关数据(这里还有其他原因就不过多赘述)
  • 保留原始数据原因是因为经过解析的 json 是我们人为正则解析,如果出现了一些问题,导致这几天收集上报的收集都是空白的 ,这时候通过原始文件排查,快速定位问题。

5.6.1 目录设计(自动生成文件夹)

因为我们的 PC 应用是用 Electron 实现的,Electron 内置了 Node 特性,所以当用户打开我们的 PC 应用,我们可通过 node fs 文件系统在用户电脑的 AppData 目录中,默认创建 networkStabilityServer / routeNetWorkData / [year-month-day] / cstore 目录,解释说明一下:

  • networkStabilityServer 监控数据目录
    • xxxNetWorkData 其他端监控数据目录
    • routeNetWorkData 路由器监控数据目录
      • [year-month-day] 以每天为文件夹名,存储当日用户进行的监控数据
        • cstore 存放每一轮[ 开始- 结束 ]监控这段时间内,一定间隔、经过解析的 json 文件
【KT】PC端收集路由器信息以保障应用稳定性实践

该文件夹下,存在原始文件 txt 与 cstore 文件夹 ,保留原始数据 txt 是为了到时候能够排查数据

【KT】PC端收集路由器信息以保障应用稳定性实践

5.6.2 本地存储方案

虽说把原始数据存在本地,但长期如此,会导致本地存储原始数据文件会导致内存占用过多,会存在许多失效数据(比如半年前、三个月前的数据,我认为是失效无用),经过讨论,我定了一个有效时长阀值,当用户开始监控收集时,会自动扫描 appData 目录下的文件夹,当达到阀值,删除阀值之前的文件夹

一天只扫描一次,例如 1 号早上 7 点跟晚上 7 点去判断七天有效期,都一样,没必要多次扫描

【KT】PC端收集路由器信息以保障应用稳定性实践

// 每天检测一次7天前的原始数据
detectValidTime(rootDirName) {
  const newDate = intToDateString(Date.parse(new Date()));
  const networkCurrentDateTime = window.localStorage.getItem('networkCurrentDateTime');
  // 一天只会执行一次
  if (!networkCurrentDateTime || newDate !== networkCurrentDateTime.split('_')[1]) {
    window.localStorage.setItem('networkCurrentDateTime', `networkCurrentDateTime_${newDate}`);
    this.deleteHistoryFolder(newDate, rootDirName);
  }
}
// 执行删除7天前的原始文件数据
deleteHistoryFolder(newDate, rootDirName) {
  fileAction.readDir(`${config.savePath}/${config.networkMonitorRootDirName}`).then((files) => {
    if (files.includes(rootDirName)) {
      fileAction.readDir(`${config.savePath}/${config.networkMonitorRootDirName}/${rootDirName}`).then((routeFolder) => {
        // 得到 networkRouteMonitorData 目录下,所有以 [year]-[month]-[day] 命名的目录
        const needDeleteFolderList = [];
        const newDateTimeStamp = new Date(newDate).valueOf();
        if (routeFolder && routeFolder.length > 0) {
          routeFolder.forEach((routeFileName) => {
            const fileNameDateTimeStamp = new Date(routeFileName).valueOf();
            if (newDateTimeStamp - config.deleteTimeStamp > fileNameDateTimeStamp) {
              // 说明该文件夹是 7 天前,需要删除
              needDeleteFolderList.push(routeFileName);
            }
          });
        }
        if (needDeleteFolderList.length > 0) {
          needDeleteFolderList.forEach((deleteFileName) => {
            fileAction.removeDir(`${config.savePath}/${config.networkMonitorRootDirName}/${rootDirName}/${deleteFileName}`).then(() => {
              console.log('删除成功');
            });
          });
        }
      });
    } else {
        console.log(`不存在文件夹 ${rootDirName}`);
    }
  }).catch((err) => {
    console.log(`不存在文件夹 ${config.networkMonitorRootDirName}`);
  });
}

5.6.3 上报 kibana

我们这边有一些业务方面的需求,因为数据方面有很多字段,并不是每个字段都需要,所以经过数据的处理,构造了一个 json 文件,会将该 txt 文件上传 cdn。下面是上传的逻辑

const { ipc } = require('sugar-electron/render');

// 上传json文件
async uploadNetworkServerFiles() {
  const fileUrl = getCDNFile();
  const result = await uploadQnFile(fileUrl);
  if (result && result.data && result.data.downloadUrl) {
    // 通过 sugar-electron 提供的 ipc 模块向服务进程发消息,表示要上报 kibana
    ipc.request('service', `rp/push`, {
      event: 'networkStability',
      param: {
        url: result.data.downloadUrl,
      }
    });
  }
}

基于公司内部封装的库,将数据上报到 kibana 平台

const { ipc } = require('sugar-electron/render');
const Monitor = require("monitor-xxx"); // 内部npm库

const MonitorInstance = new Monitor({
  // ...
});

const MyMonitor = {
  push: ({ event, param } = {}, next) {
    const upJson = {
      // 巴拉巴拉的一堆处理
    }
    MonitorInstance.push(upJson);
  }
}

ipc.response(`rp/push`, MyMonitor.push);

6. 异常处理

到此为止,我们的主流程已经完成,路由器数据收集方面的工作已经结束。我本以为解放了,没想到噩梦才刚开始,异常处理才是最为难受

6.1 老师直接关机或软件异常退出

有时候用户并不会如我们所愿,通过程序提供的 “退出” 按钮退出程序,有些用户比较“粗暴”,他会直接关机强退程序。我**的

【KT】PC端收集路由器信息以保障应用稳定性实践

比如我们正在收集数据,收集结束之后的逻辑处理,是在用户点击按钮关闭之后才执行的,但如果我们还在收集,用户直接把电脑关机了,或者电脑不懂事,自动重启,这就凉了。

虽说我们也做了程序退出的异常处理,但可能应用退出,文件数据来不及上报 kibana、上传到 cdn,这就会导致辛苦收集的数据作废。

所以必须做异常处理,我的解决方案是:将此次记录的数据文件地址进行缓存,下次再进行上传

【KT】PC端收集路由器信息以保障应用稳定性实践

class NetworkStabilityServer {
  constructor() {
    this.processing = false;
    this.routeStability = new RouteStability();
  }
  async start() {
    if (this.processing) return;
    this.processing = true;
    // 判断上次是不是直接退出 数据还没来的及上传
    this._judgeLastIsQuit();
    // 每次开始进行数据收集,都会得到该轮文件写入的具体文件路径
    const routeStabilityDir = await this.routeStability.start();
    // 把此次记录数据的文件路径都记录下来,如果应用直接退出来不及上传 cdn,下次的时候把数据进行上传。
    window.localStorage.setItem('networkStabilityServerFile', {
      ...routeStabilityDir,
    });
  }

  async _judgeLastIsQuit() {
    const networkStabilityServerFile = window.localStorage.getItem('networkStabilityServerFile');
    if (networkStabilityServerFile) {
      // 上传...
    }
  }
}

6.2 断网、切换 WIFI 异常处理

我们路由器收集是需要通过 telnet 远端登陆到路由器,从而进行数据收集,但如果发生以下情况,会出现问题:

  1. 用户一开始连接的不是 KK WiFi,之后切换至 KK WiFi
  2. 用户连接的是 KK WiFi,但中途切换其他 WiFi,或者开了飞行模式,再切回来
  3. 网络波动,导致断网等情况出现
  4. ......

解决方案:对于网络模块的切换进行监听,当网络发生变化,就尝试进行登陆 KK 路由器,如能登陆成功,继续写入当前的 txt 文件

注意是当前这一轮的数据,比如你 10 点钟,点击按钮进行收集,此时你处于 processing 状态,这时候你切换 WiFi,肯定期望数据还是写在 10 点这一次的数据中,而不是新增一份数据

class RouteModel {
    //...

    // 监听网络变化
    ipc.subscribe('main', 'network-list-change', () => {
      if (!this.processing) return;
      if (this.execTimer) clearTimeout(this.execTimer);
      if (this.restartExecTimer) clearTimeout(this.restartExecTimer);
      this._restartExec();
    });
  }

  // 尝试连入(可能超时、切换网络、无网络状态等)
  _restartExec() {
    this.processing = true;
    this.route
      .login()
      .then((telnetClient) => {
        this.client = telnetClient;
        if (telnetClient && this.processing) {
          if (this.restartExecTimer) clearTimeout(this.restartExecTimer);
          this.restartExecTimer = setTimeout(() => {
            this._exec(this.startRunTime);
          }, 3000);
        }
      })
      .catch((err) => {
        throw new Error('登录失败,当前无网络或连入的非 KK WIFI');
      });
  }
}

6.3 IP 动态修改,导致无法登陆 KK 路由器

有一天早上,在厕所摸了一会鱼,准备开始继续忙活,好家伙,直接发现登陆不了,通过控制台打印,发现原来是 IP 错误

【KT】PC端收集路由器信息以保障应用稳定性实践

IP 是会发生改变的,所以不能默认写死 config.host = 192.xxx.xxx.xxx ,看起来得整一个 “检查网关地址”的模块,动态获取默认网关

6.3.1 通过 ipconfig 获取

当时也没多想,就通过 ipconfig 进行获取本机信息

【KT】PC端收集路由器信息以保障应用稳定性实践

于是信誓旦旦的写下了这一段代码

const Telent = require('telnet-client');
const config = require('./config');
const { exec } = require('child_process');
const getExecDecode = require('../../../utils/exec');

// 正则获取 ipconfig 里的默认网关
function watchRouteGateWayIp() {
  return new Promise((resolve, reject) => {
    let defaultGateway = '';
    const { encoding, decode } = getExecDecode();
    exec('ipconfig', { encoding }, async (err, chunk) => {
      const data = decode(chunk).toString();
      if (err) {
        defaultGateway = config.host;
      } else {
        const parseArr = [];
        data.split('\n').forEach((s) => {
          if (s.trim()) parseArr.push(s.trim());
        });
        const wifiDetail = parseArr
          .join('$$')
          .match(/无线局域网适配器.*?以太网适配器/gi);
        if (wifiDetail) {
          defaultGateway =
            /默认网关.*: (.*)/.exec(wifiDetail)[1].split('$$')[0] ||
            config.host;
        } else {
          defaultGateway = config.host;
        }
      }
      resolve(defaultGateway);
    });
  });
}

async function routeClientEstablishConnect() {
  let telnetClient = new Telent();
  const defaultGatewayIP = await watchRouteGateWayIp();
  let params = {
    port: config.port,
    host: defaultGatewayIP,
    username: config.username,
    password: config.password,
    // ...
  };

  // connect...
}

module.exports = routeClientEstablishConnect;

6.4 ipconfig 数据不同,获取默认网关错误

然后过了两天,我又刚准备工作,就被告知,在落地验证的区域地方中,同一个地方,在不同房间进行验证,有的就能收集到路由器相关数据,有些就没有,好家伙,这让我百思不得其解。

【KT】PC端收集路由器信息以保障应用稳定性实践

很难想到是什么问题,关键是 3 班都有数据,1、2 班就没数据,你说是代码问题嘛,那应该都没数据才对啊,于是我自己本地验证,没问题啊,怎么发版本出去,就一些班级有问题了。

【KT】PC端收集路由器信息以保障应用稳定性实践

于是我只能跟在别人休息的时候,通过向日葵远端控制软件,远端调试看看到底什么问题。最终定位出原因了,确实是我代码问题,淦!

具体原因就是不同 window 版本下的 ipconfig 数据不一样,导致正则解析出错,最终 ip 获取失败。

看看我正则是怎么写的,我以 win10 输出的数据进行解析,以无线局域网适配器开头,以 以太网适配器结尾

const wifiDetail = parseArr
  .join('$$')
  .match(/无线局域网适配器.*?以太网适配器/gi);
if (wifiDetail) {
  defaultGateway =
    /默认网关.*: (.*)/.exec(wifiDetail)[1].split('$$')[0] || config.host;
}

【KT】PC端收集路由器信息以保障应用稳定性实践

但是这种解析规则在 win11 下,就有问题了,淦!

【KT】PC端收集路由器信息以保障应用稳定性实践

于是我就改了一下获取默认网关的逻辑

function watchRouteGateWayIp() {
  return new Promise((resolve, reject) => {
    let defaultGateway = '';
    const { encoding, decode } = getExecDecode();
    exec('ipconfig', { encoding }, async (err, chunk) => {
      const data = decode(chunk).toString();
      if (err) {
        defaultGateway = config.host;
      } else {
        const parseArr = [];
        data.split('\n').forEach((s) => {
          if (s.trim()) parseArr.push(s.trim());
        });
        const wifiGateWayIndex = parseArr
          .join('$$')
          .lastIndexOf('无线局域网适配器 WLAN');
        if (wifiGateWayIndex !== -1) {
          const searchStr = parseArr.join('$$').slice(wifiGateWayIndex);
          const gateWayValue = searchStr.slice(searchStr.indexOf('默认网关'));
          const defaultIpValue = /默认网关.*: (.*)/
            .exec(gateWayValue)[0]
            .split('$$')[0];
          if (defaultIpValue) {
            defaultGateway = defaultIpValue.split(':')[1]
              ? defaultIpValue.split(':')[1].trim()
              : config.host;
          }
        } else {
          defaultGateway = config.host;
        }
        console.log('connect to KK:', defaultGateway);
      }
      resolve(defaultGateway);
    });
  });
}

经过修改,在 maxhub 大板(win11)、thinkPad T480(win10)、HUAWEI MateBook D14(win10)、Microsoft Surface(win10)上进行测试,均未发现问题。

6.5 无线没问题,有线连入又出问题了

很明显的错误,我们获取默认网关的正则匹配都是针对无线 WiFi,但如果此时是通过有线连入,则默认使用的是 config.host,当路由器 ip 被修改,最终结果就是:有线连入的电脑,并不能收集到路由器相关的数据。

【KT】PC端收集路由器信息以保障应用稳定性实践

解决方案:当在无线 WiFi 情况下,如果获取默认网关 ip 失败,那么将进行有线的处理,如果有线也没能获取正确的默认网关,则采用默认的 config.host

这个就不贴代码了,大家知道就好了。

6.6 正则都不靠谱,还是找个库吧

其实一直都知道通过正则是不靠谱的,但是还是想试试,不过最终肯定还是要通过一些可靠的第三方库获取默认网关。

不然哪天正则又没了可咋办,或者现在 win10 、win11 没问题了,后面来个 win7 版本的,那我直接凉凉了。于是踏上寻找可靠库的艰辛路程。

6.6.1 node 的 os 模块

node 本身提供了 os 操作系统 模块,该模块提供了 networkInterfaces() 方法,返回包含已分配网络地址的网络接口的对象。但是只有 mac 地址、ipv4 地址、ipv6 地址,就是没有默认网关的地址,淦,这条路走不通,换一个

6.6.2 network 库

在不断寻找可用轮子,发现了一个库,发现了 network 这个库,它提供了一个方法叫做 get_active_interface,于是我高高兴兴写下了这段代码

const network = require('network');
network.get_active_interface(function(err, obj) {
  console.log('###', obj);
  console.log('@@@', err);
}

打印的结果为:

【KT】PC端收集路由器信息以保障应用稳定性实践

年轻人不讲武德?????怎么没有???我看了 README 说明

Returns the IP, MAC address and interface type for the active network interface. On OS X and Linux you also get the IP of its assigned gateway.

淦!我的是 window ,这条路走不通,换一条

6.6.3 default-gateway 库

default-gateway 太猛了,好顶 👍,还是这个牛逼

const defaultGateWayUtils = require('default-gateway');

function watchRouteGateWayIp() {
  return new Promise((resolve, reject) => {
    let defaultGateway = '';
    const { gateway } = defaultGateWayUtils.v4.sync();
    if (defaultGateway) defaultGateway = gateway;
    else defaultGateway = config.host;
    resolve(defaultGateway);
  });
}

7. 最终效果

最后经过几个班级的验证,该功能慢慢趋于稳定,也通过该工具去发现了一些非代码层面的问题。比如有学生掉线又重新连接,比如当前某个 WiFi 的传输速率太低,连入另一个 WiFi,再比如学生 ping

下面给大家截几张图

【KT】PC端收集路由器信息以保障应用稳定性实践

【KT】PC端收集路由器信息以保障应用稳定性实践

【KT】PC端收集路由器信息以保障应用稳定性实践

8. 最后

这也是第一次做路由器收集相关工作,中间也是遇到了许多跟网络相关的问题,从文章的问题来看其实还是较为简单的,但第一次遇到的时候还是会有点头脑发蒙,不过总体来看,还是有所成长有所收获,并且做 PC 还是挺有趣的。

以上就是本次文章的所有内容了。

该文章撰写于 2021.12.02 月,过了一年半,才发出来,属实有点久...

最后,阿宽写了本跟 Electron 相关的小册《Electron + React 从 0 到 1 实现简历平台实战》,对 Electron 感兴趣的小伙伴可以看看。

转载自:https://juejin.cn/post/7201002219490328633
评论
请登录