何谓自动化脚本,就是执行后可以自动做一些原本需要手动操作的事情的脚本。特别是那些琐碎的、重复性高的事情,能变成自动完成的,那真的太好了。 当然有些脚本并不那么容易写,需要在“写一次脚本”和“重复性工作”之间找到平衡。
作为前端,遇到写自动化脚本的场景,我首先想到的是Node.js的child_process模块。通过创建命令的子进程,侦听子进程的标准输出,根据输出自动去输入,就能实现自动化脚本。
概念性的描述可能有点抽象。我们假设一个需求:你要去“抢注”很多npm包,有10000个。如果手动来做,这必将相当枯燥且低效。我们看下如何用Node.js自动化脚本来做这件事。
const { execSync } = require('child_process');
const fs = require('fs');
/**
* 需要抢注的npm包的数组
*/
const packages = ['chun', 'xia', 'qiu', 'dong', 'qing', 'feng', 'ming', 'yue'];
/**
* 创建这些npm包
*/
packages.forEach((name) => {
fs.mkdirSync(name);
execSync(`cd ./${name} && npm init -y`);
});
上面的脚本是“非交互式”脚本,因为它不关心子进程的输出,也没有去改输入。执行 npm init -y默认的version字段是1.0.0,description是空白的。如果要把版本号定在0.1.0,初始描述是包的名称,那我们就需要写“交互式”脚本了。
const { spawn } = require('child_process');
const fs = require('fs');
const packages = ['chun', 'xia', 'qiu', 'dong', 'qing', 'feng', 'ming', 'yue'];
packages.forEach((name) => {
fs.mkdirSync(name);
//生成一个子进程
const npm = spawn('npm', ['init'], { cwd: `./${name}` });
//设置输出的编码
npm.stdout.setEncoding('utf8');
npm.stdout.on('data', (data) => {
//读取输出的版本文本,设置输入
if (data === 'version: (1.0.0) ') {
npm.stdin.write('0.1.0\n');
}
//读取输出的描述文本,设置输入
else if (data === 'description: ') {
npm.stdin.write(`${name}\n`);
} else {
npm.stdin.write('\n');
}
});
});
到目前为止我们只是在本地创建了这些包,还没有发布到npm源上去。要发布到npm,首先要登录。npm adduser命令可以用于登录。
const { spawn } = require('child_process');
const npm = spawn('npm', ['adduser']);
npm.stdout.setEncoding('utf8');
npm.stdout.on('data', (data) => {
if (data === 'Username: ') {
npm.stdin.write('你的用户名\n');
} else if (data === 'Password: ') {
npm.stdin.write(`你的密码\n`);
} else if (data === 'Email: (this IS public) ') {
npm.stdin.write(`你的邮箱\n`);
} else {
npm.stdin.write('\n');
}
});
npm.stderr.setEncoding('utf8');
npm.stderr.on('data', (data) => {
console.error(data);
});
child_process方式足够强大,能够满足不少场景。但这种方式要求所有的工作都必须能通过读取子进程的输出,和写入子进程的输入完成。然而通过child_process,有些命令的输出无法读取到,输入也是无法写入的。比如sudo -i。
const { spawn } = require('child_process');
const sudo = spawn('sudo', ['-i']);
sudo.stdout.setEncoding('utf8');
//无法获得输出,因为输入密码的提示并不在sudo这个子进程里
sudo.stdout.on('data', (data) => {
if (data === 'Password:') {
//更无法设置输入
sudo.stdin.write('你的密码\n');
} else {
sudo.stdin.write('\n');
}
});
遇到这种情况,我们就需要更强大的工具了。那就是下面要介绍的Expect。
Expect是一个用于自动执行ssh、sftp、sudo等交互式应用程序的工具。macOS上可以通过brew便捷安装Expect。
上面child_process无法实现的sudo -i,使用Expect实现如下:
spawn sudo -i;
expect "Password:";
send "你的密码\r";
interact;
假设脚本存储为sudo.exp,可以通过expect sudo.exp执行这段脚本。也可以在脚本的顶部指明该脚本需要使用expect程序。这样直接键入sudo.exp也可以执行。
#!/usr/local/bin/expect
spawn sudo -i;
expect "Password:";
send "你的密码\r";
interact;
expect可执行文件的地址并不都是/usr/local/bin/expect,如果你不确定,请使用where expect命令查看正确地址。
下面我们从Expect使用的Tcl语言说起。
Expect脚本使用Tcl语言书写。作为一门语言,有很多内容需要学习。我是通过看Expect作者写的Exploring Expect,了解了一点Tcl。下面列举我觉得重要的Tcl知识点。
Tcl里值的类型都是字符串。
# 以下命令都返回1
puts [expr 1=="1"];
puts [expr 3.1415926=="3.1415926"];
puts [expr true=="true"];
puts [expr false=="false"];
使用set命令定义变量并赋值,使用$符号取值。
set whoAmI "鱼";
puts "我是$whoAmI";
表达式都返回值。简单场景下,可以利用直觉书写表达式并使用它的值。
set age 16;
# 简单场景使用表达式$age==16
if {$age == 16} {
puts "年方二八"
}
对于复杂场景需要结合expr命令和[]。
set age 26;
#复杂场景使用表达式的值
puts "如果回到10年前,他[expr $age-10]岁";
Tcl里的命令和函数是类似的概念,Tcl内置了很多命令。前面已经提过puts、expr、set、if命令。
set animal "fish";
puts $animal;
所有命令执行后都有返回值,如果想使用命令的返回值,需要使用[]包裹一下。
set name [set animal "fish"];
puts $name;
可以使用proc命令自定义命令。
# x的y次方
proc pow {x y} {
set result 1;
for {set i 1} {$i<=$y} {incr i} {
set result [expr $result*$x];
}
return $result;
}
set result [pow 2 4];
# 输出1
puts [expr $result==16];
可以通过lindex命令取数组的元素。
# 执行命令时键入p1 p2 p3,输出p2
puts "传入的第2个参数是:[lindex $argv 1]";
$argv代表传入的参数数组,$argv0代表传入的第一个参数。
上面简单介绍了Tcl语言。下面我们聊下Expect的核心
使用spawn命令启动一个程序。启动程序后,意味着“输入”交给了这个程序,而不是用户。
spawn npm init;
interact;
此处expect是Expect的一个命令。它在等待着“输出”,输出来自用户的手动输入,或者前面提过的spawn启动的程序的自动输入。
等待的“输出”,是一个模式匹配字符串。每次匹配成功,匹配到的字符串会存储到$expect_out(0,string)变量里,匹配到的字符串和之前未匹配到的字符串存储在$expect_out(buffer)变量里。$expect_out变量不断变化的,每次匹配都是从上次匹配的结束位置开始的。
# 键入 i am robberfree
expect "r"
# 第1次输出 r - i am r
send "$expect_out(0,string) - $expect_out(buffer)\n"
expect "r"
# 第2次输出 r - obber
send "$expect_out(0,string) - $expect_out(buffer)\n"
expect "r"
# 第3次输出 r - fr
send "$expect_out(0,string) - $expect_out(buffer)\n"
expect eof;
如果expect命令期望的字符串没有匹配到,默认会等待10秒,然后跳过这个expect命令,执行后续的命令。可以通过set timeout改变等待的时间。你还可以set timeout -1,让expect无限等待下去。
set timeout 60;
send命令用于向程序输入文本。我们常需要输入回车,\r代表回车键。
spawn npm init;
expect "package name:";
send "a-package-created-by-expect\r";
interact;
成对出现的expect和send命令,它们之间没有因果关系,是两个独立的命令。可能因为expect会等待输入,会造成错觉。 如果想让expect和send有因果关系,请使用如下语法。
expect {
"hi" {
# 只有当匹配到hi时,send命令才会发出
send "hi\n";
}
"你好" {
# 可以建立多个分支,类似switch case
send "你也好啊\n";
}
}
# 和前面的expect没有因果关系。
send "自言自语";
interact将交互(主要是输入)的控制权移交给用户,对于spawn启动的程序很有用,因为有时并不需要脚本完全自动化。
有空闲时间的话准备写个js到Expect的转换器。