上期回顾
在上一篇文章中,我提出了每天登录外网的麻烦的困境,以及后面使用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脚本,其实要实现功能并不难。所以进行简单的架构设计,结果如下:
架构解释:通过web界面配置用户名、密码,发送请求到web服务,启动定时器监测网络状态,如果网络正常,不做操作;如果网络断开,那么打开无头浏览器执行登录。对于启动成功、失败均返回给web端。web端可控制程序停止,发送请求到服务端,服务端停止定时器。中间行为记录进日志系统,服务端支持日志查询。
实施
在经过需求分析和架构设计后,其实就已经可以开始写代码了,这也是我们平时工作中应该按照的流程,至于在写代码时如何让代码设计更优雅,则取决于程序员的水平了。
项目仓库搭建
这一部分主要是按照Electron官网的教程执行的,大家可以自己去看创建您的第一个应用程序 | Electron (electronjs.org)。
按照步骤,最终我们可以得到一个包含main.js
的文件夹,其中还应该有一个简单的index.html
、forge.config.js
、package.json
、package-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官网示例中给出的,区别点在于:
- 引入我们自己的web.js,启动后端服务
- 修改了窗口大小
- 最后定义了一个禁止使用
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的打包工具,在我的电脑上打包分发出.exe
执行文件。将下图的目录打成压缩包,分享给我的同事,让UI大人也可以使用,免除登录烦恼了哈哈哈~
结束
本项目我放到github上了,仓库里有些文件是我自己开发时加的,比如上一篇文章的nodejs脚本,还有一些如版本管理的工具standard-version
等,这些大家看看就行了。
仓库地址:dyggod/auto-intranet (github.com)