自动化脚本

何谓自动化脚本,就是执行后可以自动做一些原本需要手动操作的事情的脚本。特别是那些琐碎的、重复性高的事情,能变成自动完成的,那真的太好了。 当然有些脚本并不那么容易写,需要在“写一次脚本”和“重复性工作”之间找到平衡。

1. child_process

作为前端,遇到写自动化脚本的场景,我首先想到的是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。

2. 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语言说起。

2.1 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代表传入的第一个参数。

2.2 Expect

上面简单介绍了Tcl语言。下面我们聊下Expect的核心

spawn

使用spawn命令启动一个程序。启动程序后,意味着“输入”交给了这个程序,而不是用户。

spawn npm init; interact;

expect

此处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

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

interact将交互(主要是输入)的控制权移交给用户,对于spawn启动的程序很有用,因为有时并不需要脚本完全自动化。

2.3 Todo

有空闲时间的话准备写个js到Expect的转换器。