从厌烦不停的登录公司网络到使用Electron制作自己的客户端工具(二)

lxf2023-03-10 20:07:01

上期回顾

在上一篇文章中,我提出了每天登录外网的麻烦的困境,以及后面使用nodejs写了一个简单的定时程序来帮我监测外网连接状态,然后在断网时帮我自动打开一个浏览器填写用户名、密码、复选框帮我登录,这样就解放了的双手。后来也提出了问题,我想让其他同事也使用我的工具且又不需要安装nodejs的环境,于是基于我们都用的windows系统,想到写一个能在windows下运行的.exe执行文件,也即一个桌面端软件来来做之前脚本做的事。那么如何做这件事呢?我选择使用Electron来做这件事,原因是因为它允许我们使用js、html、css来构建桌面应用程序,语言上与前端完美结合,大名鼎鼎的vscode就是用electron开发的。那么就让我们来开始吧!

认识Electron

首先我们来认识一下什么是Electron,大家可以直接到中文官网去了解:简介 | Electron (electronjs.org)。

这里我简单总结一下,如果有不对的地方希望大家在评论中指出来。

Electron是一个嵌入了Chrominum和Nodejs的桌面软件开发框架,这是什么意思呢?我们前端开发平时运行的代码都是跑在浏览器里的,如果没有浏览器那么就歇菜了。而Electron就是内嵌了chrome浏览器,也就是即使使用者的电脑上没有浏览器,也能像打开网站一样的打开我们的页面,只不过是以一个软件窗口的形式,就像平时打开微信、QQ等软件一样。而内嵌Nodejs则能够让我们编写服务端语言,比如启动一个web服务、做文件系统存储等等,那么其实开发一款由前后端构成的简单网站的基本要求就都满足了。当然,正常情况来说,即便用electron开发应用,后端服务其实还是应该使用远程的,毕竟正常的应用还包括数据库、消息队列等其他服务。

软件设计

既然是要做一款桌面端软件了,那么开始做之前必然少不了设计,这是我们要养成的习惯,就是写代码应该只占用我们30%-40%的时间,而思考则占用了大部分,我们用少部分的时间写代码来实现我们的想法。

目标

设计首先要定好目标,即做这个软件的目的,解决什么问题以及应该有什么功能。列出以下:

  • 目的:编写一个桌面端软件,解决网络掉线时,需要打开网页输入信息重新登陆的问题。
  • 为什么要解决:一天内掉线多次,频繁打开登录,不方便。
  • 有什么功能:允许设置用户名、密码,可以启动功能和停止功能,最好还能有日志功能记录软件行为。

架构

结合上面的目标考虑和上篇文章中写的nodejs脚本,其实要实现功能并不难。所以进行简单的架构设计,结果如下:

从厌烦不停的登录公司网络到使用Electron制作自己的客户端工具(二)

架构解释:通过web界面配置用户名、密码,发送请求到web服务,启动定时器监测网络状态,如果网络正常,不做操作;如果网络断开,那么打开无头浏览器执行登录。对于启动成功、失败均返回给web端。web端可控制程序停止,发送请求到服务端,服务端停止定时器。中间行为记录进日志系统,服务端支持日志查询。

实施

在经过需求分析和架构设计后,其实就已经可以开始写代码了,这也是我们平时工作中应该按照的流程,至于在写代码时如何让代码设计更优雅,则取决于程序员的水平了。

项目仓库搭建

这一部分主要是按照Electron官网的教程执行的,大家可以自己去看创建您的第一个应用程序 | Electron (electronjs.org)。 按照步骤,最终我们可以得到一个包含main.js的文件夹,其中还应该有一个简单的index.htmlforge.config.jspackage.jsonpackage-lock.json,可执行运行和打包分发。

定时器类实现

这是我们的主要逻辑,所以我们优先实现这一部分。定时器的逻辑其实在上一篇中,我们已经完成了,但在此处,我决定将其作为一个类实现,并返回一个单例实例。因为我们只有一个客户端,所以使用单例为避免重复的定时器导致紊乱。具体代码如下:

// timer.js

const ping = require('ping');
const webDriver = require('selenium-webDriver');
const chrome = require('selenium-webDriver/chrome');
const logger = require('./logger')();

// 生成webDriver驱动实例
function genDriver() {
  let driver;
  var options = new chrome.Options();
  options.addArguments("--disable-popup-blocking");
  options.addArguments("no-sandbox");
  options.addArguments("disable-extensions");
  options.addArguments("no-default-browser-check");
  // 设置无头模式
  options.addArguments("headless");

  driver = new webDriver.Builder()
    .forBrowser('chrome')
    .setChromeOptions(options)
    .build();
  return driver;
}

class Timer {
  constructor() {
    this.username = '';
    this.password = '';
    this.time = null;
    this.timer = null;
    this.loginAction = false;  // 是否正在登录标识
    this.loginSucTimes = 0;  // 记录登录成功次数
  }
  start(username, password, time = 3000) {
    this.username = username;
    this.password = password;
    this.time = time;
    if (!username || !password) {
      throw new Error('用户名或密码不能为空');
    } else {
      this.refresh();
    }
  }

  refresh() {
    this.timer = setTimeout(() => {
      this.watchInternet();
    }, this.time);
  }

  stop() {
    clearTimeout(this.timer);
    this.timer = null;
    this.loginAction = false;
  }

  pause() {
    clearTimeout(this.timer);
  }

  getStatus() {
    return this.timer ? 1 : 0;
  }

  async isOnline() {
    const { alive } = await ping.promise.probe('www.baidu.com');
    return alive;
  }

  async watchInternet() {
    const online = await this.isOnline();
    if (this.timer !== null) {
      if (!online) {
        if (this.loginAction === false) {
          try {
            logger.info('网络已断开,正在尝试重新登录');
            await this.loginBySelenium();
          } catch (error) {
            logger.error(String(error));
          }
        } else {
          logger.info('登录尝试失败,请检查用户名和密码是否正确');
          this.loginAction = false;
        }
      }
      if (online && this.loginAction === true) {
        this.loginSucTimes += 1;
        logger.info(`登录成功,已成功登录${this.loginSucTimes}次`);
        this.loginAction = false;
      }
      this.refresh();
    }
  }

  async loginBySelenium() {
    const loginUrl = 'http://xxx.xx.1.254/ac_portal/20220831163936/pc.html';
    const driver = genDriver();
    // 打开登录页面
    await driver.get(loginUrl);
    // 输入用户名和密码
    await driver.findElement(webDriver.By.id('password_name')).sendKeys(this.username);
    await driver.findElement(webDriver.By.id('password_pwd')).sendKeys(this.password);
    // 找到id为password_disclaimer的复选框选中
    await driver.findElement(webDriver.By.id('password_disclaimer')).click();
    // 找到id为password_submitBtn的按钮点击
    await driver.findElement(webDriver.By.id('password_submitBtn')).click();
    // 将loginAction设置为true,表示发生过登录操作
    this.loginAction = true;

    await driver.close();
    await driver.quit();
  }
}

// 返回单例
module.exports = (function () {
  let instance;
  return function (username, password) {
    if (!instance) {
      instance = new Timer(username, password);
    }
    return instance;
  };
})();

代码中的逻辑并不难,相信大家都能看得懂。稍微要注意一点的是,我在类中加了个loginAction标识符,标识是否正在登录,之所以加整个标识符。是因为,当我们登录过一次后,没法立马判断是否就已经有网络了,因为ping 命令正常是发四个数据包才返回的,这个过程需要3-4秒时间,为了避免设置的定时间隔小于这个时间,导致在还没判断完是否登录完成时就进行了下一次监测,所以加此标志位来区别登录是否完成。

应该还注意到,我在timer.js中引入了一个logger.js,并且在代码中间挺多地方使用logger.info或logger.error来记录日志。这其实就是我实现的简单的日志管理类,用数据来记录日志,可进行简单的增/查操作,请看下一节。

日志记录实现

在timer.js同层级,我们新建looger.js,代码如下:

/**
 * 实现一个logger类,用于记录日志
 * 1. 有info、debug、error三种日志级别
 * 2. 可以写入日志、读取日志
 * 3. 日志消息格式为:{ type: 'info', msg: 'hello world', time: '2019-01-01 00:00:00' }
 *   或者 'info: hello world 2019-01-01 00:00:00'的字符串
 */
class Logger {
  constructor() {
    this.log = {
      info: [],
      debug: [],
      error: []
    }
  }
  // 输入日志内容和type,转换为日志消息格式
  convert(msg, type) {
    // 如果没有type,则默认为info
    if (!type) {
      type = 'info';
    }
    // 如果没有type 且 msg 符合'info: hello world 2023/2/8 00:00:00'的格式
    if (!type && msg.match(/^[a-z]+: .+ \d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/)) {
      const [type, msg, time] = msg.split(': ');
      return { type, msg, time };
    }
    return { type, msg, time: new Date().toLocaleString() };
  }

  info(msg) {
    this.log.info.push(this.convert(msg, 'info'));
  }
  debug(msg) {
    this.log.debug.push(this.convert(msg, 'debug'));
  }
  error(msg) {
    this.log.error.push(this.convert(msg, 'error'));
  }
  read(type) {
    return this.log[type];
  }
  readAll() {
    const allLog = [];
    for (let type in this.log) {
      this.log[type].forEach(log => {
        allLog.push(log);
      });
    }
    return allLog;
  }

  write(type, msg) {
    this.log[type].push(this.convert(msg, type));
  }
}

// 返回单例
module.exports = (function() {
  let instance;
  return function() {
    if (!instance) {
      instance = new Logger();
    }
    return instance;
  };
})();

web服务实现

在完成我们的服务端主要逻辑后,我们就可以来实现一个web服务,来增加接口处理。服务框架我采用老牌的express框架,大家对这个框架应该比我熟悉的多。让我们在同层级新建web.js,实现如下:

// web.js

const express = require('express');

const getTimer = require('./timer');
let loggerInstance = require('./logger')();


const app = express();

let timerInstance = null;

// 定义接口响应数据格式工厂函数
function resFactory(data = {}, code = 200, msg = 'success') {
    return {
        code,
        msg,
        data,
    };
}

app.get('/', (req, res) => {
  res.send('electron app is running');
});

// 获取当前定时器状态
app.get('/status', (req, res) => {
  const status = timerInstance ? timerInstance.getStatus() : 0;
  res.send(resFactory(status));
});

// 启动
app.get('/start', (req, res) => {
  const { username, password } = req.query;
  // 启动定时器
  timerInstance = getTimer();
  timerInstance.start(username, password);
  res.send(resFactory('start'));
  loggerInstance.info('监测开始');
});

// 停止
app.get('/stop', (req, res) => {
  timerInstance.stop();
  res.send(resFactory('stop'));
  loggerInstance.info('监测停止');
});

// 获取全部log日志
app.get('/log', (req, res) => {
  const loggers = loggerInstance.readAll();
  res.send(resFactory(loggers));
});

// 错误处理
app.use((err, req, res, next) => {
  res.status(500).send(err.message);
})


// 使用express启动一个web服务器,监听10256端口
let port = 10256;
const server = app.listen(port);

// 注册错误处理,当端口被占用时,会触发,然后端口号自增,直到找到可用端口
server.on('error', (err) => {
  if (err.code === 'EADDRINUSE') {
    loggerInstance.error(`端口${port}被占用,正在尝试使用端口${port + 1}`);
    // 一秒后重试
    setTimeout(() => {
      server.close();
      server.listen(++port);
    }, 1000);
  }
});

// 如果注册成功,打印日志
server.on('listening', () => {
  loggerInstance.info(`服务启动成功,监听${port}端口`);
});

web界面实现

到上面一步,我们之前架构中大部分就已经完成了,只剩下需要一个界面能与使用者进行操作,并调起我们的服务接口。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Auto-Intranet</title>
  <script src="./assets/axios.min.js"></script>
  <script src="./web_utils.js"></script>
  <link rel="stylesheet" href="./assets/common.css">
</head>
<body>
  <div class="title">Auto-Intranet</div>
  <div id="login-form" class="flex-center-col">
    <input type="text" id="username" placeholder="用户名">
    <input type="password" id="password" placeholder="密码">
  </div>

  <div id="actions" class="flex-center-col">
    <button id="btn-start" class="pointer">启动</button>
    <button id="btn-stop" class="pointer">停止</button>
  </div>
  <div>
    <button href="" id="log-a">日志</button>
  </div>
  <div id="log-content">
  </div>
</body>
<script>
  const AUTO_NET_PORT = 10256;
  const SUCCESS_CODE = 200;


  function getParams() {
    const username = document.getElementById('username').value;
    const password = document.getElementById('password').value;
    if (!username || !password) {
      return null;
    }
    return {
      username,
      password,
    };
  }

  function getStatus() {
    const url = `http://localhost:${AUTO_NET_PORT}/status`;
    request(url, 'GET').then(({ data }) => {
      if (data.data === 1) {
        status1();
      } else {
        status0();
      }
    });
  };

  function start() {
    const url = `http://localhost:${AUTO_NET_PORT}/start`;
    const data = getParams();
    if (!data) {
      alert('请输入用户名密码');
      return;
    } else {
      request(url, 'GET', data).then(({data}) => {
        if (data.code === SUCCESS_CODE) {
          status1();
        } else {
          alert('启动失败');
        }
      });
    }
  };

  function stop() {
    const url = `http://localhost:${AUTO_NET_PORT}/stop`;
    request(url, 'GET').then((res) => {
      if (res.data.code === SUCCESS_CODE) {
        status0();
      } else {
        alert('停止失败');
      }
    });
  };

  function openLog() {
    const url = `http://localhost:${AUTO_NET_PORT}/log`;
    request(url, 'GET').then((res) => {
      if (res.data.code === SUCCESS_CODE) {
        const logContent = document.getElementById('log-content');
        const logArr = res.data.data;
        let logStr = '';
        for (let i = 0; i < logArr.length; i++) {
          logStr += `<p>${JSON.stringify(logArr[i], null, 2)}</p>`;
        }
        logContent.innerHTML = logStr;
      } else {
        alert('获取日志失败');
      }
    });
  };

  const btnStart = document.getElementById('btn-start');
  btnStart.addEventListener('click', start);
  const btnStop = document.getElementById('btn-stop');
  btnStop.addEventListener('click', stop);
  const logA = document.getElementById('log-a');
  logA.addEventListener('click', openLog);

  function status1() {
    btnStart.style.display = 'none';
    btnStop.style.display = 'block';
  }

  function status0() {
    btnStart.style.display = 'block';
    btnStop.style.display = 'none';
  }

  // 一开始停止按钮不可见
  btnStop.style.display = 'none';

  // 页面加载或者刷新,调用一次getStatus
  getStatus();
  // 每隔5秒调用一次getStatus
  setInterval(getStatus, 5000);

  // 监听enter键,触发start
  document.onkeydown = function (e) {
    if (e.keyCode === 13) {
      start();
    }
  };
</script>
<style>
  body {
    text-align: center;
  }
</style>
</html>

界面这块,因为逻辑和操作很简单,所以我没有采用任何如vue、react的现代框架,只是用html + js + dom操作来实现。其中,接发请求我下载了axios的打包文件放到项目里使用,并做了一层封装如下封装:

// web_utils.js

const METHOD = {
  GET: 'get',
  POST: 'post',
};

// 封装axios请求
async function request(url, method, params, config) {
  switch (method.toLowerCase()) {
    case METHOD.GET:
      return axios.get(url, { params, ...config });
    case METHOD.POST:
      return axios.post(url, params, config);
    default:
      return axios.get(url, { params, ...config });
  }
}

css代码如下:

// common.css

.pointer {
  cursor: pointer;
}

.flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

.flex-center-col {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.title {
  /* 炫酷的标题 */
  font-size: 24px;
  font-weight: 700;
  color: #00bfff;
  margin: 20px;
}

#login-form {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 100%;
}

#login-form input {
  margin: 10px;
  padding: 10px;
  border: none;
  border-radius: 5px;
  outline: none;
  /* 边框颜色 */
  border: 1px solid #ccc;
}

#actions {
  width: 100%;
  align-items: center;
}

/* 启动按钮浅蓝色 */
#btn-start {
  background-color: #00bfff;
  width: 200px;
  color: #fff;
  border: none;
  border-radius: 5px;
  padding: 10px 20px;
  margin: 10px;
}
/* 停止按钮警告色 */
#btn-stop {
  background-color: #ff0000;
  width: 200px;
  color: #fff;
  border: none;
  border-radius: 5px;
  padding: 10px 20px;
  margin: 10px;
}

效果

将上面我们需要的所有代码准备好之后,我们整合进main.js。

这里大部分代码都是electron官网示例中给出的,区别点在于:

  1. 引入我们自己的web.js,启动后端服务
  2. 修改了窗口大小
  3. 最后定义了一个禁止使用ctrl + r刷新页面的事件监听
// main.js

const { app, BrowserWindow } = require('electron');
require('./electron/menu');
// 运行web.js
require('./server/web');

function createWindow() {
  const win = new BrowserWindow({
    width: 400,
    height: 500,
    webPreferences: {
      nodeIntegration: true,
    },
    // titleBarStyle: 'hidden',
  });

  win.loadFile('index.html');
}

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})


// 接管ctrl+R,禁止刷新
app.on('web-contents-created', (e, contents) => {
  contents.on('before-input-event', (e, input) => {
    if (input.control && input.key.toLowerCase() === 'r') {
      e.preventDefault();
    }
  });
})

结束开发,让我们看看运行效果:

  • 打开软件

从厌烦不停的登录公司网络到使用Electron制作自己的客户端工具(二)

  • 启动监听和执行记录

从厌烦不停的登录公司网络到使用Electron制作自己的客户端工具(二)

至此,这个很简单的桌面端工具就完成了。我们可以使用electron的打包工具,在我的电脑上打包分发出.exe执行文件。将下图的目录打成压缩包,分享给我的同事,让UI大人也可以使用,免除登录烦恼了哈哈哈~

从厌烦不停的登录公司网络到使用Electron制作自己的客户端工具(二)

从厌烦不停的登录公司网络到使用Electron制作自己的客户端工具(二)

结束

本项目我放到github上了,仓库里有些文件是我自己开发时加的,比如上一篇文章的nodejs脚本,还有一些如版本管理的工具standard-version等,这些大家看看就行了。

仓库地址:dyggod/auto-intranet (github.com)