【KT】PC端收集路由器信息以保障应用稳定性实践
前言
📢 博客首发 : 阿宽的博客
这篇文章已经是 2021.12.02
的库存了,今天扫了一眼草稿箱,发现了该文章,思前想后,还是发出来吧~ 内容较多,感兴趣的小伙伴可耐心去看
文章将循序渐进,围绕“为什么、做什么、怎么做、最后收益”给大家进行分享。
1. 场景
先给大家说明一下我们的场景,在电脑A上,A会安装我们用 Electron 开发的 PC 应用,其他人用设备连用同一个 WIFI,在同一个局域网内进行愉快的冲浪; 但是 A 是可以控制所有人设备的哦~
以上是场景的介绍,如果对场景不太了解,可以在评论区提问。
2. 背景
抓住核心重点:Window 电脑 A 与其他人的设备都连入同一个 WiFi,在同一个局域网下,管理员通过在 window 电脑上的 PC 端下发一些指令进行控制
但我们收到一些反馈,一部分用户在使用我们的 PC 应用时,管理员下发功能指令后,普通用户的设备端无法触发相应功能的情况,比如说:
- 无法锁屏、解屏(不进行管控的话,在开会时你丫的给我用设备在冲浪摸鱼?)
- 无法回答问题、抢答(比如年总时,主持人游戏环节,下发题目,每个普通用户的设备都会收到,此时你可以作答、抢答等)
- 设备掉线(比如正在开会,一个会议室 100 个人,就 87 个人连上了,13 个人掉线,你说是逻辑代码有问题?还是怎么回事?)
- ......
以开会场景为例,在1小时的会议中,在有限的时长内,出现功能不稳定的情况会极大影响体验及质量,但不稳定性的因素较多,例如:
- 路由器(比如我们的路由器合作方表明,当路由器内存达到 100%时,此时再接收到的包,可能会直接丢掉)
- 环境信号干扰(路由器旁边有电磁炉、微波炉等,它们工作时产生的磁场会对路由器发射的无线信号造成干扰)
- 代码写得烂(虽然说程序员不承认自己代码写的烂,但不可否认,确实有可能是软件代码写的不够好)
- 硬件问题、系统较低(也有可能是这个原因)
- ......
从表面上看不出具体问题,比如连接状态、数据包是否真实到达都无从可知。同时,在不同的区域地方,环境千差万别,影响稳定性的原因也可能各不相同,现阶段没有很好的技术手段能帮助我们分析具体原因。
所以我们需要开发一款工具,从技术层面收集影响稳定性的数据,来辅助分析不同环境下的真实原因。
需要明确的是:该工具是为了在出现问题的时候,能通过数据进行可靠性的分析,排查非程序代码导致的问题,进行可行性方案的探讨,最终保证整个 PC 应用的稳定可靠。
3. 目标
我们所希望的是,在一次有效规定时间内使用我们的应用(例如开会、上课、月总、年总等),使用工具记录下影响稳定性权重较高的不同维度数据,结合真实的不稳定情况,加上不同维度数据的辅助分析,找到问题所在,所以我们从各个端进行分析。
- 管理设备 PC 端(本章不提)
- 普通用户设备 Pad 端 (本章不提)
- 路由器端 : CPU、Memory、工作信道、WiFi 相关信息、接收包/转发包等
4. 主流程
管理员一台电脑 A,多个普通人员设备 N 台,连入同一个 WiFi
大家可能对这个工具还有一丝丝的疑惑,下面是一个基本流程:
管理员在电脑上打开 PC 应用 -> 创建一个房间 -> 多台设备连入 -> 打开“数据收集”按钮 -> 数据收集(路由器端) -> 关闭“数据收集”按钮 -> 数据上报
接下来所有的事情,都在我们点击 <Button>网络稳定性数据采集</Button>
这个按钮之后发生。
意味着点了这个按钮之后,我们要做的事情有:
- 远端登陆 KK 路由器,
- 登陆成功之后,执行脚本命令得到数据
- 数据写入本地文件系统,并经过一些正则处理
- 正则处理后的数据上报数据监控平台。
大家了解整个过程之后,接下来我们开始一步步实现。
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 路由器设备,并保证路由器正常工作
从测试那边薅过来的,差点没跟测试打起来

- 条件二:终端设备连入 KK_WiFi__5G(WiFi)
- 条件三:远端登陆路由器设备的 IP 正确
现在条件一、条件二已经满足,我们只需要实现条件三即可,下面开始进入正文~
5.2 路由器 UML 图
“面向接口开发,依赖于抽象类”,定义一个 BaseRoute 类,该类依赖 RouteEventType
接口,由于我们目前合作的厂商是 KK 路由器,也许之后会与其他的路由器厂商合作,所以路由器的具体类是继承于基类。
5.3 路由器远端登陆
回到代码实现上,现况是,KK 路由器给我们提供了脚本命令,我们只需要登录到路由器,执行对应命令得到数据即可。所以第一个问题出现了:如何远端登陆 KK 路由器?
在 KK 路由器提供的默认账号、密码下,我们可通过 telnet
命令进行远程登陆,telnet 协议是 TCP/IP 协议族中的一员,它能为用户提供在本地计算机上完成远程主机工作的能力。
整体的一个流程题是怎样的呢?

流程图直观明了,那么在代码中如何实现?我们可以在 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:
,然后就等待你输入账号。

这不是我想要的效果,毕竟我们这不是终端,我想要的是:事先写好一份配置,启动时读取配置信息,完成登录逻辑。
一般这种情况下,直接找轮子,看看有没有可用的,果不其然,我还真找到了一个库,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 之后,进行新一轮的数据收集,以此类推,为此我给大家画了一个时序图,如下
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 数据。执行的命令各不同,但实际上,预期与我们有些偏差。最终我们写入文件的内容居然都是一致的。
这就很离谱了,讲道理打印的数据不应该一致,怎么回事???

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 执行一次,最终写入的数据是不同的,符合预期。
事实证明:必须得串行执行脚本命令,才不会出现问题! 那么并行执行为什么会出现问题呢?接着往下看
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 文件
大家猜一下,是不是如我所期望的一样?一般这么问,那肯定并不是了。来看看结果如何,看看具体的打印数据及顺序
最终文件:3 个 cpu,2 个 memory,1 个 channel_total
回到上一张图片,在控制台可以看到打印的数据和报错的信息,一共有三个问题:
- 在第 6s 时,同时跑两个定时器,数据错乱?(应该写入 cpu 数据变成 channels 的数据)
- Error: response not received ?
- 第二轮时的 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 中的方法添加到执行栈中。依次执行。
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 平台
命令执行完毕,我做了两件事:
- 对原始数据进行解析,提取有效数据,构造 json 上报至 kibana
- 将原始数据写入到了本地文件系统(说白了 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 文件
- [year-month-day] 以每天为文件夹名,存储当日用户进行的监控数据

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

5.6.2 本地存储方案
虽说把原始数据存在本地,但长期如此,会导致本地存储原始数据文件会导致内存占用过多,会存在许多失效数据(比如半年前、三个月前的数据,我认为是失效无用),经过讨论,我定了一个有效时长阀值,当用户开始监控收集时,会自动扫描 appData 目录下的文件夹,当达到阀值,删除阀值之前的文件夹
一天只扫描一次,例如 1 号早上 7 点跟晚上 7 点去判断七天有效期,都一样,没必要多次扫描
// 每天检测一次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 老师直接关机或软件异常退出
有时候用户并不会如我们所愿,通过程序提供的 “退出” 按钮退出程序,有些用户比较“粗暴”,他会直接关机强退程序。我**的

比如我们正在收集数据,收集结束之后的逻辑处理,是在用户点击按钮关闭之后才执行的,但如果我们还在收集,用户直接把电脑关机了,或者电脑不懂事,自动重启,这就凉了。
虽说我们也做了程序退出的异常处理,但可能应用退出,文件数据来不及上报 kibana、上传到 cdn,这就会导致辛苦收集的数据作废。
所以必须做异常处理,我的解决方案是:将此次记录的数据文件地址进行缓存,下次再进行上传
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 远端登陆到路由器,从而进行数据收集,但如果发生以下情况,会出现问题:
- 用户一开始连接的不是 KK WiFi,之后切换至 KK WiFi
- 用户连接的是 KK WiFi,但中途切换其他 WiFi,或者开了飞行模式,再切回来
- 网络波动,导致断网等情况出现
- ......
解决方案:对于网络模块的切换进行监听,当网络发生变化,就尝试进行登陆 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 错误
IP 是会发生改变的,所以不能默认写死 config.host = 192.xxx.xxx.xxx
,看起来得整一个 “检查网关地址”的模块,动态获取默认网关
6.3.1 通过 ipconfig 获取
当时也没多想,就通过 ipconfig
进行获取本机信息
于是信誓旦旦的写下了这一段代码
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 数据不同,获取默认网关错误
然后过了两天,我又刚准备工作,就被告知,在落地验证的区域地方中,同一个地方,在不同房间进行验证,有的就能收集到路由器相关数据,有些就没有,好家伙,这让我百思不得其解。
很难想到是什么问题,关键是 3 班都有数据,1、2 班就没数据,你说是代码问题嘛,那应该都没数据才对啊,于是我自己本地验证,没问题啊,怎么发版本出去,就一些班级有问题了。

于是我只能跟在别人休息的时候,通过向日葵远端控制软件,远端调试看看到底什么问题。最终定位出原因了,确实是我代码问题,淦!
具体原因就是不同 window 版本下的 ipconfig 数据不一样,导致正则解析出错,最终 ip 获取失败。
看看我正则是怎么写的,我以 win10 输出的数据进行解析,以
无线局域网适配器
开头,以以太网适配器
结尾
const wifiDetail = parseArr
.join('$$')
.match(/无线局域网适配器.*?以太网适配器/gi);
if (wifiDetail) {
defaultGateway =
/默认网关.*: (.*)/.exec(wifiDetail)[1].split('$$')[0] || config.host;
}
但是这种解析规则在 win11 下,就有问题了,淦!
于是我就改了一下获取默认网关的逻辑
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 被修改,最终结果就是:有线连入的电脑,并不能收集到路由器相关的数据。
解决方案:当在无线 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);
}
打印的结果为:
年轻人不讲武德?????怎么没有???我看了 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
下面给大家截几张图
8. 最后
这也是第一次做路由器收集相关工作,中间也是遇到了许多跟网络相关的问题,从文章的问题来看其实还是较为简单的,但第一次遇到的时候还是会有点头脑发蒙,不过总体来看,还是有所成长有所收获,并且做 PC 还是挺有趣的。
以上就是本次文章的所有内容了。
该文章撰写于 2021.12.02 月,过了一年半,才发出来,属实有点久...
最后,阿宽写了本跟 Electron 相关的小册《Electron + React 从 0 到 1 实现简历平台实战》,对 Electron 感兴趣的小伙伴可以看看。
转载自:https://juejin.cn/post/7201002219490328633